diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..1dd196bb7 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E127,E128,E121,E123,E126,E203,E226,E24,E704,W503,W504 +exclude = .git,__pycache__,docs/source/conf.py,old,build,dist +max-line-length = 79 +builtins = _ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..7f59c2647 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +IMPORTANT: +If you are opening an issue related to the connection to your machine, please provide the debug dump! +See here how to save it: https://rayforge.org/docs/troubleshooting/debug + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Versions:** +You can collect the version info from the Rayforge about dialog. + +**Extra info:** +Anything else? diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..36014cde5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..fc9f19670 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + + - package-ecosystem: "npm" + directory: "/website" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" diff --git a/.github/workflows/build-exe.yml b/.github/workflows/build-exe.yml new file mode 100644 index 000000000..301299177 --- /dev/null +++ b/.github/workflows/build-exe.yml @@ -0,0 +1,217 @@ +name: Build Windows Executable + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - "**" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build: + runs-on: windows-2025 + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set Version + id: set-version + shell: bash + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + VERSION=${{ github.ref_name }} + elif git describe --tags >/dev/null 2>&1; then + VERSION=$(git describe --tags) + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + if [ -z "$VERSION" ]; then + echo "Error: No git version number found!" + exit 1 + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Set up MSYS2 Environment Shell + id: msys2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + #update: true + # Minimal packages required to run the setup script itself + install: base-devel git unzip wget zip + + - name: Configure Environment Paths + shell: msys2 {0} + run: | + # Use cygpath to reliably convert the runner's Windows path to an MSYS2 path. + # This is the standard and most robust method. + MSYS2_PATH_VAR=$(cygpath -u "${{ steps.msys2.outputs.msys2-location }}") + + echo "MSYS2_PATH=$MSYS2_PATH_VAR" >> $GITHUB_ENV + + # Add necessary binaries to the system PATH for subsequent steps + echo "$MSYS2_PATH_VAR/mingw64/bin" >> $GITHUB_PATH + echo "$MSYS2_PATH_VAR/usr/bin" >> $GITHUB_PATH + + - name: Install Dependencies (win_setup.sh) + shell: msys2 {0} + run: | + # The MSYS2_PATH is required by the setup script to generate the .msys2_env file. + export MSYS2_PATH=${{ env.MSYS2_PATH }} + bash scripts/win/win_setup.sh + + - name: Verify GI Typelib Files + shell: msys2 {0} + if: false + run: | + source $GITHUB_WORKSPACE/.msys2_env + echo "Listing GI typelib files:" + ls -l $GI_TYPELIB_PATH/*.typelib + + - name: List MSYS2 packages + shell: msys2 {0} + if: false + run: | + source $GITHUB_WORKSPACE/.msys2_env + pacman --version + pacman -Q + + - name: Check Cairo DLL Dependencies + shell: msys2 {0} + if: false + run: | + source $GITHUB_WORKSPACE/.msys2_env + $MSYS2_PATH/mingw64/bin/ntldd -R $MSYS2_PATH/mingw64/bin/libcairo-2.dll + $MSYS2_PATH/mingw64/bin/ntldd -R $MSYS2_PATH/mingw64/bin/libcairo-gobject-2.dll + $MSYS2_PATH/mingw64/bin/objdump -p $MSYS2_PATH/mingw64/bin/libcairo-2.dll | grep "DLL Name" + $MSYS2_PATH/mingw64/bin/objdump -p $MSYS2_PATH/mingw64/bin/libcairo-gobject-2.dll | grep "DLL Name" + + - name: Run Test Suite (win_test.sh) + shell: msys2 {0} + run: | + # Enable debug logging for Python + export PYTHONUNBUFFERED=1 + export PYTHONFAULTHANDLER=1 + # Enable RUST backtrace for vtracer + export RUST_BACKTRACE=1 + # The test script will source .msys2_env internally. + bash scripts/win/win_test.sh + + - name: Run Build Process (win_build.sh) + shell: msys2 {0} + run: | + # The build script will source .msys2_env internally. + bash scripts/win/win_build.sh "${{ env.VERSION }}" + + - name: Compress PyInstaller Bundle + shell: msys2 {0} + run: | + BUNDLE_DIR="dist/rayforge-v${{ env.VERSION }}" + ZIP_FILE="dist/rayforge-v${{ env.VERSION }}-windows-bundle.zip" + zip -r9 "${ZIP_FILE}" "${BUNDLE_DIR}" + + - name: Upload PyInstaller bundle + uses: actions/upload-artifact@v7 + with: + name: rayforge-v${{ steps.set-version.outputs.version }}-windows-bundle + path: dist/rayforge-v${{ steps.set-version.outputs.version }}-windows-bundle.zip + + - name: Upload Installer Artifact + uses: actions/upload-artifact@v7 + with: + name: rayforge-v${{ steps.set-version.outputs.version }}-installer.zip + path: dist/rayforge-v${{ steps.set-version.outputs.version }}-installer.exe + compression-level: 9 + + test-exe: + name: Test Executable + needs: build + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Download Artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-v${{ needs.build.outputs.version }}-windows-bundle + + - name: Extract Bundle + shell: bash + run: | + unzip rayforge-v${{ needs.build.outputs.version }}-windows-bundle.zip + + - name: Test Executable (CLI) + shell: bash + run: | + echo "Listing files in current directory:" + ls -l + + # Define paths based on the extracted directory + BUNDLE_DIR="dist/rayforge-v${{ needs.build.outputs.version }}" + EXECUTABLE_NAME="rayforge-v${{ needs.build.outputs.version }}.exe" + + echo "Listing files in extracted directory:" + ls -lR "${BUNDLE_DIR}" + + echo "Running executable directly:" + ./"${BUNDLE_DIR}/${EXECUTABLE_NAME}" --help + + - name: Test Executable (UI Smoke Test) + shell: bash + run: | + bash scripts/win/win_run_ui_test.sh \ + "dist/rayforge-v${{ needs.build.outputs.version }}" \ + "rayforge-v${{ needs.build.outputs.version }}.exe" + + release: + name: Create GitHub Release + needs: [build, test-exe] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && github.repository == 'barebaric/rayforge' + outputs: + is_prerelease: ${{ steps.release_info.outputs.is_prerelease }} + steps: + - name: Determine release type + id: release_info + shell: bash + run: | + TAG="${{ github.ref_name }}" + if [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download Installer Artifact + uses: actions/download-artifact@v8 + with: + # For the release, we use the installer artifact + name: rayforge-v${{ needs.build.outputs.version }}-installer.zip + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + # The file to attach to the release is the installer + files: rayforge-v${{ needs.build.outputs.version }}-installer.exe + draft: false + prerelease: ${{ steps.release_info.outputs.is_prerelease }} + name: Release ${{ needs.build.outputs.version }} + tag_name: ${{ github.ref_name }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-macos-universal.yml b/.github/workflows/build-macos-universal.yml new file mode 100644 index 000000000..b9228b06a --- /dev/null +++ b/.github/workflows/build-macos-universal.yml @@ -0,0 +1,363 @@ +name: Build macOS Universal + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + version: + name: Resolve Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set Version + id: set-version + shell: bash + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + VERSION=${{ github.ref_name }} + else + SHORT_SHA=$(git rev-parse --short HEAD) + if git describe --tags --abbrev=0 >/dev/null 2>&1; then + BASE_VERSION=$(git describe --tags --abbrev=0) + else + BASE_VERSION="v0.0.0" + fi + VERSION="${BASE_VERSION}-${SHORT_SHA}" + fi + + if [ -z "$VERSION" ]; then + echo "Error: No git version number found" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION" + + build-intel: + name: Build macOS Intel Bundle + needs: version + runs-on: macos-26-intel + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: requirements.txt + + - name: Install macOS dependencies + run: bash scripts/mac/mac_setup.sh --install + + - name: Build macOS artifacts + run: | + printf "5\n" | bash scripts/mac/mac_build.sh --version "${VERSION}" + + - name: Smoke test Intel app + run: | + APP_BIN="dist/Rayforge.app/Contents/MacOS/Rayforge" + if [ ! -x "$APP_BIN" ]; then + echo "Rayforge executable not found at $APP_BIN" + exit 1 + fi + "$APP_BIN" --version + "$APP_BIN" --exit + + - name: Package Intel app + run: | + APP_PATH="dist/Rayforge.app" + if [ ! -d "$APP_PATH" ]; then + echo "App bundle not found at $APP_PATH" + exit 1 + fi + ZIP_NAME="rayforge-${VERSION}-macos-intel-app.zip" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_NAME" + + - name: Upload Intel app artifact + uses: actions/upload-artifact@v7 + with: + name: rayforge-${{ env.VERSION }}-macos-intel-app.zip + path: rayforge-${{ env.VERSION }}-macos-intel-app.zip + compression-level: 9 + + build-arm: + name: Build macOS Apple Silicon Bundle + needs: version + runs-on: macos-26 + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: requirements.txt + + - name: Install macOS dependencies + run: bash scripts/mac/mac_setup.sh --install + + - name: Build macOS artifacts + run: | + printf "5\n" | bash scripts/mac/mac_build.sh --version "${VERSION}" + + - name: Smoke test ARM app + run: | + APP_BIN="dist/Rayforge.app/Contents/MacOS/Rayforge" + if [ ! -x "$APP_BIN" ]; then + echo "Rayforge executable not found at $APP_BIN" + exit 1 + fi + "$APP_BIN" --version + "$APP_BIN" --exit + + - name: Package ARM app + run: | + APP_PATH="dist/Rayforge.app" + if [ ! -d "$APP_PATH" ]; then + echo "App bundle not found at $APP_PATH" + exit 1 + fi + ZIP_NAME="rayforge-${VERSION}-macos-arm-app.zip" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_NAME" + + - name: Upload ARM app artifact + uses: actions/upload-artifact@v7 + with: + name: rayforge-${{ env.VERSION }}-macos-arm-app.zip + path: rayforge-${{ env.VERSION }}-macos-arm-app.zip + compression-level: 9 + + merge-universal: + name: Merge Universal App and DMG + needs: [version, build-intel, build-arm] + runs-on: macos-26 + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - name: Download Intel app artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-${{ env.VERSION }}-macos-intel-app.zip + path: artifacts/intel + + - name: Download ARM app artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-${{ env.VERSION }}-macos-arm-app.zip + path: artifacts/arm + + - name: Create universal app + shell: bash + run: | + mkdir -p build-universal + ditto -x -k "artifacts/intel/rayforge-${VERSION}-macos-intel-app.zip" \ + artifacts/intel/unpacked + ditto -x -k "artifacts/arm/rayforge-${VERSION}-macos-arm-app.zip" \ + artifacts/arm/unpacked + + INTEL_APP="artifacts/intel/unpacked/Rayforge.app" + ARM_APP="artifacts/arm/unpacked/Rayforge.app" + UNI_APP="build-universal/Rayforge.app" + + if [ ! -d "$INTEL_APP" ] || [ ! -d "$ARM_APP" ]; then + echo "Intel or ARM app bundle not found after extraction" + exit 1 + fi + + cp -R "$ARM_APP" "$UNI_APP" + rsync -a --ignore-existing "$INTEL_APP/" "$UNI_APP/" + + find "$ARM_APP" -type f | while read -r arm_file; do + rel="${arm_file#"$ARM_APP"/}" + intel_file="$INTEL_APP/$rel" + uni_file="$UNI_APP/$rel" + + if [ ! -f "$intel_file" ]; then + continue + fi + + arm_type=$(file -b "$arm_file") + intel_type=$(file -b "$intel_file") + if [[ "$arm_type" != *"Mach-O"* ]] || \ + [[ "$intel_type" != *"Mach-O"* ]]; then + continue + fi + + lipo -create -output "$uni_file" "$arm_file" "$intel_file" + if [ -x "$arm_file" ]; then + chmod +x "$uni_file" + fi + done + + - name: Set architecture priority + shell: bash + run: | + PLIST="build-universal/Rayforge.app/Contents/Info.plist" + if [ -f "$PLIST" ]; then + /usr/libexec/PlistBuddy -c "Delete :LSArchitecturePriority" \ + "$PLIST" >/dev/null 2>&1 || true + /usr/libexec/PlistBuddy -c "Add :LSArchitecturePriority array" \ + "$PLIST" + /usr/libexec/PlistBuddy \ + -c "Add :LSArchitecturePriority:0 string arm64" "$PLIST" + /usr/libexec/PlistBuddy \ + -c "Add :LSArchitecturePriority:1 string x86_64" "$PLIST" + fi + + - name: Re-sign universal app + shell: bash + run: | + APP_PATH="$(pwd)/build-universal/Rayforge.app" + if [ ! -d "$APP_PATH" ]; then + echo "Universal app bundle not found at $APP_PATH" + exit 1 + fi + chmod -R u+w "$APP_PATH" + rm -rf "$APP_PATH/Contents/_CodeSignature" + + if ! codesign --force --deep --sign - "$APP_PATH"; then + echo "Initial deep re-sign failed, retrying..." + sleep 1 + codesign --force --deep --sign - "$APP_PATH" + fi + if ! codesign --verify --deep --strict --verbose=2 "$APP_PATH"; then + echo "Warning: codesign verification failed for $APP_PATH" + fi + spctl --assess --type execute --verbose=2 "$APP_PATH" || true + "$APP_PATH/Contents/MacOS/Rayforge" --version + "$APP_PATH/Contents/MacOS/Rayforge" --exit + + - name: Package universal app + shell: bash + run: | + APP_ZIP="rayforge-${VERSION}-macos-universal-app.zip" + ditto -c -k --keepParent "build-universal/Rayforge.app" "$APP_ZIP" + + TMP_DIR=$(mktemp -d) + ditto -x -k "$APP_ZIP" "$TMP_DIR" + "$TMP_DIR/Rayforge.app/Contents/MacOS/Rayforge" --version + "$TMP_DIR/Rayforge.app/Contents/MacOS/Rayforge" --exit + rm -rf "$TMP_DIR" + + - name: Build universal DMG with retry + shell: bash + run: | + mkdir -p build-universal/dmg + cp -R "build-universal/Rayforge.app" "build-universal/dmg/" + ln -sfn /Applications "build-universal/dmg/Applications" + + DMG_NAME="rayforge-${VERSION}-macos-universal.dmg" + tries=0 + max_tries=10 + until hdiutil create -volname "Rayforge" \ + -srcfolder "build-universal/dmg" -ov -format ULMO "$DMG_NAME" + do + tries=$((tries + 1)) + if [ "$tries" -ge "$max_tries" ]; then + echo "Error: hdiutil failed after ${max_tries} attempts" + exit 1 + fi + sleep 2 + done + + - name: Upload universal app artifact + uses: actions/upload-artifact@v7 + with: + name: rayforge-${{ env.VERSION }}-macos-universal-app.zip + path: rayforge-${{ env.VERSION }}-macos-universal-app.zip + compression-level: 9 + + - name: Upload universal DMG artifact + uses: actions/upload-artifact@v7 + with: + name: rayforge-${{ env.VERSION }}-macos-universal-dmg + path: rayforge-${{ env.VERSION }}-macos-universal.dmg + + release: + name: Create GitHub Release (Universal) + needs: [version, merge-universal] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && github.repository == 'barebaric/rayforge' + permissions: + contents: write + outputs: + is_prerelease: ${{ steps.release_info.outputs.is_prerelease }} + steps: + - name: Determine release type + id: release_info + shell: bash + run: | + TAG="${{ github.ref_name }}" + if [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download Intel app artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-${{ needs.version.outputs.version }}-macos-intel-app.zip + + - name: Download ARM app artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-${{ needs.version.outputs.version }}-macos-arm-app.zip + + - name: Download universal app artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-${{ needs.version.outputs.version }}-macos-universal-app.zip + + - name: Download universal DMG artifact + uses: actions/download-artifact@v8 + with: + name: rayforge-${{ needs.version.outputs.version }}-macos-universal-dmg + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + files: | + rayforge-${{ needs.version.outputs.version }}-macos-intel-app.zip + rayforge-${{ needs.version.outputs.version }}-macos-arm-app.zip + rayforge-${{ needs.version.outputs.version }}-macos-universal-app.zip + rayforge-${{ needs.version.outputs.version }}-macos-universal.dmg + draft: false + prerelease: ${{ steps.release_info.outputs.is_prerelease }} + name: Release ${{ needs.version.outputs.version }} + tag_name: ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/download-stats.yml b/.github/workflows/download-stats.yml new file mode 100644 index 000000000..bddca0990 --- /dev/null +++ b/.github/workflows/download-stats.yml @@ -0,0 +1,28 @@ +name: Update Download Stats + +on: + schedule: + - cron: "0 6 * * *" # Daily at 6 AM UTC + workflow_dispatch: + +jobs: + update-stats: + if: github.repository == 'barebaric/rayforge' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install snapcraft + run: sudo snap install snapcraft --classic + + - name: Fetch and push stats to VictoriaMetrics + env: + SNAPCRAFT_STORE_CREDENTIALS: /tmp/snapcraft-creds.txt + METRICS_URL: ${{ secrets.METRICS_URL }} + METRICS_API_USER: ${{ secrets.METRICS_API_USER }} + METRICS_API_PASSWORD: ${{ secrets.METRICS_API_PASSWORD }} + run: | + echo "${{ secrets.STORE_LOGIN }}" > /tmp/snapcraft-creds.txt + python3 scripts/fetch_download_stats.py --output metrics + rm -f /tmp/snapcraft-creds.txt diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml deleted file mode 100644 index e7adc0ebf..000000000 --- a/.github/workflows/flake8.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: flake8 Lint - -on: [push, pull_request] - -jobs: - flake8-lint: - runs-on: ubuntu-latest - name: Lint - steps: - - name: Check out source repository - uses: actions/checkout@v3 - - - name: Set up Python environment - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - - name: flake8 Lint - uses: py-actions/flake8@v2 - with: - ignore: E127,E128,E121,E123,E126,E226,E24,E704,W503,W504 - path: rayforge diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml new file mode 100644 index 000000000..570c51d65 --- /dev/null +++ b/.github/workflows/lint-test.yml @@ -0,0 +1,111 @@ +name: Lint and Test +on: + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - "**" + workflow_call: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: v0.68.1 + frozen: true + cache: true + cache-key: pixi-${{ runner.os }}-${{ hashFiles('pixi.lock') }} + + - name: Compile Translations + run: pixi run --frozen compile-translations + + - name: Run flake8 Lint + run: pixi run --frozen lint + + test-backend: + name: Backend Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: v0.68.1 + frozen: true + cache: true + cache-key: pixi-${{ runner.os }}-${{ hashFiles('pixi.lock') }} + + - name: Compile Translations + run: pixi run --frozen compile-translations + + - name: Run backend tests + run: pixi run --frozen test -m "not ui and not stress" --log-cli-level=DEBUG + + test-stress: + name: Stress Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: v0.68.1 + frozen: true + cache: true + cache-key: pixi-${{ runner.os }}-${{ hashFiles('pixi.lock') }} + + - name: Compile Translations + run: pixi run --frozen compile-translations + + - name: Run stress tests + run: pixi run --frozen test -m stress --log-cli-level=DEBUG + + test-ui: + name: UI Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: v0.68.1 + frozen: true + cache: true + cache-key: pixi-${{ runner.os }}-${{ hashFiles('pixi.lock') }} + + - name: Install Xvfb + run: sudo apt-get update && sudo apt-get install -y xvfb + + - name: Compile Translations + run: pixi run --frozen compile-translations + + - name: Run UI tests + run: xvfb-run pixi run --frozen uitest --log-cli-level=DEBUG diff --git a/.github/workflows/pixi-dependabot.yml b/.github/workflows/pixi-dependabot.yml new file mode 100644 index 000000000..adb4ad2c9 --- /dev/null +++ b/.github/workflows/pixi-dependabot.yml @@ -0,0 +1,54 @@ +name: Update Pixi Dependencies +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: write + pull-requests: write + +jobs: + pixi-upgrade: + name: Update Pixi Dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: v0.68.1 + 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 + pixi clean cache -y + pixi upgrade --pinning-strategy exact-version + git restore requirements.txt + python3 scripts/sync_requirements.py + pixi clean cache -y + pixi update + + - name: Create pull request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + commit-message: "deps: update pixi dependencies" + title: "deps: update pixi dependencies" + body-path: diff.md + branch: pixi-dependabot + base: main + delete-branch: true + add-paths: | + pixi.toml + pixi.lock + requirements.txt + debian/requirements-bundle.txt diff --git a/.github/workflows/publish-deb.yml b/.github/workflows/publish-deb.yml new file mode 100644 index 000000000..76bb631c8 --- /dev/null +++ b/.github/workflows/publish-deb.yml @@ -0,0 +1,181 @@ +name: Publish .deb to PPA + +on: + push: + branches: + - "main" + tags: + - "*" + pull_request: + branches: + - "*" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint-test: + if: github.repository == 'barebaric/rayforge' + uses: ./.github/workflows/lint-test.yml + + publish-to-ppa: + name: Build and Publish to PPA for ${{ matrix.distro.name }} + needs: [lint-test] + outputs: + deb_filename: ${{ steps.build_test_packages.outputs.deb_filename }} + # Define a build matrix for different Ubuntu versions + strategy: + matrix: + distro: + - { name: "Ubuntu 24.04", codename: "noble", runner: "ubuntu-24.04" } + runs-on: ${{ matrix.distro.runner }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install build dependencies + run: > + sudo apt-get update && sudo apt-get install -y + build-essential + cargo + cython3 + devscripts + debhelper + dh-python + dput + equivs + jq + python3-all + python3-maturin + python3-numpy + pybuild-plugin-pyproject + python3-pip + python3-poetry-core + python3-pyclipper + cython3 + pkg-config + rustc + + - name: Import GPG key + # Run on tags OR main branch push + if: github.repository == 'barebaric/rayforge' && github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main') + env: + GPG_PASSPHRASE: ${{ secrets.PPA_GPG_PASSPHRASE }} + GPG_PRIVATE_KEY: ${{ secrets.PPA_GPG_PRIVATE_KEY }} + run: | + echo "$GPG_PASSPHRASE" | gpg --batch --yes --pinentry-mode=loopback --passphrase-fd 0 --import <(echo "$GPG_PRIVATE_KEY" | base64 --decode) + KEY_FINGERPRINT=$(gpg --list-secret-keys --with-colons | grep '^fpr' | head -n 1 | awk -F: '{print $10}') + if [ -z "$KEY_FINGERPRINT" ]; then + echo "::error::Could not extract GPG key fingerprint." + exit 1 + fi + echo "Found key fingerprint: $KEY_FINGERPRINT" + echo "KEY_ID=$KEY_FINGERPRINT" >> $GITHUB_ENV + + - name: Build Packages for Testing + id: build_test_packages + env: + TARGET_DISTRIBUTION: ${{ matrix.distro.codename }} + run: | + chmod +x ./scripts/build-deb.sh + ./scripts/build-deb.sh --source + + DEB_FILE_PATH=$(find dist -name "*.deb" -type f | head -n 1) + if [ -z "$DEB_FILE_PATH" ]; then + echo "::error::Build failed, no .deb file found in dist/ directory." + exit 1 + fi + DEB_FILENAME=$(basename "$DEB_FILE_PATH") + + echo "deb_file_path=$DEB_FILE_PATH" >> $GITHUB_OUTPUT + echo "deb_filename=$DEB_FILENAME" >> $GITHUB_OUTPUT + + - name: Install runtime dependencies for testing + run: | + sudo mk-build-deps --install --tool="apt-get -y" debian/control + + - name: Test package installation + run: | + sudo apt-get install -y ./${{ steps.build_test_packages.outputs.deb_file_path }} + + - name: Run smoke test + run: | + # Verify that the package is listed as installed + dpkg -l rayforge + # Verify the main executable is in the PATH and can be run + # This simple command proves the installation was successful. + rayforge --version + # Try running the app and immediately exiting again. + sudo apt-get install -y xvfb + xvfb-run rayforge --exit + + - name: Sign source package + id: sign_source_package + # Trigger on tags OR main branch push + if: github.repository == 'barebaric/rayforge' && github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main') + env: + GPG_PASSPHRASE: ${{ secrets.PPA_GPG_PASSPHRASE }} + KEY_ID: ${{ env.KEY_ID }} + run: | + # Retrieve the _source.changes generated by build-deb.sh + CHANGES_FILE=$(find dist -name "*_source.changes" -type f | head -n 1) + if [ -z "$CHANGES_FILE" ]; then + echo "::error::Source changes file not found in dist/." + exit 1 + fi + echo "Signing $CHANGES_FILE..." + echo "$GPG_PASSPHRASE" > /tmp/passphrase.txt + debsign -p"gpg --batch --yes --pinentry-mode loopback --passphrase-file /tmp/passphrase.txt" -k"$KEY_ID" "$CHANGES_FILE" + rm /tmp/passphrase.txt + echo "changes_file_path=$CHANGES_FILE" >> $GITHUB_OUTPUT + + - name: Upload Binary Artifact for Release + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.build_test_packages.outputs.deb_filename }} + path: ${{ steps.build_test_packages.outputs.deb_file_path }} + + - name: Upload to PPA + # Trigger on tags OR main branch push + if: github.repository == 'barebaric/rayforge' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') + run: | + CHANGES_FILE=${{ steps.sign_source_package.outputs.changes_file_path }} + echo "Uploading $CHANGES_FILE to PPA..." + dput ppa:knipknap/rayforge "$CHANGES_FILE" + + release: + name: Attach .deb to GitHub Release + needs: [publish-to-ppa] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && github.repository == 'barebaric/rayforge' + permissions: + contents: write + steps: + - name: Determine release type + id: release_info + shell: bash + run: | + TAG="${{ github.ref_name }}" + if [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download .deb artifact + uses: actions/download-artifact@v8 + with: + name: ${{ needs.publish-to-ppa.outputs.deb_filename }} + + - name: Attach .deb to GitHub Release + uses: softprops/action-gh-release@v3 + with: + files: ${{ needs.publish-to-ppa.outputs.deb_filename }} + draft: false + prerelease: ${{ steps.release_info.outputs.is_prerelease }} + tag_name: ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 6359bcd49..70aea9c08 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -1,42 +1,49 @@ -name: Publish to PyPi -on: push +name: Build and Publish Wheel + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - "**" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: - build-and-publish: - name: Build and publish Rayforge to PyPI + lint-test: + uses: ./.github/workflows/lint-test.yml + + build-publish-wheel: + name: Build and Publish Wheel + needs: [lint-test] # Run only if tests pass runs-on: ubuntu-latest environment: pypi permissions: id-token: write - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - - name: Install build environment - run: | - sudo apt install libcairo2-dev libgirepository1.0-dev - python3 -m pip install build --user - - - name: Install requirements - run: pip install --no-cache-dir -r requirements.txt - - - name: Run pytest - run: | - pip install pytest pytest-cov - python -m pytest -vv - - - name: Build a wheel - run: python3 -m build - - - name: Publish package distributions to PyPI - if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: latest + frozen: true + + - name: Build wheel with Pixi + run: | + pixi run --frozen wheel + + - name: Publish package distributions to PyPI + if: startsWith(github.ref, 'refs/tags/') && github.repository == 'barebaric/rayforge' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/.github/workflows/publish-to-snap-store.yml b/.github/workflows/publish-to-snap-store.yml index cf6343ff6..e86ad101d 100644 --- a/.github/workflows/publish-to-snap-store.yml +++ b/.github/workflows/publish-to-snap-store.yml @@ -1,23 +1,101 @@ -name: Publish to Snapcraft.io -on: push +name: Build and Publish Snap + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - "**" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: - build-and-publish: - name: Build and publish Rayforge to Snapcraft.io - runs-on: ubuntu-latest + lint-test: + if: github.repository == 'barebaric/rayforge' + uses: ./.github/workflows/lint-test.yml + build-publish-snap: + name: Build and Publish Snap + needs: [lint-test] # Run only if tests pass + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - uses: snapcore/action-build@v1 - id: build - - - uses: snapcore/action-publish@v1 - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.STORE_LOGIN }} - with: - snap: ${{ steps.build.outputs.snap }} - release: edge + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: latest + frozen: true + environments: build + + - name: Compile Translations + run: pixi run --frozen -e build compile-translations + + - name: Build snap + uses: snapcore/action-build@v1 + id: build + env: + SNAPCRAFT_DEBUG: "1" + continue-on-error: true + + - name: Capture snapcraft logs on failure + if: steps.build.outcome == 'failure' + run: | + echo "::group::Snapcraft log files" + for log in /home/runner/.local/state/snapcraft/log/*.log; do + if [ -f "$log" ]; then + echo "=== $log ===" + cat "$log" + fi + done + echo "::endgroup::" + echo "::group::LXD status" + sudo lxc list --project snapcraft 2>&1 || true + sudo lxc storage list 2>&1 || true + echo "::endgroup::" + echo "::group::System resources" + free -h + df -h + echo "::endgroup::" + + - name: Fail if build failed + if: steps.build.outcome == 'failure' + run: exit 1 + + - name: Test built snap + run: | + sudo snap install ${{ steps.build.outputs.snap }} --dangerous + rayforge --help + + - name: Determine release channel + id: release_info + if: | + github.repository == 'barebaric/rayforge' && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) + shell: bash + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "channel=edge" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.ref_name }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "channel=beta,edge" >> "$GITHUB_OUTPUT" + else + echo "channel=stable,edge" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to Snapcraft + if: steps.release_info.outputs.channel != '' + uses: snapcore/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.STORE_LOGIN }} + with: + snap: ${{ steps.build.outputs.snap }} + release: ${{ steps.release_info.outputs.channel }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..cea3ef215 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,22 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v11 + with: + days-before-stale: 30 + days-before-close: 7 + stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove the stale label or comment, otherwise it will be closed in 7 days.' + stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove the stale label or comment, otherwise it will be closed in 7 days.' + close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.' + close-pr-message: 'This PR was closed because it has been stalled for 7 days with no activity.' + exempt-draft-pr: false diff --git a/.github/workflows/unstale.yml b/.github/workflows/unstale.yml new file mode 100644 index 000000000..f5518cc3a --- /dev/null +++ b/.github/workflows/unstale.yml @@ -0,0 +1,17 @@ +name: Unmark issues and pull requests as stale on activity +on: + issue_comment: + types: [created] + +jobs: + remove-stale-label: + if: github.repository == 'barebaric/rayforge' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Remove stale label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh issue edit ${{ github.event.issue.number }} --remove-label "stale" -R ${{ github.repository }} diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 000000000..fc63d6e86 --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,99 @@ +name: Build and deploy website + +on: + push: + # Trigger on new version tags (supports both 1.2 and 1.2.0 formats) + tags: + - "*.*.*" + - "*.*" + # Trigger on pushes to the main branch to update existing docs + branches: + - main + pull_request: + # Trigger on pull requests targeting the main branch + branches: + - main + # Allow manual runs from the Actions tab + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 # Fetch all history for versioning + + - name: Set up Pixi + uses: prefix-dev/setup-pixi@v0.10.1 + with: + pixi-version: v0.68.1 + frozen: true + cache: true + + - name: Configure Git + run: | + git config user.name github-actions + git config user.email github-actions@github.com + + - name: Install website dependencies + run: pixi run --frozen -e website site-install + + - name: Build documentation for PR Preview + if: github.event_name == 'pull_request' + run: pixi run --frozen -e website site-build + + - name: Upload artifact for pull request review + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: docs-preview + path: website/build/ + retention-days: 7 + + - name: Determine Version + id: get_version + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + VERSION_NAME="${{ github.ref_name }}" + echo "Triggered by tag push. Deploying new version: $VERSION_NAME" + else + echo "Triggered by branch/manual push. Finding latest tag to update..." + VERSION_NAME=$(git describe --tags --abbrev=0) + if [[ -z "$VERSION_NAME" ]]; then + echo "::error::No tags found in history. Cannot determine version to update." + exit 1 + fi + echo "Found latest version to update: $VERSION_NAME" + fi + echo "version=$VERSION_NAME" >> $GITHUB_OUTPUT + if [[ "$VERSION_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Configure SSH + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.repository == 'barebaric/rayforge' + uses: webfactory/ssh-agent@v0.10.0 + with: + ssh-private-key: ${{ secrets.WEBSITE_DEPLOY_KEY }} + + - name: Add github.com to known_hosts + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.repository == 'barebaric/rayforge' + run: ssh-keyscan github.com >> ~/.ssh/known_hosts + + - name: Deploy Website + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.repository == 'barebaric/rayforge' + env: + DEPLOY_VERSION: ${{ steps.get_version.outputs.version }} + DEPLOY_REPO_URL: git@github.com:barebaric/rayforge-website.git + DEPLOY_BRANCH: main + IS_TAGGED_RELEASE: ${{ github.ref_type == 'tag' }} + IS_PRERELEASE: ${{ steps.get_version.outputs.is_prerelease }} + run: pixi run --frozen -e website site-deploy diff --git a/.gitignore b/.gitignore index 3f01260a6..ea58f7688 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,47 @@ venv +.venv *.swp *.py[oc] __pycache__ dist build +/media *.egg-info +.msys2_env +.DS_Store .flatpak-builder repo builddir +.aider* + +debian/rayforge +debian/.debhelper + +# Translations +*.mo +*.po~ +*.mo~ + +# Docusaurus +website/node_modules/ +website/.docusaurus/ +website/build/ +website/docs/developer/raygeo-api/ + +.mac_env +rayforge/private_addons +rayforge/version.txt + +# Rust ops crate +target/ +crates/rayforge-ops/Cargo.lock + +# External dependencies +external/ + +# macOS icon build artifacts +Assets.car +rayforge.icns +.hermes_last_pull diff --git a/.pixi/config.toml b/.pixi/config.toml new file mode 100644 index 000000000..09a78b9c1 --- /dev/null +++ b/.pixi/config.toml @@ -0,0 +1 @@ +run-post-link-scripts = "insecure" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..a365843c3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: local + hooks: + - id: pixi-lint + name: pixi run lint + entry: pixi run lint + language: system + pass_filenames: false diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 000000000..5c8d9318b --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..ad31862f0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Remote Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..593275e16 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.defaultInterpreterPath": ".pixi/envs/default/bin/python", + "python-envs.defaultEnvManager": "ms-python.python:system", + "python-envs.pythonProjects": [ + { + "path": "", + "envManager": "ms-python.python:system", + "packageManager": "ms-python.python:pip" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..47b210968 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +## General commands + +- No setup needed. Do not run "cd", assume you are in the correct path by default. +- Use these commands: + o `pixi run format`: Apply automatic code formatting + o `pixi run test`: Run backend tests + o `pixi run uitest`: Run UI tests + o `pixi run lint`. Performs linting and static code analysis + o `pixi run print-untranslated list`: List languages with untranslated strings + o `pixi run print-untranslated `: Print untranslated strings from po file + +## Code style + +- When writing Python, conform to PEP8 with maximum line length of 79 chars +- Keep cyclomatic complexity low. Write small, testable functions +- Never mark your changes with inline comments. Code is for clean, final implementation only +- Retain exiting formatting, docstrings, and comments + +## Raygeo (Rust/PyO3 geometry library) + +Even though Raygeo is installed as a regular pip dependency, we own it. If the root +cause of an issue is in Raygeo, you should fix it there instead of building a +workaround. +Source repository: https://github.com/barebaric/raygeo + +### Testing with a local Raygeo checkout + +`scripts/pixi-raygeo.sh` wraps any pixi command with a +`dependency-override` that uses a local raygeo checkout. The project's +real `pixi.toml`/`pixi.lock` are never permanently modified. + +```bash +ln -s /path/to/raygeo external/raygeo # one-time symlink (external/ is gitignored) +scripts/pixi-raygeo.sh run rayforge # run against local raygeo +scripts/pixi-raygeo.sh run test # test against local raygeo +scripts/pixi-raygeo.sh shell # activate a shell with local raygeo +``` + +After editing raygeo Rust or Python source, rebuild it with: + +```bash +scripts/rebuild-raygeo.sh # clear uv cache + rebuild raygeo +``` + +To go back to the PyPI raygeo, just use `pixi run rayforge` without the +wrapper (or any other pixi command). + +## Other rules + +- Do not run the full test suite prematurely. Fix all linter errors first. Run targeted tests. +- Never use "head" to filter CLI commands! This would hide useful error messages. +- Use proper markdown to put each file into a separate code block. +- File start markers do not belong INTO code blocks. Putting them OUTSIDE is ok. +- Do not make changes unrelated to the current task +- Never remove logging or debugging unless asked by the user +- Do not repeat files unless they have changes diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..0ce472a97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1883 @@ +# Changelog + +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). + +## 1.9.2 + +### Fixed + +- Loading projects that store a null ``opsproducer_dict`` no longer + crashes (e.g. when an engrave step has no legacy producer + parameters) +- Renaming a step now updates the step list in the main window's right + pane immediately + +## 1.9.1 + +### Added + +- New "CNC Essentials" addon with Experimental CNC machining operations: + adaptive clearing, flat spiral, helix plunge, inner and outer profiling, + ramp entry, slotting, and toroidal clearing (disabled by default; enable + it in the addon manager) +- Pipeline progress now shows the currently running operation with a + friendly, translatable status label instead of an internal name + +### Changed + +- Recipes now target one or more step types instead of a single + capability; the recipe editor gained a searchable step-type selector, + and existing recipes are migrated automatically +- Post-processor settings (lead-in/out, multipass, overscan) can now be + stored per recipe and applied to the targeted steps +- Show a confirmation dialog when enabling experimental addons +- Upgrade raygeo to 1.38.3 + +### Fixed + +- Generated G-code could be wrong when axis reversal or a non-bottom-left + origin was combined with a WCS offset +- Air assist settings in laser steps were not emitted to the G-code + (M8/M9) + +## 1.9.0 + +### Added + +- Playback speeds up to x64 in simulated playback +- 3D preview renders raster scanlines at the physical laser dot + width for a more accurate preview +- Addon-contributed settings pages now update live when the settings + dialog is open +- Addon manifests support a default enabled/disabled state + +### Changed + +- Upgrade raygeo to 1.37.0 +- Memory improvements in the pipeline: op data now uses a compressed + array, assembly intermediates are released between builds, the + final job ops are no longer cached, and a kinematic mapping is only + computed when a rotary module is present + +### Fixed + +- 3D canvas panning now follows the mouse 1:1 +- Cylinder angle interpolation during rotary animation could be + incorrect +- 3D models could obscure ops in the 3D view +- Raster preview artifacts when zooming out (moire) fixed with + max-reduction mipmaps +- Toolpath and scanline trail drawn above the raster texture +- 3D canvas not grabbing keyboard focus when clicked +- Right panel could obscure the canvas overlays +- 3D model loading errors no longer crash the app +- Machine switch config update now runs on the main thread +- `--exit` watcher is only armed after the uiscript has run + +### Performance + +- 3D canvas vertex uploads are prepared in a worker thread, reducing + main-thread stalls during pipeline finishes + +## 1.9.0-beta4 + +### Added + +- Simulated playback now advances by simulated machine time at + (approximately) real machine speed, with a 1x-16x speed multiplier: + the toolpath reveal, laser head, and laser beam interpolate within + each command so playback is smooth instead of stepping one command + per frame +- Step forward/backward buttons glide to the next command over a short + fixed duration instead of jumping; rapid clicks coalesce into a + single glide that covers the net number of commands +- Zoom and orbit now rotate around the point under the cursor +- Playback controls (play, step, speed, slider) shown as a bar below + the 3D canvas +- The 2D and 3D canvas grids draw in the preferred length unit +- Warn when a project uses cooling methods not supported by the + current machine +- Recipe manager shows the selected step in the recipe description + +### Changed + +- Upgrade raygeo to 1.33.0: simulated playback now runs at accurate + machine speed, ops no longer move slightly through the cylinder + during rotary simulation, and stroke-only cut lines are no longer + missing from the generated ops +- Internal: 3D canvas refactored into a scene presenter, camera + controller, playback overlay, renderer registry, and chunked upload + controller +- Updated translations + +### Fixed + +- Texture alpha no longer brightens after a layer completes +- Laser beam rendered over the scanline ring buffer +- Scanline overlay stays visible after playback completes +- Legacy opsproducer step parameters migrated when loading projects +- Step settings refresh when a recipe is applied +- Material colors applied per-widget in lists +- Simple GRBL driver wakes an in-flight ping-pong on cancel +- 3D canvas background falls back to the theme view background color +- Frequency and pulse width preserved in MachineState.copy + +## 1.9.0-beta3 + +### Added + +- Unit system support: metric/imperial selection in the machine + settings, with automatic unit-system detection for GRBL (from `$13`) + and Marlin (via `M149`) drivers and in the configuration wizard +- Length, speed, and acceleration inputs are now unit-aware: they + convert between the configured display unit and base units, update + live when the display unit changes, and show the unit as a tooltip +- The 2D and 3D canvas grids now follow the user's preferred length + unit: grid lines snap to multiples of that unit and axis labels are + displayed in it, updating live when the preference changes +- Generic service registry and settings-page hooks so addons can + publish key-resolved services and contribute their own pages to the + Settings dialog + +### Changed + +- Addon manifest `requires` are now enforced at load time with a + topological pass so dependencies load before dependents +- Bump addon API version to 18 +- Upgrade raygeo to 1.32.1 + +### Fixed + +- Raster engraving could be rendered up to one pixel smaller than the + workpiece size due to pixel-count truncation (raygeo 1.32.1), which + could be larger if the workpiece is scaled. + +## 1.9.0-beta2 + +### Added + +- Unified machine configuration wizard with AI-powered device spec + lookup +- Import SVG colors as layers +- Color rules that map SVG colors to step types, with a settings + page to manage the rules +- Recipes can now target specific step types +- Assembly warnings (e.g. failed faces or regions) surfaced as toast + notifications +- Right-click context menu on steps in the layer workflow strip with a + delete option + +### Changed + +- Kerf and path offset merged into a single offset setting, defaulting + to half the laser head spot size +- Raster power range on engrave steps renamed to min/max power level so + it no longer clashes with the hardware max power setting +- Upgrade raygeo to 1.31.2 (SVG color layer import, multi-face parts, + and fixed SVG ``/`` traversal) +- Bump pypdf to 6.14.2, GitPython to 3.1.58, and aiohttp to 3.14.3 to + fix security vulnerabilities +- Updated translations + +### Fixed + +- 3D toolpaths drawn at full brightness on first open instead of + power-dimmed +- Raster full-sweep mode no longer engraves empty masked regions at + full power (raygeo 1.31.2) +- Raster multi-pass mode no longer mixes Z levels when optimizing, and + cross-hatch now interleaves both angles per pass instead of running + all passes of one angle before the other +- CNC step attributes no longer dropped from project files on save +- ChArUco detection failing on some array shapes (camera calibration, + by trixdaddy) +- Intent-rebuild hot loop on documents without workflow content +- Missing features dialog now reports the original step type +- Restored macOS Monterey-compatible bundles (by pgilfernandez) +- Replaced deprecated GTK CSS APIs +- Icons that fell back to the system theme (which breaks on some + platforms) now ship with the app + +## 1.9.0-beta1 + +### Added + +- Array / Pattern tool with Grid, Point Rotation, and Circular modes +- Dot width correction for raster engraving (#316, by vyvcodd) +- LightBurn import: support for importing raster settings + (dotWidth, interval, angle, scan_angle) +- Allow renaming layers and steps directly in the layer/step + settings dialogs +- Asyncio support for parallel workpiece processing in the pipeline +- Configurable pipeline cache budget in settings +- Error notifications for pipeline failures + +### Changed + +- Upgrade raygeo to 1.27.0 (from 1.24.0) with migrated G-code encoder, + BidirScanOffsetTransformer, and MultiPassTransformer to Rust; + transformer application now uses Rust apply_transformers dispatch +- Replaced multiprocessing pipeline with raygeo intent + orchestration: compute, raster, shrinkwrap, wavefront, contour, + and view rendering now run in raygeo threads instead of + subprocesses for improved performance and reliability +- Rewrote 3D scene compiler to use Rust `compile_scene_3d` with + chunked GL upload for improved 3D canvas rendering performance +- Pipeline cache is now preserved across document and machine swaps + for faster rebuilds +- Improved addon translation fallback: English is now used when + no matching locale is found +- Wavefront icon and improved Gtk SVG compatibility for other icons +- Bump pypdf to 6.13.3 +- Bump GitPython to 3.1.51 + +### Fixed + +- Blank sketcher UI text on packaged installs (#315) +- Dragging of layers now works correctly +- Use persistent /dev/v4l/by-id/ paths for camera identification + on Linux (#318) +- Spinrow input field too narrow in some cases +- Settings widget auto-value bugs: raster/wavefront sliders + showing wrong defaults, overscan Automatic Distance switch + permanently greyed out, and auto overscan/lead-in-out distance + recalculating to a smaller value on toggle (#314, by vyvcodd) +- Fixed job generation hangs in the pipeline +- Fixed in-flight intent not cancelling on force_rebuild +- Fixed 3D rotary rendering missing mapped operations +- Fixed rotary module fallback not triggering pipeline rebuild on + machine changes +- Fixed a race condition on Windows + +## 1.8.5 + +### Changed + +- Upgrade raygeo to 1.21.3 to fix adaptive wavefronts generating + wave duplicates and mask_scan/dither raster mode ignoring + step_power +- Group selections in the properties panel no longer reset + relative positions, angles, and transformations between + grouped workpieces (#311) + +## 1.8.4 + +### Added + +- Speed vs Offset mode in the material test grid for empirical + bidirectional offset calibration (#312) by Github user vyvcodd. + +### Changed + +- Major pipeline refactor: replace OpsProducer system with + assembler registry; all step settings now read/write step + attributes directly (#309) +- Updated translations + +### Fixed + +- Upgrade raygeo to 1.12.2 to fix label power in material test + grid +- Text from addons not translated + +## 1.8.3 + +### Added + +- Language selector in the General settings page to change the UI + language at runtime (#303) +- Drag handle grab gizmo below the selection frame for easier + workpiece grabbing (#173) +- Support for tool numbers outside the 0-255 range, with new device + profile for Makera Carvera (#302) +- Air assist toggle to the material test grid (#304) +- CNC spindle and coolant fields in the G-code dialect + +### Changed + +- Upgrade raygeo to 1.21.1 with faster smoothing and 3D rendering + performance +- Text rendering now handled by raygeo for better font support + across platforms +- Updated translations + +### Fixed + +- G-code placeholders being incorrectly rejected in the encoder + context +- Axis replacement mode emitting duplicate Y words causing GRBL + error 25 (#310) +- Toggle buttons of varsets not changing background color when + toggled on +- Material test grid missing workpiece UID section commands +- Out of memory crash when opening SVG files containing circles +- macOS-only transport test failures (#306) +- Pixi environment solving for osx-arm64 (#306) + +## 1.8.2 + +### Added + +- Configurable GRBL protocol variant for Longer Ray5 (by Uwe Woessner) +- Device profile modifications for Longer Ray5 (by Uwe Woessner) + +### Changed + +- Upgrade raygeo to 1.15.1 +- Bump addon API version to 17 for incompatible raygeo changes +- Replace Cairo text path with Pango-based text_to_geometry for robust font + fallback (#293) +- Defer histogram computation to idle callback and cap render resolution in + raster widget +- Update pypdf to version 6.12.2 +- Update macOS setup to use Brewfile (by Lukas Huber) +- Updated translations + +### Fixed + +- Various device profiles missing `{extra_cmd}` in G-code dialect causing + A axis not emitted (#301) +- GRBL buffer stall recovery resending G-code to freshly reset firmware + after cancel +- Contour producer dropping open contours in Outside/Inside cut modes +- Overscan transformer doubling up for drivers with native overscan (Ruida) +- Website markdown links using trailing-slash bug in React Router + +## 1.8.1 + +### Added + +- Wavefront (adaptive clearing) toolpath operation for efficient area + clearing +- Migrate raygeo from local source to PyPI package + +### Changed + +- Upgrade raygeo to releases 0.8.0 through 0.13.2 with numerous API + improvements and renames +- Update dependencies (aiohttp, pypdf) to fix security vulnerabilities +- Updated translations + +### Fixed + +- Multi-step composite blit positioning for correct step content placement +- GRBL error state recovery when machine enters HOLD +- Backward compatibility for legacy bezier curve formats in raygeo + +## 1.8.0 + +### Added + +- LightBurn device profile (.lbdev) import with camera calibration and + device configuration +- Import LightBurn layer settings as Rayforge step parameters + +### Changed + +- Updated translations + +## 1.8.0-beta3 + +### Added + +- LightBurn (.lbrn / .lbrn2) file format import support + +### Changed + +- Updated to latest raygeo 0.6 API (Geometry API, bezier_to, fit_curves, + optimizer, canonical imports) +- Updated translations + +### Fixed + +- Optimizer no longer splits continuous scanlines +- Tab clip points now correctly scaled by workpiece size to match producer + transformation +- Fixed multiprocessing warnings on Python 3.12 + +## 1.8.0-beta2 + +### Added + +- Device profile for the Acmer P3 laser engraver +- Lens calibration dialog with status icons and tooltips in camera + properties, split from the image settings dialog + +### Changed + +- macOS app icons updated to Tahoe (Liquid Glass-style) design +- Rotary module selection is now disabled when the machine has no + rotary modules +- Updated translations + +### Fixed + +- Slider power value no longer clamped to 1% after dialog re-population + +## 1.8.0-beta1 + +### Added + +- Simple GRBL serial driver with ping-pong protocol for devices with + buffer-counting issues (GrblSerialSimpleDriver) +- "Go to WCS Zero" button in the Current Position section (#247) +- Device profile for the Creality Falcon 10W (#266) +- Device profile for the Sculpfun C1 engraver +- Allow finer raster line spacing (0.001 mm) for microfabrication (#252) +- Deadlock detection toggle in GRBL serial and telnet driver settings + +### Changed + +- Rewrote Ops container from List[Command] to Struct-of-Arrays with + index-based access; ported all transformers, encoders, producers, and + the 3D simulator to the new API +- Migrated tab operations, merge lines, overscan, lead-in/out, and hull + computation to raygeo 0.6 Rust backend +- Replaced Python raster scan loops with Rust-accelerated raygeo functions + (rasterize_power_modulation, rasterize_mask_scan, rasterize_multi_pass) +- Delegated image processing to raygeo.image (sRGB conversion, dithering, + grayscale normalization) +- Adaptive deadlock timeouts based on per-command time estimates instead + of fixed values +- Machine settings now apply immediately without requiring a restart +- Bumped addon API minimum version to 15 for raygeo 0.6 +- File dialogs prefer Rayforge project and sketch MIME types over ZIP + +### Fixed + +- Fixed O(n) OpPlayer.seek() causing 3D canvas slider to freeze on large + jobs; now uses pre-computed snapshots with binary search +- Fixed GRBL network disconnect with MKS DLC32 boards (#273) +- Fixed buffer stall recovery aborting jobs during slow moves (#256) +- Fixed machine settings not applying until restart (#267) +- Fixed ValueError when removing the active machine (#280) +- Fixed manual laser control routing +- Fixed WCS dropdown coordinates not updating on sync +- Detect and recover from crashed/unresponsive worker processes (#283) +- Fixed pipeline stress test: stale completions and busy state +- Fixed node state race: emit PROCESSING after task creation +- Skip stale cancelled tasks in worker pool queue +- Shut down multiprocessing Manager in TaskManager.shutdown() to prevent + semaphore leaks +- Hardened pool shutdown for Windows CI + +## 1.7.10 + +### Fixed + +- Fixed contour offset producing hundreds of garbage micro-contours on shapes + with multiple holes (raygeo v0.2.0) + +## 1.7.9 + +### Added + +- Raygeo version info in about dialog + +### Fixed + +- Fixed mirrored bezier control points and arc parameters in raygeo +- Fixed raster and frame icons not showing on some GTK versions + +## 1.7.8 + +### Added + +- Distance preset buttons in Print and Cut wizard for quick selection +- Updated addon API version to 13 + +### Changed + +- Migrated geometry processing from Python to Rust (raygeo) for improved performance + +### Fixed + +- Fixed WCS offset applied twice in Move to Selection buttons (#245) + +## 1.7.7 + +### Fixed + +- Fixed shallow copy of extra_axes in Command causing rotary 3D preview + distortion (#243) +- Fixed mirrored arcs rendered as full circles in G-code and 3D preview + +## 1.7.6 + +### Added + +- Space+drag pan gesture for canvas navigation (#241) +- Custom resolution option for camera image settings + +### Fixed + +- Fixed capability defaults being overwritten by duplicate step keys (#239) +- Fixed RX buffer override not applied in Creality Falcon device profiles + +### Performance + +- Numerous performance improvements for raster engraving operations + +### Changed + +- 2D canvas laser path alpha normalization for improved visibility at low power + +## 1.7.5 + +### Added + +- Overcut option for contour operations + +### Fixed + +- Fixed operations preview misalignment when zooming past the base image + resolution cap + +### Performance + +- Massive performance improvements across geometry processing, path + optimization, and vector operations + +## 1.7.4 + +### Fixed + +- Fixed crash when loading GLB models with texture visuals instead of vertex colors +- Improved 3D model lighting with a fill light and raised ambient brightness +- Remapped laser power LUT lookup so low-power paths remain visible + +## 1.7.3 + +### Fixed + +- Fixed SVG vector extraction missing group transforms for basic shapes (#237) +- Fixed error when dismissing the import file dialog +- Disabled export/send buttons when pipeline data is stale, with tooltip + prompting recalculation (F5) + +### Changed + +- Updated MarlinSerialDriver maturity level to EXPERIMENTAL (#236) +- Updated recalculate button icon in the main toolbar +- Updated translations + +## 1.7.2 + +### Added + +- Experimental Marlin driver with probing/auto configuration support (#236) +- Camera resolution selection in camera image settings (#233) +- Camera visibility toggle in SketchStudio (#235) +- RX buffer size override option for GRBL serial driver (#234) + +### Fixed + +- Fixed job generation stuck after cancellation +- Fixed RX buffer size handling in GRBL serial (#234) +- Fixed atomic buffer space checks and flow control in GrblSerialDriver (#234) +- Fixed cooperative cancellation not working in worker subprocesses + +### Changed + +- Updated GitPython dependency to 3.1.50 +- Various code cleanups + +## 1.7.1 + +### Added + +- Add a diode laser 3D model +- Reset button for sketch parameters in the panel +- Boundary tolerance checks for extent and workarea validations + +### Fixed + +- Fixed Gtk deprecation warning +- Fixed Gtk warning from duplicate WCS row in BottomPanel +- Improved GRBL command parsing and line ending handling + +### Changed + +- Updated translations + +## 1.7.0 + +### Added + +- Configuration wizard with GRBL probing support for automatic device + detection and setup + +### Changed + +- Enhance text rendering by integrating Pango for improved layout and metrics + +## 1.7.0-beta3 + +### Added + +- Manual laser control dock with per-head power, frequency, pulse width, + and auto-off timer (#225) +- Search field in the machine profile selector +- Machine profiles for Sculpfun S30 Pro Max, S40 MAX, and S70 MAX, + and Elidor Z6 + +### Changed + +- Dock layout: new dock items are now placed next to their buddy item + when restoring a saved layout that doesn't include them +- Serial transport: switch to non-blocking read for improved OS lock + management (#231) +- Serial transport: offload write operations to executor to prevent + blocking +- Serial transport: open ports in exclusive mode to avoid clashes with + other apps +- GRBL: flush serial buffer after every write + +### Fixed + +- GRBL: cache detected RX buffer size in the machine config file to + avoid buffer overflows on devices that do not report it via `$I` +- GRBL: laser-off command (M5) no longer sent during active jog, which + caused error:9 +- Deadlock detection triggered incorrectly when status polling was off +- Deadlock detection when GRBL doesn't report Bf: in status reports + +## 1.7.0-beta2 + +### Added + +- Job sanity check system that reports machine extent violations, workarea + violations, and no-go zone collisions before sending or exporting +- Device profiles for 10 popular laser cutters: Ortur LM3/LM4, Atomstack + X40 Pro/A70, TwoTrees TTS-55, NEJE Master 3 Max, Creality Falcon 2 Pro, + OMTech Polar 50W, Longer Ray5, and Thunder Laser Nova 35 +- Context menus for workpieces in the layer tab (move, delete, properties) +- Context menus in the asset browser with copy, cut, paste, and duplicate +- Paste action in the canvas background context menu +- Visual selection state in layer columns synced with the canvas +- Multi-item selection with Ctrl-click, Shift-click range, and cross-layer + drag +- Horizontal and vertical auto-constraints from snap guides in the sketcher + path tool +- Sketch parameters shown as a separate preferences group in the properties + panel +- Locale-aware number formatting in sliders + +### Fixed + +- GRBL buffer deadlock from lost ok responses on \r\r\n line endings +- Wrong visibility icon for initially invisible layers +- IndexError when laser combo selection is out of sync with machine heads +- AttributeError on startup from early view_stack signal connection +- Circle and ellipse sketches missing preview thumbnails +- Canvas stuck in shift-pressed state after layer interaction +- Incorrect 3D view mouse controls in the documentation (#229) + +## 1.7.0-beta1 + +### Added + +- CO2 laser settings: PWM frequency and pulse width support for compatible + machines +- Experimental OctoPrint driver (untested) +- Parametric text template support in the sketcher +- Device profile for the Sculpfun iCube Ultra +- Grid toggle button in the 3D canvas visibility overlay +- Project inclusion toggle in the Save Debug Log dialog + +### Changed + +- Image processing (resize, grayscale, dithering, color LUT) now operates in + linear light for more accurate results +- Visual improvements to the device profile selector +- Tab power slider is now hidden for non-cut steps + +### Fixed + +- GRBL buffer overflow on devices with smaller RX buffers +- Parametric text not updating correctly with volatile expressions +- Crash in MergeLinesTransformer when a cutting command appeared before any + positioning command + +## 1.6.1 + +### Added + +- Ruida driver with jogging, position reporting, air assist, layer selection, + auto-connect, status polling, and ref points support +- Driver maturity enum with warning banner for non-stable drivers +- Generic GRBL, Smoothieware, and Ruida device profiles +- Allow editing workpiece vectors directly (vector deletion) by double clicking + a workpiece +- Job time estimate shown in 3D canvas + +### Changed + +- Replace asyncio-serial by threading in serial transport reader loop to reduce + read buffer overflows when the asyncio loop is congested (#208) +- Print and cut: replace scale checkbox by an adw toggle button +- Project files are now zipped internally +- Creality Falcon A1 profile now uses Grbl Raster dialect + +### Fixed + +- Pipeline held reference to old machine after switching to a new machine +- Pipeline recalculation loop +- Power percentage rounding in step summary + +## 1.6 + +### Added + +- Device profiles replace machine profiles with declarative packages that bundle + machine config and G-code dialect together +- Export and import UI for sharing device profiles between machines or users +- Per-layer Work Coordinate System (WCS) assignment with edit button in layer + settings +- Redesigned layer system with visual workflow indicators in each layer column +- Drag-and-drop layer reordering in the layer list +- Workpieces are reorderable in the layer list to change z-order +- Middle-click pan in the layer list +- Layer columns now show subtitles and have limited default width +- New documents start with a default of 3 layers +- Rotary mode now supports true 4th axis and axis replacement (switching X or + Y for rotary) +- Machine settings offer settings for roller-type rotary axis +- Material test grid supports new parameter combinations with extra speed or + power labels in multipass mode +- GRBL Telnet driver for networked grblHAL and ESP3D controllers (thanks to + gyordanov) +- Update checker notifies when a new Rayforge version is available +- Right panel is now a floating overlay for more canvas space +- Values next to sliders are now editable entry fields +- Setting to choose whether ops use layer color or laser color +- Double clicking a workpiece in the layer box opens its properties +- Print and cut addon for aligning laser cuts with printed material (#180) +- Device profile for the Creality Falcon A1 +- Right-click context menu for empty canvas space +- Sketcher: support changing text color using the fill tool +- Site search on the Rayforge website +- Sponsor page on the Rayforge website + +### Changed + +- Major refactoring of kinematics and 3D simulator for better rotary support +- Improved error messages for Grbl alarms +- Kinematics now build dynamically from AxisSet and AxisMapper +- Massively decreased memory usage of multi-layer PDFs +- Reduced memory required for vertex storage by storing power values instead + of colors +- 2D canvas adapts to rotary mode automatically +- 3D canvas displays rotaries correctly in all configurations +- GRBL serial driver: improved deadlock recovery, comment stripping before + sending G-code, improved buffer handling +- Air assist state no longer resets between workpieces +- 2D simulator removed (superseded by the 3D simulator) +- Layer settings dialog is now non-modal +- Bottom panel is now visible by default +- Status polling is disabled during job execution by default + +### Fixed + +- WCS marker not updating in 2D canvas when changing WCS in layer settings +- WCS synchronization fails if device does not report Z coordinates +- Re-syncing WCS with the machine did not clear stale offsets +- G0 and G1 feedrate is shared (#210) +- Air assist disabled after workpiece (#208) +- Sketcher shortcut shadowed by New Project shortcut +- 1 key shortcut shadowed dimension input (#207) +- Canvas not centered when opening a project with rotary layer +- Opening a layer in rotary replacement mode overwrites machine Y dimensions +- Textures not drawn with proper opacity in 3D canvas +- Textures stretched too wide around the cylinder in rotary mode +- 3D canvas axis extent frame with inverted margins when origin is top-right +- 3D canvas not updating ops when rotary config changes +- Laser head not moving in Y in flat mode +- Cylinder not rotating during playback +- Drawing trails behind laser in rotary simulation +- Clipping when zooming in 3D canvas in orthographic view +- No Z in G-code for rotary in Z replacement mode +- G-code for rotary missing rotary command +- Terminal window visible on Windows +- Debouncing caused material test not to update when switching presets (#187) +- Deleting the active machine could lead to no machine being active +- Stale ops in 3D canvas after deleting a layer +- Multiple GRBL serial driver robustness improvements +- GRBL alarm codes were mapped to wrong error descriptions +- Snap fails to launch with gpu-2404 slot not connected error (#196) +- Pipeline applied gear ratio even for visual representation (#195) +- Delayed main thread callbacks not cancellable via handle.cancel() +- GRBL sending initial $I as realtime command though it is not one +- Base image of inverted SVG not inverted after import +- Rotary cylinder rendered with diameter of chuck instead of workpiece (#195) +- Beta version string parsing for debian releases +- Zero axis not working over GRBL network connection (#220) +- Jog distance not applying when entered via keyboard (#221) +- Gtk imported in worker subprocesses (#224) +- Various Gtk warnings + +## 1.5.2 + +### Fixed + +- G-code production could fail if no rotary axis commands were defined +- G5 commands in LinuxCNC and Marlin templates did not respect the omit + unchanged axis flag +- 2D canvas showing stale ops when operation generates zero ops +- Model preview showing models in wrong orientation by default +- Point light not turning off when laser is off +- Potential race conditions in the pipeline and 3D canvas +- Model preview now displays colors correctly + +## 1.5.1 + +### Changed + +- Post processor page is more compact by putting each post processor into an expander +- 3D canvas: better line width for rendered ops + +### Fixed + +- GRBL serial not connecting (#196) +- Auto brightness toggle setting not remembered in raster step settings + +## 1.5 + +### Added + +- 3D simulator with full playback: play/pause, step forward/backward, scrubber, + and speed control (1x to 16x) +- End-to-end bezier curve support (G5) through the entire pipeline, from import + to G-code output +- No-go zones: define restricted areas in machine settings with collision checking +- 3D model support for rotary axes (GLB format) with shading and proper coloring +- Global model manager for storing and reusing 3D models across machines +- Import dialog now offers three layer modes: flatten, merge to existing, or + create new layers +- Imported layers automatically get sensible default workflow steps +- Dockable bottom panel: tabs can be rearranged freely and split into separate + columns +- Layer list moved into the bottom panel with drag-and-drop from asset list +- Asset browser overhaul: all assets visible, multi-selection, drag to canvas, + thumbnails for most image formats, helpful empty-state placeholder +- Lead-in / lead-out postprocessor for zero-power approach and exit moves +- Material test: labels engraved first for cleaner results +- Material test: overscan transformer support +- Material test: independent label engraving speed setting +- Ctrl+F search in console and G-code viewer +- Addons can register their own toggles in the View menu +- YUYV camera protocol support +- Canvas remembers state of view toggles between sessions +- Double clicking a stock asset opens its properties +- Drag assets from the asset list to the layer list +- Continuous laser mode and modal feedrate G-code options +- GRBL raster dialect that omits unnecessary M4/M5 spindle commands +- Grbl MKS DLC32 machine profile +- Laser head model rendered in the 3D simulator + +### Changed + +- Canvas is significantly more responsive during drag, pan, and zoom operations + by suppressing expensive path rendering until interaction stops +- Multi-step workflows composite into a single surface for faster rendering +- Large images are automatically scaled to prevent multi-gigabyte memory spikes +- Image handling rewritten to avoid unnecessary copies, reducing overall RAM usage +- Smarter caching: base images cached on source assets and reused across workpieces +- Cache memory limit in the 2D canvas prevents unbounded memory growth +- Time estimation updates instantly instead of recomputing from scratch +- Pipeline recalculation can now be toggled off in settings +- Status bar removed; machining time moved to layer list header, ETA to machine + dropdown, status messages to canvas overlay +- Visibility toggles for perspective, model, and no-go zones moved to canvas + overlays +- Rows in main window expanders are more compact +- Bottom panel layout of coordinate controls and jog widget is responsive +- Decreased default merge lines tolerance to 0.01 +- Crop-to-stock now crops to workarea if no stock is defined in the document +- Illustrator files now use correct 72 DPI instead of 70 + +### Fixed + +- Race condition causing 2D canvas re-renders to hang +- Race condition in doceditor.wait_until_settled_sync() +- Status overlay displayed even when no message was set +- Stale job shown in 3D canvas after changes +- 3D canvas not showing dimmed versions of travel moves +- Wide strokes rendered incorrectly in import dialog preview +- Item layers not added when importing from the command line +- Tabs cutting paths in more than one place +- Addon installation failing in Snap packages +- 2D and 3D canvas stealing focus from the sketcher +- Sketch parameters could not be edited in the properties panel +- Crop-to-stock linearized arcs unnecessarily +- Deadlock when switching WCS while 3D canvas visible +- 3D canvas not updating fully after hardware changes +- Toggling no-go zones off undimmed vertices in the 3D canvas +- Mapping of stepped down vertices in rotary mode +- Drag and drop bug in the asset browser +- No G-code output if document contained an empty layer +- Editing machine settings resetting the canvas perspective +- Single instance lock: second window now gracefully exits +- Two memory leaks in the 3D simulator (shared memory and shader) +- 3D canvas empty if G-code viewer was not open + +## 1.4.1 + +### Changed + +- PDF import now falls back to `fitz` when `pymupdf` is not installed (#186) +- DPI setting in the SVG import dialog is now persistent between sessions + +## 1.4 + +### Added + +- Full rotary axis support with 3D visualization +- Support for multiple rotary modules +- Configurable rotary mode per layer +- Rotary icon displayed on rotary layers +- PDF direct vector import with layer support +- Improved five factor camera de-distortion algorithm +- Charuco card based calibration wizard with guided setup process +- Sketcher: ellipse tool replaces circle tool for more flexibility +- Sketcher: many tools automatically constrain geometry during creation +- Sketcher: magnetic snap now works while creating geometry +- Sketcher: replaced snap to grid with smarter magnetic snap +- Sketcher: equality constraint now works on ellipses +- Configure frame speed in laser head settings +- Corner dwell time setting for framing +- Repeat count setting for framing +- New merge lines post-processor to avoid double cutting +- Machine profile for Acmer S1 added +- Dialects support separate laser on command for focusing +- Add a DPI setting to the SVG import dialog if the SVG is unitless + +### Changed + +- More compact left panel layout with add buttons moved into group headers +- G-code viewer moved into the bottom panel +- 3D canvas performance improvements +- Dialects are now isolated copies (templates) +- GRBL buffer size tracking improved +- Texture dimension limit prevents memory exhaustion +- Addon manager: safer threading approach + +### Fixed + +- Material test producer bugs (issues #181 and #182) +- GRBL position reporting for machines with only X and Y axes (#179) +- Sketcher: distance constraint shadowing the line +- Texture renderer memory exhaustion on large images +- Race condition in worker initialization + +## 1.3.2 + +### Added + +- Raster step settings now display angle numerically + +### Fixed + +- Import issues on Windows +- Unknown G-code dialects now fall back to Grbl instead of causing errors +- Duplicate error notifications from the driver + +## 1.3.1 + +### Fixed + +- Icon sizing issues on systems with Gtk 4.21 +- Windows and macOS build issues +- Conflicting menu shortcuts in the sketcher + +## 1.3 + +### Added + +- Sketcher: full support for bezier curves with intuitive handle-based editing +- Sketcher: new unified path tool that combines lines and curves +- Sketcher: grid tool for visual reference and alignment +- Sketcher: toggle buttons to show/hide construction geometry and constraints +- Sketcher: hold Shift to constrain movement to the nearest axis +- Sketcher: endpoints connected by coincident constraint can be made smooth or symmetric +- Sketcher: "straighten" tool to convert bezier curves to straight lines +- Sketcher: path edit tool can now connect to existing points +- Sketcher: conflicting constraints now shown in the panel +- Raster operation: sample interval and power levels settings +- G-code viewer now shows line count and byte size + +### Changed + +- Sketcher moved to an add-on (installed and enabled by default) +- G-code now omits unchanged coordinates for more compact output +- Removed obsolete "no-Z" G-code dialect variants +- G-code viewer always shows at least the first 20,000 lines +- Sketcher: hide gray background area for cleaner editing +- AI workpiece generator now creates sketches when geometry can be mapped + +### Fixed + +- Most icons not displayed on systems with Gtk 4.21 or higher +- Fillet and chamfer tools not working correctly +- Texture encoder drawing spaced dots instead of lines in some cases +- G1 emitted without coordinates when all coordinates unchanged +- Loading project files with unknown assets + +## 1.2.1 + +### Added + +- Buttons to move to center, bottom/left and top/right of workpiece +- Support for setting a "tab power" +- Sketcher: allow entering dimensions while adding geometry + +### Changed + +- Better button layout in the control panel +- Sketcher: circle now uses diameter constraint consistently, not sometimes radius +- Laser dot now always drawn on top, not obscured by workpieces + +### Fixed + +- Builtin addon yaml files not included in .snap +- Imprecise tab location while dragging the tab handle +- Tabs not working on beziers + +## 1.2 + +### Added + +- AI workpiece generation +- Camera image enhancement with temporal noise reduction (thanks + to MausRundung) +- Fisheye lens distortion correction with radial and tangential parameters + (thanks to MausRundung) +- Zoom, pan and keyboard navigation in camera alignment dialog + (thanks to MausRundung) +- Complete addon system rewrite and refactoring +- Tons of new materials in core materials addon +- Sketcher: live preview for line tool +- Sketcher: show dimensions while adding geometry +- Sketcher: snap-to-grid on Ctrl press +- Sketcher: highlight hovered entities and constraints +- Sketcher: toolbar shows currently available shortcuts +- Sketcher: Shift+Double click selects connected geometry +- Sketcher: allow arc radius changes while adding second arc endpoint +- Path optimizer now also optimizes inter-workpiece travel +- Mach4 G-code dialect +- Post-processor: crop-to-stock +- Click canvas to set zero feature +- Support for configuring cut/raster colors per laser +- Support for duplicating stock and non-rectangular stock +- Convert workpiece to stock (right-click menu) +- RAYFORGE_DISABLE_3D environment variable +- Machine profile for OMTech K40+ +- Navigation and zoom icons for UI controls + +### Changed + +- Stock is now a document-level concept - no more stock per layer +- Improved camera selection dialog (left/right indicators, keyboard support) +- Imported items and stock now aligned with WCS origin by default +- Stock placed at WCS origin by default +- Sketcher: preserve selection when creating lines, arcs, or circles +- Sketcher symmetry constraint click order now aligns with FreeCAD +- Maximum laser G-code power increased to 100.000 +- Addon terminology changed from "plugin" to "addon" +- Context now lazy loads most services for better performance + +### Fixed + +- Undoing text box left entries in history manager stack +- Y axis drawn on wrong side of canvas +- Auto layout for rotated stock +- SVG exporter stacking multiple workpieces on top of each other +- Text color in Sketcher while editing +- When opening project file referencing non-existent laser, assign default laser +- Text box rotates while typing on Windows +- Addon handling issues on Windows + +### macOS Specific + +- Narrow macOS app menu window to MainWindow - pgilfernandez +- macOS app menu actions fix - pgilfernandez + +## 1.1.2 + +### Fixed + +- Text box rotating while typing in sketcher +- Assets cannot be deleted after loading from a project file +- Empty sketches showing as black squares after loading from project +- Maybe: Pie menu not opening in correct location on Windows + +## 1.1.1 + +- Fix screenshot link in appstream file causing Flatpak build to fail. + +## 1.1 + +### Added + +- Major: MacOS support was added thanks to Github user pgilfernandez! +- Major: Complete data pipeline backend rewrite with improved performance + and memory management +- Major: Unified raster operations - the old "raster", "depth", and + "dither" operations are now replaced by a single, unified raster operation +- Major: G-code console replacing the log view with a fully featured terminal +- SVG and DXF export support - export your documents or selected objects +- Angle constraints now available in the sketcher +- New "Raster (Dither)" operation type with configurable algorithms +- Built-in G-code dialect supporting dynamic power mode +- Auto thresholding for Variable Power and Multipass raster modes +- Histogram in raster engraver to help setting thresholds +- Symbolic visualization of raster direction in rasterizer +- Configurable line distance in all raster operations +- Axis extents, work surface, and soft limits can now be configured per machine +- RAYFORGE_MAX_WORKERS environment variable to limit the number of processes +- macOS packaging scripts and resources for better platform support +- `--uiscript` CLI argument for executing scripts after the UI is up +- Optional (opt-in) anonymous usage statistic collection +- Material files can now contain translations + +### Changed + +- Machine selector moved to the window header for easier access +- G-code viewer and control panel toggles are now independent +- Machine settings dialog reorganized for better clarity +- Pressing "reset position" on a workpiece places it at WCS origin, + not machine origin +- Workpiece properties now show the position in WCS coordinates +- Contour and shrinkwrap operations use proper tolerance instead of + laser dot size +- Increased maximum laser spot size to 10 mm +- File dialog now selects BMP by default instead of all supported files +- Imported images are now placed at reference origin by default +- Added Ukrainian translations +- Re-assigned Alt key bindings to avoid clashing with main menu actions + +### Fixed + +- Multiple memory leaks in the data pipeline +- Race conditions between worker pool task completion and pipeline shutdown +- Camera device scanning crash on Windows +- Multipass post-processor exception +- Rasterizer angle causing distortion +- Various macOS issues: shm_open length errors, SVG rendering fallback, + keyboard shortcuts +- Workpiece stage not emitting correct node state +- Zoom resolution stuck until resizing at least once +- Blurry view overlay on startup +- Race condition leads to stale vectors for cancelled tasks +- Memory for view artifacts released late +- Unresponsive serial ports now handled gracefully with log message +- Simulation preview strokes now stay constant across zoom levels +- Speed entered in Jog dialog is now correctly converted to base units +- Switching to 3D view sometimes showed no operations +- Some icons not appearing when running the snap on non-GNOME environments +- GRBL connection handshake timeout handled with backoff retry + +## [1.0.1] - 2026-02-01 + +### Fixed + +- Some strings were not translatable +- Reformatted the 1.0 appstream release notes to not trip the Flatpak build up + +## [1.0] - 2026-02-01 + +### Added + +- Major: Project save/load support +- Major: The sketcher now supports text, with many bells and whistles +- Major: The jog dialog has been merged together with the log view into a bottom panel +- Sketcher supports aspect ratio constraints +- Engraving steps now have an invert setting +- The raster engraver now supports setting the engraving angle in degrees +- A recent files menu entry was added +- Installers for all platforms now register the .ryp (project) and .rfs (sketch) file extensions + +### Changed + +- Command line interface: `--direct-vector` renamed to `--vector`; added + `--trace` to force trace mode. Default is now to try vector import first, + falling back to trace if not supported +- Import errors now collected and displayed in import dialog +- Importers almost completely rewritten for testability +- Sketcher: The solver now biases points to their previous position for more stable dragging +- Simulation mode keyboard shortcut changed to F11 to avoid conflic with "Save as..." +- Remember G-code view and control panel visibility across sessions +- Increased precision of power sliders and display digits everywhere +- Import dialog now shows number of vectors per layer +- Error message shown when attempting to delete a dialect that is in use +- Chinese translations were added +- When opening bitmap images from CLI, default to import the whole image, not tracing + +### Fixed + +- Traceback in the sketcher when adding a constraint (affected Windows build only) +- Fixed a potential memory leak and stale ops display +- Multi layer DXF import +- Numerous alignment bugs in importers +- Traceback when using invert switch in import dialog +- Sketches not properly centered on the surface after import + +### Documentation + +- Updated importer developer documentation + +### Build + +- Added hicolor-icon-theme dependency to snap build + +## [0.28.4] - 2026-01-22 + +### Fixed + +- Traceback when dialect contained deprecated attributes +- Debian package missing asyncudp dependency + +## [0.28.3] - 2026-01-18 + +### Added + +- Option to enable/disable WCS injection in G-code dialect + +### Fixed + +- Depth engraver not respecting master power setting +- Traceback when using invert image in import dialog + +## [0.28.2] - 2026-01-17 + +### Fixed + +- Test that depended on specific version string + +## [0.28.1] - 2026-01-17 + +### Fixed + +- Invalid ampersand in appstream XML + +## [0.28] - Work Coordinate Systems, True Arcs, and a New Package Manager + +### Added + +- Full support for Work Coordinate Systems (WCS) G54-G59 + - "Set Origin Here" button to define temporary work zero at any point + - Visual feedback with active WCS origin marked on 2D canvas + - 3D view renders geometry relative to active WCS + - WCS integrated across G-code encoder, 2D/3D views, and GRBL drivers + - Ability to create set G-code offsets in machine settings + - Offline configuration of WCS settings and offsets +- True arc support and superior geometry handling + - DXF and SVG importers now preserve arcs and bezier curves + - Machine settings to configure arc support and tolerance + - Non-uniform scaling of designs with arcs handled gracefully +- Package Manager for extensibility + - Install, update, and manage extensions + - Automatic update checks on startup +- New Sketcher tools + - Rectangle tool with support for rounded rectangles + - Fillet tool for rounded corners + - Chamfer tool for beveled corners +- Machine connectivity improvements + - Configurable GRBL polling (disable during job runs) + - GRBL corruption detection + - GRBL error messages more descriptive + - Support for reading WCO and extended status fields +- User interface enhancements + - "Import whole image" checkbox for direct raster import + - Multi-layer SVG import option + - Supporter recognition section in About dialog + - Maintenance counter alerts link to maintenance counter page + - Sketch instances automatically added to document on creation + +### Changed + +- G-code generator now strips unneeded trailing zeros +- Diagonal jogging now jogs in a direct line instead of two separate commands +- Connection and device errors displayed prominently next to device selector +- Debug log button moved from log view to help menu +- Stock list and sketch list merged into unified asset list +- Importing complex DXF and SVG files is now significantly faster +- Makera Air G-code now uses inline power commands +- G-code dialect editor now checks variable existence and bracket balance + +### Fixed + +- Job Control & Safety + - Application getting stuck in "Running" state after job cancellation + - Race condition where driver alarms might self-clear +- Framing + - Runaway issue with framing position drifting cumulatively + - Framing bounds calculation for full circles +- Machine & Drivers + - Position reporting not updating until machine settings saved +- Import Fixes + - SVG import dialog not allowing direct vector import of SVGs without layers + - Vector misalignment when importing certain SVG files + - DXF import failing on files with blocks containing solid fills + - Raster image with tracing threshold set to 1 not importing full image +- Platform-Specific Fixes + - (Windows) Dialog closing passing focus to wrong window + - (Windows) PNG files not opening via file selector +- General Fixes + - Duplicate axis labels drawn on canvas + - Depth Engraver treating semi-transparent pixels as black + - Material test grid including invalid power on/off toggles + - Laser position indicator updating infrequently or moving wrong direction + - Incorrect position readout in Jog dialog + - Inconsistent button states during G-code job execution + - Recipes not saved correctly when creating from step settings dialog + - Multi-layer SVGs incorrectly imported as single layer + - Coordinate systems issues for machines with negative axes + - G-code dialect changes not applied without restart + - Selected laser parameters not loading initially in machine settings UI + - Imported SVGs not scaled correctly when resized non-uniformly + - 3D canvas turntable rotation broken + - Axis grid not aligned for machines without bottom left origin + - Tracebacks and crashes in machine settings dialog and G-code generation + - `pluggy` and `GitPython` dependencies not included in Debian package + +## [0.27.1] - Maintenance Release + +### Changed + +- Importing complex SVG files is now significantly faster through intelligent + path simplification + +### Fixed + +- SVG files with complex or invalid clipping paths rendered incorrectly +- Fills in sketches loaded from disk could not be toggled on or off +- On-screen position of laser dot not taking machine's configured origin into + account + +## [0.27] - Enhanced Sketching, Machine Control & UI Refinements + +### Added + +- Expressions and parameters in sketches + - Expression editor with syntax highlighting and auto-completion + - Instance parameters for each sketch instance +- Filled shapes support in sketcher +- Rounded rectangle tool +- Drag-select for multiple sketch elements +- Variable substitutions in preamble and postscript G-code sections +- Support for machines with top-right and bottom-right origins +- Negative axis support +- Configurable single-axis homing option +- Machine hours recording support +- Intro video to homepage + +### Changed + +- Sketches treated as "templates" that can be placed multiple times +- Sketch parameters have dedicated section in properties panel +- Construction line dash lengths measured in pixels for consistent look +- Double-clicking stock opens stock properties +- `Ctrl+N` shortcut for creating new sketch +- "Preferences" renamed to "Settings" +- "Edit Recipe" dialog uses three tabs: General, Applicability, Settings to Apply +- `ESC` and `Ctrl+W` close machine settings +- Machine settings menu entry cleaned up by removing "..." +- Raster import dialog renamed to "Import Dialog" +- Export sketch default location is original import location +- Many icons replaced with built-in icons +- Machine settings dialog redesigned for clearer layout +- Dark mode text readability improvements +- Asset list unified (stock and sketch lists merged) +- "New Sketch" button removed from main toolbar +- Main window no longer updates unnecessarily on workpiece transforms + +### Fixed + +- Import dialog not working correctly for many file formats +- Focus-related bugs in sketcher +- Sketches not resizable using drag and drop +- Editing sketch resetting instance's size on canvas +- Distance constraints not selectable or highlighting on hover +- Arcs going in wrong direction in sketch-generated geometry +- Titles from varset not properly escaped +- "Reverse axis" setting affecting G-code output +- Race condition in GRBL serial driver +- Boolean variables in device settings not applied +- Key text in simulation mode not readable in dark mode +- Popover not readable in dark mode in camera alignment dialog +- Laser dot drawn too large +- Varset variables with type Var not subclassed showing as "unsupported" +- Menu not closing when clicking surface +- Missing icons on some Linux distributions +- Race condition in tasker test +- Expression editor not closing when pressing enter +- Sketch not using input parameters +- Varset out of sync after var key rename +- Dragging sketches to surface failing +- Step list drag & drop reordering broken +- Sketches not positioned at center of sketcher surface when re-editing +- Excessive linearization precision causing lag +- Pyright detecting invalid `str` argument in machine dialect definition +- Perpendicular constraint hit detection +- Radius constraints not always recognized +- Constrained geometry not being green after loading sketch + +## [0.26] - Parametric 2D Sketcher & Major Performance Upgrades + +### Added + +- Parametric 2D Sketcher + - Create 2D geometry with lines, circles, and arcs + - Constraint system: Coincident, Vertical, Horizontal, Tangent, + Perpendicular, Point on Line/Shape, Symmetry, Distance, Diameter, + Radius, Equal Length/Radius + - Import/Export sketches with parametric constraints preserved + - Context-aware pie menu for quick tool access + - Keyboard shortcuts inspired by FreeCAD + - Full undo/redo support +- Adaptive precision grid based on zoom level +- Machine profile for Makera Carvera Air CNC machine + +### Changed + +- Default cut mode for vector CAM operations changed to 'Centerline' +- Significant performance optimizations for moving, scaling, rotating geometry +- Significantly reduced memory consumption +- Complex vector files handled more smoothly during toolpath generation + +### Fixed + +- 3D view and G-code output mirrored when using Y-down machine configuration +- Smoothieware driver issues +- `.dxf` files not visible in import dialog +- Save button incorrectly enabled when workflow is empty + +## [0.25] - Import Workflow Overhaul + +### Added + +- Interactive import dialog with pre-canvas configuration +- Enhanced tracing configuration in import dialog +- Threshold slider for contour operation +- "Object -> Split Workpiece" command +- Groups now have "natural size" property +- Support for configuring custom G-code dialects +- DXF layer support for importing as Groups + +### Changed + +- Tracing logic improved for transparent images +- Contour operation now processes inner edges before outer edges (configurable) +- Debouncing in processing pipeline for snappier UI + +### Fixed + +- Geometry incorrectly removed from vector inputs if cut side not "centerline" +- Items scaled down on import even when fitting machine area +- Step box displaying 0% power regardless of actual setting +- Workpiece operations not drawn correctly when grouping/ungrouping +- Imported images displayed with masks +- Notifications not cleared when user begins editing + +## [0.24] - 2025-11-07 + +### Added + +- Operation Recipes: Save and reuse operation settings (laser head, speed, + power, kerf) for specific operation types +- Automatic recipe selection based on operation type, machine, stock material, + and thickness +- Dedicated profile for xTool D1 Pro with updated start G-code macros (M17 and + M106) +- Support for configuring specific port numbers for Grbl connections +- Session-based log file system for easier troubleshooting and debugging +- Dialog to display metadata associated with imported images +- Dedicated option to toggle laser on at low power for easy focusing + +### Changed + +- Step settings dialog now has tabs, separating post-processing settings +- Windows distribution now uses "onedir" installer bundle for faster startup +- Machine branding strings updated from "Xtool" to "xTool" capitalization +- Macros can now be executed directly from main application menu + (Machine -> Macros) + +### Fixed + +- Various issues causing application crashes on Windows systems +- Race condition in logging setup that could lead to duplicate log entries +- Workpieces retaining stale operation settings after cut and paste to new layer +- "Remove inner edges" function failing to execute correctly +- Tracebacks when copying elements with tabs +- Incorrect tab placement during shrinkwrap or frame operations +- Segmentation fault during certain import scenarios +- Task bar remaining visible after completing import operations +- Global shortcuts incorrectly captured while editing text fields in workpiece + properties panel +- Logging reliability by ensuring all logs flushed before application exits + +## [0.23.2] - 2025-11-03 + +### Fixed + +- Crashes on Windows due to Gtk 4 API incompatibility +- Crash when sending job due to not running event loop + +## [0.23.1] - 2025-11-02 + +### Added + +- New Windows installer +- French translation + +### Changed + +- RayforgeContext object introduced for future API support +- ArtifactStore refactored + +### Fixed + +- Git command line briefly opening on Windows startup +- Test suite issues on Windows +- "Reset to natural size" buttons not working for DXF and Ruida imports +- Warning for resizing on import now persistent until dismissed +- Debouncing for some step settings +- Bug where two events in rapid succession could cause stale operations + +## [0.23] - 2025-10-25 + +### Added + +- G-code Viewer & Simulator with full playback and synchronized G-code text +- Depth Engraver operation for multi-pass engravings with varying depths +- Shrink Wrap operation for form-fitting contours +- Frame operation for rectangular frames around workpieces +- Material Test Grid tool for finding optimal power and speed settings +- Material Manager in settings for managing material libraries and materials +- Flip & Mirror tools for horizontal/vertical object flipping +- Engraving overscan option for maintaining constant velocity +- Offset and kerf compensation for cutting operations +- Native importers for JPEG, full-color PNG, and BMP files +- Jog controls dialog for manual laser positioning +- Multi-head laser support with step assignment to specific heads +- Cross-hatch fill option for Raster Engraving operation +- Stock material assignment to individual layers +- Machining time estimate in status bar with progress and ETA during execution +- Preferred length unit setting in preferences +- Machine acceleration values in machine profiles +- Snap to grid functionality (Ctrl key while moving/rotating) +- Adjustable rasterizer threshold with undo/redo support +- French translation + +### Changed + +- Backend almost completely redesigned for performance +- Task manager redesigned around process pool +- Toolpath optimizer significantly faster and enabled by default +- Rendering pipeline uses shared memory for faster data transfer +- Tracing engine switched to vtracer for higher quality +- Stock handling completely redesigned +- "Edge" and "Outline" producers merged into single "Contour" operation +- Higher baud rates supported for serial connections +- GRBL streaming protocol supported + +### Fixed + +- Dependencies not correctly installed when installing with pip +- 3D view not updating correctly when toggling step visibility +- Contour operation now produces path even if offsetting fails +- Traceback when zooming into large workpiece +- Alignment issues across all importers +- PDF importer clipping direction bug +- SVG importer vector alignment problems + +## [0.22] - 2025-10-01 + +### Added + +- Tabbing system for holding parts in place during cutting + - Flexible configuration (global or per-step) + - Automatic placement + - Manual control via context menu + - Interactive editing with drag handles +- G-code macros and hooks with variable substitution +- Direct SVG vector import option + +### Changed + +- Work surface panning smoother +- Grouping and ungrouping faster +- Main menu and toolbar reorganized with keyboard shortcuts +- Global preference for UI speed units +- Step settings dialog closes with Esc or Ctrl+W +- Workpieces automatically scaled down if too large for surface +- DXF and Ruida imports pre-split into component parts +- Auto-layouter respects stock boundaries +- PDF importer auto-crops to content + +### Fixed + +- Select All (Ctrl+A) not selecting groups correctly +- Misleading error for non-existent serial ports +- Task manager warning on shutdown +- Auto-layouter 90° rotation bug + +## [0.21] - 2025-09-14 + +### Added + +- Micrometric resolution support for imports and G-code generation +- Configurable decimal places in G-code output +- Stock material area definition +- Device alarm reset button for GRBL machines +- Automatic alarm reset on connection option +- Official PPA for Ubuntu + +### Changed + +- Snap package now supports GRBL serial port connections +- Camera backend on Linux defaults to V4L2 +- New icons for layers +- Workpiece properties panel reorganized +- Connection and device status messages now translated + +### Fixed + +- 3D editor switch not working +- Device drivers not shutting down correctly on close +- Flickering "RUN" status with GrblSerial devices + +## [0.20.2] - 2025-08-20 + +### Fixed + +- ImportError when opening the app + +## [0.20.1] - 2025-08-20 + +### Added + +- Context menu on work surface (right-click) + +### Changed + +- Baud Rate field now a dropdown + +### Fixed + +- Crash during job execution +- Serial Grbl driver issues +- USB port selection lost when machine disconnected +- Layer list rendering artifacts +- Camera view alignment + +## [0.20] - 2025-08-19 + +### Added + +- 3D G-Code Previewer with orbit, pan, zoom controls +- Z Step Down per Pass option +- DXF importer with full geometry support +- Ruida (.rd) importer +- Auto-Layout tool +- Shear tool for skewing workpieces +- Grouping and ungrouping support + +### Changed + +- ESC key deselects items on canvas +- Smoothing algorithm replaced with more effective version +- Operation generation more efficient + +### Fixed + +- Invalid G-code when travel speed not set +- On-screen laser dot position slightly misplaced +- Resizing multiple selected objects incorrectly in Y-up mode +- Export Debug Log not working +- Creating new machine from profile failing +- Windows installer issues + +## [0.19.1] - 2025-08-08 + +### Fixed + +- Traceback on Windows startup +- Missing icons on Windows + +## [0.19] - 2025-08-??? + +### Added + +- New GRBL drivers (Network and Serial Port) with firmware settings UI +- Canvas alignment tools (top, bottom, left, right, center) +- Object distribution tools (horizontal, vertical) +- Ctrl+PageUp/PageDown for moving objects between layers +- Themed icon support for light/dark themes +- Ctrl + < shortcut for machine settings +- Spanish translation + +### Changed + +- Canvas and camera stream performance improved +- Default camera overlay opacity 20% +- Status icons replaced with modern symbolic icons + +### Fixed + +- "Remove All Workpieces" button not working +- Worksteps unnecessarily regenerated when unrelated step removed +- "Smoothness" slider not functional +- Canvas grid aspect ratio issue +- Rendering failure with large on-screen dimensions +- Camera toggle not updating canvas immediately +- On-screen laser dot position + +## [0.18.4] - 2025-08-04 + +### Fixed + +- Custom postscript used even if disabled +- Machine config lost on startup (race condition) +- Home, Pause and Cancel buttons not working +- Laser dot shown in wrong position when zoomed + +## [0.18.3] - 2025-08-03 + +### Added + +- --version CLI flag + +### Changed + +- Sliders in workstep settings now smoother (debouncing) + +### Fixed + +- Ops not removed when deleting workpiece +- Performance: removing step no longer re-generates all steps +- Subtitle not showing in driver selection if no driver initially selected + +## [0.18.2] - 2025-08-03 + +### Added + +- Experimental GRBL driver + +### Fixed + +- Performance regression for canvas rendering + +### Changed + +- Workstep settings dialog can be moved + +## [0.18.1] - 2025-08-03 + +### Fixed + +- Camera stream not disabling when switching machines +- Performance regression for canvas rendering + +## [0.18] - 2025-08-03 + +### Added + +- Layer support +- Multi-machine support +- Machine profiles (Sculpfun iCube, Other) +- G-code dialects (Marlin, GRBL, Smoothieware) +- Theme preferences with dark mode improvements +- Debug information collection button in machine view + +### Changed + +- Main window panel redesigned +- Paths more precise with reduced rounding errors +- Smoothing algorithm polished +- Camera rendering speed massively improved + +### Fixed + +- Boundary alignment for rastering with chunked images +- Smoothing angle threshold up to 179 degrees +- Travel optimizer running when disabled +- Driver description not shown in dropdown subtitle + +## [0.17] - 2025-07-28 + +### Added + +- Undo and redo support for all actions +- Main menu at top of window +- Copy, cut, paste support +- Ctrl+D duplicate shortcut +- Multiple selection in canvas +- Select-by-frame support +- Flipped Y-axis support +- About dialog with version info + +## [0.16.2] - 2025-07-25 + +### Fixed + +- Non-square work surfaces shown as square (now proper aspect ratio) + +## [0.16.1] - 2025-07-25 + +### Fixed + +- Path disappearing when zooming + +## [0.16] - 2025-07-25 + +### Added + +- Improved resize tool +- Workpiece rotation +- Path smoothing option in work step dialog +- Better progress bar with status messages +- Progress shown during export operations + +### Changed + +- Canvas now displays travel move optimization result +- Worksteps processed in parallel +- Larger surfaces supported via tiling (removes 32,000 x 32,000 limit) + +### Fixed + +- Numerous Windows EXE bugs + +## [0.15] - 2025-07-??? + +### Added + +- Camera alignment UI with on-screen editing +- German and Portuguese languages +- Smoothieware support (via Telnet) + +### Fixed + +- Many Windows EXE bugs, test suite now passes in CI/CD + +## [0.14] - 2025-07-12 + +### Added + +- Camera configuration (USB cameras via OpenCV) +- Live feed picture overlay on canvas +- Image settings (white balance, brightness, contrast, transparency) +- Image alignment and de-distortion support + +## [0.13] - 2025-07-10 + +### Added + +- GRBL serial connection support +- Workpiece properties panel for precise position and dimensions +- Experimental Windows installer diff --git a/MANIFEST.in b/MANIFEST.in index 9af4ba6fc..6e78d48b3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,3 @@ recursive-include rayforge/resources * +recursive-include rayforge/locale *.mo +recursive-include rayforge/builtin_addons *.py *.yaml *.po *.pot *.mo *.png *.jpg *.jpeg *.gif *.svg *.ico diff --git a/README.md b/README.md index a5faad324..1b891e5ad 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,121 @@ -# Rayforge - -Rayforge is a software for laser cutters and engravers. -It supports direct communication with GRBL based machines. - -![Screenshot](docs/ss-main.png) - - -## Installation - -### Linux - -On Linux the only currently supported method is Snap: +[![GitHub Release](https://img.shields.io/github/release/barebaric/rayforge.svg?style=flat)](https://github.com/barebaric/rayforge/releases/) +[![PyPI version](https://img.shields.io/pypi/v/rayforge)](https://pypi.org/project/rayforge/) +[![Snap Release](https://snapcraft.io/rayforge/badge.svg)](https://snapcraft.io/rayforge) +[![Launchpad PPA](https://img.shields.io/badge/PPA-blue)](https://launchpad.net/~knipknap/+archive/ubuntu/rayforge) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Get it from the Snap Store](https://snapcraft.io/en/light/install.svg)](https://snapcraft.io/rayforge) +Get it from Flathub +Become a Patron -You can also install it through PIP if you know what you are doing. Something like this: - -``` -sudo apt install python3-pip-whl python3-gi gir1.2-gtk-3.0 gir1.2-adw-1 libgirepository-1.0-1 libgirepository-2.0-0 - -pip3 install rayforge -``` - -### Other operating systems - -There is currently no installer for other operating systems - contributions are -welcome, in the form of Github workflow actions or build instructions. - -If you know what you are doing, you may be able to install manually using -PIP on Windows or Mac - the source code should be fully cross-platform. - +# Rayforge -## Features +Rayforge is a modern, cross-platform 2D CAD, G-code sender and control software for GRBL, Marlin, Ruida, and +Smoothieware-based laser cutters and engravers. +Built with Gtk4 and Libadwaita, it provides a clean, native interface for Linux, MacOS and Windows, offering a full suite of tools +for both hobbyists and professionals. + +![Screenshot](website/static/screenshots/main-3d-rotary.png) + +You can also check the [official Rayforge homepage](https://rayforge.org). +We also have a [Discord](https://discord.gg/sTHNdTtpQJ). + +## Key Features + +### Design & Editing + +| Feature | Description | +| :--------------------------- | :-------------------------------------------------------------------------------------- | +| **Parametric Sketch Editor** | Create precise, constraint-based 2D designs with geometric and dimensional constraints. | +| **Comprehensive 2D Canvas** | Full suite of tools: alignment, transformation, measurement, zoom, pan, and more. | +| **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. | +| **Project Files (.ryp)** | Compressed project format preserving all assets, layers, and configurations. | + +### Operations & Toolpaths + +| Feature | Description | +| :--------------------------- | :--------------------------------------------------------------------------------------------------- | +| **Versatile Operations** | Supports Contour, Raster Engraving (with cross-hatch fill), Shrink Wrap, Depth Engraving, and Frame. | +| **2.5D Cutting** | Multi-pass cuts with configurable step-down for thick materials. | +| **True 4th Axis Support** | Full rotary axis support - as 4th axis, or axis replacement mode for hobby machines. | +| **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. | +| **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. | + +### Machine Control + +| Feature | Description | +| :------------------------------ | :--------------------------------------------------------------------------------------------- | +| **Multi-Machine Profiles** | Configure and instantly switch between multiple machine profiles. | +| **Device Profiles** | Declarative device packages with import/export for sharing configurations. | +| **Work Coordinate Systems** | 6 WCS (G54-G59) with per-layer assignment for cutting at different offsets. | +| **No-Go Zones** | Define restricted areas with collision detection before sending G-code. | +| **Machine Hours & Maintenance** | Track operating hours with configurable maintenance counters and notification thresholds. | +| **GRBL Firmware Settings** | Read and write firmware parameters (`$$`) directly from the UI. | +| **Arc & Bezier Curves** | Native G2/G3 arc and G5 bezier curve support with automatic linearization. | +| **Multi-Laser Operations** | Choose different lasers for each operation in a job. | +| **G-code Dialects** | Supports GRBL, Smoothieware, Marlin, LinuxCNC, Mach4, and custom dialects via built-in editor. | +| **G-code Macros & Hooks** | Run custom G-code snippets before/after jobs. Supports variable substitution. | +| **Pre-flight Checks** | Validates bounds, work area, and no-go zone collisions before sending a job. | +| **G-code Console** | Interactive console with syntax highlighting and search. | + +### Materials & Presets + +| Feature | Description | +| :----------------------- | :---------------------------------------------------------------------------------------------- | +| **Material Library** | 60+ built-in materials across categories with search and user-created material libraries. | +| **Recipe/Preset System** | Auto-matching presets by material, thickness, machine, and laser head with specificity scoring. | +| **Material Test Grid** | Generate power/speed test grids to find optimal laser settings for a given material. | + +### Workflow & Automation + +| Feature | Description | +| :-------------------------- | :-------------------------------------------------------------------------------------------- | +| **Camera Integration** | USB camera for workpiece alignment, positioning, background tracing, and fisheye calibration. | +| **AI Workpiece Generation** | Generate SVG workpieces from text prompts using OpenAI-compatible AI providers. | +| **Print & Cut Alignment** | Align cuts to printed material using registration marks with a guided wizard. | +| **Headless/CLI Mode** | Worker-only mode without UI for batch processing and automation. | +| **Projector Mode** | Project toolpaths onto your machine bed for alignment. | + +### Platform & Extensibility + +| Feature | Description | +| :----------------- | :------------------------------------------------------------------------------------------------------- | +| **Modern UI** | Polished UI built with Gtk4 and Libadwaita. Supports system, light, and dark themes. | +| **Addon System** | Built-in addon manager for installing and managing community extensions. | +| **Extensible** | Open development model makes it easy to [add support for new devices](website/docs/developer/driver.md). | +| **Cross-Platform** | Native builds for Linux, Mac and Windows. | +| **Multi-Language** | Available in English, Portuguese, Spanish, German, French, Ukrainian, and Chinese. | +| **Update Checker** | Automatic background check for new versions via the GitHub Releases API. | + +### Device Support + +| Device Type | Connection Method | Notes | +| :--------------- | :---------------------- | :------------------------------------------------------------- | +| **GRBL** | Serial Port | Supported since version 0.13. The most common connection type. | +| **GRBL** | Telnet | Supported since version 0.16. | +| **GRBL** | Network (WiFi/Ethernet) | Connect to any GRBL device on your network. | +| **Smoothieware** | Telnet | Supported since version 0.15. | +| **Marlin** | Serial Port | Supported since version 1.7.2. | +| **Ruida** | Network (UDP) | Connect to Ruida-based controllers via UDP. | +| **OctoPrint** | Network (HTTP API) | Connect through an OctoPrint server. | -| Feature | Description | -| -------------------------------- | ------------------------------------------------------- | -| Intuitive user interface | Drag & drop reordering, focus on essentials | -| Multi step operations | For example, first engrave, then cut | -| Mutltiple operation types | Countour, External Outline, Raster Engraving | -| High quality path generation | Interpolation based on spot size, path optimization | -| Multiple input formats | SVG, DXF, PDF, and PNG import are supported | -| Direct device support | Easily [add support for your own laser](docs/driver.md) | -| Much more | Framing, support for air assist, control buttons, ... | +## Installation +For installation instructions [refer to our homepage](https://rayforge.org/docs/getting-started/installation). ## Development -Setup: -``` -sudo apt install python3-pip-whl python3-gi gir1.2-gtk-3.0 gir1.2-adw-1 libgirepository-1.0-1 libgirepository-2.0-0 -git clone git@github.com:barebaric/rayforge.git -cd rayforge -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -``` +For detailed information about developing for Rayforge, including setup instructions, +testing, and contribution guidelines, please see the +[Developer Documentation](https://rayforge.org/docs/developer/getting-started). -### Driver development +## License -If you want to develop a driver to support your machine with Rayforge, -please check the [driver development guide](docs/driver.md). +This project is licensed under the **MIT License**. See the `LICENSE` file for details. diff --git a/RELEASE_MANIFEST.md b/RELEASE_MANIFEST.md new file mode 100644 index 000000000..5b11eed0e --- /dev/null +++ b/RELEASE_MANIFEST.md @@ -0,0 +1,54 @@ +# Release Agent Manifest + +## Purpose + +The Release Agent automates the preparation of video content for software releases. +It bridges the gap between changelog data and Blender-based video editing by +generating scripts and assets for the release workflow. + +## Workflow Overview + +When instructed to "Prepare a release", the agent executes the following pipeline: + +### Phase 1: Context Analysis + +1. **Read CHANGELOG.md** to extract version information and release notes +2. **Read git log** to identify commit history and changes +3. **Identify media assets** in `media/[release]/` directory to learn about the style and language of the content on each platform (reddit, github, patreon) +4. **Read Patreon supporters** read `media/supporters.md` for the current list of supporters +5. Research the web using the webReader tool, for any recent mentions of Rayforge, to identify criticism or praise. See if you can incorporate this into the + content in phase 2 - not by directly adressing it, but to understand what users care about. + +### Phase 2: Content Drafting + +Generate the following files in `media/[release]/drafts/`: + +- `youtube.txt` - Text for the YouTube release video description +- `reddit_post.md` - Formatted post for Reddit, following the tone of the previous Reddit posts. Include credits to paying Patreon supporters. +- `patreon_post.md` - Formatted post for Patreon, following the tone of the previous Patreon posts +- `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`) +- 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. + Update the documentation accordingly, but keep it user-centric - this is not intended as developer documentation. +- Depending on the changes listed in the changelog, re-create relevant screenshots for the docs using the `scripts/media/take_screenshot.py` tool. + +Style-wise, avoid using bullet-point style lists - use a friendly and approachable style that users enjoy reading. +In the text files, use a maximum line length of 100 chars. +Ensure you use proper links for discord and patreon and the homepage and github, not placeholders. + +### Phase 3: User manually creates clips with audio + +Clips will be stored in `media/[release]/raw/*.mp4`. The user may also use other formats such as mkv or avi, or plain audio files such as wav or mp3 or aac. + +### Phase 4: Audio Preprocessing (FFmpeg) + +Generate and execute audio processing commands using the existing script: + +```bash +pixi run process-audio -o media/[release]/processed/ media/[release]/raw/*.mp4 +``` + +Output: `media/[release]/processed/` with processed video files diff --git a/Rayforge.spec b/Rayforge.spec new file mode 100644 index 000000000..e7946a1bf --- /dev/null +++ b/Rayforge.spec @@ -0,0 +1,83 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +from PyInstaller.utils.hooks import collect_submodules + +hiddenimports = ['gi._gi_cairo', 'cairosvg'] +hiddenimports += collect_submodules('rayforge.ui_gtk.canvas2d') +hiddenimports += collect_submodules('rayforge.ui_gtk.canvas2d.elements') +hiddenimports += collect_submodules('rayforge.ui_gtk.shared') +hiddenimports += collect_submodules('rayforge.image') +hiddenimports += collect_submodules('rayforge.core') +hiddenimports.append('rayforge.ui_gtk.canvas2d.elements.workpiece') + +# Use modern .icon (via Assets.car) when available, fall back to .icns. +_use_car = os.path.exists('Assets.car') +_icon = None if _use_car else 'rayforge.icns' + +_datas = [ + ('rayforge/version.txt', 'rayforge'), + ('rayforge/resources', 'rayforge/resources'), + ('rayforge/locale', 'rayforge/locale'), + ('rayforge/builtin_addons', 'rayforge/builtin_addons'), +] +if _use_car: + _datas.append(('Assets.car', '.')) + +a = Analysis( + ['rayforge/app.py'], + pathex=['.'], + binaries=[], + datas=_datas, + hiddenimports=hiddenimports, + hookspath=['hooks'], + hooksconfig={ + 'gi': { + 'module-versions': { + 'Gtk': '4.0', + 'Adw': '1', + }, + }, + }, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Rayforge', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=[_icon] if _icon else [], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Rayforge', +) +app = BUNDLE( + coll, + name='Rayforge.app', + icon=_icon, + bundle_identifier='org.rayforge.rayforge', + info_plist={ + **({'CFBundleIconName': 'rayforge'} if _use_car else {}), + 'LSMinimumSystemVersion': '12.0', + }, +) diff --git a/__builtins__.pyi b/__builtins__.pyi new file mode 100644 index 000000000..12b62f140 --- /dev/null +++ b/__builtins__.pyi @@ -0,0 +1 @@ +_ = str diff --git a/com.barebaric.rayforge.yml b/com.barebaric.rayforge.yml deleted file mode 100644 index 214a1efab..000000000 --- a/com.barebaric.rayforge.yml +++ /dev/null @@ -1,32 +0,0 @@ -app-id: com.barebaric.rayforge -runtime: org.gnome.Platform -runtime-version: '47' -sdk: org.gnome.Sdk -command: rayforge - -finish-args: - - --share=ipc - - --socket=wayland - - --socket=fallback-x11 - - --device=all - - --env=DISPLAY=:0 - - --share=network - - --talk-name=org.freedesktop.DBus - -modules: - - flatpak/python3-meson-python.json # This is a numpy/scipy requirement - - flatpak/python3-scikit-build.json # opencv-python requirement - - flatpak/python3-pythran.json # scipy-python requirement - - flatpak/python3-pybind11.json # scipy-python requirement - - flatpak/python3-openblas.json # scipy-python requirement - - flatpak/python3-setuptools.json - - flatpak/python3-setuptools-git-versioning.json - - flatpak/python3-requirements.json - - - name: rayforge - buildsystem: simple - build-commands: - - pip3 install --prefix=/app --no-deps --no-build-isolation . - sources: - - type: dir - path: . diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..60134f4d4 --- /dev/null +++ b/conftest.py @@ -0,0 +1,15 @@ +import sys +from pathlib import Path + +_root_dir = Path(__file__).parent +_builtin_addons = _root_dir / "rayforge" / "builtin_addons" +_private_addons = _root_dir / "rayforge" / "private_addons" + +for _addon_dir in [_builtin_addons, _private_addons]: + if not _addon_dir.exists(): + continue + for _addon_path in _addon_dir.iterdir(): + if _addon_path.is_dir(): + _resolved = _addon_path.resolve() + if str(_resolved) not in sys.path: + sys.path.insert(0, str(_resolved)) diff --git a/data/com.barebaric.rayforge.desktop b/data/com.barebaric.rayforge.desktop deleted file mode 100644 index 73ed49965..000000000 --- a/data/com.barebaric.rayforge.desktop +++ /dev/null @@ -1,11 +0,0 @@ -[Desktop Entry] -Version=1.0 -Type=Application - -Name=Rayforge -Comment=Laser cutting and engraving -Categories=AudioVideo;GTK; - -Icon=com.barebaric.rayforge -Exec=rayforge -Terminal=false diff --git a/data/com.barebaric.rayforge.metainfo.xml b/data/com.barebaric.rayforge.metainfo.xml deleted file mode 100644 index e60209143..000000000 --- a/data/com.barebaric.rayforge.metainfo.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - com.barebaric.rayforge - - Samuel Abels - - - https://github.com/barebaric/rayforge - https://github.com/barebaric/rayforge/issues - - Rayforge - A desktop application for laser cutting and engraving - - - Utility - Engineering - - - MIT - MIT - - -

- Rayforge is a desktop application for laser cutting and engraving. - It provides an intuitive interface to create and manage laser - cutting projects. - Rayforge support communicating with GRBL based laser cutters. - Rayforge supports importing SVG, DXF, PDF, and PNGs, and provides - advanced features such as multi step jobs, automatic path - optimization, interpolation based on laser dot size, and much more. -

-
- - com.barebaric.rayforge.desktop - - - keyboard - pointing - 768 - - - share/icons/hicolor/scalable/apps/com.barebaric.rayforge.svg - - - #efb0a1 - #373f43 - - - - - The main window of Rayforge. - https://raw.githubusercontent.com/barebaric/rayforge/main/docs/ss-main.png - - - - - - -

This is the initial release.

-
-
-
-
diff --git a/data/com.barebaric.rayforge.svg b/data/com.barebaric.rayforge.svg deleted file mode 100644 index 8b04fb707..000000000 --- a/data/com.barebaric.rayforge.svg +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/data/org.rayforge.rayforge.desktop b/data/org.rayforge.rayforge.desktop new file mode 100644 index 000000000..953a7b201 --- /dev/null +++ b/data/org.rayforge.rayforge.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Type=Application + +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; + +Icon=org.rayforge.rayforge +Exec=rayforge +Terminal=false diff --git a/data/org.rayforge.rayforge.metainfo.xml b/data/org.rayforge.rayforge.metainfo.xml new file mode 100644 index 000000000..f5b49eadd --- /dev/null +++ b/data/org.rayforge.rayforge.metainfo.xml @@ -0,0 +1,1539 @@ + + + org.rayforge.rayforge + + Samuel Abels + + + https://rayforge.org + https://github.com/barebaric/rayforge/issues + https://github.com/barebaric/rayforge + + Rayforge + A desktop application for laser cutting and engraving + + + Utility + Engineering + + + MIT + MIT + + rayforge + + +

+ Rayforge is a desktop application for laser cutting and engraving. + It provides an intuitive interface to create and manage laser + cutting projects. + Rayforge support communicating with GRBL based laser cutters. + Rayforge supports importing SVG, DXF, PDF, and PNGs, and provides + advanced features such as multi step jobs, automatic path + optimization, interpolation based on laser dot size, and much more. +

+
+ + org.rayforge.rayforge.desktop + + + application/x-rayforge-project + application/x-rayforge-sketch + application/x-ruida + image/png + image/bmp + image/jpeg + image/svg+xml + image/vnd.dxf + + + + keyboard + pointing + 768 + + + https://raw.githubusercontent.com/barebaric/rayforge/refs/heads/main/rayforge/resources/icons/org.rayforge.rayforge.svg + + + #efb0a1 + #373f43 + + + + + The main window of Rayforge. + https://raw.githubusercontent.com/barebaric/rayforge/main/website/static/screenshots/main-standard.png + + + + + + +

Bug Fixes:

+
    +
  • Loading projects that store a null opsproducer_dict no + longer crashes (e.g. when an engrave step has no legacy + producer parameters)
  • +
  • Renaming a step now updates the step list in the main + window's right pane immediately
  • +
+
+
+ + +

Experimental CNC machining operations, more flexible recipes, and + G-code fixes

+

New Features:

+
    +
  • New "CNC Essentials" addon with CNC machining operations: + adaptive clearing, flat spiral, helix plunge, inner and outer + profiling, ramp entry, slotting, and toroidal clearing + (disabled by default; enable it in the addon manager)
  • +
  • Pipeline progress now shows the currently running operation + with a friendly, translatable status label instead of an + internal name
  • +
+

Improvements:

+
    +
  • Recipes now target one or more step types instead of a + single capability; the recipe editor gained a searchable + step-type selector, and existing recipes are migrated + automatically
  • +
  • Post-processor settings (lead-in/out, multipass, overscan) + can now be stored per recipe and applied to the targeted + steps
  • +
  • Show a confirmation dialog when enabling experimental addons
  • +
  • Upgrade raygeo to 1.38.3
  • +
+

Bug Fixes:

+
    +
  • Generated G-code could be wrong when axis reversal or a + non-bottom-left origin was combined with a WCS offset
  • +
  • Air assist settings in laser steps were not emitted to the + G-code (M8/M9)
  • +
+
+
+ + +

Faster playback speeds, improved 3D preview, and memory + improvements

+

New Features:

+
    +
  • Playback speeds up to x64 in simulated playback
  • +
  • 3D preview renders raster scanlines at the physical laser + dot width for a more accurate preview
  • +
  • Addon-contributed settings pages now update live when the + settings dialog is open
  • +
  • Addon manifests support a default enabled/disabled + state
  • +
+

Improvements:

+
    +
  • Upgrade raygeo to 1.37.0
  • +
  • Memory improvements in the pipeline: op data now uses a + compressed array, assembly intermediates are released between + builds, the final job ops are no longer cached, and a + kinematic mapping is only computed when a rotary module is + present
  • +
+

Bug Fixes:

+
    +
  • 3D canvas panning now follows the mouse 1:1
  • +
  • Cylinder angle interpolation during rotary animation could + be incorrect
  • +
  • 3D models could obscure ops in the 3D view
  • +
  • Raster preview artifacts when zooming out (moire) fixed + with max-reduction mipmaps
  • +
  • Toolpath and scanline trail drawn above the raster + texture
  • +
  • 3D canvas not grabbing keyboard focus when clicked
  • +
  • Right panel could obscure the canvas overlays
  • +
  • 3D model loading errors no longer crash the app
  • +
  • Machine switch config update now runs on the main + thread
  • +
  • --exit watcher is only armed after the uiscript has + run
  • +
+
+
+ + +

Smooth simulated playback, refined 3D interaction, and + rotary simulation fixes

+

New Features:

+
    +
  • Simulated playback now advances by simulated machine time + at (approximately) real machine speed, with a 1x-16x speed + multiplier: the toolpath reveal, laser head, and laser beam + interpolate within each command so playback is smooth instead + of stepping one command per frame
  • +
  • Step forward/backward buttons glide to the next command + over a short fixed duration instead of jumping; rapid clicks + coalesce into a single glide that covers the net number of + commands
  • +
  • Zoom and orbit now rotate around the point under the + cursor
  • +
  • Playback controls (play, step, speed, slider) shown as a + bar below the 3D canvas
  • +
  • The 2D and 3D canvas grids draw in the preferred length + unit
  • +
  • Warn when a project uses cooling methods not supported by + the current machine
  • +
  • Recipe manager shows the selected step in the recipe + description
  • +
+

Improvements:

+
    +
  • Upgrade raygeo to 1.33.0: simulated playback now runs at + accurate machine speed, ops no longer move slightly through + the cylinder during rotary simulation, and stroke-only cut + lines (e.g. engraved crosshairs in imported SVGs) are no + longer missing from the generated ops
  • +
  • Internal: 3D canvas refactored into a scene presenter, + camera controller, playback overlay, renderer registry, and + chunked upload controller
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Texture alpha no longer brightens after a layer + completes
  • +
  • Laser beam rendered over the scanline ring buffer
  • +
  • Scanline overlay stays visible after playback + completes
  • +
  • Legacy opsproducer step parameters migrated when loading + projects
  • +
  • Step settings refresh when a recipe is applied
  • +
  • Material colors applied per-widget in lists
  • +
  • Simple GRBL driver wakes an in-flight ping-pong on + cancel
  • +
  • 3D canvas background falls back to the theme view + background color
  • +
  • Frequency and pulse width preserved in MachineState.copy
  • +
+
+
+ + +

Unit system support, unit-aware inputs, and raster fixes

+

New Features:

+
    +
  • Unit system support: metric/imperial selection in the + machine settings, with automatic unit-system detection for + GRBL (from $13) and Marlin (via M149) drivers and in the + configuration wizard
  • +
  • Length, speed, and acceleration inputs are now unit-aware: + they convert between the configured display unit and base + units, update live when the display unit changes, and show the + unit as a tooltip
  • +
  • The 2D and 3D canvas grids now follow the user's preferred + length unit: grid lines snap to multiples of that unit and + axis labels are displayed in it, updating live when the + preference changes
  • +
  • Generic service registry and settings-page hooks so addons + can publish key-resolved services and contribute their own + pages to the Settings dialog
  • +
+

Improvements:

+
    +
  • Addon manifest requires are now enforced at load time with + a topological pass so dependencies load before dependents
  • +
  • Bump addon API version to 18
  • +
  • Upgrade raygeo to 1.32.1
  • +
+

Bug Fixes:

+
    +
  • Raster engraving could be rendered up to one pixel smaller + than the workpiece size due to pixel-count truncation (raygeo + 1.32.1), which could be larger if the workpiece is scaled
  • +
+
+
+ + +

Unified machine wizard, SVG color layers, and raster + fixes

+

New Features:

+
    +
  • Unified machine configuration wizard with AI-powered + device spec lookup
  • +
  • Import SVG colors as layers
  • +
  • Color rules that map SVG colors to step types, with a + settings page to manage the rules
  • +
  • Recipes can now target specific step types
  • +
  • Assembly warnings (e.g. failed faces or regions) surfaced + as toast notifications
  • +
  • Right-click context menu on steps in the layer workflow + strip with a delete option
  • +
+

Improvements:

+
    +
  • Kerf and path offset merged into a single offset setting, + defaulting to half the laser head spot size
  • +
  • Raster power range on engrave steps renamed to min/max + power level so it no longer clashes with the hardware max + power setting
  • +
  • Upgrade raygeo to 1.31.2 (SVG color layer import, + multi-face parts, and fixed SVG defs/use traversal)
  • +
  • Bump pypdf to 6.14.2, GitPython to 3.1.58, and aiohttp to + 3.14.3 to fix security vulnerabilities
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • 3D toolpaths drawn at full brightness on first open + instead of power-dimmed
  • +
  • Raster full-sweep mode no longer engraves empty masked + regions at full power (raygeo 1.31.2)
  • +
  • Raster multi-pass mode no longer mixes Z levels when + optimizing, and cross-hatch now interleaves both angles per + pass instead of running all passes of one angle before the + other
  • +
  • CNC step attributes no longer dropped from project files + on save
  • +
  • ChArUco detection failing on some array shapes (camera + calibration, by trixdaddy)
  • +
  • Intent-rebuild hot loop on documents without workflow + content
  • +
  • Missing features dialog now reports the original step + type
  • +
  • Restored macOS Monterey-compatible bundles (by + pgilfernandez)
  • +
  • Replaced deprecated GTK CSS APIs
  • +
  • Icons that fell back to the system theme (which breaks on + some platforms) now ship with the app
  • +
+
+
+ + +

Array/Pattern tool, raygeo compute pipeline, 3D canvas + performance, and bug fixes

+

New Features:

+
    +
  • Array / Pattern tool with Grid, Point Rotation, and + Circular modes
  • +
  • Dot width correction for raster engraving (#316, + by vyvcodd)
  • +
  • LightBurn import: support for importing raster settings + (dotWidth, interval, angle, scan_angle)
  • +
  • Allow renaming layers and steps directly in the + layer/step settings dialogs
  • +
  • Asyncio support for parallel workpiece processing in + the pipeline
  • +
  • Configurable pipeline cache budget in settings
  • +
  • Error notifications for pipeline failures
  • +
+

Improvements:

+
    +
  • Upgrade raygeo to 1.27.0 with migrated G-code encoder, + BidirScanOffsetTransformer, and MultiPassTransformer to + Rust; transformer application now uses Rust + apply_transformers dispatch
  • +
  • Replaced multiprocessing pipeline with raygeo intent + orchestration: compute, raster, shrinkwrap, wavefront, + contour, and view rendering now run in raygeo threads + instead of subprocesses for improved performance and + reliability
  • +
  • Rewrote 3D scene compiler to use Rust compile_scene_3d + with chunked GL upload for improved 3D canvas rendering + performance
  • +
  • Pipeline cache is now preserved across document and + machine swaps for faster rebuilds
  • +
  • Improved addon translation fallback: English is now used + when no matching locale is found
  • +
  • Wavefront icon and improved Gtk SVG compatibility for + other icons
  • +
  • Bump pypdf to 6.13.3
  • +
  • Bump GitPython to 3.1.51
  • +
+

Bug Fixes:

+
    +
  • Blank sketcher UI text on packaged installs (#315)
  • +
  • Dragging of layers now works correctly
  • +
  • Use persistent /dev/v4l/by-id/ paths for camera + identification on Linux (#318)
  • +
  • Spinrow input field too narrow in some cases
  • +
  • Settings widget auto-value bugs: raster/wavefront sliders + showing wrong defaults, overscan Automatic Distance switch + permanently greyed out, and auto overscan/lead-in-out + distance recalculating to a smaller value on toggle + (#314, by vyvcodd)
  • +
  • Fixed job generation hangs in the pipeline
  • +
  • Fixed in-flight intent not cancelling on force_rebuild
  • +
  • Fixed 3D rotary rendering missing mapped operations
  • +
  • Fixed rotary module fallback not triggering pipeline + rebuild on machine changes
  • +
  • Fixed a race condition on Windows
  • +
+
+
+ + +

raygeo upgrade, group selection improvements

+

Improvements:

+
    +
  • Upgrade raygeo to 1.21.3 fixing adaptive wavefronts + generating wave duplicates and mask_scan/dither raster + mode ignoring step_power
  • +
  • Group selections in the properties panel no longer reset + relative positions, angles, and transformations between + grouped workpieces (#311)
  • +
+
+
+ + +

Assembler registry refactor, material test grid enhancements, + and bug fixes

+

New Features:

+
    +
  • Speed vs Offset mode in the material test grid for empirical + bidirectional offset calibration (#312) by Github user + vyvcodd.
  • +
+

Improvements:

+
    +
  • Major pipeline refactor: replace OpsProducer with assembler + registry; step settings now read/write step attributes directly + (#309)
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Upgrade raygeo to 1.12.2 to fix label power in material test + grid
  • +
  • Text from addons not being translated properly
  • +
+
+
+ + +

Language selector, selection drag handle, and performance + improvements

+

New Features:

+
    +
  • Language selector in General settings to change UI language + at runtime (#303)
  • +
  • Drag handle grab gizmo below the selection frame for + easier workpiece grabbing (#173)
  • +
  • Support for tool numbers outside 0-255 range, with new + device profile for Makera Carvera (#302)
  • +
  • Air assist toggle for the material test grid (#304)
  • +
  • CNC spindle and coolant fields in the G-code dialect
  • +
+

Improvements:

+
    +
  • Faster smoothing and 3D rendering performance
  • +
  • Text rendering now handled by raygeo for better font + support across platforms
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • G-code placeholders being incorrectly rejected in the + encoder context
  • +
  • Axis replacement mode emitting duplicate Y words causing + GRBL error 25 (#310)
  • +
  • Toggle buttons of varsets not changing background color + when toggled on
  • +
  • Material test grid missing workpiece UID section + commands
  • +
  • Out of memory crash when opening SVG files containing + circles
  • +
+
+
+ + +

Raygeo upgrade, Pango text rendering, and bug fixes

+

Improvements:

+
    +
  • Upgrade raygeo to 1.15.1
  • +
  • Bump addon API version to 17 for incompatible raygeo API + changes
  • +
  • Replace Cairo text path with Pango-based text_to_geometry for + robust font fallback (#293)
  • +
  • Defer histogram computation to idle callback and cap render + resolution in raster widget
  • +
  • Configurable GRBL protocol version (by Uwe Woessner)
  • +
  • Device profile modifications for Longer Ray5 (by Uwe Woessner)
  • +
  • Update pypdf to version 6.12.2
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Various device profiles missing {extra_cmd} in G-code dialect + causing A axis not emitted (#301)
  • +
  • GRBL buffer stall recovery resending G-code to freshly reset + firmware after cancel
  • +
  • Contour producer dropping open contours in Outside/Inside cut + modes
  • +
  • Overscan transformer doubling up for drivers with native + overscan (Ruida)
  • +
+
+
+ + +

Wavefront adaptive clearing, raygeo PyPI migration, and bug fixes

+

New Features:

+
    +
  • Wavefront (adaptive clearing) toolpath operation for efficient + area clearing with helical entry and concentric passes
  • +
  • Migrate raygeo from local source to PyPI package
  • +
+

Improvements:

+
    +
  • Upgrade raygeo to releases 0.8.0 through 0.13.2 with numerous + API improvements and renames
  • +
  • Update dependencies (aiohttp, pypdf) to fix security + vulnerabilities
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Multi-step composite blit positioning for correct step content + placement
  • +
  • GRBL error state recovery when machine enters HOLD
  • +
  • Backward compatibility for legacy bezier curve formats in + raygeo
  • +
+
+
+ + +

LightBurn device profile import and bug fixes

+

New Features:

+
    +
  • LightBurn device profile (.lbdev) import with camera + calibration and device configuration
  • +
  • Import LightBurn layer settings as Rayforge step + parameters
  • +
+

Updated translations

+
+
+ + +

LightBurn import, raygeo API update, and bug fixes

+

New Features:

+
    +
  • LightBurn (.lbrn / .lbrn2) file format import support
  • +
+

Improvements:

+
    +
  • Updated to latest raygeo 0.6 API (Geometry API, bezier_to, + fit_curves, optimizer, canonical imports)
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Optimizer no longer splits continuous scanlines
  • +
  • Fixed tab clip points not scaled by workpiece size, matching + producer transformation
  • +
  • Fixed multiprocessing warnings on Python 3.12
  • +
+
+
+ + +

Acmer P3 profile, lens calibration dialog, and bug fixes

+

New Features:

+
    +
  • Device profile for the Acmer P3 laser engraver
  • +
  • Lens calibration dialog with status icons and tooltips, + split from the image settings dialog
  • +
+

Improvements:

+
    +
  • Rotary module selection is now disabled when the machine + has no rotary modules
  • +
  • macOS app icons updated to Tahoe (Liquid Glass-style) + design
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Fixed slider power value clamped to 1% after dialog + re-population
  • +
+
+
+ + +

Rust-powered pipeline, faster rasterizing, new GRBL driver

+

Major Changes:

+
    +
  • Core Ops container rewritten with Struct-of-Arrays layout for + better performance and Rust compatibility
  • +
  • Five pipeline stages migrated to Rust via raygeo 0.6: tabs, + merge lines, overscan, lead-in/out, and hull computation
  • +
  • Raster scan loops replaced with Rust-accelerated raygeo + functions for faster engraving operations
  • +
  • Image processing (sRGB conversion, dithering, grayscale) + delegated to raygeo.image Rust backend
  • +
  • 3D canvas slider now uses binary search with pre-computed + snapshots, eliminating freezes on large jobs
  • +
+

New Features:

+
    +
  • Simple GRBL serial driver with ping-pong protocol for devices + with buffer-counting issues
  • +
  • "Go to WCS Zero" button in the Current Position section
  • +
  • Device profiles for Creality Falcon 10W and Sculpfun C1
  • +
  • Minimum raster line spacing lowered to 0.001 mm for + microfabrication
  • +
  • Deadlock detection toggle in GRBL driver settings
  • +
  • Machine settings now apply immediately without restart
  • +
+

Improvements:

+
    +
  • Adaptive deadlock timeouts based on per-command time + estimates
  • +
  • Worker pool detects crashed workers and spawns replacements + automatically
  • +
  • File dialogs prefer Rayforge MIME types over ZIP
  • +
+

Bug Fixes:

+
    +
  • Fixed GRBL network disconnect with MKS DLC32 boards
  • +
  • Fixed buffer stall recovery aborting jobs during slow + moves
  • +
  • Fixed ValueError when removing the active machine
  • +
  • Fixed manual laser control routing
  • +
  • Fixed WCS dropdown coordinates not updating on sync
  • +
  • Fixed pipeline stress test race conditions on Windows
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed contour offset producing hundreds of garbage + micro-contours on shapes with multiple holes
  • +
+
+
+ + +

Bug fix release

+
    +
  • Raygeo version info in about dialog
  • +
  • Fixed mirrored bezier control points and arc parameters in + raygeo
  • +
  • Fixed raster and frame icons not showing on some GTK + versions
  • +
+
+
+ + +

Performance improvements and bug fixes

+
    +
  • Migrated geometry processing from Python to Rust (raygeo) + for improved performance
  • +
  • Added distance preset buttons in Print and Cut wizard for + quick selection
  • +
  • Fixed WCS offset applied twice in Move to Selection buttons + (#245)
  • +
  • Updated addon API version to 13
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed shallow copy of extra_axes causing rotary 3D preview + distortion (#243)
  • +
  • Fixed mirrored arcs rendered as full circles in G-code and + 3D preview
  • +
+
+
+ + +

Canvas pan, camera enhancements, raster performance, and bug fixes

+
    +
  • Space+drag pan gesture for canvas navigation (#241)
  • +
  • Custom resolution option for camera image settings
  • +
  • Numerous performance improvements for raster engraving + operations
  • +
  • 2D canvas laser path alpha normalization for better low-power + visibility
  • +
  • Fixed capability defaults being overwritten by duplicate step + keys (#239)
  • +
  • Fixed RX buffer override not applied in Creality Falcon device + profiles
  • +
+
+
+ + +

Overcut support, performance improvements, and bug fixes

+
    +
  • Added overcut option for contour operations
  • +
  • Fixed operations preview misalignment when zooming past + the base image resolution cap
  • +
  • Massive performance improvements across geometry + processing and path optimization
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed crash when loading GLB models with texture visuals + instead of vertex colors
  • +
  • Improved 3D model lighting with a fill light and raised + ambient brightness
  • +
  • Remapped laser power LUT lookup so low-power paths remain + visible
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed SVG vector extraction missing group transforms for basic + shapes (#237)
  • +
  • Fixed error when dismissing the import file dialog
  • +
  • Export and send buttons are now disabled when pipeline data is + stale
  • +
  • Updated MarlinSerialDriver maturity level to EXPERIMENTAL + (#236)
  • +
  • Updated translations
  • +
+
+
+ + +

New Features:

+
    +
  • Experimental Marlin driver with probing/auto configuration + support (#236)
  • +
  • Camera resolution selection in camera image settings (#233)
  • +
  • Camera visibility toggle in SketchStudio (#235)
  • +
  • RX buffer size override option for GRBL serial driver (#234)
  • +
+

Bug Fixes:

+
    +
  • Fixed job generation stuck after cancellation
  • +
  • Fixed RX buffer size handling in GRBL serial (#234)
  • +
  • Fixed atomic buffer space checks and flow control in + GrblSerialDriver (#234)
  • +
  • Fixed cooperative cancellation not working in worker + subprocesses
  • +
+

Updated GitPython dependency; various code cleanups

+
+
+ + +

New Features:

+
    +
  • Add a diode laser 3D model
  • +
  • Reset button for sketch parameters in the panel
  • +
  • Boundary tolerance checks for extent and workarea validations
  • +
+

Bug Fixes:

+
    +
  • Fixed Gtk deprecation warning
  • +
  • Fixed Gtk warning from duplicate WCS row in BottomPanel
  • +
  • Improved GRBL command parsing and line ending handling
  • +
+

Updated translations

+
+
+ + +

Configuration Wizard with GRBL Probing

+

New Features:

+
    +
  • Configuration wizard that probes GRBL devices over serial or + network and creates a machine profile automatically
  • +
+

Improvements:

+
    +
  • Enhanced text rendering by integrating Pango for improved + layout and metrics
  • +
+
+
+ + +

Manual Laser Control, Serial Transport Improvements, Machine + Profile Search

+

New Features:

+
    +
  • Manual laser control dock with per-head power, frequency, + pulse width, and auto-off timer (#225)
  • +
  • Search field in the machine profile selector
  • +
  • Machine profiles for Sculpfun S30 Pro Max, S40 MAX, and + S70 MAX, and Elidor Z6
  • +
+

Improvements:

+
    +
  • Dock layout: new dock items are now placed next to their + buddy item when restoring a saved layout that doesn't include + them
  • +
  • Serial transport: non-blocking read for improved OS lock + management (#231)
  • +
  • Serial transport: write operations offloaded to executor to + prevent blocking
  • +
  • Serial transport: ports opened in exclusive mode to avoid + clashes with other apps
  • +
  • GRBL: serial buffer flushed after every write
  • +
+

Bug Fixes:

+
    +
  • Fixed GRBL RX buffer size not cached, causing buffer + overflows on devices that do not report it via $I
  • +
  • Fixed GRBL laser-off command (M5) sent during active jog, + which caused error:9
  • +
  • Fixed deadlock detection triggered incorrectly when status + polling was off
  • +
  • Fixed deadlock detection when GRBL doesn't report Bf: in + status reports
  • +
+
+
+ + +

Job Sanity Checks, 10 New Device Profiles, Better Layer + Interaction

+

New Features:

+
    +
  • Job sanity check system that reports machine extent violations, + workarea violations, and no-go zone collisions before sending or + exporting
  • +
  • Device profiles for 10 popular laser cutters: Ortur LM3/LM4, + Atomstack X40 Pro/A70, TwoTrees TTS-55, NEJE Master 3 Max, Creality + Falcon 2 Pro, OMTech Polar 50W, Longer Ray5, and Thunder Laser + Nova 35
  • +
  • Context menus for workpieces in the layer tab and in the asset + browser with copy, cut, paste, and duplicate
  • +
  • Visual selection state in layer columns synced with the + canvas
  • +
  • Multi-item selection with Ctrl-click, Shift-click range, and + cross-layer drag
  • +
  • Horizontal and vertical auto-constraints from snap guides in the + sketcher path tool
  • +
  • Sketch parameters shown as a separate preferences group
  • +
  • Locale-aware number formatting in sliders
  • +
+

Bug Fixes:

+
    +
  • Fixed GRBL buffer deadlock from lost ok responses on \r\r\n + line endings
  • +
  • Fixed wrong visibility icon for initially invisible layers
  • +
  • Fixed IndexError when laser combo selection is out of sync with + machine heads
  • +
  • Fixed AttributeError on startup from early view_stack signal + connection
  • +
  • Fixed circle and ellipse sketches missing preview + thumbnails
  • +
  • Fixed canvas stuck in shift-pressed state after layer + interaction
  • +
+
+
+ + +

CO2 Laser Support, OctoPrint Driver, Parametric Text

+

New Features:

+
    +
  • CO2 laser settings: PWM frequency and pulse width support for + compatible machines
  • +
  • Experimental OctoPrint driver
  • +
  • Parametric text template support in the sketcher
  • +
  • Device profile for the Sculpfun iCube Ultra
  • +
  • Grid toggle button in the 3D canvas visibility overlay
  • +
  • Project inclusion toggle in the Save Debug Log dialog
  • +
+

Improvements:

+
    +
  • Image processing (resize, grayscale, dithering, color LUT) now + operates in linear light for more accurate results
  • +
  • Visual improvements to the device profile selector
  • +
  • Tab power slider is now hidden for non-cut steps
  • +
+

Bug Fixes:

+
    +
  • Fixed GRBL buffer overflow on devices with smaller RX + buffers
  • +
  • Fixed parametric text not updating correctly with volatile + expressions
  • +
  • Fixed crash in MergeLinesTransformer when a cutting command + appeared before any positioning command
  • +
+
+
+ + +

Ruida Driver, Vector Editing, and Bug Fixes

+

New Features:

+
    +
  • New Ruida driver with jogging, position reporting, air assist, + layer selection, auto-connect, and status polling
  • +
  • Driver maturity indicator with warning banner for non-stable + drivers
  • +
  • Generic GRBL, Smoothieware, and Ruida device profiles
  • +
  • Edit workpiece vectors directly by double-clicking (vector + deletion)
  • +
  • Job time estimate shown in 3D canvas
  • +
+

Improvements:

+
    +
  • Serial transport uses threading instead of asyncio for fewer + buffer overflows (#208)
  • +
  • G-code settings page hidden for non-G-code drivers
  • +
  • Project files are now zipped internally
  • +
  • Creality Falcon A1 profile now uses Grbl Raster dialect
  • +
+

Bug Fixes:

+
    +
  • Fixed pipeline holding stale reference after switching machines
  • +
  • Fixed pipeline recalculation loop
  • +
  • Fixed power percentage rounding in step summary
  • +
+
+
+ + +

Device Profiles, Rotary Overhaul, Print & Cut, Redesigned + Layers

+

Major Changes:

+
    +
  • Machine profiles replaced by device profiles that bundle config + and G-code dialect together
  • +
  • Complete rotary overhaul with true 4th axis and axis replacement + support
  • +
  • Print and cut addon for aligning laser cuts with printed + material
  • +
  • Redesigned layer system with visual workflow and drag-and-drop + reordering
  • +
  • Per-layer Work Coordinate System assignment
  • +
  • GRBL Telnet driver for networked grblHAL and ESP3D controllers
  • +
  • Update checker for new version notifications
  • +
+

Device Profiles:

+
    +
  • Declarative packages bundling machine config and G-code + dialect
  • +
  • Export and import UI for sharing profiles between machines
  • +
+

Rotary Improvements:

+
    +
  • True 4th axis support alongside X/Y/Z
  • +
  • Axis replacement mode (swap Y or Z for rotary)
  • +
  • Roller-type rotary axis settings
  • +
  • Major kinematics refactoring for correct rotary rendering
  • +
  • 2D canvas adapts to rotary mode automatically
  • +
+

Layer System:

+
    +
  • Visual workflow indicators in each layer column
  • +
  • Drag-and-drop layer reordering
  • +
  • Workpiece z-ordering in the layer list
  • +
  • Per-layer WCS assignment with quick edit button
  • +
  • Layer settings dialog is now non-modal
  • +
+

Material Test Grid:

+
    +
  • New parameter combinations
  • +
  • Extra speed or power labels in multipass mode
  • +
+

Performance:

+
    +
  • Massively reduced memory usage for multi-layer PDFs
  • +
  • Vertex storage uses power values instead of colors
  • +
  • Improved GRBL serial robustness with deadlock recovery
  • +
+

UI Improvements:

+
    +
  • Right panel is now a floating overlay
  • +
  • Slider values are now editable entry fields
  • +
  • Setting for ops color: layer color or laser color
  • +
  • Right-click context menu for empty canvas space
  • +
  • Sketcher: support changing text color using the fill tool
  • +
  • Device profile for the Creality Falcon A1
  • +
  • Bottom panel visible by default
  • +
  • Terminal window no longer visible on Windows
  • +
  • Improved error messages for Grbl alarms
  • +
+

Bug Fixes:

+
    +
  • Fixed zero axis not working over GRBL network connection + (#220)
  • +
  • Fixed jog distance not applying when entered via keyboard + (#221)
  • +
  • Fixed Gtk imported in worker subprocesses (#224)
  • +
  • Fixed PEP 440 version strings causing false update + notifications
  • +
  • Fixed various Gtk warnings
  • +
+
+
+ + +

Maintenance release

+
    +
  • Fixed G-code production failure when no rotary axis commands are defined
  • +
  • Fixed G5 commands not respecting the omit unchanged axis flag
  • +
  • Fixed 2D canvas showing stale ops when operation generates zero ops
  • +
  • Fixed model preview orientation and color display
  • +
  • Fixed point light not turning off when laser is off
  • +
  • Fixed potential race conditions in pipeline and 3D canvas
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed GRBL serial not connecting (#196)
  • +
  • Fixed auto brightness toggle setting not remembered in raster step settings
  • +
  • Post processor page is more compact by using expanders
  • +
  • Better line width for rendered ops in 3D canvas
  • +
+
+
+ + +

3D Simulator:

+
    +
  • Full playback with play/pause, step, scrubber, and speed control
  • +
  • Laser beam and head rendered during simulation
  • +
  • Rotary mode simulation with cylinder rotation
  • +
  • Non-blocking playback even on complex jobs
  • +
+

Bezier Curves (G5):

+
    +
  • Native cubic bezier support through the entire pipeline
  • +
  • G5 output for LinuxCNC and Marlin, automatic linearization for others
  • +
  • Curves work with tabs, cropping, smoothing, multi-pass, and path optimization
  • +
+

No-Go Zones:

+
    +
  • Define restricted areas in machine settings
  • +
  • Collision checking warns before sending G-code
  • +
+

Performance and Memory:

+
    +
  • Canvas is significantly more responsive during interaction
  • +
  • Large images automatically scaled to prevent memory spikes
  • +
  • Smarter caching with memory limits and source-level reuse
  • +
  • Time estimation updates instantly
  • +
  • Two memory leaks fixed in the 3D simulator
  • +
+

UI Improvements:

+
    +
  • Dockable bottom panel with rearrangeable tabs
  • +
  • Layer list moved to bottom panel with drag-and-drop
  • +
  • Asset browser overhaul with multi-selection and thumbnails
  • +
  • Status bar removed, info moved to contextual locations
  • +
  • Canvas overlay toggles for perspective, model, and no-go zones
  • +
  • 3D model support for rotary axes with GLB import
  • +
+

New Import Options:

+
    +
  • Three layer modes: flatten, merge to existing, create new
  • +
  • Default workflow steps added to imported layers automatically
  • +
+

Post-Processing:

+
    +
  • Lead-in / lead-out postprocessor for approach and exit moves
  • +
  • Material test labels engraved first for cleaner results
  • +
  • Material test overscan and label speed support
  • +
+

Other Additions:

+
    +
  • Ctrl+F search in console and G-code viewer
  • +
  • YUYV camera protocol support
  • +
  • Addons can register toggles in the View menu
  • +
  • Grbl MKS DLC32 machine profile
  • +
  • Single instance lock
  • +
  • Continuous laser mode and modal feedrate G-code options
  • +
  • GRBL raster dialect with cleaner M4/M5 handling
  • +
+
+
+ + +

Bug fix release

+
    +
  • PDF import now falls back to fitz when pymupdf is not installed
  • +
  • DPI setting in the SVG import dialog is now persistent between sessions
  • +
+
+
+ + +

Rotary Axis Support, PDF Layer Import, and Camera Calibration

+

Major Changes:

+
    +
  • Full rotary axis support with 3D visualization
  • +
  • PDF direct vector import with layer support
  • +
  • Camera de-distortion with Charuco calibration wizard
  • +
  • Sketcher: ellipse tool replacing circle tool
  • +
+

Rotary Axis:

+
    +
  • Full 3D visualization for rotary mode
  • +
  • Support for multiple rotary modules
  • +
  • Configurable rotary mode per layer
  • +
  • Rotary icon displayed on rotary layers
  • +
+

PDF Improvements:

+
    +
  • Direct vector import for PDF files
  • +
  • PDF importer now supports layers
  • +
  • Split PDF based workpieces into layers
  • +
+

Camera Calibration:

+
    +
  • Improved five factor de-distortion algorithm
  • +
  • Charuco card based calibration wizard
  • +
  • Guided calibration setup process
  • +
+

Sketcher Improvements:

+
    +
  • Ellipse tool replaces circle tool for more flexibility
  • +
  • Many tools automatically constrain geometry during creation
  • +
  • Magnetic snap now works while creating geometry
  • +
  • Replaced snap to grid with smarter magnetic snap
  • +
  • Equality constraint now works on ellipses
  • +
+

Frame Feature:

+
    +
  • Configure frame speed in laser head settings
  • +
  • Corner dwell time setting for framing
  • +
  • Repeat count setting for framing
  • +
+

Post-Processors:

+
    +
  • New merge lines post-processor to avoid double cutting
  • +
+

UI Improvements:

+
    +
  • More compact left panel layout with add buttons in group headers
  • +
  • G-code viewer moved into the bottom panel
  • +
  • 3D canvas performance improvements
  • +
+

Other Improvements:

+
    +
  • Machine profile for Acmer S1 added
  • +
  • GRBL buffer size tracking improved
  • +
  • Dialects are now isolated copies (templates)
  • +
  • Dialects support separate laser on command for focusing
  • +
  • Texture dimension limit prevents memory exhaustion
  • +
+

Fixed:

+
    +
  • Material test producer bugs (issues #181 and #182)
  • +
  • GRBL position reporting for machines with only X and Y axes (#179)
  • +
  • Sketcher: Distance constraint shadowing the line
  • +
  • Texture renderer memory exhaustion on large images
  • +
  • Race condition in worker initialization
  • +
+
+
+ + +

Bug fix release

+
    +
  • Raster step settings now display angle numerically
  • +
  • Fixed import issues on Windows
  • +
  • Unknown G-code dialects now fall back to Grbl
  • +
  • Fixed duplicate error notifications from the driver
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed icon sizing issues on systems with Gtk 4.21
  • +
  • Fixed Windows and macOS build issues
  • +
  • Fixed conflicting menu shortcuts in the sketcher
  • +
+
+
+ + +

Bezier Curves in Sketcher and Modular Architecture

+

Major Changes:

+
    +
  • Full bezier curve support in the parametric sketcher
  • +
  • Sketcher moved to an optional add-on
  • +
  • New unified path tool combining lines and curves
  • +
  • More compact G-code output
  • +
+

Sketcher Improvements:

+
    +
  • Intuitive bezier editing with drag handles
  • +
  • Connect curves to existing geometry
  • +
  • Smooth or symmetric endpoints for connected paths
  • +
  • Straighten tool to convert beziers to lines
  • +
  • Grid tool for visual reference
  • +
  • Show/hide construction geometry and constraints
  • +
  • Hold Shift to constrain movement to axis
  • +
  • Conflicting constraints shown in panel
  • +
+

G-code Improvements:

+
    +
  • Unchanged coordinates now omitted for smaller files
  • +
  • G-code viewer shows line count and byte size
  • +
  • Raster operation gains sample interval and power levels settings
  • +
+

Fixed:

+
    +
  • Most icons not displayed on systems with Gtk > 4.21
  • +
  • Fillet and chamfer tools not working correctly
  • +
  • Texture encoder drawing spaced dots instead of lines
  • +
  • Loading project files with unknown assets
  • +
+
+
+ + +

Bug fix release

+

New Features:

+
    +
  • Buttons to move to center, bottom/left and top/right of workpiece
  • +
  • Support for setting a "tab power"
  • +
  • Sketcher: allow entering dimensions while adding geometry
  • +
+

Improvements:

+
    +
  • Better button layout in the control panel
  • +
  • Sketcher: circle now uses diameter constraint consistently
  • +
  • Laser dot now always drawn on top, not obscured by workpieces
  • +
+

Fixed:

+
    +
  • Builtin addon yaml files not included in .snap
  • +
  • Imprecise tab location while dragging the tab handle
  • +
  • Tabs not working on beziers
  • +
+
+
+ + +

Addon System Rewrite and AI Workpiece Generation

+

Major Changes:

+
    +
  • AI workpiece generation
  • +
  • Complete addon system rewrite and refactoring
  • +
  • Built-in addons: laser-essentials, core materials, post-processors, layout
  • +
  • Path optimizer now also optimizes inter-workpiece travel
  • +
  • Stock is now a document-level concept - no more stock per layer
  • +
+

New Features:

+
    +
  • Camera image enhancement with temporal noise reduction
  • +
  • Fisheye lens distortion correction
  • +
  • Zoom, pan and keyboard navigation in camera alignment dialog
  • +
  • Tons of new materials in core materials addon
  • +
  • Mach4 G-code dialect
  • +
  • Post-processor: crop-to-stock
  • +
  • Click canvas to set zero feature
  • +
  • Support for configuring cut/raster colors per laser
  • +
  • Support for duplicating stock and non-rectangular stock
  • +
  • Convert workpiece to stock (right-click menu)
  • +
  • Machine profile for OMTech K40+
  • +
  • Navigation and zoom icons for UI controls
  • +
+

Sketcher Improvements:

+
    +
  • Live preview for line tool
  • +
  • Dimension display while adding geometry
  • +
  • Snap-to-grid on Ctrl press
  • +
  • Hover highlighting for entities and constraints
  • +
  • Toolbar shows currently available shortcuts
  • +
  • Shift+Double click selects connected geometry
  • +
  • Arc radius can be changed while adding second endpoint
  • +
  • Selection preserved when creating lines, arcs, or circles
  • +
+

Other Improvements:

+
    +
  • Imported items and stock aligned with WCS origin by default
  • +
  • Improved camera selection dialog (left/right indicators, keyboard support)
  • +
  • Sketcher symmetry constraint click order aligns with FreeCAD
  • +
  • Maximum laser G-code power increased to 100.000
  • +
  • Context lazy loads most services for better performance
  • +
  • RAYFORGE_DISABLE_3D environment variable
  • +
+

Fixed:

+
    +
  • Undoing text box left entries in history manager stack
  • +
  • Y axis drawn on wrong side of canvas
  • +
  • Auto layout for rotated stock
  • +
  • SVG exporter stacking multiple workpieces on top of each other
  • +
  • Text color in Sketcher while editing
  • +
  • Default laser assignment when project references non-existent laser
  • +
  • Text box rotating while typing on Windows
  • +
  • Addon handling issues on Windows
  • +
  • macOS app menu window narrowed to MainWindow
  • +
  • macOS app menu actions
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed text box rotating while typing in sketcher
  • +
  • Fixed assets cannot be deleted after loading from a project file
  • +
  • Fixed empty sketches showing as black squares after loading from project
  • +
  • Maybe: Fixed pie menu not opening in correct location on Windows
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed screenshot link in appstream causing Flatpak build to fail
  • +
+
+
+ + +

Pipeline Rewrite and Unified Raster Operations

+

Version 1.1 brings a complete backend rewrite and unified raster + operations:

+

Major Changes:

+
    +
  • Complete data pipeline backend rewrite with improved + performance and memory management
  • +
  • Unified raster operations - old "raster", "depth", and + "dither" operations merged into one
  • +
  • New G-code console replacing the log view with a fully + featured terminal
  • +
  • SVG and DXF export support for documents and selected + objects
  • +
  • Angle constraints now available in the sketcher
  • +
  • New "Raster (Dither)" operation with configurable + algorithms
  • +
  • Built-in G-code dialect supporting dynamic power mode
  • +
  • Configurable axis extents, work surface, and soft limits + per machine
  • +
+

Improvements:

+
    +
  • Auto thresholding for Variable Power and Multipass raster + modes
  • +
  • Histogram in depth engraver for easier threshold + setting
  • +
  • Symbolic visualization of raster direction
  • +
  • Configurable line distance in all raster operations
  • +
  • Machine selector moved to window header
  • +
  • Workpiece properties now show WCS coordinates
  • +
  • Imported images placed at reference origin by default
  • +
  • Ukrainian translation added
  • +
  • Material files now support translations
  • +
  • New --config CLI argument for custom config path
  • +
  • Optional anonymous usage statistics (opt-in)
  • +
  • Re-assigned Alt key bindings to avoid clashing with + main menu actions
  • +
  • Improved contour offset algorithm for intersecting + geometry
  • +
+

Fixed:

+
    +
  • Multiple memory leaks in the data pipeline
  • +
  • Race conditions in worker pool and pipeline
  • +
  • Camera device scanning crash on Windows
  • +
  • Various macOS-specific issues
  • +
  • Rasterizer angle distortion
  • +
  • Unresponsive serial port handling
  • +
  • Simulation preview strokes constant across zoom levels
  • +
  • Speed in Jog dialog correctly converted to base units
  • +
  • Icons not appearing in snap on non-GNOME environments
  • +
  • GRBL connection handshake timeout with backoff retry
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed some strings not being translatable
  • +
  • Reformatted the 1.0 release notes to not trip the Flatpak build up
  • +
+
+
+ + +

It's Rayforge's Birthday Release!

+

Version 1.0 brings some exciting features and enhancements to the + Rayforge experience:

+

Major Additions:

+
    +
  • Full project save/load support
  • +
  • Sketcher now supports text, with advanced options
  • +
  • Jog dialog and log view have been merged into a new bottom + panel
  • +
  • Aspect ratio constraints now available in the sketcher
  • +
  • Engraving steps can now be inverted
  • +
  • Raster engraver gains support for setting engraving angle in + degrees
  • +
  • Recent files menu added for easier access to your last + projects
  • +
  • Installers for all platforms now register .ryp (project) and + .rfs (sketch) file extensions
  • +
+

Changes:

+
    +
  • CLI update: `--direct-vector` renamed to `--vector`; added + `--trace` to enforce trace mode (vector import is now default)
  • +
  • Import errors are now collected and shown in the import + dialog
  • +
  • Importers rewritten for better testability and stability
  • +
  • Sketcher solver now biases points towards their previous + position, improving dragging stability
  • +
  • Simulation mode keyboard shortcut changed to F11 to avoid + conflict with "Save as..."
  • +
  • Improved G-code view and control panel state retention across + sessions
  • +
  • Precision of power sliders and display digits has been + increased
  • +
  • Import dialog now displays number of vectors per layer
  • +
  • Error message added when attempting to delete a dialect in + use
  • +
  • Chinese translations added to improve localization
  • +
+

Fixed:

+
    +
  • Traceback when adding a constraint in the sketcher + (Windows only)
  • +
  • Potential memory leak and stale operations display
  • +
  • Improved multi-layer DXF import handling
  • +
  • Numerous alignment bugs in importers
  • +
  • Traceback when using invert switch in the import dialog
  • +
  • Sketches not centered on the surface after import
  • +
+

Documentation:

+
    +
  • Updated importer developer documentation
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed traceback when dialect contained deprecated attributes
  • +
  • Fixed debian package missing asyncudp dependency
  • +
+
+
+ + +

Bug fix release

+
    +
  • Added option to enable/disable WCS injection in G-code dialect
  • +
  • Fixed depth engraver not respecting master power setting
  • +
  • Fixed traceback when using invert image in import dialog
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed test that depended on specific version string
  • +
+
+
+ + +

Bug fix release

+
    +
  • Fixed invalid ampersand in appstream XML
  • +
+
+
+ + +

Work Coordinate Systems, True Arcs, and a New Package Manager

+
    +
  • Full support for Work Coordinate Systems (WCS) G54-G59
  • +
  • True arc support with superior geometry handling
  • +
  • New Package Manager for extensibility
  • +
  • New sketcher tools: Rectangle, Fillet, and Chamfer
  • +
  • Optimized G-code output and improved GRBL driver
  • +
  • Always-active Cancel button and improved UI
  • +
  • Numerous bug fixes and performance improvements
  • +
+
+
+ + +

Maintenance release with performance improvements

+
    +
  • Major performance boost for SVG imports
  • +
  • Fixed invalid clipping paths in SVG files
  • +
  • Restored sketcher fill toggling
  • +
  • Theme-aware sketcher line colors
  • +
  • Corrected laser dot position
  • +
+
+
+ + +

Enhanced Sketching, Machine Control & UI Refinements

+
    +
  • Expressions and parameters in parametric sketcher
  • +
  • Filled shapes support in sketcher
  • +
  • Improved constraint visualization and drag-select
  • +
  • Robust GRBL serial driver with better recovery
  • +
  • Variable substitutions in G-code
  • +
  • Flexible machine origins and negative axis support
  • +
  • Icon refresh and redesigned machine settings dialog
  • +
  • Dark mode improvements and streamlined settings
  • +
+
+
+ + +

Parametric 2D Sketcher & Major Performance Upgrades

+
    +
  • Parametric 2D sketcher with constraint system
  • +
  • Import/export sketches with parametric constraints
  • +
  • Significantly reduced memory usage
  • +
  • Faster operations with complex vector files
  • +
  • Fixed 3D view and G-code mirroring with Y-down machines
  • +
  • Improved Smoothieware driver compatibility
  • +
  • Added Makera Carvera Air machine profile
  • +
+
+
+ + +

New Import Experience & Object Management

+
    +
  • Interactive import dialog with tracing controls
  • +
  • Split workpiece command for separating assets
  • +
  • Custom G-code dialects support
  • +
  • Inner edges first in contour operations
  • +
  • Improved UI responsiveness with debouncing
  • +
  • Fixed power display bug and grouping visuals
  • +
+
+
+ + +

This is the initial release.

+
+
+
+
diff --git a/data/org.rayforge.rayforge.xml b/data/org.rayforge.rayforge.xml new file mode 100644 index 000000000..8d2c382ba --- /dev/null +++ b/data/org.rayforge.rayforge.xml @@ -0,0 +1,16 @@ + + + + Rayforge project file + + + + + Rayforge sketch file + + + + Ruida laser cutter file + + + diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 000000000..763cacd24 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +rayforge (0.1.0-1) unstable; urgency=medium + + * Initial release. + + -- Samuel Abels Mon, 27 May 2024 10:00:00 +0000 diff --git a/debian/control b/debian/control new file mode 100644 index 000000000..36a33e97b --- /dev/null +++ b/debian/control @@ -0,0 +1,34 @@ +Source: rayforge +Section: graphics +Priority: optional +Maintainer: Samuel Abels +Build-Depends: cargo, + cython3, + debhelper-compat (= 13), + dh-python, + python3-all, + python3-dev, + python3-maturin, + python3-numpy, + python3-pip, + pybuild-plugin-pyproject, + python3-poetry-core, + python3-setuptools, + python3-wheel, + rustc, +Standards-Version: 4.6.2 +Homepage: https://github.com/barebaric/rayforge + +Package: rayforge +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, ${python3:Depends}, + python3-gi, python3-git, python3-cairo, python3-gi-cairo, + gir1.2-gtk-4.0, gir1.2-adw-1, + libvips42t64, adwaita-icon-theme, python3-numpy, python3-scipy, + python3-opencv, python3-ezdxf, python3-pluggy, + python3-pypdf, python3-yaml, python3-aiohttp, python3-websockets, + python3-blinker, python3-platformdirs, python3-opengl, desktop-file-utils, + python3-svgelements, python3-semver, python3-fitz, python3-serial +Description: Desktop application for laser cutting and engraving + Rayforge is a powerful, open-source, and cross-platform software for + controlling your laser cutter and engraver. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 000000000..461976bce --- /dev/null +++ b/debian/copyright @@ -0,0 +1,31 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Rayforge +Upstream-Contact: Samuel Abels +Source: https://github.com/barebaric/rayforge + +Files: * +Copyright: 2023-2024 Samuel Abels +License: MIT + +Files: debian/* +Copyright: 2024 Samuel Abels +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. diff --git a/debian/lintian-overrides b/debian/lintian-overrides new file mode 100644 index 000000000..367d08e0a --- /dev/null +++ b/debian/lintian-overrides @@ -0,0 +1,6 @@ +# The bundled ezdxf wheel has a compiled dependency on the NumPy C-API. +# The standard debhelper tools do not automatically detect this for pre-compiled +# binaries installed via pip. However, our explicit dependency on 'python3-numpy' +# in debian/control satisfies this requirement in practice. +# We are overriding this lintian error to acknowledge we have handled it. +rayforge: missing-dependency-on-numpy-abi diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 000000000..f5524c1a9 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +#DEBHELPER# + +if [ "$1" = "configure" ]; then + update-desktop-database -q || true + gtk-update-icon-cache -q /usr/share/icons/hicolor || true + update-mime-database /usr/share/mime || true +fi + +exit 0 diff --git a/debian/postrm b/debian/postrm new file mode 100755 index 000000000..53937fbe8 --- /dev/null +++ b/debian/postrm @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +#DEBHELPER# + +if [ "$1" = "remove" ]; then + update-desktop-database -q || true + gtk-update-icon-cache -q /usr/share/icons/hicolor || true +fi + +exit 0 diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt new file mode 100644 index 000000000..0672f1219 --- /dev/null +++ b/debian/requirements-bundle.txt @@ -0,0 +1,5 @@ +asyncudp==0.11.0 +pyvips==3.1.1 +raygeo==1.38.3 +trimesh==5.0.0 +vtracer==0.6.15 \ No newline at end of file diff --git a/debian/rules b/debian/rules new file mode 100755 index 000000000..7c6321480 --- /dev/null +++ b/debian/rules @@ -0,0 +1,48 @@ +#!/usr/bin/make -f + +%: + dh $@ --with python3 --buildsystem=pybuild + +override_dh_auto_build: + python3 -m pip show setuptools + pip3 --version + ls -l + export VERSION="$$(dpkg-parsechangelog -S Version | sed -e 's/-[0-9~a-z].*$$//' -e 's/~alpha/a/g' -e 's/~beta/b/g' -e 's/~rc/rc/g' -e 's/~dev/.dev/g' -e 's/~/\+/g')"; \ + \ + echo "Patching pyproject.toml with static version: $$VERSION"; \ + sed -i '/^dynamic = .*"version".*/d' pyproject.toml; \ + sed -i '/^\[project\]/a version = "'$$VERSION'"' pyproject.toml; \ + sed -i '/"setuptools-git-versioning"/d' pyproject.toml; \ + sed -i 's/enabled = true/enabled = false/' pyproject.toml; \ + cat pyproject.toml; \ + \ + echo "Writing version to rayforge/version.txt: $$VERSION"; \ + echo "$$VERSION" > rayforge/version.txt; \ + \ + dh_auto_build + +override_dh_auto_install: + # STEP 1: Run the DEFAULT dh_auto_install command first. + export PIP_NO_INDEX=1; \ + export PIP_FIND_LINKS=file://$(CURDIR)/vendor/sdist; \ + dh_auto_install; \ + \ + # STEP 2: Manually install dependencies into the same staging directory. + echo "--- Manually installing vendored dependencies ---"; \ + HOME=$(CURDIR)/debian/.pip-cache pip3 install \ + --no-build-isolation \ + --no-dependencies \ + --no-index \ + --find-links=file://$(CURDIR)/vendor/sdist \ + --target=$(CURDIR)/debian/rayforge/usr/lib/python3/dist-packages \ + --upgrade \ + vtracer pyvips asyncudp trimesh raygeo + +override_dh_auto_test: + echo "Skipping tests during Debian build; they are run in the dedicated CI test job." + +override_dh_clean: + # Clean up the temporary pip cache + rm -rf debian/.pip-cache + # This is still necessary to prevent the vendor directory from being deleted. + echo "Skipping default dh_clean to preserve vendored sdist." diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 000000000..163aaf8d8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/source/include-binaries b/debian/source/include-binaries new file mode 100644 index 000000000..9c976b160 --- /dev/null +++ b/debian/source/include-binaries @@ -0,0 +1 @@ +vendor/wheels/*.whl diff --git a/debian/source/options b/debian/source/options new file mode 100644 index 000000000..bca68b05e --- /dev/null +++ b/debian/source/options @@ -0,0 +1,9 @@ +# Tell dpkg-source to ignore these directories when creating the source package. +# This prevents build artifacts and virtual environments from being included. +tar-ignore = .git +tar-ignore = .pixi +tar-ignore = .venv +tar-ignore = repo +tar-ignore = dist +tar-ignore = build +tar-ignore = *.egg-info diff --git a/docs/driver.md b/docs/driver.md deleted file mode 100644 index d1f20b987..000000000 --- a/docs/driver.md +++ /dev/null @@ -1,206 +0,0 @@ -# Rayforge Driver Development Guide - -This guide will help you create a new driver to support your laser. - -## Driver Overview - -A driver: - -- **Manages connectivity** (HTTP, WebSocket, serial, etc.). -- Translates generic Ops (machine instructions) into **specific machine commands** - (e.g., Gcode). -- **Emits signals** for UI integration, such as status changes, laser position - updates, or log messages. -- **Runs asynchronously** to avoid blocking the main thread. - -Rayforge simplifies driver implementation by providing modules for most common -tasks. A typical driver uses `Transport` and `OpsEncoder` classes to handle -connectivity and command translation. - -```mermaid -graph TD; - Driver-->Transport; - Driver-->OpsEncoder; -``` - -- Transport classes maintain stable connections to devices (with automatic - reconnection). -- Encoders convert Rayforge's internal Ops language into device-specific - commands. - -For example, the GrblDriver uses HTTP and WebSocket transports alongside a -G-code encoder: - -```mermaid -graph TD; - GrblDriver-->HttpTransport; - GrblDriver-->WebSocketTransport; - GrblDriver-->GcodeEncoder; -``` - -A driver should track the state of the device it is connected to. It does this -by using the `DeviceStatus` and `DeviceState` classes: - -- `DeviceStatus` represents a status such as IDLE, RUN, or ALARM. -- `DeviceState` Encapsulates full device state (position, speed, status, etc.). - -```mermaid -graph TD; - Driver-->State-->Status; -``` - - -## OpsEncoder Overview - -An OpsEncoder translates **Ops objects** into device-specific -commands. Ops objects are the Rayforge-internal "language" that -describes what a machine should do. - -Rayforge includes a GcodeEncoder for G-code-compatible devices. -For proprietary languages, implement a custom encoder first before -developing the driver. - -The OpsEncoder has only one method with the following signature: - -```python -def encode(ops: Ops, machine: Machine) -> str: -``` - -The Machine object is passed for additional hints, so that the -encoder can respect any machine settings that may affect the -translation. - -```mermaid -flowchart LR - Ops-->OpsEncoder - Machine-->OpsEncoder - OpsEncoder-->End[Specific machine instructions] -``` - - -## Ops Overview - -One of the main purposes of a driver is to translate the Rayforge-internal -representation of a **laser's movement** and **state changes** into something the -device understands. - -Rayforge generates these movements in an `Ops` class. The `Ops` class -represents a sequence of the following operations: - -| Method | Description | -| ------------------------- | ------------------------------------------ | -| `move_to(x, y)` | Rapid movement (no cutting) (mm) | -| `line_to(x, y)` | Cutting movement (mm) | -| `arc_to(x, y, i, j)` | Cutting arc movement (mm) | -| `set_power(value)` | Laser power (0-100%) | -| `set_cut_speed(value)` | Cutting speed (mm/min) | -| `set_travel_speed(value)` | Rapid movement speed (mm/min) | -| `enable_air_assist()` | Turn on air assist | -| `disable_air_assist()` | Turn off air assist | - -The following Ops example shows how Rayforge produces such objects: - -```python -ops = Ops() -ops.move_to(0, 0) # Move to origin -ops.set_power(80) # Set laser power -ops.enable_air_assist() # Enable air assist -ops.line_to(100, 100) # Cut diagonally -``` - -Rayforge passes the resulting Ops object to the driver's `run()` method to -execute a program. - -As explained above, the driver SHOULD use an OpsEncoder to perform the translation -into the native language of the device. -You can find examples for such encoders [here](../rayforge/opsencoder/). - - -## Driver Implementation - -All drivers MUST inherit from `rayforge.drivers.Driver`. - -```python -from .driver import Driver - -class YourDriver(Driver): - label = "Your Device" # Display name in the UI - subtitle = "Description for users" -``` - -### Methods - -All drivers MUST provide the following methods: - -- `setup()`: This is a special method that has two purposes: - - o Any arguments in the definition of the method are used to - auto-generate a user interface. For example, if the setup() - method ist defined as `setup(self, hostname: str)`, then - Rayforge will use the type hint to offer the user a UI - for entering a hostname. - **Only `str`, `int`, and `bool` types are supported.** - - o `setup()` is invoked after the user has configured the - driver in the UI. - - Example: - ```python - def setup(self, ip_address: str, port: int = 8080, enable_debug: bool = False): - """ - Parameters: - - ip_address: Device IP (e.g., "192.168.1.100") - - port: HTTP port (default: 8080) - - enable_debug: Log extra details (default: False) - """ - super().setup() - # Initialize your hardware connection here - ``` - -- `cleanup()`: Closes all connections and frees resources. -- `connect()`: Opens and maintains a persistent connection until cleanup() - is called. -- `run(ops: Ops)`: Called to execute the given operations on the - connected device. -- `home()`: Homes the device. -- `hold(hold: bool = True)`: Pause/unpause the running program. -- `cancel()`: Cancels the running program. -- `move_to(x: float, y: float)`: Move the laser to the given position. - Positions are passed in millimeters. - -### Properties - -Drivers MUST have the following properties: - -- `label`: Contains a label to be shown as the driver name in the UI. -- `subtitle`: Contains a subtitle to be shown in the UI. - -### Signals - -All drivers may provide the following signals: - -- `log_received`: for log messages -- `state_changed`: to monitor the state (see State object explanation above) -- `command_status_changed`: to monitor a command that was sent -- `connection_status_changed`: signals connectivity changes - -You MUST NOT emit these directly! Instead, call the base class -wrapper methods of the Driver for these methods, such as: - -- `Driver._log()` -- `Driver._on_state_changed()` -- `Driver._on_command_status_changed()` -- `Driver._on_connection_status_changed()` - -This ensures that the signals are sent in a GLib-safe manner. - - -## State Management - -- Assume hardware retains state between commands (e.g., laser power) -- Re-send critical states after reconnections - - -## Any questions? - -Please contact us through Github Issues! diff --git a/docs/ss-main.png b/docs/ss-main.png deleted file mode 100644 index 671405719..000000000 Binary files a/docs/ss-main.png and /dev/null differ diff --git a/flatpak/build-flatpak.sh b/flatpak/build-flatpak.sh deleted file mode 100755 index 86a060a73..000000000 --- a/flatpak/build-flatpak.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh - -set -e - -# Make sure script is started from repo root. -if [ "$0" != 'flatpak/build-flatpak.sh' ]; then - echo -e '\033[31m(ERROR)\033[0m: Script called from wrong dir. Please start from the root of the repository' - exit 1 -fi - -flatpak run org.flatpak.Builder \ - --force-clean --sandbox --user --install --ccache \ - --install-deps-from=flathub \ - --mirror-screenshots-url=https://dl.flathub.org/media/ \ - --repo=repo builddir com.barebaric.rayforge.yml diff --git a/flatpak/python3-meson-python.json b/flatpak/python3-meson-python.json deleted file mode 100644 index 0f7581ed7..000000000 --- a/flatpak/python3-meson-python.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "python3-meson-python", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"meson-python>=0.15.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/7d/ec/40c0ddd29ef4daa6689a2b9c5ced47d5b58fa54ae149b19e9a97f4979c8c/meson_python-0.17.1-py3-none-any.whl", - "sha256": "30a75c52578ef14aff8392677b09c39346e0a24d2b2c6204b8ed30583c11269c" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", - "sha256": "09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/e8/61/9dd3e68d2b6aa40a5fc678662919be3c3a7bf22cba5a6b4437619b77e156/pyproject_metadata-0.9.0-py3-none-any.whl", - "sha256": "fc862aab066a2e87734333293b0af5845fe8ac6cb69c451a41551001e923be0b" - } - ] -} \ No newline at end of file diff --git a/flatpak/python3-openblas.json b/flatpak/python3-openblas.json deleted file mode 100644 index dc3abc57b..000000000 --- a/flatpak/python3-openblas.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "openblas", - "sources": [ - { - "type": "archive", - "url": "https://github.com/xianyi/OpenBLAS/archive/v0.3.28.tar.gz", - "sha256": "f1003466ad074e9b0c8d421a204121100b0751c96fc6fcf3d1456bd12f8a00a1", - "x-checker-data": { - "type": "anitya", - "project-id": 2540, - "stable-only": true, - "url-template": "https://github.com/xianyi/OpenBLAS/archive/v$version.tar.gz" - } - } - ], - "no-autogen": true, - "make-args": [ - "DYNAMIC_ARCH=1", - "USE_OPENMP=1", - "NO_CBLAS=1", - "NO_LAPACKE=1" - ], - "make-install-args": [ - "PREFIX=/app" - ], - "cleanup": [ - "/include", - "/lib/pkgconfig" - ] -} - - diff --git a/flatpak/python3-pybind11.json b/flatpak/python3-pybind11.json deleted file mode 100644 index 6ad85b807..000000000 --- a/flatpak/python3-pybind11.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "python3-pybind11", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pybind11>=2.13.2\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/13/2f/0f24b288e2ce56f51c920137620b4434a38fd80583dbbe24fc2a1656c388/pybind11-2.13.6-py3-none-any.whl", - "sha256": "237c41e29157b962835d356b370ededd57594a26d5894a795960f0047cb5caf5" - } - ] -} \ No newline at end of file diff --git a/flatpak/python3-pythran.json b/flatpak/python3-pythran.json deleted file mode 100644 index fb1088795..000000000 --- a/flatpak/python3-pythran.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "python3-pythran", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pythran>=0.14.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/44/e4/6e8731d4d10dd09942a6f5015b2148ae612bf13e49629f33f9fade3c8253/beniget-0.4.2.post1-py3-none-any.whl", - "sha256": "e1b336e7b5f2ae201e6cc21f533486669f1b9eccba018dcff5969cd52f1c20ba" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/a3/61/8001b38461d751cd1a0c3a6ae84346796a5758123f3ed97a1b121dfbf4f3/gast-0.6.0-py3-none-any.whl", - "sha256": "52b182313f7330389f72b069ba00f174cfe2a06411099547288839c6cbafbd54" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ec/d0/c12ddfd3a02274be06ffc71f3efc6d0e457b0409c4481596881e748cb264/numpy-2.2.2.tar.gz", - "sha256": "ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", - "sha256": "096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/65/0a/557cef9d89cd21d7a4850c31701b7cfa78f6cc8b09249df678d174b6e6ca/pythran-0.17.0-py3-none-any.whl", - "sha256": "be569cc2817b625ccd2c8f74fa3c93806f245c65fadc282c26a9f546ebd34cfa" - } - ] -} \ No newline at end of file diff --git a/flatpak/python3-requirements.json b/flatpak/python3-requirements.json deleted file mode 100644 index 2469c0058..000000000 --- a/flatpak/python3-requirements.json +++ /dev/null @@ -1,439 +0,0 @@ -{ - "name": "python3-requirements.freeze", - "buildsystem": "simple", - "build-commands": [], - "modules": [ - { - "name": "python3-blinker", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"blinker~=1.9.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", - "sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" - } - ] - }, - { - "name": "python3-cairocffi", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"cairocffi~=1.7.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", - "sha256": "9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", - "sha256": "1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", - "sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" - } - ] - }, - { - "name": "python3-CairoSVG", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"CairoSVG~=2.7.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/01/a5/1866b42151f50453f1a0d28fc4c39f5be5f412a2e914f33449c42daafdf1/CairoSVG-2.7.1-py3-none-any.whl", - "sha256": "8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", - "sha256": "9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", - "sha256": "1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/9d/3a/e39436efe51894243ff145a37c4f9a030839b97779ebcc4f13b3ba21c54e/cssselect2-0.7.0-py3-none-any.whl", - "sha256": "fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", - "sha256": "a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", - "sha256": "368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", - "sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", - "sha256": "3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", - "sha256": "a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78" - } - ] - }, - { - "name": "python3-cffi", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"cffi~=1.17.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", - "sha256": "1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", - "sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" - } - ] - }, - { - "name": "python3-cssselect2", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"cssselect2~=0.7.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/9d/3a/e39436efe51894243ff145a37c4f9a030839b97779ebcc4f13b3ba21c54e/cssselect2-0.7.0-py3-none-any.whl", - "sha256": "fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", - "sha256": "3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", - "sha256": "a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78" - } - ] - }, - { - "name": "python3-defusedxml", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"defusedxml~=0.7.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", - "sha256": "a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" - } - ] - }, - { - "name": "python3-numpy", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"numpy~=2.2.2\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ec/d0/c12ddfd3a02274be06ffc71f3efc6d0e457b0409c4481596881e748cb264/numpy-2.2.2.tar.gz", - "sha256": "ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f" - } - ] - }, - { - "name": "python3-opencv-python", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"opencv-python~=4.11.0.86\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ec/d0/c12ddfd3a02274be06ffc71f3efc6d0e457b0409c4481596881e748cb264/numpy-2.2.2.tar.gz", - "sha256": "ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", - "sha256": "03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4" - } - ] - }, - { - "name": "python3-packaging", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"packaging~=24.2\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", - "sha256": "09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" - } - ] - }, - { - "name": "python3-pillow", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pillow~=11.1.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", - "sha256": "368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20" - } - ] - }, - { - "name": "python3-platformdirs", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"platformdirs~=4.3.6\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", - "sha256": "73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" - } - ] - }, - { - "name": "python3-pycairo", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pycairo~=1.27.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/07/4a/42b26390181a7517718600fa7d98b951da20be982a50cd4afb3d46c2e603/pycairo-1.27.0.tar.gz", - "sha256": "5cb21e7a00a2afcafea7f14390235be33497a2cce53a98a19389492a60628430" - } - ] - }, - { - "name": "python3-pycparser", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pycparser~=2.22\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", - "sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" - } - ] - }, - { - "name": "python3-PyGObject", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"PyGObject~=3.50.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/07/4a/42b26390181a7517718600fa7d98b951da20be982a50cd4afb3d46c2e603/pycairo-1.27.0.tar.gz", - "sha256": "5cb21e7a00a2afcafea7f14390235be33497a2cce53a98a19389492a60628430" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/2b/58/d34e67a79631177e3c08e7d02b5165147f590171f2cae6769502af5f7f7e/pygobject-3.50.0.tar.gz", - "sha256": "4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103" - } - ] - }, - { - "name": "python3-PyYAML", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"PyYAML~=6.0.2\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", - "sha256": "d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e" - } - ] - }, - { - "name": "python3-requirements-parser", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"requirements-parser~=0.11.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", - "sha256": "09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/33/190393a7d36872e237cbc99e6c44d9a078a1ba7b406462fe6eafd5a28e04/requirements_parser-0.11.0-py3-none-any.whl", - "sha256": "50379eb50311834386c2568263ae5225d7b9d0867fb55cf4ecc93959de2c2684" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/cf/a3/dbfd106751b11c728cec21cc62cbfe7ff7391b935c4b6e8f0bdc2e6fd541/types_setuptools-75.8.0.20250110-py3-none-any.whl", - "sha256": "a9f12980bbf9bcdc23ecd80755789085bad6bfce4060c2275bc2b4ca9f2bc480" - } - ] - }, - { - "name": "python3-scipy", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"scipy~=1.15.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ec/d0/c12ddfd3a02274be06ffc71f3efc6d0e457b0409c4481596881e748cb264/numpy-2.2.2.tar.gz", - "sha256": "ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/76/c6/8eb0654ba0c7d0bb1bf67bf8fbace101a8e4f250f7722371105e8b6f68fc/scipy-1.15.1.tar.gz", - "sha256": "033a75ddad1463970c96a88063a1df87ccfddd526437136b6ee81ff0312ebdf6" - } - ] - }, - { - "name": "python3-svgpathtools", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"svgpathtools~=1.6.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ec/d0/c12ddfd3a02274be06ffc71f3efc6d0e457b0409c4481596881e748cb264/numpy-2.2.2.tar.gz", - "sha256": "ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/76/c6/8eb0654ba0c7d0bb1bf67bf8fbace101a8e4f250f7722371105e8b6f68fc/scipy-1.15.1.tar.gz", - "sha256": "033a75ddad1463970c96a88063a1df87ccfddd526437136b6ee81ff0312ebdf6" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/9a/33/2777b992cff0b4240bbebe96433a37a6382e046d3471fcd14e40e1905ab7/svgpathtools-1.6.1-py2.py3-none-any.whl", - "sha256": "39967f9a817b8a12cc6dd1646fc162d522fca6c3fd5f8c94913c15ee4cb3a906" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/84/15/640e399579024a6875918839454025bb1d5f850bb70d96a11eabb644d11c/svgwrite-1.4.3-py3-none-any.whl", - "sha256": "bb6b2b5450f1edbfa597d924f9ac2dd099e625562e492021d7dd614f65f8a22d" - } - ] - }, - { - "name": "python3-svgwrite", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"svgwrite~=1.4.3\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/84/15/640e399579024a6875918839454025bb1d5f850bb70d96a11eabb644d11c/svgwrite-1.4.3-py3-none-any.whl", - "sha256": "bb6b2b5450f1edbfa597d924f9ac2dd099e625562e492021d7dd614f65f8a22d" - } - ] - }, - { - "name": "python3-tinycss2", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"tinycss2~=1.4.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", - "sha256": "3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", - "sha256": "a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78" - } - ] - }, - { - "name": "python3-types-setuptools", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"types-setuptools~=75.8.0.20250110\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/cf/a3/dbfd106751b11c728cec21cc62cbfe7ff7391b935c4b6e8f0bdc2e6fd541/types_setuptools-75.8.0.20250110-py3-none-any.whl", - "sha256": "a9f12980bbf9bcdc23ecd80755789085bad6bfce4060c2275bc2b4ca9f2bc480" - } - ] - }, - { - "name": "python3-webencodings", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"webencodings~=0.5.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", - "sha256": "a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78" - } - ] - }, - { - "name": "python3-xdg-base-dirs", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"xdg-base-dirs~=6.0.2\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fc/03/030b47fd46b60fc87af548e57ff59c2ca84b2a1dadbe721bb0ce33896b2e/xdg_base_dirs-6.0.2-py3-none-any.whl", - "sha256": "3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe" - } - ] - } - ] -} \ No newline at end of file diff --git a/flatpak/python3-scikit-build.json b/flatpak/python3-scikit-build.json deleted file mode 100644 index f3f5a2540..000000000 --- a/flatpak/python3-scikit-build.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "python3-scikit-build", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"scikit-build>=0.14.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", - "sha256": "7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", - "sha256": "09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/c3/a3/21b519f58de90d684056c52ec4e45f744cfda7483f082dcc4dd18cc74a93/scikit_build-0.18.1-py3-none-any.whl", - "sha256": "a6860e300f6807e76f21854163bdb9db16afc74eadf34bd6a9947d3fdfcd725a" - } - ] -} diff --git a/flatpak/python3-setuptools-git-versioning.json b/flatpak/python3-setuptools-git-versioning.json deleted file mode 100644 index 8b71289e9..000000000 --- a/flatpak/python3-setuptools-git-versioning.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "python3-setuptools-git-versioning", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"setuptools-git-versioning\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", - "sha256": "09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/c0/ba/daf16c2d1965bf6237fb696639e3e93645ac6801f7dcaf9ec694a74e9326/setuptools_git_versioning-2.1.0-py3-none-any.whl", - "sha256": "09a15cbb9a00884e91a3591a4c9ec1ff93c24b1b4a40de39a44815196beb7ebf" - } - ] -} \ No newline at end of file diff --git a/flatpak/python3-setuptools.json b/flatpak/python3-setuptools.json deleted file mode 100644 index e9d951060..000000000 --- a/flatpak/python3-setuptools.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "python3-setuptools", - "buildsystem": "simple", - "build-commands": [], - "modules": [] -} \ No newline at end of file diff --git a/hooks/hook-gi.repository.Gtk.py b/hooks/hook-gi.repository.Gtk.py new file mode 100644 index 000000000..5835dd886 --- /dev/null +++ b/hooks/hook-gi.repository.Gtk.py @@ -0,0 +1,57 @@ +#----------------------------------------------------------------------------- +# Custom hook for Gtk 4.0 (overrides PyInstaller's default Gtk 3.0 hook) +#----------------------------------------------------------------------------- + +import os +import os.path + +from PyInstaller.compat import is_win +from PyInstaller.utils.hooks import get_hook_config +from PyInstaller.utils.hooks.gi import ( + GiModuleInfo, + collect_glib_etc_files, + collect_glib_share_files, + collect_glib_translations, +) + + +def hook(hook_api): + # Use GTK 4.0 instead of the default 3.0 + module_info = GiModuleInfo('Gtk', '4.0', hook_api=hook_api) + if not module_info.available: + return + + binaries, datas, hiddenimports = module_info.collect_typelib_data() + + # Collect fontconfig data + datas += collect_glib_share_files('fontconfig') + + # Icons, themes, translations + icon_list = get_hook_config(hook_api, "gi", "icons") + if icon_list is not None: + for icon in icon_list: + datas += collect_glib_share_files(os.path.join('icons', icon)) + else: + datas += collect_glib_share_files('icons') + + # Themes + theme_list = get_hook_config(hook_api, "gi", "themes") + if theme_list is not None: + for theme in theme_list: + datas += collect_glib_share_files(os.path.join('themes', theme)) + else: + datas += collect_glib_share_files('themes') + + # Translations - use gtk40 for GTK 4.0 + lang_list = get_hook_config(hook_api, "gi", "languages") + datas += collect_glib_translations('gtk40', lang_list) + + # These only seem to be required on Windows + if is_win: + datas += collect_glib_etc_files('fonts') + datas += collect_glib_etc_files('pango') + datas += collect_glib_share_files('fonts') + + hook_api.add_datas(datas) + hook_api.add_binaries(binaries) + hook_api.add_imports(*hiddenimports) diff --git a/hooks/hook-rayforge.py b/hooks/hook-rayforge.py new file mode 100644 index 000000000..05ba54cb1 --- /dev/null +++ b/hooks/hook-rayforge.py @@ -0,0 +1,13 @@ +# ----------------------------------------------------------------------------- +# Hook for rayforge package - ensures all submodules are collected +# This is needed because builtin addons import from rayforge but are +# loaded dynamically, so PyInstaller's static analysis misses these imports. +# ----------------------------------------------------------------------------- + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +# Collect all submodules from rayforge and its subpackages +hiddenimports = collect_submodules("rayforge") + +# Collect data files from rayforge (locale, resources, etc.) +datas = collect_data_files("rayforge", include_py_files=False) diff --git a/hooks/hook-raygeo.py b/hooks/hook-raygeo.py new file mode 100644 index 000000000..2dc6c5537 --- /dev/null +++ b/hooks/hook-raygeo.py @@ -0,0 +1,17 @@ +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_submodules, +) + +hiddenimports = collect_submodules("raygeo") +hiddenimports += [ + "raygeo.geo", + "raygeo.geo.algo", + "raygeo.geo.types", + "raygeo.ops", + "raygeo.ops.axis", + "raygeo.ops.state", + "raygeo.ops.types", +] + +datas = collect_data_files("raygeo", include_py_files=True) diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 000000000..7cd4d5bba --- /dev/null +++ b/pixi.lock @@ -0,0 +1,9672 @@ +version: 7 +platforms: +- name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 +- name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 +environments: + build: + channels: + - url: https://conda.anaconda.org/conda-forge/ + - url: https://conda.anaconda.org/bioconda/ + - url: https://conda.anaconda.org/msys2/ + - url: https://conda.anaconda.org/coastline/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/appstream-1.1.1-py314h5c79613_2.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-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-hb03c661_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.2-h27c8c51_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.3-h95f0039_0.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/gtk4-4.22.4-h5525360_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.3.0-ha770c72_0.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-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.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/lerc-4.2.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.9.3-h8422834_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-devel-0.25.1-h3f43e3d_1.conda + - 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.21.0-heca4667_4.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.127-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfyaml-0.9.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.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.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.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 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.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.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.0-hf670292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.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-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.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/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.2-hda50119_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb03c661_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - 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 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + 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 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/appstream-1.1.1-py314hac25a1c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.2-h2b252f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.7-h4e57454_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-h3dcc1bd_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.3-h5f197ff_0.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/gtk4-4.22.4-hafa59c1_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.3.0-hce30654_0.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-hc7cc350_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.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/libadwaita-1.9.3-h033e7b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.21.0-hf618e03_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-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/libedit-3.1.20250104-pl5321hafb1f1b_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/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfyaml-0.9.6-h84a0fba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_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.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.2.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.0-h7a62e17_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-h282da08_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-h3feff0a_0.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.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.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/libzlib-1.3.2-h8088a28_3.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/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.58.2-hf80efc4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.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/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + - url: https://conda.anaconda.org/bioconda/ + - url: https://conda.anaconda.org/msys2/ + - url: https://conda.anaconda.org/coastline/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/appstream-1.1.1-py314h5c79613_2.conda + - 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/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cfitsio-4.6.4-hab81a10_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-hb03c661_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fftw-3.3.11-nompi_h3b011a4_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.2-h27c8c51_0.conda + - 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_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py314hef2df3c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-host-tools-1.86.0-h1167242_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-16.1.0-h5fcb69b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ghostscript-10.07.1-hecca717_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/glib-2.88.3-h84d461a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.3-h95f0039_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py314h626c733_0.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.22.4-h5525360_4.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.3.0-ha770c72_0.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-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_27-agpl_h9fd05cd_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-h1588d4d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.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/lerc-4.2.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.9.3-h8422834_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.9-gpl_hc2c16d8_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-devel-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.2-h0ed3d04_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-9_h4a7cf45_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-9_h0358290_openblas.conda + - 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.21.0-heca4667_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/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.127-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexif-0.6.26-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfyaml-0.9.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h5fbf134_12.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.1.0-h79bb938_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgirepository-1.86.0-hac26d07_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.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.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.1-gpl_h214e2e5_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h3a9caae_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.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/libjxl-0.12.0-h174a0a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-9_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_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_h94d23a6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_3.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.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.0-hf670292_0.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.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-16.1.0-hf2715c6_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.1.0-hdf11a46_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvips-8.18.5-ha123a74_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.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-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.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.2-h6991a6a_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-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda + - 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.2-py314h2b28147_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-h6de6307_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.31.0-h8d634f6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openslide-4.0.1-hbacd67c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.2-hda50119_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/patchelf-0.17.2-h58526e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h7c397b8_1011.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-hb03c661_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.0-py314h9cd037b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.56.3-py314h4bbdf5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py314hc02f841_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.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-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.97.1-h53717f1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.0-py314hf07bd8e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-compositeproto-0.4.2-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-damageproto-1.2.1-hb9d3cd8_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-glproto-1.4.17-hb9d3cd8_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-inputproto-2.3.2-hb9d3cd8_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-kbproto-1.0.7-hb9d3cd8_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxt-1.3.1-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-presentproto-1.1-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-renderproto-0.11.1-hb9d3cd8_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xextproto-7.3.0-hb9d3cd8_1004.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xf86vidmodeproto-2.3.1-hb9d3cd8_1005.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xineramaproto-1.2.1-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xproto-7.0.31-hb9d3cd8_1008.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-16.1.0-h59071f9_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-16.1.0-h41cdd0d_101.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.20.0-pyhd8ed1ab_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.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.97.1-h2c6d0dc_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/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/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-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/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.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/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.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/27/4a/6cd81533ab277ae6c73256fb0b6eab2b3e03d5c6e05416763fbf3c921208/raygeo-1.38.3.tar.gz + - pypi: https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/2d/6a/282936de9faac6addf6bc8792c18e006489d0023ffd8856b8643f54d0558/pyvips-3.1.1.tar.gz + - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/37/82/19a03ba344ecb66ea8caab697b3059e0fbea576420f99945944c479caa78/trimesh-5.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl + - 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/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/62/47/9af590d5319433f976baaa9ae1bdd309a68ea5201403246a48d2ba827e39/vtracer-0.6.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.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/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-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/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.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/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.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/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.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/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.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/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-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/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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/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.20.0-pyhd8ed1ab_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-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.97.1-hf6ec828_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/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-7_kmp_llvm.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aom-3.14.1-pl5321h513545f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/appstream-1.1.1-py314hac25a1c_2.conda + - 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/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cfitsio-4.6.4-h29bb15e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/expat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fftw-3.3.11-nompi_haf1500d_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.2-h2b252f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py314he620bd9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-host-tools-1.86.0-h148b53a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.7-h4e57454_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-h3dcc1bd_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-h493aca8_0.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/glib-2.88.3-hf4c8184_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.3-h5f197ff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py314h7098110_0.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.22.4-hafa59c1_4.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.3.0-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-1.14.6-nompi_had3affe_110.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-hc7cc350_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_27-agpl_h3167ce9_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imath-3.2.2-h13e8271_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jasper-4.2.9-h7543a42_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hdfa7624_1.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/libadwaita-1.9.3-h033e7b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libaec-1.1.5-h8664d51_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.9-gpl_h6fbacd7_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libavif16-1.4.2-h84013e8_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-9_h51639a9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-9_hb0561ab_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.21.0-hf618e03_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.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/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-pl5321hafb1f1b_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.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfyaml-0.9.6-h84a0fba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.1.0-h3cf6597_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgd-2.3.3-h05bcc79_12.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-16.1.0-h07b0088_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.1.0-h32cdfcc_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgirepository-1.86.0-h8c9ecdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_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.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.1-gpl_h800d22d_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-h493e8d7_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.2.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.12.0-h934fa54_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-9_hd9741b5_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmatio-1.5.30-h8eade5c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_he657e61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.0-h7a62e17_0.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.3-he8aa2a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-h282da08_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvips-8.18.5-hacd21a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-h3feff0a_0.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-hdb1d25a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.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.2-h1336266_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-22.1.8-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.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.2-py314hb79c6fa_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.4.13-he09da85_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hd9e9057_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjph-0.31.0-h2a4d681_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openslide-4.0.1-h8cefcf1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.58.2-hf80efc4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/patchelf-0.18.0-h965bd2d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.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/pkg-config-0.29.2-hdc6e874_1011.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-h84a0fba_1003.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.0-py314hde3b82e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.56.3-py314h089b223_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py314hdcf55e8_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.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-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.97.1-h4ff7c5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.0-py314h18e1515_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h0cb729a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-hbc6ce65_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libice-1.1.2-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libsm-1.2.6-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libx11-1.8.13-hf948f5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxext-1.3.7-h84a0fba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxrender-0.9.12-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.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/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.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/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.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/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/2d/6a/282936de9faac6addf6bc8792c18e006489d0023ffd8856b8643f54d0558/pyvips-3.1.1.tar.gz + - pypi: https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/37/82/19a03ba344ecb66ea8caab697b3059e0fbea576420f99945944c479caa78/trimesh-5.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/4d/21/74ec0b790c1b2c1e7c28f1ebea5684a5bb568a0ee4350892351f2617ba1f/raygeo-1.38.3-cp311-abi3-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.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/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.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/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.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/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.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/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.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/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-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/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.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/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.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/ca/c6/22c009b147de23fb8d1b18587ec303ab99fe4af423802185a2991a0a5bc4/vtracer-0.6.15-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.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/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.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/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl + video: + channels: + - url: https://conda.anaconda.org/conda-forge/ + - url: https://conda.anaconda.org/bioconda/ + - url: https://conda.anaconda.org/msys2/ + - url: https://conda.anaconda.org/coastline/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/appstream-1.1.1-py314h5c79613_2.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-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-hb03c661_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-9.0.0-gpl_h95e667c_900.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.2-h27c8c51_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.3-h95f0039_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.5.0-h718be3e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.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/gtk4-4.22.4-h5525360_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.3.0-ha770c72_0.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-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.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/lerc-4.2.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.9.3-h8422834_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.5-h434b012_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-h084b8d7_1.conda + - 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.21.0-heca4667_4.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/libdovi-3.4.0-ha23c83e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfyaml-0.9.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.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.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h3a9caae_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.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/libjxl-0.12.0-h174a0a3_1.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/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.3.0-hb2f3c86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.3.0-h9f30d58_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.3.0-h9f30d58_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.3.0-hfeb6f35_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.3.0-hb2f3c86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.3.0-hb2f3c86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.3.0-hb2f3c86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.3.0-hfeb6f35_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.3.0-h6c33c14_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.3.0-h6c33c14_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.3.0-h0542e35_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.3.0-h565fa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.3.0-h0542e35_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.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/libplacebo-7.360.1-hc50b9dd_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h2840a7c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.0-hf670292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.1.0-hdf11a46_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.24.1-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.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-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.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/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-h8142553_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.2-hda50119_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb03c661_1003.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-h9a6aba3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.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.14-hdeec2a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.3-h1b60276_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - 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 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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-8_cp314.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 + 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 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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-8_cp314.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-pl5321h513545f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/appstream-1.1.1-py314hac25a1c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dbus-1.16.2-h3ff7a7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ffmpeg-9.0.0-gpl_habfc2cb_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.2-h2b252f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.7-h4e57454_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.3-h5f197ff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glslang-16.5.0-hf31e910_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.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/gtk4-4.22.4-hafa59c1_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.3.0-hce30654_0.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-hc7cc350_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + - 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-hdfa7624_1.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_h2062a1b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.9.3-h033e7b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libass-0.17.5-h3245dfc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.21.0-hf618e03_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-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/libdovi-3.4.0-h78f8ca3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_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/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfyaml-0.9.6-h84a0fba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_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.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.13.0-default_ha97f43a_1000.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-h493e8d7_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.2.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.12.0-h934fa54_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libogg-1.3.5-h48c0fde_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-2026.3.0-hb34758f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-arm-cpu-plugin-2026.3.0-hb34758f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-batch-plugin-2026.3.0-h9254539_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-plugin-2026.3.0-h9254539_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-hetero-plugin-2026.3.0-hd4b9630_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-ir-frontend-2026.3.0-hd4b9630_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-onnx-frontend-2026.3.0-h543423f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-paddle-frontend-2026.3.0-h543423f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-pytorch-frontend-2026.3.0-haa2453c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-frontend-2026.3.0-habdbaaf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-lite-frontend-2026.3.0-haa2453c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopus-1.6.1-h1a92334_0.conda + - 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.58-h132b30e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-7.35.1-h8daa630_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.0-h7a62e17_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-h282da08_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libusb-1.0.29-hbc156a2_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.15.2-ha759d40_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-h3feff0a_0.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.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.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/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mpg123-1.33.7-hbb31fce_0.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/openh264-2.6.0-hdf0efb5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.58.2-hf80efc4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.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.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.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.14-h6fa9c73_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/shaderc-2026.3-h565cd3f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.2-h4ddebb9_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h0cb729a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tbb-2023.0.0-he0260a5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x264-1!164.3095-h57fd34a_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-hbc6ce65_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl + website: + channels: + - url: https://conda.anaconda.org/conda-forge/ + - url: https://conda.anaconda.org/bioconda/ + - url: https://conda.anaconda.org/msys2/ + - url: https://conda.anaconda.org/coastline/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/appstream-1.1.1-py314h5c79613_2.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-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-hb03c661_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.2-h27c8c51_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.3-h95f0039_0.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/gtk4-4.22.4-h5525360_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.3.0-ha770c72_0.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-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.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/lerc-4.2.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.9.3-h8422834_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - 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.21.0-heca4667_4.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.127-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfyaml-0.9.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.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.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.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 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.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.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.0-hf670292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.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-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.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/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.6.0-hc039f44_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.2-hda50119_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb03c661_1003.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - 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 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + 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 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - 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-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/appstream-1.1.1-py314hac25a1c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.2-h2b252f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.7-h4e57454_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.3-h5f197ff_0.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/gtk4-4.22.4-hafa59c1_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.3.0-hce30654_0.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-hc7cc350_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.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_h2062a1b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.9.3-h033e7b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.21.0-hf618e03_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-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/libedit-3.1.20250104-pl5321hafb1f1b_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/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfyaml-0.9.6-h84a0fba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_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.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.3.0-h5a65909_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.2.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.0-h7a62e17_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-h282da08_0.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-h3feff0a_0.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.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.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/libzlib-1.3.2-h8088a28_3.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/nodejs-26.6.0-h00e74ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.58.2-hf80efc4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.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/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + sha256: cf93ca0f1f107e95a35969a4622684e08fcb8cf37f8cf4a1e9e424828386c921 + md5: 8904e09bda369377b3dd07e2ac828c5d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - alsa-lib >=1.2.16.1,<1.3.0a0 + size: 592377 + timestamp: 1781521980743 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + sha256: b1d972a9b949a88babee681437535550b3ca5dbca6a23a40dffeb7900fec19fd + md5: 5a78a69eb3b50f24b379e9d2a93163ae + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - aom >=3.14.1,<3.15.0a0 + size: 3103347 + timestamp: 1780752473089 +- conda: https://conda.anaconda.org/conda-forge/linux-64/appstream-1.1.1-py314h5c79613_2.conda + sha256: 0548baecff31948716c5704068b6ccfdee9314e63ca2d3c2d40956953b602496 + md5: 4378a0f239113f1f40ad5c11a9dfb66a + depends: + - python + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libglib >=2.86.4,<3.0a0 + - libxmlb >=0.3.25,<0.4.0a0 + - libfyaml >=0.9.4,<0.10.0a0 + - python_abi 3.14.* *_cp314 + - libcurl >=8.18.0,<9.0a0 + - libzlib >=1.3.1,<2.0a0 + - libxml2 + - libxml2-16 >=2.15.1 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - appstream >=1.1.1,<1.2.0a0 + size: 2391094 + timestamp: 1771521889244 +- conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2 + sha256: 26ab9386e80bf196e51ebe005da77d57decf6d989b4f34d96130560bc133479c + md5: 6b889f174df1e0f816276ae69281af4d + depends: + - at-spi2-core >=2.40.0,<2.41.0a0 + - atk-1.0 >=2.36.0 + - dbus >=1.13.6,<2.0a0 + - libgcc-ng >=9.3.0 + - libglib >=2.68.1,<3.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - at-spi2-atk >=2.38.0,<3.0a0 + size: 339899 + timestamp: 1619122953439 +- conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2 + sha256: c4f9b66bd94c40d8f1ce1fad2d8b46534bdefda0c86e3337b28f6c25779f258d + md5: 8cb2fc4cd6cc63f1369cfa318f581cc3 + depends: + - dbus >=1.13.6,<2.0a0 + - libgcc-ng >=9.3.0 + - libglib >=2.68.3,<3.0a0 + - xorg-libx11 + - xorg-libxi + - xorg-libxtst + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - at-spi2-core >=2.40.3,<2.41.0a0 + size: 658390 + timestamp: 1625848454791 +- conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda + sha256: df682395d05050cd1222740a42a551281210726a67447e5258968dd55854302e + md5: f730d54ba9cd543666d7220c9f7ed563 + depends: + - libgcc-ng >=12 + - libglib >=2.80.0,<3.0a0 + - libstdcxx-ng >=12 + constrains: + - atk-1.0 2.38.0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - 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 + depends: + - ld_impl_linux-64 2.46.1 default_hbd61a6d_102 + - 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 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 + md5: e675fabcf81499adc7edf58124fb1e01 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 257808 + timestamp: 1785906269155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-h280c20c_1.conda + sha256: 5139b6afbfaca91c47104ba9a6a40f81211e1c9200e96b73ce3a56f0ca1902f4 + md5: 2cef891b791040aab83e218c7d137679 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - c-ares-static <0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - c-ares >=1.34.8,<2.0a0 + size: 226755 + timestamp: 1786116641939 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + sha256: 06525fa0c4e4f56e771a3b986d0fdf0f0fc5a3270830ee47e127a5105bde1b9a + md5: bb6c4808bfa69d6f7f6b07e5846ced37 + depends: + - __glibc >=2.17,<3.0.a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libglib >=2.86.3,<3.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.46.4,<1.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - xorg-libsm >=1.2.6,<2.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: LGPL-2.1-only or MPL-1.1 + purls: [] + run_exports: + weak: + - cairo >=1.18.4,<2.0a0 + size: 989514 + timestamp: 1766415934926 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cfitsio-4.6.4-hab81a10_1.conda + sha256: e770ca978296eb95648e8e306477f7ba67af2b481324a8ca30742b6a5377e665 + md5: fcbbaa771ddad5047edf3c4ee7ac01da + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: LicenseRef-fitsio + purls: [] + run_exports: + weak: + - cfitsio >=4.6.4,<4.6.5.0a0 + size: 760708 + timestamp: 1777678404791 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + sha256: 22053a5842ca8ee1cf8e1a817138cdb5e647eb2c46979f84153f6ad7bde73020 + md5: 418c6ca5929a611cbd69204907a83995 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - dav1d >=1.2.1,<1.2.2.0a0 + size: 760229 + timestamp: 1685695754230 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + sha256: 8bb557af1b2b7983cf56292336a1a1853f26555d9c6cecf1e5b2b96838c9da87 + md5: ce96f2f470d39bd96ce03945af92e280 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - libglib >=2.86.2,<3.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + purls: [] + run_exports: + weak: + - dbus >=1.16.2,<2.0a0 + size: 447649 + timestamp: 1764536047944 +- conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-hb03c661_2.conda + sha256: a5b51e491fec22bcc1765f5b2c8fff8a97428e9a5a7ee6730095fb9d091b0747 + md5: 057083b06ccf1c2778344b6dabace38b + depends: + - __glibc >=2.17,<3.0.a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libegl-devel + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libgl-devel + - libglx >=1.7.0,<2.0a0 + - libglx-devel + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + - xorg-libxxf86vm >=1.1.6,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - epoxy >=1.5.10,<1.6.0a0 + size: 411735 + timestamp: 1758743520805 +- conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.8.1-hecca717_1.conda + sha256: bc1e8177a4ebbbfcda98b6f4a1575f3337e69f0d2f1025a84570533ca27b89eb + md5: e211a78613b9d50a096644b97cede8f7 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat 2.8.1 hecca717_1 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libexpat >=2.8.1,<3.0a0 + size: 147797 + timestamp: 1781203608158 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-9.0.0-gpl_h95e667c_900.conda + sha256: 6cd7e0f7ae87f773b004888d5d1ca569e9c0e260e69faf014cb19f1b983ca706 + md5: d2270310efc9b4616069cd393743d42e + depends: + - __glibc >=2.17,<3.0.a0 + - alsa-lib >=1.2.16.1,<1.3.0a0 + - aom >=3.14.1,<3.15.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - lame >=3.100,<3.101.0a0 + - libass >=0.17.5,<0.17.6.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libharfbuzz >=14.3.0 + - libiconv >=1.18,<2.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - liblzma >=5.8.3,<6.0a0 + - libopenvino >=2026.3.0,<2026.3.1.0a0 + - libopenvino-auto-batch-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-auto-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-hetero-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-intel-cpu-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-intel-gpu-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-intel-npu-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-ir-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-onnx-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-paddle-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-pytorch-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-tensorflow-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.3.0,<2026.3.1.0a0 + - libopus >=1.6.1,<2.0a0 + - libplacebo >=7.360.1,<7.361.0a0 + - librsvg >=2.62.3,<3.0a0 + - libstdcxx >=14 + - libva >=2.24.1,<3.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpl >=2.16.0,<2.17.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - libvulkan-loader >=1.4.357.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.7,<4.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - sdl2 >=2.32.56,<3.0a0 + - svt-av1 >=4.2.0,<4.2.1.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + constrains: + - __cuda >=12.8 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - ffmpeg >=9.0.0,<10.0a0 + size: 13550988 + timestamp: 1786321280624 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fftw-3.3.11-nompi_h3b011a4_100.conda + sha256: 6fd5d681fba20adaca771f138ac52dbf0a52e0dc2ac31b9ce7406068d102a9a7 + md5: 0717f4eb3d18259358a1fa77edb18917 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - libstdcxx >=14 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - fftw >=3.3.11,<4.0a0 + size: 2195198 + timestamp: 1776781721834 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.2-h27c8c51_0.conda + sha256: d77a47bc2b340b997c680241751e581672d1d0377a680eaf0b19026f013f56b7 + md5: 3c702747058a5d0af93fe71e559327f3 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libuuid >=2.42.2,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + size: 292684 + timestamp: 1784754430789 +- conda: https://conda.anaconda.org/conda-forge/linux-64/freeglut-3.2.2-h215f996_4.conda + sha256: f94040a0d7c449038811097e145f223bd3b2ab4c5181870c6e27e1b9dd777d48 + md5: b39dccf5af984bcb68ee2aa0f3213ea6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglu >=9.0.3,<9.1.0a0 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxau >=1.0.12,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libxi >=1.8.2,<2.0a0 + - xorg-libxxf86vm >=1.1.7,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - freeglut >=3.2.2,<4.0a0 + size: 146159 + timestamp: 1776928018299 +- conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_1.conda + sha256: d109354d4584aa1ef6e915a8f02cbe13a9708f384aa167c075953b6f8196111c + md5: 8dd74ae1edf2b6490af0e960be773431 + depends: + - libfreetype 2.14.3 ha770c72_1 + - libfreetype6 2.14.3 h73754d4_1 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: + weak: + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + size: 174748 + timestamp: 1785641176027 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + sha256: 4846a3ca0402f3fe33ad84ed50ab213c6aafde4a0faef3c5002f6bf753e21671 + md5: 1cd10eda5692519d01bb20e086e214c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - fribidi >=1.0.16,<2.0a0 + size: 61782 + timestamp: 1785912528684 +- conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py314hef2df3c_0.conda + sha256: f93bb1833ed2a9770c7cb69714f576506a59968bf248573b015c32e42e2ba85a + md5: 32ca4f091f2244958f3f95c2ca6bb56e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libglib >=2.86.3,<3.0a0 + - pkg-config + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - setuptools + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 364389 + timestamp: 1770759709604 +- conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-host-tools-1.86.0-h1167242_0.conda + sha256: f51c921124d6500e38f7ac9acd9ec316dff22890f4246e4e15b5774431654d08 + md5: b4da80262ffa71c8508e581f8927a0fc + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libgirepository 1.86.0 hac26d07_0 + - libglib >=2.86.3,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 109669 + timestamp: 1770759739211 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-16.1.0-h5fcb69b_1.conda + sha256: 00c87015522248adb5565a1b8f977cfe927831dd7ef0cb0a5d13f896844af719 + md5: 419982d8913246db404319048e062d3e + depends: + - binutils_impl_linux-64 >=2.46.1 + - libgcc >=16.1.0 + - libgcc-devel_linux-64 16.1.0 h59071f9_101 + - libgomp >=16.1.0 + - libsanitizer 16.1.0 hf2715c6_1 + - libstdcxx >=16.1.0 + - libstdcxx-devel_linux-64 16.1.0 h41cdd0d_101 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 85161422 + timestamp: 1785375529345 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + sha256: 1c22e37f9d7e06e9e0582ee5a55c2ddd19ea75f71f44eb13b56f504ef5c37aa5 + md5: 5d355db3e937086e22cf4cb5fe19787c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libglib >=2.88.2,<3.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gdk-pixbuf >=2.44.7,<3.0a0 + size: 581631 + timestamp: 1782591374199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda + sha256: cbfa8c80771d1842c2687f6016c5e200b52d4ca8f2cc119f6377f64f899ba4ff + md5: c42356557d7f2e37676e121515417e3b + depends: + - __glibc >=2.17,<3.0.a0 + - gettext-tools 0.25.1 h3f43e3d_1 + - libasprintf 0.25.1 h3f43e3d_1 + - libasprintf-devel 0.25.1 h3f43e3d_1 + - libgcc >=14 + - libgettextpo 0.25.1 h3f43e3d_1 + - libgettextpo-devel 0.25.1 h3f43e3d_1 + - libiconv >=1.18,<2.0a0 + - libstdcxx >=14 + license: LGPL-2.1-or-later AND GPL-3.0-or-later + purls: [] + run_exports: + weak: + - libasprintf >=0.25.1,<1.0a0 + - libgettextpo >=0.25.1,<1.0a0 + size: 541357 + timestamp: 1753343006214 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda + sha256: c792729288bdd94f21f25f80802d4c66957b4e00a57f7cb20513f07aadfaff06 + md5: a59c05d22bdcbb4e984bf0c021a2a02f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 3644103 + timestamp: 1753342966311 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ghostscript-10.07.1-hecca717_0.conda + sha256: 2686eae130a785566612d808c922372943efe0b21c04afcfa42edfd264ccc1e6 + md5: de2303b5911424628b4ad8bb49855bc8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: AGPL-3.0-only + license_family: AGPL + purls: [] + run_exports: {} + size: 62853028 + timestamp: 1779452333796 +- conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-ha257d8a_1.conda + sha256: 3611c5d605cd56017704e6c01c7bd849e74a656a546d4c49d655fde49ddfeecc + md5: bd7c3a8586ed0101f094962100d30a63 + depends: + - __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 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glib-2.88.3-h84d461a_0.conda + sha256: 75dd599db3ad90cfb90792ffa68c8118e5bdafc16c6135cde9400042990506df + md5: 09243f78bae86499d96123a8699d1f79 + depends: + - python * + - packaging + - libglib ==2.88.3 h0d30a3d_0 + - glib-tools ==2.88.3 h95f0039_0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 85415 + timestamp: 1785442107463 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.3-h95f0039_0.conda + sha256: ccdad3d9149ca12353583818bdfde9f66753da7e8ea6dd35a6312062e2fe60d2 + md5: 841fd9387fd3f24ea69a4f679242e32c + depends: + - libglib ==2.88.3 h0d30a3d_0 + - libffi + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: {} + size: 238245 + timestamp: 1785442107463 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.5.0-h718be3e_1.conda + sha256: b213106181aa7bb52e202ddaef411f106a2e9d641f1ee618fd7ce9e30b265f9b + md5: 3e8c7b2e4ddda1d61b8b3afa01be7d97 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - glslang >=16,<17.0a0 + size: 1395808 + timestamp: 1785879900020 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda + sha256: 309cf4f04fec0c31b6771a5809a1909b4b3154a2208f52351e1ada006f4c750c + md5: c94a5994ef49749880a8139cf9afcbe1 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: GPL-2.0-or-later OR LGPL-3.0-or-later + purls: [] + run_exports: + weak: + - gmp >=6.3.0,<7.0a0 + size: 460055 + timestamp: 1718980856608 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py314h626c733_0.conda + sha256: 2dfc639b2054a1788ce499771c527e52dae7e2cefc212fdf9489e25a8afe9e2d + md5: 8bf415ba12e2706072c8ee9aabcf4838 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - g-ir-build-tools 1.86.0 py314hef2df3c_0 + - g-ir-host-tools 1.86.0 h1167242_0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libgirepository 1.86.0 hac26d07_0 + - libglib >=2.86.3,<3.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 144536 + timestamp: 1770759762630 +- conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-h54a6638_1.conda + sha256: 7fa3b6a9c081fa3e545573152a788d061a0a0ba57df7251cc0f4f75225fc93e7 + md5: f9fe2984587fa8235a6af6004760cd18 + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - graphite2 >=1.3.15,<2.0a0 + size: 102835 + timestamp: 1786118485753 +- conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-14.1.2-h8b86629_0.conda + sha256: 48d4aae8d2f7dd038b8c2b6a1b68b7bca13fa6b374b78c09fcc0757fa21234a1 + md5: 341fc61cfe8efa5c72d24db56c776f44 + depends: + - __glibc >=2.17,<3.0.a0 + - adwaita-icon-theme + - cairo >=1.18.4,<2.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.4,<3.0a0 + - gtk3 >=3.24.43,<4.0a0 + - gts >=0.7.6,<0.8.0a0 + - libexpat >=2.7.3,<3.0a0 + - libgcc >=14 + - libgd >=2.3.3,<2.4.0a0 + - libglib >=2.86.3,<3.0a0 + - librsvg >=2.60.0,<3.0a0 + - libstdcxx >=14 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pango >=1.56.4,<2.0a0 + license: EPL-1.0 + license_family: Other + purls: [] + run_exports: + weak: + - graphviz >=14.1.2,<15.0a0 + size: 2426455 + timestamp: 1769427102743 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.52-ha5ea40c_0.conda + sha256: c6bb4f06331bcb0a566d84e0f0fad7af4b9035a03b13e2d5ecfaf13be57e6e10 + md5: bcaea22d85999a4f17918acfab877e61 + depends: + - __glibc >=2.17,<3.0.a0 + - at-spi2-atk >=2.38.0,<3.0a0 + - atk-1.0 >=2.38.0 + - cairo >=1.18.4,<2.0a0 + - epoxy >=1.5.10,<1.6.0a0 + - fontconfig >=2.17.1,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - gdk-pixbuf >=2.44.5,<3.0a0 + - glib-tools + - harfbuzz >=13.2.1 + - hicolor-icon-theme + - libcups >=2.3.3,<2.4.0a0 + - libcups >=2.3.3,<3.0a0 + - libexpat >=2.7.4,<3.0a0 + - libfreetype >=2.14.2 + - libfreetype6 >=2.14.2 + - libgcc >=14 + - libglib >=2.86.4,<3.0a0 + - liblzma >=5.8.2,<6.0a0 + - libxkbcommon >=1.13.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - pango >=1.56.4,<2.0a0 + - wayland >=1.25.0,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxcomposite >=0.4.7,<1.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libxi >=1.8.2,<2.0a0 + - xorg-libxinerama >=1.1.6,<1.2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gtk3 >=3.24.52,<4.0a0 + - adwaita-icon-theme + size: 5939083 + timestamp: 1774288645605 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gtk4-4.22.4-h5525360_4.conda + sha256: 15e0cf3a62102f2131a6d320a3ac8cfd2c8779e7a42b25e13c4ff6fee30163fd + md5: f817c0f897d54fb0cfc94dd538b67af8 + depends: + - hicolor-icon-theme + - pango + - libgraphene + - fribidi + - fontconfig + - libcups >=2.3.3,<3.0a0 + - glib-tools + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.28,<3.0.a0 + - epoxy >=1.5.10,<1.6.0a0 + - xorg-libxcomposite >=0.4.7,<1.0a0 + - xorg-libxinerama >=1.1.6,<1.2.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - librsvg >=2.62.3,<3.0a0 + - cairo >=1.18.4,<2.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - harfbuzz >=14.2.1 + - xorg-libsm >=1.2.6,<2.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - xorg-libxi >=1.8.3,<2.0a0 + - libpng >=1.6.58,<1.7.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - gdk-pixbuf >=2.44.6,<3.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - wayland >=1.25.0,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + - pango >=1.56.4,<2.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - libglib >=2.88.1,<3.0a0 + - libcups >=2.3.3,<2.4.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gtk4 >=4.22.4,<5.0a0 + - adwaita-icon-theme + size: 24377145 + timestamp: 1782208644363 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda + sha256: b5cd16262fefb836f69dc26d879b6508d29f8a5c5948a966c47fe99e2e19c99b + md5: 4d8df0b0db060d33c9a702ada998a8fe + depends: + - libgcc-ng >=12 + - libglib >=2.76.3,<3.0a0 + - libstdcxx-ng >=12 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gts >=0.7.6,<0.8.0a0 + size: 318312 + timestamp: 1686545244763 +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.3.0-ha770c72_0.conda + sha256: 511813aaad42ed2494efc77452738fed7734985e069efc08c279a701b1708ba0 + md5: 7f42f814e1306f83e4cac267baa9acab + depends: + - libharfbuzz-devel 14.3.0 h17a8019_0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libharfbuzz >=14.3.0 + size: 11045 + timestamp: 1785770087614 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda + sha256: ac57ce3abbda2a82d6192bc36fbd7fe96e867d84c999ef81a3da034dfd01a995 + md5: 9c6e11a83468e1d5decae516077a73fb + depends: + - __glibc >=2.17,<3.0.a0 + - libaec >=1.1.5,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - hdf5 >=1.14.6,<1.14.7.0a0 + size: 3721555 + timestamp: 1780581675871 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_3.conda + sha256: 6d7e6e1286cb521059fe69696705100a03b006efb914ffe82a2ae97ecbae66b7 + md5: 129e404c5b001f3ef5581316971e3ea0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 17625 + timestamp: 1771539597968 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + sha256: d7c260b7e1cf22ce04d6ba8a86eabf4e6c50bc96a5c27fe2ecb32298af3e88eb + md5: 4ef4b977bb216a3001a3334696a80850 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14455340 + timestamp: 1784916378180 +- conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_27-agpl_h9fd05cd_100.conda + sha256: 51fb2005b2394cb99e70dd4dbcdf9c85981621126b684f2d9fcaae2347ab1b96 + md5: 7ab41fba53fa329b9a0bfc13b200939a + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - fftw >=3.3.11,<4.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - fonts-conda-forge + - ghostscript + - giflib >=5.2.2,<5.3.0a0 + - graphviz >=14.1.2,<15.0a0 + - lcms2 >=2.19.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.2,<3.0a0 + - libheif >=1.23.0,<1.24.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libraw >=0.22.1,<0.23.0a0 + - librsvg >=2.62.3,<3.0a0 + - libstdcxx >=14 + - libtiff >=4.7.2,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzip >=1.11.2,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openexr >=3.4.13,<3.5.0a0 + - openjpeg >=2.5.4,<3.0a0 + - pango >=1.56.4,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - xorg-libxt >=1.3.1,<2.0a0 + license: AGPL-3.0-only AND ImageMagick + license_family: AGPL + purls: [] + run_exports: {} + size: 2618505 + timestamp: 1783294501637 +- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.2-hde8ca8f_0.conda + sha256: 43f30e6fd8cbe1fef59da760d1847c9ceff3fb69ceee7fd4a34538b0927959dd + md5: c427448c6f3972c76e8a4474e0fe367b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - imath >=3.2.2,<3.2.3.0a0 + size: 160289 + timestamp: 1759983212466 +- conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + sha256: bc231d69eb6663db0e09738fb916c5e5507147cf1ac60f364f964004e0b29bab + md5: 10909406c1b0e4b57f9f4f0eb0999af8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - intel-gmmlib >=22.10.0,<23.0a0 + size: 1013714 + timestamp: 1774422680665 +- conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + sha256: 7cbd7fda22db70c64af64c9173434a4ede58e4f220bda52a044e469aa94c65cb + md5: aaf7c3db8c7c4533deb5449d3ba1c51f + depends: + - __glibc >=2.17,<3.0.a0 + - intel-gmmlib >=22.10.0,<23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libva >=2.23.0,<3.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - intel-media-driver >=26.1.6,<26.2.0a0 + size: 8782375 + timestamp: 1776080148587 +- conda: https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-h1588d4d_1.conda + sha256: a6a9858eadb4c794b56a1c954c1d4f4b57d96c9fb87092dd46f5bff9b0697b35 + md5: 115ecf05370670f93bc81a8c4f7fd57f + depends: + - __glibc >=2.17,<3.0.a0 + - freeglut >=3.2.2,<4.0a0 + - libexpat >=2.7.4,<3.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglu >=9.0.3,<10.0a0 + - libglu >=9.0.3,<9.1.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + license: JasPer-2.0 + purls: [] + run_exports: + weak: + - jasper >=4.2.9,<5.0a0 + size: 684185 + timestamp: 1773677703432 +- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 + md5: b38117a3c920364aff79f870c984b4a3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - keyutils >=1.6.3,<2.0a0 + size: 134088 + timestamp: 1754905959823 +- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + sha256: 9b07046870772f28740e3f6149f09ff222843733087a33c5540b169c6289652d + md5: 54157a1c8c0bb70f62dd0b17fba7e7f2 + depends: + - __glibc >=2.17,<3.0.a0 + - keyutils >=1.6.3,<2.0a0 + - libedit >=3.1.20250104,<3.2.0a0 + - libedit >=3.1.20250104,<4.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - krb5 >=1.22.2,<1.23.0a0 + size: 1388990 + timestamp: 1781859420533 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + sha256: aad2a703b9d7b038c0f745b853c6bb5f122988fe1a7a096e0e606d9cbec4eaab + md5: a8832b479f93521a9e7b5b743803be51 + depends: + - libgcc-ng >=12 + license: LGPL-2.0-only + license_family: LGPL + purls: [] + run_exports: + weak: + - lame >=3.100,<3.101.0a0 + size: 508258 + timestamp: 1664996250081 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda + sha256: 112b5b9462572d970f4abd2912f76a25ee7db158b1e7260163d91dd8a630db84 + md5: 8b3ce45e929cd8e8e5f4d18586b56d8b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - lcms2 >=2.19.1,<3.0a0 + size: 251971 + timestamp: 1780211695895 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + sha256: 27d83f1188cd19bcb7754a078b3fa7f4cfb8527f8eb2fde54dd01fc529d1adec + md5: 449500f2c089da11c40f5c21312e3e07 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.46.1 + license: GPL-3.0-only + license_family: GPL + purls: [] + run_exports: {} + size: 745303 + timestamp: 1784214507189 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda + sha256: bf9fdebf55d8bc99d83531cdffda00703f4dc5f93a1a956768c147362c72feda + md5: fb9d356b1a57d6d54768be7ebd5fce09 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - lerc >=4.2.0,<5.0a0 + size: 271158 + timestamp: 1785036167977 +- conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + sha256: d87cfc5eaa08eefff97d891ecb49faa958fcfc32a425767796269c4100d4e516 + md5: f3c3bc77c96af553f761af0e78bc8d9d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 875773 + timestamp: 1780142086148 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + sha256: 32933de2d4fa6e6ffd949052815b49cb65a0649ad70007155c533ab97ea8cefd + md5: c4393db381bffa0a83a8d9e47b238106 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - abseil-cpp =20260526.0 + - libabseil-static =20260526.0=cxx17* + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - libabseil >=20260526.0,<20260527.0a0 + - libabseil =*=cxx17* + size: 1437712 + timestamp: 1780524559298 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.9.3-h8422834_0.conda + sha256: a8a863a40294069a10731d588f739244fe262ecadbb83be8a609495dc1d60e6c + md5: 033e7f38710d02a0ade52998babbdf9a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - appstream >=1.1.1,<1.2.0a0 + - pango >=1.58.0,<2.0a0 + - gtk4 >=4.22.4,<5.0a0 + - libfreetype >=2.14.3 + - libxml2 + - libasprintf >=0.25.1,<1.0a0 + - libgettextpo >=0.25.1,<1.0a0 + - libglib >=2.88.3,<3.0a0 + - fribidi >=1.0.16,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libadwaita >=1.9.3,<1.10.0a0 + size: 982288 + timestamp: 1785768889004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda + sha256: 822e4ae421a7e9c04e841323526321185f6659222325e1a9aedec811c686e688 + md5: 86f7414544ae606282352fa1e116b41f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libaec >=1.1.5,<2.0a0 + size: 36544 + timestamp: 1769221884824 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.9-gpl_hc2c16d8_100.conda + sha256: 000b4baa9ce849b5fb259af9256331d0c7872361ac63a2bba0d0c48eb35663d0 + md5: 3988040b14a8083b1a5382d99f584e5f + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - lzo >=2.10,<3.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libarchive >=3.8.9,<3.9.0a0 + size: 876197 + timestamp: 1785250447640 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda + sha256: cb728a2a95557bb6a5184be2b8be83a6f2083000d0c7eff4ad5bbe5792133541 + md5: 3b0d184bc9404516d418d4509e418bdc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libasprintf >=0.25.1,<1.0a0 + size: 53582 + timestamp: 1753342901341 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-devel-0.25.1-h3f43e3d_1.conda + sha256: 2fc95060efc3d76547b7872875af0b7212d4b1407165be11c5f830aeeb57fc3a + md5: fd9cf4a11d07f0ef3e44fc061611b1ed + depends: + - __glibc >=2.17,<3.0.a0 + - libasprintf 0.25.1 h3f43e3d_1 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libasprintf >=0.25.1,<1.0a0 + size: 34734 + timestamp: 1753342921605 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.5-h434b012_0.conda + sha256: 24d4b59a0267e1c159c3af82df106b42faeefccceba3c489044c93abf113c503 + md5: c1cb4d6e8a6e3f724740dee5346fc8b4 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libzlib >=1.3.2,<2.0a0 + - fribidi >=1.0.16,<2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - libiconv >=1.18,<2.0a0 + - harfbuzz >=14.2.1 + license: ISC + purls: [] + run_exports: + weak: + - libass >=0.17.5,<0.17.6.0a0 + size: 154964 + timestamp: 1782298715788 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.2-h0ed3d04_3.conda + sha256: 0f7ddf0f9438d6c3dad8c344768fd546499454d7ae88070240d2097d91eeeafd + md5: 1adfc4577f7994251b53a5cdc922d7c6 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - svt-av1 >=4.2.0,<4.2.1.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - aom >=3.14.1,<3.15.0a0 + - rav1e >=0.8.1,<0.9.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libavif16 >=1.4.2,<2.0a0 + size: 165201 + timestamp: 1784120029480 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-9_h4a7cf45_openblas.conda + build_number: 9 + sha256: 39c7b3c5427b435c9c059ede9da61d46d42574e5b846ad37fdc3af4a5eab1e48 + md5: f5c4b041925dea221dc4bad2e50569d9 + depends: + - libopenblas >=0.3.34,<0.3.35.0a0 + - libopenblas >=0.3.34,<1.0a0 + constrains: + - blas 2.309 openblas + - libcblas 3.11.0 9*_openblas + - liblapack 3.11.0 9*_openblas + - liblapacke 3.11.0 9*_openblas + - mkl <2027 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libblas >=3.11.0,<4.0a0 + size: 18033 + timestamp: 1786059035239 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e + md5: 72c8fd1af66bd67bf580645b426513ed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + size: 79965 + timestamp: 1764017188531 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b + md5: 366b40a69f0ad6072561c1d09301c886 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 34632 + timestamp: 1764017199083 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d + md5: 4ffbb341c8b616aa2494b6afb26a0c5f + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 + size: 298378 + timestamp: 1764017210931 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-h084b8d7_1.conda + sha256: 8cb25174d6b6fac95d31e86cfe41faffc8ee9dacbf2bfd22e6c23377e8f338c1 + md5: 5db514adf5f843126ff846d1510f22a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libcap >=2.78,<2.79.0a0 + size: 124306 + timestamp: 1786025967663 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-9_h0358290_openblas.conda + build_number: 9 + sha256: 4c532a70ea9aeff2fa1aabaa4828ebc00c2ed12b22aa8ba19da5302b882fc82b + md5: 092c5649f3727af436ab0f67f48c3811 + depends: + - libblas 3.11.0 9_h4a7cf45_openblas + constrains: + - blas 2.309 openblas + - liblapack 3.11.0 9*_openblas + - liblapacke 3.11.0 9*_openblas + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libcblas >=3.11.0,<4.0a0 + size: 17998 + timestamp: 1786059041397 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda + sha256: 205c4f19550f3647832ec44e35e6d93c8c206782bdd620c1d7cf66237580ff9c + md5: 49c553b47ff679a6a1e9fc80b9c5a2d4 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - libcups >=2.3.3,<2.4.0a0 + size: 4518030 + timestamp: 1770902209173 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.21.0-heca4667_4.conda + sha256: aff8ef75636d0825ce34911e0bef2f26816e7afd090a9dcb4b9bacab75cbf584 + md5: 3f2fd5617cfacac49c85f6dc63842ea1 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libnghttp2 >=1.68.1,<2.0a0 + - libpsl >=0.23.0,<0.24.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + purls: [] + run_exports: + weak: + - libcurl >=8.21.0,<9.0a0 + size: 480565 + timestamp: 1785500108494 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.1-h171cf75_0.conda + sha256: 61ea18b0fe5cc57f6a497123a561f3c8414349be6e7ceb3ce5ff060f54d6b830 + md5: 05dd7cae10a462986a357df1102d7ae0 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __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 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda + sha256: 82e134c8a08b1eed9a2ed8ab578b89aa1730dcde3dea8dd87645ed0637878e54 + md5: 40f9b31aa9cf007789867df0decd0492 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 73710 + timestamp: 1785908694612 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdicom-1.3.0-hb03c661_0.conda + sha256: a33fc360063299eb042a21e74e65452ac2c89ada5ab02e9234701a9c02a9afb7 + md5: e4b56e7cd448fa320b35bd2d9c10a3a0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdicom >=1.3.0,<1.4.0a0 + size: 128266 + timestamp: 1781466070354 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.4.0-ha23c83e_0.conda + sha256: cea351b57c30d70e288b53ea69a1dcf6b750992f5d7717a7fc364072fa1209e7 + md5: 4377d220f09344452b227d699cacce4f + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdovi >=3.4.0,<4.0a0 + size: 404998 + timestamp: 1784281566921 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_1.conda + sha256: b4e74db8ed1d5f8b6c9edf9582953c063650d677b299e90585d00f127644eb87 + md5: 65514a7ba857e9dbffbffd641f293e33 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpciaccess >=0.19,<0.20.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdrm >=2.4.127,<2.5.0a0 + size: 311002 + timestamp: 1785984394604 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 + md5: c277e0a4d549b03ac1e9d6cbbe3d017b + depends: + - ncurses + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 134676 + timestamp: 1738479519902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + sha256: 9a25ea93e8272785405a21d30f84e620befb1d545f6dfaae18f06103b5df0443 + md5: 75e9f795be506c96dd43cb09c7c8d557 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + purls: [] + run_exports: {} + size: 46500 + timestamp: 1779728188901 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_3.conda + sha256: e4b46919c9bb65930bce238bd2736110ed7b8c30e5cd5394e4e1edb48de54843 + md5: 5bc6d55503483aabe8a90c5e7f49a2a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libegl 1.7.0 ha4b6fd6_3 + - libgl-devel 1.7.0 ha4b6fd6_3 + - xorg-libx11 + license: LicenseRef-libglvnd + purls: [] + run_exports: + weak: + - libegl >=1.7.0,<2.0a0 + size: 31718 + timestamp: 1779728222280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda + sha256: e4418f68d01a6307c4431b585c71b584f40189877364e542ee87deb777f5e85b + md5: 7bc31538d6e3fb349e897353969237ec + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-2-Clause OR GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - libev >=4.33,<4.34.0a0 + size: 43220 + timestamp: 1785917328200 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexif-0.6.26-h280c20c_3.conda + sha256: 4d13437f5619c210e53331bcd1d289bd18ff6b16a2986822d5c740c5d03ac9eb + md5: 9ff773ddcb6610a29c7dbf198017199a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + purls: [] + run_exports: + weak: + - libexif >=0.6.26,<0.6.27.0a0 + size: 281725 + timestamp: 1780051509193 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + sha256: e755e234236bdda3d265ae82e5b0581d259a9279e3e5b31d745dc43251ad64fb + md5: 47595b9d53054907a00d95e4d47af1d6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libflac >=1.5.0,<1.6.0a0 + size: 424563 + timestamp: 1764526740626 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + sha256: 9f09d889d1021fa0d99968e5f209a7a7b316dee48c97ed0d79e996e1272a6280 + md5: 12a05d10f2e4eadfd7b8f754a17f5e1a + depends: + - libfreetype6 >=2.14.3 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: {} + size: 8419 + timestamp: 1785641173212 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + sha256: 162f1736f9ec7b19915658cbb25a685748932f697418ab85657332de5da5f496 + md5: e63acf9b3849fd9fc2dba64b69849716 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - freetype >=2.14.3 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: {} + size: 386042 + timestamp: 1785641172605 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfyaml-0.9.6-hb03c661_0.conda + sha256: 1560fd6ee73fdce0bc3e4cb1c2e5f7409d111a5213ce51a8da3e011819b97bee + md5: f27c36717e026ee9be4ebe9f404690af + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libfyaml >=0.9.6,<0.10.0a0 + size: 608026 + timestamp: 1773590827634 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + sha256: d5cb8475131c31680f8fd30512c418f373064e272e452063276a8fb14c9fa42f + md5: 5a7d954665c707c93311657cd779c705 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgomp 16.1.0 he0feb66_1 + - libgcc-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 1057877 + timestamp: 1785375436766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + sha256: 225275c562337a1cd61705da0ee4235dde7bba7504de1c34b74c894adb2b0eee + md5: 7ed870c014a6f23c7dfafda53d2763a9 + depends: + - libgcc 16.1.0 ha9f2e26_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: + strong: + - libgcc + size: 28210 + timestamp: 1785375440733 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h5fbf134_12.conda + sha256: 245be793e831170504f36213134f4c24eedaf39e634679809fd5391ad214480b + md5: 88c1c66987cd52a712eea89c27104be6 + depends: + - __glibc >=2.17,<3.0.a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libjpeg-turbo >=3.1.2,<4.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + license: GD + license_family: BSD + purls: [] + run_exports: + weak: + - libgd >=2.3.3,<2.4.0a0 + size: 177306 + timestamp: 1766331805898 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda + sha256: 50a9e9815cf3f5bce1b8c5161c0899cc5b6c6052d6d73a4c27f749119e607100 + md5: 2f4de899028319b27eb7a4023be5dfd2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - libgettextpo >=0.25.1,<1.0a0 + size: 188293 + timestamp: 1753342911214 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda + sha256: c7ea10326fd450a2a21955987db09dde78c99956a91f6f05386756a7bfe7cc04 + md5: 3f7a43b3160ec0345c9535a9f0d7908e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgettextpo 0.25.1 h3f43e3d_1 + - libiconv >=1.18,<2.0a0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - libgettextpo >=0.25.1,<1.0a0 + size: 37407 + timestamp: 1753342931100 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.1.0-h69a702a_1.conda + sha256: 9e82d410a50bd4e5e47cbbb026454c3eb543954baca4db23929575b571bf56a3 + md5: 2fbed65cc90cf0724e1ec4de13696737 + depends: + - libgfortran5 16.1.0 h79bb938_1 + constrains: + - libgfortran-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 28134 + timestamp: 1785375470055 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.1.0-h79bb938_1.conda + sha256: 05078ab464d506dff971860cb1a553b35bc27c0b5ce8ec29b8bfaca5f2359652 + md5: dd51ed33e8c70995f8e33cc9dc537297 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=16.1.0 + constrains: + - libgfortran 16.1.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 2538696 + timestamp: 1785375448623 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgirepository-1.86.0-hac26d07_0.conda + sha256: 87a91e064d0ce6013efcf346a61c974bc1a3c3b9d471af183d16cbaf70065b6c + md5: 3f9d386cb377cf0e6a4575fa7bf1de53 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libglib >=2.86.3,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 140985 + timestamp: 1770759722447 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + sha256: ec353b3076ed8e357ed961d0e9ff6997491cade0e603de5bd18a2e301ac78ebd + md5: f25206d7322c0e9648e8b83694d143ab + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + - libglx 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + purls: [] + run_exports: {} + size: 133469 + timestamp: 1779728207669 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_3.conda + sha256: 41d7d864ad1f199bdb06ff6cc3931455c8af62f1d2071a08c6fa08affbcb678f + md5: 63e43d278ee5084813fe3c2edf4834ce + depends: + - __glibc >=2.17,<3.0.a0 + - libgl 1.7.0 ha4b6fd6_3 + - libglx-devel 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + purls: [] + run_exports: + weak: + - libgl >=1.7.0,<2.0a0 + size: 115664 + timestamp: 1779728218325 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + sha256: 69ea4df61403531e5b3e5f3d52ba2837423df361b4eb8727f5b63aaf6de6768a + md5: 17c3b7b6bcbd35b688c933eb4834c0bc + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - pcre2 >=10.47,<10.48.0a0 + - libffi >=3.5.2,<3.6.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 4755324 + timestamp: 1785442107463 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda + sha256: a0105eb88f76073bbb30169312e797ed5449ebb4e964a756104d6e54633d17ef + md5: 8422fcc9e5e172c91e99aef703b3ce65 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libopengl >=1.7.0,<2.0a0 + - libstdcxx >=13 + license: SGI-B-2.0 + purls: [] + run_exports: + weak: + - libglu >=9.0.3,<9.1.0a0 + size: 325262 + timestamp: 1748692137626 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + sha256: e019ebe4e3f5cdf23e2f5e58ddf7ade27988c53820115b17b98f218ebcc87748 + md5: eb83f3f8cecc3e9bff9e250817fc69b6 + depends: + - __glibc >=2.17,<3.0.a0 + license: LicenseRef-libglvnd + purls: [] + run_exports: {} + size: 133586 + timestamp: 1779728183422 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + sha256: 2f74713c9ca408ea84e88a30a9028153e7b553e8bb42e06139eac9a753c27da9 + md5: ec3c4350aa0261bf7f87b8ca15c8e80e + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + - xorg-libx11 >=1.8.13,<2.0a0 + license: LicenseRef-libglvnd + purls: [] + run_exports: {} + size: 76586 + timestamp: 1779728199059 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_3.conda + sha256: a17ae2d4cb2de04a20882ae14ec3cc1958e868a4dec81e3d7eca30115ee50e94 + md5: 16b6330783ce0d1ae8d22782173b32c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libglx 1.7.0 ha4b6fd6_3 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-xorgproto + license: LicenseRef-libglvnd + purls: [] + run_exports: + weak: + - libglx >=1.7.0,<2.0a0 + size: 27363 + timestamp: 1779728211402 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + sha256: 62cb599ad0539d99386515326d9d5e8f51f75a60c69c2131b21df76edf35bd89 + md5: 88f2d91cb1533194c323534253094d23 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 640415 + timestamp: 1785375373755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgraphene-1.10.8-h23b58f8_2.conda + sha256: 2101b06d852bd62754aafa55553f88d50dd5765497f2068bf6bffcbf8d854871 + md5: 2c91a33ad870e1e84b559dd810b90984 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libglib >=2.86.4,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 123320 + timestamp: 1776657405605 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.3.0-h17a8019_0.conda + sha256: fb72d6f7e90d4927cb53a4e13592559ce74b65052323d5d6dd12499b026c680e + md5: f33637ebded146eafa6b2197c8f597a6 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.15,<2.0a0 + - icu >=78.3,<79.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.3,<3.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 1333436 + timestamp: 1785770053613 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + sha256: 7ce9326c2520ae758f523ae2d72a0762f7494d77fe8cc9a96065df541e1db8e2 + md5: 15634b3c7e5a68ca7c6e0bbf84cb2383 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - freetype + - graphite2 >=1.3.15,<2.0a0 + - icu >=78.3,<79.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.3,<3.0a0 + - libharfbuzz 14.3.0 h17a8019_0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libharfbuzz >=14.3.0 + size: 2081226 + timestamp: 1785770079548 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.1-gpl_h214e2e5_100.conda + sha256: 44aab7b239cd1e6ecaec10433289ad612112ec175b420844472f16fef6c93d1e + md5: 20aacdfbd0eb4b8ebbd0e0536f42e95b + depends: + - __glibc >=2.17,<3.0.a0 + - aom >=3.14.1,<3.15.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - libavif16 >=1.4.2,<2.0a0 + - libde265 >=1.1.1,<1.1.2.0a0 + - libgcc >=14 + - libstdcxx >=14 + - x265 >=3.5,<3.6.0a0 + license: LGPL-3.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - libheif >=1.23.1,<1.24.0a0 + size: 915493 + timestamp: 1784841356330 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + sha256: 5041d295813dfb84652557839825880aae296222ab725972285c5abe3b6e4288 + md5: c197985b58bc813d26b42881f0021c82 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libhwloc >=2.13.0,<2.13.1.0a0 + size: 2436378 + timestamp: 1770953868164 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h3a9caae_0.conda + sha256: 02ab5e50c3921e88ad4dd0bc8f3fe282d2d7d03a20203d3281954b94641deb3d + md5: 9544a7225c8366ea2c397aad5fb53470 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 OR BSD-3-Clause + purls: [] + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 1431901 + timestamp: 1784325535334 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f + md5: 915f5995e94f60e9a4826e0b0920ee88 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + purls: [] + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 790176 + timestamp: 1754908768807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda + sha256: bba8538e6538ed58a8479b332337b96986561f975d06cfa2039a016c2d246ee4 + md5: 898d1c9793eaa52efc4727bd84d2e39a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + purls: [] + run_exports: + weak: + - libjpeg-turbo >=3.2.0,<4.0a0 + size: 650434 + timestamp: 1785896381946 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.12.0-h174a0a3_1.conda + sha256: 1811d6c6558fbfe89326616c207cc7584032b60bc6f4329d2a76b961e2936a15 + md5: 1fff55640e1f12d7606965915be37bbb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libhwy >=1.4.0,<1.5.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libjxl >=0.12.0,<0.13.0a0 + size: 1849836 + timestamp: 1783146019356 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-9_h47877c9_openblas.conda + build_number: 9 + sha256: ea989e2dabd21d296a5a4ec515e695645aefcdf778ffdb5eeea515421d243ab5 + md5: e51473c2b7e1f9cb61daafccfd912abf + depends: + - libblas 3.11.0 9_h4a7cf45_openblas + constrains: + - blas 2.309 openblas + - libcblas 3.11.0 9*_openblas + - liblapacke 3.11.0 9*_openblas + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - liblapack >=3.11.0,<3.12.0a0 + size: 18021 + timestamp: 1786059046733 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda + sha256: 9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab + md5: 1390b7c5ac0b1d8e447bc5efa6d3c8c2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + purls: [] + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 112995 + timestamp: 1786348617826 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmatio-1.5.30-he0a2e19_0.conda + sha256: c9ac2d45e7504a4844f3b56dbac2c78330d67a64432b1ae47de9d7059548edf9 + md5: c253b59cce00f8d6a7588500ff3597b7 + depends: + - __glibc >=2.17,<3.0.a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + - zlib + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libmatio >=1.5.30,<1.5.31.0a0 + size: 202346 + timestamp: 1767753592345 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: {} + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + sha256: 663444d77a42f2265f54fb8b48c5450bfff4388d9c0f8253dd7855f0d993153f + md5: 2a45e7f8af083626f009645a6481f12d + depends: + - __glibc >=2.17,<3.0.a0 + - c-ares >=1.34.6,<2.0a0 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libnghttp2 >=1.68.1,<2.0a0 + size: 663344 + timestamp: 1773854035739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + sha256: ffb066ddf2e76953f92e06677021c73c85536098f1c21fcd15360dbc859e22e4 + md5: 68e52064ed3897463c0e958ab5c8f91b + depends: + - libgcc >=13 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libogg >=1.3.5,<1.4.0a0 + size: 218500 + timestamp: 1745825989535 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_h94d23a6_0.conda + sha256: 23392fc4f4e5ba230fcd1ef825878ba5ca7ee4f6259fac0cbb13299134b7bf7a + md5: c282d68f272927612462b5d626838ef1 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + constrains: + - openblas >=0.3.34,<0.3.35.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libopenblas >=0.3.34,<1.0a0 + size: 5952629 + timestamp: 1784287497473 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda + sha256: 90777039b48529283df5f16383fc399866024257a8bd93de583f4730db1ab30a + md5: c2bd8055a2e2dce7a7f32cfd02101fb6 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + purls: [] + run_exports: {} + size: 51767 + timestamp: 1779728204026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_3.conda + sha256: 6958088c10e21bae95a3db5ff170b3e32a8b439736c1add7c037c312d4bd0b87 + md5: 50c6d76c6c5ec179ad463837f0f12a17 + depends: + - __glibc >=2.17,<3.0.a0 + - libopengl 1.7.0 ha4b6fd6_3 + license: LicenseRef-libglvnd + purls: [] + run_exports: + weak: + - libopengl >=1.7.0,<2.0a0 + size: 16667 + timestamp: 1779728214747 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.3.0-hb2f3c86_0.conda + sha256: b52a974c4414aaf035ea9925e2f584d4a5b54194a7944978650a36c0153a8d9c + md5: 103554a12a2f6666aaaa360d30513911 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino >=2026.3.0,<2026.3.1.0a0 + size: 6958517 + timestamp: 1786130942671 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.3.0-h9f30d58_0.conda + sha256: d70333e5fb2cab7518ba7db2fab371a98670a1b74e7bd53b49e18f1d55e67e38 + md5: 9728955c5c968923cdaf79317837ded4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 115265 + timestamp: 1786130964003 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.3.0-h9f30d58_0.conda + sha256: c9d1c80ec9b267cbbcbf234a358087ea42903f3bccdbbc7c663782943b4679ca + md5: 5fcbc9ef55adbffc1b804ae9c5f0e8d2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 251255 + timestamp: 1786130975924 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.3.0-hfeb6f35_0.conda + sha256: b0614ed14c8896c3c60d06522a40d5e4414024112fe88e6eab62afcbfb259a03 + md5: 00addda23f9daf34d787e8b445d81256 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 224643 + timestamp: 1786130986082 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.3.0-hb2f3c86_0.conda + sha256: 2306149a9b5fbac3a7f4217c868c504cb1706691ef9f63c4d6b0661cd84e160c + md5: 7559b02ec79104bb3e60658310815b4e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 13897562 + timestamp: 1786130996462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.3.0-hb2f3c86_0.conda + sha256: 1442f41ff6ae171aba788c6e3bf1b156759b9f239d06a974f723b56e8bcb067b + md5: e9fed808af5cab2c1cf79c4a1c5fc399 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - ocl-icd >=2.3.4,<3.0a0 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 12124352 + timestamp: 1786131034750 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.3.0-hb2f3c86_0.conda + sha256: 1634885934423c4ffbaee56d9699190adc67334eab92b370f9015e5fc1247e58 + md5: 2402c917805aa66fd8604fc0b7292ec7 + depends: + - __glibc >=2.17,<3.0.a0 + - level-zero >=1.29.0,<2.0a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 2802782 + timestamp: 1786131067457 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.3.0-hfeb6f35_0.conda + sha256: 4bca7d1b71182b1c95a82e77ad01402b261dac603acd9200f5b492e65bc8ca3d + md5: 2254de1e118bb39c876297b941c41d7b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-ir-frontend >=2026.3.0,<2026.3.1.0a0 + size: 205085 + timestamp: 1786131080935 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.3.0-h6c33c14_0.conda + sha256: f07168ce6d25aa529458c5c548df0b0e8b4033c8093a22aed92ee818c2e06679 + md5: 5a81c93a4ef5fe765fdc72ea0bb8ee48 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-onnx-frontend >=2026.3.0,<2026.3.1.0a0 + size: 2106186 + timestamp: 1786131093355 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.3.0-h6c33c14_0.conda + sha256: 3f28b4438c3014c5b05d196cf4abbb7214dc5877b887100f7663eff722347713 + md5: 5ad8dacb488082db12c72422c3deba74 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-paddle-frontend >=2026.3.0,<2026.3.1.0a0 + size: 690800 + timestamp: 1786131106085 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.3.0-h0542e35_0.conda + sha256: 9ae24b2df1aeda9aedfcc6ba08fcf1ba34b169481ff874288310282ce5659d07 + md5: 7e0fd6af38383ab7bd71dc6af22355f3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-pytorch-frontend >=2026.3.0,<2026.3.1.0a0 + size: 1236788 + timestamp: 1786131116811 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.3.0-h565fa1b_0.conda + sha256: d1a027725dc6d6c2558e9e2b5ea65f99e982ec6adf575cef49be1ab5ecae8a47 + md5: e76e8e113e406442c0ca41284f6c3f0d + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + - snappy >=1.2.2,<1.3.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-tensorflow-frontend >=2026.3.0,<2026.3.1.0a0 + size: 1289288 + timestamp: 1786131128541 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.3.0-h0542e35_0.conda + sha256: 0a48ed163e475f3eff7ee358921f1393bbc769a58412c3b84cf27d86d1253440 + md5: e4318029124b4cacb8a2bfe884613676 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.3.0 hb2f3c86_0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-tensorflow-lite-frontend >=2026.3.0,<2026.3.1.0a0 + size: 511389 + timestamp: 1786131139830 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda + sha256: f1061a26213b9653bbb8372bfa3f291787ca091a9a3060a10df4d5297aad74fd + md5: 2446ac1fe030c2aa6141386c1f5a6aed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libopus >=1.6.1,<2.0a0 + size: 324993 + timestamp: 1768497114401 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_1.conda + sha256: addc80c69d362a9e6c40305c493139a8e9ee504b2f45a6687dbdaa9da3c6183c + md5: 35fa2b34bbced424e6976d30f5fde576 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libpciaccess >=0.19,<0.20.0a0 + size: 30070 + timestamp: 1785971678815 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-hc50b9dd_1.conda + sha256: 7fa90c06b81559cb56ea7806a6696fb4902a1acc20bbaff1bd3a4a75b3ffa0d5 + md5: 4a750e2ae0d52d003bb1e3421581585e + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libdovi >=3.4.0,<4.0a0 + - lcms2 >=2.19.1,<3.0a0 + - shaderc >=2026.3,<2026.4.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libplacebo >=7.360.1,<7.361.0a0 + size: 550759 + timestamp: 1784287829706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + sha256: 377cfe037f3eeb3b1bf3ad333f724a64d32f315ee1958581fc671891d63d3f89 + md5: eba48a68a1a2b9d3c0d9511548db85db + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: zlib-acknowledgement + purls: [] + run_exports: + weak: + - libpng >=1.6.58,<1.7.0a0 + size: 317729 + timestamp: 1776315175087 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h2840a7c_2.conda + sha256: b5ac3938186516c1091b96414c34f90255da2a3bbd736a0712b22bbf4b5ebf02 + md5: 729db8acaadb9a5e5bb2dc3d9ac84ae4 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libprotobuf >=7.35.1,<7.35.2.0a0 + size: 3774596 + timestamp: 1783168720126 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.0-hf670292_0.conda + sha256: bbb184b81f28a868528b05a81db2448a232a762e47c983330d57b98e1211ae32 + md5: 075e9aafb806c06f190e4757506d2631 + depends: + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libpsl >=0.23.0,<0.24.0a0 + size: 72585 + timestamp: 1785426713969 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libraw-0.22.2-h074291d_0.conda + sha256: fa3ccb18cf22f8ac94ec4f6bfcc9fd5805bf7af11cf56bf19c27fb051b36dd6a + md5: a11f92bd6dd6721cce01564c7c9fdb25 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - _openmp_mutex >=4.5 + - libzlib >=1.3.2,<2.0a0 + - lcms2 >=2.19.1,<3.0a0 + - jasper >=4.2.9,<5.0a0 + - libjpeg-turbo >=3.2.0,<4.0a0 + license: LGPL-2.1-only + purls: [] + run_exports: + weak: + - libraw >=0.22.2,<0.23.0a0 + size: 742828 + timestamp: 1784220858002 +- conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + sha256: 5571bd8239d71961d4e3ce972f865b3ea95a91ce0b53d5749fe2dd24254ddbda + md5: 492c8d9b1c564c2e948b6cb4ba0f8261 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.0,<3.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.6,<3.0a0 + - harfbuzz >=14.2.0 + - libgcc >=14 + - libglib >=2.88.1,<3.0a0 + - libxml2-16 >=2.14.6 + - pango >=1.56.4,<2.0a0 + constrains: + - __glibc >=2.17 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - librsvg >=2.62.3,<3.0a0 + size: 3476570 + timestamp: 1780450632624 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-16.1.0-hf2715c6_1.conda + sha256: 85662ecadd3961bc96cbcc38dbc024a768cc35932c8440677ed028ea6322c36c + md5: abd77210925872ee084672cf5be1d491 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=16.1.0 + - libstdcxx >=16.1.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: + weak: + - libsanitizer 16.1.0 + size: 7780843 + timestamp: 1785375481116 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + sha256: 57cb5f92110324c04498b96563211a1bca6a74b2918b1e8df578bfed03cc32e4 + md5: 067590f061c9f6ea7e61e3b2112ed6b3 + depends: + - __glibc >=2.17,<3.0.a0 + - lame >=3.100,<3.101.0a0 + - libflac >=1.5.0,<1.6.0a0 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + - libopus >=1.5.2,<2.0a0 + - libstdcxx >=14 + - libvorbis >=1.3.7,<1.4.0a0 + - mpg123 >=1.32.9,<1.33.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - libsndfile >=1.2.2,<1.3.0a0 + size: 355619 + timestamp: 1765181778282 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + sha256: 72023efc207fe681e26b65fc9d668062cf0b4f0eacf3431e6eb099b95c1f2efd + md5: df088a279cd5e6fd2790b4c196434da1 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + purls: [] + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 964200 + timestamp: 1785016112246 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + sha256: fa39bfd69228a13e553bd24601332b7cfeb30ca11a3ca50bb028108fe90a7661 + md5: eecce068c7e4eddeb169591baac20ac4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libssh2 >=1.11.1,<2.0a0 + size: 304790 + timestamp: 1745608545575 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + sha256: 79721dd08aeb0ab9e773f1f9ef41cf4e6c17477e3d72319147619045bce05a09 + md5: aed6cf89adc1e9b846e4367ac538e434 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 16.1.0 ha9f2e26_1 + constrains: + - libstdcxx-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 6631744 + timestamp: 1785375462643 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.1.0-hdf11a46_1.conda + sha256: 2876ca4463d1b394eb969ce4a84d1620aa63fb8202a6397837d1e45ec76c1208 + md5: c94f06123272d8e129d4acf3a25ffb35 + depends: + - libstdcxx 16.1.0 h934c35e_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: + strong: + - libstdcxx + size: 28253 + timestamp: 1785375500257 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + sha256: 2293884d59cf0436c37fc0a4bad71011a8de2a6913610d1c701a7703377c1f75 + md5: ea0da9c20bbb221b530810c3c68bbe62 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: {} + size: 493022 + timestamp: 1780084748140 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + sha256: b31346e1c01ab40a170e91147092ee8fd92b1dee3c66ee47ef025571c879b159 + md5: c1fcb4a88bc15a9f77ad8d27d7af1df9 + depends: + - __glibc >=2.17,<3.0.a0 + - lerc >=4.1.0,<5.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libstdcxx >=14 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + purls: [] + run_exports: + weak: + - libtiff >=4.7.2,<4.8.0a0 + size: 452337 + timestamp: 1783084902636 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + sha256: 287d05680e49eea51b8145fbf34bc213c0618b04f32e450e9da5d715e5134e38 + md5: 89e5671a076d99516a6acd72a35b1640 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: {} + size: 145969 + timestamp: 1780084753104 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + sha256: 71c8b9d5c72473752a0bb6e91b01dd209a03916cb71f36cc6a564e3a2a132d7a + md5: e179a69edd30d75c0144d7a380b88f28 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libunwind >=1.8.3,<1.9.0a0 + size: 75995 + timestamp: 1757032240102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + sha256: 3d17b7aa90610afc65356e9e6149aeac0b2df19deda73a51f0a09cf04fd89286 + md5: 56f65185b520e016d29d01657ac02c0d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - liburing >=2.14,<2.15.0a0 + size: 154203 + timestamp: 1770566529700 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + sha256: 89c84f5b26028a9d0f5c4014330703e7dff73ba0c98f90103e9cef6b43a5323c + md5: d17e3fb595a9f24fa9e149239a33475d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libudev1 >=257.4 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libusb >=1.0.29,<2.0a0 + size: 89551 + timestamp: 1748856210075 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda + sha256: 67761d0206f84140047a367eaf9befe03a7e157a29ee25aee0047b40801cc6b6 + md5: 01c4ed87826af55996768af6dbc936b4 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 420040 + timestamp: 1785914567661 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.24.1-he1eb515_0.conda + sha256: 16a76abbb4fd1de4516ac4a3d06cbf1f561bc8049ca72b04dcac395eee74d017 + md5: eb1b7f8bfdea40eef150c4a1d37df09e + depends: + - __glibc >=2.17,<3.0.a0 + - libdrm >=2.4.127,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglx >=1.7.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - wayland >=1.25.0,<2.0a0 + - wayland-protocols + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libva >=2.24.1,<3.0a0 + size: 222717 + timestamp: 1783519315031 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvips-8.18.5-ha123a74_0.conda + sha256: b152dab7016daf6aa7ea847fa27b0d34cdb9e4e66b86beec01a80069f4795b43 + md5: f5ea659c6ec54da762195ba8b2cbbc8b + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - cfitsio >=4.6.4,<4.6.5.0a0 + - fftw >=3.3.11,<4.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - imagemagick + - lcms2 >=2.19.1,<3.0a0 + - libarchive >=3.8.9,<3.9.0a0 + - libexif >=0.6.26,<0.6.27.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.3,<3.0a0 + - libheif >=1.23.1,<1.24.0a0 + - libhwy >=1.4.0,<1.5.0a0 + - libjpeg-turbo >=3.2.0,<4.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - libmatio >=1.5.30,<1.5.31.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libraw >=0.22.2,<0.23.0a0 + - librsvg >=2.62.3,<3.0a0 + - libstdcxx >=14 + - libtiff >=4.7.2,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openjpeg >=2.5.4,<3.0a0 + - openslide >=4.0.1,<5.0a0 + - pango >=1.58.0,<2.0a0 + - poppler >=26.7.0,<26.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - libvips >=8.18.5,<9.0a0 + size: 1837919 + timestamp: 1785786497662 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + sha256: ca494c99c7e5ecc1b4cd2f72b5584cef3d4ce631d23511184411abcbb90a21a5 + md5: b4ecbefe517ed0157c37f8182768271c + depends: + - libogg + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libvorbis >=1.3.7,<1.4.0a0 + size: 285894 + timestamp: 1753879378005 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + sha256: 38850657dd6835613ef16b34895a54bea98bc7639db6a649c886b331635714fc + md5: 9f6b0090c3902b2c763a16f7dace7b6e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - intel-media-driver >=26.1.2,<26.2.0a0 + - libva >=2.23.0,<3.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libvpl >=2.16.0,<2.17.0a0 + size: 287992 + timestamp: 1772980546550 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + sha256: 8e1119977f235b488ab32d540c018d3fd1eccefc3dd3859921a0ff555d8c10d2 + md5: 10f5008f1c89a40b09711b5a9cdbd229 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libvpx >=1.15.2,<1.16.0a0 + size: 1070048 + timestamp: 1762010217363 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.conda + sha256: 1e30138cff1e6ba5739ce3ec787b24ef22ac6e2008d8a2073df334e9e5f8690b + md5: 9d8c72f797f6f2d1c32897603d70824c + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + constrains: + - libvulkan-headers 1.4.357.0.* + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libvulkan-loader >=1.4.357.0,<2.0a0 + size: 203456 + timestamp: 1785311377294 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda + sha256: 8415001414f488c85b72b9d8cc2071dfb3981a47bc3c8eb56ef91a57d12eae7f + md5: 9332b53d0ea93c5d39e33be03a0c611a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libwebp-base >=1.6.0,<2.0a0 + size: 428430 + timestamp: 1785954557217 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + sha256: 666c0c431b23c6cec6e492840b176dde533d48b7e6fb8883f5071223433776aa + md5: 92ed62436b625154323d40d5f2f11dd7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libxcb >=1.17.0,<2.0a0 + size: 395888 + timestamp: 1727278577118 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + sha256: 046f2ff4acebd8729fac03e99c8c307dfb48b6a32894ba8c11576e78f6e76e43 + md5: dc8b067e22b414172bedd8e3f03f3c95 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - xkeyboard-config + - xorg-libxau >=1.0.12,<2.0a0 + license: MIT/X11 Derivative + license_family: MIT + purls: [] + run_exports: + weak: + - libxkbcommon >=1.13.2,<2.0a0 + size: 851166 + timestamp: 1780213397575 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + sha256: 3d44f737c5ae52d5af32682cc1530df433f401f8e58a7533926536244127572a + md5: e79d2c2f24b027aa8d5ab1b1ba3061e7 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 559775 + timestamp: 1776376739004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + sha256: 3bc5551720c58591f6ea1146f7d1539c734ed1c40e7b9f5cb8cb7e900c509aba + md5: 995d8c8bad2a3cc8db14675a153dec2b + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 hca6bf5a_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 46810 + timestamp: 1776376751152 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxmlb-0.3.29-he944c4b_0.conda + sha256: c14c4c8c2644eb5ca75faa1e24f1852d1b7cfe00ecc2639328d2a0798727ef90 + md5: ae0d870aa25d493483fbb6a9668d7169 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.2,<2.0a0 + - libglib >=2.88.2,<3.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libxmlb >=0.3.29,<0.4.0a0 + size: 145134 + timestamp: 1785182520171 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda + sha256: 991e7348b0f650d495fb6d8aa9f8c727bdf52dabf5853c0cc671439b160dce48 + md5: a7b27c075c9b7f459f1c022090697cba + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.3.2,<4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libzip >=1.11.2,<2.0a0 + size: 109043 + timestamp: 1730442108429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 + md5: 0de0122d9570a8ab637c6b73db268389 + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + purls: [] + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63713 + timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346 + md5: 9de5350a85c4a20c685259b889aa6393 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - lz4-c >=1.10.0,<1.11.0a0 + size: 167055 + timestamp: 1733741040117 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda + sha256: 5c6bbeec116e29f08e3dad3d0524e9bc5527098e12fc432c0e5ca53ea16337d4 + md5: 45161d96307e3a447cc3eb5896cf6f8c + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - lzo >=2.10,<3.0a0 + size: 191060 + timestamp: 1753889274283 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-h8142553_0.conda + sha256: 72393d525761389d0225534d20f4cd917e255704f337f84939b74464d9bc6acb + md5: 0dae03fd088c1ca13a8f6c9e5508e36c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: LGPL-2.1-only + license_family: LGPL + purls: [] + run_exports: + weak: + - mpg123 >=1.32.9,<1.33.0a0 + size: 487458 + timestamp: 1786190190098 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 + md5: ee6c0cd80a60961a1f48aa3e0b91f986 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + purls: [] + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 911196 + timestamp: 1786355078102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.6.0-hc039f44_0.conda + sha256: 049788cee5e10fe13846f806d8ae854642414aeb06018ad268ce9959b529be77 + md5: c99a655b4c729eef64e63140962b8cc9 + depends: + - __glibc >=2.28,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - libuv >=1.52.1,<2.0a0 + - libabseil >=20260526.0,<20260527.0a0 + - libabseil * cxx17* + - zstd >=1.5.7,<1.6.0a0 + - libsqlite >=3.53.4,<4.0a0 + - icu >=78.3,<79.0a0 + - openssl >=3.5.7,<4.0a0 + - libnghttp2 >=1.68.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - c-ares >=1.34.8,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - nodejs >=26.6.0,<27.0a0 + size: 20013853 + timestamp: 1785852655297 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nspr-4.40-h29cc59b_0.conda + sha256: 7cf473259ef9945ce19ecc898a2dd146cd32dc86099d87b86f79c5e5b0ef55f0 + md5: 4ebda462e16da6e6164cb82b9d58fcea + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MPL-2.0 + purls: [] + run_exports: + weak: + - nspr >=4.40,<5.0a0 + size: 231356 + timestamp: 1786154777346 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda + sha256: 44dd98ffeac859d84a6dcba79a2096193a42fc10b29b28a5115687a680dd6aea + md5: 567fbeed956c200c1db5782a424e58ee + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libsqlite >=3.51.0,<4.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - nspr >=4.38,<5.0a0 + license: MPL-2.0 + license_family: MOZILLA + purls: [] + run_exports: + weak: + - nss >=3.118,<4.0a0 + size: 2057773 + timestamp: 1763485556350 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.2-py314h2b28147_0.conda + sha256: 124b753583ea9c157301fe78de3e88aa5fa8806bd2da8abaa8808065d1b93d51 + md5: d77631addad93399a90157d2c597e7f3 + depends: + - python + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.14.* *_cp314 + - libblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + purls: + - pkg:pypi/numpy?source=hash-mapping + run_exports: + weak: + - numpy >=1.25,<3 + size: 9119694 + timestamp: 1786330625923 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + sha256: 75f3bf733523a338f73d6c276c4a26634877cd970edb558f2769d9fa52b100a9 + md5: c2871ba95727fd1382c05db66048b64c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - opencl-headers >=2025.6.13 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - ocl-icd >=2.3.4,<3.0a0 + size: 109598 + timestamp: 1780362789611 +- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + sha256: 8de2f0cd8a659b01abf86e7fbb8cea4f28ada62fd288429a2bbc040db1b98dd0 + md5: c930c8052d780caa41216af7de472226 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 55754 + timestamp: 1773844383536 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-h6de6307_2.conda + sha256: 2404399799fd4f6c76af77a57a12c01af2295d24146bd8a4b7dda5a33b066ea2 + md5: 7161bd368507413012914284f8182712 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - openjph >=0.31.0,<0.32.0a0 + - libzlib >=1.3.2,<2.0a0 + - libdeflate >=1.25,<1.26.0a0 + - imath >=3.2.2,<3.2.3.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openexr >=3.4.13,<3.5.0a0 + size: 1224156 + timestamp: 1785311096564 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + sha256: 5317c5c23762f3fe1c8510565a2bb94c645e1470ff73b386315656404f7eb58a + md5: 69894a95220a17a66272daa701c387bc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openh264 >=2.6.0,<2.6.1.0a0 + size: 726478 + timestamp: 1782685945856 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + sha256: 3900f9f2dbbf4129cf3ad6acf4e4b6f7101390b53843591c53b00f034343bc4d + md5: 11b3379b191f63139e29c0d19dee24cd + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.50,<1.7.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openjpeg >=2.5.4,<3.0a0 + size: 355400 + timestamp: 1758489294972 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.31.0-h8d634f6_0.conda + sha256: 3c7a4118c678c43952ea10183388f175a4bcee16a1c61d90dab2fbdafddc45d7 + md5: 49ca947333c62d5985a88cbdd8f59468 + depends: + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.17,<3.0.a0 + - libtiff >=4.7.2,<4.8.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openjph >=0.31.0,<0.32.0a0 + size: 295193 + timestamp: 1785149856790 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openslide-4.0.1-hbacd67c_0.conda + sha256: 25a59fe66759ad674c04cc3a3fc8316ac02a18d6728ee8296c92f9b197c0db05 + md5: e9fa28309cfce31e60f87dd7e1e4e8b3 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - libdicom >=1.3.0,<1.4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.1,<3.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openjpeg >=2.5.4,<3.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: LGPL-2.1-only + license_family: LGPL + purls: [] + run_exports: + weak: + - openslide >=4.0.1,<5.0a0 + size: 162796 + timestamp: 1781499485474 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + sha256: 012096056b97abf1f68c46b7146bd2cbd68c1be762340b4f5dad4fbbe99177bc + md5: c5955c27917ff2234def47f075e71e02 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3182423 + timestamp: 1785913583650 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.2-hda50119_0.conda + sha256: 48f27a6c3e4062bc09dafe9c7f6b288c5de5655e81095ab7f1aad920b2163b7b + md5: 6a2822aaf9a34ac3708904a47ff3dd7e + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.3,<3.0a0 + - libharfbuzz >=14.3.0 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - pango >=1.58.2,<2.0a0 + size: 469916 + timestamp: 1786107384537 +- conda: https://conda.anaconda.org/conda-forge/linux-64/patchelf-0.17.2-h58526e2_0.conda + sha256: eb355ac225be2f698e19dba4dcab7cb0748225677a9799e9cc8e4cadc3cb738f + md5: ba76a6a448819560b5f8b08a9c74f415 + depends: + - libgcc-ng >=7.5.0 + - libstdcxx-ng >=7.5.0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 94048 + timestamp: 1673473024463 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + sha256: 5e6f7d161356fefd981948bea5139c5aa0436767751a6930cb1ca801ebb113ff + md5: 7a3bff861a6583f1889021facefc08b1 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 1222481 + timestamp: 1763655398280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda + sha256: 829d8288764282de5a9f7b9169acb75cc7dc0b6c3fe2535cfe87dea3436bbc5d + md5: 0ee5bb30034b081a1386c1e2c98ab0a7 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - pixman >=0.46.4,<1.0a0 + size: 376704 + timestamp: 1786106621354 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h7c397b8_1011.conda + sha256: ff8d0023722ef5600850074a9bbf418be4d2ec78edbf4d5db45d9f0331f248e4 + md5: 435898aaa55d40aa4c016214d9e9ed98 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: GPL-2.0-or-later + purls: [] + run_exports: {} + size: 141002 + timestamp: 1786352333107 +- conda: https://conda.anaconda.org/conda-forge/linux-64/poppler-26.07.0-he594abd_3.conda + sha256: 646869d659de20b98caf6e249b468d1a13b4593f5d83ce7f269fc75aed18faf0 + md5: 386b8320e1f218cb1a1d75edc7ac3599 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - lcms2 >=2.19.1,<3.0a0 + - libcurl >=8.21.0,<9.0a0 + - libegl >=1.7.0,<2.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglib >=2.88.2,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - libtiff >=4.7.2,<4.8.0a0 + - libzlib >=1.3.2,<2.0a0 + - nspr >=4.38,<5.0a0 + - nss >=3.118,<4.0a0 + - openjpeg >=2.5.4,<3.0a0 + - poppler-data + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - poppler >=26.7.0,<26.8.0a0 + size: 2113458 + timestamp: 1783343328643 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb03c661_1003.conda + sha256: afc3b27b2cbb0487c1d0e963f96e71181ecfb623a24fb393bb19ff974a6382a1 + md5: df2c27f36bdb0dde779f55b5df76a352 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 9115 + timestamp: 1786067714761 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda + sha256: 23c98a5000356e173568dc5c5770b53393879f946f3ace716bbdefac2a8b23d2 + md5: b11a4c6bf6f6f44e5e143f759ffa2087 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - pugixml >=1.15,<1.16.0a0 + size: 118488 + timestamp: 1736601364156 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda + sha256: 0a0858c59805d627d02bdceee965dd84fde0aceab03a2f984325eec08d822096 + md5: b8ea447fdf62e3597cb8d2fae4eb1a90 + depends: + - __glibc >=2.17,<3.0.a0 + - dbus >=1.16.2,<2.0a0 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libsndfile >=1.2.2,<1.3.0a0 + - libsystemd0 >=257.10 + - libxcb >=1.17.0,<2.0a0 + constrains: + - pulseaudio 17.0 *_3 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - pulseaudio-client >=17.0,<17.1.0a0 + size: 750785 + timestamp: 1763148198088 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.0-py314h9cd037b_1.conda + sha256: d325468144ce190d0f162a62e2e93dc23ddb5d68bb16764bd6f541c3a9430c26 + md5: 2bec2eaba61461dd0d0ebeaff47a5c7d + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: LGPL-2.1-only OR MPL-1.1 + purls: + - pkg:pypi/pycairo?source=hash-mapping + run_exports: {} + size: 118155 + timestamp: 1770726345430 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.56.3-py314h4bbdf5d_0.conda + sha256: 1b49ec5f49e15b5ae53d0f3c0180dc89b0c2b376d0ee57475acff0d44bdd6ee7 + md5: 76dc10faa04675dffdbe25b4bca629c7 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - libexpat >=2.8.0,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libgirepository + - libglib >=2.88.1,<3.0a0 + - libiconv + - libzlib >=1.3.2,<2.0a0 + - pycairo + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: LGPL-2.1-or-later + license_family: LGPL + purls: + - pkg:pypi/pygobject?source=hash-mapping + run_exports: {} + size: 344857 + timestamp: 1778343387141 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py314hc02f841_2.conda + sha256: 1637a24c6e50f6a803c34d42f289bf3424a55ef20f861d906c2424029df9c851 + md5: 91993f04a00124efc882d99cab4f5644 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - numpy >=1.23,<3 + - pyopengl 3.1.10 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: LicenseRef-pyopengl + purls: + - pkg:pypi/pyopengl-accelerate?source=hash-mapping + run_exports: {} + size: 303446 + timestamp: 1764024709275 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + build_number: 101 + sha256: ee8f2006e1724b1f2e9e0ccc5a7cfdcab973460faa2f63ac1f6e44fdad4c0344 + md5: 78975a41cf3c525da654f17e35bfca9e + 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.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.3,<4.0a0 + - libuuid >=2.42.2,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + purls: [] + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 36869055 + timestamp: 1784910110714 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda + sha256: cf550bbc8e5ebedb6dba9ccaead3e07bd1cb86b183644a4c853e06e4b3ad5ac7 + md5: d83958768626b3c8471ce032e28afcd3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - rav1e >=0.8.1,<0.9.0a0 + size: 5595970 + timestamp: 1772540833621 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.97.1-h53717f1_0.conda + sha256: 66f7e7d46602a4fdc20769ef33cb30229983b3cfe8fb447bb2d2081e895383ef + md5: f0d36652f4d8bbe4a51de53414f15db2 + depends: + - __glibc >=2.17,<3.0.a0 + - gcc_impl_linux-64 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + - rust-std-x86_64-unknown-linux-gnu 1.97.1 h2c6d0dc_0 + - sysroot_linux-64 >=2.17 + license: MIT + license_family: MIT + purls: [] + run_exports: + strong_constrains: + - __glibc >=2.17 + size: 173497457 + timestamp: 1784279883262 +- conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.0-py314hf07bd8e_0.conda + sha256: 85503102237f8515ab92319fc14609e894ac9e95e3a1398b0c49db1f9ee50877 + md5: 62c390c1f8f51240f1ebc7ba782669ad + depends: + - __glibc >=2.17,<3.0.a0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - liblapack >=3.9.0,<4.0a0 + - libstdcxx >=14 + - numpy <2.7 + - numpy >=1.23,<3 + - numpy >=2.0.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + run_exports: {} + size: 17260022 + timestamp: 1781912924009 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda + sha256: 987ad072939fdd51c92ea8d3544b286bb240aefda329f9b03a51d9b7e777f9de + md5: cdd138897d94dc07d99afe7113a07bec + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libgl >=1.7.0,<2.0a0 + - sdl3 >=3.2.22,<4.0a0 + - libegl >=1.7.0,<2.0a0 + license: Zlib + purls: [] + run_exports: + weak: + - sdl2 >=2.32.56,<3.0a0 + size: 589145 + timestamp: 1757842881000 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.14-hdeec2a5_0.conda + sha256: b7f4a338074d0daa5086d6d7f319dd79b277c47a761abd8ebac72c0253f4c6ad + md5: 1ef39a7b42a06e262723fa7937210639 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libvulkan-loader >=1.4.357.0,<2.0a0 + - liburing >=2.14,<2.15.0a0 + - libudev1 >=257.13 + - xorg-libxcursor >=1.2.3,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - dbus >=1.16.2,<2.0a0 + - libunwind >=1.8.3,<1.9.0a0 + - libusb >=1.0.29,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - libgl >=1.7.0,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - xorg-libxi >=1.8.3,<2.0a0 + - wayland >=1.26.0,<2.0a0 + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + license: Zlib + purls: [] + run_exports: + weak: + - sdl3 >=3.4.14,<4.0a0 + size: 2158268 + timestamp: 1785816103164 +- conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.3-h1b60276_0.conda + sha256: 1325456e9cff1ec8a5e826f64b8eef5806162bfcceeed2e32817a3c280f0dfc9 + md5: ee5e719bbf258faa3b6533a1a621092b + depends: + - __glibc >=2.17,<3.0.a0 + - glslang >=16,<17.0a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - shaderc >=2026.3,<2026.4.0a0 + size: 114267 + timestamp: 1784251192959 +- conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + sha256: 48f3f6a76c34b2cfe80de9ce7f2283ecb55d5ed47367ba91e8bb8104e12b8f11 + md5: 98b6c9dc80eb87b2519b97bcf7e578dd + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - snappy >=1.2.2,<1.3.0a0 + size: 45829 + timestamp: 1762948049098 +- conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_3.conda + sha256: 94e64d435ca9649b74188c0072033193fda5a2b1f7603ee9d136130e5cd15e9c + md5: c402e3603c22e3fa9f3101a703a041d0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - spirv-headers >=1.4.357.0,<1.4.357.1.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - spirv-tools >=2026,<2027.0a0 + size: 2405017 + timestamp: 1785688937831 +- conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hecca717_0.conda + sha256: c79f983a6bb4218bdef9064aec5821d59d744f9a98f8cf8437c7bd0351df0d95 + md5: 683f1b6d013bb1eb0d5c8025d2eb21a3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - svt-av1 >=4.2.0,<4.2.1.0a0 + size: 2666786 + timestamp: 1784069888521 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + sha256: 30cb9355c2fefc20ff1a3d6566b9714d5614086a2524c07721fc344eb20515ae + md5: 7073b15f9364ebc118998601ac6ca6a6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libhwloc >=2.13.0,<2.13.1.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 182331 + timestamp: 1778673758649 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + build_number: 103 + sha256: 43624eab22f5f29df7d6ffe914cf442f28fd559b55b290906255492826e636e8 + md5: 48a1049e710857572fc2a832aa394d9f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + constrains: + - xorg-libx11 >=1.8.13,<2.0a0 + license: TCL + purls: [] + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3550916 + timestamp: 1784229071544 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + sha256: 6b9e182021ef3a64ab3bf788ebdab6de6775035612237c639ccafc941639eb13 + md5: b34c5559f45d8996e3bc0b6250a6cc84 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - wayland >=1.26.0,<2.0a0 + size: 340543 + timestamp: 1784249169392 +- conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + sha256: 175315eb3d6ea1f64a6ce470be00fa2ee59980108f246d3072ab8b977cb048a5 + md5: 6c99772d483f566d59e25037fea2c4b1 + depends: + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - x264 >=1!164.3095,<1!165 + size: 897548 + timestamp: 1660323080555 +- conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + sha256: 76c7405bcf2af639971150f342550484efac18219c0203c5ee2e38b8956fe2a0 + md5: e7f6ed84d4623d52ee581325c1587a6b + depends: + - libgcc-ng >=10.3.0 + - libstdcxx-ng >=10.3.0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - x265 >=3.5,<3.6.0a0 + size: 3357188 + timestamp: 1646609687141 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + sha256: 3b04afd5d1a65d2d27ac2d49a63b01ab8bcd875776779ec63e337370ed38afdc + md5: b233b41be0bf210989d57160ed39b394 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - xorg-libx11 >=1.8.13,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 441670 + timestamp: 1782027360439 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-compositeproto-0.4.2-hb9d3cd8_1002.conda + sha256: b800b09a090e4acef0b8653bcb1a4811e8f44559d4eff050886770fdfa77857b + md5: 317d35860cab6c16d91a81f67303023d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 14808 + timestamp: 1726801836848 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-damageproto-1.2.1-hb9d3cd8_1003.conda + sha256: 424a9202255359a75770eaca534e4e8b07464242f0146871915e1bb76fb3ffae + md5: 5135d24c55235da88c2fe48a8662927a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 27720 + timestamp: 1726801874619 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-glproto-1.4.17-hb9d3cd8_1003.conda + sha256: cd29d1023230078cf83a06d01f4f013d9bbfbc7f8082ba59b40090c4f2f4eec3 + md5: 8361b4e3d72dc700eb46422470b34901 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 23473 + timestamp: 1726801830878 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-inputproto-2.3.2-hb9d3cd8_1003.conda + sha256: 77eea289f9d3fa753a290f988533c842694b826fe1900abd6d7b142c528512ba + md5: 32623b33f2047dbc9ae2f2e8fd3880e9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 22320 + timestamp: 1726802558171 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-kbproto-1.0.7-hb9d3cd8_1003.conda + sha256: 849555ddf7fee334a5a6be9f159d2931c9d076ffb310a9e75b9124f789049d3e + md5: e87bfacb110d85e1eb6099c9ed8e7236 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 30242 + timestamp: 1726846706299 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b + md5: fb901ff28063514abb6046c9ec2c4a45 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libice >=1.1.2,<2.0a0 + size: 58628 + timestamp: 1734227592886 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + sha256: 277841c43a39f738927145930ff963c5ce4c4dacf66637a3d95d802a64173250 + md5: 1c74ff8c35dcadf952a16f752ca5aa49 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - xorg-libice >=1.1.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libsm >=1.2.6,<2.0a0 + size: 27590 + timestamp: 1741896361728 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + sha256: 516d4060139dbb4de49a4dcdc6317a9353fb39ebd47789c14e6fe52de0deee42 + md5: 861fb6ccbc677bb9a9fb2468430b9c6a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libx11 >=1.8.13,<2.0a0 + size: 839652 + timestamp: 1770819209719 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + sha256: 6bc6ab7a90a5d8ac94c7e300cc10beb0500eeba4b99822768ca2f2ef356f731b + md5: b2895afaf55bf96a8c8282a2e47a5de0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxau >=1.0.12,<2.0a0 + size: 15321 + timestamp: 1762976464266 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda + sha256: 048c103000af9541c919deef03ae7c5e9c570ffb4024b42ecb58dbde402e373a + md5: f2ba4192d38b6cef2bb2c25029071d90 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxcomposite >=0.4.7,<1.0a0 + size: 14415 + timestamp: 1770044404696 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + sha256: 832f538ade441b1eee863c8c91af9e69b356cd3e9e1350fff4fe36cc573fc91a + md5: 2ccd714aa2242315acaf0a67faea780b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxcursor >=1.2.3,<2.0a0 + size: 32533 + timestamp: 1730908305254 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + sha256: 43b9772fd6582bf401846642c4635c47a9b0e36ca08116b3ec3df36ab96e0ec0 + md5: b5fcc7172d22516e1f965490e65e33a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxdamage >=1.1.6,<2.0a0 + size: 13217 + timestamp: 1727891438799 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + sha256: 25d255fb2eef929d21ff660a0c687d38a6d2ccfbcbf0cc6aa738b12af6e9d142 + md5: 1dafce8548e38671bea82e3f5c6ce22f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxdmcp >=1.1.5,<2.0a0 + size: 20591 + timestamp: 1762976546182 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + sha256: 79c60fc6acfd3d713d6340d3b4e296836a0f8c51602327b32794625826bd052f + md5: 34e54f03dfea3e7a2dcf1453a85f1085 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxext >=1.3.7,<2.0a0 + size: 50326 + timestamp: 1769445253162 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + sha256: 83c4c99d60b8784a611351220452a0a85b080668188dce5dfa394b723d7b64f4 + md5: ba231da7fccf9ea1e768caf5c7099b84 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxfixes >=6.0.2,<7.0a0 + size: 20071 + timestamp: 1759282564045 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + sha256: 495f99c8eacfa4ae2d8fed2a7f2105777af89acdc204df145d2bbbc380ac631b + md5: adba2e334082bb218db806d4c12277c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxi >=1.8.3,<2.0a0 + size: 47717 + timestamp: 1779111857071 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.6-hecca717_0.conda + sha256: 3a9da41aac6dca9d3ff1b53ee18b9d314de88add76bafad9ca2287a494abcd86 + md5: 93f5d4b5c17c8540479ad65f206fea51 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxinerama >=1.1.6,<1.2.0a0 + size: 14818 + timestamp: 1769432261050 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + sha256: 80ed047a5cb30632c3dc5804c7716131d767089f65877813d4ae855ee5c9d343 + md5: e192019153591938acf7322b6459d36e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxrandr >=1.5.5,<2.0a0 + size: 30456 + timestamp: 1769445263457 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + sha256: 044c7b3153c224c6cedd4484dd91b389d2d7fd9c776ad0f4a34f099b3389f4a1 + md5: 96d57aba173e878a2089d5638016dc5e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxrender >=0.9.12,<0.10.0a0 + size: 33005 + timestamp: 1734229037766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + sha256: 58e8fc1687534124832d22e102f098b5401173212ac69eb9fd96b16a3e2c8cb2 + md5: 303f7a0e9e0cd7d250bb6b952cecda90 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + size: 14412 + timestamp: 1727899730073 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxt-1.3.1-hb9d3cd8_0.conda + sha256: a8afba4a55b7b530eb5c8ad89737d60d60bc151a03fbef7a2182461256953f0e + md5: 279b0de5f6ba95457190a1c459a64e31 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libice >=1.1.1,<2.0a0 + - xorg-libsm >=1.2.4,<2.0a0 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxt >=1.3.1,<2.0a0 + size: 379686 + timestamp: 1731860547604 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + sha256: 752fdaac5d58ed863bbf685bb6f98092fe1a488ea8ebb7ed7b606ccfce08637a + md5: 7bbe9a0cc0df0ac5f5a8ad6d6a11af2f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxi >=1.7.10,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxtst >=1.2.5,<2.0a0 + size: 32808 + timestamp: 1727964811275 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda + sha256: 64db17baaf36fa03ed8fae105e2e671a7383e22df4077486646f7dbf12842c9f + md5: 665d152b9c6e78da404086088077c844 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxxf86vm >=1.1.7,<2.0a0 + size: 18701 + timestamp: 1769434732453 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-presentproto-1.1-hb9d3cd8_1002.conda + sha256: 84e0033f1893ae243ff9f6c63c8f7ac1d39fd709b48bca35bdd81d23c66e8334 + md5: 46ed663380ef53055e61687717308b98 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 15605 + timestamp: 1726846366412 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-renderproto-0.11.1-hb9d3cd8_1003.conda + sha256: 54dd934b0e1c942e54759eb13672fd59b7e523fabea6e69a32d5bf483e45b329 + md5: bf90782559bce8447609933a7d45995a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 11867 + timestamp: 1726802820431 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xextproto-7.3.0-hb9d3cd8_1004.conda + sha256: f302a3f6284ee9ad3b39e45251d7ed15167896564dc33e006077a896fd3458a6 + md5: bc4cd53a083b6720d61a1519a1900878 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-xextproto >=7.3.0,<8.0a0 + size: 30549 + timestamp: 1726846235301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xf86vidmodeproto-2.3.1-hb9d3cd8_1005.conda + sha256: d3189527c5b8e1fea2a2e391012d3e8f794e03bdabe9f4457a0ac4cb8fc7214c + md5: 1c08f67e3406550eef135e17263f8154 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 26134 + timestamp: 1731320782817 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xineramaproto-1.2.1-hb9d3cd8_1002.conda + sha256: e620cccfad9750e26e4810059c1c7e4097f102e7e0127d97666052329faf9259 + md5: edc68fe3100cc8e9e87eb5eacf0e3920 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 9857 + timestamp: 1726801788210 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-h280c20c_1.conda + sha256: 051c6088bf2381840fcf8764737b829cc6c5f793718d2417d097d6e3b153eba9 + md5: 3b51576511038b50fdbd05245e22e4b1 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 594844 + timestamp: 1786114408394 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-xproto-7.0.31-hb9d3cd8_1008.conda + sha256: ea02425c898d6694167952794e9a865e02e14e9c844efb067374f90b9ce8ce33 + md5: a63f5b66876bb1ec734ab4bdc4d11e86 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 73315 + timestamp: 1726845753874 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_3.conda + sha256: 16080a1c7724f7d25727cdc23c7658e0cec2db52448c1dc0c33467ee2c6e1c62 + md5: 6acb86426229f96f93e5468d1df3a5e8 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib 1.3.2 h25fd6f3_3 + license: Zlib + license_family: Other + purls: [] + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 96132 + timestamp: 1785362957588 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda + sha256: a362b4f5c96a0bf4def96be1a77317e2730af38915eb9bec85e2a92836501ed7 + md5: b3f0179590f3c0637b7eb5309898f79e + depends: + - __unix + - hicolor-icon-theme + - librsvg + license: LGPL-3.0-or-later OR CC-BY-SA-3.0 + license_family: LGPL + purls: [] + run_exports: {} + size: 631452 + timestamp: 1758743294412 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + sha256: 0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6 + md5: 0f51e2391ade309db462a55611263e9c + depends: + - __unix + license: ISC + purls: [] + run_exports: {} + size: 131780 + timestamp: 1784754889428 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/colorama?source=hash-mapping + run_exports: {} + size: 27011 + timestamp: 1733218222191 +- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + purls: + - pkg:pypi/exceptiongroup?source=hash-mapping + run_exports: {} + size: 21333 + timestamp: 1763918099466 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b + md5: 0c96522c6bdaed4b1566d11387caaf45 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: {} + size: 397370 + timestamp: 1566932522327 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c + md5: 34893075a5c9e55cdafac56607368fc6 + license: OFL-1.1 + license_family: Other + purls: [] + run_exports: {} + size: 96530 + timestamp: 1620479909603 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139 + md5: 4d59c254e01d9cde7957100457e2d5fb + license: OFL-1.1 + license_family: Other + purls: [] + run_exports: {} + size: 700814 + timestamp: 1620479612257 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14 + md5: 49023d73832ef61042f6a237cb2687e7 + license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 + license_family: Other + purls: [] + run_exports: {} + size: 1620504 + timestamp: 1727511233259 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61 + md5: fee5683a3f04bd15cbd8318b096a27ab + depends: + - fonts-conda-forge + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: {} + size: 3667 + timestamp: 1566974674465 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333 + md5: a7970cd949a077b7cb9696379d338681 + depends: + - font-ttf-ubuntu + - font-ttf-inconsolata + - font-ttf-dejavu-sans-mono + - font-ttf-source-code-pro + license: BSD-3-Clause + license_family: BSD + purls: [] + 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 + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/iniconfig?source=hash-mapping + run_exports: {} + size: 13387 + timestamp: 1760831448842 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-16.1.0-h59071f9_101.conda + sha256: b314251d957b16c71ec241119ac09b9530c5c4ce140026ec98d297f55d6c5e08 + md5: 19b0151ecb1d122f706ebd2f82f9a017 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 3096495 + timestamp: 1785375361053 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-16.1.0-h41cdd0d_101.conda + sha256: c1521172f2fdf5510d79b621ea835064209fe3131518e0d8b2b43362a75e7b4c + md5: 8593636203272a748b63d56cbd674753 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 22519609 + timestamp: 1785375386152 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + sha256: c432626b16768b8dab228bfb706f7060c2d462a21c516d240f68f2f902b5a044 + md5: 936687ed80f295a1f5dbcf8bd34c252c + depends: + - python >=3.9 + - python + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/packaging?source=hash-mapping + run_exports: {} + size: 116363 + timestamp: 1785888127370 +- conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e + md5: d7585b6550ad04c8c5e21097ada2888e + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/pluggy?source=hash-mapping + run_exports: {} + size: 25877 + timestamp: 1764896838868 +- conda: https://conda.anaconda.org/conda-forge/noarch/poppler-data-0.4.12-hd8ed1ab_0.conda + sha256: 2f227e17b3c0346112815faa605502b66c1c4511a856127f2899abf15a98a2cf + md5: d8d7293c5b37f39b2ac32940621c6592 + license: BSD-3-Clause AND (GPL-2.0-only OR GPL-3.0-only) + license_family: OTHER + purls: [] + run_exports: {} + size: 2348171 + timestamp: 1675353652214 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + sha256: cf70b2f5ad9ae472b71235e5c8a736c9316df3705746de419b59d442e8348e86 + md5: 16c18772b340887160c79a6acc022db0 + depends: + - python >=3.10 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/pygments?source=hash-mapping + run_exports: {} + size: 893031 + timestamp: 1774796815820 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyopengl-3.1.10-pyh534df25_2.conda + sha256: 0d6496145c2a88eac74650dfe363729af5bfc47f50bcb8820b957037237cfdab + md5: dae7cd715f93d0e2f12af26dd3777328 + depends: + - __osx + - python >=3.10 + license: LicenseRef-pyopengl + purls: + - pkg:pypi/pyopengl?source=hash-mapping + run_exports: {} + size: 1375109 + timestamp: 1756496518537 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyopengl-3.1.10-pyha804496_2.conda + sha256: 0775255f81e2c6b49948b3656796823758e64938a15543302857d99da0e3d737 + md5: cce156023226a62044a8112bebf3d5e1 + depends: + - __linux + - libopengl-devel + - python >=3.10 + license: LicenseRef-pyopengl + purls: + - pkg:pypi/pyopengl?source=hash-mapping + run_exports: {} + size: 1327162 + timestamp: 1756496351413 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + sha256: 430051d80765207a7d782b2b188230ba1489d35c6e75fd9903f76cb9fda4af16 + md5: 64c98a12c4e23eb238bf66bbecafdf3c + depends: + - colorama + - pygments >=2.7.2 + - python >=3.10 + - iniconfig >=1.0.1 + - packaging >=22 + - pluggy >=1.5,<2 + - tomli >=1 + - exceptiongroup >=1 + - python + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pytest?source=hash-mapping + run_exports: {} + size: 306724 + timestamp: 1782127176429 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: {} + size: 6989 + timestamp: 1752805904792 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.97.1-hf6ec828_0.conda + sha256: 1f3b42624457088f1570b579f1bfe76aa2306230e924b2f79fa3e079a60cbd1c + md5: 06f1111f9d5a1143ac0a20f057967ef5 + depends: + - __unix + constrains: + - rust >=1.97.1,<1.97.2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 34841287 + timestamp: 1784278690097 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.97.1-h2c6d0dc_0.conda + sha256: a00778be0151d1351d45b2f7b9f986321b682e132acabbc585992375c7cc4f8d + md5: d530601fa0c954eda9b76db577e95315 + depends: + - __unix + constrains: + - rust >=1.97.1,<1.97.2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 37492970 + timestamp: 1784279804310 +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda + sha256: 9e200ee5f9ff19a4d94e4b51c4856d53dec849f91032f345cf0c6bc3d51a7183 + md5: 62ac906f1cd582c6c264c95625cb9d6f + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/setuptools?source=compressed-mapping + run_exports: {} + size: 524488 + timestamp: 1786282924579 +- conda: https://conda.anaconda.org/conda-forge/noarch/svgelements-1.9.6-pyhcf101f3_1.conda + sha256: 06f544cd037f7d0852fce64c874b1ed3150e2817c3675d9560b11d9676f9ea41 + md5: 499b95285bb802299ce37292afa45be9 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/svgelements?source=hash-mapping + run_exports: {} + size: 124335 + timestamp: 1770589362612 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + strong: + - __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 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/tomli?source=hash-mapping + run_exports: {} + size: 21561 + timestamp: 1774492402955 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 + md5: c70ad746c22219b9700931707482992c + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + purls: + - pkg:pypi/typing-extensions?source=hash-mapping + run_exports: {} + size: 52631 + timestamp: 1783002732887 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + sha256: b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8 + md5: fcb489df604d100968b737f2cb6076c6 + license: LicenseRef-Public-Domain + purls: [] + run_exports: {} + size: 118849 + timestamp: 1784250406640 +- conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda + sha256: 04ce686cd187d379344f9b2be7b4da5f431b265dc0944a6b764fab9da9171948 + md5: 0839a3421140d4a9ba93fb988698fc00 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 147954 + timestamp: 1780946721169 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda + build_number: 7 + sha256: 7acaa2e0782cad032bdaf756b536874346ac1375745fb250e9bdd6a48a7ab3cd + md5: a44032f282e7d2acdeb1c240308052dd + depends: + - llvm-openmp >=9.0.1 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - _openmp_mutex >=4.5 + size: 8325 + timestamp: 1764092507920 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aom-3.14.1-pl5321h513545f_1.conda + sha256: 58ea99a3fe5fed86bfa40acc801010eb2a0a04dcf0180f68b4e2bf7b0ba7ec1f + md5: 506b0327a51de9871ce2725022c0c955 + depends: + - libcxx >=19 + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - aom >=3.14.1,<3.15.0a0 + size: 2669709 + timestamp: 1780752523088 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/appstream-1.1.1-py314hac25a1c_2.conda + sha256: a41f5539b6480351b618e2b79d7b37cd6b221be35afef15646df64df160f6868 + md5: 49cd42ac3544fcd60f58ed0950d49321 + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + - libcurl >=8.18.0,<9.0a0 + - libfyaml >=0.9.4,<0.10.0a0 + - libglib >=2.86.4,<3.0a0 + - libxml2 + - libxml2-16 >=2.15.1 + - libzlib >=1.3.1,<2.0a0 + - libxmlb >=0.3.25,<0.4.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - appstream >=1.1.1,<1.2.0a0 + size: 2327082 + timestamp: 1771521976195 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda + sha256: b0747f9b1bc03d1932b4d8c586f39a35ac97e7e72fe6e63f2b2a2472d466f3c1 + md5: 57301986d02d30d6805fdce6c99074ee + depends: + - __osx >=11.0 + - libcxx >=16 + - libglib >=2.80.0,<3.0a0 + - libintl >=0.22.5,<1.0a0 + constrains: + - atk-1.0 2.38.0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - atk-1.0 >=2.38.0 + size: 347530 + timestamp: 1713896411580 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 + md5: b50612e7d190b8061ab4e7dc119cf4d5 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 124965 + timestamp: 1785906749812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda + sha256: 104b41473845649101ba8ebf8221c7431256465d34ce380b10e9a90558ed33ac + md5: 7d9390a4d4b43f91652823d870f68065 + depends: + - __osx >=11.0 + constrains: + - c-ares-static <0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - c-ares >=1.34.8,<2.0a0 + size: 197274 + timestamp: 1786116660078 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + sha256: cde9b79ee206fe3ba6ca2dc5906593fb7a1350515f85b2a1135a4ce8ec1539e3 + md5: 36200ecfbbfbcb82063c87725434161f + depends: + - __osx >=11.0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libcxx >=19 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libglib >=2.86.3,<3.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.46.4,<1.0a0 + license: LGPL-2.1-only or MPL-1.1 + purls: [] + run_exports: + weak: + - cairo >=1.18.4,<2.0a0 + size: 900035 + timestamp: 1766416416791 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cfitsio-4.6.4-h29bb15e_1.conda + sha256: f54e91c2c1d3571fb91302a3b10bd0c3d9bf5de66af83e35ee1de759a0925a5d + md5: 0240dfbed60b00a028c0a5325269f8bf + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LicenseRef-fitsio + purls: [] + run_exports: + weak: + - cfitsio >=4.6.4,<4.6.5.0a0 + size: 604254 + timestamp: 1777679084571 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda + sha256: 93e077b880a85baec8227e8c72199220c7f87849ad32d02c14fb3807368260b8 + md5: 5a74cdee497e6b65173e10d94582fae6 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - dav1d >=1.2.1,<1.2.2.0a0 + size: 316394 + timestamp: 1685695959391 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/dbus-1.16.2-h3ff7a7c_1.conda + sha256: a8207751ed261764061866880da38e4d3063e167178bfe85b6db9501432462ba + md5: 5a3506971d2d53023c1c4450e908a8da + depends: + - libcxx >=19 + - __osx >=11.0 + - libglib >=2.86.2,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + purls: [] + run_exports: + weak: + - dbus >=1.16.2,<2.0a0 + size: 393811 + timestamp: 1764536084131 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda + sha256: ba685b87529c95a4bf9de140a33d703d57dc46b036e9586ed26890de65c1c0d5 + md5: 3b87dabebe54c6d66a07b97b53ac5874 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - epoxy >=1.5.10,<1.6.0a0 + size: 296347 + timestamp: 1758743805063 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/expat-2.8.1-hf6b4638_1.conda + sha256: 0f1f9c2f72a18f41c2096bd245425a7c43bc82d1aac24025ea308055352639b9 + md5: 79cef10347b58a6d3c10cc78614efda6 + depends: + - __osx >=11.0 + - libexpat 2.8.1 hf6b4638_1 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libexpat >=2.8.1,<3.0a0 + size: 136056 + timestamp: 1781203642860 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ffmpeg-9.0.0-gpl_habfc2cb_100.conda + sha256: 29efbe1b2599dd34c520c1af21e77ced310d99e8c48f34f075d99a58b0a6ed3d + md5: 9bafab46c7f0d4c762102e07c269fa7d + depends: + - __osx >=11.0 + - aom >=3.14.1,<3.15.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - lame >=4.0,<4.1.0a0 + - libass >=0.17.5,<0.17.6.0a0 + - libcxx >=19 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libharfbuzz >=14.3.0 + - libiconv >=1.18,<2.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - liblzma >=5.8.3,<6.0a0 + - libopenvino >=2026.3.0,<2026.3.1.0a0 + - libopenvino-arm-cpu-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-auto-batch-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-auto-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-hetero-plugin >=2026.3.0,<2026.3.1.0a0 + - libopenvino-ir-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-onnx-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-paddle-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-pytorch-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-tensorflow-frontend >=2026.3.0,<2026.3.1.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.3.0,<2026.3.1.0a0 + - libopus >=1.6.1,<2.0a0 + - libplacebo >=7.360.1,<7.361.0a0 + - librsvg >=2.62.3,<3.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - libvulkan-loader >=1.4.357.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.7,<4.0a0 + - sdl2 >=2.32.56,<3.0a0 + - svt-av1 >=4.2.0,<4.2.1.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - ffmpeg >=9.0.0,<10.0a0 + size: 10625036 + timestamp: 1786320593670 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fftw-3.3.11-nompi_haf1500d_100.conda + sha256: fc6c507d7c68db156d6c8c5f6a79ca6b34c2a4c0c6222d8d4ecd0e4b97d3fd5e + md5: 58628fdc0c614982f1dafd2116916288 + depends: + - __osx >=11.0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - llvm-openmp >=19.1.7 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - fftw >=3.3.11,<4.0a0 + size: 746873 + timestamp: 1776782373231 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.2-h2b252f5_0.conda + sha256: 9977c3f83d7f383de9bbd8a1ac6fd6eb4485cd9fda1679a9a63b697f4cb45a89 + md5: 992ac5eb08a3a29b5f465cf90f326471 + depends: + - __osx >=11.0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libintl >=0.25.1,<1.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + size: 262776 + timestamp: 1784754851028 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_1.conda + sha256: 96b33f1e2a32c602b167f43719e3acf89ec742b4a1e25e99ffd0e6f99b38d277 + md5: 7bd06ab4ed807154c2d9031eb5ebf025 + depends: + - libfreetype 2.14.3 hce30654_1 + - libfreetype6 2.14.3 hdfa99f5_1 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: + weak: + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + size: 173518 + timestamp: 1780933616544 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-h84a0fba_1.conda + sha256: 6dd18694340b84290bb1906cc1359502b974aada6a8476cfe6dec3ce0e860af8 + md5: 2bb7d7dd91116b8c85e805b0e08cc67b + depends: + - __osx >=11.0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - fribidi >=1.0.16,<2.0a0 + size: 60230 + timestamp: 1785912572097 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py314he620bd9_0.conda + sha256: e438def22f808583b9cdafac2f1372e7e49c5b126d757cd2066ceec390507181 + md5: 66194d999f40d3efd3af2f177160d15b + depends: + - __osx >=11.0 + - libglib >=2.86.3,<3.0a0 + - pkg-config + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - setuptools + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 360067 + timestamp: 1770760029054 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-host-tools-1.86.0-h148b53a_0.conda + sha256: b1a64b2016a8e9f1c3837c6561aa9d27c910be347951d458e5a7a4f84337012c + md5: 45cb5968cc10c0adb4d6625f9dd35f40 + depends: + - __osx >=11.0 + - libffi >=3.5.2,<3.6.0a0 + - libgirepository 1.86.0 h8c9ecdb_0 + - libglib >=2.86.3,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 93301 + timestamp: 1770760106229 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.7-h4e57454_0.conda + sha256: 69bb2e62a93f6407c9e9ccd4d21fe4d4e5373d64eecd6c5df318144ca4c80953 + md5: f717a22e13a1499c9552ffff02d22d64 + depends: + - __osx >=11.0 + - libglib >=2.88.2,<3.0a0 + - libintl >=0.25.1,<1.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gdk-pixbuf >=2.44.7,<3.0a0 + size: 553902 + timestamp: 1782591436963 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-h3dcc1bd_0.conda + sha256: 129a81e9da9f60ae6955b49938447e7faeb7e1be815b2db99e76956dddf8c392 + md5: 7059ba83fd98707b2cd9a5f06f589dd4 + depends: + - __osx >=11.0 + - gettext-tools 0.25.1 h493aca8_0 + - libasprintf 0.25.1 h493aca8_0 + - libasprintf-devel 0.25.1 h493aca8_0 + - libcxx >=18 + - libgettextpo 0.25.1 h493aca8_0 + - libgettextpo-devel 0.25.1 h493aca8_0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + - libintl-devel 0.25.1 h493aca8_0 + license: LGPL-2.1-or-later AND GPL-3.0-or-later + purls: [] + run_exports: + weak: + - libintl >=0.25.1,<1.0a0 + - libasprintf >=0.25.1,<1.0a0 + - libgettextpo >=0.25.1,<1.0a0 + size: 543276 + timestamp: 1751558682952 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-h493aca8_0.conda + sha256: e8dd68706676d5b6f6ee09240936a0ecd1ae12b87dbb37e4c4be263e332ab125 + md5: 817042c017930497931da6aa04a47f09 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 3748044 + timestamp: 1751558602508 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ghostscript-10.04.0-hf9b8971_0.conda + sha256: 14ffaf8c8b2c9f1f6ce5d6e2ba812c823e45263d85420b817a441b97c5ff2efd + md5: 9c76de1251a1cba00adfa38e083aef1b + depends: + - __osx >=11.0 + - libcxx >=17 + license: AGPL-3.0-only + license_family: AGPL + purls: [] + 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 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - giflib >=5.2.2,<5.3.0a0 + size: 73137 + timestamp: 1784694527697 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-2.88.3-hf4c8184_0.conda + sha256: d8798b8dd29a6ef7dae345c23432aab371ccc0100933fded4b555530575cfb7a + md5: c479e34be59e7cba3b73fe62ef0402c1 + depends: + - python * + - packaging + - libglib ==2.88.3 ha08bb59_0 + - glib-tools ==2.88.3 h5f197ff_0 + - libintl-devel + - libintl >=0.25.1,<1.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 91137 + timestamp: 1785442168180 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.3-h5f197ff_0.conda + sha256: 672b06a290ae9bd4443f9315334f2dbb6d0ba239b3a1f2ea40fd66321e7a3ac6 + md5: 607824e2f42f49049c999432385832e3 + depends: + - libglib ==2.88.3 ha08bb59_0 + - libffi + - __osx >=11.0 + - libintl >=0.25.1,<1.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: {} + size: 205078 + timestamp: 1785442168180 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glslang-16.5.0-hf31e910_1.conda + sha256: a8c64e4febf808fb49ab6e54565157ca9171d0e103c2fd2678e41e43b13f934f + md5: 5a4118473935b9676fc5284d069825b7 + depends: + - __osx >=11.0 + - libcxx >=19 + - spirv-tools >=2026,<2027.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - glslang >=16,<17.0a0 + size: 892729 + timestamp: 1785880407815 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.conda + sha256: 76e222e072d61c840f64a44e0580c2503562b009090f55aa45053bf1ccb385dd + md5: eed7278dfbab727b56f2c0b64330814b + depends: + - __osx >=11.0 + - libcxx >=16 + license: GPL-2.0-or-later OR LGPL-3.0-or-later + purls: [] + run_exports: + weak: + - gmp >=6.3.0,<7.0a0 + size: 365188 + timestamp: 1718981343258 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py314h7098110_0.conda + sha256: 2f21f86dc4c036d7f8be673019dcf3b92f4cdedc22e1ee707eb6ab2c99981fae + md5: 595f29841ad12960a8ddb5cc24185b5a + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - g-ir-build-tools 1.86.0 py314he620bd9_0 + - g-ir-host-tools 1.86.0 h148b53a_0 + - libffi >=3.5.2,<3.6.0a0 + - libgirepository 1.86.0 h8c9ecdb_0 + - libglib >=2.86.3,<3.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 75941 + timestamp: 1770760135086 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-h784d473_1.conda + sha256: 471f34a187fdb4f2df33e26f2e471b16c239a5b277903f643d9c3a8c9a9f44ec + md5: 0c7b78d9ffff4f5a6ca28d346f734d8f + depends: + - libcxx >=19 + - __osx >=11.0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - graphite2 >=1.3.15,<2.0a0 + size: 86493 + timestamp: 1786118637573 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-14.1.2-hec8c438_0.conda + sha256: 755c72d469330265f80a615912a3b522aef6f26cbc52763862b6a3c492fbf97c + md5: 1f3d859de3ca2bcaa845e92e87d73660 + depends: + - __osx >=11.0 + - adwaita-icon-theme + - cairo >=1.18.4,<2.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.4,<3.0a0 + - gtk3 >=3.24.43,<4.0a0 + - gts >=0.7.6,<0.8.0a0 + - libcxx >=19 + - libexpat >=2.7.3,<3.0a0 + - libgd >=2.3.3,<2.4.0a0 + - libglib >=2.86.3,<3.0a0 + - librsvg >=2.60.0,<3.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pango >=1.56.4,<2.0a0 + license: EPL-1.0 + license_family: Other + purls: [] + run_exports: + weak: + - graphviz >=14.1.2,<15.0a0 + size: 2218284 + timestamp: 1769427599940 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.52-hc0f3e19_0.conda + sha256: 26862a9898054b8552e55e609e5ce73c7ef1eb28bbe6fb87f0b9109d73cd09df + md5: 5557a2433b1339b8e536c264afea41ef + depends: + - __osx >=11.0 + - atk-1.0 >=2.38.0 + - cairo >=1.18.4,<2.0a0 + - epoxy >=1.5.10,<1.6.0a0 + - fribidi >=1.0.16,<2.0a0 + - gdk-pixbuf >=2.44.5,<3.0a0 + - glib-tools + - harfbuzz >=13.2.1 + - hicolor-icon-theme + - libexpat >=2.7.4,<3.0a0 + - libfreetype >=2.14.2 + - libfreetype6 >=2.14.2 + - libglib >=2.86.4,<3.0a0 + - libintl >=0.25.1,<1.0a0 + - liblzma >=5.8.2,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + - pango >=1.56.4,<2.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gtk3 >=3.24.52,<4.0a0 + - adwaita-icon-theme + size: 9385734 + timestamp: 1774288504338 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk4-4.22.4-hafa59c1_4.conda + sha256: 028abc5d4138184520d8e1ce0e461c21c77d3ad3983ebfc7271bd4de07c5f4be + md5: bfa36190d2ab1ee6716ed735d8d480af + depends: + - hicolor-icon-theme + - pango + - libgraphene + - fribidi + - fontconfig + - glib-tools + - libcxx >=19 + - __osx >=11.0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - cairo >=1.18.4,<2.0a0 + - gdk-pixbuf >=2.44.6,<3.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - librsvg >=2.62.3,<3.0a0 + - libpng >=1.6.58,<1.7.0a0 + - epoxy >=1.5.10,<1.6.0a0 + - libzlib >=1.3.2,<2.0a0 + - libglib >=2.88.1,<3.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - harfbuzz >=14.2.1 + - pango >=1.56.4,<2.0a0 + - libintl >=0.25.1,<1.0a0 + - libasprintf >=0.25.1,<1.0a0 + - libgettextpo >=0.25.1,<1.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gtk4 >=4.22.4,<5.0a0 + - adwaita-icon-theme + size: 23238996 + timestamp: 1782208719671 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda + sha256: e0f8c7bc1b9ea62ded78ffa848e37771eeaaaf55b3146580513c7266862043ba + md5: 21b4dd3098f63a74cf2aa9159cbef57d + depends: + - libcxx >=15.0.7 + - libglib >=2.76.3,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gts >=0.7.6,<0.8.0a0 + size: 304331 + timestamp: 1686545503242 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.3.0-hce30654_0.conda + sha256: 67042b0e8896f707c60981947ae22fe0c13942283c8d7ac9b8286cc01cc1c2d2 + md5: aab13bb027c8295f5b09ccb8cd084698 + depends: + - libharfbuzz-devel 14.3.0 h5a65909_0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libharfbuzz >=14.3.0 + size: 11005 + timestamp: 1785770060418 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-1.14.6-nompi_had3affe_110.conda + sha256: 6964fee3d3a4a59c85d2589eb5e8c8bff7dde9721854f493cc7b9aca5cd7d4be + md5: 65797138355b90a8e219afcf7e77b273 + depends: + - __osx >=11.0 + - libaec >=1.1.5,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - hdf5 >=1.14.6,<1.14.7.0a0 + size: 3290207 + timestamp: 1780581564967 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_3.conda + sha256: 46a4958f2f916c5938f2a6dc0709f78b175ece42f601d79a04e0276d55d25d07 + md5: cfb39109ac5fa8601eb595d66d5bf156 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 17616 + timestamp: 1771539622983 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda + sha256: f0b22bc30e4cc29e29ba3234cb38497fe8def2c2aae4b775d42fe5b378a018c9 + md5: 6133ddbb17ba2b50700dd88e9303ce27 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14070698 + timestamp: 1784916459058 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_27-agpl_h3167ce9_100.conda + sha256: 6e2e85cf9690b47620003b0594e96731eb2b0316d046851246d4531e52781d1c + md5: b0067e0d7e440653c33b5f2a528b4cce + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - fftw >=3.3.11,<4.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - fonts-conda-forge + - ghostscript + - giflib >=5.2.2,<5.3.0a0 + - graphviz >=14.1.2,<15.0a0 + - lcms2 >=2.19.1,<3.0a0 + - libcxx >=19 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.2,<3.0a0 + - libheif >=1.23.0,<1.24.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libraw >=0.22.1,<0.23.0a0 + - librsvg >=2.62.3,<3.0a0 + - libtiff >=4.7.2,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzip >=1.11.2,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openexr >=3.4.13,<3.5.0a0 + - openjpeg >=2.5.4,<3.0a0 + - pango >=1.56.4,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - xorg-libxt >=1.3.1,<2.0a0 + license: AGPL-3.0-only AND ImageMagick + license_family: AGPL + purls: [] + run_exports: {} + size: 2064983 + timestamp: 1783294493345 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/imath-3.2.2-h13e8271_0.conda + sha256: 18986a94213497b9e60880fbbb71c54ac4f34adf948373d8298af8316fab17fc + md5: 29a16094695fed33cf0f7881f461aea3 + depends: + - __osx >=11.0 + - libcxx >=19 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - imath >=3.2.2,<3.2.3.0a0 + size: 155893 + timestamp: 1784330021386 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/jasper-4.2.9-h7543a42_1.conda + sha256: 58bfd15b7426f99ca2b1854535d4ede1ca2a35be4f8c43c5e34b6ffdcfd7a5c8 + md5: e3f3a2a62fbaad99f327440dfd8a3ac3 + depends: + - __osx >=11.0 + - libjpeg-turbo >=3.1.2,<4.0a0 + license: JasPer-2.0 + purls: [] + run_exports: + weak: + - jasper >=4.2.9,<5.0a0 + size: 584700 + timestamp: 1773681839297 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + sha256: c740e4a2e7247776a9883158fdab50ae0732c8f67f96d8f1db8ad9da5e0b5222 + md5: 8780f41b013d19219faef9c82260744b + depends: + - __osx >=11.0 + - libcxx >=19 + - libedit >=3.1.20250104,<3.2.0a0 + - libedit >=3.1.20250104,<4.0a0 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - krb5 >=1.22.2,<1.23.0a0 + size: 1159780 + timestamp: 1781859501654 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lame-4.0-hef9b5c2_1.conda + sha256: 98b9195b315777c9cf1d785b5d43f223bafadbb2c5467116d688bd96305fa353 + md5: 5f74847ab1fbc7ffdcfe9d25269eeff6 + depends: + - __osx >=11.0 + - mpg123 >=1.33.7,<1.34.0a0 + license: LGPL-2.0-only + license_family: LGPL + purls: [] + run_exports: + weak: + - lame >=4.0,<4.1.0a0 + size: 296576 + timestamp: 1786293154234 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hdfa7624_1.conda + sha256: ccb5598fad3694e79bf54f0eb812e3b3c3dd63d1497e631f5978800eadb9bcc4 + md5: d2f2c7c10e2957647d45589b7701a453 + depends: + - __osx >=11.0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - lcms2 >=2.19.1,<3.0a0 + size: 213747 + timestamp: 1780212240694 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.2.0-h1eee2c3_0.conda + sha256: c97aa17d16d2ac332ba9f184e82ce8f72dcb10e9a10c5f299030be2d44e191b9 + md5: e429aec4037d5cb8fd34ded9f5dadd39 + depends: + - __osx >=11.0 + - libcxx >=19 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - lerc >=4.2.0,<5.0a0 + size: 166477 + timestamp: 1785036480092 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda + sha256: 450026eb01a52acd0ff122e331ec9b8546c93790143214b73e1c14bc2b075b22 + md5: 8adfdc0215e979a0ce31be676883e0b3 + depends: + - __osx >=11.0 + - libcxx >=19 + constrains: + - libabseil-static =20260526.0=cxx17* + - abseil-cpp =20260526.0 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - libabseil >=20260526.0,<20260527.0a0 + - libabseil =*=cxx17* + size: 1273408 + timestamp: 1780524599788 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.9.3-h033e7b5_0.conda + sha256: 148587abac97f33ff64152af067b271515bfe75e5a40679f13927be0de31bbb0 + md5: d4699da8429ad90e228ff34bfb2e3f25 + depends: + - __osx >=11.0 + - fribidi >=1.0.16,<2.0a0 + - pango >=1.58.0,<2.0a0 + - gtk4 >=4.22.4,<5.0a0 + - libxml2 + - appstream >=1.1.1,<1.2.0a0 + - libglib >=2.88.3,<3.0a0 + - libfreetype >=2.14.3 + - libintl >=0.25.1,<1.0a0 + - libasprintf >=0.25.1,<1.0a0 + - libgettextpo >=0.25.1,<1.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libadwaita >=1.9.3,<1.10.0a0 + size: 849309 + timestamp: 1785768734011 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libaec-1.1.5-h8664d51_0.conda + sha256: af9cd8db11eb719e38a3340c88bb4882cf19b5b4237d93845224489fc2a13b46 + md5: 13e6d9ae0efbc9d2e9a01a91f4372b41 + depends: + - __osx >=11.0 + - libcxx >=19 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libaec >=1.1.5,<2.0a0 + size: 30390 + timestamp: 1769222133373 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.9-gpl_h6fbacd7_100.conda + sha256: b58cfffd0774142d308a25f1bdc610c46d71cb67489f1b1eec0891e78ede5b11 + md5: 4963589c8bed67dd0540d890ba690f53 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - lzo >=2.10,<3.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libarchive >=3.8.9,<3.9.0a0 + size: 800281 + timestamp: 1785251408018 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + sha256: 7265547424e978ea596f51cc8e7b81638fb1c660b743e98cc4deb690d9d524ab + md5: 0deb80a2d6097c5fb98b495370b2435b + depends: + - __osx >=11.0 + - libcxx >=18 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libasprintf >=0.25.1,<1.0a0 + size: 52316 + timestamp: 1751558366611 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-devel-0.25.1-h493aca8_0.conda + sha256: fc76b07620eabde52928c69bcdcb5497da3fdad3331a76f9d4bffeb27e0bdd8f + md5: c18067d2d5864e77f84456d97c1c17cc + depends: + - __osx >=11.0 + - libasprintf 0.25.1 h493aca8_0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libasprintf >=0.25.1,<1.0a0 + size: 35256 + timestamp: 1751558418167 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libass-0.17.5-h3245dfc_0.conda + sha256: b006fccaa3e4d122188bd3db71d630d4d3a7d2f99fbcdef5ac53b3299c65909d + md5: baae8eafd053d3e6bd88c6d053c6f624 + depends: + - __osx >=11.0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libiconv >=1.18,<2.0a0 + - harfbuzz >=14.2.1 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - libzlib >=1.3.2,<2.0a0 + - fribidi >=1.0.16,<2.0a0 + license: ISC + purls: [] + run_exports: + weak: + - libass >=0.17.5,<0.17.6.0a0 + size: 139969 + timestamp: 1782299036301 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libavif16-1.4.2-h84013e8_3.conda + sha256: 4ca37f5e0a48348578914f707bd45efef08d42a081d30a8471125f059be82d8b + md5: c180959743b896db9689e9e2d581af9a + depends: + - __osx >=11.0 + - aom >=3.14.1,<3.15.0a0 + - svt-av1 >=4.2.0,<4.2.1.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - rav1e >=0.8.1,<0.9.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libavif16 >=1.4.2,<2.0a0 + size: 135648 + timestamp: 1784120040973 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-9_h51639a9_openblas.conda + build_number: 9 + sha256: 0437866fe43b4c911470d3e7ddea18d78390bd7062d9563ce6d38c8ba5798405 + md5: cb1f85be9d88af453fcda3dfba995099 + depends: + - libopenblas >=0.3.34,<0.3.35.0a0 + - libopenblas >=0.3.34,<1.0a0 + constrains: + - blas 2.309 openblas + - libcblas 3.11.0 9*_openblas + - liblapack 3.11.0 9*_openblas + - liblapacke 3.11.0 9*_openblas + - mkl <2027 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libblas >=3.11.0,<4.0a0 + size: 18162 + timestamp: 1786058887392 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + sha256: a7cb9e660531cf6fbd4148cff608c85738d0b76f0975c5fc3e7d5e92840b7229 + md5: 006e7ddd8a110771134fcc4e1e3a6ffa + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + size: 79443 + timestamp: 1764017945924 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + sha256: 2eae444039826db0454b19b52a3390f63bfe24f6b3e63089778dd5a5bf48b6bf + md5: 079e88933963f3f149054eec2c487bc2 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 29452 + timestamp: 1764017979099 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + sha256: 01436c32bb41f9cb4bcf07dda647ce4e5deb8307abfc3abdc8da5317db8189d1 + md5: b2b7c8288ca1a2d71ff97a8e6a1e8883 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 + size: 290754 + timestamp: 1764018009077 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-9_hb0561ab_openblas.conda + build_number: 9 + sha256: c4c71f20fdb20c86bf6f61c8c31bb349e4055bb4d19928e8580bb5615039cb4b + md5: a7b6ba94e3ca58bc7d4e1ae4ff95c215 + depends: + - libblas 3.11.0 9_h51639a9_openblas + constrains: + - blas 2.309 openblas + - liblapack 3.11.0 9*_openblas + - liblapacke 3.11.0 9*_openblas + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libcblas >=3.11.0,<4.0a0 + size: 18110 + timestamp: 1786058893756 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.21.0-hf618e03_4.conda + sha256: d25be36712d7f854a5f93513b9fbf1b0ee3219975b7fef4a3ee071b021b10167 + md5: 66cf9c5003ee81ecdb0dc9f9df17bbe5 + depends: + - __osx >=11.0 + - krb5 >=1.22.2,<1.23.0a0 + - libnghttp2 >=1.68.1,<2.0a0 + - libpsl >=0.23.0,<0.24.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + purls: [] + run_exports: + weak: + - libcurl >=8.21.0,<9.0a0 + size: 411491 + timestamp: 1785500129743 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef + md5: 89f76a2a21a3ec3ec983b5eb237c4113 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + purls: [] + run_exports: {} + size: 569349 + timestamp: 1781670209146 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.1-h3feff0a_0.conda + sha256: 38bdf17d4da3e913ace05dc3890363dc85cb39fcfd3aea746e9ad3270763cf69 + md5: b1d3450f78861e98cf805c3c78eee009 + depends: + - __osx >=11.0 + - libcxx >=19 + 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 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda + sha256: d896f4aa4ce4c590c2838678cb1917356fdb461d2a189991c0280c818c362172 + md5: 78650d671cb56909bb3e5c13bce310f9 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 55727 + timestamp: 1785909153744 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdicom-1.3.0-h84a0fba_0.conda + sha256: a267d8317d57f38f30a7a339e88d4c52ed188264e3d9fdf502c811137837d077 + md5: d9271d68420d41029bd7d232176b3354 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdicom >=1.3.0,<1.4.0a0 + size: 123122 + timestamp: 1781466431193 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdovi-3.4.0-h78f8ca3_0.conda + sha256: 601623820de052084831278e9fce93aa47fc9afb571a84b806688e46920a276b + md5: 39f3bc34f80d45b03176c600f1d3c9f5 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libdovi >=3.4.0,<4.0a0 + size: 357852 + timestamp: 1784281788521 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + sha256: 66aa216a403de0bb0c1340a88d1a06adaff66bae2cfd196731aa24db9859d631 + md5: 44083d2d2c2025afca315c7a172eab2b + depends: + - ncurses + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 107691 + timestamp: 1738479560845 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda + sha256: c0100064506ae8abb432c5a506d474f10af2cf48c33d62bc221fb28b6d6ff6ac + md5: 19e86c8a6a47e92bb2e70ca12e758c5c + depends: + - __osx >=11.0 + license: BSD-2-Clause OR GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - libev >=4.33,<4.34.0a0 + size: 39991 + timestamp: 1785917376956 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexif-0.6.26-h1a92334_3.conda + sha256: 4edd25cf6c04d97d17d64883c46077d4c11193c84b1ef48facd79ad4e0d0f74e + md5: 3528ec1280898ce2767264d8be370553 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + purls: [] + run_exports: + weak: + - libexif >=0.6.26,<0.6.27.0a0 + size: 144822 + timestamp: 1780051521753 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + sha256: d5637b01941c0fc8f5cbb1f170c238f4ee153b3c1708b9d50f4f1305438ff051 + md5: 0582e67cd14cfed773be2f3b1aba08e0 + depends: + - libfreetype6 >=2.14.3 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: {} + size: 8365 + timestamp: 1780933612390 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + sha256: abbfffd8a8c776bb8b59a10c8247fc3aa6b17ba0051e9f6d199dca38479f214f + md5: a0bb0678f67c464938d3693fa96f6884 + depends: + - __osx >=11.0 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - freetype >=2.14.3 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: {} + size: 338442 + timestamp: 1780933611662 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfyaml-0.9.6-h84a0fba_0.conda + sha256: 97e63f7ae21e09ccb8c8e1129d7c53c86b0b2ba31b982d3ac2c4a4c0369175ce + md5: 897cf4123c6d1d6cc9b59eb5902b7775 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libfyaml >=0.9.6,<0.10.0a0 + size: 483569 + timestamp: 1773591014515 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.1.0-h3cf6597_1.conda + sha256: fdd1502babb50b802d090496586231f56236d7a6fc042a4e1ac2dee48da8366b + md5: 0124dc2e6f70f3e8ebea643b42b18abb + depends: + - _openmp_mutex + constrains: + - libgomp 16.1.0 1 + - libgcc-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 364162 + timestamp: 1785374452947 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgd-2.3.3-h05bcc79_12.conda + sha256: 269edce527e204a80d3d05673301e0207efcd0dbeebc036a118ceb52690d6341 + md5: fa4a92cfaae9570d89700a292a9ca714 + depends: + - __osx >=11.0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libiconv >=1.18,<2.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + license: GD + license_family: BSD + purls: [] + run_exports: + weak: + - libgd >=2.3.3,<2.4.0a0 + size: 159247 + timestamp: 1766331953491 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + sha256: 3ba35ff26b3b9573b5df5b9bbec5c61476157ec3a9f12c698e2a9350cd4338fd + md5: 98acd9989d0d8d5914ccc86dceb6c6c2 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - libgettextpo >=0.25.1,<1.0a0 + size: 183091 + timestamp: 1751558452316 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-devel-0.25.1-h493aca8_0.conda + sha256: 976941e18f879e5c1e67553f9657f7bb9d3935c89014ebfeafe89dcfba2de9e7 + md5: 91c2fdde1cb4a61b5cb7afa682af359e + depends: + - __osx >=11.0 + - libgettextpo 0.25.1 h493aca8_0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - libgettextpo >=0.25.1,<1.0a0 + size: 37894 + timestamp: 1751558502415 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-16.1.0-h07b0088_1.conda + sha256: d7c6dd601dbbde495ab213a76d690b2fec19455d26c595ec0257cfde3e80166b + md5: d6f10dbb9c5830f540904c91ea5b252a + depends: + - libgfortran5 16.1.0 h32cdfcc_1 + constrains: + - libgfortran-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 98528 + timestamp: 1785374566402 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.1.0-h32cdfcc_1.conda + sha256: 3e8cb79a421e0c350566717febdba9079574cbbe204591b4fd4b4081ea89cb92 + md5: c1c10ea48f95054aa9b93c2315a0a74a + depends: + - libgcc >=16.1.0 + constrains: + - libgfortran 16.1.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 556657 + timestamp: 1785374459225 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgirepository-1.86.0-h8c9ecdb_0.conda + sha256: 3bdb475d4cc4bc876df926116e0c52568e94c0c39c5b531ac980b8cc43c359f6 + md5: b79548958712256b7981981528377cdd + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libglib >=2.86.3,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + purls: [] + run_exports: {} + size: 125028 + timestamp: 1770760074709 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_0.conda + sha256: a14417ae1f3f4a92c5766354eb280342fa6aae72a09af86bf7fcfc12a8c9225c + md5: 4a9309d9502a08a2d29b1743dcfb0e7f + depends: + - __osx >=11.0 + - pcre2 >=10.47,<10.48.0a0 + - libzlib >=1.3.2,<2.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libintl >=0.25.1,<1.0a0 + - libiconv >=1.18,<2.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 4448109 + timestamp: 1785442168180 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgraphene-1.10.8-h77cb426_2.conda + sha256: ff9f1a571e51ee1d4dd414de8de0d1034691617694072374446f46d807fe31f8 + md5: aa16391f2c19ac0e6f37daa81b4f2779 + depends: + - __osx >=11.0 + - libglib >=2.86.4,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 192347 + timestamp: 1776657964747 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.3.0-h5a65909_0.conda + sha256: 5803da482e914fc5d7ea82b31789471973b03ca58e9caffedd86e175c3aee447 + md5: 19248fa96b4c573e46bfec7fa3c875fa + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.15,<2.0a0 + - icu >=78.3,<79.0a0 + - libcxx >=19 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.3,<3.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 942277 + timestamp: 1785770026085 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.3.0-h5a65909_0.conda + sha256: ec6822ad0101e1d627aebb04792a0a642a706ba3c12e2691a90c7c3995b09fc1 + md5: 9b9a765bb25ac591d79ced348d4dc340 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - freetype + - graphite2 >=1.3.15,<2.0a0 + - icu >=78.3,<79.0a0 + - libcxx >=19 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.3,<3.0a0 + - libharfbuzz 14.3.0 h5a65909_0 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libharfbuzz >=14.3.0 + size: 1470616 + timestamp: 1785770054858 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.1-gpl_h800d22d_100.conda + sha256: 925a24f4cda9dd3b6abd05799b6b908be6845aa9c7608f2fca4bf5c749f5c745 + md5: f6031e2cb1e826c30bae14bd80ff7bf2 + depends: + - __osx >=11.0 + - aom >=3.14.1,<3.15.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - libavif16 >=1.4.2,<2.0a0 + - libcxx >=19 + - libde265 >=1.1.1,<1.1.2.0a0 + - x265 >=3.5,<3.6.0a0 + license: LGPL-3.0-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - libheif >=1.23.1,<1.24.0a0 + size: 656496 + timestamp: 1784842154951 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.13.0-default_ha97f43a_1000.conda + sha256: d47c3c030671d196ff1cdd343e93eb2ae0d7b665cb79f8164cc91488796db437 + md5: fed55ddd65a830cb62e78f07cfffcd41 + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libhwloc >=2.13.0,<2.13.1.0a0 + size: 2339152 + timestamp: 1770953916323 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-h493e8d7_0.conda + sha256: e2b0108325d360961750bd35d975ca59694f9c1efff6ec241470c68ca09cacc0 + md5: 5b102fdc40afa0b6030c7867d939c00e + depends: + - __osx >=11.0 + - libcxx >=19 + license: Apache-2.0 OR BSD-3-Clause + purls: [] + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 610044 + timestamp: 1784325884330 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03 + md5: 4d5a7445f0b25b6a3ddbb56e790f5251 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + purls: [] + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 750379 + timestamp: 1754909073836 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + sha256: 99d2cebcd8f84961b86784451b010f5f0a795ed1c08f1e7c76fbb3c22abf021a + md5: 5103f6a6b210a3912faf8d7db516918c + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libintl >=0.25.1,<1.0a0 + size: 90957 + timestamp: 1751558394144 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-devel-0.25.1-h493aca8_0.conda + sha256: 5a446cb0501d87e0816da0bce524c60a053a4cf23c94dfd3e2b32a8499009e36 + md5: 5f9888e1cdbbbef52c8cf8b567393535 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libintl >=0.25.1,<1.0a0 + size: 40340 + timestamp: 1751558481257 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.2.0-h84a0fba_1.conda + sha256: 05006418f9392c9b723e8428808db106de0350a497832f95f921b85ff1072310 + md5: b2f8c8e5a7651a1d7c05404c3a159517 + depends: + - __osx >=11.0 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + purls: [] + run_exports: + weak: + - libjpeg-turbo >=3.2.0,<4.0a0 + size: 558459 + timestamp: 1785896382474 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.12.0-h934fa54_1.conda + sha256: 83b69f759845ddc12444f5a812bdf08782ab2b0156ae08b706b26777918416c3 + md5: a7040219e41397bf619b7062aaa88de1 + depends: + - __osx >=11.0 + - libcxx >=19 + - libhwy >=1.4.0,<1.5.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libjxl >=0.12.0,<0.13.0a0 + size: 1032881 + timestamp: 1783146206980 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-9_hd9741b5_openblas.conda + build_number: 9 + sha256: db09e8e6a58415da1d866221cf518e53e84c6766a4500faae716cfa204f696ff + md5: ecc87ca1e25bd94a08c82904eb4e6846 + depends: + - libblas 3.11.0 9_h51639a9_openblas + constrains: + - blas 2.309 openblas + - libcblas 3.11.0 9*_openblas + - liblapacke 3.11.0 9*_openblas + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - liblapack >=3.11.0,<3.12.0a0 + size: 18144 + timestamp: 1786058899889 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311 + md5: 8ab10323068b107661a4b9a4af84f3b5 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + purls: [] + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 91720 + timestamp: 1786348695846 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmatio-1.5.30-h8eade5c_0.conda + sha256: 80b60a764c75bd3153d29751403142a7254376fa02ce6035f36f64134d24b784 + md5: 5bea68d5a5e10226b130f456921a1a57 + depends: + - __osx >=11.0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - zlib + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libmatio >=1.5.30,<1.5.31.0a0 + size: 174283 + timestamp: 1767753950525 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: {} + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + sha256: 2bc7bc3978066f2c274ebcbf711850cc9ab92e023e433b9631958a098d11e10a + md5: 6ea18834adbc3b33df9bd9fb45eaf95b + depends: + - __osx >=11.0 + - c-ares >=1.34.6,<2.0a0 + - libcxx >=19 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libnghttp2 >=1.68.1,<2.0a0 + size: 576526 + timestamp: 1773854624224 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libogg-1.3.5-h48c0fde_1.conda + sha256: 28bd1fe20fe43da105da41b95ac201e95a1616126f287985df8e86ddebd1c3d8 + md5: 29b8b11f6d7e6bd0e76c029dcf9dd024 + depends: + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libogg >=1.3.5,<1.4.0a0 + size: 216719 + timestamp: 1745826006052 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_he657e61_0.conda + sha256: bcf1967f12f1b1cc769dcc77b255fb1b27aaceb2f185450e9596d137bc6ede76 + md5: 89d28fb841cf16211318524fa985e384 + depends: + - __osx >=11.0 + - libgfortran + - libgfortran5 >=14.3.0 + - llvm-openmp >=19.1.7 + constrains: + - openblas >=0.3.34,<0.3.35.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libopenblas >=0.3.34,<1.0a0 + size: 4318474 + timestamp: 1784288246205 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-2026.3.0-hb34758f_0.conda + sha256: 90cfa7519289f326191c0e3b5d75a781484449c0ef221d5c58dd9c3015372c9e + md5: be6d63e533043034652723d21ab1ce71 + depends: + - __osx >=12.0 + - libcxx >=19 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino >=2026.3.0,<2026.3.1.0a0 + size: 4754020 + timestamp: 1786127169030 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-arm-cpu-plugin-2026.3.0-hb34758f_0.conda + sha256: 248b0ae01ba4fde2c08780458cd35271212c113c0c46c907bb11a9d9c8079582 + md5: 5f84b030ce112d944eb810eab31a7d94 + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 8698915 + timestamp: 1786127199252 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-batch-plugin-2026.3.0-h9254539_0.conda + sha256: a63e0a9ca5ce7d331a8324cf1763e71587902101db1812214b3130a422b98a6f + md5: a37cce8eefaaca0a07aa77abf1f2fc89 + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 105309 + timestamp: 1786127248151 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-plugin-2026.3.0-h9254539_0.conda + sha256: ac3ab03103650bd0e7bf8204faeea040cf731bd8fd90b352740203e46b033310 + md5: ff82aec6ec58d823a8eb5cc10b28611b + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 213311 + timestamp: 1786127265673 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-hetero-plugin-2026.3.0-hd4b9630_0.conda + sha256: bb558d26e23655c6e0b8459834cdb1654f1cbcf3c9f1ca090c42a5c69e2d140f + md5: 1ccbfacbdaa5c04c0d53a99cc0a5812d + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 192161 + timestamp: 1786127278708 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-ir-frontend-2026.3.0-hd4b9630_0.conda + sha256: 40fd3dc3afda228d5b65a381c54865b2b477ba944fd3ba17faeea11a52db2abe + md5: 94f7c8aa629b0916d7af47c80c925b27 + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-ir-frontend >=2026.3.0,<2026.3.1.0a0 + size: 181104 + timestamp: 1786127291150 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-onnx-frontend-2026.3.0-h543423f_0.conda + sha256: e29b17107e05e6e79671c5dc7f27f2317b5191eeabb99f8bcff7055ea5b78901 + md5: ee5892a00c4254ba29ab0628ae17fde5 + depends: + - __osx >=12.0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-onnx-frontend >=2026.3.0,<2026.3.1.0a0 + size: 1581719 + timestamp: 1786127307285 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-paddle-frontend-2026.3.0-h543423f_0.conda + sha256: 833a4b62df3fe61cbe12eeaa9c49c1a8a23c5a087da578b6141852f6fa682683 + md5: f8d9b9b0922c2f0cb2ab2ad1cead43bb + depends: + - __osx >=12.0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-paddle-frontend >=2026.3.0,<2026.3.1.0a0 + size: 437495 + timestamp: 1786127325371 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-pytorch-frontend-2026.3.0-haa2453c_0.conda + sha256: 436e03dd3c9582f7c9ca632865790ea827c23c7f1b6a13678163cf7647cdc7fd + md5: 7fa7627c08a185697a8219e5973230d4 + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-pytorch-frontend >=2026.3.0,<2026.3.1.0a0 + size: 852295 + timestamp: 1786127342440 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-frontend-2026.3.0-habdbaaf_0.conda + sha256: efae4bdca6ad1418de56ac614b4fd4f34f10bbd55ae289d6c56dd5483cdd8d56 + md5: d0dfb2fb4ec2ce8e9d52d5d07f9efbc9 + depends: + - __osx >=12.0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - snappy >=1.2.2,<1.3.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-tensorflow-frontend >=2026.3.0,<2026.3.1.0a0 + size: 926559 + timestamp: 1786127358588 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-lite-frontend-2026.3.0-haa2453c_0.conda + sha256: bd5804bb13723f93307cd840bafb5f9bbeae0e89a02db321d18ce59634725b75 + md5: 952df3c0f71fec5b2aa01ac17de024bc + depends: + - __osx >=12.0 + - libcxx >=19 + - libopenvino 2026.3.0 hb34758f_0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-tensorflow-lite-frontend >=2026.3.0,<2026.3.1.0a0 + size: 413329 + timestamp: 1786127374260 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopus-1.6.1-h1a92334_0.conda + sha256: 5c95a5f7712f543c59083e62fc3a95efec8b7f3773fbf4542ad1fb87fbf51ff4 + md5: 7f414dd3fd1cb7a76e51fec074a9c49e + depends: + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libopus >=1.6.1,<2.0a0 + size: 308000 + timestamp: 1768497248058 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libplacebo-7.360.1-hca394fb_1.conda + sha256: 7b50ecb8b110540b5b980ba94499fe760947e079bc119a7fac02dfb3df9cfb53 + md5: dff7f8dd3053f3253d79171216aa1db7 + depends: + - __osx >=11.0 + - libcxx >=19 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - shaderc >=2026.3,<2026.4.0a0 + - lcms2 >=2.19.1,<3.0a0 + - libdovi >=3.4.0,<4.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libplacebo >=7.360.1,<7.361.0a0 + size: 529560 + timestamp: 1784287976080 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda + sha256: 66eae34546df1f098a67064970c92aa14ae7a7505091889e00468294d2882c36 + md5: 2259ae0949dbe20c0665850365109b27 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: zlib-acknowledgement + purls: [] + run_exports: + weak: + - libpng >=1.6.58,<1.7.0a0 + size: 289546 + timestamp: 1776315246750 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-7.35.1-h8daa630_2.conda + sha256: 782d30325e61249e3240979e0f76d9dbf67867b1f38424cfdb1abb3a0f2cfa95 + md5: 7f77d9a99dd9e79e1f5be50d6632f675 + depends: + - __osx >=12.0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libcxx >=19 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libprotobuf >=7.35.1,<7.35.2.0a0 + size: 2900719 + timestamp: 1783168180812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.0-h7a62e17_0.conda + sha256: f71026a40df9ef28ada6d97c0f441e7ca9bafa03d0ae0c3f5f03d1971acb656b + md5: 1aecee022672c6c97c827ceb6b0e2ead + depends: + - libcxx >=19 + - __osx >=11.0 + - icu >=78.3,<79.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libpsl >=0.23.0,<0.24.0a0 + size: 72956 + timestamp: 1785426941596 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libraw-0.22.2-h2d05ff4_0.conda + sha256: e3e654527f10346ca39fe628bb6f81dadf946647e5c081f84703ab654ae15a45 + md5: c312034f884b32402cd6d97dc230c495 + depends: + - __osx >=11.0 + - libcxx >=19 + - libjpeg-turbo >=3.2.0,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - lcms2 >=2.19.1,<3.0a0 + - jasper >=4.2.9,<5.0a0 + license: LGPL-2.1-only + purls: [] + run_exports: + weak: + - libraw >=0.22.2,<0.23.0a0 + size: 692346 + timestamp: 1784221114443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda + sha256: f5b4fb7b6f13bbfca59613bff2e70b5a398e80727b9d0f814837ffcbc34185e1 + md5: 6973724fadafe66ac6e4f1c55c191407 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.0,<3.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.6,<3.0a0 + - harfbuzz >=14.2.0 + - libglib >=2.88.1,<3.0a0 + - libxml2-16 >=2.14.6 + - pango >=1.56.4,<2.0a0 + constrains: + - __osx >=11.0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - librsvg >=2.62.3,<3.0a0 + size: 2397567 + timestamp: 1780452232118 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + sha256: 745662565e103f290e9dc4263bbd88285082f8cf699854fe2d5f1e35a4a0d326 + md5: 0e3477c0c3e718dcf2eb74ccc8f68570 + 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: 929203 + timestamp: 1785016131414 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + sha256: 8bfe837221390ffc6f111ecca24fa12d4a6325da0c8d131333d63d6c37f27e0a + md5: b68e8f66b94b44aaa8de4583d3d4cc40 + depends: + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libssh2 >=1.11.1,<2.0a0 + size: 279193 + timestamp: 1745608793272 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-h282da08_0.conda + sha256: 253153cabf9469170e02b21a6bc9aa598048e7c34966b1103af52d27d2a708a4 + md5: 6fa87106b3c041ad6d965a9411e79b94 + depends: + - __osx >=11.0 + - lerc >=4.1.0,<5.0a0 + - libcxx >=19 + - libdeflate >=1.25,<1.26.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + purls: [] + run_exports: + weak: + - libtiff >=4.7.2,<4.8.0a0 + size: 387825 + timestamp: 1783085754081 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libusb-1.0.29-hbc156a2_0.conda + sha256: 5eee9a2bf359e474d4548874bcfc8d29ebad0d9ba015314439c256904e40aaad + md5: f6654e9e96e9d973981b3b2f898a5bfa + depends: + - __osx >=11.0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libusb >=1.0.29,<2.0a0 + size: 83849 + timestamp: 1748856224950 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_1.conda + sha256: 4f47de9de1990efd998edbbd6793f89c8f02ffde987ae8120b1c006acefd2a04 + md5: de09bd0f175611e94f21b28f8c708e80 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 122729 + timestamp: 1785914645797 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvips-8.18.5-hacd21a2_0.conda + sha256: adadfb5109a0141e2e5295e4a5cd9a36cd2276e26a0e8a6aac6f767e2afe899f + md5: 2b1e2ebc1e70bb1cdee7153c0b50dda5 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - cfitsio >=4.6.4,<4.6.5.0a0 + - fftw >=3.3.11,<4.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - imagemagick + - lcms2 >=2.19.1,<3.0a0 + - libarchive >=3.8.9,<3.9.0a0 + - libasprintf >=0.25.1,<1.0a0 + - libcxx >=19 + - libexif >=0.6.26,<0.6.27.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgettextpo >=0.25.1,<1.0a0 + - libglib >=2.88.3,<3.0a0 + - libheif >=1.23.1,<1.24.0a0 + - libhwy >=1.4.0,<1.5.0a0 + - libintl >=0.25.1,<1.0a0 + - libjpeg-turbo >=3.2.0,<4.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - libmatio >=1.5.30,<1.5.31.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libraw >=0.22.2,<0.23.0a0 + - librsvg >=2.62.3,<3.0a0 + - libtiff >=4.7.2,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openjpeg >=2.5.4,<3.0a0 + - openslide >=4.0.1,<5.0a0 + - pango >=1.58.0,<2.0a0 + - poppler >=26.7.0,<26.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - libvips >=8.18.5,<9.0a0 + size: 1561864 + timestamp: 1785787289656 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvorbis-1.3.7-h81086ad_2.conda + sha256: 95768e4eceaffb973081fd986d03da15d93aa10609ed202e6fd5ca1e490a3dce + md5: 719e7653178a09f5ca0aa05f349b41f7 + depends: + - libogg + - libcxx >=19 + - __osx >=11.0 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libvorbis >=1.3.7,<1.4.0a0 + size: 259122 + timestamp: 1753879389702 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvpx-1.15.2-ha759d40_0.conda + sha256: d21729b04fe101d1b2f8cdd607faacf1070abba3702db699787a3fe026eeaca6 + md5: 0d2febd301e25a48e00447b300d68f9c + depends: + - __osx >=11.0 + - libcxx >=19 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libvpx >=1.15.2,<1.16.0a0 + size: 1192913 + timestamp: 1762010603501 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-h3feff0a_0.conda + sha256: 0ce6db16d80c6e1992d25661bdcc0eac041e9f0a2e8b7f7ccd01fb667382f742 + md5: 0996a81de4f2e102521aed02fa6d95eb + depends: + - libcxx >=19 + - __osx >=11.0 + constrains: + - libvulkan-headers 1.4.357.0.* + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libvulkan-loader >=1.4.357.0,<2.0a0 + size: 185415 + timestamp: 1785311587495 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h202fb40_1.conda + sha256: 0ff54650d470c7e54cbeffdd53a8c063e055a7bcf784388c0385ce5c4741b0f4 + md5: 168a13e329259710b28277abc1395b8e + depends: + - __osx >=11.0 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libwebp-base >=1.6.0,<2.0a0 + size: 294522 + timestamp: 1785955350410 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hdb1d25a_0.conda + sha256: bd3816218924b1e43b275863e21a3e13a5db4a6da74cca8e60bc3c213eb62f71 + md5: af523aae2eca6dfa1c8eec693f5b9a79 + depends: + - __osx >=11.0 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libxcb >=1.17.0,<2.0a0 + size: 323658 + timestamp: 1727278733917 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + sha256: ff75b84cdb9e8d123db2fa694a8ac2c2059516b6cbc98ac21fb68e235d0fd354 + md5: 19edaa53885fc8205614b03da2482282 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 466360 + timestamp: 1776377102261 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + sha256: 2fe1d8de0854342ae9cabe408b476935f82f5636e153b3b497456264dc8ff3a1 + md5: 8e037d73747d6fe34e12d7bcac10cf21 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 h5ef1a60_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 41102 + timestamp: 1776377119495 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxmlb-0.3.29-h10573d7_0.conda + sha256: e75db4758af9e6aa29794c4e3b3034a21359b80deb3fd4c06c751123d42c646c + md5: d692edd5a675528e3e027de95d5a897d + depends: + - __osx >=11.0 + - libglib >=2.88.2,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libxmlb >=0.3.29,<0.4.0a0 + size: 131403 + timestamp: 1785182599572 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzip-1.11.2-h1336266_0.conda + sha256: 507599a77c1ce823c2d3acaefaae4ead0686f183f3980467a4c4b8ba209eff40 + md5: 7177414f275db66735a17d316b0a81d6 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.3.2,<4.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libzip >=1.11.2,<2.0a0 + size: 125507 + timestamp: 1730442214849 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 + md5: f39288f0ea63ae962e1a2e4f355a0d75 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + purls: [] + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47822 + timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda + sha256: ccbaad6bbc88f135ab849bc36af5fa6eda36a9ed18ce6f58e3dde3d11784c156 + md5: a9c118f6343fb6301b6f3b4e94c4c562 + depends: + - __osx >=11.0 + constrains: + - intel-openmp <0.0a0 + - openmp 22.1.8|22.1.8.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + purls: [] + run_exports: + strong: + - llvm-openmp >=22.1.8 + size: 286313 + timestamp: 1781736516782 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + sha256: 94d3e2a485dab8bdfdd4837880bde3dd0d701e2b97d6134b8806b7c8e69c8652 + md5: 01511afc6cc1909c5303cf31be17b44f + depends: + - __osx >=11.0 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - lz4-c >=1.10.0,<1.11.0a0 + size: 148824 + timestamp: 1733741047892 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda + sha256: db40fd25c6306bfda469f84cddd8b5ebb9aa08d509cecb49dfd0bb8228466d0c + md5: e56eaa1beab0e7fed559ae9c0264dd88 + depends: + - __osx >=11.0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - lzo >=2.10,<3.0a0 + size: 152755 + timestamp: 1753889267953 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/mpg123-1.33.7-hbb31fce_0.conda + sha256: f6a784f6cbfbc43d53c67d7bc43944b92bbeb2bc48c37d616a204982d461d46f + md5: 5da73e2ab0e0cf059aca721e4daf0270 + depends: + - __osx >=11.0 + - libcxx >=19 + license: LGPL-2.1-only + license_family: LGPL + purls: [] + run_exports: + weak: + - mpg123 >=1.33.7,<1.34.0a0 + size: 364688 + timestamp: 1786232686567 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + sha256: 7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68 + md5: 3dfa0d0316dc246cd44937a557de4501 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + purls: [] + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 804298 + timestamp: 1786355189145 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.6.0-h00e74ec_0.conda + sha256: 379ba26eb11cccb4d9a10989d9afef3f8bc4c9039cbe371dec3e3ee24594597b + md5: f0ba635c2293dff6fb86fc2150c8c110 + depends: + - libcxx >=19 + - __osx >=12.0 + - libnghttp2 >=1.68.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libuv >=1.52.1,<2.0a0 + - icu >=78.3,<79.0a0 + - openssl >=3.5.7,<4.0a0 + - libabseil >=20260526.0,<20260527.0a0 + - libabseil * cxx17* + - zstd >=1.5.7,<1.6.0a0 + - c-ares >=1.34.8,<2.0a0 + - libsqlite >=3.53.4,<4.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - nodejs >=26.6.0,<27.0a0 + size: 18228083 + timestamp: 1785852758781 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nspr-4.40-hdcbdcf5_0.conda + sha256: 429549e611c625d1f90714646b94aa62531be258b614414a54f6a402f8124c4a + md5: 7697df3a4af3427af9bc66dfaec228ae + depends: + - __osx >=11.0 + - libcxx >=19 + license: MPL-2.0 + purls: [] + run_exports: + weak: + - nspr >=4.40,<5.0a0 + size: 202842 + timestamp: 1786155286337 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nss-3.118-h1c710a3_0.conda + sha256: d57f7b2cf2860a2a848e3dd43cc4f5488e60050a7d62af1834da3ee43911d9c4 + md5: ae07409126ced4f0d982dfeab0681016 + depends: + - __osx >=11.0 + - libcxx >=19 + - libsqlite >=3.51.0,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - nspr >=4.38,<5.0a0 + license: MPL-2.0 + license_family: MOZILLA + purls: [] + run_exports: + weak: + - nss >=3.118,<4.0a0 + size: 1839904 + timestamp: 1763486575227 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.2-py314hb79c6fa_0.conda + sha256: e70532da227635af2338676036c4a6b6acf8863e4dc9fc2cc55d68bd74e2f1f5 + md5: d050d2d7aac4d90c0dccf2b5829e2a7a + depends: + - python + - __osx >=11.0 + - libcxx >=19 + - python_abi 3.14.* *_cp314 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + purls: + - pkg:pypi/numpy?source=hash-mapping + run_exports: + weak: + - numpy >=1.25,<3 + size: 7154942 + timestamp: 1786330664173 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.4.13-he09da85_2.conda + sha256: 5e6249b5034158c9007651b0dbfdbc0254d90e1c7967f6c15bdaf6ef0c5413ec + md5: a4ed37864e4051d409ec9d5cbe1c3d7b + depends: + - libcxx >=19 + - __osx >=11.0 + - libdeflate >=1.25,<1.26.0a0 + - libzlib >=1.3.2,<2.0a0 + - openjph >=0.31.0,<0.32.0a0 + - imath >=3.2.2,<3.2.3.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openexr >=3.4.13,<3.5.0a0 + size: 983153 + timestamp: 1785311291156 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openh264-2.6.0-hdf0efb5_1.conda + sha256: b7a43bf86db73d41e87e342e44df201bd08e9e1276508ec317ee603a32abdf8b + md5: 16691d628bbf06c067c5325f6b2edf1c + depends: + - __osx >=11.0 + - libcxx >=19 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openh264 >=2.6.0,<2.6.1.0a0 + size: 603670 + timestamp: 1782686332958 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hd9e9057_0.conda + sha256: 60aca8b9f94d06b852b296c276b3cf0efba5a6eb9f25feb8708570d3a74f00e4 + md5: 4b5d3a91320976eec71678fad1e3569b + depends: + - __osx >=11.0 + - libcxx >=19 + - libpng >=1.6.55,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openjpeg >=2.5.4,<3.0a0 + size: 319697 + timestamp: 1772625397692 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjph-0.31.0-h2a4d681_0.conda + sha256: 853c41f0e041a5c1acce60490ec065e4ca1b43d7b63fbad504980de3a822f719 + md5: 2afb97479668cfb83c386074f8050ad6 + depends: + - libcxx >=19 + - __osx >=11.0 + - libtiff >=4.7.2,<4.8.0a0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openjph >=0.31.0,<0.32.0a0 + size: 191450 + timestamp: 1785150188971 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openslide-4.0.1-h8cefcf1_0.conda + sha256: 378dc9ebb90ac0341a18da026aec55f60ddff1197ee8c324651dcc217fa05d91 + md5: 4782728efb38f82eb414ce99f92bef3d + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - libdicom >=1.3.0,<1.4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.1,<3.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openjpeg >=2.5.4,<3.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: LGPL-2.1-only + license_family: LGPL + purls: [] + run_exports: + weak: + - openslide >=4.0.1,<5.0a0 + size: 139198 + timestamp: 1781499912021 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + sha256: 66be2283b5b37dcda1332b5e74c1782a8cb14fd2e62e0d38017c2d35bf73c119 + md5: 65d1906712b85d1679263c518d011b5b + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3109132 + timestamp: 1785913735357 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.58.2-hf80efc4_0.conda + sha256: c300fba11c4cd7cdd7609b6f165981af24d2181d40280ca6af420710c4c7d42e + md5: 83c3d3d895dd96f87b0a1916e2c41f70 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.3,<3.0a0 + - libharfbuzz >=14.3.0 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - pango >=1.58.2,<2.0a0 + size: 444011 + timestamp: 1786108209790 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/patchelf-0.18.0-h965bd2d_1.conda + sha256: 1eb22ab6a99f8133ee84b4710a08e6242d49b38baa08a45d9af1143e8d168bc2 + md5: 89831e1334149fad1a2a9f533bc9aa29 + depends: + - __osx >=10.9 + - libcxx >=16.0.6 + license: GPL-3.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 107784 + timestamp: 1698345706579 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + sha256: 5e2e443f796f2fd92adf7978286a525fb768c34e12b1ee9ded4000a41b2894ba + md5: 9b4190c4055435ca3502070186eba53a + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 850231 + timestamp: 1763655726735 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h784d473_3.conda + sha256: 4779cd57231ce2e96fac643fcbdd4a6f51a57986264545445574e9c4acf526d3 + md5: 9a99c0b60efe41c194d01c182d000733 + depends: + - __osx >=11.0 + - libcxx >=19 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - pixman >=0.46.4,<1.0a0 + size: 198717 + timestamp: 1786106922508 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hdc6e874_1011.conda + sha256: 92c51e21a2c37382df8e15d817692b3e4cf8b810ba14de2d4adc42be58e8d50b + md5: f2e323830f7c2ed5e94569dc88ce22f9 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + license: GPL-2.0-or-later + purls: [] + run_exports: {} + size: 274286 + timestamp: 1786352332199 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/poppler-26.07.0-h4cfec15_3.conda + sha256: c8784f244cc6cd33bf73b056ef04a3bc44be1d1257da4cf63381ffa358741fcf + md5: 0a477ba01d6418f51a114f63cb64a9b5 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - lcms2 >=2.19.1,<3.0a0 + - libcurl >=8.21.0,<9.0a0 + - libcxx >=19 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.2,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libintl >=0.25.1,<1.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libtiff >=4.7.2,<4.8.0a0 + - libzlib >=1.3.2,<2.0a0 + - nspr >=4.38,<5.0a0 + - nss >=3.118,<4.0a0 + - openjpeg >=2.5.4,<3.0a0 + - poppler-data + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - poppler >=26.7.0,<26.8.0a0 + size: 1626453 + timestamp: 1783345166234 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-h84a0fba_1003.conda + sha256: 1c2338b9b0486af86883b9593d2f3e2845bbf216ea453dfe5d9a228e8dbe4057 + md5: d4852e6054b74645cf49ca10f6349191 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 9238 + timestamp: 1786068031100 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pugixml-1.15-hd3d436d_0.conda + sha256: 5ad8d036040b095f85d23c70624d3e5e1e4c00bc5cea97831542f2dcae294ec9 + md5: b9a4004e46de7aeb005304a13b35cb94 + depends: + - __osx >=11.0 + - libcxx >=18 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - pugixml >=1.15,<1.16.0a0 + size: 91283 + timestamp: 1736601509593 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.0-py314hde3b82e_1.conda + sha256: 97da1779bd2f4021a5ab9e0daec3e1d3e90177103405a689cdd62d1f31f95475 + md5: e34d788d99be9c605517f50548c40f04 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: LGPL-2.1-only OR MPL-1.1 + purls: + - pkg:pypi/pycairo?source=hash-mapping + run_exports: {} + size: 106588 + timestamp: 1770727058819 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.56.3-py314h089b223_0.conda + sha256: 2a1817e408c5a27b43499b1d83dd26e717df4640f9a17374d0947f80c32c2787 + md5: 5d9516cff41a7029110bffb803f33087 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - libexpat >=2.8.0,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgirepository + - libglib >=2.88.1,<3.0a0 + - libiconv + - libzlib >=1.3.2,<2.0a0 + - pycairo + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: LGPL-2.1-or-later + license_family: LGPL + purls: + - pkg:pypi/pygobject?source=hash-mapping + run_exports: {} + size: 323828 + timestamp: 1778343749249 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py314hdcf55e8_2.conda + sha256: 45d3717a16d8229111eed48a071556eae5d84dc84030c750065118bb6e7f0baf + md5: 686ad905ac298f665a30af1bec16b973 + depends: + - __osx >=11.0 + - numpy >=1.23,<3 + - pyopengl 3.1.10 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: LicenseRef-pyopengl + purls: + - pkg:pypi/pyopengl-accelerate?source=hash-mapping + run_exports: {} + size: 271428 + timestamp: 1764025036742 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + build_number: 101 + sha256: fc70ae73df7798bce7cac7adef7fdfb874208b2623a0e8ccb4354194b8508769 + md5: 6e9670f5238dfb27ef4f6364ed536cc0 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.3,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + purls: [] + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 14035244 + timestamp: 1784909523029 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rav1e-0.8.1-h8246384_0.conda + sha256: 925e35b71fe513e0380ecd2fe137e3f4f248bf7ce4bad96946c7c704b7a50d26 + md5: 4706a8a71474c692482c3f86c2175454 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - rav1e >=0.8.1,<0.9.0a0 + size: 886953 + timestamp: 1772541394570 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.97.1-h4ff7c5d_0.conda + sha256: 6f62b62d9dce1c639f22f84a10d5c23001d320894812d2b6855c2f19ef902e76 + md5: 73f61370e11ffc7b7da19066a155cc61 + depends: + - rust-std-aarch64-apple-darwin 1.97.1 hf6ec828_0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 179760662 + timestamp: 1784278770645 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.0-py314h18e1515_0.conda + sha256: 7ce218a4e1c55775547835d21a7ead0d50e5ac348fd638ce6ba316c48c8547b7 + md5: e55fe08bb5d43e7120672338dd129030 + depends: + - __osx >=11.0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.7 + - numpy >=1.23,<3 + - numpy >=2.0.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + run_exports: {} + size: 14122215 + timestamp: 1781912992503 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl2-2.32.56-h784d473_0.conda + sha256: 595db3f62eec1b86aad03ad8c7e4943e78a18e112228c91adf3f3ada4e959a5c + md5: 81a4c982e9ac52e620eb810f463de9ad + depends: + - __osx >=11.0 + - libcxx >=19 + - sdl3 >=3.4.12,<4.0a0 + license: Zlib + purls: [] + run_exports: + weak: + - sdl2 >=2.32.56,<3.0a0 + size: 543557 + timestamp: 1783451926011 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl3-3.4.14-h6fa9c73_0.conda + sha256: d8b6805b8b1011afcad6c857ddd9fd38335f32dcf369152a8ec0615518f6331a + md5: 1e0f2089d4efd60b4407466b0f4cf81a + depends: + - __osx >=11.0 + - libcxx >=19 + - libusb >=1.0.29,<2.0a0 + - libvulkan-loader >=1.4.357.0,<2.0a0 + - dbus >=1.16.2,<2.0a0 + license: Zlib + purls: [] + run_exports: + weak: + - sdl3 >=3.4.14,<4.0a0 + size: 1569006 + timestamp: 1785816152396 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/shaderc-2026.3-h565cd3f_0.conda + sha256: 124051bbe2f7daa875f1612363a7b17f5aa8dbbd61ef18b9de64eef2733cd6ed + md5: dcba860d2b44c4bd7101e5d6cdef7639 + depends: + - __osx >=11.0 + - glslang >=16,<17.0a0 + - libcxx >=19 + - spirv-tools >=2026,<2027.0a0 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - shaderc >=2026.3,<2026.4.0a0 + size: 112153 + timestamp: 1784251566375 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + sha256: cb9305ede19584115f43baecdf09a3866bfcd5bcca0d9e527bd76d9a1dbe2d8d + md5: fca4a2222994acd7f691e57f94b750c5 + depends: + - libcxx >=19 + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - snappy >=1.2.2,<1.3.0a0 + size: 38883 + timestamp: 1762948066818 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.2-h4ddebb9_3.conda + sha256: 9c9c36f393ddd992ba969cc364b8eb230bc93cb530a9f8f350875329dfccd381 + md5: 88dc80dd3bc3cde960d0164d155b594d + depends: + - __osx >=11.0 + - libcxx >=19 + constrains: + - spirv-headers >=1.4.357.0,<1.4.357.1.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - spirv-tools >=2026,<2027.0a0 + size: 1653571 + timestamp: 1785689554684 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h0cb729a_0.conda + sha256: 182f692ddbcab92bf0992dcf853e8f82d626bcccf0f0dcf7aac995924b7fe796 + md5: 7d697f995ff16231780ae534ea0d5266 + depends: + - __osx >=11.0 + - libcxx >=19 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - svt-av1 >=4.2.0,<4.2.1.0a0 + size: 1478231 + timestamp: 1784070471084 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tbb-2023.0.0-he0260a5_2.conda + sha256: 6f72a2984052444b9381020fa329b83dace95f335573dc21199f1b1d1a5f5473 + md5: 440c0a36cc20db1f28877a69afbb5e88 + depends: + - __osx >=11.0 + - libcxx >=19 + - libhwloc >=2.13.0,<2.13.1.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 122303 + timestamp: 1778675142610 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + sha256: 47186bc7ab8d7e8bee86bbd1a917196f8c21cf63f081fc33cd6d1221af087580 + md5: 8e3cf0e455e6b54519f0b1c72c61780a + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: TCL + purls: [] + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3338712 + timestamp: 1784229090530 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/x264-1!164.3095-h57fd34a_2.tar.bz2 + sha256: debdf60bbcfa6a60201b12a1d53f36736821db281a28223a09e0685edcce105a + md5: b1f6dccde5d3a1f911960b6e567113ff + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - x264 >=1!164.3095,<1!165 + size: 717038 + timestamp: 1660323292329 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-hbc6ce65_3.tar.bz2 + sha256: 2fed6987dba7dee07bd9adc1a6f8e6c699efb851431bcb6ebad7de196e87841d + md5: b1f7f2780feffe310b068c021e8ff9b2 + depends: + - libcxx >=12.0.1 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - x265 >=3.5,<3.6.0a0 + size: 1832744 + timestamp: 1646609481185 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libice-1.1.2-h5505292_0.conda + sha256: 0e68b75a51901294ab21c031dcc1e485a65770a4893f98943b0908c4217b14e1 + md5: daf3b34253eea046c9ab94e0c3b2f83d + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libice >=1.1.2,<2.0a0 + size: 48418 + timestamp: 1734227712919 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libsm-1.2.6-h5505292_0.conda + sha256: 9bd3cb47ad7bb6c2d0b3b39d76c0e0a7b1d39fc76524fe76a7ff014073467bf5 + md5: a01171a0aee17fc4e74a50971a87755d + depends: + - __osx >=11.0 + - xorg-libice >=1.1.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libsm >=1.2.6,<2.0a0 + size: 24419 + timestamp: 1741896544082 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libx11-1.8.13-hf948f5a_0.conda + sha256: e8889281828ee19afb2fdd7b5d7a4b7c2e013b2ae38af4529040de542f92b065 + md5: 85b1ce864f9a18468db4c583c7778c7d + depends: + - __osx >=11.0 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libx11 >=1.8.13,<2.0a0 + size: 756862 + timestamp: 1770819743113 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda + sha256: adae11db0f66f86156569415ed79cda75b2dbf4bea48d1577831db701438164f + md5: 78b548eed8227a689f93775d5d23ae09 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxau >=1.0.12,<2.0a0 + size: 14105 + timestamp: 1762976976084 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda + sha256: f7fa0de519d8da589995a1fe78ef74556bb8bc4172079ae3a8d20c3c81354906 + md5: 9d1299ace1924aa8f4e0bc8e71dd0cf7 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxdmcp >=1.1.5,<2.0a0 + size: 19156 + timestamp: 1762977035194 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxext-1.3.7-h84a0fba_0.conda + sha256: 18bbf20b4da142b368e1ae8c2124a3fd7148e2003480ad8b1acdcaa3e6454b07 + md5: 72851739795cdef9bb7124114c630df9 + depends: + - __osx >=11.0 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxext >=1.3.7,<2.0a0 + size: 42748 + timestamp: 1769445838425 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxrender-0.9.12-h5505292_0.conda + sha256: 1c4a8a229e847604045de1f2af032104cab0f0e93b57f0cc553478f8a21f970a + md5: 01690f6107fc7487529242d29bf2abe8 + depends: + - __osx >=11.0 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxrender >=0.9.12,<0.10.0a0 + size: 28434 + timestamp: 1734229187899 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-h5505292_0.conda + sha256: c32235891d65e49e97babe649c45ec2e40a148b4e6ca4cae4ed84811238e0aae + md5: a5c47d582f31083353559dc9aff907c3 + depends: + - __osx >=11.0 + - xorg-libice >=1.1.1,<2.0a0 + - xorg-libsm >=1.2.4,<2.0a0 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - xorg-libxt >=1.3.1,<2.0a0 + size: 185960 + timestamp: 1731860774152 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda + sha256: ab46d85e4fcffff1b1c5cac517afa40b7ae784cf76fc9da1f55f6a6934291eb6 + md5: 2c966485853aa27985fbec7e3fcc4e77 + depends: + - __osx >=11.0 + - libzlib 1.3.2 h8088a28_3 + license: Zlib + license_family: Other + purls: [] + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 81744 + timestamp: 1785277062753 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 433413 + timestamp: 1764777166076 +- pypi: ./ + name: rayforge + requires_dist: + - raygeo==1.38.3 + - aiohttp==3.14.3 + - asyncudp==0.11.0 + - blinker==1.9.0 + - ezdxf==1.4.4 + - gitpython==3.1.59 + - numpy==2.5.2 + - opencv-python + - platformdirs==4.11.1 + - pluggy==1.6.0 + - pycairo==1.29.0 + - pygobject==3.56.3 + - pymupdf==1.28.2 + - pyopengl==3.1.10 + - pyopengl-accelerate==3.1.10 + - pypdf==6.15.0 + - pyserial==3.5 + - pyvips==3.1.1 + - pyyaml==6.0.3 + - scipy==1.18.0 + - semver==3.0.4 + - svgelements==1.9.6 + - trimesh==5.0.0 + - vtracer==0.6.15 + - websockets==17.0.1 + - pytest ; extra == 'test' + - pytest-asyncio ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-cov ; extra == 'test' + - pygobject-stubs ; extra == 'test' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + name: distlib + version: 0.4.3 + sha256: 4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b +- 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 + sha256: 933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 + requires_dist: + - backports-asyncio-runner>=1.1,<2 ; python_full_version < '3.11' + - pytest>=8.4,<10 + - typing-extensions>=4.12 ; python_full_version < '3.13' + - sphinx>=5.3 ; extra == 'docs' + - sphinx-rtd-theme>=1 ; extra == 'docs' + - sphinx-tabs>=3.5 ; extra == 'docs' + - coverage>=6.2 ; extra == 'testing' + - hypothesis>=5.7.1 ; extra == 'testing' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl + name: defusedxml + version: 0.7.1 + sha256: a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' +- pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl + name: pyserial + version: '3.5' + sha256: c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 + requires_dist: + - hidapi ; extra == 'cp2110' +- pypi: https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl + name: pyright + version: 1.1.411 + sha256: dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9 + requires_dist: + - nodeenv>=1.6.0 + - typing-extensions>=4.1 + - twine>=3.4.1 ; extra == 'dev' + - nodejs-wheel-binaries ; extra == 'nodejs' + - twine>=3.4.1 ; extra == 'all' + - nodejs-wheel-binaries ; extra == 'all' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl + name: coverage + version: 7.15.4 + sha256: d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- 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/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl + name: build + version: 1.5.0 + sha256: 13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f + requires_dist: + - packaging>=24.0 + - pyproject-hooks + - colorama ; os_name == 'nt' + - importlib-metadata>=4.6 ; python_full_version < '3.10.2' + - tomli>=1.1.0 ; python_full_version < '3.11' + - keyring ; extra == 'keyring' + - uv>=0.1.18 ; extra == 'uv' + - virtualenv>=20.17 ; python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'virtualenv' + - virtualenv>=20.31 ; python_full_version >= '3.14' and extra == 'virtualenv' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl + name: pyparsing + version: 3.3.2 + sha256: 850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d + requires_dist: + - railroad-diagrams ; extra == 'diagrams' + - jinja2 ; extra == 'diagrams' + requires_python: '>=3.9' +- 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/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/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl + name: yarl + version: 1.24.5 + sha256: e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: msgpack + version: 1.2.1 + sha256: 0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl + name: idna + version: '3.18' + sha256: 7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 + requires_dist: + - ruff>=0.6.2 ; extra == 'all' + - mypy>=1.11.2 ; extra == 'all' + - pytest>=8.3.2 ; extra == 'all' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl + name: cssselect2 + version: 0.9.0 + sha256: 6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 + requires_dist: + - tinycss2 + - webencodings + - sphinx ; extra == 'doc' + - furo ; extra == 'doc' + - pytest ; extra == 'test' + - ruff ; extra == 'test' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl + name: mccabe + version: 0.7.0 + sha256: 6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/27/4a/6cd81533ab277ae6c73256fb0b6eab2b3e03d5c6e05416763fbf3c921208/raygeo-1.38.3.tar.gz + name: raygeo + version: 1.38.3 + sha256: 1c8b593d10fd41e955593001ae33646cf94cbc5e922b28f4c2e480aff4b469c1 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl + name: fonttools + version: 4.63.0 + sha256: fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745 + 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>=17.0.0 ; python_full_version < '3.15' 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>=17.0.0 ; python_full_version < '3.15' 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.10' +- pypi: https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: propcache + version: 0.5.2 + sha256: e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2d/6a/282936de9faac6addf6bc8792c18e006489d0023ffd8856b8643f54d0558/pyvips-3.1.1.tar.gz + name: pyvips + version: 3.1.1 + sha256: 84fe744d023b1084ac2516bb17064cacd41c7f8aabf8e524dd383534941b9301 + requires_dist: + - cffi>=1.0.0 + - pyvips-binary ; extra == 'binary' + - tox ; extra == 'tox' + - pytest ; extra == 'test' + - pyperf ; extra == 'test' + - build ; extra == 'sdist' + - sphinx ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl + name: websockets + version: 17.0.1 + sha256: cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl + name: ruff + version: 0.16.2 + sha256: a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: fonttools + version: 4.63.0 + sha256: 308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b + 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>=17.0.0 ; python_full_version < '3.15' 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>=17.0.0 ; python_full_version < '3.15' 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.10' +- pypi: https://files.pythonhosted.org/packages/37/82/19a03ba344ecb66ea8caab697b3059e0fbea576420f99945944c479caa78/trimesh-5.0.0-py3-none-any.whl + name: trimesh + version: 5.0.0 + sha256: 51ec67d7f9f74b918f2a695da5fd511b9c084e96307648ae83f45b58f13f8009 + requires_dist: + - numpy>=1.21 + - colorlog ; extra == 'easy' + - manifold3d>=2.3.0 ; extra == 'easy' + - charset-normalizer ; extra == 'easy' + - lxml ; extra == 'easy' + - jsonschema ; extra == 'easy' + - networkx ; extra == 'easy' + - svg-path ; extra == 'easy' + - pycollada ; extra == 'easy' + - shapely ; extra == 'easy' + - xxhash ; extra == 'easy' + - rtree ; extra == 'easy' + - httpx ; extra == 'easy' + - scipy ; extra == 'easy' + - embreex ; platform_machine != 'aarch64' and extra == 'easy' + - pillow ; extra == 'easy' + - vhacdx ; extra == 'easy' + - mapbox-earcut>=1.0.2 ; extra == 'easy' + - sympy ; extra == 'recommend' + - pyglet<2 ; extra == 'recommend' + - scikit-image ; extra == 'recommend' + - fast-simplification ; extra == 'recommend' + - python-fcl ; extra == 'recommend' + - cascadio ; extra == 'recommend' + - pytest-cov ; extra == 'test' + - pytest ; extra == 'test' + - pyinstrument ; extra == 'test' + - ruff ; extra == 'test' + - ezdxf ; extra == 'test-more' + - meshio ; extra == 'test-more' + - xatlas ; extra == 'test-more' + - pytest-beartype ; extra == 'test-more' + - matplotlib ; extra == 'test-more' + - pymeshlab ; python_full_version < '3.14' and extra == 'test-more' + - triangle ; python_full_version < '3.14' and extra == 'test-more' + - ipython ; extra == 'test-more' + - marimo ; extra == 'test-more' + - requests ; extra == 'test-more' + - aiohttp ; extra == 'test-more' + - trimesh[deprecated,easy,recommend,test,test-more] ; extra == 'all' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl + name: platformdirs + version: 4.11.1 + sha256: 2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/4d/21/74ec0b790c1b2c1e7c28f1ebea5684a5bb568a0ee4350892351f2617ba1f/raygeo-1.38.3-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.38.3 + sha256: dc3639e297c99a425a91550199cab92128bade06c8f8cf4003c33bf6b26dc06f + 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/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl + name: pytest-mock + version: 3.15.1 + sha256: 0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d + requires_dist: + - pytest>=6.2.5 + - pre-commit ; extra == 'dev' + - pytest-asyncio ; extra == 'dev' + - tox ; extra == 'dev' + requires_python: '>=3.9' +- 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/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl + name: tinycss2 + version: 1.5.1 + sha256: 3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 + requires_dist: + - webencodings>=0.4 + - sphinx ; extra == 'doc' + - furo ; extra == 'doc' + - pytest ; extra == 'test' + - ruff ; extra == 'test' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl + name: msgpack + version: 1.2.1 + sha256: 810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/62/47/9af590d5319433f976baaa9ae1bdd309a68ea5201403246a48d2ba827e39/vtracer-0.6.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: vtracer + version: 0.6.15 + sha256: e254fd9a9780d684d17772dc9c243a8739ab15c2f55455d4b96f2db7987b86e8 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl + name: propcache + version: 0.5.2 + sha256: 97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + name: attrs + version: 26.1.0 + sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + name: python-discovery + version: 1.5.1 + sha256: ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932 + requires_dist: + - filelock>=3.15.4 + requires_python: '>=3.8' +- 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/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl + name: virtualenv + version: 21.7.3 + sha256: 26dfda3c34f29bf1a3ca167426a67658d59979b9954e705aef60a5f724ce1773 + requires_dist: + - distlib>=0.3.7,<1 + - filelock>=3.24.2,<4 ; python_full_version >= '3.10' + - filelock>=3.16.1,<=3.19.1 ; python_full_version < '3.10' + - platformdirs>=3.9.1,<5 + - python-discovery>=1.4.2 + - typing-extensions>=4.13.2 ; python_full_version < '3.11' + requires_python: '>=3.9' +- 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/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl + name: nodeenv + version: 1.10.0 + sha256: 5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*' +- pypi: https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: pyyaml + version: 6.0.3 + sha256: c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 + 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 + sha256: 66884d26974dd7fb99a8bc5972b5bdeb4b7399ecc7ac53913a6cafa6ab21491f + requires_dist: + - pygobject>=3.55.0 + - typing-extensions + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl + name: cairocffi + version: 1.7.1 + sha256: 9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f + requires_dist: + - cffi>=1.1.0 + - sphinx ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - pytest ; extra == 'test' + - ruff ; extra == 'test' + - numpy ; extra == 'test' + - pikepdf ; extra == 'test' + - xcffib>=1.4.0 ; extra == 'xcb' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + name: identify + version: 2.6.19 + sha256: 20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a + requires_dist: + - ukkonen ; extra == 'license' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: yarl + version: 1.24.5 + sha256: 66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl + name: opencv-python + version: 5.0.0.93 + sha256: 198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898 + requires_dist: + - 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/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + name: pytest-cov + version: 7.1.0 + sha256: a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + requires_dist: + - coverage[toml]>=7.10.6 + - pluggy>=1.2 + - pytest>=7 + - process-tests ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - virtualenv ; extra == 'testing' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl + name: flake8 + version: 7.3.0 + sha256: b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e + requires_dist: + - mccabe>=0.7.0,<0.8.0 + - pycodestyle>=2.14.0,<2.15.0 + - pyflakes>=3.4.0,<3.5.0 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl + name: ezdxf + version: 1.4.4 + sha256: 666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865 + 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/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + name: gitdb + version: 4.0.12 + sha256: 67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf + requires_dist: + - smmap>=3.0.1,<6 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: coverage + version: 7.15.4 + sha256: ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + 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/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl + name: semver + version: 3.0.4 + sha256: 9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: frozenlist + version: 1.8.0 + sha256: cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl + name: pypdf + version: 6.15.0 + sha256: 14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee + requires_dist: + - typing-extensions>=4.0 ; python_full_version < '3.11' + - cryptography>3.0 ; extra == 'crypto' + - pycryptodome ; extra == 'cryptodome' + - flit ; extra == 'dev' + - pip-tools ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-socket ; extra == 'dev' + - pytest-timeout ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - wheel ; extra == 'dev' + - myst-parser ; extra == 'docs' + - sphinx ; extra == 'docs' + - sphinx-rtd-theme ; extra == 'docs' + - fonttools ; extra == 'fonts' + - arabic-reshaper ; extra == 'full' + - cryptography>3.0 ; extra == 'full' + - fonttools ; extra == 'full' + - pillow>=8.0.0 ; extra == 'full' + - python-bidi ; extra == 'full' + - pillow>=8.0.0 ; extra == 'image' + - arabic-reshaper ; extra == 'rtl-text' + - python-bidi ; extra == 'rtl-text' + requires_python: '>=3.9' +- 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/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl + name: pyproject-hooks + version: 1.2.0 + sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 + requires_python: '>=3.7' +- 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/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl + name: cairosvg + version: 2.9.0 + sha256: 4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68 + requires_dist: + - cairocffi + - cssselect2 + - defusedxml + - pillow + - tinycss2 + - sphinx ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - pytest ; extra == 'test' + - flake8 ; extra == 'test' + - isort ; extra == 'test' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl + name: smmap + version: 5.0.3 + sha256: c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl + name: filelock + version: 3.32.2 + sha256: 87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl + name: pyflakes + version: 3.4.0 + sha256: f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.16.2 + sha256: ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f + requires_python: '>=3.7' +- 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 + 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/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/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 + sha256: c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039 + requires_dist: + - 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/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: aiohttp + version: 3.14.3 + sha256: 18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 + 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/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl + name: pycodestyle + version: 2.14.0 + sha256: dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl + name: cfgv + version: 3.5.0 + sha256: a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: cffi + version: 2.1.1 + sha256: b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 + requires_dist: + - pycparser ; implementation_name != 'PyPy' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + name: gitpython + version: 3.1.59 + sha256: 67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c + requires_dist: + - gitdb>=4.0.1,<5 + - typing-extensions>=3.10.0.2 ; python_full_version < '3.10' + - coverage[toml] ; extra == 'test' + - basedpyright==1.39.9 ; python_full_version >= '3.9' and sys_platform != 'cygwin' and extra == 'test' + - ddt>=1.1.1,!=1.4.3 ; extra == 'test' + - mock ; python_full_version < '3.8' and extra == 'test' + - mypy==1.18.2 ; python_full_version >= '3.9' and extra == 'test' + - pre-commit ; extra == 'test' + - pytest>=7.3.1 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-instafail ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-sugar ; extra == 'test' + - typing-extensions ; python_full_version < '3.11' and extra == 'test' + - sphinx>=7.4.7,<8 ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - sphinx-autodoc-typehints ; extra == 'doc' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl + name: multidict + version: 6.7.1 + sha256: 0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 + requires_dist: + - typing-extensions>=4.1.0 ; python_full_version < '3.11' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl + name: webencodings + version: 0.5.1 + sha256: a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 +- pypi: https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl + name: pymupdf + version: 1.28.2 + sha256: 7113846b35dbf0a033f088e4f4fb543dabeb4b0b12c112966a1ca1ee2d5eacae + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl + name: pre-commit + version: 4.6.1 + sha256: 0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717 + requires_dist: + - cfgv>=2.0.0 + - identify>=1.0.0 + - nodeenv>=0.11.1 + - pyyaml>=5.1 + - virtualenv>=20.10.0 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl + name: aiosignal + version: 1.4.0 + sha256: 053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e + requires_dist: + - frozenlist>=1.1.0 + - typing-extensions>=4.2 ; python_full_version < '3.13' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: multidict + version: 6.7.1 + sha256: 7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 + requires_dist: + - typing-extensions>=4.1.0 ; python_full_version < '3.11' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: websockets + version: 17.0.1 + sha256: 72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c + requires_python: '>=3.11' diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 000000000..3f7403939 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,196 @@ +[workspace] +name = "rayforge" +version = "0.1.0" +channels = ["conda-forge", "bioconda", "msys2", "coastline"] +platforms = ["linux-64", "osx-arm64"] + +[environments] +default = { features = ["app", "test"], solve-group = "default" } +website = { features = ["website"], solve-group = "default" } +build = { features = ["build-tools"], solve-group = "build" } +video = { features = ["video-tools"], solve-group = "video" } + +[feature.app.dependencies] +adwaita-icon-theme = "==49.0" +glib = "==2.88.3" +cairo = "==1.18.4" +expat = "==2.8.1" +gettext = "==0.25.1" +gobject-introspection = "==1.86.0" +gtk4 = "==4.22.4" +libheif = "==1.23.1" +libvips = "==8.18.5" +openslide = "==4.0.1" +poppler = "==26.7.0" +python = "==3.14.6" +librsvg = "==2.62.3" +svgelements = "==1.9.6" +pluggy = "==1.6.0" +scipy = "==1.18.0" +numpy = "==2.5.2" +pycairo = "==1.29.0" +PyOpenGL = "==3.1.10" +PyOpenGL-accelerate = "==3.1.10" +PyGObject = "==3.56.3" +pkg-config = "==0.29.2" +rust = "==1.97.1" +patchelf = "==0.18.0|==0.17.2" + +[feature.app.pypi-dependencies] +raygeo = "==1.38.3" +rayforge = { editable = true, path = "." } +aiohttp = "==3.14.3" +asyncudp = "==0.11.0" +blinker = "==1.9.0" +build = "==1.5.0" +cairosvg = "==2.9.0" +ezdxf = "==1.4.4" +GitPython = "==3.1.59" +platformdirs = "==4.11.1" +pypdf = "==6.15.0" +pymupdf = "==1.28.2" +pyserial = "==3.5" +pyvips = "==3.1.1" +PyYAML = "==6.0.3" +semver = "==3.0.4" +trimesh = "==5.0.0" +vtracer = "==0.6.15" +websockets = "==17.0.1" + +[feature.app.target.linux-64.dependencies] +xorg-xproto = "==7.0.31" +xorg-libx11 = "==1.8.13" +xorg-kbproto = "==1.0.7" +xorg-xextproto = "==7.3.0" +xorg-xineramaproto = "==1.2.1" +xorg-xf86vidmodeproto = "==2.3.1" +xorg-renderproto = "==0.11.1" +xorg-inputproto = "==2.3.2" +xorg-compositeproto = "==0.4.2" +xorg-damageproto = "==1.2.1" +xorg-glproto = "==1.4.17" +xorg-presentproto = "==1.1" +xorg-libxext = "==1.3.7" +xorg-libxinerama = "==1.1.6" +xorg-libxrandr = "==1.5.5" + +[feature.build-tools.dependencies] +gettext = "==0.25.1" + +[feature.test.dependencies] +pytest = "==9.1.1" + +[feature.test.pypi-dependencies] +pytest-asyncio = "==1.4.0" +pytest-mock = "==3.15.1" +pytest-cov = "==7.1.0" +pygobject-stubs = "==2.17.0" +flake8 = "==7.3.0" +pyflakes = "==3.4.0" +pyright = "==1.1.411" +ruff = "==0.16.2" +pre-commit = "==4.6.1" + +[feature.website.dependencies] +nodejs = "==26.6.0" +[feature.video-tools.dependencies] +ffmpeg = "==9.0.0" +python = "==3.14.6" + +[feature.video-tools.pypi-dependencies] +pillow = "==12.3.0" + +[tool.pixi] +workdir = "." + +[tasks] +pre-commit-install = "pre-commit install" +lint = { depends-on = ["ruff", "flake", "pyflakes", "pyright"] } +update-translations = "bash scripts/update_translations.sh" +compile-translations = "bash scripts/update_translations.sh --compile-only" +print-untranslated = "bash scripts/print_untranslated.sh" +update-supporters = "python3 scripts/media/update_supporters.py" +fetch-stats = "python3 scripts/fetch_download_stats.py" +normalize-icons = "python3 scripts/normalize_icons.py" +gen-affiliate = "python3 scripts/generate_affiliate_link.py" + +[tasks.clean] +cmd = "bash scripts/clean.sh" + +[tasks.format] +cmd = "ruff format rayforge tests scripts && ruff check --select I --fix rayforge tests scripts" + +[tasks.flake] +depends-on = ["format"] +cmd = "flake8 --ignore=E127,E128,E121,E123,E126,E203,E226,E24,E704,W503,W504 --builtins=_ rayforge tests" + +[tasks.ruff] +depends-on = ["format"] +cmd = "ruff check rayforge tests scripts" + +[tasks.pyflakes] +depends-on = ["format"] +env = { PYFLAKES_BUILTINS = "_" } +cmd = "pyflakes rayforge tests" + +[tasks.pyright] +depends-on = ["format"] +env = { VIRTUAL_ENV = ".pixi/envs/default" } +cmd = "pyright" + +[tasks.rayforge] +env = { GI_TYPELIB_PATH = "$(pkgconf --variable=typelibdir gobject-introspection-1.0)"} +cmd = "scripts/with_gdk.sh python -m rayforge" + +[tasks.testapp] +env = { GI_TYPELIB_PATH = "$(pkgconf --variable=typelibdir gobject-introspection-1.0):/usr/lib/x86_64-linux-gnu/girepository-1.0"} +cmd = "scripts/with_gdk.sh" + +[tasks.test] +env = { GI_TYPELIB_PATH = "$(pkgconf --variable=typelibdir gobject-introspection-1.0)"} +cmd = "pytest -v --ignore=tests/ui_gtk" + +[tasks.uitest] +env = { GI_TYPELIB_PATH = "$(pkgconf --variable=typelibdir gobject-introspection-1.0):/usr/lib/x86_64-linux-gnu/girepository-1.0"} +cmd = "pytest -v -m ui" + +[tasks.screenshot] +env = { GI_TYPELIB_PATH = "$(pkgconf --variable=typelibdir gobject-introspection-1.0)"} +cmd = "scripts/with_gdk.sh scripts/screenshot/cli.py" + +[tasks.wheel] +cmd = "python3 -m build" +depends-on = ["compile-translations"] + +[tasks.build-deb] +# Builds a binary .deb package for local installation and testing. +cmd = "bash scripts/build-deb.sh" +depends-on = ["compile-translations"] + +[tasks.build-deb-source] +# Builds a source package, ready for upload to a PPA. +cmd = "bash scripts/build-deb.sh --source" +depends-on = ["compile-translations"] + +[dependencies] +libadwaita = "==1.9.3" + +[target.linux-64.dependencies] +libglvnd = "==1.7.0" + +# Tasks for developing and generating the website +[feature.website.tasks] +site-gen-api = "python3 scripts/update_api_docs.py" +site-install = { cmd = "npm install", cwd = "website" } +site-serve = { cmd = "npm start", cwd = "website", depends-on = ["update-api-docs"] } +site-build = { cmd = "npm run build", cwd = "website", depends-on = ["site-gen-api"] } +site-deploy = { cmd = "bash scripts/deploy_website.sh", depends-on = ["site-gen-api"] } + +[tasks.update-api-docs] +cmd = "python3 scripts/update_api_docs.py" + +# Tasks for video processing +[feature.video-tools.tasks] +process-audio = "python3 scripts/media/process_audio.py" +generate-blender-setup = "bash scripts/media/run_blender_setup.sh" +generate-thumbnail = "python3 scripts/media/generate_thumbnail.py" diff --git a/pyproject.toml b/pyproject.toml index dd05c420d..d400a5249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,15 @@ [project] name = "rayforge" -dynamic = ["version"] +dynamic = ["version", "dependencies"] authors = [ { name="Samuel Abels", email="knipknap@gmail.com" }, ] description = "A desktop application for laser cutting and engraving" readme = "README.md" -requires-python = ">=3.9" +license = {text = "MIT"} +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Intended Audience :: End Users/Desktop", "Intended Audience :: Manufacturing", @@ -19,12 +19,12 @@ classifiers = [ "Homepage" = "https://github.com/barebaric/rayforge" "Bug Tracker" = "https://github.com/barebaric/rayforge/issues" -[project.scripts] +[project.gui-scripts] rayforge = "rayforge.app:main" [build-system] requires = [ - "setuptools >= 40.9.0", "setuptools-git-versioning", "pytest" + "setuptools >= 40.9.0", "setuptools-git-versioning" ] build-backend = "setuptools.build_meta" @@ -37,7 +37,76 @@ include = ["rayforge*"] [tool.setuptools-git-versioning] enabled = true +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + +[tool.ruff] +builtins = ["_"] +line-length = 79 + +[tool.ruff.lint] +extend-select = ["E402"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] +"rayforge/builtin_addons/*/tests/**" = ["S101"] +"rayforge/private_addons/*/tests/**" = ["S101"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pyright] +stubPath = "typings" +reportMissingModuleSource = false +include = [ + "rayforge", + "tests/**/*.py", + "rayforge/builtin_addons/*", + "rayforge/private_addons/*", +] + +[tool.mypy] +mypy_path = "stubs" + [tool.setuptools.data-files] -"share/applications" = ["data/com.barebaric.rayforge.desktop"] -"share/metainfo" = ["data/com.barebaric.rayforge.metainfo.xml"] -"share/icons/hicolor/scalable/apps" = ["data/com.barebaric.rayforge.svg"] +"share/applications" = ["data/org.rayforge.rayforge.desktop"] +"share/metainfo" = ["data/org.rayforge.rayforge.metainfo.xml"] +"share/mime/packages" = ["data/org.rayforge.rayforge.xml"] +"share/icons/hicolor/scalable/apps" = ["rayforge/resources/icons/org.rayforge.rayforge.svg"] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-asyncio", + "pytest-mock", + "pytest-cov", # Add coverage tool + "pygobject-stubs", +] + +[tool.pytest.ini_options] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + # Ignore Swig related deprecation warnings from importlib + "ignore::DeprecationWarning:importlib._bootstrap", + # PyGObject pulls a removed-asyncio-policy deprecation on Python 3.14+ + "ignore::DeprecationWarning:gi.events", +] +testpaths = [ + "tests", + "rayforge/builtin_addons/*/tests", + "rayforge/private_addons/*/tests", +] +norecursedirs = [ + "builddir", + ".git", + ".pytest_cache", + ".ruff_cache", + ".pixi", + "__pycache__", +] +markers = [ + "ui: marks tests as UI tests that require a graphical environment and a real GTK event loop", + "stress: marks tests as stress tests that run for extended periods", +] +addopts = "-m 'not ui and not stress'" diff --git a/rayforge.code-workspace b/rayforge.code-workspace new file mode 100644 index 000000000..4dfc55e5e --- /dev/null +++ b/rayforge.code-workspace @@ -0,0 +1,17 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "external/raygeo" + }, + { + "path": "rayforge/private_addons" + } + ], + "settings": { + "python.defaultInterpreterPath": ".pixi/envs/default/bin/python", + "python-envs.defaultEnvManager": "ms-python.python:system" + } +} \ No newline at end of file diff --git a/rayforge/__init__.py b/rayforge/__init__.py index 5e6d5bf27..7ad9b7dc4 100644 --- a/rayforge/__init__.py +++ b/rayforge/__init__.py @@ -1,3 +1,9 @@ -from .version import get_version_from_git, get_version_from_pkg +from .version import ( + get_version_from_file, + get_version_from_git, + get_version_from_pkg, +) -__version__ = get_version_from_git() or get_version_from_pkg() +__version__ = ( + get_version_from_file() or get_version_from_git() or get_version_from_pkg() +) diff --git a/rayforge/__main__.py b/rayforge/__main__.py new file mode 100644 index 000000000..f453e17dd --- /dev/null +++ b/rayforge/__main__.py @@ -0,0 +1,6 @@ +"""Allow running the application via ``python -m rayforge``.""" + +from .app import main + +if __name__ == "__main__": + main() diff --git a/rayforge/addon_mgr/__init__.py b/rayforge/addon_mgr/__init__.py new file mode 100644 index 000000000..e3c69de76 --- /dev/null +++ b/rayforge/addon_mgr/__init__.py @@ -0,0 +1,7 @@ +""" +Addon management utilities. +""" + +from .addon_manager import AddonManager + +__all__ = ["AddonManager"] diff --git a/rayforge/addon_mgr/addon.py b/rayforge/addon_mgr/addon.py new file mode 100644 index 000000000..d4d2fba1a --- /dev/null +++ b/rayforge/addon_mgr/addon.py @@ -0,0 +1,565 @@ +import logging +import re +from dataclasses import asdict, dataclass, field +from enum import Enum, auto +from pathlib import Path +from typing import Any, Optional + +import semver +import yaml + +from rayforge.core.hooks import MINIMUM_API_VERSION, PLUGIN_API_VERSION +from rayforge.shared.util.versioning import UnknownVersion, get_git_tag_version + +logger = logging.getLogger(__name__) + +METADATA_FILENAME = "rayforge-addon.yaml" + + +class AddonValidationError(Exception): + """ + Raised when an addon fails validation checks. + """ + + +class AddonMaturity(Enum): + """Represents the maturity level of an addon.""" + + STABLE = auto() + UNTESTED = auto() + EXPERIMENTAL = auto() + KNOWN_BUGGY = auto() + + +def parse_maturity(value: Any) -> AddonMaturity: + """ + Parses a maturity value from a manifest or registry entry. + + Unknown or missing values fall back to AddonMaturity.STABLE. + """ + if isinstance(value, AddonMaturity): + return value + if not isinstance(value, str): + return AddonMaturity.STABLE + try: + return AddonMaturity[value.strip().upper()] + except KeyError: + logger.warning( + f"Unknown addon maturity '{value}', falling back to stable" + ) + return AddonMaturity.STABLE + + +@dataclass +class AddonAuthor: + """Represents the author of an addon.""" + + name: str + email: str + + +@dataclass +class AddonLicense: + """ + Represents licensing information for an addon. + + Attributes: + name: SPDX license identifier (e.g., "MIT", "BSL-1.1"). + required: Whether a license is required for use. + purchase_url: URL to purchase a license. + product_id: Product identifier for license validation. + product_ids: List of product identifiers for license validation. + patreon_tier_ids: List of Patreon tier IDs that grant access. + """ + + name: str = "" + required: bool = False + purchase_url: str = "" + product_id: str = "" + product_ids: list[str] = field(default_factory=list) + patreon_tier_ids: list[str] = field(default_factory=list) + + @classmethod + def from_dict( + cls, data: dict[str, Any] | None + ) -> Optional["AddonLicense"]: + """Creates an AddonLicense from a dictionary, or None if empty.""" + if not data: + return None + if not isinstance(data, dict): + return None + tier_ids = data.get("patreon_tier_ids", []) + if tier_ids is None: + tier_ids = [] + product_ids = data.get("product_ids", []) + if product_ids is None: + product_ids = [] + return cls( + name=data.get("name", ""), + required=bool(data.get("required", False)), + purchase_url=data.get("purchase_url", ""), + product_id=data.get("product_id", ""), + product_ids=product_ids if isinstance(product_ids, list) else [], + patreon_tier_ids=tier_ids if isinstance(tier_ids, list) else [], + ) + + def to_dict(self) -> dict[str, Any]: + """Converts the license to a dictionary for serialization.""" + result = {} + if self.name: + result["name"] = self.name + if self.required: + result["required"] = self.required + if self.purchase_url: + result["purchase_url"] = self.purchase_url + if self.product_id: + result["product_id"] = self.product_id + if self.product_ids: + result["product_ids"] = self.product_ids + if self.patreon_tier_ids: + result["patreon_tier_ids"] = self.patreon_tier_ids + return result + + def get_all_product_ids(self) -> list[str]: + """Returns all product IDs (both single and list).""" + ids = list(self.product_ids) + if self.product_id: + ids.append(self.product_id) + return ids + + +@dataclass +class AddonProvides: + """ + Defines what the addon provides to the system. + + Attributes: + worker (Optional[str]): Module path loaded in worker and main + processes (e.g., 'my_addon.plugin'). + frontend (Optional[str]): Module path loaded only in main process + (e.g., 'my_addon.ui'). + assets (List[Dict[str, str]]): A list of asset definitions. + """ + + worker: str | None = None + frontend: str | None = None + assets: list[dict[str, str]] = field(default_factory=list) + + +VersionType = str | object + + +@dataclass +class AddonMetadata: + """ + Serializable metadata for a Rayforge addon. + """ + + name: str + description: str + version: VersionType + depends: list[str] + author: AddonAuthor + provides: AddonProvides + api_version: int = 1 + url: str = "" + display_name: str = "" + requires: list[str] = field(default_factory=list) + license: AddonLicense | None = None + version_entries: list[dict[str, Any]] = field(default_factory=list) + default_state: str = "enabled" + maturity: AddonMaturity = AddonMaturity.STABLE + + @property + def license_name(self) -> str: + """Returns the license name if available.""" + return self.license.name if self.license else "" + + def to_dict(self) -> dict[str, Any]: + """Converts metadata back to a dictionary for YAML serialization.""" + result = asdict(self) + if self.version is UnknownVersion: + result["version"] = None + if self.license: + result["license"] = self.license.to_dict() + if isinstance(self.maturity, AddonMaturity): + result["maturity"] = self.maturity.name.lower() + return result + + @classmethod + def from_registry_entry( + cls, addon_name: str, data: dict[str, Any] + ) -> "AddonMetadata": + """ + Parses a registry dictionary entry into an AddonMetadata object. + Handles normalization of author fields and mapping keys. + """ + author_info = data.get("author", {}) + if isinstance(author_info, dict): + author = AddonAuthor( + name=author_info.get("name", ""), + email=author_info.get("email", ""), + ) + elif isinstance(author_info, str): + match = re.match(r"(.*) <(.*)>", author_info) + if match: + author = AddonAuthor( + name=match.group(1).strip(), email=match.group(2).strip() + ) + else: + author = AddonAuthor(name=author_info, email="") + else: + author = AddonAuthor(name="", email="") + + provides = AddonProvides() + + depends = data.get("depends", []) + if isinstance(depends, str): + depends = [depends] + + requires = data.get("requires", []) + if isinstance(requires, str): + requires = [requires] + + version_entries = _parse_version_entries(data.get("versions", [])) + + return cls( + name=addon_name, + display_name=data.get("display_name", addon_name), + description=data.get("description", ""), + version=str( + data.get("latest_stable", data.get("version", "0.0.0")) + ), + depends=depends, + author=author, + provides=provides, + url=data.get("repository", ""), + api_version=data.get("api_version", PLUGIN_API_VERSION), + requires=requires, + license=AddonLicense.from_dict(data.get("license")), + version_entries=version_entries, + default_state=data.get("default_state", "enabled"), + maturity=parse_maturity(data.get("maturity")), + ) + + +def _parse_version_entries( + raw: list, +) -> list[dict[str, Any]]: + """Parse the 'versions' list from registry data into structured dicts. + + Each entry may be either a string (legacy format, e.g. ``"v1.0.0"``) + or a dict with keys ``version``, ``api_version``. + """ + entries: list[dict[str, Any]] = [] + if not isinstance(raw, list): + return entries + for item in raw: + if isinstance(item, str): + entries.append({"version": item}) + elif isinstance(item, dict): + entries.append( + { + "version": str(item.get("version", "")), + "api_version": item.get("api_version", 0), + } + ) + return entries + + +class Addon: + """ + A class representing a loadable Rayforge addon. + """ + + def __init__(self, path: Path, metadata: AddonMetadata): + """ + Initialize the Addon. + + Args: + path (Path): The root directory of the addon on the filesystem. + metadata (AddonMetadata): The parsed metadata object. + """ + self.root_path = path + self.metadata = metadata + self.license_message: str = "" + self.purchase_url: str = "" + + @classmethod + def load_from_directory( + cls, + addon_dir: Path, + version: VersionType | None = None, + ) -> "Addon": + """ + Loads an addon from a directory by parsing its YAML metadata file. + + Args: + addon_dir (Path): The directory containing the addon. + version (Optional[VersionType]): The version to use. If None, + version is determined from git tags. For builtin addons + that cannot determine version from git, pass UnknownVersion. + + Raises: + FileNotFoundError: If the metadata file is missing. + AddonValidationError: If parsing fails or required fields + are missing. + RuntimeError: If version is None and no git tags are found. + """ + meta_file = addon_dir / METADATA_FILENAME + + if not meta_file.exists(): + raise FileNotFoundError( + f"No addon metadata file ('{METADATA_FILENAME}') " + f"found in {addon_dir}" + ) + + try: + with open(meta_file, "r") as f: + data = yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise AddonValidationError(f"Failed to parse YAML metadata: {e}") + + try: + # The addon ID (namespace) MUST be defined in the metadata. + addon_name = data.get("name") + if not addon_name or not str(addon_name).strip(): + raise AddonValidationError( + f"Metadata file '{METADATA_FILENAME}' in {addon_dir} " + "is missing the required 'name' field." + ) + addon_name = str(addon_name).strip() + + author_data = data.get("author", {}) + if isinstance(author_data, str): + match = re.match(r"(.*) <(.*)>", author_data) + if match: + author = AddonAuthor( + name=match.group(1).strip(), + email=match.group(2).strip(), + ) + else: + author = AddonAuthor(name=author_data, email="") + else: + author = AddonAuthor( + name=author_data.get("name", ""), + email=author_data.get("email", ""), + ) + + provides_data = data.get("provides", {}) + + worker = provides_data.get("worker") or provides_data.get( + "backend" + ) + provides = AddonProvides( + worker=worker, + frontend=provides_data.get("frontend"), + assets=provides_data.get("assets", []), + ) + + depends = data.get("depends", []) + if isinstance(depends, str): + depends = [depends] + + requires = data.get("requires", []) + if isinstance(requires, str): + requires = [requires] + + resolved_version: VersionType + if version is not None or version is UnknownVersion: + resolved_version = version + else: + resolved_version = get_git_tag_version(addon_dir) + + default_state = data.get("default_state", "enabled") + + metadata = AddonMetadata( + name=addon_name, + display_name=data.get("display_name", addon_name), + description=data.get("description", ""), + version=resolved_version, + depends=depends, + author=author, + provides=provides, + url=data.get("url", ""), + api_version=data.get("api_version", PLUGIN_API_VERSION), + requires=requires, + license=AddonLicense.from_dict(data.get("license")), + default_state=default_state, + maturity=parse_maturity(data.get("maturity")), + ) + + return cls(path=addon_dir, metadata=metadata) + + except AddonValidationError: + raise + except Exception as e: # noqa: BLE001 - wrap into AddonValidationError + raise AddonValidationError(f"Structure error in metadata: {e}") + + def validate(self) -> bool: + """ + Performs rigorous validation on the addon. + """ + logger.debug(f"Validating addon structure for: {self.metadata.name}") + + if not self.metadata.name or not self.metadata.name.strip(): + raise AddonValidationError("Addon 'name' cannot be empty.") + + if not self.metadata.name.isidentifier(): + raise AddonValidationError( + f"Addon name '{self.metadata.name}' is not a valid Python " + "identifier. It must contain only letters, numbers, and " + "underscores, and cannot start with a number." + ) + + if not self.metadata.description: + logger.warning(f"Addon '{self.metadata.name}' has no description.") + + if self.metadata.version is UnknownVersion: + pass + else: + try: + version_str = str(self.metadata.version) + clean_ver = version_str.lstrip("v") + semver.VersionInfo.parse(clean_ver) + except ValueError: + raise AddonValidationError( + f"Invalid semantic version: {self.metadata.version}" + ) + + if not isinstance(self.metadata.api_version, int): + raise AddonValidationError( + f"api_version must be an integer, got: " + f"{type(self.metadata.api_version).__name__}" + ) + if self.metadata.api_version < MINIMUM_API_VERSION: + raise AddonValidationError( + f"Unsupported api_version: {self.metadata.api_version}. " + f"Minimum supported version is {MINIMUM_API_VERSION}." + ) + if self.metadata.api_version > PLUGIN_API_VERSION: + raise AddonValidationError( + f"Unsupported api_version: {self.metadata.api_version}. " + f"Maximum supported version is {PLUGIN_API_VERSION}." + ) + + for dep in self.metadata.depends: + if not isinstance(dep, str): + raise AddonValidationError( + f"Dependency must be a string: {dep}" + ) + parts = dep.split(",") + if not parts or not parts[0]: + raise AddonValidationError(f"Invalid dependency format: {dep}") + pkg_part = parts[0].strip() + if not pkg_part: + raise AddonValidationError(f"Invalid dependency format: {dep}") + for constraint in parts[1:]: + constraint = constraint.strip() + if not constraint: + continue + op_match = re.match(r"^([~^><=!]+)(.+)$", constraint) + if not op_match: + raise AddonValidationError( + f"Invalid version constraint '{constraint}' in: {dep}" + ) + version_str = op_match.group(2).lstrip("v") + operator = op_match.group(1) + + if operator == "~": + version_parts = version_str.split(".") + if len(version_parts) == 2: + version_str = f"{version_str}.0" + elif len(version_parts) == 1: + version_str = f"{version_str}.0.0" + + try: + semver.VersionInfo.parse(version_str) + except ValueError: + raise AddonValidationError( + f"Invalid semantic version in constraint " + f"'{constraint}': {dep}" + ) + + if not self.metadata.author.name: + raise AddonValidationError("Author name is required.") + + if "your-github-username" in self.metadata.author.name.lower(): + raise AddonValidationError("Placeholder detected in author name.") + + if self.metadata.author.email and not re.match( + r"^[^@\s]+@[^@\s]+\.[^@\s]+$", self.metadata.author.email + ): + logger.warning( + f"Author email '{self.metadata.author.email}' appears invalid." + ) + + for asset in self.metadata.provides.assets: + path_str = asset.get("path") + if not path_str: + raise AddonValidationError( + "Asset entry is missing 'path' key." + ) + + if ".." in path_str or path_str.startswith("/"): + raise AddonValidationError( + f"Invalid asset path '{path_str}'. Paths must be relative." + ) + + full_path = self.root_path / path_str + if not full_path.exists(): + raise AddonValidationError(f"Asset path not found: {path_str}") + + if self.metadata.provides.worker: + self._validate_entry_point(self.metadata.provides.worker) + + if self.metadata.provides.frontend: + self._validate_entry_point(self.metadata.provides.frontend) + + return True + + def _validate_entry_point(self, entry_point: str): + """ + Validates a module entry point. + + Entry point must be a valid Python module path + like 'my_addon.plugin'. + """ + if not self._is_valid_module_path(entry_point): + raise AddonValidationError( + f"Entry point '{entry_point}' is not a valid module path. " + "Use dotted notation (e.g., 'my_addon.plugin')." + ) + + file_path = self._resolve_module_path(entry_point) + if not file_path: + raise AddonValidationError( + f"Module '{entry_point}' not found in {self.root_path}" + ) + + def _is_valid_module_path(self, path: str) -> bool: + """Check if a string is a valid Python module path.""" + if not path or path.startswith(".") or path.endswith("."): + return False + parts = path.split(".") + return all(part.isidentifier() for part in parts) + + def _resolve_module_path(self, module_str: str) -> Path | None: + """ + Resolves a dotted module string or a filename to a path. + """ + direct_file_path = self.root_path / module_str + if direct_file_path.exists(): + return direct_file_path + + rel_path = module_str.replace(".", "/") + + path_init = self.root_path / rel_path / "__init__.py" + if path_init.exists(): + return path_init + + path_py = self.root_path / (rel_path + ".py") + if path_py.exists(): + return path_py + + return None diff --git a/rayforge/addon_mgr/addon_manager.py b/rayforge/addon_mgr/addon_manager.py new file mode 100644 index 000000000..a541c170f --- /dev/null +++ b/rayforge/addon_mgr/addon_manager.py @@ -0,0 +1,1702 @@ +import importlib.util +import io +import json +import logging +import os +import shutil +import sys +import tempfile +import urllib.request +import zipfile +from collections.abc import Callable +from enum import Enum, auto +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Protocol, + runtime_checkable, +) +from urllib.parse import quote, urlparse + +import pluggy +import yaml +from blinker import Signal + +from rayforge.shared.util.localized import register_addon_domain + +from .. import __version__ +from ..config import ADDON_REGISTRY_URL +from ..core.addon_config import AddonConfig +from ..core.addon_config import AddonState as ConfigAddonState +from ..core.hooks import PLUGIN_API_VERSION +from ..core.registration import call_registration_hooks +from ..license import LicenseValidator +from ..shared.util.po_compiler import compile_po_to_mo, needs_compilation +from ..shared.util.versioning import ( + UnknownVersion, + check_rayforge_compatibility, + get_git_tag_version, + is_newer_version, + parse_requirement, +) +from .addon import ( + Addon, + AddonMetadata, + AddonValidationError, + VersionType, +) + +if TYPE_CHECKING: + from ..shared.tasker.manager import TaskManager + +logger = logging.getLogger(__name__) + +GITHUB_ZIP_URL = ( + "https://github.com/{owner}/{repo}/archive/refs/heads/main.zip" +) +GITHUB_TAG_ZIP_URL = ( + "https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.zip" +) +GITHUB_TAGS_URL = "https://api.github.com/repos/{owner}/{repo}/tags?per_page=1" +GITLAB_ZIP_URL = ( + "https://gitlab.com/{owner}/{repo}/-/archive/main/{repo}-main.zip" +) +GITLAB_TAG_ZIP_URL = ( + "https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.zip" +) +GITLAB_TAGS_URL = ( + "https://gitlab.com/api/v4/projects/{encoded}" + "/repository/tags?per_page=1&order_by=version" +) +GITEA_ZIP_URL = "https://{host}/{owner}/{repo}/archive/main.zip" +GITEA_TAG_ZIP_URL = "https://{host}/{owner}/{repo}/archive/{tag}.zip" +GITEA_TAGS_URL = "https://{host}/api/v1/repos/{owner}/{repo}/tags?limit=1" + + +@runtime_checkable +class AddonRegistry(Protocol): + """Protocol for registries that support addon item cleanup.""" + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all items registered by the named addon. + + Args: + addon_name: The canonical name of the addon. + + Returns: + The number of items unregistered. + """ + ... + + +class AddonState(Enum): + """Represents the state of an addon.""" + + ENABLED = "enabled" + DISABLED = "disabled" + PENDING_UNLOAD = "pending_unload" + LOAD_ERROR = "load_error" + NOT_INSTALLED = "not_installed" + INCOMPATIBLE = "incompatible" + LICENSE_REQUIRED = "license_required" + + +class UpdateStatus(Enum): + """Represents the installation status of an addon from the registry.""" + + NOT_INSTALLED = auto() + UPDATE_AVAILABLE = auto() + UP_TO_DATE = auto() + INCOMPATIBLE = auto() + + +class AddonManager: + """ + Manages the lifecycle of Rayforge addons (install, load, list). + """ + + def __init__( + self, + addon_dirs: list[Path], + install_dir: Path, + plugin_mgr: pluggy.PluginManager, + task_mgr: "TaskManager", + addon_config: AddonConfig | None = None, + is_job_active_callback: Callable[[], bool] | None = None, + registries: dict[str, "AddonRegistry"] | None = None, + license_validator: LicenseValidator | None = None, + ): + """ + Args: + addon_dirs (List[Path]): Directories to scan for addons. + install_dir (Path): Directory for installing new addons. + plugin_mgr (pluggy.PluginManager): The core plugin manager + instance for registration. + task_mgr (TaskManager): Task manager for shared state access + and worker pool restarts on configuration changes. + addon_config (Optional[AddonConfig]): Addon state persistence + manager. If None, addons will always be loaded. + is_job_active_callback (Optional[Callable]): A callback that + returns True if any job is currently active. Used to defer + addon unloading until jobs complete. + registries (Optional[Dict[str, AddonRegistry]]): Dict mapping + hook parameter names to registry instances. Expected keys: + 'step_registry', 'widget_registry', + 'menu_registry', 'layout_registry'. + license_validator (Optional[LicenseValidator]): License validator + for checking paid addon licenses. + """ + self.addon_dirs = addon_dirs + self.install_dir = install_dir + self.plugin_mgr = plugin_mgr + self.addon_config = addon_config + self.is_job_active_callback = is_job_active_callback + self.registries: dict[str, AddonRegistry] = registries or {} + self._window: Any | None = None + self.loaded_addons: dict[str, Addon] = {} + self.incompatible_addons: dict[str, Addon] = {} + self.disabled_addons: dict[str, Addon] = {} + self.license_required_addons: dict[str, Addon] = {} + self._pending_unloads: set[str] = set() + self._load_errors: dict[str, str] = {} + self._task_mgr = task_mgr + self.license_validator = license_validator + + self.addon_state_changed = Signal() + + if license_validator: + license_validator.changed.connect(self._on_license_changed) + + def _on_license_changed(self, sender): + for addon_name in list(self.license_required_addons.keys()): + self.recheck_license(addon_name) + + def set_registries(self, registries: dict[str, AddonRegistry]): + """ + Set the registries dict for addon cleanup. + + Args: + registries: Dict mapping hook parameter names to registry + instances. Expected keys: 'step_registry', + 'widget_registry', 'action_registry', + 'layout_registry'. + """ + logger.debug( + f"set_registries called with keys: {list(registries.keys())}" + ) + self.registries = registries + + def set_window(self, window: Any): + """ + Set the main window for registering actions. + + Args: + window: The MainWindow instance for registering actions. + """ + self._window = window + + def _parse_registry_dict( + self, registry_data: dict[str, Any] + ) -> list[AddonMetadata]: + """Helper to parse the standard dictionary-based registry format.""" + addons = registry_data.get("addons", {}) + if not isinstance(addons, dict): + logger.warning("Registry 'addons' key is not a dictionary.") + return [] + + result = [] + for addon_id, addon_data in addons.items(): + if not isinstance(addon_data, dict): + logger.warning( + f"Registry entry for '{addon_id}' is not a dict." + ) + continue + try: + meta = AddonMetadata.from_registry_entry(addon_id, addon_data) + self._pick_compatible_version(meta) + result.append(meta) + except ( + AddonValidationError, + ValueError, + KeyError, + TypeError, + ) as e: + logger.warning( + f"Failed to parse registry entry '{addon_id}': {e}" + ) + return result + + @staticmethod + def _pick_compatible_version(meta: AddonMetadata): + """ + Update metadata to use the latest version whose api_version is + compatible with this Rayforge build. + + If no compatible version is found, the metadata is left unchanged + (pointing to the absolute latest, which will be marked + incompatible downstream). + """ + if not meta.version_entries: + return + + for entry in meta.version_entries: + api_ver = entry.get("api_version", 0) + if not isinstance(api_ver, int) or api_ver > PLUGIN_API_VERSION: + continue + meta.version = str(entry.get("version", meta.version)) + meta.api_version = api_ver + return + + def fetch_registry(self) -> list[AddonMetadata]: + """ + Fetches and parses the addon registry from the remote repository. + Returns a list of AddonMetadata objects. + """ + if yaml is None: + logger.error("PyYAML is required to fetch the registry.") + return [] + + try: + logger.info(f"Fetching registry from {ADDON_REGISTRY_URL}") + with urllib.request.urlopen( + ADDON_REGISTRY_URL, timeout=10 + ) as response: + if response.status != 200: + logger.error( + f"Registry fetch failed: HTTP {response.status}" + ) + return [] + data = response.read() + parsed = yaml.safe_load(data) + except (OSError, TimeoutError, ValueError, yaml.YAMLError) as e: + logger.error(f"Failed to fetch or parse registry: {e}") + return [] + + result: list[AddonMetadata] = [] + if isinstance(parsed, list): + for addon_data in parsed: + addon_id = addon_data.get("name") + if not addon_id: + logger.warning( + f"Skipping list-based registry entry without a " + f"'name': {addon_data}", + ) + continue + try: + meta = AddonMetadata.from_registry_entry( + addon_id, addon_data + ) + self._pick_compatible_version(meta) + result.append(meta) + except ( + AddonValidationError, + ValueError, + KeyError, + TypeError, + ) as e: + logger.warning( + "Failed to parse list-based registry entry '%s': %s", + addon_id, + e, + ) + return result + + if isinstance(parsed, dict): + return self._parse_registry_dict(parsed) + + logger.warning( + "Registry format is not a recognized list or dictionary." + ) + return [] + + def get_installed_addon(self, addon_id: str) -> Addon | None: + """ + Finds an installed addon by its canonical ID. + + Returns: + The Addon object if found, otherwise None. + """ + return ( + self.loaded_addons.get(addon_id) + or self.disabled_addons.get(addon_id) + or self.incompatible_addons.get(addon_id) + or self.license_required_addons.get(addon_id) + ) + + def check_update_status( + self, remote_meta: AddonMetadata + ) -> tuple[UpdateStatus, str | None]: + """ + Checks a remote addon against local installations. + + Returns: + A tuple of (UpdateStatus, local_version_str). + """ + installed_addon = self.get_installed_addon(remote_meta.name) + if not installed_addon: + return (UpdateStatus.NOT_INSTALLED, None) + + local_version = installed_addon.metadata.version + if local_version is UnknownVersion: + return (UpdateStatus.UP_TO_DATE, None) + + local_version_str: str | None = str(local_version) + remote_version = remote_meta.version + if remote_version is UnknownVersion: + return (UpdateStatus.UP_TO_DATE, local_version_str) + + is_newer = is_newer_version(str(remote_version), str(local_version)) + + if is_newer: + return (UpdateStatus.UPDATE_AVAILABLE, local_version_str) + return (UpdateStatus.UP_TO_DATE, local_version_str) + + def check_for_updates(self) -> list[tuple[Addon, AddonMetadata]]: + """ + Compares all installed addons against the remote registry to find + available updates. + + Returns: + A list of tuples, where each tuple contains the locally installed + Addon object and the remote AddonMetadata for the update. + """ + logger.info("Checking for available addon updates...") + try: + remote_addons_list = self.fetch_registry() + if not remote_addons_list: + logger.warning( + "Could not fetch remote registry for update check." + ) + return [] + except (OSError, TimeoutError, ValueError, yaml.YAMLError) as e: + logger.error(f"Failed to fetch registry for update check: {e}") + return [] + + remote_addons = {addon.name: addon for addon in remote_addons_list} + updates_available: list[tuple[Addon, AddonMetadata]] = [] + + all_installed = list(self.loaded_addons.values()) + list( + self.disabled_addons.values() + ) + for installed_addon in all_installed: + remote_meta = remote_addons.get(installed_addon.metadata.name) + if not remote_meta: + continue + + local_ver = installed_addon.metadata.version + remote_ver = remote_meta.version + if local_ver is UnknownVersion or remote_ver is UnknownVersion: + continue + + if is_newer_version(str(remote_ver), str(local_ver)): + logger.info( + f"Update found for '{installed_addon.metadata.name}': " + f"{local_ver} -> {remote_ver}" + ) + updates_available.append((installed_addon, remote_meta)) + + if not updates_available: + logger.info("All installed addons are up to date.") + + return updates_available + + def load_addon_by_name( + self, addon_name: str, worker_only: bool = False + ) -> bool: + """ + Load an addon by its canonical name. + + Searches addon directories for an addon with the given name + and loads it if found. + + Args: + addon_name: The canonical name of the addon to load. + worker_only: If True, only load worker entry points + (skip frontend to avoid pulling in GTK dependencies). + + Returns: + True if the addon was loaded successfully, False otherwise. + """ + for addon_dir in self.addon_dirs: + if not addon_dir.exists(): + continue + for child in addon_dir.iterdir(): + if not child.is_dir(): + continue + manifest_path = child / "rayforge-addon.yaml" + if not manifest_path.exists(): + continue + try: + addon = Addon.load_from_directory( + child, version=UnknownVersion + ) + if addon.metadata.name == addon_name: + self.load_addon( + child.resolve(), worker_only=worker_only + ) + call_registration_hooks( + self.plugin_mgr, registries=self.registries + ) + return addon_name in self.loaded_addons + except Exception: + logger.debug( + "Skipping addon directory %s during search", + child, + exc_info=True, + ) + continue + logger.warning(f"Addon '{addon_name}' not found in addon directories") + return False + + def load_installed_addons(self, worker_only: bool = False): + """ + Scans the addon directories and loads valid addons. + + Addons are loaded in dependency order: each addon's manifest + ``requires`` are loaded before it. An addon whose ``requires`` + references a name that is not installed is skipped (its + dependency is unsatisfied). + + Args: + worker_only: If True, only load worker entry points + (skip frontend to avoid pulling in GTK dependencies). + """ + discovered = self._discover_addons() + for _name, addon_path, _req in self._order_by_requires(discovered): + self.load_addon(addon_path, worker_only=worker_only) + + def _discover_addons( + self, + ) -> list[tuple[str | None, Path, list[str]]]: + """ + Scan addon directories, returning ``(name, path, requires)`` + tuples in discovery order. + + The first directory to provide a given canonical name wins; + later duplicates are skipped. Addons whose metadata cannot be + parsed are included with ``name=None`` so that + :meth:`load_addon` can still emit the detailed validation error. + """ + discovered: list[tuple[str | None, Path, list[str]]] = [] + seen_names: set[str] = set() + for addon_dir in self.addon_dirs: + if not addon_dir.exists(): + if addon_dir == self.install_dir: + addon_dir.mkdir(parents=True, exist_ok=True) + continue + + logger.info(f"Scanning for addons in {addon_dir}...") + for child in sorted(addon_dir.iterdir()): + if not child.is_dir(): + continue + try: + addon = Addon.load_from_directory( + child.resolve(), version=UnknownVersion + ) + name = addon.metadata.name + requires = list(addon.metadata.requires) + except ( + AddonValidationError, + FileNotFoundError, + RuntimeError, + ) as e: + logger.debug( + f"Could not pre-parse metadata for {child}: {e}" + ) + discovered.append((None, child.resolve(), [])) + continue + if name in seen_names: + logger.debug(f"Skipping duplicate addon '{name}'") + continue + seen_names.add(name) + discovered.append((name, child.resolve(), requires)) + return discovered + + def _order_by_requires( + self, + discovered: list[tuple[str | None, Path, list[str]]], + ) -> list[tuple[str | None, Path, list[str]]]: + """ + Topologically sort discovered addons by ``requires``. + + Dependencies are emitted before dependents. Addons with an + unsatisfied ``requires`` (a name not present among discovered + addons) are dropped. Cycles are broken by falling back to + discovery order (with a warning). Entries with ``name=None`` + (unparseable metadata) are appended at the end in discovery + order so :meth:`load_addon` logs their error. + """ + by_name = { + name: (name, path, requires) + for (name, path, requires) in discovered + if name is not None + } + ordered: list[tuple[str | None, Path, list[str]]] = [] + state: dict[str, str] = {} + + def visit(name: str) -> bool: + if name not in by_name: + return False + st = state.get(name) + if st == "done": + return True + if st == "visiting": + logger.warning( + f"Circular 'requires' dependency involving " + f"'{name}'; breaking the cycle." + ) + return True + state[name] = "visiting" + _, path, requires = by_name[name] + for dep in requires: + if not visit(dep): + state.pop(name, None) + logger.warning( + f"Addon '{name}' requires '{dep}', which is not " + "installed; skipping." + ) + return False + state[name] = "done" + ordered.append((name, path, requires)) + return True + + for name, _p, _r in discovered: + if name is not None: + visit(name) + + unnamed = [e for e in discovered if e[0] is None] + return ordered + unnamed + + def load_addon( + self, + addon_path: Path, + worker_only: bool = False, + version: VersionType | None = None, + ): + """ + Loads a single addon from a directory. + + Args: + addon_path: Path to the addon directory. + worker_only: If True, only load worker entry points + (skip frontend to avoid pulling in GTK dependencies). + version: If provided, skip version resolution and use + this version directly. + """ + try: + # 1. Load addon structure without resolving version yet to get + # canonical name + addon = Addon.load_from_directory( + addon_path, version=UnknownVersion + ) + addon_name = addon.metadata.name + + # 2. Resolve version using the proper addon_name + is_builtin = not addon_path.is_relative_to(self.install_dir) + if version is not None: + resolved_version = version + elif is_builtin: + resolved_version = UnknownVersion + else: + resolved_version = None + if self.addon_config: + resolved_version = self.addon_config.get_version( + addon_name + ) + + if resolved_version is None: + try: + resolved_version = get_git_tag_version(addon_path) + except RuntimeError: + logger.warning( + f"No stored version for addon " + f"'{addon_name}' at {addon_path} " + "and no git tags found, " + "using UnknownVersion" + ) + resolved_version = UnknownVersion + if resolved_version is UnknownVersion: + logger.warning( + f"No stored version for addon " + f"'{addon_name}' at {addon_path} " + "and no git tags found, " + "using UnknownVersion" + ) + + addon.metadata.version = resolved_version + + # 3. Now completely validate the populated addon + addon.validate() + + has_worker = addon.metadata.provides.worker is not None + has_frontend = addon.metadata.provides.frontend is not None + + if not has_worker and not has_frontend: + self.loaded_addons[addon_name] = addon + logger.info(f"Loaded asset addon: {addon_name}") + return + + if self.addon_config: + state = self.addon_config.get_state( + addon_name, + default=addon.metadata.default_state, + ) + if state == ConfigAddonState.DISABLED: + logger.info( + f"Addon '{addon_name}' is disabled, skipping load" + ) + self.disabled_addons[addon_name] = addon + return + + if ( + self._check_version_compatibility(addon) + != UpdateStatus.UP_TO_DATE + ): + logger.warning( + f"Addon '{addon_name}' is incompatible with " + "this version of Rayforge" + ) + self.incompatible_addons[addon_name] = addon + return + + allowed, message, purchase_url = self._check_license(addon) + if not allowed: + logger.info( + f"Addon '{addon_name}' requires license: {message}" + ) + addon.license_message = message + addon.purchase_url = purchase_url + self.license_required_addons[addon_name] = addon + return + + self.compile_translations(addon_path) + + locale_dir = addon_path / "locale" + if not locale_dir.is_dir(): + locale_dir = addon_path / "locales" + if locale_dir.is_dir(): + register_addon_domain(addon_name, locale_dir) + + self._import_and_register(addon, addon.metadata.provides.worker) + if not worker_only: + self._import_and_register( + addon, addon.metadata.provides.frontend + ) + + version_str = ( + "(builtin)" + if addon.metadata.version is UnknownVersion + else str(addon.metadata.version) + ) + logger.info(f"Loaded addon: {addon_name} {version_str}") + + except (AddonValidationError, FileNotFoundError) as e: + logger.warning(f"Skipping invalid addon at {addon_path}: {e}") + except Exception: + logger.exception(f"Failed to load addon at {addon_path}") + + def _check_version_compatibility(self, addon: Addon): + """ + Checks if addon's dependencies are compatible. + Returns UpdateStatus.UP_TO_DATE if compatible, INCOMPATIBLE otherwise. + """ + current_version = __version__ + if not current_version: + logger.warning("Could not determine current rayforge version") + return UpdateStatus.UP_TO_DATE + + if check_rayforge_compatibility( + addon.metadata.depends, current_version + ): + return UpdateStatus.UP_TO_DATE + return UpdateStatus.INCOMPATIBLE + + def _check_license(self, addon: Addon) -> tuple[bool, str, str]: + """ + Check if addon requires and has valid license. + + Returns: + Tuple of (is_allowed, message, purchase_url) + """ + if not self.license_validator: + return True, "", "" + + license_config = addon.metadata.license + + if not license_config or not license_config.required: + return True, "", "" + + return self.license_validator.check_license( + addon.metadata.name, license_config.to_dict() + ) + + def _import_and_register(self, addon: Addon, entry_point: str | None): + """ + Imports the module specified by entry_point and registers it. + + Args: + addon: The addon to load. + entry_point: Entry point string like 'module.submodule', + or None to skip. + """ + if not entry_point: + return + + name = addon.metadata.name + + # Module name logic: "rayforge_addons.." + # Example: + # 1. rayforge_addons + # 2. laser_essentials (addon name) + # 3. laser_essentials.worker (inner python structure) + module_name = f"rayforge_addons.{name}.{entry_point}" + + module_path = self._resolve_entry_point_path( + entry_point, addon.root_path + ) + if not module_path: + error_msg = f"Entry point {entry_point} not found for {name}." + logger.error(error_msg) + self._load_errors[name] = error_msg + return + + try: + self._ensure_parent_modules( + module_name, addon.root_path, entry_point + ) + + spec = importlib.util.spec_from_file_location( + module_name, module_path + ) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + logger.debug( + f"Registering module {module_name} with plugin_mgr" + ) + self.plugin_mgr.register(module) + self.loaded_addons[name] = addon + if name in self._load_errors: + del self._load_errors[name] + except Exception as e: # noqa: BLE001 - addon import boundary + error_msg = str(e) + logger.error(f"Error importing addon {name}: {e}") + self._load_errors[name] = error_msg + + def _ensure_parent_modules( + self, module_name: str, root_path: Path, entry_point: str + ): + """ + Ensure parent modules exist in sys.modules for relative imports. + + The module structure is: + rayforge_addons.. + + We need to ensure all intermediates exist. + """ + import types + + # 1. Ensure root 'rayforge_addons' + if "rayforge_addons" not in sys.modules: + ns = types.ModuleType("rayforge_addons") + ns.__path__ = [] + ns.__package__ = "rayforge_addons" + sys.modules["rayforge_addons"] = ns + + parts = module_name.split(".") + # parts[0] is rayforge_addons + # parts[1] is ADDON_ID (e.g. laser_essentials) + # parts[2:] are the rest + + if len(parts) > 1: + addon_id = parts[1] + addon_ns = f"rayforge_addons.{addon_id}" + + # 2. Ensure addon namespace + if addon_ns not in sys.modules: + ns = types.ModuleType(addon_ns) + ns.__path__ = [str(root_path)] + ns.__package__ = addon_ns + sys.modules[addon_ns] = ns + + current_ns = addon_ns + current_path = root_path + + # 3. Walk down entry point parts + # entry_point = "pkg.sub.mod" + # -> parts are [rayforge_addons, ID, pkg, sub, mod] + # inner_parts are [pkg, sub, mod] + inner_parts = parts[2:] + + # Iterate up to the second to last part (creating parent packages) + for i in range(len(inner_parts) - 1): + pkg_name = inner_parts[i] + full_pkg_name = f"{current_ns}.{pkg_name}" + current_path = current_path / pkg_name + + if full_pkg_name not in sys.modules: + init_file = current_path / "__init__.py" + if init_file.exists(): + spec = importlib.util.spec_from_file_location( + full_pkg_name, init_file + ) + if spec and spec.loader: + pkg_module = importlib.util.module_from_spec(spec) + sys.modules[full_pkg_name] = pkg_module + spec.loader.exec_module(pkg_module) + else: + ns = types.ModuleType(full_pkg_name) + ns.__path__ = [str(current_path)] + ns.__package__ = full_pkg_name + sys.modules[full_pkg_name] = ns + + current_ns = full_pkg_name + + def _resolve_entry_point_path( + self, entry_point: str, root_path: Path + ) -> Path | None: + """ + Resolve a module path to a file path. + + Args: + entry_point: Module path like 'my_addon.plugin' + root_path: The addon root directory. + + Returns: + Path to the module file, or None if not found. + """ + module_path = root_path / entry_point.replace(".", "/") + if module_path.is_dir(): + module_path = module_path / "__init__.py" + else: + module_path = module_path.with_suffix(".py") + + if not module_path.exists(): + return None + return module_path + + def compile_translations( + self, addon_path: Path, force: bool = False + ) -> int: + """ + Compile .po files to .mo files in an addon's locales directory. + + This is called automatically when an addon is installed or loaded. + It finds all .po files under /locales/ and compiles them to .mo + files in the corresponding LC_MESSAGES directories. By default, it only + compiles if the .mo file is missing or outdated. + + Args: + addon_path: Path to the installed addon directory. + force: If True, always compile even if .mo exists and is + up to date. + + Returns: + The number of .mo files compiled. + """ + locales_dir = addon_path / "locales" + if not locales_dir.exists(): + locales_dir = addon_path / "locale" + if not locales_dir.exists(): + return 0 + + compiled_count = 0 + for po_file in locales_dir.rglob("*.po"): + mo_file = po_file.with_suffix(".mo") + if ( + force + or needs_compilation(po_file, mo_file) + and compile_po_to_mo(po_file, mo_file) + ): + compiled_count += 1 + logger.debug(f"Compiled {po_file} -> {mo_file}") + + if compiled_count > 0: + logger.info(f"Compiled {compiled_count} translation file(s)") + return compiled_count + + @staticmethod + def _import_git(): + if os.environ.get("RAYFORGE_NOGIT"): + raise ImportError("RAYFORGE_NOGIT is set") + import git + + assert git is not None + + def _fetch_addon_source(self, git_url: str, dest: Path) -> bool: + """ + Download addon source to a staging directory. + + Uses git clone when GitPython is available, otherwise falls + back to downloading a zip archive. Returns True on success. + """ + try: + self._import_git() + except ImportError: + logger.info("GitPython not available, trying zip download...") + return self._download_addon_zip(git_url, dest) + + from git import Repo + from git.exc import GitError + + logger.info(f"Cloning {git_url} to staging area...") + try: + Repo.clone_from(git_url, dest) + logger.info(f"Successfully cloned {git_url}") + return True + except GitError as e: + logger.error(f"Git clone failed: {e}") + return False + + def _resolve_addon_version( + self, staging_path: Path, git_url: str | None = None + ) -> VersionType: + """ + Determine the version of an addon in a staging directory. + + Tries git tags first (when GitPython is available), then + falls back to querying the remote tag API, then to the + version field in the manifest. + """ + try: + self._import_git() + except ImportError: + logger.info("GitPython not available for version resolution") + if git_url: + remote = self._get_remote_tag_version(git_url) + if remote: + logger.info(f"Using remote tag version: {remote}") + return remote + version = self._version_from_manifest(staging_path) + if version: + logger.info(f"Using manifest version: {version}") + else: + logger.info("Version unknown, no source available") + return version or UnknownVersion + + try: + version = get_git_tag_version(staging_path) + logger.info(f"Using git tag version: {version}") + return version + except RuntimeError: + logger.debug("No git tag version found") + version = self._version_from_manifest(staging_path) or UnknownVersion + logger.info(f"Using manifest version: {version}") + return version + + def _finalize_addon_install( + self, + addon: Addon, + git_url: str, + addon_id: str | None, + ) -> Path: + """ + Copy validated addon from staging to the install directory + and register it with the manager. + + Returns the final installation path. + """ + addon_name = addon.metadata.name + version = addon.metadata.version + install_dir_name = addon_id or self._extract_repo_name(git_url) + final_path = self.install_dir / install_dir_name + logger.info( + f"Finalizing install of '{addon_name}' v{version} to {final_path}" + ) + + if final_path.exists(): + logger.info(f"Upgrading existing addon at {final_path}") + self.uninstall_addon(addon_name) + + shutil.copytree(addon.root_path, final_path, dirs_exist_ok=True) + self.compile_translations(final_path, force=True) + + if self.addon_config and version is not UnknownVersion: + self.addon_config.set_version(addon_name, str(version)) + + logger.info(f"Successfully installed addon to {final_path}") + self.load_addon(final_path, version=version) + call_registration_hooks(self.plugin_mgr, registries=self.registries) + logger.info(f"Addon '{addon_name}' fully loaded and registered") + return final_path + + def install_addon( + self, git_url: str, addon_id: str | None = None + ) -> Path | None: + """ + Install an addon from a remote Git repository. + + Falls back to downloading a zip archive if GitPython is not + available. + + Args: + git_url (str): The URL of the repository to clone. + addon_id (Optional[str]): The canonical ID for the addon, + provided by the registry. If None, it's derived from + the URL (for manual installs). + """ + logger.info( + f"install_addon called: git_url={git_url}, addon_id={addon_id}" + ) + result: Path | None = None + with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True + ) as temp_dir: + temp_path = Path(temp_dir) + + if not self._fetch_addon_source(git_url, temp_path): + logger.error("Failed to fetch addon source, aborting install") + return None + + version = self._resolve_addon_version(temp_path, git_url) + logger.info(f"Resolved addon version: {version}") + + try: + logger.info("Validating addon structure and code safety...") + addon = Addon.load_from_directory(temp_path, version=version) + addon.validate() + logger.info("Validation passed.") + result = self._finalize_addon_install(addon, git_url, addon_id) + except AddonValidationError as e: + logger.error(f"Addon validation failed: {e}") + return None + except Exception: + logger.exception("Installation failed") + return None + + logger.info(f"install_addon returning: {result}") + return result + + def uninstall_addon(self, addon_name: str) -> bool: + """ + Deletes the addon directory and unloads the module. + """ + addon = ( + self.loaded_addons.get(addon_name) + or self.incompatible_addons.get(addon_name) + or self.disabled_addons.get(addon_name) + or self.license_required_addons.get(addon_name) + ) + if not addon: + logger.warning( + f"Attempted to uninstall unknown or already " + f"uninstalled addon: {addon_name}" + ) + # Fallback: scan install_dir for matching addon + for child in self.install_dir.iterdir(): + if child.is_dir() and (child / "rayforge-addon.yaml").exists(): + try: + temp_addon = Addon.load_from_directory( + child, version=UnknownVersion + ) + if temp_addon.metadata.name == addon_name: + self._cleanup_directory(child) + return True + except ( + AddonValidationError, + FileNotFoundError, + RuntimeError, + OSError, + ) as e: + logger.error( + f"Failed to uninstall addon {addon_name}: {e}" + ) + return False + + addon_path = addon.root_path + + try: + if addon_path.exists() and addon_path.is_dir(): + self._cleanup_directory(addon_path) + logger.info(f"Uninstalled addon at {addon_path}") + + # Robust unloading: Find all modules loaded from this addon's + # directory + # This handles any module structure (old style or new style) + addon_path_str = str(addon_path.resolve()) + modules_to_unload = [] + for name, module in list(sys.modules.items()): + if not hasattr(module, "__file__") or not module.__file__: + continue + try: + # Resolve to absolute path to match + mod_path = str(Path(module.__file__).resolve()) + if mod_path.startswith(addon_path_str): + modules_to_unload.append(name) + except OSError: + logger.warning( + f"Could not resolve path for module {name}, " + "skipping unload." + ) + + for module_name in modules_to_unload: + module = sys.modules.get(module_name) + if module: + try: + self.plugin_mgr.unregister(module) + except (ValueError, AssertionError): + pass # plugin was not registered + del sys.modules[module_name] + logger.info(f"Unloaded module: {module_name}") + + self._unregister_addon_items(addon_name) + + if addon_name in self.loaded_addons: + del self.loaded_addons[addon_name] + if addon_name in self.incompatible_addons: + del self.incompatible_addons[addon_name] + if addon_name in self.disabled_addons: + del self.disabled_addons[addon_name] + if addon_name in self.license_required_addons: + del self.license_required_addons[addon_name] + if addon_name in self._load_errors: + del self._load_errors[addon_name] + self._pending_unloads.discard(addon_name) + + if self.addon_config: + self.addon_config.remove_state(addon_name) + + return True + + except Exception as e: # noqa: BLE001 - best-effort uninstall + logger.error(f"Failed to uninstall {addon_name}: {e}") + return False + + @staticmethod + def _parse_git_url( + git_url: str, + ) -> tuple[str, str, str] | None: + """ + Extract (host, owner, repo) from an HTTPS git URL. + + Returns None for non-HTTP URLs or malformed paths. + """ + parsed = urlparse(git_url) + if parsed.scheme not in ("http", "https") or not parsed.path: + return None + if not parsed.hostname: + return None + path = parsed.path.rstrip("/") + path = path.removesuffix(".git") + parts = path.strip("/").split("/") + if len(parts) < 2: + return None + return parsed.hostname, parts[0], parts[1] + + def _get_remote_tag_version(self, git_url: str) -> str | None: + """ + Query the platform REST API for the latest tag. + + Supports GitHub, GitLab, and Gitea. Returns the tag name + (without 'v' prefix) or None if no tags are found. + """ + parsed = self._parse_git_url(git_url) + if parsed is None: + return None + host, owner, repo = parsed + if host == "github.com": + api_url = GITHUB_TAGS_URL.format(owner=owner, repo=repo) + elif host == "gitlab.com": + encoded = quote(f"{owner}/{repo}", safe="") + api_url = GITLAB_TAGS_URL.format(encoded=encoded) + else: + api_url = GITEA_TAGS_URL.format(host=host, owner=owner, repo=repo) + try: + req = urllib.request.Request( + api_url, headers={"Accept": "application/json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status != 200: + return None + tags = json.loads(resp.read()) + except (OSError, TimeoutError, ValueError) as e: + logger.debug(f"Remote tag lookup failed for {git_url}: {e}") + return None + if not tags or not isinstance(tags, list): + return None + name = tags[0].get("name", "") + name = name.removeprefix("v") + return name or None + + @staticmethod + def _git_url_to_zip(git_url: str) -> str | None: + """Convert a git repository URL to a downloadable zip URL.""" + parsed = AddonManager._parse_git_url(git_url) + if parsed is None: + return None + host, owner, repo = parsed + if host == "github.com": + return GITHUB_ZIP_URL.format(owner=owner, repo=repo) + if host == "gitlab.com": + return GITLAB_ZIP_URL.format(owner=owner, repo=repo) + return GITEA_ZIP_URL.format(host=host, owner=owner, repo=repo) + + @staticmethod + def _version_from_manifest(addon_path: Path) -> str | None: + """Read the version field from rayforge-addon.yaml.""" + manifest = addon_path / "rayforge-addon.yaml" + if manifest.exists(): + try: + data = yaml.safe_load(manifest.read_text()) + v = data.get("version") if isinstance(data, dict) else None + return str(v) if v else None + except yaml.YAMLError: + pass + return None + + @staticmethod + def _fetch_zip_data(zip_url: str) -> io.BytesIO | None: + """Download a zip archive from a URL. Returns None on failure.""" + try: + with urllib.request.urlopen(zip_url, timeout=30) as resp: + if resp.status != 200: + logger.error(f"Zip download failed: HTTP {resp.status}") + return None + return io.BytesIO(resp.read()) + except (OSError, TimeoutError) as e: + logger.error(f"Zip download failed: {e}") + return None + + @staticmethod + def _extract_zip_archive(data: io.BytesIO, dest: Path) -> bool: + """ + Extract a zip archive, stripping the top-level directory. + + Handles the common case where GitHub/GitLab zips wrap + everything in a "repo-branch/" prefix. + """ + try: + with zipfile.ZipFile(data) as zf: + names = zf.namelist() + if not names: + logger.error("Downloaded zip archive is empty") + return False + prefix = names[0].split("/")[0] + "/" + for member in zf.infolist(): + if member.filename.startswith(prefix): + member.filename = member.filename[len(prefix) :] + if member.filename: + zf.extract(member, dest) + return True + except zipfile.BadZipFile as e: + logger.error(f"Downloaded file is not a valid zip: {e}") + return False + + def _download_addon_zip(self, git_url: str, dest: Path) -> bool: + """Download a repository as a zip archive and extract to dest.""" + zip_url = self._git_url_to_zip(git_url) + if not zip_url: + logger.error(f"Cannot convert URL to zip download: {git_url}") + return False + + data = self._fetch_zip_data(zip_url) + if data is None: + return False + logger.info(f"Successfully downloaded {zip_url}") + + result = self._extract_zip_archive(data, dest) + if result: + logger.info(f"Successfully extracted {zip_url}") + return result + + def _extract_repo_name(self, git_url: str) -> str: + """ + Extract the repository name from a Git URL. + """ + parsed = urlparse(git_url) + path = parsed.path + repo_name = path.rstrip("/").split("/")[-1] + repo_name = repo_name.removesuffix(".git") + return repo_name + + def _cleanup_directory(self, addon_path: Path): + """ + Clean up a directory. + """ + try: + if addon_path.exists(): + shutil.rmtree(addon_path) + logger.debug(f"Cleaned up directory: {addon_path}") + except OSError as e: + logger.error(f"Failed to clean up {addon_path}: {e}") + + def enable_addon(self, addon_name: str) -> bool: + """ + Enable an addon. Returns True if successful. + + The addon will be loaded on the next application start or when + load_addon is called explicitly. + """ + if not self.addon_config: + logger.warning("Cannot enable addon: addon_config not configured") + return False + + addon = self.disabled_addons.get(addon_name) + if not addon: + logger.warning(f"Cannot enable addon: '{addon_name}' not found") + return False + + self.addon_config.set_state(addon_name, ConfigAddonState.ENABLED) + del self.disabled_addons[addon_name] + + if self._check_version_compatibility(addon) != UpdateStatus.UP_TO_DATE: + self.incompatible_addons[addon_name] = addon + logger.info(f"Addon '{addon_name}' enabled but is incompatible") + else: + self._import_and_register(addon, addon.metadata.provides.worker) + self._import_and_register(addon, addon.metadata.provides.frontend) + call_registration_hooks( + self.plugin_mgr, registries=self.registries + ) + logger.info(f"Addon '{addon_name}' enabled and loaded") + + self.addon_state_changed.send(self, addon_name=addon_name) + return True + + def disable_addon(self, addon_name: str) -> bool: + """ + Disable an addon. Returns True if immediate, False if deferred. + + If jobs are active, the addon is marked for deferred unload and + will be unloaded when complete_pending_unloads() is called. + """ + if not self.addon_config: + logger.warning("Cannot disable addon: addon_config not configured") + return False + + addon = self.loaded_addons.get(addon_name) + if not addon: + logger.warning(f"Cannot disable addon: '{addon_name}' not loaded") + return False + + if self.is_job_active_callback and self.is_job_active_callback(): + logger.info( + f"Jobs active, deferring unload of addon '{addon_name}'" + ) + self._pending_unloads.add(addon_name) + self.addon_config.set_state(addon_name, ConfigAddonState.DISABLED) + return False + + self._do_unload_addon(addon_name, addon) + self.addon_state_changed.send(self, addon_name=addon_name) + return True + + def _do_unload_addon(self, addon_name: str, addon: Addon): + """Perform the actual unload of an addon.""" + if self.addon_config: + self.addon_config.set_state(addon_name, ConfigAddonState.DISABLED) + + # Robust unloading: Find all modules loaded from this addon's directory + # This handles any module structure (old style or new style) + addon_path_str = str(addon.root_path.resolve()) + modules_to_unload = [] + for name, module in list(sys.modules.items()): + if not hasattr(module, "__file__") or not module.__file__: + continue + try: + mod_path = str(Path(module.__file__).resolve()) + if mod_path.startswith(addon_path_str): + modules_to_unload.append(name) + except OSError: + logger.warning( + f"Could not resolve path for module {name}, " + "skipping unload." + ) + + self.plugin_mgr.hook.on_unload() + + for module_name in modules_to_unload: + module = sys.modules.get(module_name) + if module: + try: + registered = self.plugin_mgr.get_plugin(module_name) + if registered is not None: + self.plugin_mgr.unregister(registered) + except (TypeError, AttributeError, ValueError, AssertionError): + pass + del sys.modules[module_name] + logger.debug(f"Unloaded module: {module_name}") + + self._unregister_addon_items(addon_name) + + del self.loaded_addons[addon_name] + self.disabled_addons[addon_name] = addon + self._pending_unloads.discard(addon_name) + logger.info(f"Addon '{addon_name}' disabled") + + def _unregister_addon_items(self, addon_name: str): + """Unregister all items registered by an addon.""" + for name, registry in self.registries.items(): + count = registry.unregister_all_from_addon(addon_name) + if count: + logger.debug( + f"Unregistered {count} items from {name} for {addon_name}" + ) + + def complete_pending_unloads(self) -> list[str]: + """ + Complete any pending addon unloads. + + Should be called when jobs finish to unload addons that were + disabled while jobs were active. + + Returns: + List of addon names that were unloaded. + """ + if not self._pending_unloads: + return [] + + unloaded = [] + for addon_name in list(self._pending_unloads): + addon = self.loaded_addons.get(addon_name) + if addon: + self._do_unload_addon(addon_name, addon) + unloaded.append(addon_name) + + return unloaded + + def has_pending_unloads(self) -> bool: + """Check if there are addons waiting to be unloaded.""" + return len(self._pending_unloads) > 0 + + def get_pending_unloads(self) -> set[str]: + """Get the set of addon names pending unload.""" + return self._pending_unloads.copy() + + def is_addon_enabled(self, addon_name: str) -> bool: + """Check if an addon is currently enabled and loaded.""" + return addon_name in self.loaded_addons + + def get_addon_state(self, addon_name: str) -> str: + """ + Get the current state of an addon. + + Returns one of: 'enabled', 'disabled', 'pending_unload', + 'load_error', 'incompatible', 'license_required', 'not_installed' + """ + if addon_name in self._pending_unloads: + return AddonState.PENDING_UNLOAD.value + if addon_name in self._load_errors: + return AddonState.LOAD_ERROR.value + if addon_name in self.loaded_addons: + return AddonState.ENABLED.value + if addon_name in self.disabled_addons: + return AddonState.DISABLED.value + if addon_name in self.incompatible_addons: + return AddonState.INCOMPATIBLE.value + if addon_name in self.license_required_addons: + return AddonState.LICENSE_REQUIRED.value + return AddonState.NOT_INSTALLED.value + + def get_addon_error(self, addon_name: str) -> str | None: + """Get the error message for an addon that failed to load.""" + return self._load_errors.get(addon_name) + + def reload_addon(self, addon_name: str) -> bool: + """ + Reload an addon (disable then enable). + + Returns True if successful. + """ + if addon_name not in self.loaded_addons: + logger.warning( + f"Cannot reload addon '{addon_name}': not currently loaded" + ) + return False + + if self.is_job_active_callback and self.is_job_active_callback(): + logger.warning( + f"Cannot reload addon '{addon_name}': jobs are active" + ) + return False + + addon = self.loaded_addons.get(addon_name) + if not addon: + return False + + self._do_unload_addon(addon_name, addon) + + del self.disabled_addons[addon_name] + if self.addon_config: + self.addon_config.set_state(addon_name, ConfigAddonState.ENABLED) + + if self._check_version_compatibility(addon) != UpdateStatus.UP_TO_DATE: + self.incompatible_addons[addon_name] = addon + logger.info(f"Addon '{addon_name}' reloaded but is incompatible") + return False + + self._import_and_register(addon, addon.metadata.provides.worker) + self._import_and_register(addon, addon.metadata.provides.frontend) + call_registration_hooks(self.plugin_mgr, registries=self.registries) + if addon_name in self.loaded_addons: + logger.info(f"Addon '{addon_name}' reloaded successfully") + self.addon_state_changed.send(self, addon_name=addon_name) + return True + else: + logger.error(f"Failed to reload addon '{addon_name}'") + return False + + def _find_dependents(self, addon_name: str) -> list[str]: + """ + Find all enabled addons that depend on the given addon. + + Returns: + List of addon names that depend on this addon. + """ + dependents = [] + for name, addon in self.loaded_addons.items(): + for req in addon.metadata.requires: + req_name, _ = parse_requirement(req) + if req_name == addon_name: + dependents.append(name) + break + return dependents + + def can_disable(self, addon_name: str) -> tuple[bool, str]: + """ + Check if an addon can be disabled. + + Returns: + Tuple of (can_disable, reason). If can_disable is False, + reason contains the explanation. + """ + dependents = self._find_dependents(addon_name) + if dependents: + return False, f"Required by: {', '.join(dependents)}" + return True, "" + + def get_missing_dependencies( + self, addon_name: str + ) -> list[tuple[str, str | None]]: + """ + Get missing or disabled dependencies for an addon. + + Returns: + List of (name, version_spec) tuples for missing dependencies. + """ + addon = ( + self.loaded_addons.get(addon_name) + or self.disabled_addons.get(addon_name) + or self.incompatible_addons.get(addon_name) + ) + if not addon: + return [] + + missing = [] + for req in addon.metadata.requires: + req_name, version_spec = parse_requirement(req) + if req_name not in self.loaded_addons: + missing.append((req_name, version_spec)) + return missing + + def enable_addon_with_deps( + self, addon_name: str + ) -> tuple[bool, list[str]]: + """ + Enable an addon along with its missing dependencies. + + Returns: + Tuple of (success, list_of_enabled_addons). + """ + addon = self.disabled_addons.get(addon_name) + if not addon: + logger.warning(f"Cannot enable addon: '{addon_name}' not found") + return False, [] + + missing = self.get_missing_dependencies(addon_name) + enabled = [] + + for req_name, _ in missing: + if req_name in self.disabled_addons: + if not self.enable_addon(req_name): + logger.error( + f"Failed to enable dependency '{req_name}' " + f"for '{addon_name}'" + ) + for name in enabled: + self.disable_addon(name) + return False, [] + enabled.append(req_name) + elif req_name not in self.loaded_addons: + logger.error( + f"Missing dependency '{req_name}' for '{addon_name}' " + "is not installed" + ) + for name in enabled: + self.disable_addon(name) + return False, [] + + if not self.enable_addon(addon_name): + for name in enabled: + self.disable_addon(name) + return False, [] + + enabled.append(addon_name) + return True, enabled + + def get_license_required_addon(self, addon_name: str) -> Addon | None: + """ + Get an addon that requires a license. + + Returns: + The Addon object if found in license_required_addons, else None. + """ + return self.license_required_addons.get(addon_name) + + def recheck_license(self, addon_name: str) -> tuple[bool, str]: + """ + Recheck the license for an addon and attempt to load it if valid. + + Returns: + Tuple of (success, message). + """ + addon = self.license_required_addons.get(addon_name) + if not addon: + return False, "Addon not in license-required state" + + allowed, message, _ = self._check_license(addon) + if not allowed: + return False, message + + del self.license_required_addons[addon_name] + + self._import_and_register(addon, addon.metadata.provides.worker) + self._import_and_register(addon, addon.metadata.provides.frontend) + + if addon_name in self.loaded_addons: + call_registration_hooks( + self.plugin_mgr, registries=self.registries + ) + return True, "License validated, addon loaded successfully" + + return False, "Failed to load addon after license validation" + + def get_all_license_required_addons(self) -> dict[str, Addon]: + """ + Get all addons that require a license. + + Returns: + Dict of addon_name -> Addon for all license-required addons. + """ + return dict(self.license_required_addons) + + def get_all_addons(self) -> dict[str, Addon]: + """ + Get all addons from all categories. + + Returns: + Dict of addon_name -> Addon for all addons (loaded, + license-required, disabled, and incompatible). + """ + all_addons = {} + for source in [ + self.loaded_addons, + self.license_required_addons, + self.disabled_addons, + self.incompatible_addons, + ]: + all_addons.update(source) + return all_addons diff --git a/rayforge/addon_mgr/update_cmd.py b/rayforge/addon_mgr/update_cmd.py new file mode 100644 index 000000000..90ef4a0d1 --- /dev/null +++ b/rayforge/addon_mgr/update_cmd.py @@ -0,0 +1,178 @@ +import asyncio +import logging +from gettext import gettext as _ +from gettext import ngettext +from typing import TYPE_CHECKING + +from blinker import Signal + +from ..context import RayforgeContext +from .addon import Addon, AddonMetadata + +if TYPE_CHECKING: + from ..shared.tasker import TaskManager + +logger = logging.getLogger(__name__) + + +class UpdateCommand: + """ + Handles checking for and installing addon updates. + This class orchestrates the AddonManager, running its blocking + operations in background threads and communicating results back to the + UI via signals. + """ + + notification_requested = Signal() + + def __init__(self, task_mgr: "TaskManager", context: RayforgeContext): + self._context = context + self._addon_mgr = context.addon_mgr + self._task_mgr = task_mgr + + def check_for_updates_on_startup(self): + """ + Initiates a background task to check for addon updates. + This method is non-blocking. + """ + logger.info("Scheduling startup addon update check.") + self._task_mgr.add_coroutine( + self._check_for_updates_worker, key="addon-update-check" + ) + + async def _check_for_updates_worker(self, ctx): + """ + The async worker that performs the update check in a thread. + """ + try: + ctx.set_message(_("Checking for addon updates...")) + + updates = await asyncio.to_thread( + self._addon_mgr.check_for_updates + ) + + if updates: + logger.info(f"Found {len(updates)} available addon updates.") + names = [ + remote_meta.display_name or remote_meta.name + for _, remote_meta in updates + ] + + if len(names) == 1: + msg = _("An update is available for {name}.").format( + name=names[0] + ) + elif len(names) == 2: + msg = _( + "Updates are available for {name1} and {name2}." + ).format(name1=names[0], name2=names[1]) + else: + msg = _( + "Updates are available for {name1}, " + "{name2}, and {num} others." + ).format( + name1=names[0], name2=names[1], num=len(names) - 2 + ) + + def _install_callback(): + self.install_updates(updates) + + self._task_mgr.schedule_on_main_thread( + self.notification_requested.send, + self, + message=msg, + persistent=True, + action_label=_("Install All"), + action_callback=_install_callback, + ) + ctx.set_message(_("Addon updates found.")) + else: + ctx.set_message(_("Addons are up to date.")) + + except Exception as e: # noqa: BLE001 - async update-check task + logger.error(f"Failed to check for addon updates: {e}") + ctx.set_message(_("Update check failed.")) + + def install_updates(self, updates: list[tuple[Addon, AddonMetadata]]): + """ + Initiates a background task to install a list of addon updates. + This method is non-blocking. + """ + if not updates: + return + + logger.info(f"Scheduling installation of {len(updates)} addon(s).") + self._task_mgr.add_coroutine( + self._install_updates_worker, updates, key="addon-install" + ) + + async def _install_updates_worker( + self, ctx, updates: list[tuple[Addon, AddonMetadata]] + ): + """ + The async worker that installs multiple addons concurrently. + """ + install_tasks = [] + for __, remote_meta in updates: + task = asyncio.to_thread( + self._addon_mgr.install_addon, + remote_meta.url, + remote_meta.name, + ) + install_tasks.append(task) + + ctx.set_message(_("Installing addon updates...")) + results = await asyncio.gather(*install_tasks, return_exceptions=True) + + successful = [] + failed = [] + for i, result in enumerate(results): + __, remote_meta = updates[i] + if isinstance(result, Exception) or result is None: + logger.error( + f"Failed to install update for {remote_meta.name}", + exc_info=result if isinstance(result, Exception) else None, + ) + failed.append(remote_meta) + else: + logger.info( + f"Successfully installed update for {remote_meta.name}" + ) + successful.append(remote_meta) + + num_success = len(successful) + num_failed = len(failed) + msg = "" + + if num_failed == 0 and num_success > 0: + msg = ngettext( + "Addon successfully updated.", + "{num} addons successfully updated.", + num_success, + ).format(num=num_success) + elif num_success > 0 and num_failed > 0: + msg = _("{num_s} addons updated, {num_f} failed.").format( + num_s=num_success, num_f=num_failed + ) + elif num_failed > 0 and num_success == 0: + msg = ngettext( + "Failed to update addon.", + "Failed to update {num} addons.", + num_failed, + ).format(num=num_failed) + + if msg: + self._task_mgr.schedule_on_main_thread( + self.notification_requested.send, + self, + message=msg, + ) + + if failed: + ctx.set_message( + _("Finished with {num_failed} errors.").format( + num_failed=len(failed) + ) + ) + else: + ctx.set_message(_("All addon updates installed!")) diff --git a/rayforge/app.py b/rayforge/app.py index 8617cb3ec..245ea780e 100644 --- a/rayforge/app.py +++ b/rayforge/app.py @@ -1,57 +1,667 @@ -import mimetypes +# flake8: noqa: E402 import argparse -import gi +import asyncio +import gettext +import locale +import logging +import mimetypes +import os +import sys +import traceback +import warnings +from gettext import gettext as _ +from pathlib import Path +from typing import cast + +# Parse --config early before any rayforge imports, as they may +# import config.py which computes CONFIG_DIR at module load time +for i, arg in enumerate(sys.argv): + if arg == "--config" and i + 1 < len(sys.argv): + os.environ["RAYFORGE_CONFIG_DIR"] = sys.argv[i + 1] + break + +from rayforge.logging_setup import setup_logging + +# =================================================================== +# SECTION 1: SAFE, MODULE-LEVEL SETUP +# This code will run for the main app AND all subprocesses. +# =================================================================== + +logger = logging.getLogger(__name__) +_unhandled_exception = False + + +# Suppress NumPy longdouble UserWarning when run under mingw on Windows +warnings.filterwarnings( + "ignore", + message="Signature.*for does not" + " match any known type", +) + +# Gettext MUST be initialized before importing app modules. +if hasattr(sys, "_MEIPASS"): + # In a PyInstaller bundle, the project root is in a temporary + # directory stored in sys._MEIPASS. + base_dir = Path(sys._MEIPASS) # type: ignore +else: + base_dir = Path(__file__).parent.parent + +# Set the locale from environment so Python's locale module (e.g. +# locale.format_string) respects LC_NUMERIC for decimal separators. +try: + locale.setlocale(locale.LC_ALL, "") +except locale.Error: + pass + + +# Read the language preference from the config file before initializing +# gettext. This avoids importing the full Config class (which would +# create circular dependencies at this early stage). None or missing +# means "use the system default language". +def _read_language_from_config() -> str | None: + import yaml + + from rayforge.config import CONFIG_FILE + + if not CONFIG_FILE.exists(): + return None + try: + with open(CONFIG_FILE, "r") as f: + data = yaml.safe_load(f) + if data and data.get("language"): + return data["language"] + except (OSError, yaml.YAMLError) as e: + logger.warning(f"Could not read language from config: {e}") + return None -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw # noqa: E402 -from .widgets.mainwindow import MainWindow # noqa: E402 -from .asyncloop import shutdown # noqa: E402 -from .config import config_mgr # noqa: E402 +_configured_language = _read_language_from_config() +if _configured_language: + os.environ["LANGUAGE"] = _configured_language -class App(Adw.Application): - def __init__(self, args): - super().__init__(application_id='com.barebaric.rayforge') - self.set_accels_for_action("win.quit", ["Q"]) - self.args = args +# Configure gettext with the locale directory +locale_dir = base_dir / "rayforge" / "locale" +gettext.bindtextdomain("rayforge", str(locale_dir)) +gettext.textdomain("rayforge") - def do_activate(self): - win = MainWindow(application=self) - if self.args.filename: - mime_type, _ = mimetypes.guess_type(self.args.filename) - win.load_file(self.args.filename, mime_type) - if self.args.dumpsurface: - win.doc.save_bitmap(self.args.dumpsurface, 10, 10) +# -------------------------------------------------------- +# GObject Introspection Repository (gi) +# -------------------------------------------------------- +# When running in a PyInstaller bundle, we need to set the GI_TYPELIB_PATH +# environment variable to point to the bundled typelib files. +if hasattr(sys, "_MEIPASS"): + if sys.platform == "darwin": + # macOS PyInstaller bundles use a Frameworks directory structure + # that requires specific environment variables for dynamic linking + # and GObject Introspection to work correctly. + frameworks_dir = Path(sys._MEIPASS).parent / "Frameworks" + bundled_typelibs = frameworks_dir / "gi_typelibs" + bundled_gio_modules = frameworks_dir / "gio_modules" + lib_path = str(frameworks_dir) + # DYLD_LIBRARY_PATH: Directories for dynamic linker to search + existing_dyld = os.environ.get("DYLD_LIBRARY_PATH") + os.environ["DYLD_LIBRARY_PATH"] = ( + lib_path if not existing_dyld else f"{lib_path}:{existing_dyld}" + ) + # DYLD_FALLBACK_LIBRARY_PATH: Fallback if DYLD_LIBRARY_PATH fails + os.environ.setdefault("DYLD_FALLBACK_LIBRARY_PATH", lib_path) + # GI_TYPELIB_PATH: Path to GObject Introspection typelib files + seen = set() + candidates = [] + for path in [bundled_typelibs]: + if path.exists(): + resolved = str(path.resolve()) + if resolved not in seen: + seen.add(resolved) + candidates.append(resolved) + if candidates: + os.environ["GI_TYPELIB_PATH"] = ":".join(candidates) + logger.info(f"GI_TYPELIB_PATH is {os.environ['GI_TYPELIB_PATH']}") + else: + logger.warning("No GI typelibs found for bundled build.") + # GIO_EXTRA_MODULES: Path to additional GIO modules + if bundled_gio_modules.exists(): + os.environ.setdefault( + "GIO_EXTRA_MODULES", str(bundled_gio_modules) + ) + else: + # Non-macOS platforms use the standard gi/repository structure + typelib_path = base_dir / "gi" / "repository" + logger.info(f"GI_TYPELIB_PATH is {typelib_path}") + os.environ["GI_TYPELIB_PATH"] = str(typelib_path) + files = [p.name for p in typelib_path.iterdir()] + logger.info(f"Files in typelib path: {files}") - win.present() + # On Windows, subprocesses need explicit DLL search path. + # This must be at module level to run during worker import. + if sys.platform == "win32": + logger.info( + f"Windows build detected. Adding '{base_dir}' " + "to DLL search path." + ) + try: + os.add_dll_directory(str(base_dir)) + except OSError: + pass - def do_shutdown(self): - shutdown() - Adw.Application.do_shutdown(self) + +def handle_exception(exc_type, exc_value, exc_traceback): + """ + Catches unhandled exceptions, logs them, and shows a user-friendly dialog. + This is crucial for --noconsole builds. + """ + global _unhandled_exception + + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + + _unhandled_exception = True + + # Print full traceback to stderr (console or log) + traceback.print_exception(exc_type, exc_value, exc_traceback) + + logger.error( + "Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback) + ) + logging.shutdown() def main(): + # =================================================================== + # SECTION 2: MAIN APPLICATION ENTRY POINT + # This function contains all logic that should ONLY run in the + # main process. + # =================================================================== + + global _unhandled_exception + + _unhandled_exception = False + + # Set the global exception handler. + sys.excepthook = handle_exception + + # We need Adw for the class definition, so this one import is okay here. + import gi + + gi.require_version("Adw", "1") + from gi.repository import Adw, Gio, GLib, Gtk + + if os.environ.get("SNAP"): + settings = Gtk.Settings.get_default() + if settings: + settings.set_property("gtk-icon-theme-name", "Adwaita") + + from rayforge.context import get_context + + class App(Adw.Application): + def __init__(self, args): + super().__init__(application_id="org.rayforge.rayforge") + from rayforge.ui_gtk.shared.keyboard import PRIMARY_ACCEL + + self.args = args + self.win = None + self._restart_requested = False + self._register_app_actions() + self.set_accels_for_action("app.quit", [f"{PRIMARY_ACCEL}q"]) + self.set_accels_for_action( + "app.preferences", [f"{PRIMARY_ACCEL}comma"] + ) + + def _register_app_actions(self): + action_specs = ( + ("about", self._on_app_about), + ("preferences", self._on_app_preferences), + ("quit", self._on_app_quit), + ) + for name, callback in action_specs: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def _get_main_window(self): + from rayforge.ui_gtk.mainwindow import MainWindow + + window = self.get_active_window() + if isinstance(window, MainWindow): + return window + if isinstance(self.win, MainWindow): + return self.win + return None + + def request_restart(self): + """Mark the app for restart and initiate shutdown. + + Closes the main window, which triggers the unsaved-changes + dialog if needed. After the main loop exits, the process + is re-executed with the same arguments so the new language + takes effect. + """ + self._restart_requested = True + if self.win is not None: + self.win.close() + else: + GLib.idle_add(self.quit) + + def _on_app_about(self, action, param): + window = self._get_main_window() + if window is None: + return + window.show_about_dialog(None, None) + + def _on_app_preferences(self, action, param): + window = self._get_main_window() + if window is None: + return + window.show_settings(None, None) + + def _on_app_quit(self, action, param): + logger.debug("_on_app_quit called.") + window = self._get_main_window() + if window is None: + self.quit() + return + window.on_quit_action(None, None) + + def do_shutdown(self): + logger.info("App.do_shutdown called.") + Adw.Application.do_shutdown(self) + logger.info("App.do_shutdown completed. Calling self.quit().") + self.quit() + + def do_activate(self): + if self.win is not None: + self.win.present() + return + + # Import the window here to avoid module-level side-effects + from rayforge.ui_gtk.mainwindow import MainWindow + + self.win = MainWindow(application=self) + + # Don't load files until the window is fully mapped and + # allocated on screen. The 'map' signal guarantees this. + if self.args.filenames: + # We connect a one-shot handler to the 'map' event. + self.win.connect("map", self._load_initial_files) + else: + # No files specified on command line, check config for + # startup behavior + self.win.connect("map", self._load_startup_files) + + if self.args.uiscript: + self.win.connect("map", self._run_uiscript) + + self.win.present() + + # Now that the UI is active, trigger the initial machine + # connections. + context = get_context() + if context.machine_mgr: + context.machine_mgr.initialize_connections() + + def _load_initial_files(self, widget): + """ + Loads files passed via the command line. This is called from the + 'map' signal handler to ensure the main window is fully + initialized. + Command line files always override the startup behavior setting. + """ + # These imports must be inside the method. + from rayforge.core.vectorization_spec import ( + PassthroughSpec, + TraceSpec, + ) + from rayforge.image import ImporterFeature + + assert self.win is not None + editor = self.win.doc_editor + + # self.args.filenames will be a list of paths + for filename in self.args.filenames: + file_path = Path(filename) + + if file_path.suffix.lower() == ".ryp": + self.win.load_project(file_path) + continue + + mime_type, __ = mimetypes.guess_type(file_path) + + importer_cls, features = editor.file.get_importer_info( + file_path, mime_type + ) + if not importer_cls: + logger.warning( + f"No importer found for '{file_path.name}'. Skipping." + ) + + editor.notification_requested.send( + self, + message=_( + "Cannot open '{file}'. The required addon " + "may be disabled." + ).format(file=file_path.name), + ) + continue + + vectorization_spec = None + if self.args.trace: + if ImporterFeature.BITMAP_TRACING not in features: + logger.error( + f"Error: The importer for '{file_path.name}' does " + "not support tracing." + ) + sys.exit(1) + vectorization_spec = TraceSpec() + elif self.args.vector: + if ImporterFeature.DIRECT_VECTOR not in features: + logger.warning( + f"Warning: The importer for '{file_path.name}' " + "may not support direct vector import." + ) + vectorization_spec = PassthroughSpec() + + # If no flag is passed, vectorization_spec remains None, + # allowing the importer to use its smart default. + editor.file.load_file_from_path( + filename=file_path, + mime_type=mime_type, + vectorization_spec=vectorization_spec, + ) + + if self.args.exit and not self.args.uiscript: + self._setup_exit_watch() + + return GLib.SOURCE_REMOVE + + def _setup_exit_watch(self): + """ + Arms the --exit watcher so the app quits once the editor + settles. Deferred until after the uiscript has started so the + script gets a chance to kick off the pipeline. + """ + get_context().exit_after_settle = True + assert self.win is not None + self.win.doc_editor.document_settled.connect( + self._on_document_settled_exit + ) + + def _on_document_settled_exit(self, sender): + ctx = get_context() + if ctx.exit_pending: + return + ctx.exit_pending = True + assert self.win is not None + if self.win.doc_editor.is_processing: + ctx.exit_pending = False + return + logger.info("Document settled, exiting due to --exit flag.") + self.quit() + + def quit_idle(self): + """Thread-safe quit for use from background threads.""" + GLib.idle_add(self.quit) + + def _run_uiscript(self, widget): + """Schedule UI script execution after window is mapped.""" + from rayforge.uiscript import run_script + + run_script(Path(self.args.uiscript), self, self.win) + + if self.args.exit: + self._setup_exit_watch() + + return GLib.SOURCE_REMOVE + + def _load_startup_files(self, widget): + """ + Loads files based on the startup behavior setting when no files + are specified on the command line. + """ + from rayforge.core.config import StartupBehavior + + assert self.win is not None + context = get_context() + config = context.config + + startup_behavior = config.startup_behavior + project_path = None + + if startup_behavior == StartupBehavior.LAST_PROJECT.value: + project_path = config.last_opened_project + elif startup_behavior == StartupBehavior.SPECIFIC_PROJECT.value: + project_path = config.startup_project_path + + if project_path and project_path.exists(): + if project_path.suffix.lower() == ".ryp": + logger.info(f"Loading startup project from {project_path}") + self.win.load_project(project_path) + else: + logger.warning( + f"Startup project path {project_path} " + "is not a .ryp file" + ) + elif project_path: + logger.warning( + f"Startup project path {project_path} does not exist" + ) + + if self.args.exit and not self.args.uiscript: + self._setup_exit_watch() + + return GLib.SOURCE_REMOVE + + # Import version for the --version flag. + from rayforge import __version__ + parser = argparse.ArgumentParser( - description="A GCode generator for laser cutters." + description=_("A GCode generator for laser cutters.") + ) + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + parser.add_argument( + "filenames", + help=_("Paths to one or more input SVG or image files."), + nargs="*", + ) + + # Create a mutually exclusive group for import mode flags + import_mode_group = parser.add_mutually_exclusive_group() + import_mode_group.add_argument( + "--vector", + action="store_true", + help=_( + "Force import as direct vectors. This is the default for " + "supported files." + ), + ) + import_mode_group.add_argument( + "--trace", + action="store_true", + help=_( + "Force import by tracing the file's bitmap representation. " + "Aborts if not supported." + ), ) + parser.add_argument( - "filename", - help="Path to the input SVG or image file.", - nargs='?' + "--loglevel", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help=_("Set the logging level (default: INFO)"), ) + + parser.add_argument( + "--exit", + action="store_true", + help=_( + "Exit after importing documents and the editor has settled. " + "Useful for testing." + ), + ) + parser.add_argument( - "--dumpsurface", - metavar="FILENAME", - help="Stores the work surface (no paths) as a PNG image.", - nargs='?' + "--uiscript", + metavar="SCRIPT", + help=_( + "Path to a Python script to execute after the main window " + "is fully loaded. Useful for automation and testing." + ), + ) + + parser.add_argument( + "--config", + metavar="DIR", + help=_( + "Path to a custom configuration directory. " + "Useful for testing with isolated configs." + ), ) args = parser.parse_args() + + # Set logging level based on the command-line argument. + setup_logging(args.loglevel) + logger.info(f"Application starting with log level {args.loglevel.upper()}") + + # =================================================================== + # SECTION 3: PLATFORM SPECIFIC INITIALIZATION + # =================================================================== + + # Set the PyOpenGL platform before importing anything that uses OpenGL. + # 'egl' is generally the best choice for GTK4 on modern Linux + # (Wayland/X11). + # On Windows and macOS, letting PyOpenGL auto-detect is more reliable. + if sys.platform.startswith("linux"): + logger.info("Linux detected. Setting PYOPENGL_PLATFORM=egl") + os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + + # Print PyCairo version + import cairo + + logger.info(f"PyCairo version: {cairo.version}") + + # Register the standalone 'cairo' module + # as a foreign type *before* the GObject-introspected cairo is loaded. + gi.require_foreign("cairo") + + # Now, when gi.repository.cairo is loaded, it will know how to + # interact with the already-imported standalone module. + gi.require_version("cairo", "1.0") + gi.require_version("Pango", "1.0") + gi.require_version("PangoCairo", "1.0") + gi.require_version("Gtk", "4.0") + gi.require_version("GdkPixbuf", "2.0") + + # Initialize the 3D canvas module to check for OpenGL availability. + # This must be done after setting the platform env var and after + # making Gtk available in gi, as the canvas uses Gtk. + # The rest of the app can now check + # `rayforge.ui_gtk.sim3d.initialized`. + # It is safe to import other modules that depend on canvas3d after this. + from rayforge.ui_gtk.sim3d import initialize + + initialize() + + # Import modules that depend on GTK or manage global state + import rayforge.shared.tasker + from rayforge.shared.tasker.manager import TaskManagerProxy + from rayforge.shared.util.glib import idle_add + from rayforge.worker_init import initialize_worker + + # Get the context first to ensure the ArtifactStore is created + # before the TaskManager is initialized. This breaks the circular + # dependency chain (app -> config -> machine -> task_manager). + get_context() + + # Initialize the TaskManager with the worker initializer. + # This MUST happen before accessing addon_mgr because the + # MachineManager creates machines which import task_mgr, which + # would trigger the creation of the TaskManager. + task_mgr_proxy = cast(TaskManagerProxy, rayforge.shared.tasker.task_mgr) + task_mgr_proxy.initialize( + worker_initializer=initialize_worker, + main_thread_scheduler=idle_add, + ) + + # Run application app = App(args) - app.run(None) - config_mgr.save() + exit_code = app.run(None) + logger.info("app.run() returned with exit_code=%s", exit_code) + if app.win is None: + if _unhandled_exception: + logger.error( + "Application startup failed before creating a window." + ) + return exit_code or 1 + logger.info( + "No window created (another instance is likely running). Exiting." + ) + return exit_code + + # =================================================================== + # SECTION 4: SHUTDOWN SEQUENCE + # =================================================================== + + logger.info("Application exiting.") + context = get_context() + + # 1. Define an async function to shut down high-level components. + async def shutdown_async(): + logger.info("Starting graceful async shutdown...") + # The context now handles shutting down all its owned managers + # (machine_mgr, camera_mgr, artifact_store) in the correct order. + await context.shutdown() + logger.info("Async shutdown complete.") + + # 2. Run the async shutdown on the TaskManager's event loop and + # wait for it. + loop = rayforge.shared.tasker.task_mgr.loop + if loop.is_running(): + logger.info(f"Running async shutdown on loop {loop}...") + future = asyncio.run_coroutine_threadsafe(shutdown_async(), loop) + try: + # Block until the async cleanup is finished. + future.result(timeout=10) + except Exception as e: # noqa: BLE001 - graceful shutdown boundary + logger.error(f"Error during graceful shutdown: {e}") + else: + logger.warning( + "Task manager loop not running, skipping async shutdown." + ) + + # 3. Save configuration. This happens AFTER async tasks are done. + logger.info("Saving configuration") + if context.config_mgr: + context.config_mgr.save() + logger.info("Saved config.") + else: + logger.info("No config manager to save.") + + # 4. As the final step, clean up the document editor, + # and shut down the task manager itself. + # The context shutdown (including artifact store) now happens in the async + # part above, so we only need to clean up the editor here. + logger.info("Cleaning up DocEditor") + app.win.doc_editor.cleanup() + logger.info("DocEditor cleaned up.") + + logger.info("Shutting down TaskManager") + rayforge.shared.tasker.task_mgr.shutdown() + logger.info("Task manager shut down.") + + # If the user requested a restart (e.g. after changing the + # language), re-exec the process with the same arguments. + if app._restart_requested: + logger.info("Restart requested, re-executing process.") + os.execv(sys.executable, [sys.executable] + sys.argv) + + return exit_code if __name__ == "__main__": - main() + from multiprocessing import freeze_support + + freeze_support() # needed to use multiprocessing in PyInstaller bundles + sys.exit(main()) diff --git a/rayforge/asyncloop.py b/rayforge/asyncloop.py deleted file mode 100644 index 8e22336bf..000000000 --- a/rayforge/asyncloop.py +++ /dev/null @@ -1,86 +0,0 @@ -import asyncio -import threading -from gi.repository import GLib -from collections import defaultdict -from typing import Optional, Callable - -_loop = asyncio.new_event_loop() -_shutdown = asyncio.Event() -_task_queue = asyncio.Queue() # Queue for managing tasks -_progress_callbacks = defaultdict(list) # Track progress callbacks by key - - -def _run_until_complete(): - _loop.run_until_complete(_shutdown.wait()) - - -async def _cancel_existing_tasks(key: str): - """Cancel all existing tasks associated with the given key.""" - if key is None: - return - for task in _progress_callbacks.get(key, []): - task.cancel() - _progress_callbacks[key].clear() - - -def _handle_task_result(fut, when_done: Optional[Callable]): - """Handle the result of a completed task.""" - try: - if when_done: - result = fut.result() - GLib.idle_add(when_done, result) - except (asyncio.CancelledError, Exception): - pass - - -def _cleanup_task(task, key: str): - """Clean up the task from the progress callbacks.""" - if key is None or key not in _progress_callbacks: - return - - if task in _progress_callbacks[key]: - _progress_callbacks[key].remove(task) - - if not _progress_callbacks[key]: - del _progress_callbacks[key] - - -def _handle_task_completion(task, when_done: Optional[Callable], key: str): - """Handle task completion and cleanup.""" - def _done_callback(fut): - _handle_task_result(fut, when_done) - _cleanup_task(task, key) - task.add_done_callback(_done_callback) - - -async def _worker(): - """Worker to process tasks from the queue.""" - while not _shutdown.is_set(): - key, coro, when_done = await _task_queue.get() - await _cancel_existing_tasks(key) - - task = asyncio.create_task(coro) - if key is not None: - _progress_callbacks[key].append(task) - - _handle_task_completion(task, when_done, key) - - -def run_async(coro, when_done: Optional[Callable] = None, key=None): - """Schedule a coroutine to run asynchronously.""" - _loop.call_soon_threadsafe(_task_queue.put_nowait, (key, coro, when_done)) - - -def shutdown(): - """Shutdown the event loop and cancel all tasks.""" - _loop.call_soon_threadsafe(_shutdown.set) - for tasks in _progress_callbacks.values(): - for task in tasks: - task.cancel() - _progress_callbacks.clear() - - -# Start the worker and event loop -_loop.create_task(_worker()) -thread = threading.Thread(target=_run_until_complete, daemon=True) -thread.start() diff --git a/rayforge/models/__init__.py b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/__init__.py similarity index 100% rename from rayforge/models/__init__.py rename to rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/__init__.py diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/controller.py b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/controller.py new file mode 100644 index 000000000..88c37f2a3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/controller.py @@ -0,0 +1,137 @@ +import asyncio +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Protocol, cast + +from raygeo.geo import Geometry +from raygeo.svg import svg_string_to_geometry + +from rayforge.core.asset_registry import asset_type_registry +from rayforge.shared.tasker import task_mgr + +from .generator import generate_svg + +if TYPE_CHECKING: + from rayforge.core.geometry_provider import IGeometryProvider + + +class SketchInstanceProtocol(Protocol): + """Protocol for Sketch instance with name attribute.""" + + name: str + + +class SketchClassProtocol(Protocol): + """Protocol for Sketch class with from_geometry classmethod.""" + + @classmethod + def from_geometry(cls, geometry: Geometry) -> SketchInstanceProtocol: + """Create a Sketch from Geometry.""" + ... + + +logger = logging.getLogger(__name__) + + +@dataclass +class GenerationResult: + """Result of AI generation with optional sketch conversion.""" + + sketch: Optional["IGeometryProvider"] = None + svg_content: str | None = None + geometry: Geometry | None = None + error: str | None = None + + +class AISvgGeneratorController: + """Controller for AI SVG generation - pure business logic.""" + + def __init__(self): + self._cancelled = False + + def generate( + self, + prompt: str, + on_success: Callable[[GenerationResult], None], + on_error: Callable[[str], None], + ) -> None: + """ + Generate SVG and attempt to convert to editable Sketch. + + Args: + prompt: The text prompt for SVG generation + on_success: Callback with GenerationResult containing sketch or + svg_content for fallback + on_error: Callback with error message + """ + self._cancelled = False + + async def do_generate(): + try: + svg_content, error = await generate_svg(prompt) + + if self._cancelled: + return + + if error: + task_mgr.schedule_on_main_thread(on_error, error) + return + + if not svg_content: + task_mgr.schedule_on_main_thread( + on_error, "Failed to generate SVG." + ) + return + + result = GenerationResult(svg_content=svg_content) + + try: + geometry = svg_string_to_geometry(svg_content, 1.0, 1.0) + result.geometry = geometry + + if not geometry.is_empty(): + sketch_cls = asset_type_registry.get("sketch") + if sketch_cls: + sketch = cast( + SketchClassProtocol, sketch_cls + ).from_geometry(geometry) + sketch.name = prompt[:50] + result.sketch = cast("IGeometryProvider", sketch) + logger.info( + "Successfully converted AI-generated SVG to " + "editable sketch" + ) + except (ValueError, TypeError) as e: + logger.warning( + "Failed to convert SVG to sketch: %s", e, exc_info=True + ) + + if self._cancelled: + return + + task_mgr.schedule_on_main_thread(on_success, result) + + except Exception as e: + logger.exception("Error in generation task") + if not self._cancelled: + task_mgr.schedule_on_main_thread(on_error, str(e)) + + future = asyncio.run_coroutine_threadsafe(do_generate(), task_mgr.loop) + + def on_done(f): + try: + f.result() + except Exception as e: # noqa: BLE001 - future callback boundary + logger.error("Generation future error: %s", e) + if not self._cancelled: + task_mgr.schedule_on_main_thread(on_error, str(e)) + + future.add_done_callback(on_done) + + def cancel(self) -> None: + """Cancel any ongoing generation.""" + self._cancelled = True + + +controller = AISvgGeneratorController() diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/frontend.py b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/frontend.py new file mode 100644 index 000000000..543423a81 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/frontend.py @@ -0,0 +1,36 @@ +from gettext import gettext as _ + +from gi.repository import Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement + +from .controller import controller +from .widgets import AIWorkpieceGeneratorDialog + +ADDON_NAME = "ai_workpiece_generator" + + +@hookimpl +def register_actions(action_registry): + """Register action for AI workpiece generation with menu placement.""" + action = Gio.SimpleAction.new("ai_generate_workpiece", None) + + def on_activate(action, param) -> None: + window = action_registry.window + editor = window.doc_editor + dialog = AIWorkpieceGeneratorDialog( + editor=editor, + controller=controller, + parent=window, + ) + dialog.present() + + action.connect("activate", on_activate) + action_registry.register( + action_name="ai_generate_workpiece", + action=action, + addon_name=ADDON_NAME, + label=_("Generate Workpiece with AI..."), + menu=MenuPlacement(menu_id="tools", priority=50), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/generator.py b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/generator.py new file mode 100644 index 000000000..c32b19bdb --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/generator.py @@ -0,0 +1,103 @@ +import logging +import re +from gettext import gettext as _ + +from rayforge.context import get_context +from rayforge.core.ai import AIServiceError +from rayforge.core.ai.provider import ChatMessage + +logger = logging.getLogger(__name__) + + +SYSTEM_PROMPT = """You are an expert SVG generator for laser cutting. +When asked to generate a design, output ONLY valid SVG code with no +explanations. + +Requirements: +- Output ONLY the SVG code, no markdown, no explanations, no code blocks +- ALWAYS include width and height attributes in millimeters + (e.g., width="50mm" height="50mm") +- Set width/height to match the requested physical dimensions +- Use a viewBox that matches the width/height values + (e.g., viewBox="0 0 50 50" for 50mm) +- All shapes must be closed paths suitable for laser cutting +- Use simple stroke="black" for cut lines + +Example output for a 50x50mm square: + + +""" + + +def extract_svg_from_response(content: str) -> str | None: + """Extract SVG code from AI response, handling various formats.""" + content = content.strip() + + if content.startswith("" in content: + return content[: content.index("") + 6] + return content + + code_block_match = re.search( + r"```(?:svg|xml)?\s*\n(.*?)\n```", content, re.DOTALL | re.IGNORECASE + ) + if code_block_match: + return code_block_match.group(1).strip() + + svg_match = re.search( + r"]*>.*?", content, re.DOTALL | re.IGNORECASE + ) + if svg_match: + return svg_match.group(0) + + return None + + +async def generate_svg(prompt: str) -> tuple[str | None, str | None]: + """ + Generate SVG from a text prompt using the configured AI provider. + + Returns: + Tuple of (svg_content, error_message) + """ + context = get_context() + ai_service = context.ai_service + + if not ai_service.get_provider(): + return None, _( + "No AI provider configured. " + "Please configure an AI provider in Settings." + ) + + messages = [ + ChatMessage(role="system", content=SYSTEM_PROMPT), + ChatMessage( + role="user", content=f"Generate an SVG design for: {prompt}" + ), + ] + + try: + response = await ai_service.chat(messages) + if not response: + return None, _("No response from AI provider.") + + svg_content = extract_svg_from_response(response.content) + if not svg_content: + logger.warning( + "AI response did not contain valid SVG: %s", + response.content[:200], + ) + return None, _( + "AI did not generate valid SVG code. " + "Please try a different prompt." + ) + + return svg_content, None + + except AIServiceError as e: + logger.error("AI service error generating SVG: %s", e) + return None, str(e) + except Exception as e: + logger.exception("Error generating SVG") + return None, str(e) diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/widgets/__init__.py b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/widgets/__init__.py new file mode 100644 index 000000000..6229c0dec --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/widgets/__init__.py @@ -0,0 +1,3 @@ +from .dialog import AIWorkpieceGeneratorDialog + +__all__ = ["AIWorkpieceGeneratorDialog"] diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/widgets/dialog.py b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/widgets/dialog.py new file mode 100644 index 000000000..6d6878d6d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/ai_workpiece_generator/widgets/dialog.py @@ -0,0 +1,281 @@ +import logging +import tempfile +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gdk, GLib, Gtk + +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ..controller import AISvgGeneratorController, GenerationResult + +logger = logging.getLogger(__name__) + + +class AIWorkpieceGeneratorDialog(PatchedDialogWindow): + """Dialog for generating workpieces using AI.""" + + def __init__( + self, + editor: "DocEditor", + controller: "AISvgGeneratorController", + parent=None, + ): + super().__init__(transient_for=parent) + self._editor = editor + self._controller = controller + self._generating = False + self._pulse_source_id = None + + self.set_title(_("Generate a Workpiece")) + self.set_default_size(800, 500) + + self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(self._main_box) + + toolbar_view = Adw.ToolbarView() + toolbar_view.set_vexpand(True) + self._main_box.append(toolbar_view) + + header_bar = Adw.HeaderBar() + toolbar_view.add_top_bar(header_bar) + + self._cancel_btn = Gtk.Button(label=_("Cancel")) + self._cancel_btn.connect("clicked", self._on_cancel_clicked) + header_bar.pack_start(self._cancel_btn) + + self._generate_btn = Gtk.Button(label=_("Generate")) + self._generate_btn.add_css_class("suggested-action") + self._generate_btn.connect("clicked", self._on_generate_clicked) + header_bar.pack_end(self._generate_btn) + + content_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + margin_start=12, + margin_end=12, + margin_top=12, + margin_bottom=12, + ) + toolbar_view.set_content(content_box) + + description = Gtk.Label( + label=_( + "Describe the design you want to create. " + "Example: 'A simple star shape with 5 points'" + ), + wrap=True, + xalign=0, + css_classes=["dim-label", "caption"], + margin_bottom=6, + ) + content_box.append(description) + + self._buffer = Gtk.TextBuffer() + self._text_view = Gtk.TextView( + buffer=self._buffer, + wrap_mode=Gtk.WrapMode.WORD_CHAR, + vexpand=True, + hexpand=True, + top_margin=6, + bottom_margin=6, + ) + self._text_view.add_css_class("card") + + frame = Gtk.Frame(child=self._text_view) + frame.set_vexpand(True) + frame.add_css_class("flat") + content_box.append(frame) + + self._error_label = Gtk.Label( + wrap=True, + css_classes=["error", "caption"], + margin_top=6, + visible=False, + ) + content_box.append(self._error_label) + + self._progress_bar = Gtk.ProgressBar( + hexpand=True, + valign=Gtk.Align.END, + visible=False, + ) + self._progress_bar.add_css_class("thin-progress-bar") + self._apply_progress_bar_style() + self._main_box.append(self._progress_bar) + + self._buffer.connect("changed", self._on_prompt_changed) + self._on_prompt_changed(self._buffer) + + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self._text_view.add_controller(key_controller) + + def _apply_progress_bar_style(self) -> None: + css_provider = Gtk.CssProvider() + css_provider.load_from_string( + """ + progressbar.thin-progress-bar { + min-height: 5px; + } + """ + ) + Gtk.StyleContext.add_provider_for_display( + self.get_display(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _start_pulse(self) -> None: + self._progress_bar.set_visible(True) + self._progress_bar.pulse() + self._pulse_source_id = GLib.timeout_add(100, self._on_pulse_timeout) + + def _stop_pulse(self) -> None: + if self._pulse_source_id: + GLib.source_remove(self._pulse_source_id) + self._pulse_source_id = None + self._progress_bar.set_visible(False) + + def _on_pulse_timeout(self) -> bool: + self._progress_bar.pulse() + return True + + def _on_prompt_changed(self, buffer) -> None: + text = buffer.get_text( + buffer.get_start_iter(), buffer.get_end_iter(), False + ) + self._generate_btn.set_sensitive( + len(text.strip()) > 0 and not self._generating + ) + + def _on_key_pressed(self, controller, keyval, keycode, state) -> bool: + if keyval == Gdk.KEY_Return and ( + state & Gdk.ModifierType.CONTROL_MASK + ): + if not self._generating and self.get_prompt(): + self._start_generation() + return True + return False + + def _on_cancel_clicked(self, button) -> None: + self._controller.cancel() + self.close() + + def _on_generate_clicked(self, button) -> None: + if self._generating: + return + self._start_generation() + + def _start_generation(self) -> None: + prompt = self.get_prompt() + if not prompt: + return + + self.set_generating(True) + + def on_success(result: "GenerationResult") -> None: + self._stop_pulse() + + if result.sketch: + self._add_sketch_workpiece(result.sketch) + elif result.svg_content: + self._import_svg_as_geometry(result.svg_content) + else: + self.set_generating(False) + self.set_error(_("No content generated.")) + return + + self.close() + + def on_error(message: str) -> None: + self._stop_pulse() + self.set_generating(False) + self.set_error(message) + + self._start_pulse() + self._controller.generate(prompt, on_success, on_error) + + def _add_sketch_workpiece(self, sketch) -> None: + """Add a sketch-based workpiece to the document.""" + self._editor.doc.add_asset(sketch) + + workpiece = WorkPiece.from_geometry_provider(sketch) + + machine_dims = self._editor.machine_dimensions + if machine_dims: + ws_width, ws_height = machine_dims + width, height = workpiece.natural_size + workpiece.pos = ( + ws_width / 2 - width / 2, + ws_height / 2 - height / 2, + ) + + target_layer = self._editor.default_workpiece_layer + with self._editor.history_manager.transaction( + _("Add AI-Generated Workpiece") + ) as t: + from rayforge.core.undo import ListItemCommand + + command = ListItemCommand( + owner_obj=target_layer, + item=workpiece, + undo_command="remove_child", + redo_command="add_child", + name=_("Add AI-Generated Workpiece"), + ) + t.execute(command) + + logger.info("Created editable sketch workpiece: %s", sketch.name) + + def _import_svg_as_geometry(self, svg_content: str) -> None: + """Fall back to importing SVG as non-editable geometry.""" + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".svg", + delete=False, + encoding="utf-8", + ) as temp_file: + temp_file.write(svg_content) + + machine_dims = self._editor.machine_dimensions + position_mm = None + if machine_dims: + ws_width, ws_height = machine_dims + position_mm = (ws_width / 2, ws_height / 2) + + self._editor.file.load_file_from_path( + filename=Path(temp_file.name), + mime_type="image/svg+xml", + vectorization_spec=PassthroughSpec(trim_padding=0), + position_mm=position_mm, + ) + + logger.info("Imported AI-generated SVG as geometry (contains beziers)") + + def get_prompt(self) -> str: + return self._buffer.get_text( + self._buffer.get_start_iter(), + self._buffer.get_end_iter(), + False, + ).strip() + + def set_error(self, message: str) -> None: + self._error_label.set_text(message) + self._error_label.set_visible(bool(message)) + + def set_generating(self, generating: bool) -> None: + self._generating = generating + self._generate_btn.set_sensitive(not generating) + self._cancel_btn.set_label( + _("Close") if not generating else _("Cancel") + ) + self._text_view.set_sensitive(not generating) + if not generating: + self.set_error("") diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/ai_workpiece_generator.pot b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/ai_workpiece_generator.pot new file mode 100644 index 000000000..f7214ad6e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/ai_workpiece_generator.pot @@ -0,0 +1,64 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "" + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "" + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/de/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/de/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..e882a2a53 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/de/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,70 @@ +# German translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "Ein Werkstück erstellen" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "Abbrechen" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "Erstellen" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" +"Beschreibe das Design, das du erstellen möchtest. Beispiel: 'Eine einfache " +"Sternform mit 5 Spitzen'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "Kein Inhalt generiert." + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "KI-generiertes Werkstück hinzufügen" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "Schließen" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "Werkstück mit KI erstellen..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" +"Kein KI-Anbieter konfiguriert. Bitte konfiguriere einen KI-Anbieter in den " +"Einstellungen." + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "Keine Antwort vom KI-Anbieter." + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "" +"Die KI hat keinen gültigen SVG-Code generiert. Bitte versuche einen anderen " +"Prompt." diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/en/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/en/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..cdef84782 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/en/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,67 @@ +# English translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "Generate a Workpiece" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "Cancel" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "Generate" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "Close" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "Generate Workpiece with AI..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" +"No AI provider configured. Please configure an AI provider in Settings." + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "No response from AI provider." + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "AI did not generate valid SVG code. Please try a different prompt." diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/es/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/es/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..3b72598aa --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/es/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,69 @@ +# Spanish translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "Generar una pieza de trabajo" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "Cancelar" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "Generar" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" +"Describe el diseño que quieres crear. Ejemplo: 'Una forma de estrella simple " +"con 5 puntas'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "No se generó contenido." + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "Añadir pieza generada por IA" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "Cerrar" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "Generar pieza de trabajo con IA..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" +"No hay proveedor de IA configurado. Por favor configura un proveedor de IA " +"en Configuración." + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "Sin respuesta del proveedor de IA." + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "" +"La IA no generó código SVG válido. Por favor intenta con un prompt diferente." diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/fr/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/fr/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..5ff61837d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/fr/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,69 @@ +# French translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "Générer une pièce" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "Annuler" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "Générer" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" +"Décrivez le design que vous souhaitez créer. Exemple : 'Une forme d'étoile " +"simple avec 5 pointes'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "Aucun contenu généré." + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "Ajouter une pièce générée par l'IA" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "Fermer" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "Générer une pièce avec l'IA..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" +"Aucun fournisseur d'IA configuré. Veuillez configurer un fournisseur d'IA " +"dans les Paramètres." + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "Pas de réponse du fournisseur d'IA." + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "" +"L'IA n'a pas généré de code SVG valide. Veuillez essayer une autre invite." diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/pt/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/pt/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..199d71037 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/pt/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,68 @@ +# Portuguese translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "Gerar uma peça de trabalho" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "Cancelar" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "Gerar" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" +"Descreva o design que você deseja criar. Exemplo: 'Uma forma de estrela " +"simples com 5 pontas'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "Nenhum conteúdo gerado." + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "Adicionar Peça Gerada por IA" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "Fechar" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "Gerar peça de trabalho com IA..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" +"Nenhum provedor de IA configurado. Por favor configure um provedor de IA nas " +"Configurações." + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "Sem resposta do provedor de IA." + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "A IA não gerou código SVG válido. Por favor tente um prompt diferente." diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/uk/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/uk/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..18664e482 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/uk/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,69 @@ +# Ukrainian translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "Створити деталь" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "Скасувати" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "Створити" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "" +"Опишіть дизайн, який ви хочете створити. Приклад: 'Проста форма зірки з 5 " +"променями'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "Вміст не згенеровано." + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "Додати деталь, згенеровану ШІ" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "Закрити" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "Створити деталь за допомогою ШІ..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "" +"Не налаштовано провайдер ШІ. Будь ласка, налаштуйте провайдер ШІ у " +"Налаштуваннях." + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "Немає відповіді від провайдера ШІ." + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "ШІ не згенерував дійсний SVG-код. Спробуйте інший запит." diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/zh_CN/LC_MESSAGES/ai_workpiece_generator.po b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/zh_CN/LC_MESSAGES/ai_workpiece_generator.po new file mode 100644 index 000000000..974ae43f7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/locale/zh_CN/LC_MESSAGES/ai_workpiece_generator.po @@ -0,0 +1,63 @@ +# Chinese translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-30 19:31+0200\n" +"PO-Revision-Date: 2026-03-07 20:16+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate a Workpiece" +msgstr "生成工件" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Cancel" +msgstr "取消" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Generate" +msgstr "生成" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "" +"Describe the design you want to create. Example: 'A simple star shape with 5 " +"points'" +msgstr "描述您想要创建的设计。例如:'一个简单的5角星形'" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "No content generated." +msgstr "未生成内容。" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Add AI-Generated Workpiece" +msgstr "添加AI生成的工件" + +#: ai_workpiece_generator/widgets/dialog.py +msgid "Close" +msgstr "关闭" + +#: ai_workpiece_generator/frontend.py +msgid "Generate Workpiece with AI..." +msgstr "使用AI生成工件..." + +#: ai_workpiece_generator/generator.py +msgid "No AI provider configured. Please configure an AI provider in Settings." +msgstr "未配置AI提供商。请在设置中配置AI提供商。" + +#: ai_workpiece_generator/generator.py +msgid "No response from AI provider." +msgstr "AI提供商无响应。" + +#: ai_workpiece_generator/generator.py +msgid "AI did not generate valid SVG code. Please try a different prompt." +msgstr "AI未生成有效的SVG代码。请尝试不同的提示。" diff --git a/rayforge/builtin_addons/rayforge-addon-ai-workpiece/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/rayforge-addon.yaml new file mode 100644 index 000000000..c5b7fa892 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-ai-workpiece/rayforge-addon.yaml @@ -0,0 +1,13 @@ +name: ai_workpiece_generator +display_name: "AI Workpiece Generator" +description: "Generate workpieces using AI directly within Rayforge" +api_version: 18 +requires: + - sketcher +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + frontend: "ai_workpiece_generator.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/__init__.py new file mode 100644 index 000000000..19cbb5393 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/__init__.py @@ -0,0 +1,5 @@ +""" +CNC Essentials addon. + +Provides step classes and UI for CNC machining operations. +""" diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py new file mode 100644 index 000000000..5006df550 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py @@ -0,0 +1,3 @@ +""" +CNC Commands (planned for Phase 5). +""" diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py new file mode 100644 index 000000000..fbbee48ed --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py @@ -0,0 +1,3 @@ +""" +Auto-plan command (planned for Phase 5). +""" diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/frontend.py new file mode 100644 index 000000000..cd26b8b38 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/frontend.py @@ -0,0 +1,20 @@ +""" +Frontend entry point for cnc-essentials addon. + +Registers UI widgets with the main application. +""" + +from rayforge.core.hooks import hookimpl + +from .widgets import ASSEMBLER_WIDGETS + +ADDON_NAME = "cnc_essentials" + + +@hookimpl +def register_step_settings_pages(step_settings_page_registry): + """Register step settings page classes based on assembler name.""" + for assembler_name, page_cls in ASSEMBLER_WIDGETS.items(): + step_settings_page_registry.register( + assembler_name, page_cls, ADDON_NAME + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/__init__.py new file mode 100644 index 000000000..34d7c8d72 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/__init__.py @@ -0,0 +1,27 @@ +""" +CNC steps. + +Provides step implementations for CNC milling operations. +""" + +from .adaptive_clearing_step import AdaptiveClearStep +from .cnc_assembler_step import CncAssemblerStep +from .flat_spiral_step import FlatSpiralStep +from .helix_plunge_step import HelixPlungeStep +from .profile_inner_step import ProfileInnerStep +from .profile_outer_step import ProfileOuterStep +from .ramp_entry_step import RampEntryStep +from .slot_step import SlotStep +from .toroidal_clear_step import ToroidalClearStep + +__all__ = [ + "AdaptiveClearStep", + "CncAssemblerStep", + "FlatSpiralStep", + "HelixPlungeStep", + "ProfileInnerStep", + "ProfileOuterStep", + "RampEntryStep", + "SlotStep", + "ToroidalClearStep", +] diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/adaptive_clearing_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/adaptive_clearing_step.py new file mode 100644 index 000000000..1829d2ca3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/adaptive_clearing_step.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, cast + +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly.adaptive import AdaptiveClearingSpec +from raygeo.ops.part import Part + +from rayforge.core.varset import FloatVar, LengthVar, VarSet + +from .cnc_assembler_step import CncAssemblerStep + +if TYPE_CHECKING: + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + +class AdaptiveClearStep(CncAssemblerStep): + ASSEMBLER_NAME = "adaptive_clearing" # matches PlanStep.kind + TYPELABEL = _("Adaptive Clear") + uses_global_state = True # consumes predecessor cleared-area + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *CncAssemblerStep.recipe_varset().vars, + LengthVar( + key="step_over", + label=_("Step Over"), + default=2.0, + min_val=0.1, + ), + LengthVar( + key="step_length", + label=_("Step Length"), + default=0.6, + min_val=0.1, + ), + FloatVar( + key="max_deflection_deg", + label=_("Max Deflection"), + default=30.0, + min_val=0.0, + max_val=90.0, + ), + LengthVar( + key="wall_margin", + label=_("Wall Margin"), + default=0.0, + min_val=0.0, + ), + FloatVar( + key="area_tolerance", + label=_("Area Tolerance"), + default=1.0, + min_val=0.0, + ), + ] + ) + + def __init__(self, name=None, typelabel=None): + super().__init__(name=name, typelabel=typelabel) + self.step_over: float = 2.0 + self.step_length: float = 0.6 + self.max_deflection_deg: float = 30.0 + self.wall_margin: float = 0.0 + self.area_tolerance: float = 1.0 + + def build_spec(self, workpiece) -> AdaptiveClearingSpec: + return AdaptiveClearingSpec( + tool_radius=self.tool_diameter / 2, + step_over=self.step_over, + step_length=self.step_length, + target_z=self.target_depth, + safe_z=self.safe_z, + max_deflection_deg=self.max_deflection_deg, + wall_margin=self.wall_margin, + area_tolerance=self.area_tolerance, + ) + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + # Multi-pocket handling is done on the Rust side: + # Part.from_geometry_multi_face exposes each pocket as a face, + # the compute stage iterates them, and AdaptiveClearingSpec + # splits each pocket into regions via find_regions. No + # Python-side seeding needed. + return super().build_compute_payload(machine, workpiece) + + def assembler_token_params(self, machine, workpiece) -> dict[str, Any]: + params = super().assembler_token_params(machine, workpiece) + params.update( + { + "step_over": self.step_over, + "step_length": self.step_length, + "max_deflection_deg": self.max_deflection_deg, + "wall_margin": self.wall_margin, + "area_tolerance": self.area_tolerance, + } + ) + return params + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "step_over": self.step_over, + "step_length": self.step_length, + "max_deflection_deg": self.max_deflection_deg, + "wall_margin": self.wall_margin, + "area_tolerance": self.area_tolerance, + } + ) + return result + + @classmethod + def from_dict(cls, data) -> AdaptiveClearStep: + step = cast("AdaptiveClearStep", super().from_dict(data)) + step.step_over = data.get("step_over", step.step_over) + step.step_length = data.get("step_length", step.step_length) + step.max_deflection_deg = data.get( + "max_deflection_deg", step.max_deflection_deg + ) + step.wall_margin = data.get("wall_margin", step.wall_margin) + step.area_tolerance = data.get("area_tolerance", step.area_tolerance) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset( + { + "step_over", + "step_length", + "max_deflection_deg", + "wall_margin", + "area_tolerance", + } + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/cnc_assembler_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/cnc_assembler_step.py new file mode 100644 index 000000000..86cf6ce3a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/cnc_assembler_step.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, ClassVar, cast + +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.part import Part + +from rayforge.core.capability import MachineCapability +from rayforge.core.step import Step +from rayforge.core.varset import IntVar, LengthVar, SpeedVar, VarSet +from rayforge.machine.models.spindle import SpindleHead + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + +class CncAssemblerStep(Step): + """Base class for CNC assembler-driven steps. + + Subclasses set ``ASSEMBLER_NAME`` and override + ``build_spec`` to construct the matching raygeo spec. + """ + + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.MILL}) + TYPELABEL = "CNC Step" + + @property + def show_general_settings(self) -> bool: + return False + + # Phase 3 turns this on for steps that consume predecessor state. + uses_global_state: ClassVar[bool] = False + + def __init__(self, name=None, typelabel=None): + self.tool_diameter: float = 6.0 + self.spindle_rpm: int = 12000 + self.plunge_speed: int = 200 + self.target_depth: float = -5.0 + self.depth_per_pass: float = 1.0 + self.safe_z: float = 2.0 + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + + @classmethod + def create( + cls, + context: RayforgeContext, + name=None, + **kwargs, + ) -> CncAssemblerStep: + machine = context.machine + step = cls(name=name) + step.per_workpiece_transformers_dicts = [] + step.per_step_transformers_dicts = [] + if machine is not None: + default_head = machine.get_default_head() + step.selected_head_uid = default_head.uid + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + else: + step.selected_head_uid = None + return step + + def set_tool_diameter(self, diameter: float): + if self.tool_diameter != diameter: + self.tool_diameter = float(diameter) + self.updated.send(self) + + def set_spindle_rpm(self, rpm: int): + if self.spindle_rpm != rpm: + self.spindle_rpm = int(rpm) + self.updated.send(self) + + def set_plunge_speed(self, speed: int): + if self.plunge_speed != speed: + self.plunge_speed = int(speed) + self.updated.send(self) + + def set_target_depth(self, depth: float): + if self.target_depth != depth: + self.target_depth = float(depth) + self.updated.send(self) + + def set_depth_per_pass(self, depth: float): + if self.depth_per_pass != depth: + self.depth_per_pass = float(depth) + self.updated.send(self) + + def set_safe_z(self, z: float): + if self.safe_z != z: + self.safe_z = float(z) + self.updated.send(self) + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "tool_diameter": self.tool_diameter, + "spindle_rpm": self.spindle_rpm, + "plunge_speed": self.plunge_speed, + "target_depth": self.target_depth, + "depth_per_pass": self.depth_per_pass, + "safe_z": self.safe_z, + } + ) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CncAssemblerStep: + step = cast("CncAssemblerStep", super().from_dict(data)) + step.tool_diameter = data.get("tool_diameter", step.tool_diameter) + step.spindle_rpm = data.get("spindle_rpm", step.spindle_rpm) + step.plunge_speed = data.get("plunge_speed", step.plunge_speed) + step.target_depth = data.get("target_depth", step.target_depth) + step.depth_per_pass = data.get("depth_per_pass", step.depth_per_pass) + step.safe_z = data.get("safe_z", step.safe_z) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset( + { + "tool_diameter", + "spindle_rpm", + "plunge_speed", + "target_depth", + "depth_per_pass", + "safe_z", + } + ) + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + LengthVar( + key="tool_diameter", + label=_("Tool Diameter"), + default=6.0, + min_val=0.1, + max_val=50.0, + ), + IntVar( + key="spindle_rpm", + label=_("Spindle RPM"), + default=12000, + min_val=100, + max_val=60000, + ), + *Step.recipe_varset().vars, + SpeedVar( + key="plunge_speed", + label=_("Plunge Rate"), + default=200, + min_val=1, + role="cut", + ), + LengthVar( + key="target_depth", + label=_("Target Depth"), + default=-5.0, + min_val=-50.0, + max_val=0.0, + ), + LengthVar( + key="depth_per_pass", + label=_("Depth per Pass"), + default=1.0, + min_val=0.1, + max_val=10.0, + ), + LengthVar( + key="safe_z", + label=_("Safe Z Height"), + default=2.0, + min_val=0.0, + max_val=50.0, + ), + ] + ) + + @classmethod + def recipe_varset_groups(cls) -> list[tuple[str, VarSet]]: + full = cls.recipe_varset() + base_keys = {v.key for v in CncAssemblerStep.recipe_varset()} + cnc_vars = [v for v in full if v.key in base_keys] + step_vars = [v for v in full if v.key not in base_keys] + groups: list[tuple[str, VarSet]] = [] + if cnc_vars: + groups.append((_("CNC"), VarSet(vars=cnc_vars))) + if step_vars: + groups.append((_("Step Settings"), VarSet(vars=step_vars))) + return groups or [(_("CNC"), VarSet(vars=cnc_vars))] + + def build_spec(self, workpiece: WorkPiece) -> object: + """Return the raygeo assembler spec for this step. + + Subclasses must override. + """ + raise NotImplementedError + + def populate_payload(self, payload, machine: Machine): + super().populate_payload(payload, machine) + # The renderer colours ops by power and treats zero as a "no cut" + # state. Express the spindle's power level as the fraction of its + # max RPM so a running spindle renders as a cut at the right intensity. + payload.power = self._spindle_power_fraction(machine) + + def _spindle_power_fraction(self, machine: Machine) -> float: + """CNC power level in ``[0, 1]`` from the spindle's RPM ratio.""" + head = self.get_selected_head(machine) + if isinstance(head, SpindleHead): + return min(1.0, self.spindle_rpm / head.max_rpm) + return 1.0 + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + part = workpiece.to_part() + if part is None: + part = Part(size_mm=workpiece.size) + spec = self.build_spec(workpiece) + return part, ComputePayload( + assembler=Assembler(spec), + cut_speed=self.cut_speed, + ) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict[str, Any]: + """Return a JSON-serialisable view of ``build_spec``'s params. + + Default implementation reflects the common CNC attributes; + subclasses extend it with their own. + """ + return { + "tool_diameter": self.tool_diameter, + "target_depth": self.target_depth, + "depth_per_pass": self.depth_per_pass, + "safe_z": self.safe_z, + } diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/flat_spiral_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/flat_spiral_step.py new file mode 100644 index 000000000..20ba8cea1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/flat_spiral_step.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import Any + +from raygeo.ops.assembly.spiral import SpiralSpec + +from .cnc_assembler_step import CncAssemblerStep + + +class FlatSpiralStep(CncAssemblerStep): + ASSEMBLER_NAME = "spiral" + TYPELABEL = _("Flat Spiral") + + def build_spec(self, workpiece) -> SpiralSpec: + part = workpiece.to_part() + if part is not None: + sr = part.stock_region + cx = sum(p[0] for p in sr.boundary) / len(sr.boundary) + cy = sum(p[1] for p in sr.boundary) / len(sr.boundary) + max_r = max( + ((p[0] - cx) ** 2 + (p[1] - cy) ** 2) ** 0.5 + for p in sr.boundary + ) + else: + cx = workpiece.size[0] / 2.0 + cy = workpiece.size[1] / 2.0 + max_r = min(cx, cy) * 0.8 + return SpiralSpec( + center=(cx, cy), + z=self.target_depth, + start_radius=self.tool_diameter / 2.0 * 1.5, + end_radius=max_r * 0.9, + revolutions=3.0, + ) + + def assembler_token_params(self, machine, workpiece) -> dict[str, Any]: + return { + "tool_diameter": self.tool_diameter, + "target_depth": self.target_depth, + } diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/helix_plunge_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/helix_plunge_step.py new file mode 100644 index 000000000..5d0e0ab50 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/helix_plunge_step.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import Any + +from raygeo.ops.assembly.helix import HelixSpec + +from .cnc_assembler_step import CncAssemblerStep + + +class HelixPlungeStep(CncAssemblerStep): + ASSEMBLER_NAME = "helix" + TYPELABEL = _("Helix Plunge") + + def build_spec(self, workpiece) -> HelixSpec: + part = workpiece.to_part() + if part is not None: + sr = part.stock_region + cx = sum(p[0] for p in sr.boundary) / len(sr.boundary) + cy = sum(p[1] for p in sr.boundary) / len(sr.boundary) + else: + cx = workpiece.size[0] / 2.0 + cy = workpiece.size[1] / 2.0 + return HelixSpec( + center=(cx, cy), + start_radius=self.tool_diameter / 2.0 * 1.5, + z_start=0.0, + z_end=self.target_depth, + pitch=abs(self.target_depth / max(self.depth_per_pass, 0.1)), + ) + + def assembler_token_params(self, machine, workpiece) -> dict[str, Any]: + return { + "tool_diameter": self.tool_diameter, + "target_depth": self.target_depth, + "depth_per_pass": self.depth_per_pass, + } diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/profile_inner_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/profile_inner_step.py new file mode 100644 index 000000000..8655ae873 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/profile_inner_step.py @@ -0,0 +1,86 @@ +from gettext import gettext as _ +from typing import Any, cast + +from raygeo.ops.assembly.profile import ProfileSpec + +from rayforge.core.varset import LengthVar, VarSet + +from .cnc_assembler_step import CncAssemblerStep + + +class ProfileInnerStep(CncAssemblerStep): + ASSEMBLER_NAME = "profile_inner" + TYPELABEL = _("Profile Inner") + uses_global_state = True + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *CncAssemblerStep.recipe_varset().vars, + LengthVar( + key="step_over", + label=_("Step Over"), + default=2.0, + min_val=0.1, + ), + LengthVar( + key="step_length", + label=_("Step Length"), + default=0.6, + min_val=0.1, + ), + LengthVar( + key="wall_margin", + label=_("Wall Margin"), + default=0.0, + min_val=0.0, + ), + ] + ) + + def __init__(self, name=None, typelabel=None): + super().__init__(name=name, typelabel=typelabel) + self.step_over: float = 2.0 + self.step_length: float = 0.6 + self.wall_margin: float = 0.0 + + def build_spec(self, workpiece) -> ProfileSpec: + return ProfileSpec( + kind="inner", + tool_radius=self.tool_diameter / 2.0, + step_over=self.step_over, + step_length=self.step_length, + target_z=self.target_depth, + safe_z=self.safe_z, + wall_margin=self.wall_margin, + ) + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "step_over": self.step_over, + "step_length": self.step_length, + "wall_margin": self.wall_margin, + } + ) + return result + + @classmethod + def from_dict(cls, data) -> "ProfileInnerStep": + step = cast("ProfileInnerStep", super().from_dict(data)) + step.step_over = data.get("step_over", step.step_over) + step.step_length = data.get("step_length", step.step_length) + step.wall_margin = data.get("wall_margin", step.wall_margin) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset( + { + "step_over", + "step_length", + "wall_margin", + } + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/profile_outer_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/profile_outer_step.py new file mode 100644 index 000000000..24d73b34b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/profile_outer_step.py @@ -0,0 +1,86 @@ +from gettext import gettext as _ +from typing import Any, cast + +from raygeo.ops.assembly.profile import ProfileSpec + +from rayforge.core.varset import LengthVar, VarSet + +from .cnc_assembler_step import CncAssemblerStep + + +class ProfileOuterStep(CncAssemblerStep): + ASSEMBLER_NAME = "profile_outer" + TYPELABEL = _("Profile Outer") + uses_global_state = True + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *CncAssemblerStep.recipe_varset().vars, + LengthVar( + key="step_over", + label=_("Step Over"), + default=2.0, + min_val=0.1, + ), + LengthVar( + key="step_length", + label=_("Step Length"), + default=0.6, + min_val=0.1, + ), + LengthVar( + key="wall_margin", + label=_("Wall Margin"), + default=0.0, + min_val=0.0, + ), + ] + ) + + def __init__(self, name=None, typelabel=None): + super().__init__(name=name, typelabel=typelabel) + self.step_over: float = 2.0 + self.step_length: float = 0.6 + self.wall_margin: float = 0.0 + + def build_spec(self, workpiece) -> ProfileSpec: + return ProfileSpec( + kind="outer", + tool_radius=self.tool_diameter / 2.0, + step_over=self.step_over, + step_length=self.step_length, + target_z=self.target_depth, + safe_z=self.safe_z, + wall_margin=self.wall_margin, + ) + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "step_over": self.step_over, + "step_length": self.step_length, + "wall_margin": self.wall_margin, + } + ) + return result + + @classmethod + def from_dict(cls, data) -> "ProfileOuterStep": + step = cast("ProfileOuterStep", super().from_dict(data)) + step.step_over = data.get("step_over", step.step_over) + step.step_length = data.get("step_length", step.step_length) + step.wall_margin = data.get("wall_margin", step.wall_margin) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset( + { + "step_over", + "step_length", + "wall_margin", + } + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/ramp_entry_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/ramp_entry_step.py new file mode 100644 index 000000000..7dab1f3fd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/ramp_entry_step.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import Any + +from raygeo.ops.assembly.ramp import RampSpec + +from .cnc_assembler_step import CncAssemblerStep + + +class RampEntryStep(CncAssemblerStep): + ASSEMBLER_NAME = "ramp" + TYPELABEL = _("Ramp Entry") + + def build_spec(self, workpiece) -> RampSpec: + part = workpiece.to_part() + if part is not None: + sr = part.stock_region + cx = sum(p[0] for p in sr.boundary) / len(sr.boundary) + cy = sum(p[1] for p in sr.boundary) / len(sr.boundary) + else: + cx = workpiece.size[0] / 2.0 + cy = workpiece.size[1] / 2.0 + r = self.tool_diameter / 2.0 + return RampSpec( + start=(cx - r, cy), + end=(cx + r, cy), + z_start=0.0, + z_end=self.target_depth, + ) + + def assembler_token_params(self, machine, workpiece) -> dict[str, Any]: + return { + "tool_diameter": self.tool_diameter, + "target_depth": self.target_depth, + } diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/slot_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/slot_step.py new file mode 100644 index 000000000..9dd74f639 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/slot_step.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import Any + +from raygeo.ops.assembly.slot import SlotSpec + +from .cnc_assembler_step import CncAssemblerStep + + +class SlotStep(CncAssemblerStep): + ASSEMBLER_NAME = "slot" + TYPELABEL = _("Slot") + + def build_spec(self, workpiece) -> SlotSpec: + part = workpiece.to_part() + if part is not None: + sr = part.stock_region + cx = sum(p[0] for p in sr.boundary) / len(sr.boundary) + cy = sum(p[1] for p in sr.boundary) / len(sr.boundary) + else: + cx = workpiece.size[0] / 2.0 + cy = workpiece.size[1] / 2.0 + half_len = self.tool_diameter * 2 + return SlotSpec( + carrier=[(cx - half_len, cy), (cx + half_len, cy)], + tool_radius=self.tool_diameter / 2.0, + target_z=self.target_depth, + ) + + def assembler_token_params(self, machine, workpiece) -> dict[str, Any]: + return { + "tool_diameter": self.tool_diameter, + "target_depth": self.target_depth, + } diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/toroidal_clear_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/toroidal_clear_step.py new file mode 100644 index 000000000..b262cf0fd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/steps/toroidal_clear_step.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import Any, cast + +from raygeo.ops.assembly.toroid import ToroidalClearSpec + +from rayforge.core.varset import LengthVar, VarSet + +from .cnc_assembler_step import CncAssemblerStep + + +class ToroidalClearStep(CncAssemblerStep): + ASSEMBLER_NAME = "toroidal_clear" + TYPELABEL = _("Toroidal Clear") + uses_global_state = True + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *CncAssemblerStep.recipe_varset().vars, + LengthVar( + key="step_over", + label=_("Step Over"), + default=2.0, + min_val=0.1, + ), + ] + ) + + def __init__(self, name=None, typelabel=None): + super().__init__(name=name, typelabel=typelabel) + self.step_over: float = 2.0 + + def build_spec(self, workpiece) -> ToroidalClearSpec: + part = workpiece.to_part() + if part is not None: + sr = part.stock_region + cx = sum(p[0] for p in sr.boundary) / len(sr.boundary) + cy = sum(p[1] for p in sr.boundary) / len(sr.boundary) + else: + cx = workpiece.size[0] / 2.0 + cy = workpiece.size[1] / 2.0 + r = self.tool_diameter / 2.0 * 1.5 + carrier = [(cx, cy), (cx + self.step_over * 2, cy)] + return ToroidalClearSpec( + carrier=carrier, + start=(cx, cy, 0.0), + target_z=self.target_depth, + tool_radius=r, + step_over=self.step_over, + ) + + def assembler_token_params(self, machine, workpiece) -> dict[str, Any]: + return { + "tool_diameter": self.tool_diameter, + "target_depth": self.target_depth, + "depth_per_pass": self.depth_per_pass, + "step_over": self.step_over, + } + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result["step_over"] = self.step_over + return result + + @classmethod + def from_dict(cls, data) -> ToroidalClearStep: + step = cast("ToroidalClearStep", super().from_dict(data)) + step.step_over = data.get("step_over", step.step_over) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset({"step_over"}) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/__init__.py new file mode 100644 index 000000000..86fe08c44 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/__init__.py @@ -0,0 +1,33 @@ +""" +CNC Essentials UI Widgets. +""" + +from .pages import ( + AdaptiveClearPage, + HelixPlungePage, + ProfileInnerPage, + ProfileOuterPage, + SlotPage, + ToroidalClearPage, +) + +ASSEMBLER_WIDGETS = { + "adaptive_clearing": AdaptiveClearPage, + "helix": HelixPlungePage, + "spiral": HelixPlungePage, + "ramp": HelixPlungePage, + "toroidal_clear": ToroidalClearPage, + "slot": SlotPage, + "profile_inner": ProfileInnerPage, + "profile_outer": ProfileOuterPage, +} + +__all__ = [ + "ASSEMBLER_WIDGETS", + "AdaptiveClearPage", + "HelixPlungePage", + "ProfileInnerPage", + "ProfileOuterPage", + "SlotPage", + "ToroidalClearPage", +] diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/__init__.py new file mode 100644 index 000000000..4a2dc4951 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/__init__.py @@ -0,0 +1,19 @@ +"""CNC step settings pages.""" + +from .adaptive_clear_page import AdaptiveClearPage +from .cnc_step_page import CncStepSettingsPage +from .helix_plunge_page import HelixPlungePage +from .profile_inner_page import ProfileInnerPage +from .profile_outer_page import ProfileOuterPage +from .slot_page import SlotPage +from .toroidal_clear_page import ToroidalClearPage + +__all__ = [ + "AdaptiveClearPage", + "CncStepSettingsPage", + "HelixPlungePage", + "ProfileInnerPage", + "ProfileOuterPage", + "SlotPage", + "ToroidalClearPage", +] diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/adaptive_clear_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/adaptive_clear_page.py new file mode 100644 index 000000000..0ba2464e7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/adaptive_clear_page.py @@ -0,0 +1,34 @@ +"""Adaptive clearing step settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..rows import ( + AreaToleranceRow, + MaxDeflectionRow, + StepLengthRow, + StepOverRow, + WallMarginRow, +) +from .cnc_step_page import CncStepSettingsPage + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class AdaptiveClearPage(CncStepSettingsPage): + """Settings page for the adaptive clearing step.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__(editor, step) + self.add_section( + _("Adaptive Clearing"), + StepOverRow, + StepLengthRow, + MaxDeflectionRow, + WallMarginRow, + AreaToleranceRow, + description=_("Rough out a pocket with adaptive passes."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/cnc_step_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/cnc_step_page.py new file mode 100644 index 000000000..9772825a9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/cnc_step_page.py @@ -0,0 +1,53 @@ +"""CNC step settings widget base.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.pages import StepSettingsPage +from rayforge.ui_gtk.doceditor.step_settings.rows import ( + CutSpeedRow, + TravelSpeedRow, +) + +from ..rows.depth_per_pass_row import DepthPerPassRow +from ..rows.plunge_speed_row import PlungeSpeedRow +from ..rows.safe_z_row import SafeZRow +from ..rows.spindle_rpm_row import SpindleRpmRow +from ..rows.target_depth_row import TargetDepthRow +from ..rows.tool_diameter_row import ToolDiameterRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class CncStepSettingsPage(StepSettingsPage): + """Base page for CNC step settings. + + Adds the common CNC sections (spindle, depth, feed). Subclasses + add their step-specific sections. + """ + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__(editor, step) + self.add_section( + _("Spindle"), + SpindleRpmRow, + ToolDiameterRow, + description=_("Spindle speed and tool geometry."), + ) + self.add_section( + _("Depth"), + TargetDepthRow, + DepthPerPassRow, + SafeZRow, + description=_("Cut depth, depth per pass, and safe height."), + ) + self.add_section( + _("Feed"), + CutSpeedRow(editor, step, title=_("Feed Rate")), + TravelSpeedRow, + PlungeSpeedRow, + description=_("Cutting, plunging, and travel feed rates."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/helix_plunge_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/helix_plunge_page.py new file mode 100644 index 000000000..987072be9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/helix_plunge_page.py @@ -0,0 +1,10 @@ +"""Helix/ramp/spiral step settings page.""" + +from .cnc_step_page import CncStepSettingsPage + + +class HelixPlungePage(CncStepSettingsPage): + """Settings page for helix/ramp/spiral steps. + + All parameters live on the common CNC sections; no extra rows. + """ diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/profile_inner_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/profile_inner_page.py new file mode 100644 index 000000000..ee776b3a9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/profile_inner_page.py @@ -0,0 +1,26 @@ +"""Inner profiling step settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..rows import StepLengthRow, StepOverRow, WallMarginRow +from .cnc_step_page import CncStepSettingsPage + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class ProfileInnerPage(CncStepSettingsPage): + """Settings page for the inner profiling step.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__(editor, step) + self.add_section( + _("Profiling"), + StepOverRow, + StepLengthRow, + WallMarginRow, + description=_("Cut the interior profile of the workpiece."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/profile_outer_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/profile_outer_page.py new file mode 100644 index 000000000..8d1ea17a0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/profile_outer_page.py @@ -0,0 +1,26 @@ +"""Outer profiling step settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..rows import StepLengthRow, StepOverRow, WallMarginRow +from .cnc_step_page import CncStepSettingsPage + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class ProfileOuterPage(CncStepSettingsPage): + """Settings page for the outer profiling step.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__(editor, step) + self.add_section( + _("Profiling"), + StepOverRow, + StepLengthRow, + WallMarginRow, + description=_("Cut the exterior profile of the workpiece."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/slot_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/slot_page.py new file mode 100644 index 000000000..fc778d030 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/slot_page.py @@ -0,0 +1,10 @@ +"""Slot step settings page.""" + +from .cnc_step_page import CncStepSettingsPage + + +class SlotPage(CncStepSettingsPage): + """Settings page for the slot step. + + All parameters live on the common CNC sections; no extra rows. + """ diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/toroidal_clear_page.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/toroidal_clear_page.py new file mode 100644 index 000000000..c432efb9d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/pages/toroidal_clear_page.py @@ -0,0 +1,24 @@ +"""Toroidal clearing step settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..rows import StepOverRow +from .cnc_step_page import CncStepSettingsPage + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class ToroidalClearPage(CncStepSettingsPage): + """Settings page for the toroidal clearing step.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__(editor, step) + self.add_section( + _("Clearing"), + StepOverRow, + description=_("Clear a pocket with concentric passes."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/__init__.py new file mode 100644 index 000000000..36640fb01 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/__init__.py @@ -0,0 +1,27 @@ +"""CNC-domain row widgets.""" + +from .area_tolerance_row import AreaToleranceRow +from .depth_per_pass_row import DepthPerPassRow +from .max_deflection_row import MaxDeflectionRow +from .plunge_speed_row import PlungeSpeedRow +from .safe_z_row import SafeZRow +from .spindle_rpm_row import SpindleRpmRow +from .step_length_row import StepLengthRow +from .step_over_row import StepOverRow +from .target_depth_row import TargetDepthRow +from .tool_diameter_row import ToolDiameterRow +from .wall_margin_row import WallMarginRow + +__all__ = [ + "AreaToleranceRow", + "DepthPerPassRow", + "MaxDeflectionRow", + "PlungeSpeedRow", + "SafeZRow", + "SpindleRpmRow", + "StepLengthRow", + "StepOverRow", + "TargetDepthRow", + "ToolDiameterRow", + "WallMarginRow", +] diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/area_tolerance_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/area_tolerance_row.py new file mode 100644 index 000000000..92a97165c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/area_tolerance_row.py @@ -0,0 +1,28 @@ +"""CNC area-tolerance row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class AreaToleranceRow(SpinRow): + """A spin row bound to the step's ``area_tolerance`` attribute.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "area_tolerance", + _("Area Tolerance"), + _("Stopping tolerance in mm²"), + 0.01, + 5.0, + 0.01, + 2, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/depth_per_pass_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/depth_per_pass_row.py new file mode 100644 index 000000000..680e8e1d8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/depth_per_pass_row.py @@ -0,0 +1,29 @@ +"""CNC depth-per-pass row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class DepthPerPassRow(SpinRow): + """A spin row bound to ``CncAssemblerStep.depth_per_pass``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "depth_per_pass", + _("Depth per Pass"), + _("Depth removed by each pass"), + 0.1, + 10.0, + 0.1, + 2, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/max_deflection_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/max_deflection_row.py new file mode 100644 index 000000000..82f4f0fa2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/max_deflection_row.py @@ -0,0 +1,39 @@ +"""CNC max-deflection row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow +from rayforge.ui_gtk.shared.pref_rows import AngleSpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class MaxDeflectionRow(SpinRow): + """A spin row bound to the step's ``max_deflection_deg``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "max_deflection_deg", + _("Max Deflection"), + _("Max steering deflection per step (degrees)"), + 1.0, + 60.0, + 1.0, + 0, + is_int=True, + ) + + def build_widget(self): + return AngleSpinRow( + self._title, + self._subtitle, + lower=self._lower, + upper=self._upper, + digits=self._digits, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/plunge_speed_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/plunge_speed_row.py new file mode 100644 index 000000000..8983555cc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/plunge_speed_row.py @@ -0,0 +1,34 @@ +"""CNC plunge speed row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class PlungeSpeedRow(SpinRow): + """A spin row bound to ``CncAssemblerStep.plunge_speed``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "plunge_speed", + _("Plunge Rate"), + _("Vertical feed rate"), + 1.0, + float(step.max_cut_speed), + 10.0, + 0, + is_int=True, + quantity="speed", + ) + + def _sync_dependencies(self): + if self.step.max_cut_speed: + self.set_range(1.0, float(self.step.max_cut_speed)) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/safe_z_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/safe_z_row.py new file mode 100644 index 000000000..a6cdea8c8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/safe_z_row.py @@ -0,0 +1,29 @@ +"""CNC safe-Z row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class SafeZRow(SpinRow): + """A spin row bound to ``CncAssemblerStep.safe_z``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "safe_z", + _("Safe Z Height"), + _("Height to retract between moves"), + 0.0, + 50.0, + 0.1, + 2, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/spindle_rpm_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/spindle_rpm_row.py new file mode 100644 index 000000000..ba792071d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/spindle_rpm_row.py @@ -0,0 +1,29 @@ +"""CNC spindle RPM row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class SpindleRpmRow(SpinRow): + """A spin row bound to ``CncAssemblerStep.spindle_rpm``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "spindle_rpm", + _("Spindle RPM"), + None, + 100, + 60000, + 100, + 0, + is_int=True, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/step_length_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/step_length_row.py new file mode 100644 index 000000000..1f0a90ca6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/step_length_row.py @@ -0,0 +1,29 @@ +"""CNC step-length row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class StepLengthRow(SpinRow): + """A spin row bound to the step's ``step_length`` attribute.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "step_length", + _("Step Length"), + _("Forward step length"), + 0.1, + 5.0, + 0.1, + 1, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/step_over_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/step_over_row.py new file mode 100644 index 000000000..2c0ac5364 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/step_over_row.py @@ -0,0 +1,29 @@ +"""CNC step-over row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class StepOverRow(SpinRow): + """A spin row bound to the step's ``step_over`` attribute.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "step_over", + _("Step Over"), + _("Lateral step-over between passes"), + 0.1, + 25.0, + 0.1, + 1, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/target_depth_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/target_depth_row.py new file mode 100644 index 000000000..4ed377167 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/target_depth_row.py @@ -0,0 +1,29 @@ +"""CNC target depth row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class TargetDepthRow(SpinRow): + """A spin row bound to ``CncAssemblerStep.target_depth``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "target_depth", + _("Target Depth"), + _("Final depth of the cut (negative is downward)"), + -50.0, + 0.0, + 0.1, + 2, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/tool_diameter_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/tool_diameter_row.py new file mode 100644 index 000000000..1fb49f143 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/tool_diameter_row.py @@ -0,0 +1,29 @@ +"""CNC tool diameter row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class ToolDiameterRow(SpinRow): + """A spin row bound to ``CncAssemblerStep.tool_diameter``.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "tool_diameter", + _("Tool Diameter"), + _("Diameter of the cutting tool"), + 0.1, + 50.0, + 0.1, + 2, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/wall_margin_row.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/wall_margin_row.py new file mode 100644 index 000000000..8947d5fa0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/widgets/rows/wall_margin_row.py @@ -0,0 +1,29 @@ +"""CNC wall-margin row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ...steps.cnc_assembler_step import CncAssemblerStep + + +class WallMarginRow(SpinRow): + """A spin row bound to the step's ``wall_margin`` attribute.""" + + def __init__(self, editor: "DocEditor", step: "CncAssemblerStep"): + super().__init__( + editor, + step, + "wall_margin", + _("Wall Margin"), + _("Extra clearance from the pocket wall"), + 0.0, + 10.0, + 0.1, + 1, + quantity="length", + ) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/worker.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/worker.py new file mode 100644 index 000000000..176172572 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/worker.py @@ -0,0 +1,33 @@ +""" +Backend entry point for cnc-essentials addon. + +Registers steps with the main application. +""" + +from rayforge.core.hooks import hookimpl + +from .steps import ( + AdaptiveClearStep, + FlatSpiralStep, + HelixPlungeStep, + ProfileInnerStep, + ProfileOuterStep, + RampEntryStep, + SlotStep, + ToroidalClearStep, +) + +ADDON_NAME = "cnc_essentials" + + +@hookimpl +def register_steps(step_registry): + """Register CNC steps with the step registry.""" + step_registry.register(AdaptiveClearStep, addon_name=ADDON_NAME) + step_registry.register(HelixPlungeStep, addon_name=ADDON_NAME) + step_registry.register(FlatSpiralStep, addon_name=ADDON_NAME) + step_registry.register(RampEntryStep, addon_name=ADDON_NAME) + step_registry.register(ToroidalClearStep, addon_name=ADDON_NAME) + step_registry.register(SlotStep, addon_name=ADDON_NAME) + step_registry.register(ProfileInnerStep, addon_name=ADDON_NAME) + step_registry.register(ProfileOuterStep, addon_name=ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-cnc/rayforge-addon.yaml new file mode 100644 index 000000000..17e0c22fe --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/rayforge-addon.yaml @@ -0,0 +1,16 @@ +name: cnc_essentials +display_name: "CNC Essentials" +description: "CNC machining operations: adaptive clearing, profiling, slotting" +api_version: 20 +requires: + - post_processors +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "cnc_essentials.worker" + frontend: "cnc_essentials.frontend" +license: + name: "MIT" +default_state: "disabled" +maturity: experimental diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/conftest.py new file mode 100644 index 000000000..c11b30fe2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/conftest.py @@ -0,0 +1,53 @@ +""" +Pytest configuration for cnc_essentials builtin addon tests. + +This conftest ensures that steps are registered with the step registry +before tests run, mirroring the laser_essentials addon conftest. +""" + +from unittest.mock import MagicMock + +import pytest +from cnc_essentials.steps import ( + AdaptiveClearStep, + FlatSpiralStep, + HelixPlungeStep, + ProfileInnerStep, + ProfileOuterStep, + RampEntryStep, + SlotStep, + ToroidalClearStep, +) + +from rayforge.core.step_registry import step_registry +from rayforge.machine.models.spindle import SpindleHead + + +@pytest.fixture +def machine(): + """A machine with a spindle head for CNC steps.""" + m = MagicMock() + m.heads = [SpindleHead()] + return m + + +def _register_steps(): + """Register all steps from cnc_essentials addon.""" + step_registry.register(AdaptiveClearStep, addon_name="cnc_essentials") + step_registry.register(HelixPlungeStep, addon_name="cnc_essentials") + step_registry.register(FlatSpiralStep, addon_name="cnc_essentials") + step_registry.register(RampEntryStep, addon_name="cnc_essentials") + step_registry.register(ToroidalClearStep, addon_name="cnc_essentials") + step_registry.register(SlotStep, addon_name="cnc_essentials") + step_registry.register(ProfileInnerStep, addon_name="cnc_essentials") + step_registry.register(ProfileOuterStep, addon_name="cnc_essentials") + + +@pytest.fixture(scope="session", autouse=True) +def register_cnc_essentials(): + """ + Automatically register cnc_essentials steps for all tests in this + addon. + """ + _register_steps() + yield diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_adaptive_clearing_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_adaptive_clearing_step.py new file mode 100644 index 000000000..686aa90ae --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_adaptive_clearing_step.py @@ -0,0 +1,97 @@ +"""Tests for the adaptive-clearing CNC step.""" + +import pytest + +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def adaptive_clear_step(): + from cnc_essentials.steps import AdaptiveClearStep + + step = AdaptiveClearStep(name="adaptive_clear") + step.tool_diameter = 6.0 + step.step_over = 2.0 + step.step_length = 0.6 + step.max_deflection_deg = 30.0 + step.wall_margin = 0.0 + step.area_tolerance = 1.0 + step.target_depth = -5.0 + step.safe_z = 2.0 + return step + + +class TestAdaptiveClearSpec: + def test_build_spec_returns_adaptive_clearing_spec( + self, adaptive_clear_step + ): + from raygeo.ops.assembly.adaptive import AdaptiveClearingSpec + + wp = WorkPiece(name="wp") + wp.set_size(60.0, 60.0) + + spec = adaptive_clear_step.build_spec(wp) + + assert isinstance(spec, AdaptiveClearingSpec) + assert spec.tool_radius == 3.0 + assert spec.step_over == 2.0 + assert spec.step_length == 0.6 + assert spec.max_deflection_deg == 30.0 + assert spec.wall_margin == 0.0 + assert spec.area_tolerance == 1.0 + assert spec.target_z == -5.0 + assert spec.safe_z == 2.0 + + def test_build_compute_payload_returns_part_and_payload( + self, adaptive_clear_step, machine + ): + from raygeo.cnc.execution.specs import ComputePayload + from raygeo.ops.assembly import Assembler + from raygeo.ops.assembly.adaptive import AdaptiveClearingSpec + from raygeo.ops.part import Part + + wp = WorkPiece(name="wp") + wp.set_size(60.0, 60.0) + + part, payload = adaptive_clear_step.build_compute_payload(machine, wp) + + assert isinstance(part, Part) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, AdaptiveClearingSpec) + assert spec.tool_radius == 3.0 + + def test_populate_payload_stamps_spindle_power( + self, adaptive_clear_step, machine + ): + """CNC payloads express power as the spindle's RPM / max RPM + ratio, so a running spindle renders as a cut at the right + intensity rather than the zero-power (no-cut) colour.""" + adaptive_clear_step.spindle_rpm = 15000 + wp = WorkPiece(name="wp") + wp.set_size(60.0, 60.0) + + _part, payload = adaptive_clear_step.build_compute_payload(machine, wp) + adaptive_clear_step.populate_payload(payload, machine) + + assert payload.power == 0.75 # 15000 / 20000 (fixture spindle max) + assert payload.head_uid is not None + + def test_assembler_token_params_keys_and_values( + self, adaptive_clear_step, machine + ): + wp = WorkPiece(name="wp") + wp.set_size(60.0, 60.0) + + token = adaptive_clear_step.assembler_token_params(machine, wp) + + assert token["step_over"] == 2.0 + assert token["step_length"] == 0.6 + assert token["max_deflection_deg"] == 30.0 + assert token["wall_margin"] == 0.0 + assert token["area_tolerance"] == 1.0 + # Base CNC attributes are included too. + assert token["tool_diameter"] == 6.0 + assert token["target_depth"] == -5.0 + assert token["safe_z"] == 2.0 diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_cnc_assembler_step.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_cnc_assembler_step.py new file mode 100644 index 000000000..9807c6316 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_cnc_assembler_step.py @@ -0,0 +1,195 @@ +"""Tests for the base CNC assembler step setters.""" + +from unittest.mock import MagicMock + +import pytest +from cnc_essentials.steps import ( + AdaptiveClearStep, + ProfileOuterStep, + ToroidalClearStep, +) +from cnc_essentials.steps.cnc_assembler_step import CncAssemblerStep + +from rayforge.core.step import Step + + +@pytest.fixture +def cnc_step(): + return ProfileOuterStep(name="profile_outer") + + +@pytest.mark.parametrize( + "attr, value, expected", + [ + ("tool_diameter", 8.0, 8.0), + ("spindle_rpm", 15000, 15000), + ("plunge_speed", 300, 300), + ("target_depth", -3.5, -3.5), + ("depth_per_pass", 0.5, 0.5), + ("safe_z", 5.0, 5.0), + ], +) +def test_setters_update_attribute_and_signal(cnc_step, attr, value, expected): + handler = MagicMock() + cnc_step.updated.connect(handler) + + setter = getattr(cnc_step, f"set_{attr}") + setter(value) + + assert getattr(cnc_step, attr) == expected + handler.assert_called_once_with(cnc_step) + + +@pytest.mark.parametrize( + "attr", + [ + "tool_diameter", + "spindle_rpm", + "plunge_speed", + "target_depth", + "depth_per_pass", + "safe_z", + ], +) +def test_setters_no_signal_on_same_value(cnc_step, attr): + handler = MagicMock() + cnc_step.updated.connect(handler) + + getattr(cnc_step, f"set_{attr}")(getattr(cnc_step, attr)) + + handler.assert_not_called() + + +def test_int_setters_coerce_values(cnc_step): + cnc_step.set_spindle_rpm(15000.9) + cnc_step.set_plunge_speed(300.9) + assert cnc_step.spindle_rpm == 15000 + assert cnc_step.plunge_speed == 300 + + +def test_float_setters_coerce_values(cnc_step): + cnc_step.set_tool_diameter(8) + assert isinstance(cnc_step.tool_diameter, float) + assert cnc_step.tool_diameter == 8.0 + + +def test_all_recipe_keys_have_setters(cnc_step): + for var in CncAssemblerStep.recipe_varset(): + assert hasattr(cnc_step, f"set_{var.key}"), var.key + + +BASE_CNC_KEYS = ( + "tool_diameter", + "spindle_rpm", + "plunge_speed", + "target_depth", + "depth_per_pass", + "safe_z", +) + +PROFILE_KEYS = ("step_over", "step_length", "wall_margin") + + +class TestCncSerialization: + def test_base_cnc_attrs_round_trip(self): + step = ProfileOuterStep(name="profile_outer") + step.tool_diameter = 8.0 + step.spindle_rpm = 15000 + step.plunge_speed = 300 + step.target_depth = -3.5 + step.depth_per_pass = 0.5 + step.safe_z = 5.0 + + data = step.to_dict() + restored = ProfileOuterStep.from_dict(data) + + assert restored.tool_diameter == 8.0 + assert restored.spindle_rpm == 15000 + assert restored.plunge_speed == 300 + assert restored.target_depth == -3.5 + assert restored.depth_per_pass == 0.5 + assert restored.safe_z == 5.0 + + def test_step_specific_attrs_round_trip(self): + step = ProfileOuterStep(name="profile_outer") + step.step_over = 3.0 + step.step_length = 0.8 + step.wall_margin = 0.5 + + data = step.to_dict() + restored = ProfileOuterStep.from_dict(data) + + assert restored.step_over == 3.0 + assert restored.step_length == 0.8 + assert restored.wall_margin == 0.5 + + def test_adaptive_clear_attrs_round_trip(self): + step = AdaptiveClearStep(name="adaptive_clear") + step.step_over = 2.5 + step.step_length = 0.7 + step.max_deflection_deg = 25.0 + step.wall_margin = 0.2 + step.area_tolerance = 0.5 + + data = step.to_dict() + restored = AdaptiveClearStep.from_dict(data) + + assert restored.step_over == 2.5 + assert restored.step_length == 0.7 + assert restored.max_deflection_deg == 25.0 + assert restored.wall_margin == 0.2 + assert restored.area_tolerance == 0.5 + + def test_toroidal_clear_attrs_round_trip(self): + step = ToroidalClearStep(name="toroidal_clear") + step.step_over = 3.5 + + data = step.to_dict() + restored = ToroidalClearStep.from_dict(data) + + assert restored.step_over == 3.5 + + def test_cnc_attrs_not_stashed_in_extra(self): + step = ProfileOuterStep(name="profile_outer") + data = step.to_dict() + restored = ProfileOuterStep.from_dict(data) + + for key in BASE_CNC_KEYS + PROFILE_KEYS: + assert key not in restored.extra + + def test_old_files_without_cnc_keys_load_defaults(self): + step = ProfileOuterStep(name="profile_outer") + data = step.to_dict() + for key in BASE_CNC_KEYS: + data.pop(key) + + restored = ProfileOuterStep.from_dict(data) + + assert restored.tool_diameter == 6.0 + assert restored.spindle_rpm == 12000 + assert restored.plunge_speed == 200 + assert restored.target_depth == -5.0 + assert restored.depth_per_pass == 1.0 + assert restored.safe_z == 2.0 + + def test_unknown_keys_preserved_in_extra(self): + step = ProfileOuterStep(name="profile_outer") + data = step.to_dict() + data["future_field"] = "future value" + + restored = ProfileOuterStep.from_dict(data) + + assert restored.extra["future_field"] == "future value" + re_serialized = restored.to_dict() + assert re_serialized["future_field"] == "future value" + + def test_dispatch_via_base_step_from_dict(self): + step = ProfileOuterStep(name="profile_outer") + step.step_over = 3.0 + data = step.to_dict() + + restored = Step.from_dict(data) + + assert isinstance(restored, ProfileOuterStep) + assert restored.step_over == 3.0 + assert restored.tool_diameter == step.tool_diameter diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_cnc_recipe_keys.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_cnc_recipe_keys.py new file mode 100644 index 000000000..e91da51db --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/steps/test_cnc_recipe_keys.py @@ -0,0 +1,156 @@ +"""Tests for step-declared recipe keys and recipe varsets. + +Verifies that each step's recipe_keys() is composed correctly through +the inheritance hierarchy and that recipe_varset() exposes the keys +needed by the recipe editor. +""" + +from cnc_essentials.steps import ( + AdaptiveClearStep, + FlatSpiralStep, + HelixPlungeStep, + ProfileInnerStep, + ProfileOuterStep, + RampEntryStep, + SlotStep, + ToroidalClearStep, +) +from cnc_essentials.steps.cnc_assembler_step import CncAssemblerStep + +from rayforge.core.step import Step + + +class TestRecipeKeys: + """recipe_keys() composition through the step hierarchy.""" + + def test_base_step_keys(self): + assert "cut_speed" in Step.recipe_keys() + assert "travel_speed" in Step.recipe_keys() + + def test_cnc_step_extends_base(self): + assert set(Step.recipe_keys()).issubset( + set(CncAssemblerStep.recipe_keys()) + ) + for key in ( + "tool_diameter", + "spindle_rpm", + "plunge_speed", + "target_depth", + "depth_per_pass", + "safe_z", + ): + assert key in CncAssemblerStep.recipe_keys() + + def test_adaptive_clear_extends_cnc(self): + assert set(CncAssemblerStep.recipe_keys()).issubset( + set(AdaptiveClearStep.recipe_keys()) + ) + for key in ( + "step_over", + "step_length", + "max_deflection_deg", + "wall_margin", + "area_tolerance", + ): + assert key in AdaptiveClearStep.recipe_keys() + + def test_profile_inner_extends_cnc(self): + assert set(CncAssemblerStep.recipe_keys()).issubset( + set(ProfileInnerStep.recipe_keys()) + ) + for key in ("step_over", "step_length", "wall_margin"): + assert key in ProfileInnerStep.recipe_keys() + + def test_profile_outer_extends_cnc(self): + assert set(CncAssemblerStep.recipe_keys()).issubset( + set(ProfileOuterStep.recipe_keys()) + ) + for key in ("step_over", "step_length", "wall_margin"): + assert key in ProfileOuterStep.recipe_keys() + + def test_toroidal_clear_extends_cnc(self): + assert set(CncAssemblerStep.recipe_keys()).issubset( + set(ToroidalClearStep.recipe_keys()) + ) + assert "step_over" in ToroidalClearStep.recipe_keys() + + def test_simple_steps_inherit_cnc_keys(self): + """Steps without extra attrs inherit CncAssemblerStep keys.""" + for cls in ( + FlatSpiralStep, + HelixPlungeStep, + RampEntryStep, + SlotStep, + ): + assert cls.recipe_keys() == CncAssemblerStep.recipe_keys() + + +class TestRecipeVarsetKeys: + """recipe_varset() keys are consistent with recipe_keys(). + + The CNC domain varset covers all process keys but not + ``selected_head_uid`` (same as the base Step pattern). + """ + + def test_cnc_step_varset(self): + keys = [var.key for var in CncAssemblerStep.recipe_varset()] + for key in CncAssemblerStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_adaptive_clear_varset_covers_keys(self): + keys = [var.key for var in AdaptiveClearStep.recipe_varset()] + for key in AdaptiveClearStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_profile_inner_varset_covers_keys(self): + keys = [var.key for var in ProfileInnerStep.recipe_varset()] + for key in ProfileInnerStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_profile_outer_varset_covers_keys(self): + keys = [var.key for var in ProfileOuterStep.recipe_varset()] + for key in ProfileOuterStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_toroidal_clear_varset_covers_keys(self): + keys = [var.key for var in ToroidalClearStep.recipe_varset()] + for key in ToroidalClearStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + +class TestRecipeVarsetGroups: + """recipe_varset_groups() splits into CNC and Step Settings.""" + + def test_cnc_base_single_group(self): + groups = CncAssemblerStep.recipe_varset_groups() + assert len(groups) == 1 + assert groups[0][0] == "CNC" + + def test_adaptive_clear_splits(self): + groups = AdaptiveClearStep.recipe_varset_groups() + assert len(groups) == 2 + titles = [g[0] for g in groups] + assert "CNC" in titles + assert "Step Settings" in titles + + def test_profile_inner_splits(self): + groups = ProfileInnerStep.recipe_varset_groups() + assert len(groups) == 2 + + def test_profile_outer_splits(self): + groups = ProfileOuterStep.recipe_varset_groups() + assert len(groups) == 2 + + def test_toroidal_clear_splits(self): + groups = ToroidalClearStep.recipe_varset_groups() + assert len(groups) == 2 + + def test_simple_steps_single_group(self): + for cls in ( + FlatSpiralStep, + HelixPlungeStep, + RampEntryStep, + SlotStep, + ): + groups = cls.recipe_varset_groups() + assert len(groups) == 1 diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/conftest.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/conftest.py new file mode 100644 index 000000000..f26df1134 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/conftest.py @@ -0,0 +1,76 @@ +"""UI fixtures for cnc_essentials page tests.""" + +import asyncio +import logging + +import pytest + +from rayforge import config as config_module +from rayforge import context as context_module +from rayforge.context import get_context +from rayforge.doceditor.editor import DocEditor +from rayforge.machine.models.machine import Machine +from rayforge.machine.models.spindle import SpindleHead +from rayforge.shared import tasker +from rayforge.shared.tasker.manager import TaskManager +from rayforge.shared.util.glib import idle_add + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def ui_task_mgr(): + """A test-isolated TaskManager for sync UI tests.""" + tm = TaskManager(main_thread_scheduler=idle_add) + yield tm + if tm.has_tasks(): + logger.warning( + "Task manager still has tasks at end of test. Shutting down." + ) + tm.shutdown() + + +@pytest.fixture +def ui_context(ui_task_mgr, monkeypatch, tmp_path): + """A UI context for CNC addon tests.""" + temp_config_dir = tmp_path / "config" + temp_dialect_dir = temp_config_dir / "dialects" + temp_machine_dir = temp_config_dir / "machines" + temp_addons_dir = temp_config_dir / "addons" + monkeypatch.setattr(config_module, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(config_module, "DIALECT_DIR", temp_dialect_dir) + monkeypatch.setattr(config_module, "MACHINE_DIR", temp_machine_dir) + monkeypatch.setattr(config_module, "ADDONS_DIR", temp_addons_dir) + monkeypatch.setattr(tasker.task_mgr, "_instance", ui_task_mgr) + + context = get_context() + yield context + + asyncio.run(context.shutdown()) + context_module._context_instance = None + + +@pytest.fixture +def editor(ui_context, ui_task_mgr): + editor = DocEditor(task_manager=ui_task_mgr, context=ui_context) + yield editor + editor.cleanup() + + +@pytest.fixture +def cnc_machine(ui_context): + """A machine with a spindle head, set as the active machine.""" + machine = Machine(ui_context) + machine.set_axis_extents(200, 150) + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + + spindle = SpindleHead() + spindle.name = "Spindle 1" + machine.heads.clear() + machine.add_head(spindle) + + ui_context.machine_mgr.machines.clear() + ui_context.machine_mgr.add_machine(machine) + ui_context.config.set_machine(machine) + return machine diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/test_cnc_frontend_registry.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/test_cnc_frontend_registry.py new file mode 100644 index 000000000..3210cc194 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/test_cnc_frontend_registry.py @@ -0,0 +1,22 @@ +# flake8: noqa: E402 +"""Verify the cnc_essentials frontend registers pages via the new hook.""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from cnc_essentials.frontend import register_step_settings_pages + +from rayforge.ui_gtk.doceditor.step_settings.page_registry import ( + StepSettingsPageRegistry, +) + + +def test_frontend_registers_pages(): + registry = StepSettingsPageRegistry() + register_step_settings_pages(registry) + assert registry.get("adaptive_clearing") is not None + assert registry.get("profile_outer") is not None + assert registry.get("slot") is not None + assert registry.get("helix") is not None diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/test_cnc_pages.py b/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/test_cnc_pages.py new file mode 100644 index 000000000..b7fd61ae7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-cnc/tests/ui_gtk/test_cnc_pages.py @@ -0,0 +1,93 @@ +# flake8: noqa: E402 +"""UI tests for the CNC step settings pages.""" + +from typing import cast + +import gi +import pytest + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from cnc_essentials.steps.cnc_assembler_step import CncAssemblerStep +from cnc_essentials.widgets.pages import AdaptiveClearPage, ProfileOuterPage +from cnc_essentials.widgets.rows import ( + MaxDeflectionRow, + PlungeSpeedRow, + ToolDiameterRow, +) + +from rayforge.core.step_registry import step_registry +from rayforge.ui_gtk.doceditor.step_settings.pages import StepSettingsPage +from rayforge.ui_gtk.doceditor.step_settings.rows import ( + CutSpeedRow, + TravelSpeedRow, +) +from rayforge.ui_gtk.shared.pref_rows import ( + AngleSpinRow, + LengthSpinRow, + SpeedSpinRow, +) + + +def _find(widget, cls): + for row in widget._rows: + if isinstance(row, cls): + return row + raise AssertionError(f"row {cls.__name__} not found in page") + + +@pytest.mark.ui +def test_profile_outer_page_composes_common_sections( + editor, cnc_machine, ui_context +): + step_cls = step_registry.get("ProfileOuterStep") + assert step_cls is not None + step = cast(CncAssemblerStep, step_cls.create(ui_context)) + + page = ProfileOuterPage(editor, step) + assert isinstance(page, StepSettingsPage) + + rows = list(page._rows) + assert any(isinstance(row, CutSpeedRow) for row in rows) + assert any(isinstance(row, TravelSpeedRow) for row in rows) + + +@pytest.mark.ui +def test_length_rows_use_user_units(editor, cnc_machine, ui_context): + ui_context.config.unit_preferences["length"] = "in" + step_cls = step_registry.get("ProfileOuterStep") + assert step_cls is not None + step = cast(CncAssemblerStep, step_cls.create(ui_context)) + + page = ProfileOuterPage(editor, step) + tool = _find(page, ToolDiameterRow) + assert isinstance(tool.widget, LengthSpinRow) + + step.tool_diameter = 25.4 + step.updated.send(step) + + assert tool.widget.get_value_in_base_units() == pytest.approx(25.4) + assert tool.widget.get_value() == pytest.approx(1.0, abs=1e-2) + + +@pytest.mark.ui +def test_plunge_speed_row_uses_speed_units(editor, cnc_machine, ui_context): + step_cls = step_registry.get("ProfileOuterStep") + assert step_cls is not None + step = cast(CncAssemblerStep, step_cls.create(ui_context)) + + page = ProfileOuterPage(editor, step) + plunge = _find(page, PlungeSpeedRow) + assert isinstance(plunge.widget, SpeedSpinRow) + + +@pytest.mark.ui +def test_deflection_row_uses_angle_spin_row(editor, cnc_machine, ui_context): + step_cls = step_registry.get("AdaptiveClearStep") + assert step_cls is not None + step = cast(CncAssemblerStep, step_cls.create(ui_context)) + + page = AdaptiveClearPage(editor, step) + deflection = _find(page, MaxDeflectionRow) + assert isinstance(deflection.widget, AngleSpinRow) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/__init__.py new file mode 100644 index 000000000..7d7a06abd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/__init__.py @@ -0,0 +1,6 @@ +""" +Laser Essentials - Core laser cutting functionality. + +This builtin addon provides the essential producers, steps, and widgets +for laser cutting operations. +""" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py new file mode 100644 index 000000000..bea20ae93 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py @@ -0,0 +1,11 @@ +""" +Laser Essentials Commands. + +Provides command implementations for laser operations. +""" + +from .material_test_cmd import MaterialTestCmd + +__all__ = [ + "MaterialTestCmd", +] 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 new file mode 100644 index 000000000..c16fb6903 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/material_test_cmd.py @@ -0,0 +1,205 @@ +import json +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.core.item import DocItem +from rayforge.core.step import Step +from rayforge.core.step_registry import step_registry +from rayforge.core.undo import ListItemCommand +from rayforge.core.vectorization_spec import ProceduralSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image.procedural import ProceduralImporter + +from ..material_test_helpers import ( + draw_material_test_preview, + get_material_test_proportional_size, +) + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +_PARAM_KEYS = ( + "test_type", + "grid_mode", + "speed_range", + "power_range", + "passes_range", + "offset_range", + "fixed_speed", + "fixed_power", + "grid_dimensions", + "shape_size", + "spacing", + "include_labels", + "label_power_percent", + "label_speed", + "line_interval_mm", +) + + +def _extract_params(step: Step) -> dict[str, Any]: + """Extract material test params from step attributes.""" + return {k: getattr(step, k, None) for k in _PARAM_KEYS} + + +class MaterialTestCmd: + """Handles creation and updates for material test grids. + + Registered with the command registry so it persists for the + editor's lifetime. This is essential because the instance holds + a blinker signal connection to ``doc.descendant_updated``; if the + instance were garbage-collected the connection would be lost and + preview updates would stop working. + """ + + def __init__(self, editor: "DocEditor"): + self._editor = editor + self._connect_doc_signals() + editor.document_changed.connect(self._on_document_changed) + + @property + def _doc(self): + return self._editor.doc + + @property + def _history_manager(self): + return self._editor.history_manager + + def _connect_doc_signals(self): + self._doc.descendant_updated.connect(self._on_step_updated) + + def _disconnect_doc_signals(self): + try: + self._doc.descendant_updated.disconnect(self._on_step_updated) + except KeyError: + pass + + def _on_document_changed(self, sender): + self._disconnect_doc_signals() + self._connect_doc_signals() + + def create_test_grid(self): + """ + Creates a new material test grid, including its Step and WorkPiece, + and adds them to the document. + """ + with self._history_manager.transaction(_("Add Material Test")) as t: + name = _("Material Test Grid") + + MaterialTestClass = step_registry.get("MaterialTestStep") + assert MaterialTestClass is not None + step = MaterialTestClass.create(self._editor.context) + step.name = name + + params = _extract_params(step) + + # Get function paths programmatically for type safety. + draw_func_path = ( + f"{draw_material_test_preview.__module__}." + f"{draw_material_test_preview.__name__}" + ) + size_func_path = ( + f"{get_material_test_proportional_size.__module__}." + f"{get_material_test_proportional_size.__name__}" + ) + + # Use the generic importer to create the procedural content. + importer = ProceduralImporter( + drawing_function_path=draw_func_path, + size_function_path=size_func_path, + params=params, + name=name, + ) + payload = importer.get_doc_items(ProceduralSpec()) + if not payload or not payload.payload: + logger.error("Failed to create material test grid.") + return + + source = payload.payload.source + workpiece = payload.payload.items[0] + assert isinstance(workpiece, WorkPiece) + + self._doc.add_asset(source) + step.generated_workpiece_uid = workpiece.uid # Link step to WP + width_mm, height_mm = workpiece.size + + machine_dims = self._editor.machine_dimensions + if machine_dims: + ws_width, ws_height = machine_dims + workpiece.pos = ( + ws_width / 2 - width_mm / 2, + ws_height / 2 - height_mm / 2, + ) + + active_layer = self._doc.active_layer + if active_layer.workflow: + t.execute( + ListItemCommand( + owner_obj=active_layer.workflow, + item=step, + undo_command="remove_step", + redo_command="add_step", + ) + ) + t.execute( + ListItemCommand( + owner_obj=active_layer, + item=workpiece, + undo_command="remove_child", + redo_command="add_child", + ) + ) + logger.info( + f"Created material test grid ({width_mm:.1f}x{height_mm:.1f} mm)" + ) + + def _on_step_updated( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ): + if not isinstance(origin, Step): + return + if origin.ASSEMBLER_NAME != "material_test_grid": + return + self.sync_preview_from_step(origin) + + def sync_preview_from_step(self, step: Step): + if not step.generated_workpiece_uid: + return + + item = self._doc.find_descendant_by_uid(step.generated_workpiece_uid) + if not isinstance(item, WorkPiece): + logger.warning("Could not find workpiece owned by step.") + return + + workpiece_to_update = item + if not workpiece_to_update.source: + return + + source = workpiece_to_update.source + params = _extract_params(step) + + # Re-create the recipe with the updated geometric parameters. + try: + old_recipe = json.loads(source.original_data) + new_recipe_dict = { + "drawing_function_path": old_recipe["drawing_function_path"], + "size_function_path": old_recipe["size_function_path"], + "params": params, + } + new_recipe_data = json.dumps(new_recipe_dict).encode("utf-8") + source.original_data = new_recipe_data + except (json.JSONDecodeError, KeyError) as e: + logger.error(f"Could not update procedural source data: {e}") + return + + # Recalculate size first so the re-render uses correct dimensions. + new_width_mm, new_height_mm = get_material_test_proportional_size( + params + ) + workpiece_to_update.clear_render_cache() + workpiece_to_update.set_size(new_width_mm, new_height_mm) + workpiece_to_update.updated.send(workpiece_to_update) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py new file mode 100644 index 000000000..2cf40a2ec --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py @@ -0,0 +1,57 @@ +""" +Frontend entry point for laser-essentials addon. + +Registers UI widgets and actions with the main application. +""" + +from gettext import gettext as _ +from pathlib import Path + +from gi.repository import Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement +from rayforge.ui_gtk.icons import register_icon_path + +from .commands import MaterialTestCmd +from .widgets import ASSEMBLER_WIDGETS + +ADDON_NAME = "laser_essentials" +_ICONS_DIR = Path(__file__).parent / "resources" / "icons" + +register_icon_path(_ICONS_DIR) + + +@hookimpl +def register_step_settings_pages(step_settings_page_registry): + """Register step settings page classes based on assembler name.""" + for assembler_name, page_cls in ASSEMBLER_WIDGETS.items(): + step_settings_page_registry.register( + assembler_name, page_cls, ADDON_NAME + ) + + +@hookimpl +def register_commands(command_registry): + """Register editor command handlers.""" + command_registry.register("material_test", MaterialTestCmd, ADDON_NAME) + + +@hookimpl +def register_actions(action_registry): + """Register actions with menu placement.""" + action = Gio.SimpleAction.new("material_test", None) + + def on_activate(action, param): + window = action_registry.window + editor = window.doc_editor + editor.material_test.create_test_grid() + + action.connect("activate", on_activate) + action_registry.register( + action_name="material_test", + action=action, + addon_name=ADDON_NAME, + label=_("Create Material Test Grid"), + menu=MenuPlacement(menu_id="tools", priority=100), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/laser_head_var.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/laser_head_var.py new file mode 100644 index 000000000..b2e9317e6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/laser_head_var.py @@ -0,0 +1,77 @@ +"""The laser-head selection VarSet variable.""" + +from gettext import gettext as _ + +from rayforge.context import get_context +from rayforge.core.capability import MachineCapability +from rayforge.core.varset import ChoiceVar + + +class LaserHeadVar(ChoiceVar): + """ + A special ChoiceVar that dynamically populates its choices with the + names of the laser heads from the currently active machine. + + It also handles the mapping between human-readable names (for the UI) + and the UIDs (for data storage). + """ + + def __init__( + self, + key: str = "selected_head_uid", + label: str = _("Laser Head"), + description: str | None = None, + default: str | None = None, + value: str | None = None, + ): + """ + Initializes a new LaserHeadVar instance. + + Args: + key: The unique machine-readable identifier. + label: The human-readable name for the UI. + description: A longer, human-readable description. + default: The default value (a laser head UID). + value: The initial value. If provided, it overrides the default. + """ + self.name_to_uid_map: dict[str, str] = {} + self.uid_to_name_map: dict[str, str] = {} + head_names: list[str] = [] + + active_machine = get_context().machine + if active_machine and active_machine.heads: + laser_heads = [ + h + for h in active_machine.heads + if h.machine_capability is MachineCapability.LASER + ] + self.name_to_uid_map = {h.name: h.uid for h in laser_heads} + self.uid_to_name_map = {h.uid: h.name for h in laser_heads} + head_names = sorted(self.name_to_uid_map.keys()) + + # The value stored in the Var itself is the UID. + # We need to translate the initial name-based value to a UID. + initial_value_uid = value + if value and value in self.name_to_uid_map: + initial_value_uid = self.name_to_uid_map[value] + + super().__init__( + key=key, + label=label, + choices=head_names, + description=description, + default=default, + value=initial_value_uid, + ) + + def get_display_for_value(self, value: str | None) -> str | None: + """Given a UID (value), return the display name.""" + if value is None: + return None + return self.uid_to_name_map.get(value, value) + + def get_value_for_display(self, display: str | None) -> str | None: + """Given a display name, return the UID (value).""" + if display is None: + return None + return self.name_to_uid_map.get(display, display) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/material_test_helpers.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/material_test_helpers.py new file mode 100644 index 000000000..64695f589 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/material_test_helpers.py @@ -0,0 +1,143 @@ +""" +Helper functions and enums for the material test grid. + +Moved from the deleted material_test_grid_producer.py (Phase 6: eliminate +producer infrastructure). These utilities are used by the material test +widget and command for preview rendering and size calculation. +""" + +from enum import Enum +from gettext import gettext as _ +from typing import Any + +import cairo +import numpy as np +from raygeo.ops.assembly.material_test_grid import ( + generate_material_test_grid_preview, +) + +_MM_PER_INCH = 25.4 + + +class MaterialTestGridType(Enum): + """Material test types.""" + + CUT = "Cut" + ENGRAVE = "Engrave" + + def label(self) -> str: + labels = { + self.CUT: _("Cut"), + self.ENGRAVE: _("Engrave"), + } + return labels[self] + + +class GridMode(Enum): + """Defines which parameters vary on grid axes.""" + + POWER_VS_SPEED = "Power vs Speed" + POWER_VS_PASSES = "Power vs Passes" + SPEED_VS_PASSES = "Speed vs Passes" + SPEED_VS_OFFSET = "Speed vs Offset" + + def label(self) -> str: + labels = { + self.POWER_VS_SPEED: _("Power vs Speed"), + self.POWER_VS_PASSES: _("Power vs Passes"), + self.SPEED_VS_PASSES: _("Speed vs Passes"), + self.SPEED_VS_OFFSET: _("Speed vs Offset"), + } + return labels[self] + + +def get_material_test_proportional_size( + params: dict[str, Any], +) -> tuple[float, float]: + """ + Calculates the natural size in mm for a material test grid. + + Args: + params: A dictionary of geometric parameters for the grid, including + 'grid_dimensions', 'shape_size', 'spacing', and 'include_labels'. + + Returns: + A tuple of (width, height) in millimeters. + """ + cols, rows = map(int, params.get("grid_dimensions", (5, 5))) + shape_size = params.get("shape_size", 10.0) + spacing = params.get("spacing", 2.0) + include_labels = params.get("include_labels", True) + + base_margin_left = min(shape_size * 1.5, 15.0) + base_margin_top = min(shape_size * 1.5, 15.0) + width = (cols * shape_size) + ((cols - 1) * spacing) + height = (rows * shape_size) + ((rows - 1) * spacing) + if include_labels: + width += base_margin_left + height += base_margin_top + return width, height + + +def draw_preview( + ctx: cairo.Context, + width_px: float, + height_px: float, + params: dict[str, Any], +): + """ + Draws a visual-only preview of the material test grid. + + Renders the Ops via ``generate_material_test_grid_preview`` (raygeo), + then blits the resulting RGBA buffer to the Cairo context. + """ + size_mm = get_material_test_proportional_size(params) + dpi_x = width_px / size_mm[0] * _MM_PER_INCH + dpi_y = height_px / size_mm[1] * _MM_PER_INCH + dpi = (dpi_x + dpi_y) / 2.0 + img = generate_material_test_grid_preview( + size_mm=size_mm, + dpi=dpi, + cols=params.get("grid_dimensions", (5, 5))[0], + rows=params.get("grid_dimensions", (5, 5))[1], + min_speed=params.get("speed_range", (100.0, 500.0))[0], + max_speed=params.get("speed_range", (100.0, 500.0))[1], + min_power=params.get("power_range", (10.0, 100.0))[0], + max_power=params.get("power_range", (10.0, 100.0))[1], + min_passes=params.get("passes_range", (1, 5))[0], + max_passes=params.get("passes_range", (1, 5))[1], + min_offset=params.get("offset_range", (-0.5, 0.5))[0], + max_offset=params.get("offset_range", (-0.5, 0.5))[1], + fixed_speed=params.get("fixed_speed", 1000.0), + fixed_power=params.get("fixed_power", 50.0), + shape_size=params.get("shape_size", 10.0), + spacing=params.get("spacing", 2.0), + mode=("cut" if params.get("test_type", "Cut") == "Cut" else "engrave"), + grid_mode=params.get("grid_mode", "Power vs Speed"), + include_labels=params.get("include_labels", True), + label_power_percent=params.get("label_power_percent", 10.0), + label_speed=params.get("label_speed", 1000.0), + ) + + h, w = img.shape[:2] + bgra = np.empty_like(img) + bgra[:, :, 0] = img[:, :, 2] # B = R + bgra[:, :, 1] = img[:, :, 1] # G = G + bgra[:, :, 2] = img[:, :, 0] # R = B + bgra[:, :, 3] = img[:, :, 3] # A = A + surface = cairo.ImageSurface.create_for_data( + np.ascontiguousarray(bgra), + cairo.FORMAT_ARGB32, + w, + h, + ) + ctx.set_source_surface(surface, 0, 0) + ctx.paint() + surface.finish() + + +def draw_material_test_preview( + ctx: cairo.Context, width: float, height: float, params: dict[str, Any] +): + """Stable entry point for the generic procedural renderer.""" + draw_preview(ctx, width, height, params) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-contour-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-contour-symbolic.svg new file mode 100644 index 000000000..05380df7a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-contour-symbolic.svg @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-frame-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-frame-symbolic.svg new file mode 100644 index 000000000..c7c6a0bf8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-frame-symbolic.svg @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-raster-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-raster-symbolic.svg new file mode 100644 index 000000000..212d0481a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-raster-symbolic.svg @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-shrinkwrap-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-shrinkwrap-symbolic.svg new file mode 100644 index 000000000..216593281 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-shrinkwrap-symbolic.svg @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-wavefront-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-wavefront-symbolic.svg new file mode 100644 index 000000000..207d01567 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/resources/icons/step-wavefront-symbolic.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/__init__.py new file mode 100644 index 000000000..9001d61a1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/__init__.py @@ -0,0 +1,23 @@ +""" +Laser Essentials Steps. + +Provides step implementations for laser cutting operations. +""" + +from .contour_step import ContourStep +from .frame_step import FrameStep +from .laser_step import LaserStep +from .material_test import MaterialTestStep +from .raster_step import EngraveStep +from .shrinkwrap_step import ShrinkWrapStep +from .wavefront_step import WavefrontStep + +__all__ = [ + "ContourStep", + "EngraveStep", + "FrameStep", + "LaserStep", + "MaterialTestStep", + "ShrinkWrapStep", + "WavefrontStep", +] 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 new file mode 100644 index 000000000..2fb31650d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/contour_step.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Protocol, cast + +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.contour import ContourSpec +from raygeo.ops.part import Part + +from rayforge.core.capability import MachineCapability +from rayforge.core.cut_side import CutOrder, CutSide +from rayforge.core.step import legacy_producer_params +from rayforge.core.varset import ( + BoolVar, + LabeledChoiceVar, + LengthVar, + VarSet, +) +from rayforge.pipeline.stage.assembler_helpers import ( + build_part_vector_with_raster_fallback, +) +from rayforge.pipeline.transformer.registry import transformer_registry + +from .laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + class LeadInOutTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +class ContourStep(LaserStep): + TYPELABEL = _("Contour") + ICON = "step-contour-symbolic" + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.LASER}) + ASSEMBLER_NAME = "contour" + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *LaserStep.recipe_varset().vars, + LabeledChoiceVar( + key="cut_side", + label=_("Cut Side"), + choices=[(cs.label(), cs.name) for cs in CutSide], + default="CENTERLINE", + ), + LabeledChoiceVar( + key="cut_order", + label=_("Cut Order"), + choices=[(co.label(), co.name) for co in CutOrder], + default="INSIDE_OUTSIDE", + ), + BoolVar( + key="remove_inner_paths", + label=_("Remove Inner Paths"), + default=False, + ), + LengthVar( + key="offset_mm", + label=_("Offset"), + description=_( + "Shifts the cut path inward/outward per Cut " + "Side (none on Centerline). Defaults to kerf " + "compensation for the head" + ), + default=0.0, + ), + LengthVar( + key="overcut", + label=_("Overcut"), + default=0.0, + min_val=0.0, + ), + ] + ) + + def __init__(self, name: str | None = None, typelabel: str | None = None): + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + self.power = 0.8 + self.cut_side = "CENTERLINE" + self.cut_order = "INSIDE_OUTSIDE" + self.remove_inner_paths = False + self.offset_mm = 0.0 + self.overcut = 0.0 + self.override_threshold = False + self.threshold = 0.5 + + def get_operation_mode_short(self): + try: + return CutSide[self.cut_side].label() + except (KeyError, TypeError): + return None + + def get_assembler_kwargs( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict: + kwargs: dict = {} + kwargs["cut_side"] = str(self.cut_side).lower() + kwargs["cut_order"] = str(self.cut_order).lower() + kwargs["remove_inner"] = self.remove_inner_paths + kwargs["offset_mm"] = self.offset_mm + kwargs["overcut"] = self.overcut + kwargs["arc_tolerance"] = machine.arc_tolerance + kwargs["allow_arcs"] = machine.supports_arcs + kwargs["supports_curves"] = machine.supports_curves + return kwargs + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + """Build a :class:`Part` (from the workpiece's vector + geometry) and a :class:`ComputePayload` carrying a + :class:`ContourSpec` populated from this step's resolved + assembler kwargs. + + When the workpiece has no vector boundaries (e.g. an SVG + with empty ``pristine_geometry``), the source is rendered + to pixels and traced into geometry before assembling. + """ + part = build_part_vector_with_raster_fallback( + workpiece, + self.pixels_per_mm, + override_threshold=self.override_threshold, + threshold=self.threshold, + ) + kwargs = self.get_assembler_kwargs(machine, workpiece) + spec = ContourSpec( + offset_mm=kwargs["offset_mm"], + cut_side=kwargs["cut_side"], + overcut=kwargs["overcut"], + cut_order=kwargs["cut_order"], + remove_inner=kwargs["remove_inner"], + arc_tolerance=kwargs["arc_tolerance"], + allow_arcs=kwargs["allow_arcs"], + supports_curves=kwargs["supports_curves"], + ) + return part, ComputePayload(assembler=Assembler(spec)) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict | None: + """Expose the resolved assembler kwargs for the compute token.""" + return self.get_assembler_kwargs(machine, workpiece) + + def apply_import_settings(self, settings: dict) -> None: + """Apply importer-provided settings this step owns.""" + super().apply_import_settings(settings) + offset_mm = settings.get("offset_mm") + if offset_mm is not None: + self.offset_mm = offset_mm + + def to_dict(self) -> dict: + data = super().to_dict() + data["cut_side"] = self.cut_side + data["cut_order"] = self.cut_order + data["remove_inner_paths"] = self.remove_inner_paths + data["offset_mm"] = self.offset_mm + data["overcut"] = self.overcut + data["override_threshold"] = self.override_threshold + data["threshold"] = self.threshold + return data + + @classmethod + def from_dict(cls, data: dict) -> ContourStep: + step = cast("ContourStep", super().from_dict(data)) + legacy = legacy_producer_params(data) + step.cut_side = data.get( + "cut_side", + legacy.get("cut_side", legacy.get("kerf_mode", "CENTERLINE")), + ) + step.cut_order = data.get( + "cut_order", legacy.get("cut_order", "INSIDE_OUTSIDE") + ) + step.remove_inner_paths = data.get( + "remove_inner_paths", legacy.get("remove_inner_paths", False) + ) + if "offset_mm" in data: + step.offset_mm = data["offset_mm"] + else: + path_offset = data.get( + "path_offset_mm", + legacy.get("path_offset_mm", legacy.get("offset_mm", 0.0)), + ) + step.offset_mm = path_offset + (data.get("kerf_mm", 0.0) / 2.0) + step.overcut = data.get("overcut", legacy.get("overcut", 0.0)) + step.override_threshold = data.get( + "override_threshold", + legacy.get("override_threshold", False), + ) + step.threshold = data.get("threshold", legacy.get("threshold", 0.5)) + return step + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + Smooth = transformer_registry.get("Smooth") + LeadInOutTransformer = transformer_registry.get("LeadInOutTransformer") + TabOpsTransformer = transformer_registry.get("TabOpsTransformer") + CropTransformer = transformer_registry.get("CropTransformer") + MergeLinesTransformer = transformer_registry.get( + "MergeLinesTransformer" + ) + Optimize = transformer_registry.get("Optimize") + MultiPassTransformer = transformer_registry.get("MultiPassTransformer") + assert Smooth is not None + assert LeadInOutTransformer is not None + assert TabOpsTransformer is not None + assert CropTransformer is not None + assert MergeLinesTransformer is not None + assert Optimize is not None + assert MultiPassTransformer is not None + optimize_dict = Optimize().to_dict() + return [ + Smooth(enabled=False, amount=20).to_dict(), + LeadInOutTransformer( + enabled=False, lead_in_mm=0, lead_out_mm=0, auto=True + ).to_dict(), + TabOpsTransformer().to_dict(), + CropTransformer(enabled=False).to_dict(), + optimize_dict, + ], [ + MergeLinesTransformer().to_dict(), + optimize_dict, + MultiPassTransformer(passes=1, z_step_down=0.0).to_dict(), + ] + + @classmethod + def create( + cls, + context: RayforgeContext, + name: str | None = None, + optimize: bool = True, + **kwargs, + ) -> ContourStep: + machine = context.machine + assert machine is not None + default_head = machine.get_default_laser_head() + if default_head is None: + raise ValueError("Machine has no laser heads configured.") + + step = cls(name=name) + per_wp, per_step = cls.get_default_transformers_dicts() + if not optimize: + per_wp = [t for t in per_wp if t.get("name") != "Optimize"] + + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + step.selected_head_uid = default_head.uid + step.offset_mm = default_head.kerf_mm + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + # Operating feed defaults are machine-derived: the machine only + # exposes its ceiling, so the default is that ceiling, bounded by + # the operation's typical feed rate. + step.cut_speed = min(machine.max_cut_speed, 500) + params = machine.get_pwm_params(default_head) + if params is not None: + step.frequency = params.frequency + step.pulse_width = params.pulse_width + + LeadInOutTransformer = cast( + "LeadInOutTransformerType", + transformer_registry.get("LeadInOutTransformer"), + ) + if LeadInOutTransformer: + calc = LeadInOutTransformer.calculate_auto_distance + auto_distance = calc(step.cut_speed, machine.acceleration) + for t in per_wp: + if t.get("name") == "LeadInOutTransformer": + t["lead_in_mm"] = auto_distance + t["lead_out_mm"] = auto_distance + + return step diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/frame_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/frame_step.py new file mode 100644 index 000000000..abbab0608 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/frame_step.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Protocol, cast + +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.frame import FrameSpec +from raygeo.ops.part import Part + +from rayforge.core.capability import MachineCapability +from rayforge.core.cut_side import CutSide +from rayforge.core.step import legacy_producer_params +from rayforge.core.varset import LabeledChoiceVar, LengthVar, VarSet +from rayforge.pipeline.stage.assembler_helpers import ( + build_part_vector_with_raster_fallback, +) +from rayforge.pipeline.transformer.registry import transformer_registry + +from .laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + class LeadInOutTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +class FrameStep(LaserStep): + TYPELABEL = _("Frame") + ICON = "step-frame-symbolic" + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.LASER}) + ASSEMBLER_NAME = "frame" + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *LaserStep.recipe_varset().vars, + LabeledChoiceVar( + key="cut_side", + label=_("Cut Side"), + choices=[(cs.label(), cs.name) for cs in CutSide], + default="CENTERLINE", + ), + LengthVar( + key="offset_mm", + label=_("Offset"), + description=_( + "Shifts the frame inward/outward per Cut Side " + "(none on Centerline). Defaults to kerf " + "compensation for the head" + ), + default=0.0, + ), + ] + ) + + def __init__(self, name: str | None = None, typelabel: str | None = None): + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + self.power = 0.8 + self.offset_mm = 0.0 + self.cut_side = "CENTERLINE" + + def get_operation_mode_short(self): + try: + return CutSide[self.cut_side].label() + except (KeyError, TypeError): + return None + + def get_assembler_kwargs( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict: + kwargs: dict = {} + kwargs["cut_side"] = str(self.cut_side).lower() + kwargs["offset_mm"] = self.offset_mm + return kwargs + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + """Build a :class:`Part` (from the workpiece's vector + geometry) and a :class:`ComputePayload` carrying a + :class:`FrameSpec`. + + When the workpiece has no vector boundaries, the source is + rendered to pixels and traced into geometry before assembling. + """ + part = build_part_vector_with_raster_fallback( + workpiece, self.pixels_per_mm + ) + kwargs = self.get_assembler_kwargs(machine, workpiece) + spec = FrameSpec( + offset_mm=kwargs["offset_mm"], + cut_side=kwargs["cut_side"], + ) + return part, ComputePayload(assembler=Assembler(spec)) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict | None: + return self.get_assembler_kwargs(machine, workpiece) + + def apply_import_settings(self, settings: dict) -> None: + """Apply importer-provided settings this step owns.""" + super().apply_import_settings(settings) + offset_mm = settings.get("offset_mm") + if offset_mm is not None: + self.offset_mm = offset_mm + + def to_dict(self) -> dict: + data = super().to_dict() + data["cut_side"] = self.cut_side + data["offset_mm"] = self.offset_mm + return data + + @classmethod + def from_dict(cls, data: dict) -> FrameStep: + step = cast("FrameStep", super().from_dict(data)) + legacy = legacy_producer_params(data) + step.cut_side = data.get( + "cut_side", + legacy.get("cut_side", legacy.get("kerf_mode", "CENTERLINE")), + ) + if "offset_mm" in data: + step.offset_mm = data["offset_mm"] + else: + path_offset = data.get( + "path_offset_mm", + legacy.get("path_offset_mm", legacy.get("offset_mm", 0.0)), + ) + step.offset_mm = path_offset + (data.get("kerf_mm", 0.0) / 2.0) + return step + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + LeadInOutTransformer = transformer_registry.get("LeadInOutTransformer") + TabOpsTransformer = transformer_registry.get("TabOpsTransformer") + CropTransformer = transformer_registry.get("CropTransformer") + MergeLinesTransformer = transformer_registry.get( + "MergeLinesTransformer" + ) + Optimize = transformer_registry.get("Optimize") + MultiPassTransformer = transformer_registry.get("MultiPassTransformer") + assert LeadInOutTransformer is not None + assert TabOpsTransformer is not None + assert CropTransformer is not None + assert MergeLinesTransformer is not None + assert Optimize is not None + assert MultiPassTransformer is not None + optimize_dict = Optimize().to_dict() + return [ + LeadInOutTransformer( + enabled=False, lead_in_mm=0, lead_out_mm=0, auto=True + ).to_dict(), + TabOpsTransformer().to_dict(), + CropTransformer(enabled=False).to_dict(), + optimize_dict, + ], [ + MergeLinesTransformer().to_dict(), + optimize_dict, + MultiPassTransformer(passes=1, z_step_down=0.0).to_dict(), + ] + + @classmethod + def create( + cls, + context: RayforgeContext, + name: str | None = None, + **kwargs, + ) -> FrameStep: + machine = context.machine + assert machine is not None + default_head = machine.get_default_laser_head() + if default_head is None: + raise ValueError("Machine has no laser heads configured.") + + step = cls(name=name) + per_wp, per_step = cls.get_default_transformers_dicts() + + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + step.selected_head_uid = default_head.uid + step.offset_mm = default_head.kerf_mm + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + # Operating feed defaults are machine-derived: the machine only + # exposes its ceiling, so the default is that ceiling, bounded by + # the operation's typical feed rate. + step.cut_speed = min(machine.max_cut_speed, 500) + params = machine.get_pwm_params(default_head) + if params is not None: + step.frequency = params.frequency + step.pulse_width = params.pulse_width + + LeadInOutTransformer = cast( + "LeadInOutTransformerType", + transformer_registry.get("LeadInOutTransformer"), + ) + if LeadInOutTransformer: + calc = LeadInOutTransformer.calculate_auto_distance + auto_distance = calc(step.cut_speed, machine.acceleration) + for t in per_wp: + if t.get("name") == "LeadInOutTransformer": + t["lead_in_mm"] = auto_distance + t["lead_out_mm"] = auto_distance + + return step 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 new file mode 100644 index 000000000..12656eb5e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py @@ -0,0 +1,239 @@ +"""Laser-domain step base class. + +Intermediate base for all laser steps. Declares the laser process +attributes and the laser-specific behaviour (initial ops, summary, +settlers, serialization of the laser keys). +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, cast + +from raygeo.ops import Ops +from raygeo.ops.state import AirAssistMode + +from rayforge.core.step import Step +from rayforge.core.varset import ( + BoolVar, + SliderFloatVar, + VarSet, +) +from rayforge.machine.models.laser import LaserHead +from rayforge.shared.units.formatter import format_value + +from ..laser_head_var import LaserHeadVar + +if TYPE_CHECKING: + from rayforge.machine.models.machine import Machine + + +class LaserStep(Step): + """Base for all laser-domain steps. Owns laser attributes.""" + + def __init__(self, typelabel, name=None): + self.power: float = 1.0 + self.max_power: int = 1000 + self.air_assist: bool = False + self.tab_power: float = 0.0 + self.frequency: int = 0 + self.pulse_width: int = 0 + super().__init__(typelabel, name=name) + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + LaserHeadVar( + description=_("Optionally force a specific laser head") + ), + SliderFloatVar( + key="power", + label=_("Power"), + default=0.8, + min_val=0.0, + max_val=1.0, + show_value=True, + format_suffix="%", + ), + *Step.recipe_varset().vars, + SliderFloatVar( + key="tab_power", + label=_("Tab Power"), + description=_( + "Laser power at tab positions (% of cut power)" + ), + default=0.0, + min_val=0.0, + max_val=1.0, + show_value=True, + format_suffix="%", + ), + BoolVar( + key="air_assist", + label=_("Air Assist"), + default=False, + ), + ] + ) + + @classmethod + def recipe_varset_groups(cls) -> list[tuple[str, VarSet]]: + """Split into a "Laser" group (inherited process settings) and a + "Step Settings" group (attributes the concrete step adds).""" + full = cls.recipe_varset() + base_keys = {v.key for v in LaserStep.recipe_varset()} + laser_vars = [v for v in full if v.key in base_keys] + step_vars = [v for v in full if v.key not in base_keys] + groups: list[tuple[str, VarSet]] = [] + if laser_vars: + groups.append((_("Laser"), VarSet(vars=laser_vars))) + if step_vars: + groups.append((_("Step Settings"), VarSet(vars=step_vars))) + return groups or [(_("Laser"), VarSet(vars=laser_vars))] + + def create_initial_ops(self) -> Ops: + """Build the initial Ops object with step-wide machine settings.""" + ops = Ops() + ops.set_power(self.power) + ops.set_feed_rate(self.cut_speed) + ops.set_rapid_rate(self.travel_speed) + ops.set_air_assist( + AirAssistMode.ON if self.air_assist else AirAssistMode.OFF + ) + if self.frequency: + ops.set_frequency(self.frequency) + if self.pulse_width: + ops.set_pulse_width(self.pulse_width) + return ops + + def populate_payload(self, payload, machine: "Machine"): + super().populate_payload(payload, machine) + payload.power = self.power + payload.air_assist = ( + AirAssistMode.ON if self.air_assist else AirAssistMode.OFF + ) + + def get_settings(self) -> dict[str, Any]: + """ + Bundles all physical process parameters into a dictionary. + Only includes settings of the step itself, and not of producer, + transformer, etc. + """ + return { + "power": self.power, + "cut_speed": self.cut_speed, + "travel_speed": self.travel_speed, + "air_assist": self.air_assist, + "pixels_per_mm": self.pixels_per_mm, + "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: + """Apply importer-provided laser settings this step owns.""" + super().apply_import_settings(settings) + power = settings.get("power") + if power is not None: + self.set_power(power) + + def get_cache_params(self) -> dict[str, Any]: + params = super().get_cache_params() + params.update( + { + "power": self.power, + "max_power": self.max_power, + "air_assist": self.air_assist, + "tab_power": self.tab_power, + "frequency": self.frequency, + "pulse_width": self.pulse_width, + } + ) + return params + + def get_selected_laser(self, machine: "Machine") -> LaserHead | None: + """Typed convenience — returns the selected LaserHead or None.""" + head = self.get_selected_head(machine) + if isinstance(head, LaserHead): + return head + return None + + def set_power(self, power: float): + if not (0.0 <= power <= 1.0): + raise ValueError("Power must be between 0.0 and 1.0") + if self.power != power: + self.power = power + self.updated.send(self) + + def set_air_assist(self, enabled: bool): + if self.air_assist != enabled: + self.air_assist = bool(enabled) + self.updated.send(self) + + def set_tab_power(self, power: float): + if not (0.0 <= power <= 1.0): + raise ValueError("Tab power must be between 0.0 and 1.0") + if self.tab_power != power: + self.tab_power = power + self.updated.send(self) + + def set_frequency(self, frequency: int): + if self.frequency != frequency: + self.frequency = int(frequency) + self.updated.send(self) + + def set_pulse_width(self, width: int): + if self.pulse_width != width: + self.pulse_width = int(width) + self.updated.send(self) + + def get_summary(self) -> str: + power_percent = round(self.power * 100) + speed_str = format_value(self.cut_speed, "speed") + return _("{power_percent}% power, {speed_str}").format( + power_percent=power_percent, speed_str=speed_str + ) + + def get_operation_color(self, head) -> str | None: + """The head's cut color, used to represent cutting operations.""" + if isinstance(head, LaserHead): + return head.cut_color + return None + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "power": self.power, + "max_power": self.max_power, + "air_assist": self.air_assist, + "tab_power": self.tab_power, + "frequency": self.frequency, + "pulse_width": self.pulse_width, + } + ) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "LaserStep": + step = cast("LaserStep", super().from_dict(data)) + step.power = data.get("power", step.power) + step.max_power = data.get("max_power", step.max_power) + step.air_assist = data.get("air_assist", step.air_assist) + step.tab_power = data.get("tab_power", step.tab_power) + step.frequency = data.get("frequency", step.frequency) + step.pulse_width = data.get("pulse_width", step.pulse_width) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset( + { + "power", + "max_power", + "air_assist", + "tab_power", + "frequency", + "pulse_width", + } + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/material_test.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/material_test.py new file mode 100644 index 000000000..8202761bc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/material_test.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Protocol, cast + +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.material_test_grid import MaterialTestGridSpec +from raygeo.ops.part import Part + +from rayforge.core.capability import MachineCapability +from rayforge.core.step import legacy_producer_params +from rayforge.machine.models.laser import LaserHead +from rayforge.pipeline.transformer.registry import transformer_registry + +from .laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + class OverscanTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +class MaterialTestStep(LaserStep): + TYPELABEL = _("Material Test Grid") + ICON = "test-symbolic" + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.LASER}) + ASSEMBLER_NAME = "material_test_grid" + HIDDEN = True + + def __init__(self, name: str | None = None, typelabel: str | None = None): + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + self.test_type = "Cut" + self.grid_mode = "Power vs Speed" + self.speed_range = (100.0, 500.0) + self.power_range = (10.0, 100.0) + self.passes_range = (1, 5) + self.offset_range = (-0.5, 0.5) + self.fixed_speed = 1000.0 + self.fixed_power = 50.0 + self.grid_dimensions = (5, 5) + self.shape_size = 10.0 + self.spacing = 2.0 + self.include_labels = True + self.label_power_percent = 10.0 + self.label_speed = 1000.0 + self.line_interval_mm = None + + def get_assembler_kwargs( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict: + _spot_x, spot_y = LaserHead.get_spot_size( + self.get_selected_laser(machine) + ) + kwargs: dict = {} + kwargs["size_mm"] = workpiece.size if workpiece else (0, 0) + kwargs["cols"] = self.grid_dimensions[0] + kwargs["rows"] = self.grid_dimensions[1] + kwargs["min_speed"] = self.speed_range[0] + kwargs["max_speed"] = self.speed_range[1] + kwargs["min_power"] = self.power_range[0] + kwargs["max_power"] = self.power_range[1] + kwargs["min_passes"] = self.passes_range[0] + kwargs["max_passes"] = self.passes_range[1] + kwargs["min_offset"] = self.offset_range[0] + kwargs["max_offset"] = self.offset_range[1] + kwargs["mode"] = "cut" if self.test_type == "Cut" else "engrave" + kwargs["grid_mode"] = self.grid_mode + kwargs["fixed_speed"] = self.fixed_speed + kwargs["fixed_power"] = self.fixed_power + kwargs["shape_size"] = self.shape_size + kwargs["spacing"] = self.spacing + kwargs["include_labels"] = self.include_labels + kwargs["label_power_percent"] = self.label_power_percent + kwargs["label_speed"] = self.label_speed + kwargs["line_interval_mm"] = ( + self.line_interval_mm + if self.line_interval_mm is not None + else spot_y + ) + return kwargs + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + """Build a :class:`Part` (empty — the material-test grid + needs no geometry) and a :class:`ComputePayload` carrying a + :class:`MaterialTestGridSpec`.""" + size = workpiece.size if workpiece else (0.0, 0.0) + part = Part(size_mm=size) + kwargs = self.get_assembler_kwargs(machine, workpiece) + spec = MaterialTestGridSpec( + size_mm=kwargs["size_mm"], + cols=kwargs["cols"], + rows=kwargs["rows"], + min_speed=kwargs["min_speed"], + max_speed=kwargs["max_speed"], + min_power=kwargs["min_power"], + max_power=kwargs["max_power"], + min_passes=kwargs["min_passes"], + max_passes=kwargs["max_passes"], + fixed_speed=kwargs["fixed_speed"], + fixed_power=kwargs["fixed_power"], + shape_size=kwargs["shape_size"], + spacing=kwargs["spacing"], + line_interval_mm=kwargs["line_interval_mm"], + mode=kwargs["mode"], + grid_mode=kwargs["grid_mode"], + include_labels=kwargs["include_labels"], + label_power_percent=kwargs["label_power_percent"], + label_speed=kwargs["label_speed"], + min_offset=kwargs["min_offset"], + max_offset=kwargs["max_offset"], + ) + return part, ComputePayload(assembler=Assembler(spec)) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict | None: + return self.get_assembler_kwargs(machine, workpiece) + + def to_dict(self) -> dict: + result = super().to_dict() + result["test_type"] = self.test_type + result["grid_mode"] = self.grid_mode + result["speed_range"] = list(self.speed_range) + result["power_range"] = list(self.power_range) + result["passes_range"] = list(self.passes_range) + result["offset_range"] = list(self.offset_range) + result["fixed_speed"] = self.fixed_speed + result["fixed_power"] = self.fixed_power + result["grid_dimensions"] = list(self.grid_dimensions) + result["shape_size"] = self.shape_size + result["spacing"] = self.spacing + result["include_labels"] = self.include_labels + result["label_power_percent"] = self.label_power_percent + result["label_speed"] = self.label_speed + result["line_interval_mm"] = self.line_interval_mm + return result + + @classmethod + def from_dict(cls, data: dict) -> MaterialTestStep: + step = cast("MaterialTestStep", super().from_dict(data)) + legacy = legacy_producer_params(data) + step.test_type = data.get("test_type", legacy.get("test_type", "Cut")) + step.grid_mode = data.get( + "grid_mode", legacy.get("grid_mode", "Power vs Speed") + ) + step.speed_range = tuple( + data.get("speed_range", legacy.get("speed_range", (100.0, 500.0))) + ) + step.power_range = tuple( + data.get("power_range", legacy.get("power_range", (10.0, 100.0))) + ) + step.passes_range = tuple( + data.get("passes_range", legacy.get("passes_range", (1, 5))) + ) + step.offset_range = tuple( + data.get("offset_range", legacy.get("offset_range", (-0.5, 0.5))) + ) + step.fixed_speed = data.get( + "fixed_speed", legacy.get("fixed_speed", 1000.0) + ) + step.fixed_power = data.get( + "fixed_power", legacy.get("fixed_power", 50.0) + ) + step.grid_dimensions = tuple( + data.get("grid_dimensions", legacy.get("grid_dimensions", (5, 5))) + ) + step.shape_size = data.get( + "shape_size", legacy.get("shape_size", 10.0) + ) + step.spacing = data.get("spacing", legacy.get("spacing", 2.0)) + step.include_labels = data.get( + "include_labels", legacy.get("include_labels", True) + ) + step.label_power_percent = data.get( + "label_power_percent", + legacy.get("label_power_percent", 10.0), + ) + step.label_speed = data.get( + "label_speed", legacy.get("label_speed", 1000.0) + ) + step.line_interval_mm = data.get( + "line_interval_mm", legacy.get("line_interval_mm", None) + ) + return step + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + OverscanTransformer = transformer_registry.get("OverscanTransformer") + Optimize = transformer_registry.get("Optimize") + BidirScanOffsetTransformer = transformer_registry.get( + "BidirScanOffsetTransformer" + ) + assert OverscanTransformer is not None + assert Optimize is not None + assert BidirScanOffsetTransformer is not None + # Off by default: Optimize's nearest-neighbor travel reordering has + # no concept of "cell" boundaries, so it can interleave lines from + # different cells instead of engraving each one fully before moving + # to the next. Left toggleable (rather than removed outright) so + # it's easy to compare with/without. + optimize_dict = Optimize(enabled=False).to_dict() + return [ + OverscanTransformer( + enabled=True, distance_mm=0, auto=True + ).to_dict(), + optimize_dict, + BidirScanOffsetTransformer(enabled=True).to_dict(), + ], [ + optimize_dict, + ] + + @classmethod + def create( + cls, + context: RayforgeContext, + name: str | None = None, + **kwargs, + ) -> MaterialTestStep: + machine = context.machine + assert machine is not None + + step = cls(name=name) + per_wp, per_step = cls.get_default_transformers_dicts() + + OverscanTransformer = cast( + "OverscanTransformerType", + transformer_registry.get("OverscanTransformer"), + ) + assert OverscanTransformer is not None + # Double the usual auto-calculated distance: individual test blocks + # benefit from extra run-up/run-out so backlash settling happens + # outside the visible engrave area, keeping it distinguishable from + # whatever parameter the grid is testing. + auto_distance = ( + OverscanTransformer.calculate_auto_distance( + step.cut_speed, machine.acceleration + ) + * 2 + ) + for t in per_wp: + if t.get("name") == "OverscanTransformer": + t["distance_mm"] = auto_distance + + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + default_head = machine.get_default_laser_head() + if default_head is None: + raise ValueError("Machine has no laser heads configured.") + + step.selected_head_uid = default_head.uid + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + params = machine.get_pwm_params(default_head) + if params is not None: + step.frequency = params.frequency + step.pulse_width = params.pulse_width + return step 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 new file mode 100644 index 000000000..925d57942 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py @@ -0,0 +1,554 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Protocol, + cast, +) + +import numpy as np +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.raster import RasterSpec +from raygeo.ops.part import Part +from raygeo.ops.part.image_source import WholeImageSource + +from rayforge.core.capability import MachineCapability +from rayforge.core.step import legacy_producer_params +from rayforge.core.varset import ( + BoolVar, + FloatVar, + LabeledChoiceVar, + SliderFloatVar, + VarSet, +) +from rayforge.image.dither import DitherAlgorithm +from rayforge.machine.models.laser import LaserHead +from rayforge.pipeline.stage.assembler_helpers import ( + DepthMode, + compute_raster_auto_levels, + preprocess_raster_image, +) +from rayforge.pipeline.transformer.registry import transformer_registry + +from .laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + class OverscanTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +class EngraveStep(LaserStep): + TYPELABEL = _("Engrave") + ICON = "step-raster-symbolic" + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.LASER}) + ASSEMBLER_NAME = "raster" + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *LaserStep.recipe_varset().vars, + FloatVar( + key="scan_angle", + label=_("Scan Angle"), + default=0.0, + min_val=0.0, + max_val=360.0, + ), + LabeledChoiceVar( + key="depth_mode", + label=_("Depth Mode"), + choices=[(m.display_name, m.name) for m in DepthMode], + default="POWER_MODULATION", + ), + BoolVar( + key="invert", + label=_("Invert"), + default=False, + ), + SliderFloatVar( + key="min_power_level", + label=_("Min Power Level"), + default=0.0, + min_val=0.0, + max_val=1.0, + show_value=True, + format_suffix="%", + ), + SliderFloatVar( + key="max_power_level", + label=_("Max Power Level"), + default=1.0, + min_val=0.0, + max_val=1.0, + show_value=True, + format_suffix="%", + ), + ] + ) + + def __init__(self, name: str | None = None, typelabel: str | None = None): + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + self.power = 0.2 + self.scan_angle = 0.0 + self.depth_mode = "POWER_MODULATION" + self.invert = False + self.auto_levels = True + self.black_point = 0 + self.white_point = 255 + self.threshold = 128 + self.line_interval_mm = None + self.sample_interval_mm = None + self.dot_width_correction_mm = None + self.min_power_level = 0.0 + self.max_power_level = 1.0 + self.num_power_levels = 25 + self.offset_x_mm = 0.0 + self.offset_y_mm = 0.0 + self.scan_mode = "SEGMENTED" + self.cross_hatch = False + self.num_depth_levels = 5 + self.z_step_down = 0.0 + self.angle_increment = 0.0 + self.dither_algorithm = None + self.bidir_x_offset_mm = 0.0 + + def get_operation_mode_short(self): + if not self.depth_mode: + return None + try: + return DepthMode[self.depth_mode].short_name + except KeyError: + return None + + def get_operation_color(self, head) -> str | None: + """The head's raster color, used to represent engraving.""" + if isinstance(head, LaserHead): + return head.raster_color + return None + + def is_position_sensitive(self) -> bool: + """The raster assembler bakes ``workpiece.bbox`` into its + output via ``offset_x_mm`` / ``offset_y_mm`` so the compute + result depends on the workpiece's absolute world position + (not just on per-workpiece transformers like CropTransformer). + Returning True ensures the compute token folds in + ``transform_revision`` so a pure move invalidates the + workpiece compute cache rather than leaving stale, + wrong-position ops to be re-displaced by the aggregate's new + placement matrix.""" + return True + + def get_assembler_kwargs( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict: + _spot_x, spot_y = LaserHead.get_spot_size( + self.get_selected_laser(machine) + ) + line_interval = ( + self.line_interval_mm + if self.line_interval_mm is not None + else spot_y + ) + return { + "mode": DepthMode[self.depth_mode].raygeo_name, + "line_interval_mm": line_interval, + "sample_interval_mm": self.sample_interval_mm, + "dot_width_correction_mm": self.dot_width_correction_mm, + "min_power": self.min_power_level, + "max_power": self.max_power_level, + "step_power": self.power, + "num_power_levels": self.num_power_levels, + "angle": self.scan_angle, + "offset_x_mm": self.offset_x_mm, + "offset_y_mm": self.offset_y_mm, + "scan_mode": self.scan_mode.lower(), + "cross_hatch": self.cross_hatch, + "num_depth_levels": self.num_depth_levels, + "z_step_down": self.z_step_down, + "angle_increment": self.angle_increment, + } + + def apply_import_settings(self, settings: dict[str, Any]) -> None: + """Apply importer-provided raster settings this step owns.""" + super().apply_import_settings(settings) + for key in ( + "min_power_level", + "max_power_level", + "dot_width_correction_mm", + "line_interval_mm", + "scan_angle", + ): + if key in settings: + setattr(self, key, settings[key]) + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + """Build a :class:`Part` with the preprocessed raster image + attached as a :class:`WholeImageSource`, and a + :class:`ComputePayload` carrying a :class:`RasterSpec`. + + Rendering and preprocessing (dither / auto-levels / depth + mode) happen here, on the calling thread, so the Rust + assembler on the rayon worker only reads slabs from the + attached image source. + """ + spot_x, spot_y = LaserHead.get_spot_size( + self.get_selected_laser(machine) + ) + part, alpha = _build_raster_part(self, machine, workpiece) + kwargs = self.get_assembler_kwargs(machine, workpiece) + depth_mode = DepthMode[self.depth_mode] + line_interval = kwargs["line_interval_mm"] or spot_y + sample_interval = kwargs["sample_interval_mm"] or spot_x / 2.0 + dot_width = ( + kwargs["dot_width_correction_mm"] + if kwargs["dot_width_correction_mm"] is not None + else spot_x / 2.0 + ) + x_off, y_off, _w, _h = workpiece.bbox + alpha_arr = ( + (alpha * 255).astype(np.uint8).tobytes() + if alpha is not None + else None + ) + spec = RasterSpec( + mode=depth_mode.raygeo_name, + line_interval_mm=line_interval, + sample_interval_mm=sample_interval, + min_power=kwargs["min_power"], + max_power=kwargs["max_power"], + step_power=kwargs["step_power"], + num_power_levels=kwargs["num_power_levels"], + angle=kwargs["angle"], + offset_x_mm=x_off, + offset_y_mm=y_off, + scan_mode=kwargs["scan_mode"], + cross_hatch=kwargs["cross_hatch"], + num_depth_levels=kwargs["num_depth_levels"], + z_step_down=kwargs["z_step_down"], + angle_increment=kwargs["angle_increment"], + dot_width_correction_mm=dot_width, + alpha=alpha_arr, + ) + return part, ComputePayload(assembler=Assembler(spec)) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict | None: + return self.get_assembler_kwargs(machine, workpiece) + + 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 + result["threshold"] = self.threshold + result["line_interval_mm"] = self.line_interval_mm + result["sample_interval_mm"] = self.sample_interval_mm + result["dot_width_correction_mm"] = self.dot_width_correction_mm + result["min_power_level"] = self.min_power_level + result["max_power_level"] = self.max_power_level + result["num_power_levels"] = self.num_power_levels + result["offset_x_mm"] = self.offset_x_mm + result["offset_y_mm"] = self.offset_y_mm + result["scan_mode"] = self.scan_mode + result["cross_hatch"] = self.cross_hatch + result["num_depth_levels"] = self.num_depth_levels + result["z_step_down"] = self.z_step_down + result["angle_increment"] = self.angle_increment + result["dither_algorithm"] = ( + self.dither_algorithm.value if self.dither_algorithm else None + ) + result["bidir_x_offset_mm"] = self.bidir_x_offset_mm + return result + + @classmethod + def from_dict(cls, data: dict) -> EngraveStep: + step = cast("EngraveStep", super().from_dict(data)) + legacy = legacy_producer_params(data) + # Legacy type names implied a depth mode when none was saved. + old_type = (data.get("opsproducer_dict") or {}).get("type") + if old_type == "Rasterizer" and "depth_mode" not in legacy: + legacy["depth_mode"] = "CONSTANT_POWER" + if "direction_degrees" in legacy: + legacy["scan_angle"] = legacy.pop("direction_degrees") + elif old_type == "DitherRasterizer": + legacy["depth_mode"] = "DITHER" + step.scan_angle = data.get("scan_angle", legacy.get("scan_angle", 0.0)) + step.depth_mode = data.get( + "depth_mode", legacy.get("depth_mode", "POWER_MODULATION") + ) + step.invert = data.get("invert", legacy.get("invert", False)) + step.auto_levels = data.get( + "auto_levels", legacy.get("auto_levels", True) + ) + step.black_point = data.get( + "black_point", legacy.get("black_point", 0) + ) + step.white_point = data.get( + "white_point", legacy.get("white_point", 255) + ) + step.threshold = data.get("threshold", legacy.get("threshold", 128)) + step.line_interval_mm = data.get( + "line_interval_mm", legacy.get("line_interval_mm", None) + ) + step.sample_interval_mm = data.get( + "sample_interval_mm", legacy.get("sample_interval_mm", None) + ) + step.dot_width_correction_mm = data.get( + "dot_width_correction_mm", None + ) + step.min_power_level = data.get( + "min_power_level", + legacy.get("min_power", data.get("min_power", 0.0)), + ) + step.max_power_level = data.get( + "max_power_level", + legacy.get("max_power", data.get("max_power", 1.0)), + ) + if "max_power_level" not in data: + # Legacy engrave files stored the raster ceiling under the + # max_power key; don't let it leak into the hardware max slot. + step.max_power = 1000 + step.num_power_levels = int( + data.get("num_power_levels", legacy.get("num_power_levels", 25)) + ) + step.offset_x_mm = data.get( + "offset_x_mm", legacy.get("offset_x_mm", 0.0) + ) + step.offset_y_mm = data.get( + "offset_y_mm", legacy.get("offset_y_mm", 0.0) + ) + scan_mode_str = data.get( + "scan_mode", legacy.get("scan_mode", "SEGMENTED") + ) + scan_mode_map = { + "SEGMENTED": "SEGMENTED", + "FULL_SWEEP": "FULL_SWEEP", + "Segmented": "SEGMENTED", + "FullSweep": "FULL_SWEEP", + } + step.scan_mode = scan_mode_map.get(scan_mode_str, "SEGMENTED") + step.cross_hatch = data.get( + "cross_hatch", legacy.get("cross_hatch", False) + ) + step.num_depth_levels = int( + data.get("num_depth_levels", legacy.get("num_depth_levels", 5)) + ) + step.z_step_down = data.get( + "z_step_down", legacy.get("z_step_down", 0.0) + ) + step.angle_increment = data.get( + "angle_increment", legacy.get("angle_increment", 0.0) + ) + dither_val = data.get( + "dither_algorithm", legacy.get("dither_algorithm") + ) + if dither_val is not None: + try: + step.dither_algorithm = DitherAlgorithm(dither_val) + except ValueError: + step.dither_algorithm = DitherAlgorithm.FLOYD_STEINBERG + step.bidir_x_offset_mm = data.get("bidir_x_offset_mm", 0.0) + return step + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + return super()._serialized_keys() | frozenset( + { + "scan_angle", + "depth_mode", + "invert", + "auto_levels", + "black_point", + "white_point", + "threshold", + "line_interval_mm", + "sample_interval_mm", + "dot_width_correction_mm", + "min_power_level", + "max_power_level", + "num_power_levels", + "offset_x_mm", + "offset_y_mm", + "scan_mode", + "cross_hatch", + "num_depth_levels", + "z_step_down", + "angle_increment", + "dither_algorithm", + "bidir_x_offset_mm", + "min_power", + "max_power", + } + ) + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + OverscanTransformer = transformer_registry.get("OverscanTransformer") + Optimize = transformer_registry.get("Optimize") + MultiPassTransformer = transformer_registry.get("MultiPassTransformer") + BidirScanOffsetTransformer = transformer_registry.get( + "BidirScanOffsetTransformer" + ) + assert OverscanTransformer is not None + assert Optimize is not None + assert MultiPassTransformer is not None + assert BidirScanOffsetTransformer is not None + optimize_dict = Optimize().to_dict() + return [ + OverscanTransformer( + enabled=True, distance_mm=0, auto=True + ).to_dict(), + optimize_dict, + BidirScanOffsetTransformer(enabled=True).to_dict(), + ], [ + optimize_dict, + MultiPassTransformer(passes=1, z_step_down=0.0).to_dict(), + ] + + @classmethod + def create( + cls, + context: RayforgeContext, + name: str | None = None, + **kwargs, + ) -> EngraveStep: + machine = context.machine + assert machine is not None + default_head = machine.get_default_laser_head() + if default_head is None: + raise ValueError("Machine has no laser heads configured.") + + step = cls(name=name) + per_wp, per_step = cls.get_default_transformers_dicts() + + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + step.selected_head_uid = default_head.uid + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + # Operating feed defaults are machine-derived: the machine only + # exposes its ceiling, so the default is that ceiling, bounded by + # the operation's typical feed rate (engraving is faster than + # cutting). + step.cut_speed = min(machine.max_cut_speed, 4000) + params = machine.get_pwm_params(default_head) + if params is not None: + step.frequency = params.frequency + step.pulse_width = params.pulse_width + + OverscanTransformer = cast( + "OverscanTransformerType", + transformer_registry.get("OverscanTransformer"), + ) + assert OverscanTransformer is not None + auto_distance = OverscanTransformer.calculate_auto_distance( + step.cut_speed, machine.acceleration + ) + for t in per_wp: + if t.get("name") == "OverscanTransformer": + t["distance_mm"] = auto_distance + + return step + + +def _build_raster_part( + step: EngraveStep, + machine: Machine, + workpiece: WorkPiece, +) -> tuple[Part, np.ndarray | None]: + """Render and preprocess the workpiece into a :class:`Part` + carrying a :class:`WholeImageSource`, and return the alpha + channel separately so the caller can fold it into the + :class:`RasterSpec`. + + The rendering resolution is clamped to + :data:`MAX_RASTER_RENDER_PIXELS` to bound memory. Auto-levels + are precomputed here (see target-architecture.md B3.3) so all + slabs see consistent black/white points. + """ + size = workpiece.size + if size[0] <= 0 or size[1] <= 0: + return Part(size_mm=size), None + + spot_x, spot_y = LaserHead.get_spot_size(step.get_selected_laser(machine)) + px_per_mm_x = 1.0 / (step.sample_interval_mm or spot_x / 2.0) + px_per_mm_y = 1.0 / spot_y + + target_w = max(1, int(size[0] * px_per_mm_x)) + target_h = max(1, int(size[1] * px_per_mm_y)) + num_pixels = target_w * target_h + if num_pixels > MAX_RASTER_RENDER_PIXELS: + scale = (MAX_RASTER_RENDER_PIXELS / num_pixels) ** 0.5 + target_w = max(1, int(target_w * scale)) + target_h = max(1, int(target_h * scale)) + + # Recompute pixels-per-mm from the actual integer target dimensions so + # that the rendered image pixels exactly cover the workpiece size. + # Without this, the int() truncation above leaves the image slightly + # smaller than size_mm, shrinking the raster by up to one pixel. + px_per_mm_x = target_w / size[0] + px_per_mm_y = target_h / size[1] + + surface = workpiece.render_to_pixels(target_w, target_h) + if surface is None: + return Part(size_mm=size), None + + depth_mode = DepthMode[step.depth_mode] + + computed_auto_levels = None + if step.auto_levels: + 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, + white_point=step.white_point, + threshold=step.threshold, + dither_algorithm=step.dither_algorithm, + laser_spot_x_mm=spot_x, + pixels_per_mm_x=px_per_mm_x, + ) + surface.flush() + if image is None: + return Part(size_mm=size), None + + part = Part( + size_mm=size, + pixels_per_mm=(px_per_mm_x, px_per_mm_y), + ) + part.image_source = WholeImageSource(image) + return part, alpha + + +MAX_RASTER_RENDER_PIXELS = 16 * 1024 * 1024 diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/shrinkwrap_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/shrinkwrap_step.py new file mode 100644 index 000000000..fbc6a1315 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/shrinkwrap_step.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Protocol, cast + +import numpy as np +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.shrinkwrap import ShrinkwrapSpec +from raygeo.ops.part import Part +from raygeo.ops.part.image_source import WholeImageSource + +from rayforge.core.capability import MachineCapability +from rayforge.core.cut_side import CutSide +from rayforge.core.step import legacy_producer_params +from rayforge.core.varset import ( + FloatVar, + LabeledChoiceVar, + LengthVar, + VarSet, +) +from rayforge.image.tracing import prepare_surface +from rayforge.pipeline.stage.assembler_helpers import ( + build_part_vector, +) +from rayforge.pipeline.transformer.registry import transformer_registry + +from .laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + class LeadInOutTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +class ShrinkWrapStep(LaserStep): + TYPELABEL = _("Shrink Wrap") + ICON = "step-shrinkwrap-symbolic" + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.LASER}) + ASSEMBLER_NAME = "shrinkwrap" + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *LaserStep.recipe_varset().vars, + LabeledChoiceVar( + key="cut_side", + label=_("Cut Side"), + choices=[(cs.label(), cs.name) for cs in CutSide], + default="CENTERLINE", + ), + LengthVar( + key="offset_mm", + label=_("Offset"), + description=_( + "Shifts the contour inward/outward per Cut " + "Side (none on Centerline). Defaults to kerf " + "compensation for the head" + ), + default=0.0, + ), + FloatVar( + key="gravity", + label=_("Gravity"), + default=0.0, + ), + ] + ) + + def __init__(self, name: str | None = None, typelabel: str | None = None): + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + self.power = 0.8 + self.gravity = 0.0 + self.offset_mm = 0.0 + self.cut_side = "CENTERLINE" + + def get_operation_mode_short(self): + if not self.cut_side: + return None + try: + return CutSide[self.cut_side].label() + except KeyError: + return None + + def get_assembler_kwargs( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict: + kwargs: dict = {} + kwargs["cut_side"] = self.cut_side.lower() + kwargs["gravity"] = self.gravity + kwargs["offset_mm"] = self.offset_mm + kwargs["arc_tolerance"] = machine.arc_tolerance + kwargs["allow_arcs"] = machine.supports_arcs + kwargs["supports_curves"] = machine.supports_curves + return kwargs + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + """Build a :class:`Part` with vector geometry and a boolean + image, and a :class:`ComputePayload` carrying a + :class:`ShrinkwrapSpec`.""" + part = _build_shrinkwrap_part(workpiece) + kwargs = self.get_assembler_kwargs(machine, workpiece) + spec = ShrinkwrapSpec( + gravity=kwargs["gravity"], + offset_mm=kwargs["offset_mm"], + cut_side=kwargs["cut_side"], + arc_tolerance=kwargs["arc_tolerance"], + allow_arcs=kwargs["allow_arcs"], + supports_curves=kwargs["supports_curves"], + ) + return part, ComputePayload(assembler=Assembler(spec)) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict | None: + return self.get_assembler_kwargs(machine, workpiece) + + def apply_import_settings(self, settings: dict) -> None: + """Apply importer-provided settings this step owns.""" + super().apply_import_settings(settings) + offset_mm = settings.get("offset_mm") + if offset_mm is not None: + self.offset_mm = offset_mm + + def to_dict(self) -> dict: + result = super().to_dict() + result["gravity"] = self.gravity + result["offset_mm"] = self.offset_mm + result["cut_side"] = self.cut_side + return result + + @classmethod + def from_dict(cls, data: dict) -> ShrinkWrapStep: + step = cast("ShrinkWrapStep", super().from_dict(data)) + legacy = legacy_producer_params(data) + step.gravity = data.get("gravity", legacy.get("gravity", 0.0)) + if "offset_mm" in data: + step.offset_mm = data["offset_mm"] + else: + path_offset = data.get( + "path_offset_mm", + legacy.get("path_offset_mm", legacy.get("offset_mm", 0.0)), + ) + step.offset_mm = path_offset + (data.get("kerf_mm", 0.0) / 2.0) + step.cut_side = data.get( + "cut_side", + legacy.get("cut_side", legacy.get("kerf_mode", "CENTERLINE")), + ) + return step + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + Smooth = transformer_registry.get("Smooth") + LeadInOutTransformer = transformer_registry.get("LeadInOutTransformer") + TabOpsTransformer = transformer_registry.get("TabOpsTransformer") + CropTransformer = transformer_registry.get("CropTransformer") + MergeLinesTransformer = transformer_registry.get( + "MergeLinesTransformer" + ) + Optimize = transformer_registry.get("Optimize") + MultiPassTransformer = transformer_registry.get("MultiPassTransformer") + assert Smooth is not None + assert LeadInOutTransformer is not None + assert TabOpsTransformer is not None + assert CropTransformer is not None + assert MergeLinesTransformer is not None + assert Optimize is not None + assert MultiPassTransformer is not None + optimize_dict = Optimize().to_dict() + return [ + Smooth(enabled=False, amount=20).to_dict(), + LeadInOutTransformer( + enabled=False, lead_in_mm=0, lead_out_mm=0, auto=True + ).to_dict(), + TabOpsTransformer().to_dict(), + CropTransformer(enabled=False).to_dict(), + optimize_dict, + ], [ + MergeLinesTransformer().to_dict(), + optimize_dict, + MultiPassTransformer(passes=1, z_step_down=0.0).to_dict(), + ] + + @classmethod + def create( + cls, + context: RayforgeContext, + name: str | None = None, + **kwargs, + ) -> ShrinkWrapStep: + machine = context.machine + assert machine is not None + default_head = machine.get_default_laser_head() + if default_head is None: + raise ValueError("Machine has no laser heads configured.") + + step = cls(name=name) + per_wp, per_step = cls.get_default_transformers_dicts() + + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + step.selected_head_uid = default_head.uid + step.offset_mm = default_head.kerf_mm + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + # Operating feed defaults are machine-derived: the machine only + # exposes its ceiling, so the default is that ceiling, bounded by + # the operation's typical feed rate. + step.cut_speed = min(machine.max_cut_speed, 500) + params = machine.get_pwm_params(default_head) + if params is not None: + step.frequency = params.frequency + step.pulse_width = params.pulse_width + + LeadInOutTransformer = cast( + "LeadInOutTransformerType", + transformer_registry.get("LeadInOutTransformer"), + ) + if LeadInOutTransformer: + calc = LeadInOutTransformer.calculate_auto_distance + auto_distance = calc(step.cut_speed, machine.acceleration) + for t in per_wp: + if t.get("name") == "LeadInOutTransformer": + t["lead_in_mm"] = auto_distance + t["lead_out_mm"] = auto_distance + + return step + + +def _build_shrinkwrap_part(workpiece: WorkPiece) -> Part: + """Build a :class:`Part` for the shrinkwrap assembler. + + The shrinkwrap assembler needs both vector geometry (for the + boundary constraint) and a boolean image (for the hull + computation). This function always renders the workpiece to a + surface and prepares the boolean image, then attaches it as a + :class:`WholeImageSource` alongside any vector geometry. + """ + size = workpiece.size + if size[0] <= 0 or size[1] <= 0: + return Part(size_mm=size) + + px_per_mm = (50.0, 50.0) + target_w = max(1, int(size[0] * px_per_mm[0])) + target_h = max(1, int(size[1] * px_per_mm[1])) + surface = workpiece.render_to_pixels(target_w, target_h) + if surface is None: + return Part(size_mm=size) + + boolean = prepare_surface(surface) + if not np.any(boolean): + return Part(size_mm=size) + + part = build_part_vector(workpiece) + if part is None or not part.has_geometry(): + part = Part(size_mm=size) + part.image_source = WholeImageSource(boolean) + return part diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/wavefront_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/wavefront_step.py new file mode 100644 index 000000000..d15e83780 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/wavefront_step.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.wavefront import AdaptiveWavefrontSpec +from raygeo.ops.part import Part + +from rayforge.core.capability import MachineCapability +from rayforge.core.step import legacy_producer_params +from rayforge.core.varset import LengthVar, VarSet +from rayforge.machine.models.laser import LaserHead +from rayforge.pipeline.stage.assembler_helpers import ( + build_part_vector_with_raster_fallback, +) +from rayforge.pipeline.transformer.registry import transformer_registry + +from .laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + from rayforge.core.workpiece import WorkPiece + from rayforge.machine.models.machine import Machine + + +class WavefrontStep(LaserStep): + TYPELABEL = _("Wavefront") + ICON = "step-wavefront-symbolic" + REQUIRED_MACHINE_CAPS = frozenset({MachineCapability.LASER}) + ASSEMBLER_NAME = "wavefront" + + @classmethod + def recipe_varset(cls) -> VarSet: + return VarSet( + vars=[ + *LaserStep.recipe_varset().vars, + LengthVar( + key="step_over_mm", + label=_("Step Over"), + description=_( + "Distance between wavefront passes; defaults to " + "the laser spot width when unset" + ), + default=None, + min_val=0.0, + ), + LengthVar( + key="offset_mm", + label=_("Offset"), + default=0.0, + ), + ] + ) + + def __init__(self, name: str | None = None, typelabel: str | None = None): + super().__init__(typelabel=typelabel or self.TYPELABEL, name=name) + self.power = 0.8 + self.step_over_mm: float | None = None + self.offset_mm = 0.0 + self.area_tolerance = 0.01 + + def get_assembler_kwargs( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict: + spot_x, _spot_y = LaserHead.get_spot_size( + self.get_selected_laser(machine) + ) + kwargs: dict = {} + kwargs["offset_mm"] = self.offset_mm + kwargs["area_tolerance"] = self.area_tolerance + kwargs["step_over"] = ( + self.step_over_mm if self.step_over_mm is not None else spot_x + ) + kwargs["precision"] = machine.arc_tolerance + kwargs["cut_feed_rate"] = self.cut_speed + kwargs["cut_power"] = self.power + return kwargs + + def build_compute_payload( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> tuple[Part, ComputePayload]: + """Build a :class:`Part` with normalised-winding vector + geometry and a :class:`ComputePayload` carrying an + :class:`AdaptiveWavefrontSpec`. + + When the workpiece has no vector boundaries (e.g. an SVG with + empty ``pristine_geometry``), the source is rendered to pixels + and traced into geometry before assembling. + """ + part = build_part_vector_with_raster_fallback( + workpiece, + self.pixels_per_mm, + normalize_windings=True, + ) + kwargs = self.get_assembler_kwargs(machine, workpiece) + spec = AdaptiveWavefrontSpec( + kwargs["step_over"], + 0.0, + kwargs["area_tolerance"], + kwargs["precision"], + ) + return part, ComputePayload(assembler=Assembler(spec)) + + def assembler_token_params( + self, + machine: Machine, + workpiece: WorkPiece, + ) -> dict | None: + return self.get_assembler_kwargs(machine, workpiece) + + def to_dict(self) -> dict: + result = super().to_dict() + result["step_over_mm"] = self.step_over_mm + result["offset_mm"] = self.offset_mm + result["area_tolerance"] = self.area_tolerance + return result + + @classmethod + def from_dict(cls, data: dict) -> WavefrontStep: + step = cast("WavefrontStep", super().from_dict(data)) + # Projects saved before the raygeo-pipeline refactor stored the + # producer parameters inside ``opsproducer_dict.params``. Migrate + # them so the saved step-over survives loading instead of falling + # back to the laser spot size. + legacy = legacy_producer_params(data) + step.step_over_mm = data.get( + "step_over_mm", legacy.get("step_over_mm", None) + ) + step.offset_mm = data.get("offset_mm", legacy.get("offset_mm", 0.0)) + step.area_tolerance = data.get( + "area_tolerance", legacy.get("area_tolerance", 0.01) + ) + return step + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + CropTransformer = transformer_registry.get("CropTransformer") + Optimize = transformer_registry.get("Optimize") + MultiPassTransformer = transformer_registry.get("MultiPassTransformer") + assert CropTransformer is not None + assert Optimize is not None + assert MultiPassTransformer is not None + optimize_dict = Optimize().to_dict() + return [ + CropTransformer(enabled=False).to_dict(), + optimize_dict, + ], [ + optimize_dict, + MultiPassTransformer(passes=1, z_step_down=0.0).to_dict(), + ] + + @classmethod + def create( + cls, + context: RayforgeContext, + name: str | None = None, + **kwargs, + ) -> WavefrontStep: + machine = context.machine + assert machine is not None + default_head = machine.get_default_laser_head() + if default_head is None: + raise ValueError("Machine has no laser heads configured.") + + step = cls(name=name) + per_wp, per_step = cls.get_default_transformers_dicts() + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + step.selected_head_uid = default_head.uid + step.max_cut_speed = machine.max_cut_speed + step.max_travel_speed = machine.max_travel_speed + # Operating feed defaults are machine-derived: the machine only + # exposes its ceiling, so the default is that ceiling, bounded by + # the operation's typical feed rate. + step.cut_speed = min(machine.max_cut_speed, 500) + params = machine.get_pwm_params(default_head) + if params is not None: + step.frequency = params.frequency + step.pulse_width = params.pulse_width + return step diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/__init__.py new file mode 100644 index 000000000..ff3a94ae1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/__init__.py @@ -0,0 +1,29 @@ +""" +Laser Essentials UI Widgets. +""" + +from .contour_page import ContourStepSettingsPage +from .frame_page import FrameStepSettingsPage +from .material_test_grid_page import MaterialTestGridSettingsPage +from .raster_page import RasterSettingsPage +from .shrinkwrap_page import ShrinkWrapStepSettingsPage +from .wavefront_page import WavefrontStepSettingsPage + +ASSEMBLER_WIDGETS = { + "contour": ContourStepSettingsPage, + "frame": FrameStepSettingsPage, + "raster": RasterSettingsPage, + "shrinkwrap": ShrinkWrapStepSettingsPage, + "wavefront": WavefrontStepSettingsPage, + "material_test_grid": MaterialTestGridSettingsPage, +} + +__all__ = [ + "ASSEMBLER_WIDGETS", + "ContourStepSettingsPage", + "FrameStepSettingsPage", + "MaterialTestGridSettingsPage", + "RasterSettingsPage", + "ShrinkWrapStepSettingsPage", + "WavefrontStepSettingsPage", +] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/contour_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/contour_page.py new file mode 100644 index 000000000..bdc444aec --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/contour_page.py @@ -0,0 +1,121 @@ +"""Contour step settings widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.core.cut_side import CutOrder +from rayforge.ui_gtk.doceditor.step_settings.rows import ( + ComboRow, + SliderRow, + SpinRow, + SwitchRow, +) + +from .rows import CutSideRow, LaserStepSettingsPage, OffsetRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class CutOrderRow(ComboRow): + """Combo row bound to the ``cut_order`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + choices = [(co.label(), co.name) for co in CutOrder] + super().__init__( + editor, + step, + "cut_order", + _("Cut Order"), + choices, + _("Processing order for nested paths"), + ) + + +class RemoveInnerPathsRow(SwitchRow): + """Switch row bound to the ``remove_inner_paths`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "remove_inner_paths", + _("Remove Inner Paths"), + _("If enabled, only trace the outer outline of shapes"), + ) + + +class OvercutRow(SpinRow): + """Spin row bound to the ``overcut`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "overcut", + _("Overcut"), + _( + "Extend closed contours past their start point " + "so the cut overlaps itself" + ), + 0.0, + 100.0, + 0.1, + 2, + quantity="length", + ) + + +class RescanContentRow(SwitchRow): + """Switch row bound to the ``override_threshold`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "override_threshold", + _("Rescan Content"), + _("Ignore source geometry and re-trace within the workpiece"), + ) + + +class ThresholdRow(SliderRow): + """Slider row bound to the ``threshold`` attribute. + + Only visible while rescanning content is enabled. + """ + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "threshold", + _("Tracing Threshold"), + _("Brightness level (0.0-1.0) to define edges"), + 0.0, + 1.0, + 0.01, + 2, + ) + + def _sync_dependencies(self): + self.set_visible(self.step.override_threshold) + + +class ContourStepSettingsPage(LaserStepSettingsPage): + """Settings page for the ContourStep.""" + + include_tab_power = True + + def _add_step_sections(self): + self.add_section( + _("Contour Settings"), + CutSideRow, + OffsetRow, + CutOrderRow, + RemoveInnerPathsRow, + OvercutRow, + ThresholdRow, + RescanContentRow, + description=_("Trace the outline of the selected shapes."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/frame_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/frame_page.py new file mode 100644 index 000000000..0f701fe35 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/frame_page.py @@ -0,0 +1,22 @@ +"""Frame step settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from .rows import CutSideRow, LaserStepSettingsPage, OffsetRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class FrameStepSettingsPage(LaserStepSettingsPage): + """Settings page for the FrameStep.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__(editor, step) + self.add_section( + _("Geometry"), + CutSideRow, + OffsetRow, + description=_("Cut a frame around the selected content."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py new file mode 100644 index 000000000..e602e7fac --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py @@ -0,0 +1,752 @@ +""" +Material Test Grid Settings Widget + +Provides UI for configuring material test array parameters. +""" + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, GLib, GObject, Gtk + +from rayforge.machine.models.laser import LaserHead +from rayforge.ui_gtk.shared.pref_rows import SpinRow +from rayforge.ui_gtk.shared.slider import create_slider_row + +from ..material_test_helpers import GridMode +from .rows import LaserStepSettingsPage + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +logger = logging.getLogger(__name__) + +PRESET_KEYS = [ + "Diode Engrave", + "Diode Cut", + "CO2 Engrave", + "CO2 Cut", +] + +PRESETS = { + "Diode Engrave": { + "test_type": "Engrave", + "speed_range": (1000.0, 10000.0), + "power_range": (10.0, 100.0), + }, + "Diode Cut": { + "test_type": "Cut", + "speed_range": (100.0, 5000.0), + "power_range": (50.0, 100.0), + }, + "CO2 Engrave": { + "test_type": "Engrave", + "speed_range": (3000.0, 20000.0), + "power_range": (10.0, 50.0), + }, + "CO2 Cut": { + "test_type": "Cut", + "speed_range": (1000.0, 20000.0), + "power_range": (30.0, 100.0), + }, +} + + +class MaterialTestGridSettingsPage(LaserStepSettingsPage): + """Material Test Grid settings widget.""" + + include_process = False + + def __init__( + self, + editor: "DocEditor", + step: Any, + ): + super().__init__(editor, step) + preset_group = self.add_section( + _("Preset"), + description=_("Load common test configurations."), + ) + grid_group = self.add_section( + _("Grid"), + description=_("Test cell dimensions, shape, and spacing."), + ) + labels_group = self.add_section( + _("Labels"), + description=_("Speed/power annotations on the grid."), + ) + self._param_group = self.add_section( + _("Parameters"), + description=_("Define the parameter ranges for the test grid."), + ) + + self._build_preset_selector(preset_group) + self._build_test_type_selector(grid_group) + self._build_grid_mode_selector(grid_group) + self._build_grid_dimensions(grid_group) + self._build_shape_size(grid_group) + self._build_spacing(grid_group) + self._build_label_settings(labels_group) + self._build_power_and_speed_group(self._param_group) + + def _add(self, group, widget): + self._rows.append(widget) + group.add(widget) + + def _build_preset_selector(self, group): + """Builds the preset dropdown.""" + _PRESET_LABELS = { + "Diode Engrave": _("Diode Engrave"), + "Diode Cut": _("Diode Cut"), + "CO2 Engrave": _("CO2 Engrave"), + "CO2 Cut": _("CO2 Cut"), + } + string_list = Gtk.StringList() + string_list.append(_("Select")) + for key in PRESET_KEYS: + string_list.append(_PRESET_LABELS[key]) + + self.preset_row = Adw.ComboRow( + title=_("Presets"), + subtitle=_("Load common test configurations"), + model=string_list, + ) + self.preset_row.set_selected(0) + self._add(group, self.preset_row) + self.preset_row.connect("notify::selected", self._on_preset_changed) + + def _build_test_type_selector(self, group): + """Builds the test type dropdown (Cut/Engrave).""" + from ..material_test_helpers import MaterialTestGridType + + self._test_type_values = [m.value for m in MaterialTestGridType] + test_type_labels = [m.label() for m in MaterialTestGridType] + string_list = Gtk.StringList.new(test_type_labels) + self.test_type_row = Adw.ComboRow( + title=_("Test Type"), + subtitle=_("Cut: outlines; Engrave: fills with raster lines"), + model=string_list, + ) + current_text = self.step.test_type + for i, val in enumerate(self._test_type_values): + if val == current_text: + self.test_type_row.set_selected(i) + break + self._add(group, self.test_type_row) + self.test_type_row.connect( + "notify::selected", self._on_test_type_changed + ) + + def _build_grid_mode_selector(self, group): + """Builds the grid mode dropdown.""" + self._grid_mode_values = [m.value for m in GridMode] + grid_mode_labels = [m.label() for m in GridMode] + string_list = Gtk.StringList.new(grid_mode_labels) + self.grid_mode_row = Adw.ComboRow( + title=_("Grid Mode"), + subtitle=_("Choose which parameters to vary on axes"), + model=string_list, + ) + current_mode = self.step.grid_mode + for i, val in enumerate(self._grid_mode_values): + if val == current_mode: + self.grid_mode_row.set_selected(i) + break + self._add(group, self.grid_mode_row) + self.grid_mode_row.connect( + "notify::selected", self._on_grid_mode_changed + ) + + def _build_power_and_speed_group(self, group): + """Builds the group for power and speed settings.""" + machine_max_speed = self.step.max_cut_speed + + # Fixed Speed (used in Power vs Passes mode) + self.fixed_speed_row = SpinRow( + _("Fixed Speed"), + _("Constant speed for all cells (mm/min)"), + lower=1.0, + upper=machine_max_speed, + step_increment=10.0, + digits=0, + value=min(self.step.fixed_speed, machine_max_speed), + ) + self._add(group, self.fixed_speed_row) + self.fixed_speed_row.value_changed.connect( + lambda r: self._debounce(self._on_fixed_speed_changed, r), + ) + + # Fixed Power (used in Speed vs Passes mode) + fixed_power_adj = Gtk.Adjustment( + lower=1, + upper=100, + step_increment=0.1, + value=self.step.fixed_power, + ) + self.fixed_power_row, self.fixed_power_scale = create_slider_row( + title=_("Fixed Power (%)"), + adjustment=fixed_power_adj, + subtitle=_("Constant power for all cells"), + digits=1, + on_value_changed=lambda s: self._debounce( + self._on_fixed_power_changed, s + ), + ) + self._add(group, self.fixed_power_row) + + # Power Range (used in Power vs Speed and Power vs Passes modes) + min_power, max_power = self.step.power_range + self.min_power_adj = Gtk.Adjustment( + lower=1, upper=100, step_increment=0.1, value=min_power + ) + min_power_row, self.min_power_scale = create_slider_row( + title=_("Minimum Power (%)"), + adjustment=self.min_power_adj, + subtitle=_("For first column"), + digits=1, + ) + self.min_power_row = min_power_row + self._add(group, self.min_power_row) + + self.max_power_adj = Gtk.Adjustment( + lower=1, upper=100, step_increment=0.1, value=max_power + ) + max_power_row, self.max_power_scale = create_slider_row( + title=_("Maximum Power (%)"), + adjustment=self.max_power_adj, + subtitle=_("For last column"), + digits=1, + ) + self.max_power_row = max_power_row + self._add(group, self.max_power_row) + + self.min_power_handler_id = self.min_power_scale.connect( + "value-changed", self._on_min_power_scale_changed + ) + self.max_power_handler_id = self.max_power_scale.connect( + "value-changed", self._on_max_power_scale_changed + ) + + # Speed Range (used in Power vs Speed and Speed vs Passes modes) + min_speed, max_speed = self.step.speed_range + machine_max_speed = self.step.max_cut_speed + min_speed = min(min_speed, machine_max_speed) + max_speed = min(max_speed, machine_max_speed) + self.speed_min_row = SpinRow( + _("Minimum Speed"), + _("Starting speed (mm/min)"), + lower=1.0, + upper=machine_max_speed, + step_increment=10.0, + digits=0, + value=min_speed, + ) + self._add(group, self.speed_min_row) + + self.speed_max_row = SpinRow( + _("Maximum Speed"), + _("Ending speed (mm/min)"), + lower=1.0, + upper=machine_max_speed, + step_increment=10.0, + digits=0, + value=max_speed, + ) + self._add(group, self.speed_max_row) + + self.speed_min_row.value_changed.connect( + lambda r: self._debounce(self._on_speed_min_changed, r) + ) + self.speed_max_row.value_changed.connect( + lambda r: self._debounce(self._on_speed_max_changed, r) + ) + + # Passes Range (used in Power vs Passes and Speed vs Passes modes) + min_passes, max_passes = self.step.passes_range + self.passes_min_row = SpinRow( + _("Minimum Passes"), + _("Starting number of passes"), + lower=1, + upper=50, + digits=0, + value=min_passes, + ) + self._add(group, self.passes_min_row) + + self.passes_max_row = SpinRow( + _("Maximum Passes"), + _("Ending number of passes"), + lower=1, + upper=50, + digits=0, + value=max_passes, + ) + self._add(group, self.passes_max_row) + + self.passes_min_row.value_changed.connect( + lambda r: self._debounce(self._on_passes_min_changed, r), + ) + self.passes_max_row.value_changed.connect( + lambda r: self._debounce(self._on_passes_max_changed, r), + ) + + # Offset Range (used in Speed vs Offset mode) + min_offset, max_offset = self.step.offset_range + self.offset_min_row = SpinRow( + _("Minimum Offset"), + _("Bidir scan X-offset for first row (mm)"), + lower=-10.0, + upper=10.0, + step_increment=0.05, + digits=2, + value=min_offset, + ) + self._add(group, self.offset_min_row) + + self.offset_max_row = SpinRow( + _("Maximum Offset"), + _("Bidir scan X-offset for last row (mm)"), + lower=-10.0, + upper=10.0, + step_increment=0.05, + digits=2, + value=max_offset, + ) + self._add(group, self.offset_max_row) + + self.offset_min_row.value_changed.connect( + lambda r: self._debounce(self._on_offset_min_changed, r), + ) + self.offset_max_row.value_changed.connect( + lambda r: self._debounce(self._on_offset_max_changed, r), + ) + + # Label settings + power_adj = Gtk.Adjustment( + lower=1, + upper=100, + step_increment=0.1, + value=self.step.label_power_percent, + ) + self.label_power_row, _power_scale = create_slider_row( + title=_("Label Engrave Power (%)"), + adjustment=power_adj, + digits=1, + on_value_changed=lambda s: self._debounce( + self._on_label_power_changed, s + ), + ) + self._add(group, self.label_power_row) + + self.label_speed_row = SpinRow( + _("Label Engrave Speed"), + _("Speed for engraving labels (mm/min)"), + lower=1.0, + upper=machine_max_speed, + step_increment=10.0, + digits=0, + value=min(self.step.label_speed, machine_max_speed), + ) + self._add(group, self.label_speed_row) + self.label_speed_row.value_changed.connect( + lambda r: self._debounce(self._on_label_speed_changed, r), + ) + + self._on_labels_toggled( + self.include_labels_switch, self.step.include_labels + ) + + self._update_control_visibility() + self._update_dimension_labels() + + def _build_grid_dimensions(self, group): + """Builds grid dimension controls.""" + cols, rows = self.step.grid_dimensions + + self.cols_row = SpinRow( + _("Columns (Power Steps)"), + _("Number of power variations"), + lower=2, + upper=20, + digits=0, + value=cols, + ) + self._add(group, self.cols_row) + + self.rows_row = SpinRow( + _("Rows (Speed Steps)"), + _("Number of speed variations"), + lower=2, + upper=20, + digits=0, + value=rows, + ) + self._add(group, self.rows_row) + + self.cols_row.value_changed.connect( + lambda r: self._debounce(self._on_grid_cols_changed, r) + ) + self.rows_row.value_changed.connect( + lambda r: self._debounce(self._on_grid_rows_changed, r) + ) + + def _build_shape_size(self, group): + """Builds shape size control.""" + self.shape_size_row = SpinRow( + _("Shape Size"), + _("Size of each test square (mm)"), + lower=1, + upper=100, + digits=1, + value=self.step.shape_size, + ) + self._add(group, self.shape_size_row) + self.shape_size_row.value_changed.connect( + lambda r: self._debounce(self._on_shape_size_changed, r) + ) + + def _build_spacing(self, group): + """Builds spacing control.""" + self.spacing_row = SpinRow( + _("Spacing"), + _("Gap between test squares (mm)"), + upper=50, + step_increment=0.5, + digits=1, + value=self.step.spacing, + ) + self._add(group, self.spacing_row) + self.spacing_row.value_changed.connect( + lambda r: self._debounce(self._on_spacing_changed, r) + ) + + head = self.get_selected_head() + laser = head if isinstance(head, LaserHead) else None + default_line_interval_mm = laser.spot_size_mm[1] if laser else 0.1 + self.line_interval_row = SpinRow( + _("Line Interval"), + _( + "Distance between scan lines in machine units " + "(for Engrave mode). Leave at 0 to use laser spot size." + ), + lower=0.01, + upper=10.0, + step_increment=0.01, + digits=2, + value=( + self.step.line_interval_mm + if self.step.line_interval_mm is not None + else default_line_interval_mm + ), + ) + self._add(group, self.line_interval_row) + self.line_interval_row.value_changed.connect( + lambda r: self._debounce(self._on_line_interval_changed, r), + ) + + def _build_label_settings(self, group): + """Builds controls for label appearance and behavior.""" + self.include_labels_switch = Gtk.Switch( + valign=Gtk.Align.CENTER, active=self.step.include_labels + ) + labels_row = Adw.ActionRow( + title=_("Include Labels"), + subtitle=_("Add speed/power annotations to the grid"), + ) + labels_row.add_suffix(self.include_labels_switch) + labels_row.set_activatable_widget(self.include_labels_switch) + self._add(group, labels_row) + + self.include_labels_switch.connect( + "state-set", self._on_labels_toggled + ) + + # Signal handlers + def _on_preset_changed(self, row: Adw.ComboRow, _pspec): + """Loads preset values.""" + selected_idx = row.get_selected() + if selected_idx == Gtk.INVALID_LIST_POSITION or selected_idx == 0: + return + preset_key = PRESET_KEYS[selected_idx - 1] + preset = PRESETS[preset_key] + speed_range = preset["speed_range"] + power_range = preset["power_range"] + test_type = preset.get("test_type", "Cut") + + machine_max_speed = self.step.max_cut_speed + min_speed = min(speed_range[0], machine_max_speed) + max_speed = min(speed_range[1], machine_max_speed) + + self.speed_min_row.set_value(min_speed) + self.speed_max_row.set_value(max_speed) + self.min_power_adj.set_value(power_range[0]) + self.max_power_adj.set_value(power_range[1]) + + # Cancel any debounced callbacks triggered by set_value() above. + # DebounceMixin uses a single timer slot, so rapid set_value() + # calls cause earlier callbacks to be lost. We commit directly + # below instead. + if self._debounce_timer > 0: + GLib.source_remove(self._debounce_timer) + self._debounce_timer = 0 + + self._update_range_param("speed_range", (min_speed, max_speed)) + self._commit_power_range_change() + + for i, val in enumerate(self._test_type_values): + if val == test_type: + self.test_type_row.set_selected(i) + break + + def _on_test_type_changed(self, row: Adw.ComboRow, _pspec): + """Updates the test type parameter.""" + selected_idx = row.get_selected() + if selected_idx != Gtk.INVALID_LIST_POSITION: + test_type_text = self._test_type_values[selected_idx] + self._update_param("test_type", test_type_text) + + def _on_speed_min_changed(self, spin_row): + min_speed = spin_row.get_value() + max_speed = self.speed_max_row.get_value() + self._update_range_param("speed_range", (min_speed, max_speed)) + + def _on_speed_max_changed(self, spin_row): + min_speed = self.speed_min_row.get_value() + max_speed = spin_row.get_value() + self._update_range_param("speed_range", (min_speed, max_speed)) + + def _commit_power_range_change(self): + """Commits the min/max power range to the step.""" + min_p = self.min_power_adj.get_value() + max_p = self.max_power_adj.get_value() + new_range = (min_p, max_p) + + if self.step.power_range == new_range: + return + + self._exit_preview_mode_if_active() + self.set_step_property("power_range", new_range) + + def _on_min_power_scale_changed(self, scale: Gtk.Scale): + new_min_value = self.min_power_adj.get_value() + GObject.signal_handler_block( + self.max_power_scale, self.max_power_handler_id + ) + if self.max_power_adj.get_value() < new_min_value: + self.max_power_adj.set_value(new_min_value) + GObject.signal_handler_unblock( + self.max_power_scale, self.max_power_handler_id + ) + self._debounce(self._commit_power_range_change) + + def _on_max_power_scale_changed(self, scale: Gtk.Scale): + new_max_value = self.max_power_adj.get_value() + GObject.signal_handler_block( + self.min_power_scale, self.min_power_handler_id + ) + if self.min_power_adj.get_value() > new_max_value: + self.min_power_adj.set_value(new_max_value) + GObject.signal_handler_unblock( + self.min_power_scale, self.min_power_handler_id + ) + self._debounce(self._commit_power_range_change) + + def _on_grid_cols_changed(self, spin_row): + cols = spin_row.get_int_value() + _, rows = self.step.grid_dimensions + self._update_grid_param((cols, rows)) + + def _on_grid_rows_changed(self, spin_row): + cols, _ = self.step.grid_dimensions + rows = spin_row.get_int_value() + self._update_grid_param((cols, rows)) + + def _on_shape_size_changed(self, spin_row): + self._update_param("shape_size", spin_row.get_value()) + + def _on_spacing_changed(self, spin_row): + self._update_param("spacing", spin_row.get_value()) + + def _on_line_interval_changed(self, spin_row): + value = spin_row.get_value() + if value <= 0: + value = None + self._update_param("line_interval_mm", value) + + def _on_labels_toggled(self, switch, state): + self.label_power_row.set_sensitive(state) + self.label_speed_row.set_sensitive(state) + self._update_param("include_labels", state) + return False + + def _on_label_power_changed(self, scale: Gtk.Scale): + val = scale.get_value() + logger.debug("Label power slider changed: %s", val) + self._update_param("label_power_percent", val) + + def _on_label_speed_changed(self, spin_row): + self._update_param("label_speed", spin_row.get_value()) + + def _on_grid_mode_changed(self, row: Adw.ComboRow, _pspec): + selected_idx = row.get_selected() + if selected_idx == Gtk.INVALID_LIST_POSITION: + return + mode_value = self._grid_mode_values[selected_idx] + self._update_param("grid_mode", mode_value) + self._update_control_visibility() + self._update_dimension_labels() + + if mode_value == "Speed vs Offset": + self._apply_speed_vs_offset_defaults() + + def _apply_speed_vs_offset_defaults(self): + """Bidir scan offset calibration only makes sense for raster + engraving (Cut has no bidirectional scanning to calibrate), and + needs wide line spacing to make row-to-row misalignment clearly + visible by eye. Can't default the preset dropdown too, since + there are multiple Engrave presets (Diode/CO2) with different + ranges.""" + for i, val in enumerate(self._test_type_values): + if val == "Engrave": + self.test_type_row.set_selected(i) + break + + if self._debounce_timer > 0: + GLib.source_remove(self._debounce_timer) + self._debounce_timer = 0 + self.line_interval_row.set_value(0.5) + self._update_param("line_interval_mm", 0.5) + + def _on_fixed_speed_changed(self, spin_row): + self._update_param("fixed_speed", spin_row.get_value()) + + def _on_fixed_power_changed(self, scale: Gtk.Scale): + self._update_param("fixed_power", scale.get_value()) + + def _on_passes_min_changed(self, spin_row): + min_passes = spin_row.get_int_value() + _, max_passes = self.step.passes_range + self._update_range_param("passes_range", (min_passes, max_passes)) + + def _on_passes_max_changed(self, spin_row): + min_passes, _ = self.step.passes_range + max_passes = spin_row.get_int_value() + self._update_range_param("passes_range", (min_passes, max_passes)) + + def _on_offset_min_changed(self, spin_row): + min_offset = spin_row.get_value() + _, max_offset = self.step.offset_range + self._update_range_param("offset_range", (min_offset, max_offset)) + + def _on_offset_max_changed(self, spin_row): + min_offset, _ = self.step.offset_range + max_offset = spin_row.get_value() + self._update_range_param("offset_range", (min_offset, max_offset)) + + def _get_current_grid_mode(self) -> str: + selected_idx = self.grid_mode_row.get_selected() + if selected_idx == Gtk.INVALID_LIST_POSITION: + return GridMode.POWER_VS_SPEED.value + return self._grid_mode_values[selected_idx] + + def _update_control_visibility(self): + mode = self._get_current_grid_mode() + show_power_range = mode in ("Power vs Speed", "Power vs Passes") + show_speed_range = mode in ( + "Power vs Speed", + "Speed vs Passes", + "Speed vs Offset", + ) + show_passes_range = mode in ("Power vs Passes", "Speed vs Passes") + show_offset_range = mode == "Speed vs Offset" + show_fixed_speed = mode == "Power vs Passes" + show_fixed_power = mode in ("Speed vs Passes", "Speed vs Offset") + + self.fixed_speed_row.set_visible(show_fixed_speed) + self.fixed_power_row.set_visible(show_fixed_power) + + self.min_power_row.set_visible(show_power_range) + self.max_power_row.set_visible(show_power_range) + self.min_power_scale.set_visible(show_power_range) + self.max_power_scale.set_visible(show_power_range) + + self.speed_min_row.set_visible(show_speed_range) + self.speed_max_row.set_visible(show_speed_range) + + self.passes_min_row.set_visible(show_passes_range) + self.passes_max_row.set_visible(show_passes_range) + + self.offset_min_row.set_visible(show_offset_range) + self.offset_max_row.set_visible(show_offset_range) + + def _update_dimension_labels(self): + mode = self._get_current_grid_mode() + if mode == "Power vs Passes": + col_title = _("Columns (Power Steps)") + col_sub = _("Number of power variations") + row_title = _("Rows (Passes Steps)") + row_sub = _("Number of passes variations") + elif mode == "Speed vs Passes": + col_title = _("Columns (Speed Steps)") + col_sub = _("Number of speed variations") + row_title = _("Rows (Passes Steps)") + row_sub = _("Number of passes variations") + elif mode == "Speed vs Offset": + col_title = _("Columns (Speed Steps)") + col_sub = _("Number of speed variations") + row_title = _("Rows (Offset Steps)") + row_sub = _("Number of offset variations") + else: + col_title = _("Columns (Power Steps)") + col_sub = _("Number of power variations") + row_title = _("Rows (Speed Steps)") + row_sub = _("Number of speed variations") + self.cols_row.set_title(col_title) + self.cols_row.set_subtitle(col_sub) + self.rows_row.set_title(row_title) + self.rows_row.set_subtitle(row_sub) + + # Helper methods + def _update_param(self, param_name: str, new_value: Any): + """Updates a simple parameter on the step.""" + current = getattr(self.step, param_name, None) + if current == new_value: + return + self._exit_preview_mode_if_active() + self.set_step_property(param_name, new_value) + + def _update_range_param(self, param_name: str, new_value: Any): + """Updates a range tuple parameter on the step.""" + current = getattr(self.step, param_name, None) + if current == new_value: + return + self._exit_preview_mode_if_active() + self.set_step_property(param_name, new_value) + + def _update_grid_param(self, new_value: Any): + """Updates grid dimensions on the step.""" + current = self.step.grid_dimensions + if current == new_value: + return + self._exit_preview_mode_if_active() + self.set_step_property("grid_dimensions", new_value) + + def _exit_preview_mode_if_active(self): + """Exits execution preview mode if currently active.""" + if not self.step.doc: + return + from rayforge.ui_gtk.mainwindow import MainWindow + + root = self.get_root() + if not isinstance(root, MainWindow): + return + + action = root.action_manager.get_action("view_mode") + if not action: + return + + state = action.get_state() + if state and state.get_string() == "preview": + action.change_state(GLib.Variant.new_string("2d")) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py new file mode 100644 index 000000000..f440d0a69 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py @@ -0,0 +1,632 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import numpy as np +from gi.repository import Adw, GLib, GObject, Gtk +from raygeo.image.grayscale import compute_auto_levels +from raygeo.image.scan import ScanMode + +from rayforge.image.dither import DitherAlgorithm +from rayforge.image.util import ( + get_visible_grayscale_values, +) +from rayforge.machine.models.laser import LaserHead +from rayforge.pipeline.stage.assembler_helpers import DepthMode +from rayforge.ui_gtk.shared.direction_preview import DirectionPreview +from rayforge.ui_gtk.shared.histogram_preview import HistogramPreview +from rayforge.ui_gtk.shared.pref_rows import ( + AngleSpinRow, + LengthSpinRow, + SpinRow, +) +from rayforge.ui_gtk.shared.slider import create_slider, create_slider_row + +from .rows import LaserStepSettingsPage + +_SCAN_MODES = [ScanMode.SEGMENTED, ScanMode.FULL_SWEEP] + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class RasterSettingsPage(LaserStepSettingsPage): + """UI for configuring the EngraveStep.""" + + cut_speed_title = _("Engrave Speed") + + def __init__( + self, + editor: "DocEditor", + step: Any, + ): + super().__init__(editor, step) + engrave_group = self.add_section( + _("Engrave"), + description=_("Raster the image onto the material."), + ) + histogram_group = self.add_section( + _("Power"), + description=_("Power modulation and brightness range."), + ) + + mode_choices = [m.display_name for m in DepthMode] + self.mode_row = Adw.ComboRow( + title=_("Mode"), model=Gtk.StringList.new(mode_choices) + ) + self.mode_row.set_selected( + list(DepthMode).index(DepthMode[step.depth_mode]) + ) + self._add(engrave_group, self.mode_row) + + # --- Threshold (for Constant Power mode) --- + threshold_adj = Gtk.Adjustment( + lower=0, + upper=255, + step_increment=1, + page_increment=10, + value=step.threshold, + ) + self.threshold_row, self.threshold_scale = create_slider_row( + title=_("Threshold"), + adjustment=threshold_adj, + subtitle=_("Brightness cutoff for black/white (0-255)"), + digits=0, + on_value_changed=lambda s: self._on_threshold_changed(s), + ) + self._add(engrave_group, self.threshold_row) + + # --- Dither Algorithm (for Dither mode) --- + dither_choices = [m.display_name for m in DitherAlgorithm] + self.dither_algorithm_row = Adw.ComboRow( + title=_("Engraving Method"), + subtitle=_("Algorithm for converting grayscale to binary"), + model=Gtk.StringList.new(dither_choices), + ) + current_algo = step.dither_algorithm or DitherAlgorithm.FLOYD_STEINBERG + self.dither_algorithm_row.set_selected( + list(DitherAlgorithm).index(current_algo) + ) + self.dither_algorithm_row.connect( + "notify::selected", self._on_dither_algorithm_changed + ) + self._add(engrave_group, self.dither_algorithm_row) + + # --- Raster Geometry --- + self._build_raster_geometry_group(engrave_group) + + # --- Histogram (Black/White Point) --- + self.histogram_preview = HistogramPreview() + self.histogram_preview.set_points(step.black_point, step.white_point) + self.histogram_preview.auto_mode = step.auto_levels + self.histogram_preview.black_point_changed.connect( + self._on_black_point_changed + ) + self.histogram_preview.white_point_changed.connect( + self._on_white_point_changed + ) + + self.auto_levels_row = Adw.SwitchRow( + title=_("Auto Levels"), + subtitle=_("Automatically adjust black/white points"), + ) + self.auto_levels_row.set_active(step.auto_levels) + self.auto_levels_row.connect( + "notify::active", self._on_auto_levels_changed + ) + self._add(histogram_group, self.auto_levels_row) + + self.histogram_row = Adw.ActionRow( + title=_("Brightness Range"), + subtitle=( + _("Auto-adjusted based on image content") + if step.auto_levels + else _("Drag markers to set black/white points") + ), + ) + self.histogram_row.add_suffix(self.histogram_preview) + self._add(histogram_group, self.histogram_row) + + # --- Power Modulation Settings --- + self.min_power_adj = Gtk.Adjustment( + lower=0, + upper=100, + step_increment=0.1, + value=step.min_power_level * 100, + ) + self.min_power_row, self.min_power_scale = create_slider_row( + title=_("Min Power"), + adjustment=self.min_power_adj, + subtitle=_( + "Power for lightest areas, as a % of the step's main power" + ), + digits=1, + ) + self._add(histogram_group, self.min_power_row) + + self.max_power_adj = Gtk.Adjustment( + lower=0, + upper=100, + step_increment=0.1, + value=step.max_power_level * 100, + ) + self.max_power_row, self.max_power_scale = create_slider_row( + title=_("Max Power"), + adjustment=self.max_power_adj, + subtitle=_( + "Power for darkest areas, as a % of the step's main power" + ), + digits=1, + ) + self._add(histogram_group, self.max_power_row) + + self.power_levels_row = SpinRow( + _("Power Levels"), + _("Number of discrete power steps (lower = fewer moves)"), + lower=2, + upper=256, + digits=0, + value=step.num_power_levels, + ) + self.power_levels_row.value_changed.connect( + lambda r: self._debounce( + self._on_param_changed, + "num_power_levels", + r.get_int_value(), + ), + ) + self._add(histogram_group, self.power_levels_row) + + self._update_power_labels(step.invert) + + # --- Multi-Pass Settings --- + self.levels_row = SpinRow( + _("Number of Depth Levels"), + lower=1, + upper=255, + value=step.num_depth_levels, + ) + self._add(engrave_group, self.levels_row) + + self.z_step_row = LengthSpinRow( + _("Z Step-Down per Level"), + upper=50, + value_in_base=step.z_step_down, + ) + self._add(engrave_group, self.z_step_row) + self.z_step_row.value_changed.connect( + lambda r: self._debounce( + self._on_param_changed, + "z_step_down", + r.get_value_in_base_units(), + ) + ) + + self.angle_incr_row = AngleSpinRow( + _("Rotate Angle Per Pass"), + _("Degrees to rotate each successive pass"), + lower=0, + upper=180, + digits=0, + value=step.angle_increment, + ) + self._add(engrave_group, self.angle_incr_row) + + # Connect signals + self.mode_row.connect("notify::selected", self._on_mode_changed) + + self.min_power_handler_id = self.min_power_scale.connect( + "value-changed", self._on_min_power_scale_changed + ) + self.max_power_handler_id = self.max_power_scale.connect( + "value-changed", self._on_max_power_scale_changed + ) + + self.levels_row.value_changed.connect( + lambda r: self._debounce( + self._on_param_changed, + "num_depth_levels", + r.get_int_value(), + ), + ) + self.angle_incr_row.value_changed.connect( + lambda r: self._debounce( + self._on_param_changed, + "angle_increment", + r.get_value(), + ), + ) + + GLib.idle_add(self._compute_and_update_histogram, step.invert) + self._on_mode_changed(self.mode_row, None) + + def _add(self, group, widget): + self._rows.append(widget) + group.add(widget) + + def _build_raster_geometry_group(self, group): + """Builds the Engraving Pattern preferences group.""" + + # --- Cross-Hatch & Scan Angle with Preview --- + angle_adj = Gtk.Adjustment( + lower=0, + upper=360, + step_increment=0.1, + page_increment=15, + value=self.step.scan_angle, + ) + self.angle_scale = create_slider( + adjustment=angle_adj, + digits=1, + draw_value=True, + on_value_changed=lambda s: self._on_angle_changed(s), + ) + + self.direction_preview = DirectionPreview( + self.step.scan_angle, self.step.cross_hatch + ) + + preview_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + preview_box.append(self.direction_preview) + preview_box.append(self.angle_scale) + + self.scan_angle_row = Adw.ActionRow( + title=_("Angle"), + subtitle=_("Angle of scan lines in degrees"), + ) + self.scan_angle_row.add_suffix(preview_box) + self._add(group, self.scan_angle_row) + + self.cross_hatch_row = Adw.SwitchRow( + title=_("Cross-Hatch"), + subtitle=_("Add a second pass at 90 degrees"), + ) + self.cross_hatch_row.set_active(self.step.cross_hatch) + self.cross_hatch_row.connect( + "notify::active", self._on_cross_hatch_changed + ) + self._add(group, self.cross_hatch_row) + + scan_mode_choices = [ + _("Segmented"), + _("Full Sweep"), + ] + self.scan_mode_row = Adw.ComboRow( + title=_("Scan Mode"), + subtitle=_( + "Segmented: moves between content regions. " + "Full Sweep: scans full width with laser toggling." + ), + model=Gtk.StringList.new(scan_mode_choices), + ) + self.scan_mode_row.set_selected( + _SCAN_MODES.index(getattr(ScanMode, self.step.scan_mode)) + ) + self.scan_mode_row.connect( + "notify::selected", self._on_scan_mode_changed + ) + self._add(group, self.scan_mode_row) + + head = self.get_selected_head() + laser = head if isinstance(head, LaserHead) else None + default_line_interval_mm = laser.spot_size_mm[1] if laser else 0.1 + default_sample_interval_mm = ( + laser.spot_size_mm[0] / 2.0 if laser else 0.05 + ) + + self.line_interval_row = LengthSpinRow( + _("Line Spacing"), + _("Distance between scan lines"), + lower=0.001, + upper=20.0, + step_increment=0.01, + digits=3, + value_in_base=( + self.step.line_interval_mm + if self.step.line_interval_mm is not None + else default_line_interval_mm + ), + ) + self._add(group, self.line_interval_row) + self.line_interval_row.value_changed.connect( + lambda r: self._debounce( + self._on_line_interval_changed, + r.get_value_in_base_units(), + ) + ) + + self.sample_interval_row = LengthSpinRow( + _("Sample Interval"), + _( + "Distance between power samples along scan line. " + "Lower values improve accuracy, but increase output size. " + ), + lower=0.001, + upper=20.0, + step_increment=0.01, + digits=3, + value_in_base=( + self.step.sample_interval_mm + if self.step.sample_interval_mm is not None + else default_sample_interval_mm + ), + ) + self._add(group, self.sample_interval_row) + self.sample_interval_row.value_changed.connect( + lambda r: self._debounce( + self._on_sample_interval_changed, + r.get_value_in_base_units(), + ) + ) + + default_dot_width_correction_mm = ( + laser.spot_size_mm[0] / 2.0 if laser else 0.05 + ) + self.dot_width_correction_row = LengthSpinRow( + _("Dot Width Correction"), + _( + "Reduces engrave length at both ends to compensate " + "for physical dot width" + ), + upper=5.0, + step_increment=0.01, + digits=3, + value_in_base=( + self.step.dot_width_correction_mm + if self.step.dot_width_correction_mm is not None + else default_dot_width_correction_mm + ), + ) + self._add(group, self.dot_width_correction_row) + self.dot_width_correction_row.value_changed.connect( + lambda r: self._debounce( + self._on_dot_width_correction_changed, + r.get_value_in_base_units(), + ) + ) + + self.bidir_x_offset_row = LengthSpinRow( + _("Bidirectional Scan Offset"), + _( + "Corrects X misalignment between left-to-right and " + "right-to-left raster passes" + ), + lower=-5.0, + upper=5.0, + step_increment=0.01, + digits=3, + value_in_base=self.step.bidir_x_offset_mm, + ) + self._add(group, self.bidir_x_offset_row) + self.bidir_x_offset_row.value_changed.connect( + lambda r: self._debounce( + self._on_bidir_x_offset_changed, + r.get_value_in_base_units(), + ) + ) + + self.invert_row = Adw.SwitchRow( + title=_("Invert"), + subtitle=_("Engrave white areas instead of black areas"), + ) + self.invert_row.set_active(self.step.invert) + self.invert_row.connect("notify::active", self._on_invert_changed) + self._add(group, self.invert_row) + + def _compute_and_update_histogram(self, invert: bool): + layer = self.step.layer + if not layer: + self.histogram_preview.update_histogram(None) + return + + workpieces = layer.all_workpieces + if not workpieces: + self.histogram_preview.update_histogram(None) + return + + pixels_per_mm = self.step.pixels_per_mm + all_gray_values = [] + + for workpiece in workpieces: + size = workpiece.size + if not size or size[0] <= 0 or size[1] <= 0: + continue + + width_px = int(size[0] * pixels_per_mm[0]) + height_px = int(size[1] * pixels_per_mm[1]) + + if width_px <= 0 or height_px <= 0: + continue + + max_px = 256 + if width_px > max_px or height_px > max_px: + scale = min(max_px / width_px, max_px / height_px) + width_px = max(int(width_px * scale), 1) + height_px = max(int(height_px * scale), 1) + + surface = workpiece.render_to_pixels(width_px, height_px) + if not surface: + continue + + gray_values = get_visible_grayscale_values(surface, invert) + if gray_values.size > 0: + all_gray_values.append(gray_values) + + if not all_gray_values: + self.histogram_preview.update_histogram(None) + return + + combined_gray = np.concatenate(all_gray_values) + + histogram, _ = np.histogram(combined_gray, bins=64, range=(0, 255)) + + self.histogram_preview.update_histogram(histogram) + + auto_black, auto_white = compute_auto_levels(combined_gray) + self.histogram_preview.set_auto_points(auto_black, auto_white) + + def _commit_power_range_change(self): + """Commits the min/max power to the step via commands.""" + min_p = self.min_power_adj.get_value() / 100.0 + max_p = self.max_power_adj.get_value() / 100.0 + + min_changed = abs(self.step.min_power_level - min_p) > 1e-6 + max_changed = abs(self.step.max_power_level - max_p) > 1e-6 + + if not min_changed and not max_changed: + return + + with self.history_manager.transaction(_("Change Power Range")): + if min_changed: + self.set_step_property("min_power_level", min_p) + if max_changed: + self.set_step_property("max_power_level", max_p) + + def _on_min_power_scale_changed(self, scale: Gtk.Scale): + new_min_value = self.min_power_adj.get_value() + + GObject.signal_handler_block( + self.max_power_scale, self.max_power_handler_id + ) + + if self.max_power_adj.get_value() < new_min_value: + self.max_power_adj.set_value(new_min_value) + + GObject.signal_handler_unblock( + self.max_power_scale, self.max_power_handler_id + ) + + self._debounce(self._commit_power_range_change) + + def _on_max_power_scale_changed(self, scale: Gtk.Scale): + new_max_value = self.max_power_adj.get_value() + + GObject.signal_handler_block( + self.min_power_scale, self.min_power_handler_id + ) + + if self.min_power_adj.get_value() > new_max_value: + self.min_power_adj.set_value(new_max_value) + + GObject.signal_handler_unblock( + self.min_power_scale, self.min_power_handler_id + ) + + self._debounce(self._commit_power_range_change) + + def _on_mode_changed(self, row, pspec): + selected_idx = row.get_selected() + selected_mode = list(DepthMode)[selected_idx] + is_power_mode = selected_mode == DepthMode.POWER_MODULATION + is_constant_power = selected_mode == DepthMode.CONSTANT_POWER + is_dither = selected_mode == DepthMode.DITHER + is_multi_pass = selected_mode == DepthMode.MULTI_PASS + + self.min_power_row.set_visible(is_power_mode) + self.max_power_row.set_visible(is_power_mode) + self.sample_interval_row.set_visible(is_power_mode) + self.power_levels_row.set_visible(is_power_mode) + + uses_grayscale = is_power_mode or is_multi_pass + self.histogram_row.set_visible(uses_grayscale) + self.auto_levels_row.set_visible(uses_grayscale) + + self.threshold_row.set_visible(is_constant_power) + self.dither_algorithm_row.set_visible(is_dither) + + self.levels_row.set_visible(is_multi_pass) + self.z_step_row.set_visible(is_multi_pass) + self.angle_incr_row.set_visible(is_multi_pass) + + self._on_param_changed("depth_mode", selected_mode.name) + + def _on_black_point_changed(self, sender, black_point: int): + self._on_param_changed("black_point", black_point) + + def _on_white_point_changed(self, sender, white_point: int): + self._on_param_changed("white_point", white_point) + + def _on_auto_levels_changed(self, w, pspec): + auto_levels = w.get_active() + self.histogram_preview.auto_mode = auto_levels + if auto_levels: + self.histogram_row.set_subtitle( + _("Auto-adjusted based on image content") + ) + else: + self.histogram_row.set_subtitle( + _("Drag markers to set black/white points") + ) + self._on_param_changed("auto_levels", auto_levels) + + def _on_dither_algorithm_changed(self, row, pspec): + selected_idx = row.get_selected() + selected_algo = list(DitherAlgorithm)[selected_idx] + self._on_param_changed("dither_algorithm", selected_algo) + + def _on_threshold_changed(self, scale): + value = int(scale.get_value()) + self._debounce(self._on_param_changed, "threshold", value) + + def _on_angle_changed(self, scale): + value = float(scale.get_value()) + self.direction_preview.update(value, self.cross_hatch_row.get_active()) + self._debounce(self._on_param_changed, "scan_angle", value) + + def _on_cross_hatch_changed(self, w, pspec): + cross_hatch = w.get_active() + self.direction_preview.update( + self.angle_scale.get_value(), cross_hatch + ) + self._on_param_changed("cross_hatch", cross_hatch) + + def _on_scan_mode_changed(self, row, pspec): + selected_idx = row.get_selected() + selected_mode = _SCAN_MODES[selected_idx] + self._on_param_changed("scan_mode", selected_mode.name) + + def _update_power_labels(self, invert: bool): + """Update min/max power labels based on invert setting.""" + lightest_subtitle = _( + "Power for lightest areas, as a % of the step's main power" + ) + darkest_subtitle = _( + "Power for darkest areas, as a % of the step's main power" + ) + + if invert: + self.min_power_row.set_title(_("Min Power (Black)")) + self.min_power_row.set_subtitle(darkest_subtitle) + self.max_power_row.set_title(_("Max Power (White)")) + self.max_power_row.set_subtitle(lightest_subtitle) + else: + self.min_power_row.set_title(_("Min Power (White)")) + self.min_power_row.set_subtitle(lightest_subtitle) + self.max_power_row.set_title(_("Max Power (Black)")) + self.max_power_row.set_subtitle(darkest_subtitle) + + def _on_invert_changed(self, w, pspec): + invert = w.get_active() + self._update_power_labels(invert) + self._compute_and_update_histogram(invert) + self._on_param_changed("invert", invert) + + def _on_line_interval_changed(self, value: float | None): + if value is not None and value <= 0: + value = None + self._on_param_changed("line_interval_mm", value) + + def _on_sample_interval_changed(self, value: float | None): + if value is not None and value <= 0: + value = None + self._on_param_changed("sample_interval_mm", value) + + def _on_dot_width_correction_changed(self, value: float | None): + # Unlike line/sample interval, 0.0 is a meaningful explicit value + # here (no correction), not a sentinel for "reset to auto". + self._on_param_changed("dot_width_correction_mm", value or 0.0) + + def _on_bidir_x_offset_changed(self, value: float | None): + self._on_param_changed("bidir_x_offset_mm", value or 0.0) + + def _on_param_changed(self, key: str, value: Any): + self.set_step_property(key, value) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/__init__.py new file mode 100644 index 000000000..35d23e887 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/__init__.py @@ -0,0 +1,21 @@ +"""Laser-domain row widgets.""" + +from .air_assist_row import AirAssistRow +from .cut_side_row import CutSideRow +from .laser_step_page import LaserSettingsPage, LaserStepSettingsPage +from .offset_row import OffsetRow +from .power_row import PowerRow +from .pwm_row import FrequencyRow, PulseWidthRow +from .tab_power_row import TabPowerRow + +__all__ = [ + "AirAssistRow", + "CutSideRow", + "FrequencyRow", + "LaserSettingsPage", + "LaserStepSettingsPage", + "OffsetRow", + "PowerRow", + "PulseWidthRow", + "TabPowerRow", +] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/air_assist_row.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/air_assist_row.py new file mode 100644 index 000000000..4d6f1dadf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/air_assist_row.py @@ -0,0 +1,19 @@ +"""Laser air-assist row widget.""" + +from gettext import gettext as _ +from typing import Any + +from rayforge.ui_gtk.doceditor.step_settings.rows import SwitchRow + + +class AirAssistRow(SwitchRow): + """A switch row bound to the ``LaserStep.air_assist`` attribute.""" + + def __init__(self, editor: Any, step: Any): + super().__init__( + editor, + step, + "air_assist", + _("Air Assist"), + _("Blow air over the cut to clear debris"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/cut_side_row.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/cut_side_row.py new file mode 100644 index 000000000..39abc70c3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/cut_side_row.py @@ -0,0 +1,21 @@ +"""Laser cut-side row widget.""" + +from gettext import gettext as _ +from typing import Any + +from rayforge.core.cut_side import CutSide +from rayforge.ui_gtk.doceditor.step_settings.rows import ComboRow + + +class CutSideRow(ComboRow): + """A combo row bound to the step's ``cut_side`` attribute.""" + + def __init__(self, editor: Any, step: Any): + choices = [(cs.label(), cs.name) for cs in CutSide] + super().__init__( + editor, + step, + "cut_side", + _("Cut Side"), + choices, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/laser_step_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/laser_step_page.py new file mode 100644 index 000000000..ef48092a3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/laser_step_page.py @@ -0,0 +1,146 @@ +"""Laser step settings pages.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.core.undo import ChangePropertyCommand +from rayforge.machine.models.laser import LaserHead +from rayforge.ui_gtk.doceditor.step_settings.pages import StepSettingsPage +from rayforge.ui_gtk.doceditor.step_settings.rows import ( + CutSpeedRow, + HeadRow, + TravelSpeedRow, +) + +from ..rows.air_assist_row import AirAssistRow +from ..rows.power_row import PowerRow +from ..rows.pwm_row import FrequencyRow, PulseWidthRow +from ..rows.tab_power_row import TabPowerRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class LaserSettingsPage(StepSettingsPage): + """The laser process settings page (head, power, speed, PWM).""" + + show_identity = False + + def __init__( + self, + editor: "DocEditor", + step: Any, + include_tab_power: bool = False, + include_process: bool = True, + cut_speed_title: str = _("Cut Speed"), + ): + super().__init__(editor, step) + producer_type = step.ASSEMBLER_NAME or "unknown" + self.key = f"{producer_type.lower()}/laser" + self.head_row = HeadRow(editor, step) + self.head_row.head_changed.connect(self._on_head_changed) + if include_process: + rows = [ + self.head_row, + PowerRow, + CutSpeedRow(editor, step, title=cut_speed_title), + TravelSpeedRow, + AirAssistRow, + ] + if include_tab_power: + rows.append(TabPowerRow) + else: + rows = [self.head_row, AirAssistRow] + self.add_section( + _("Laser"), + *rows, + description=_( + "Laser power, speed, and head selection for this operation." + ), + ) + self.machine_section = self.add_section( + _("Machine"), + FrequencyRow, + PulseWidthRow, + description=_( + "Settings provided by the machine's hardware for this head." + ), + ) + step.updated.connect(self._update_machine_section_visibility) + self._update_machine_section_visibility() + + def _update_machine_section_visibility(self, *args): + machine = self.get_machine() + head = self.get_selected_head() + supported = bool(machine and head and machine.get_pwm_params(head)) + self.machine_section.set_visible(supported) + + def _on_head_changed(self, sender, head_uid): + step = self.step + if head_uid == step.selected_head_uid: + return + machine = self.get_machine() + head = None + if machine: + head = next((h for h in machine.heads if h.uid == head_uid), None) + with self.history_manager.transaction(_("Change Head")) as t: + t.execute( + ChangePropertyCommand( + target=step, + property_name="selected_head_uid", + new_value=head_uid, + setter_method_name="set_selected_head_uid", + ) + ) + if isinstance(head, LaserHead): + params = machine.get_pwm_params(head) if machine else None + if params is not None: + t.execute( + ChangePropertyCommand( + target=step, + property_name="frequency", + new_value=params.frequency, + setter_method_name="set_frequency", + ) + ) + t.execute( + ChangePropertyCommand( + target=step, + property_name="pulse_width", + new_value=params.pulse_width, + setter_method_name="set_pulse_width", + ) + ) + + +class LaserStepSettingsPage(StepSettingsPage): + """Base page for laser step settings. + + Shows the step's own settings; the laser process settings live on + a second ``LaserSettingsPage`` opened from the settings dialog. + Subclasses override ``_add_step_sections`` and the laser options + class attributes. + """ + + include_tab_power = False + include_process = True + cut_speed_title = _("Cut Speed") + + extra_pages = (("laser_page", _("Laser"), "laser-on-symbolic"),) + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__(editor, step) + self._add_step_sections() + + def _add_step_sections(self): + """Add step-specific sections right after the General section.""" + + def laser_page(self) -> LaserSettingsPage: + """Build the companion laser process settings page.""" + return LaserSettingsPage( + self.editor, + self.step, + include_tab_power=self.include_tab_power, + include_process=self.include_process, + cut_speed_title=self.cut_speed_title, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/offset_row.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/offset_row.py new file mode 100644 index 000000000..e0e0ed2ca --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/offset_row.py @@ -0,0 +1,37 @@ +"""Laser offset row widget.""" + +from gettext import gettext as _ +from typing import Any + +from rayforge.core.cut_side import CutSide +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + + +class OffsetRow(SpinRow): + """A spin row bound to the step's ``offset_mm`` attribute. + + The row is insensitive while the cut side is CENTERLINE, where an + offset has no effect. + """ + + def __init__(self, editor: Any, step: Any): + super().__init__( + editor, + step, + "offset_mm", + _("Offset"), + _( + "Shifts the path inward/outward per Cut Side (none on " + "Centerline). Defaults to kerf compensation for the head" + ), + 0.0, + 100.0, + 0.1, + 2, + quantity="length", + ) + + def _sync_dependencies(self): + self.set_sensitive( + getattr(self.step, "cut_side", None) != CutSide.CENTERLINE.name + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/power_row.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/power_row.py new file mode 100644 index 000000000..9b9dfbda8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/power_row.py @@ -0,0 +1,26 @@ +"""Laser power row widget.""" + +from gettext import gettext as _ +from typing import Any + +from rayforge.ui_gtk.doceditor.step_settings.rows import SliderRow + + +class PowerRow(SliderRow): + """A slider row bound to the ``LaserStep.power`` attribute.""" + + def __init__(self, editor: Any, step: Any): + super().__init__( + editor, + step, + "power", + _("Power"), + _("Laser power as a percentage"), + 0.0, + 1.0, + 0.01, + 1, + ) + + def _format(self, value: float) -> str: + return f"{value * 100:.0f}%" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/pwm_row.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/pwm_row.py new file mode 100644 index 000000000..38fcc68a7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/pwm_row.py @@ -0,0 +1,65 @@ +"""Laser PWM row widgets.""" + +from gettext import gettext as _ +from typing import Any + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + + +class _PwmRow(SpinRow): + """Base for rows shown only when the selected head has PWM.""" + + def __init__( + self, + editor: Any, + step: Any, + attr: str, + title: str, + subtitle: str, + ): + super().__init__( + editor, + step, + attr, + title, + subtitle, + 1, + 100000, + 1, + 0, + is_int=True, + ) + + def _sync_dependencies(self): + machine = self.get_machine() + head = self.get_selected_head() + if machine is None or head is None: + self.set_visible(False) + return + self.set_visible(machine.get_pwm_params(head) is not None) + + +class FrequencyRow(_PwmRow): + """A spin row bound to the ``LaserStep.frequency`` attribute.""" + + def __init__(self, editor: Any, step: Any): + super().__init__( + editor, + step, + "frequency", + _("Frequency"), + _("Laser PWM frequency in Hz"), + ) + + +class PulseWidthRow(_PwmRow): + """A spin row bound to the ``LaserStep.pulse_width`` attribute.""" + + def __init__(self, editor: Any, step: Any): + super().__init__( + editor, + step, + "pulse_width", + _("Pulse Width"), + _("Laser PWM pulse width in ns"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/tab_power_row.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/tab_power_row.py new file mode 100644 index 000000000..e6b0e81b9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/rows/tab_power_row.py @@ -0,0 +1,26 @@ +"""Laser tab-power row widget.""" + +from gettext import gettext as _ +from typing import Any + +from rayforge.ui_gtk.doceditor.step_settings.rows import SliderRow + + +class TabPowerRow(SliderRow): + """A slider row bound to the ``LaserStep.tab_power`` attribute.""" + + def __init__(self, editor: Any, step: Any): + super().__init__( + editor, + step, + "tab_power", + _("Tab Power"), + _("Laser power at tab positions as a percentage"), + 0.0, + 1.0, + 0.01, + 1, + ) + + def _format(self, value: float) -> str: + return f"{value * 100:.0f}%" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/shrinkwrap_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/shrinkwrap_page.py new file mode 100644 index 000000000..08cb51a19 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/shrinkwrap_page.py @@ -0,0 +1,42 @@ +"""Shrink-wrap step settings widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.ui_gtk.doceditor.step_settings.rows import SliderRow + +from .rows import CutSideRow, LaserStepSettingsPage, OffsetRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class GravityRow(SliderRow): + """Slider row bound to the ``gravity`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "gravity", + _("Gravity"), + _("Pulls the hull inward. 0.0 is a standard convex hull"), + 0.0, + 1.0, + 0.01, + 2, + ) + + +class ShrinkWrapStepSettingsPage(LaserStepSettingsPage): + """Settings page for the ShrinkWrapStep.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__(editor, step) + self.add_section( + _("Shrink Wrap"), + GravityRow, + CutSideRow, + OffsetRow, + description=_("Fit a hull around the content and trace it."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/wavefront_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/wavefront_page.py new file mode 100644 index 000000000..03705b511 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/wavefront_page.py @@ -0,0 +1,60 @@ +"""Wavefront step settings widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.ui_gtk.doceditor.step_settings.rows import SpinRow + +from .rows import LaserStepSettingsPage + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class StepOverRow(SpinRow): + """Spin row bound to the ``step_over_mm`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "step_over_mm", + _("Step Over"), + _("Lateral step-over between wavefront passes"), + 0.05, + 50.0, + 0.1, + 2, + quantity="length", + ) + + +class OffsetRow(SpinRow): + """Spin row bound to the ``offset_mm`` attribute.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__( + editor, + step, + "offset_mm", + _("Offset"), + _("Extra offset from walls"), + 0.0, + 20.0, + 0.1, + 2, + quantity="length", + ) + + +class WavefrontStepSettingsPage(LaserStepSettingsPage): + """Settings page for the WavefrontStep.""" + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__(editor, step) + self.add_section( + _("Wavefront"), + StepOverRow, + OffsetRow, + description=_("Clear pockets with a wavefront toolpath."), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py new file mode 100644 index 000000000..d51011b4b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py @@ -0,0 +1,29 @@ +""" +Backend entry point for laser-essentials addon. + +Registers steps with the main application. +""" + +from rayforge.core.hooks import hookimpl + +from .steps import ( + ContourStep, + EngraveStep, + FrameStep, + MaterialTestStep, + ShrinkWrapStep, + WavefrontStep, +) + +ADDON_NAME = "laser_essentials" + + +@hookimpl +def register_steps(step_registry): + """Register steps with the step registry.""" + step_registry.register(ContourStep, addon_name=ADDON_NAME) + step_registry.register(EngraveStep, addon_name=ADDON_NAME) + step_registry.register(FrameStep, addon_name=ADDON_NAME) + step_registry.register(MaterialTestStep, addon_name=ADDON_NAME) + step_registry.register(ShrinkWrapStep, addon_name=ADDON_NAME) + step_registry.register(WavefrontStep, addon_name=ADDON_NAME) 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 new file mode 100644 index 000000000..41883f2b4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,773 @@ +# German translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# Samuel Abels , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2025-07-24 22:08+0200\n" +"Last-Translator: Samuel Abels \n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "Schnitt" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "Gravieren" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "Leistung vs. Geschwindigkeit" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "Leistung vs. Durchgänge" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "Geschwindigkeit vs. Durchgänge" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "Geschwindigkeit vs. Versatz" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "Anziehung" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "Zieht die Hülle nach innen. 0.0 ist eine standardmäßige konvexe Hülle" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "Umhüllung" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "Lege eine Hülle um den Inhalt und zeichne sie nach." + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "Schrittweite" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "Seitliche Zustellung zwischen den Wellenfrontdurchgängen" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "Versatz" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "Zusätzlicher Versatz von Wänden" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "Wellenfront" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "Taschen mit einer Wellenfront-Werkzeugbahn ausräumen." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "Schnittreihenfolge" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "Verarbeitungsreihenfolge für verschachtelte Pfade" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "Innere Pfade entfernen" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "Wenn aktiviert, wird nur die äußere Kontur von Formen nachgezeichnet." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "Überschnitt" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" +"Geschlossene Konturen über den Startpunkt hinaus verlängern, sodass sich der " +"Schnitt überlappt" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "Inhalt neu scannen" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "" +"Quellgeometrie ignorieren und innerhalb des Werkstücks neu nachzeichnen" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "Nachzeichnungsschwellenwert" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "Helligkeitsstufe (0.0-1.0) zur Definition von Kanten" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "Kontureinstellungen" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "Zeichne die Kontur der ausgewählten Formen nach." + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "Geometrie" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "Schneide einen Rahmen um den ausgewählten Inhalt." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "Schnittgeschwindigkeit" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "Laser" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "Laserleistung, -geschwindigkeit und Kopfauswahl für diesen Vorgang." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "Maschine" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "" +"Einstellungen, die von der Hardware der Maschine für diesen Kopf " +"bereitgestellt werden." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "Kopf wechseln" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Verschiebt den Pfad je nach Schnittseite nach innen/außen (keine bei " +"Mittellinie). Standardmäßig Kerf-Kompensation für den Kopf" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "Haltesteg-Leistung" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "Laserleistung an den Haltesteg-Positionen als Prozentsatz" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "Luftunterstützung" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "Bläst Luft über den Schnitt, um Rückstände zu entfernen" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "Schnittseite" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "Frequenz" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "Laser-PWM-Frequenz in Hz" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "Pulsbreite" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "Laser-PWM-Pulsbreite in ns" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "Leistung" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "Laserleistung als Prozentsatz" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "Voreinstellung" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "Lade gängige Testkonfigurationen." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "Raster" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "Abmessungen, Form und Abstand der Testzellen." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "Beschriftungen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "Geschwindigkeits-/Leistungs-Anmerkungen auf dem Raster." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "Parameter" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "Definiere die Parameterbereiche für das Testraster." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "Dioden-Gravur" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "Dioden-Schnitt" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "CO2-Gravur" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "CO2-Schnitt" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "Auswählen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "Voreinstellungen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "Häufige Testkonfigurationen laden" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "Testtyp" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "Schnitt: Umrisse; Gravieren: Füllt mit Rasterlinien" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "Rastermodus" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "Wähle, welche Parameter auf den Achsen variiert werden sollen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "Feste Geschwindigkeit" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "Konstante Geschwindigkeit für alle Zellen (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "Feste Leistung (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "Konstante Leistung für alle Zellen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "Minimale Leistung (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "Für die erste Spalte" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "Maximale Leistung (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "Für die letzte Spalte" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "Minimale Geschwindigkeit" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "Startgeschwindigkeit (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "Maximale Geschwindigkeit" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "Endgeschwindigkeit (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "Minimale Durchgänge" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "Startanzahl der Durchgänge" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "Maximale Durchgänge" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "Endanzahl der Durchgänge" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "Minimaler Versatz" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "Bidirektionaler Scan-X-Versatz für erste Zeile (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "Maximaler Versatz" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "Bidirektionaler Scan-X-Versatz für letzte Zeile (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "Beschriftungs-Gravurleistung (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "Graviergeschwindigkeit für Beschriftung" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "Geschwindigkeit zum Gravieren von Beschriftungen (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "Spalten (Leistungsschritte)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "Anzahl der Leistungsvariationen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "Zeilen (Geschwindigkeitsschritte)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "Anzahl der Geschwindigkeitvariationen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "Formgröße" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "Größe jedes Testquadrats (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "Abstand" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "Abstand zwischen Testquadraten (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "Linienabstand" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" +"Abstand zwischen Scanlinien in Maschineneinheiten (für Gravurmodus). Auf 0 " +"setzen, um die Laserpunktgröße zu verwenden." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "Beschriftungen einschließen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "Geschwindigkeits-/Leistungs-Anmerkungen zum Gitter hinzufügen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "Zeilen (Durchgangsschritte)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "Anzahl der Durchgangsvariationen" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "Spalten (Geschwindigkeitsschritte)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "Zeilen (Versatzschritte)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "Anzahl der Versatzvariationen" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "Graviergeschwindigkeit" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "Rastere das Bild auf das Material." + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "Leistungsmodulation und Helligkeitsbereich." + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "Modus" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "Schwellenwert" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "Helligkeitsgrenzwert für Schwarz/Weiß (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "Gravurmethode" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "Algorithmus zum Konvertieren von Graustufen in Binär" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "Automatische Pegel" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "Schwarz/Weiß-Punkte automatisch anpassen" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "Helligkeitsbereich" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "Automatisch angepasst basierend auf Bildinhalt" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "Ziehe die Markierungen, um Schwarz-/Weißpunkte festzulegen" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "Min. Leistung" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "" +"Leistung für die hellsten Bereiche, als % der Hauptleistung des Schritts" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "Max. Leistung" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "" +"Leistung für die dunkelsten Bereiche, als % der Hauptleistung des Schritts" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "Leistungsstufen" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "Anzahl der diskreten Leistungsstufen (weniger = weniger Bewegungen)" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "Anzahl der Tiefenstufen" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "Z-Absenkung pro Ebene" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "Rotationswinkel pro Durchgang" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "Grad, um die jeder folgende Durchgang rotiert wird" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "Winkel" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "Winkel der Scanlinien in Grad" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "Kreuzschraffur" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "Einen zweiten Durchgang bei 90 Grad hinzufügen" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "Segmentiert" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "Vollflächig" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "Scan-Modus" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" +"Segmentiert: Bewegt sich zwischen Inhaltsbereichen. Vollflächig: Scannt die " +"gesamte Breite mit Laser-Toggle." + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "Linienabstand" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "Abstand zwischen Scanzeilen" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "Abtastintervall" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" +"Abstand zwischen den Leistungsabtastpunkten entlang der Scanlinie. " +"KleinereWerte verbessern die Genauigkeit, erhöhen aber die Ausgabegröße." + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "Punktbreitenkorrektur" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" +"Verkürzt die Gravurlänge an beiden Enden, um die physikalische Punktbreite " +"auszugleichen" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "Bidirektionaler Scan-Versatz" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" +"Korrigiert die X-Ausrichtung zwischen Rasterdurchgängen von links nachrechts " +"und von rechts nach links" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "Invertieren" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "Weiße Bereiche anstelle von schwarzen Bereichen gravieren" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "Leistungsbereich ändern" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "Min. Leistung (Schwarz)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "Max. Leistung (Weiß)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "Minimale Leistung (Weiß)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "Maximale Leistung (Schwarz)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "Optional einen bestimmten Laserkopf erzwingen" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "Laserleistung an den Haltesteg-Positionen (% der Schnittleistung)" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "Schritt-Einstellungen" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "{power_percent}% Leistung, {speed_str}" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Verschiebt die Kontur je nach Schnittseite nach innen/außen (keine bei " +"Mittellinie). Standardmäßig Kerf-Kompensation für den Kopf" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" +"Abstand zwischen Wellenfront-Durchgängen; standardmäßig die " +"Laserpunktbreite, wenn nicht festgelegt" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "Rahmen" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Verschiebt den Rahmen je nach Schnittseite nach innen/außen (keine bei " +"Mittellinie). Standardmäßig Kerf-Kompensation für den Kopf" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "Scan-Winkel" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "Tiefenmodus" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "Min. Leistungsstufe" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "Max. Leistungsstufe" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "Kontur" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Verschiebt den Schnittpfad je nach Schnittseite nach innen/außen (keine bei " +"Mittellinie). Standardmäßig Kerf-Kompensation für den Kopf" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "Materialtestgitter" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "Materialtestgitter erstellen" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "Laserkopf" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +msgstr "Materialtest hinzufügen" 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 new file mode 100644 index 000000000..1488a00a9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,748 @@ +# English translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2026-03-01 19:25+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "Engrave" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "Gravity" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "Pulls the hull inward. 0.0 is a standard convex hull" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "Shrink Wrap" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "Cut Order" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "Processing order for nested paths" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "Remove Inner Paths" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "If enabled, only trace the outer outline of shapes" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "Rescan Content" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "Ignore source geometry and re-trace within the workpiece" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "Tracing Threshold" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "Brightness level (0.0-1.0) to define edges" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "" + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "Cut Side" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "Presets" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "Load common test configurations" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "Test Type" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "Cut: outlines; Engrave: fills with raster lines" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "Minimum Power (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "For first column" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "Maximum Power (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "For last column" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "Minimum Speed" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "Starting speed (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "Maximum Speed" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "Ending speed (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "Label Engrave Power (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "Columns (Power Steps)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "Number of power variations" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "Rows (Speed Steps)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "Number of speed variations" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "Shape Size" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "Size of each test square (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "Spacing" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "Gap between test squares (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "Line Interval" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "Include Labels" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "Add speed/power annotations to the grid" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "Mode" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "Threshold" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "Brightness cutoff for black/white (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "Engraving Method" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "Algorithm for converting grayscale to binary" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "Auto Levels" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "Automatically adjust black/white points" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "Brightness Range" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "Auto-adjusted based on image content" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "Drag markers to set black/white points" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "Min Power" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "Power for lightest areas, as a % of the step's main power" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "Max Power" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "Power for darkest areas, as a % of the step's main power" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "Number of Depth Levels" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "Rotate Angle Per Pass" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "Degrees to rotate each successive pass" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "Angle" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "Angle of scan lines in degrees" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "Cross-Hatch" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "Add a second pass at 90 degrees" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "Line Spacing" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "Invert" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "Engrave white areas instead of black areas" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "Change Power Range" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "Min Power (Black)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "Max Power (White)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "Min Power (White)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "Max Power (Black)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "Contour" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "Material Test Grid" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "Create Material Test Grid" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +msgstr "Add Material Test" 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 new file mode 100644 index 000000000..e4504fa08 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,777 @@ +# Spanish translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2025-08-08 10:00+0200\n" +"Last-Translator: Samuel Abels\n" +"Language-Team: Spanish \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "Corte" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "Grabar" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "Potencia vs Velocidad" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "Potencia vs Pasadas" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "Velocidad vs Pasadas" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "Velocidad vs Desplazamiento" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "Gravedad" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "" +"Tira de la envoltura hacia adentro. 0.0 es una envoltura convexa estándar." + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "Envoltura" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "Ajusta un casco alrededor del contenido y trázalo." + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "Paso lateral" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "Paso lateral entre pasadas de frente de onda" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "Desplazamiento" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "Desplazamiento adicional desde las paredes" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "Frente de onda" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "Limpia bolsillos con una trayectoria de frente de onda." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "Orden de corte" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "Orden de procesamiento para trayectorias anidadas" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "Eliminar trayectorias internas" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "Si está habilitado, solo traza el contorno exterior de las formas." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "Sobrecorte" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" +"Extender los contornos cerrados más allá de su punto de inicio para que el " +"corte se solape" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "Reescanear contenido" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "" +"Ignorar geometría de origen y volver a trazar dentro de la pieza de trabajo" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "Umbral de trazado" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "Nivel de brillo (0.0-1.0) para definir bordes" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "Ajustes de contorno" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "Traza el contorno de las formas seleccionadas." + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "Geometría" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "Corta un marco alrededor del contenido seleccionado." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "Velocidad de corte" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "Láser" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "" +"Potencia del láser, velocidad y selección de cabezal para esta operación." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "Máquina" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "" +"Ajustes proporcionados por el hardware de la máquina para este cabezal." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "Cambiar cabezal" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Desplaza la trayectoria hacia dentro/fuera según el lado de corte (ninguno " +"en la línea central). Se predetermina a la compensación de kerf del cabezal" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "Potencia de pestañas" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "Potencia del láser en las posiciones de pestaña como porcentaje" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "Asistencia de aire" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "Sopla aire sobre el corte para eliminar residuos" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "Lado de corte" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "Frecuencia" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "Frecuencia PWM del láser en Hz" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "Ancho de pulso" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "Ancho de pulso PWM del láser en ns" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "Potencia" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "Potencia del láser como porcentaje" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "Preajuste" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "Cargar configuraciones de prueba comunes." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "Cuadrícula" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "Dimensiones, forma y espaciado de las celdas de prueba." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "Etiquetas" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "Anotaciones de velocidad/potencia en la cuadrícula." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "Parámetros" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "Define los rangos de parámetros para la cuadrícula de prueba." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "Grabado con diodo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "Corte con diodo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "Grabado con CO2" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "Corte con CO2" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "Seleccionar" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "Preajustes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "Cargar configuraciones de prueba comunes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "Tipo de prueba" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "Corte: contornos; Grabado: rellenos con líneas de trama" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "Modo de cuadrícula" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "Elige qué parámetros variar en los ejes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "Velocidad fija" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "Velocidad constante para todas las celdas (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "Potencia fija (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "Potencia constante para todas las celdas" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "Potencia mínima (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "Para la primera columna" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "Potencia máxima (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "Para la última columna" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "Velocidad mínima" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "Velocidad inicial (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "Velocidad máxima" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "Velocidad final (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "Pases mínimos" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "Número inicial de pases" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "Pases máximos" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "Número final de pases" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "Desplazamiento mínimo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "Desplazamiento X de escaneo bidireccional para la primera fila (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "Desplazamiento máximo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "Desplazamiento X de escaneo bidireccional para la última fila (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "Potencia de grabado de etiqueta (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "Velocidad de grabado de etiquetas" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "Velocidad para grabar etiquetas (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "Columnas (Pasos de potencia)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "Número de variaciones de potencia" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "Filas (Pasos de velocidad)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "Número de variaciones de velocidad" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "Tamaño de la forma" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "Tamaño de cada cuadrado de prueba (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "Espaciado" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "Separación entre cuadrados de prueba (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "Intervalo de línea" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" +"Distancia entre líneas de escaneo en unidades de máquina (para modo " +"Grabado). Dejar en 0 para usar el tamaño del punto láser." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "Incluir etiquetas" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "Añadir anotaciones de velocidad/potencia a la cuadrícula" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "Filas (Pasos de pases)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "Número de variaciones de pases" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "Columnas (Pasos de velocidad)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "Filas (pasos de desplazamiento)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "Número de variaciones de desplazamiento" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "Velocidad de grabado" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "Trama la imagen sobre el material." + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "Modulación de potencia y rango de brillo." + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "Modo" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "Umbral" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "Umbral de brillo para blanco/negro (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "Método de grabado" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "Algoritmo para convertir escala de grises a binario" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "Niveles Automáticos" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "Ajustar automáticamente los puntos negro/blanco" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "Rango de brillo" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "Ajustado automáticamente según el contenido de la imagen" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "Arrastra los marcadores para ajustar los puntos de blanco/negro" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "Potencia mínima" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "" +"Potencia para las áreas más claras, como % de la potencia principal del paso." + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "Potencia máxima" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "" +"Potencia para las áreas más oscuras, como % de la potencia principal del " +"paso." + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "Niveles de potencia" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "Número de pasos de potencia discretos (menor = menos movimientos)" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "Número de niveles de profundidad" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "Descenso en Z por nivel" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "Ángulo de rotación por pasada" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "Grados para rotar cada pasada sucesiva" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "Ángulo" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "Ángulo de las líneas de escaneo en grados" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "Trama cruzada" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "Añadir una segunda pasada a 90 grados" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "Segmentado" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "Barrido completo" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "Modo de escaneo" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" +"Segmentado: se mueve entre regiones de contenido. Barrido completo: escanea " +"todo el ancho con activación del láser." + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "Espaciado de línea" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "Distancia entre líneas de escaneo" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "Intervalo de muestreo" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" +"Distancia entre muestras de potencia a lo largo de la línea de escaneo. " +"Losvalores más bajos mejoran la precisión, pero aumentan el tamaño de salida." + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "Corrección de ancho de punto" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" +"Reduce la longitud de grabado en ambos extremos para compensar el ancho " +"físico del punto" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "Desplazamiento de escaneo bidireccional" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" +"Corrige la desalineación en X entre pasadas de rasterizado de izquierda " +"aderecha y de derecha a izquierda" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "Invertir" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "Grabar áreas blancas en lugar de áreas negras" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "Cambiar rango de potencia" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "Potencia mínima (Negro)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "Potencia máxima (Blanco)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "Potencia mín. (Blanco)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "Potencia máx. (Negro)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "Opcionalmente, fuerza un cabezal láser específico" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "" +"Potencia del láser en las posiciones de pestaña (% de la potencia de corte)" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "Ajustes del paso" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "{power_percent}% de potencia, {speed_str}" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Desplaza el contorno hacia dentro/fuera según el lado de corte (ninguno en " +"la línea central). Se predetermina a la compensación de kerf del cabezal" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" +"Distancia entre pasadas de frente de onda; se predetermina al ancho del " +"punto láser si no se establece" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "Marco" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Desplaza el marco hacia dentro/fuera según el lado de corte (ninguno en la " +"línea central). Se predetermina a la compensación de kerf del cabezal" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "Ángulo de escaneo" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "Modo de profundidad" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "Nivel de potencia mínima" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "Nivel de potencia máxima" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "Contorno" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Desplaza la trayectoria de corte hacia dentro/fuera según el lado de corte " +"(ninguno en la línea central). Se predetermina a la compensación de kerf del " +"cabezal" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "Cuadrícula de prueba de material" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "Crear cuadrícula de prueba de material" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "Cabezal láser" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +msgstr "Añadir prueba de material" 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 new file mode 100644 index 000000000..57b868509 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,777 @@ +# French translations for Rayforge package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Samuel , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2025-10-15 07:10+0200\n" +"Last-Translator: Samuel \n" +"Language-Team: French\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 3.4.2\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "Découpe" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "Graver" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "Puissance vs Vitesse" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "Puissance vs Passages" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "Vitesse vs Passages" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "Vitesse vs Décalage" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "Gravité" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "" +"Tire l'enveloppe vers l'intérieur. 0.0 correspond à une enveloppe convexe " +"standard" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "Enveloppe convexe" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "Envelopper le contenu puis tracer le contour." + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "Pas latéral" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "Pas latéral entre les passages de front d'onde" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "Décalage" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "Décalage supplémentaire par rapport aux parois" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "Front d'onde" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "Défoncer les poches avec un trajet d'outil en front d'onde." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "Ordre de coupe" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "Ordre de traitement des chemins imbriqués" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "Supprimer les chemins intérieurs" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "Si activé, ne tracer que le contour extérieur des formes" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "Surcoupe" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" +"Prolonger les contours fermés au-delà de leur point de départ afin que la " +"coupe se chevauche" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "Réanalyser le contenu" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "Ignorer la géométrie source et retracer à l'intérieur de la pièce" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "Seuil de traçage" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "Niveau de luminosité (0.0-1.0) pour définir les bords" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "Paramètres du contour" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "Tracer le contour des formes sélectionnées." + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "Géométrie" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "Découper un cadre autour du contenu sélectionné." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "Vitesse de coupe" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "Laser" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "Puissance, vitesse et sélection de la tête laser pour cette opération." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "Machine" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "Paramètres fournis par le matériel de la machine pour cette tête." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "Changer de tête" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Décale le chemin vers l'intérieur/extérieur selon le côté de coupe (aucun " +"sur la ligne centrale). Par défaut, compense le kerf de la tête" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "Puissance des languettes" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "Puissance laser aux positions des languettes en pourcentage" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "Assistance d'air" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "Souffler de l'air sur la coupe pour évacuer les débris" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "Côté de coupe" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "Fréquence" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "Fréquence PWM du laser en Hz" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "Largeur d'impulsion" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "Largeur d'impulsion PWM du laser en ns" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "Puissance" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "Puissance laser en pourcentage" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "Préréglage" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "Charger les configurations de test courantes." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "Grille" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "Dimensions, forme et espacement des cellules de test." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "Étiquettes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "Annotations de vitesse/puissance sur la grille." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "Paramètres" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "Définir les plages de paramètres pour la grille de test." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "Gravure diode" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "Découpe diode" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "Gravure CO2" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "Découpe CO2" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "Sélectionner" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "Préréglages" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "Charger les configurations de test courantes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "Type de test" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "Coupe : contours ; Gravure : remplissage avec des lignes de trame" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "Mode grille" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "Choisir quels paramètres varier sur les axes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "Vitesse fixe" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "Vitesse constante pour toutes les cellules (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "Puissance fixe (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "Puissance constante pour toutes les cellules" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "Puissance minimale (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "Pour la première colonne" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "Puissance maximale (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "Pour la dernière colonne" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "Vitesse minimale" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "Vitesse de départ (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "Vitesse maximale" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "Vitesse finale (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "Passages minimum" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "Nombre de passages de départ" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "Passages maximum" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "Nombre de passages de fin" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "Décalage minimum" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "Décalage X de balayage bidirectionnel pour la première ligne (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "Décalage maximum" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "Décalage X de balayage bidirectionnel pour la dernière ligne (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "Puissance de gravure de l'étiquette (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "Vitesse de gravure des étiquettes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "Vitesse pour graver les étiquettes (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "Colonnes (paliers de puissance)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "Nombre de variations de puissance" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "Lignes (paliers de vitesse)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "Nombre de variations de vitesse" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "Taille de la forme" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "Taille de chaque carré de test (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "Espacement" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "Écart entre les carrés de test (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "Intervalle de ligne" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" +"Distance entre les lignes de balayage en unités machine (pour le mode " +"Gravure). Laisser à 0 pour utiliser la taille du point laser." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "Inclure les étiquettes" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "Ajouter les annotations de vitesse/puissance à la grille" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "Lignes (Pas de passages)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "Nombre de variations de passages" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "Colonnes (Pas de vitesse)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "Lignes (étapes de décalage)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "Nombre de variations de décalage" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "Vitesse de gravure" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "Rasteriser l'image sur le matériau." + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "Modulation de puissance et plage de luminosité." + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "Mode" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "Seuil" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "Seuil de luminosité pour le noir/blanc (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "Méthode de gravure" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "Algorithme de conversion du niveaux de gris en binaire" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "Niveaux Automatiques" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "Ajuster automatiquement les points noir/blanc" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "Plage de luminosité" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "Ajusté automatiquement en fonction du contenu de l'image" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "Glisser les marqueurs pour définir les points noir/blanc" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "Puissance minimale" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "" +"Puissance pour les zones les plus claires, en % de la puissance principale " +"de l’étape" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "Puissance max." + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "" +"Puissance pour les zones les plus sombres, en % de la puissance principale " +"de l’étape" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "Niveaux de puissance" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "Nombre de pas de puissance discrets (plus bas = moins de déplacements)" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "Nombre de niveaux de profondeur" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "Descente en Z par niveau" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "Angle de rotation par passe" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "Degrés de rotation pour chaque passe successive" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "Angle" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "Angle des lignes de balayage en degrés" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "Hachures croisées" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "Ajouter une seconde passe à 90 degrés" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "Segmenté" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "Balayage complet" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "Mode de balayage" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" +"Segmenté : se déplace entre les zones de contenu. Balayage complet : balaie " +"toute la largeur avec activation du laser." + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "Espacement des lignes" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "Distance entre les lignes de balayage" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "Intervalle d'échantillonnage" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" +"Distance entre les échantillons de puissance le long de la ligne debalayage. " +"Des valeurs plus faibles améliorent la précision, mais augmententla taille " +"de sortie." + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "Correction de largeur de point" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" +"Réduit la longueur de gravure aux deux extrémités pour compenser la largeur " +"physique du point" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "Décalage de balayage bidirectionnel" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" +"Corrige le désalignement en X entre les passages de rasterisation de gaucheà " +"droite et de droite à gauche" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "Inverser" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "Graver les zones blanches au lieu des zones noires" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "Modifier la plage de puissance" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "Puissance minimale (Noir)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "Puissance maximale (Blanc)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "Puissance min (Blanc)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "Puissance max (Noir)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "Forcer facultativement une tête laser spécifique" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "" +"Puissance laser aux positions des languettes (% de la puissance de coupe)" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "Paramètres de l'étape" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "{power_percent}% de puissance, {speed_str}" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Décale le contour vers l'intérieur/extérieur selon le côté de coupe (aucun " +"sur la ligne centrale). Par défaut, compense le kerf de la tête" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" +"Distance entre les passes de front d'onde ; par défaut, la largeur du spot " +"laser si non définie" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "Cadre" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Décale le cadre vers l'intérieur/extérieur selon le côté de coupe (aucun sur " +"la ligne centrale). Par défaut, compense le kerf de la tête" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "Angle de balayage" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "Mode de profondeur" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "Niveau de puissance min." + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "Niveau de puissance max." + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "Contour" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Décale le trajet de coupe vers l'intérieur/extérieur selon le côté de coupe " +"(aucun sur la ligne centrale). Par défaut, compense le kerf de la tête" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "Grille de test de matériau" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "Créer une grille de test de matériau" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "Tête laser" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +msgstr "Ajouter un test de matériau" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot new file mode 100644 index 000000000..2ad91ee01 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot @@ -0,0 +1,749 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py +#: laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "" + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +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 new file mode 100644 index 000000000..9b27d027d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,772 @@ +# Portuguese translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# Samuel Abels , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2025-07-24 22:09+0200\n" +"Last-Translator: Samuel Abels \n" +"Language-Team: Portuguese \n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "Corte" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "Gravar" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "Potência vs Velocidade" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "Potência vs Passagens" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "Velocidade vs Passagens" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "Velocidade vs Deslocamento" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "Gravidade" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "Puxa o invólucro para dentro. 0.0 é um invólucro convexo padrão." + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "Envoltório" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "Encaixe um invólucro à volta do conteúdo e trace-o." + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "Passo lateral" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "Avanço lateral entre passadas de frente de onda" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "Deslocamento" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "Deslocamento extra em relação às paredes" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "Frente de onda" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "Limpe cavidades com um percurso de ferramenta de frente de onda." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "Ordem de Corte" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "Ordem de processamento para caminhos aninhados" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "Remover Caminhos Internos" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "Se ativado, traça apenas o contorno externo das formas." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "Sobrecorte" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" +"Estender contornos fechados além do ponto de início para que o corte se " +"sobreponha" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "Reexaminar Conteúdo" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "Ignorar geometria de origem e retraçar dentro da peça de trabalho" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "Limiar de Rastreamento" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "Nível de brilho (0.0-1.0) para definir bordas" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "Definições de Contorno" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "Trace o contorno das formas selecionadas." + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "Geometria" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "Corte um quadro à volta do conteúdo selecionado." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "Velocidade de Corte" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "Laser" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "Potência, velocidade e seleção de cabeça do laser para esta operação." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "Máquina" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "Definições fornecidas pelo hardware da máquina para esta cabeça." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "Alterar Cabeça" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Desloca o percurso para dentro/fora conforme o Lado de Corte (nenhum na " +"Linha Central). Por predefinição, compensação de kerf para a cabeça" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "Potência das Abas" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "Potência do laser nas posições das abas em percentagem" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "Assistência de Ar" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "Sopre ar sobre o corte para limpar resíduos" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "Lado de Corte" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "Frequência" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "Frequência PWM do laser em Hz" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "Largura de Pulso" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "Largura de pulso PWM do laser em ns" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "Potência" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "Potência do laser em percentagem" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "Predefinição" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "Carregue configurações de teste comuns." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "Grelha" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "Dimensões, forma e espaçamento das células de teste." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "Rótulos" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "Anotações de velocidade/potência na grelha." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "Parâmetros" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "Defina os intervalos de parâmetros para a grelha de teste." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "Gravura com diodo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "Corte com diodo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "Gravura com CO2" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "Corte com CO2" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "Selecionar" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "Predefinições" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "Carregar configurações de teste comuns" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "Tipo de Teste" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "Corte: contornos; Gravação: preenchimentos com linhas raster" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "Modo de grade" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "Escolha quais parâmetros variar nos eixos" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "Velocidade fixa" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "Velocidade constante para todas as células (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "Potência fixa (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "Potência constante para todas as células" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "Potência Mínima (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "Para a primeira coluna" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "Potência Máxima (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "Para a última coluna" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "Velocidade Mínima" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "Velocidade inicial (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "Velocidade Máxima" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "Velocidade final (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "Passagens mínimas" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "Número inicial de passagens" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "Passagens máximas" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "Número final de passagens" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "Deslocamento mínimo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "Deslocamento X de varredura bidirecional para a primeira linha (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "Deslocamento máximo" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "Deslocamento X de varredura bidirecional para a última linha (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "Potência de Gravação do Rótulo (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "Velocidade de gravação de rótulos" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "Velocidade para gravar rótulos (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "Colunas (Etapas de Potência)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "Número de variações de potência" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "Linhas (Etapas de Velocidade)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "Número de variações de velocidade" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "Tamanho da Forma" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "Tamanho de cada quadrado de teste (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "Espaçamento" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "Intervalo entre os quadrados de teste (mm)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "Intervalo de Linha" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" +"Distância entre linhas de varredura em unidades de máquina (para modo " +"Gravação). Deixar em 0 para usar o tamanho do ponto do laser." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "Incluir Rótulos" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "Adicionar anotações de velocidade/potência à grade" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "Linhas (Passos de passagens)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "Número de variações de passagens" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "Colunas (Passos de velocidade)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "Linhas (etapas de deslocamento)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "Número de variações de deslocamento" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "Velocidade de Gravação" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "Gere a imagem raster sobre o material." + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "Modulação de potência e intervalo de brilho." + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "Modo" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "Limiar" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "Limite de brilho para preto/branco (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "Método de Gravação" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "Algoritmo para converter tons de cinza para binário" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "Níveis Automáticos" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "Ajustar automaticamente pontos preto/branco" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "Intervalo de Brilho" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "Ajustado automaticamente com base no conteúdo da imagem" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "Arraste os marcadores para definir pontos de preto/branco" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "Potência Mínima" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "" +"Potência para as áreas mais claras, como uma % da potência principal da etapa" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "Potência Máxima" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "" +"Potência para as áreas mais escuras, como uma % da potência principal da " +"etapa" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "Níveis de Potência" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "Número de etapas discretas de potência (menor = menos movimentos)" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "Número de Níveis de Profundidade" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "Avanço Z por nível" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "Ângulo de Rotação Por Passagem" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "Graus para rodar cada passagem sucessiva" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "Ângulo" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "Ângulo das linhas de varredura em graus" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "Hachura Cruzada" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "Adicionar uma segunda passagem a90 graus" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "Segmentado" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "Varredura completa" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "Modo de varredura" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" +"Segmentado: move-se entre regiões de conteúdo. Varredura completa: varre " +"toda a largura com ativação do laser." + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "Espaçamento de Linha" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "Distância entre linhas de varredura" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "Intervalo de Amostragem" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" +"Distância entre amostras de potência ao longo da linha de varredura.Valores " +"menores melhoram a precisão, mas aumentam o tamanho da saída." + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "Correção de largura do ponto" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" +"Reduz o comprimento de gravação em ambas as extremidades para compensar a " +"largura física do ponto" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "Deslocamento de varredura bidirecional" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" +"Corrige o desalinhamento em X entre passadas de rasterização da esquerdapara " +"a direita e da direita para a esquerda" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "Inverter" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "Gravar áreas brancas em vez de áreas pretas" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "Alterar Faixa de Potência" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "Potência Mínima (Preto)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "Potência Máxima (Branco)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "Potência Mín. (Branco)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "Potência Máx. (Preto)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "Opcionalmente, force uma cabeça de laser específica" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "Potência do laser nas posições das abas (% da potência de corte)" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "Definições do Passo" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "{power_percent}% de potência, {speed_str}" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Desloca o contorno para dentro/fora conforme o Lado de Corte (nenhum na " +"Linha Central). Por predefinição, compensação de kerf para a cabeça" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" +"Distância entre passagens da frente de onda; por predefinição, a largura do " +"ponto do laser quando não definida" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "Moldura" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Desloca o quadro para dentro/fora conforme o Lado de Corte (nenhum na Linha " +"Central). Por predefinição, compensação de kerf para a cabeça" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "Ângulo de Varrimento" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "Modo de Profundidade" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "Nível de Potência Mín" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "Nível de Potência Máx" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "Contorno" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Desloca o percurso de corte para dentro/fora conforme o Lado de Corte " +"(nenhum na Linha Central). Por predefinição, compensação de kerf para a " +"cabeça" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "Grade de Teste de Material" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "Criar Grade de Teste de Material" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "Cabeça de Laser" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +msgstr "Adicionar Teste de Material" 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 new file mode 100644 index 000000000..fc60750f2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,767 @@ +# Ukrainian translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2026-02-23 01:17+0100\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Ukrainian\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ?0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ?1 :2);\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "Різання" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "Гравіювання" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "Потужність vs Швидкість" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "Потужність vs Проходи" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "Швидкість vs Проходи" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "Швидкість vs Зсув" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "Гравітація" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "Тягне оболонку всередину. 0.0 — стандартна опукла оболонка" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "Обтискання" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "Побудуйте оболонку навколо вмісту та виконайте її трасування." + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "Крок переміщення" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "Бічний крок між проходами хвильового фронту" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "Зміщення" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "Додатковий зсув від стінок" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "Хвильовий фронт" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "Очистіть кишені траєкторією хвильового фронту." + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "Порядок різання" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "Порядок обробки вкладених контурів" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "Видалити внутрішні контури" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "Якщо увімкнено, трасувати лише зовнішній контур фігур" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "Надріз" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "" +"Продовжувати замкнуті контури за початкову точку, щоб різання перетиналося" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "Пересканувати вміст" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "Ігнорувати вихідну геометрію і повторно трасувати в робочій деталі" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "Поріг трасування" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "Рівень яскравості (0.0-1.0) для визначення країв" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "Налаштування контуру" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "Виконайте трасування контуру вибраних фігур." + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "Геометрія" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "Виріжте рамку навколо вибраного вмісту." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "Швидкість різання" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "Лазер" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "Потужність лазера, швидкість та вибір голівки для цієї операції." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "Машина" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "Налаштування, що надаються обладнанням машини для цієї голівки." + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "Змінити голівку" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Зсуває шлях усередину/назовні залежно від сторони різу (жодного на " +"центральній лінії). За замовчуванням — компенсація kerf для голівки" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "Потужність перемичок" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "Потужність лазера в позиціях перемичок у відсотках" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "Повітряний обдув" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "Подавати повітря на місце різу, щоб прибирати відходи" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "Сторона різу" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "Частота" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "Частота ШІМ лазера в Гц" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "Ширина імпульсу" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "Ширина імпульсу ШІМ лазера в нс" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "Потужність" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "Потужність лазера у відсотках" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "Пресет" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "Завантажити типові конфігурації тестування." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "Сітка" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "Розміри тестових комірок, форма та інтервали." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "Мітки" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "Анотації швидкості/потужності на сітці." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "Параметри" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "Визначте діапазони параметрів для тестової сітки." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "Діодне гравіювання" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "Діодне різання" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "CO2 гравіювання" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "CO2 різання" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "Вибрати" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "Пресети" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "Завантажити типові конфігурації тестування" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "Тип тесту" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "Різання: контури; Гравіювання: заповнення растровими лініями" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "Режим сітки" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "Виберіть, які параметри змінювати на осях" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "Фіксована швидкість" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "Постійна швидкість для всіх клітинок (мм/хв)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "Фіксована потужність (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "Постійна потужність для всіх клітинок" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "Мінімальна потужність (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "Для першого стовпця" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "Максимальна потужність (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "Для останнього стовпця" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "Мінімальна швидкість" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "Початкова швидкість (мм/хв)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "Максимальна швидкість" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "Кінцева швидкість (мм/хв)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "Мінімум проходів" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "Початкова кількість проходів" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "Максимум проходів" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "Кінцева кількість проходів" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "Мінімальний зсув" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "Двоспрямований X-зсув сканування для першого рядка (мм)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "Максимальний зсув" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "Двоспрямований X-зсув сканування для останнього рядка (мм)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "Потужність гравіювання міток (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "Швидкість гравіювання міток" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "Швидкість гравіювання міток (мм/хв)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "Стовпці (кроки потужності)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "Кількість варіацій потужності" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "Рядки (кроки швидкості)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "Кількість варіацій швидкості" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "Розмір фігури" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "Розмір кожного тестового квадрата (мм)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "Інтервал" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "Проміжок між тестовими квадратами (мм)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "Інтервал ліній" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "" +"Відстань між лініями сканування в одиницях верстата (для режиму " +"гравіювання). Залиште 0, щоб використовувати розмір плями лазера." + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "Включити мітки" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "Додати анотації швидкості/потужності до сітки" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "Рядки (Кроки проходів)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "Кількість варіацій проходів" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "Стовпці (Кроки швидкості)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "Рядки (кроки зсуву)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "Кількість варіацій зсуву" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "Швидкість гравіювання" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "Виконайте растрування зображення на матеріалі." + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "Модуляція потужності та діапазон яскравості." + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "Режим" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "Поріг" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "Поріг яскравості для чорно-білого (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "Метод гравіювання" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "Алгоритм перетворення відтінків сірого у двійковий формат" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "Авто рівні" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "Автоматично налаштувати чорні/білі точки" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "Діапазон яскравості" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "Автоналаштування на основі вмісту зображення" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "Перетягніть маркери, щоб встановити чорні/білі точки" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "Мін. потужність" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "Потужність для найсвітліших ділянок, у % від основної потужності кроку" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "Макс. потужність" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "Потужність для найтемніших ділянок, у % від основної потужності кроку" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "Рівні потужності" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "Кількість дискретних кроків потужності (менше = менше рухів)" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "Кількість рівнів глибини" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "Зниження по Z за рівень" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "Кут повороту за прохід" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "Градуси повороту кожного наступного проходу" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "Кут" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "Кут ліній сканування в градусах" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "Перехресна штриховка" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "Додати другий прохід під 90 градусів" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "Сегментований" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "Повне проходження" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "Режим сканування" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "" +"Сегментований: рухається між областями вмісту. Повне проходження: сканує всю " +"ширину з вмиканням/вимиканням лазера." + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "Інтервал між лініями" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "Відстань між лініями сканування" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "Інтервал вибірки" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "" +"Відстань між зразками потужності вздовж лінії сканування. Нижчі " +"значенняпідвищують точність, але збільшують розмір результату." + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "Корекція ширини точки" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "" +"Зменшує довжину гравіювання на обох кінцях для компенсації фізичної ширини " +"точки" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "Двоспрямований зсув сканування" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "" +"Виправляє зміщення по X між растрними проходами зліва направо та справаналіво" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "Інвертувати" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "Гравіювати білі ділянки замість чорних" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "Змінити діапазон потужності" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "Мін. потужність (чорне)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "Макс. потужність (біле)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "Мін. потужність (біле)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "Макс. потужність (чорне)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "За бажанням примусово вибрати конкретну лазерну голівку" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "Потужність лазера в позиціях перемичок (% від потужності різання)" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "Налаштування кроку" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "{power_percent}% потужності, {speed_str}" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Зсуває контур усередину/назовні залежно від сторони різу (жодного на " +"центральній лінії). За замовчуванням — компенсація kerf для голівки" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "" +"Відстань між проходами хвильового фронту; за замовчуванням — ширина плями " +"лазера, якщо не встановлено" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "Рамка" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"Зсуває рамку усередину/назовні залежно від сторони різу (жодного на " +"центральній лінії). За замовчуванням — компенсація kerf для голівки" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "Кут сканування" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "Режим глибини" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "Мінімальний рівень потужності" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "Максимальний рівень потужності" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "Контур" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"Зсуває шлях різання усередину/назовні залежно від сторони різу (жодного на " +"центральній лінії). За замовчуванням — компенсація kerf для голівки" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "Сітка тестування матеріалу" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "Створити сітку тестування матеріалу" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "Лазерна голівка" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +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 new file mode 100644 index 000000000..3928a4098 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po @@ -0,0 +1,750 @@ +# Chinese (Simplified) translations for Rayforge package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-11 00:30+0200\n" +"PO-Revision-Date: 2025-07-13 11:49+0200\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Chinese (Simplified)\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: laser_essentials/material_test_helpers.py +msgid "Cut" +msgstr "切割" + +#: laser_essentials/material_test_helpers.py +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Engrave" +msgstr "雕刻" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Speed" +msgstr "功率 vs 速度" + +#: laser_essentials/material_test_helpers.py +msgid "Power vs Passes" +msgstr "功率 vs 次数" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Passes" +msgstr "速度 vs 次数" + +#: laser_essentials/material_test_helpers.py +msgid "Speed vs Offset" +msgstr "速度 vs 偏移" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Gravity" +msgstr "重力" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Pulls the hull inward. 0.0 is a standard convex hull" +msgstr "向内拉动外壳。0.0 是标准的凸包" + +#: laser_essentials/widgets/shrinkwrap_page.py +#: laser_essentials/steps/shrinkwrap_step.py +msgid "Shrink Wrap" +msgstr "收缩包裹" + +#: laser_essentials/widgets/shrinkwrap_page.py +msgid "Fit a hull around the content and trace it." +msgstr "在内容周围拟合外壳并描摹它。" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Step Over" +msgstr "步进" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Lateral step-over between wavefront passes" +msgstr "波前通道之间的横向步距" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/widgets/rows/offset_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/wavefront_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Offset" +msgstr "偏移" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Extra offset from walls" +msgstr "与墙体的额外偏移" + +#: laser_essentials/widgets/wavefront_page.py +#: laser_essentials/steps/wavefront_step.py +msgid "Wavefront" +msgstr "波前" + +#: laser_essentials/widgets/wavefront_page.py +msgid "Clear pockets with a wavefront toolpath." +msgstr "使用波前刀路清除腔体。" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Cut Order" +msgstr "切割顺序" + +#: laser_essentials/widgets/contour_page.py +msgid "Processing order for nested paths" +msgstr "嵌套路径的处理顺序" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Remove Inner Paths" +msgstr "移除内部路径" + +#: laser_essentials/widgets/contour_page.py +msgid "If enabled, only trace the outer outline of shapes" +msgstr "如果启用,仅描摹形状的外部轮廓" + +#: laser_essentials/widgets/contour_page.py +#: laser_essentials/steps/contour_step.py +msgid "Overcut" +msgstr "过切" + +#: laser_essentials/widgets/contour_page.py +msgid "" +"Extend closed contours past their start point so the cut overlaps itself" +msgstr "将闭合轮廓延伸至起点以外,使切口重叠" + +#: laser_essentials/widgets/contour_page.py +msgid "Rescan Content" +msgstr "重新扫描内容" + +#: laser_essentials/widgets/contour_page.py +msgid "Ignore source geometry and re-trace within the workpiece" +msgstr "忽略源几何图形并在工件内重新描摹" + +#: laser_essentials/widgets/contour_page.py +msgid "Tracing Threshold" +msgstr "描摹阈值" + +#: laser_essentials/widgets/contour_page.py +msgid "Brightness level (0.0-1.0) to define edges" +msgstr "定义边缘的亮度级别 (0.0-1.0)" + +#: laser_essentials/widgets/contour_page.py +msgid "Contour Settings" +msgstr "轮廓设置" + +#: laser_essentials/widgets/contour_page.py +msgid "Trace the outline of the selected shapes." +msgstr "描摹所选形状的轮廓。" + +#: laser_essentials/widgets/frame_page.py +msgid "Geometry" +msgstr "几何" + +#: laser_essentials/widgets/frame_page.py +msgid "Cut a frame around the selected content." +msgstr "在所选内容周围切割一个边框。" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Cut Speed" +msgstr "切割速度" + +#: laser_essentials/widgets/rows/laser_step_page.py +#: laser_essentials/steps/laser_step.py +msgid "Laser" +msgstr "激光" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Laser power, speed, and head selection for this operation." +msgstr "此操作的激光功率、速度和机头选择。" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Machine" +msgstr "机器" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Settings provided by the machine's hardware for this head." +msgstr "机器硬件为此机头提供的设置。" + +#: laser_essentials/widgets/rows/laser_step_page.py +msgid "Change Head" +msgstr "更换机头" + +#: laser_essentials/widgets/rows/offset_row.py +msgid "" +"Shifts the path inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"根据切割侧向内/向外偏移路径(中心线时无偏移)。默认根据机头进行切缝补偿" + +#: laser_essentials/widgets/rows/tab_power_row.py +#: laser_essentials/steps/laser_step.py +msgid "Tab Power" +msgstr "定位片功率" + +#: laser_essentials/widgets/rows/tab_power_row.py +msgid "Laser power at tab positions as a percentage" +msgstr "定位片位置的激光功率(百分比)" + +#: laser_essentials/widgets/rows/air_assist_row.py +#: laser_essentials/steps/laser_step.py +msgid "Air Assist" +msgstr "空气辅助" + +#: laser_essentials/widgets/rows/air_assist_row.py +msgid "Blow air over the cut to clear debris" +msgstr "向切口吹气以清除碎屑" + +#: laser_essentials/widgets/rows/cut_side_row.py +#: laser_essentials/steps/shrinkwrap_step.py +#: laser_essentials/steps/frame_step.py laser_essentials/steps/contour_step.py +msgid "Cut Side" +msgstr "切割侧" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Frequency" +msgstr "频率" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM frequency in Hz" +msgstr "激光 PWM 频率(Hz)" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Pulse Width" +msgstr "脉冲宽度" + +#: laser_essentials/widgets/rows/pwm_row.py +msgid "Laser PWM pulse width in ns" +msgstr "激光 PWM 脉冲宽度(ns)" + +#: laser_essentials/widgets/rows/power_row.py +#: laser_essentials/widgets/raster_page.py laser_essentials/steps/laser_step.py +msgid "Power" +msgstr "功率" + +#: laser_essentials/widgets/rows/power_row.py +msgid "Laser power as a percentage" +msgstr "激光功率(百分比)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Preset" +msgstr "预设" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations." +msgstr "加载常用测试配置。" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid" +msgstr "网格" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test cell dimensions, shape, and spacing." +msgstr "测试单元的尺寸、形状和间距。" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Labels" +msgstr "标签" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed/power annotations on the grid." +msgstr "网格上的速度/功率标注。" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Parameters" +msgstr "参数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Define the parameter ranges for the test grid." +msgstr "定义测试网格的参数范围。" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Engrave" +msgstr "二极管雕刻" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Diode Cut" +msgstr "二极管切割" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Engrave" +msgstr "CO2 雕刻" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "CO2 Cut" +msgstr "CO2 切割" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Select" +msgstr "选择" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Presets" +msgstr "预设" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Load common test configurations" +msgstr "加载常用测试配置" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Test Type" +msgstr "测试类型" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Cut: outlines; Engrave: fills with raster lines" +msgstr "切割:轮廓;雕刻:用光栅线填充" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Grid Mode" +msgstr "网格模式" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Choose which parameters to vary on axes" +msgstr "选择在轴上变化的参数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Speed" +msgstr "固定速度" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant speed for all cells (mm/min)" +msgstr "所有单元格的恒定速度(毫米/分钟)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Fixed Power (%)" +msgstr "固定功率(%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Constant power for all cells" +msgstr "所有单元格的恒定功率" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Power (%)" +msgstr "最小功率 (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For first column" +msgstr "第一列" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Power (%)" +msgstr "最大功率 (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "For last column" +msgstr "最后一列" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Speed" +msgstr "最小速度" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting speed (mm/min)" +msgstr "起始速度 (毫米/分钟)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Speed" +msgstr "最大速度" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending speed (mm/min)" +msgstr "结束速度 (毫米/分钟)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Passes" +msgstr "最小次数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Starting number of passes" +msgstr "起始通过次数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Passes" +msgstr "最大次数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Ending number of passes" +msgstr "结束通过次数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Minimum Offset" +msgstr "最小偏移" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for first row (mm)" +msgstr "第一行的双向扫描X偏移(毫米)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Maximum Offset" +msgstr "最大偏移" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Bidir scan X-offset for last row (mm)" +msgstr "最后一行的双向扫描X偏移(毫米)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Power (%)" +msgstr "标签雕刻功率 (%)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Label Engrave Speed" +msgstr "标签雕刻速度" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Speed for engraving labels (mm/min)" +msgstr "雕刻标签的速度 (mm/min)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Power Steps)" +msgstr "列 (功率阶梯)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of power variations" +msgstr "功率变化的数量" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Speed Steps)" +msgstr "行 (速度阶梯)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of speed variations" +msgstr "速度变化的数量" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Shape Size" +msgstr "形状大小" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Size of each test square (mm)" +msgstr "每个测试方块的大小 (毫米)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Spacing" +msgstr "间距" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Gap between test squares (mm)" +msgstr "测试方块之间的间隙 (毫米)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Line Interval" +msgstr "线间隔" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "" +"Distance between scan lines in machine units (for Engrave mode). Leave at 0 " +"to use laser spot size." +msgstr "扫描线之间的距离(以机器单位,用于雕刻模式)。留0使用激光光斑大小。" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Include Labels" +msgstr "包含标签" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Add speed/power annotations to the grid" +msgstr "向网格添加速度/功率注释" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Passes Steps)" +msgstr "行(次数步进)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of passes variations" +msgstr "通过次数变体数" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Columns (Speed Steps)" +msgstr "列(速度步进)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Rows (Offset Steps)" +msgstr "行(偏移步数)" + +#: laser_essentials/widgets/material_test_grid_page.py +msgid "Number of offset variations" +msgstr "偏移变化次数" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave Speed" +msgstr "雕刻速度" + +#: laser_essentials/widgets/raster_page.py +msgid "Raster the image onto the material." +msgstr "将图像栅格化到材料上。" + +#: laser_essentials/widgets/raster_page.py +msgid "Power modulation and brightness range." +msgstr "功率调制和亮度范围。" + +#: laser_essentials/widgets/raster_page.py +msgid "Mode" +msgstr "模式" + +#: laser_essentials/widgets/raster_page.py +msgid "Threshold" +msgstr "阈值" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness cutoff for black/white (0-255)" +msgstr "黑/白亮度截止值 (0-255)" + +#: laser_essentials/widgets/raster_page.py +msgid "Engraving Method" +msgstr "雕刻方法" + +#: laser_essentials/widgets/raster_page.py +msgid "Algorithm for converting grayscale to binary" +msgstr "将灰度转换为二值的算法" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto Levels" +msgstr "自动色阶" + +#: laser_essentials/widgets/raster_page.py +msgid "Automatically adjust black/white points" +msgstr "自动调整黑白点" + +#: laser_essentials/widgets/raster_page.py +msgid "Brightness Range" +msgstr "亮度范围" + +#: laser_essentials/widgets/raster_page.py +msgid "Auto-adjusted based on image content" +msgstr "根据图像内容自动调整" + +#: laser_essentials/widgets/raster_page.py +msgid "Drag markers to set black/white points" +msgstr "拖动标记以设置黑/白点" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power" +msgstr "最小功率" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for lightest areas, as a % of the step's main power" +msgstr "最亮区域的功率,占该步骤主功率的百分比" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power" +msgstr "最大功率" + +#: laser_essentials/widgets/raster_page.py +#, python-format +msgid "Power for darkest areas, as a % of the step's main power" +msgstr "最暗区域的功率,占该步骤主功率的百分比" + +#: laser_essentials/widgets/raster_page.py +msgid "Power Levels" +msgstr "功率级别" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of discrete power steps (lower = fewer moves)" +msgstr "离散功率步数(越低=移动越少)" + +#: laser_essentials/widgets/raster_page.py +msgid "Number of Depth Levels" +msgstr "深度层级数" + +#: laser_essentials/widgets/raster_page.py +msgid "Z Step-Down per Level" +msgstr "每层级 Z 轴下降量" + +#: laser_essentials/widgets/raster_page.py +msgid "Rotate Angle Per Pass" +msgstr "每次旋转角度" + +#: laser_essentials/widgets/raster_page.py +msgid "Degrees to rotate each successive pass" +msgstr "每次连续扫描旋转的度数" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle" +msgstr "角度" + +#: laser_essentials/widgets/raster_page.py +msgid "Angle of scan lines in degrees" +msgstr "扫描线角度(度)" + +#: laser_essentials/widgets/raster_page.py +msgid "Cross-Hatch" +msgstr "交叉填充" + +#: laser_essentials/widgets/raster_page.py +msgid "Add a second pass at 90 degrees" +msgstr "添加90度第二次扫描" + +#: laser_essentials/widgets/raster_page.py +msgid "Segmented" +msgstr "分段" + +#: laser_essentials/widgets/raster_page.py +msgid "Full Sweep" +msgstr "全幅扫描" + +#: laser_essentials/widgets/raster_page.py +msgid "Scan Mode" +msgstr "扫描模式" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Segmented: moves between content regions. Full Sweep: scans full width with " +"laser toggling." +msgstr "分段:在内容区域之间移动。全幅扫描:扫描整个宽度,激光切换。" + +#: laser_essentials/widgets/raster_page.py +msgid "Line Spacing" +msgstr "线间距" + +#: laser_essentials/widgets/raster_page.py +msgid "Distance between scan lines" +msgstr "扫描线之间的距离" + +#: laser_essentials/widgets/raster_page.py +msgid "Sample Interval" +msgstr "采样间隔" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Distance between power samples along scan line. Lower values improve " +"accuracy, but increase output size. " +msgstr "沿扫描线的功率采样点之间的距离。较低的值可提高精度,但会增加输出大小。" + +#: laser_essentials/widgets/raster_page.py +msgid "Dot Width Correction" +msgstr "点宽校正" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Reduces engrave length at both ends to compensate for physical dot width" +msgstr "缩短两端雕刻长度以补偿物理点宽" + +#: laser_essentials/widgets/raster_page.py +msgid "Bidirectional Scan Offset" +msgstr "双向扫描偏移" + +#: laser_essentials/widgets/raster_page.py +msgid "" +"Corrects X misalignment between left-to-right and right-to-left raster passes" +msgstr "校正从左到右与从右到左光栅通道之间的 X 错位" + +#: laser_essentials/widgets/raster_page.py +#: laser_essentials/steps/raster_step.py +msgid "Invert" +msgstr "反转" + +#: laser_essentials/widgets/raster_page.py +msgid "Engrave white areas instead of black areas" +msgstr "雕刻白色区域而不是黑色区域" + +#: laser_essentials/widgets/raster_page.py +msgid "Change Power Range" +msgstr "更改功率范围" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (Black)" +msgstr "最小功率 (黑)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (White)" +msgstr "最大功率 (白)" + +#: laser_essentials/widgets/raster_page.py +msgid "Min Power (White)" +msgstr "最小功率 (白)" + +#: laser_essentials/widgets/raster_page.py +msgid "Max Power (Black)" +msgstr "最大功率 (黑)" + +#: laser_essentials/steps/laser_step.py +msgid "Optionally force a specific laser head" +msgstr "可选:强制使用特定的激光头" + +#: laser_essentials/steps/laser_step.py +#, python-format +msgid "Laser power at tab positions (% of cut power)" +msgstr "定位片位置的激光功率(切割功率的 %)" + +#: laser_essentials/steps/laser_step.py +msgid "Step Settings" +msgstr "步骤设置" + +#: laser_essentials/steps/laser_step.py +#, python-brace-format +msgid "{power_percent}% power, {speed_str}" +msgstr "{power_percent}% 功率,{speed_str}" + +#: laser_essentials/steps/shrinkwrap_step.py +msgid "" +"Shifts the contour inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"根据切割侧向内/向外偏移轮廓(中心线时无偏移)。默认根据机头进行切缝补偿" + +#: laser_essentials/steps/wavefront_step.py +msgid "" +"Distance between wavefront passes; defaults to the laser spot width when " +"unset" +msgstr "波前扫描之间的距离;未设置时默认为激光光斑宽度" + +#: laser_essentials/steps/frame_step.py +msgid "Frame" +msgstr "边框" + +#: laser_essentials/steps/frame_step.py +msgid "" +"Shifts the frame inward/outward per Cut Side (none on Centerline). Defaults " +"to kerf compensation for the head" +msgstr "" +"根据切割侧向内/向外偏移边框(中心线时无偏移)。默认根据机头进行切缝补偿" + +#: laser_essentials/steps/raster_step.py +msgid "Scan Angle" +msgstr "扫描角度" + +#: laser_essentials/steps/raster_step.py +msgid "Depth Mode" +msgstr "深度模式" + +#: laser_essentials/steps/raster_step.py +msgid "Min Power Level" +msgstr "最小功率级别" + +#: laser_essentials/steps/raster_step.py +msgid "Max Power Level" +msgstr "最大功率级别" + +#: laser_essentials/steps/contour_step.py +msgid "Contour" +msgstr "轮廓" + +#: laser_essentials/steps/contour_step.py +msgid "" +"Shifts the cut path inward/outward per Cut Side (none on Centerline). " +"Defaults to kerf compensation for the head" +msgstr "" +"根据切割侧向内/向外偏移切割路径(中心线时无偏移)。默认根据机头进行切缝补偿" + +#: laser_essentials/steps/material_test.py +#: laser_essentials/commands/material_test_cmd.py +msgid "Material Test Grid" +msgstr "材料测试网格" + +#: laser_essentials/frontend.py +msgid "Create Material Test Grid" +msgstr "创建材料测试网格" + +#: laser_essentials/laser_head_var.py +msgid "Laser Head" +msgstr "激光头" + +#: laser_essentials/commands/material_test_cmd.py +msgid "Add Material Test" +msgstr "添加材料测试" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-laser/rayforge-addon.yaml new file mode 100644 index 000000000..179dc65ef --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/rayforge-addon.yaml @@ -0,0 +1,14 @@ +name: laser_essentials +display_name: "Laser Essentials" +description: "Core laser cutting functionality" +api_version: 19 +requires: + - post_processors +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "laser_essentials.worker" + frontend: "laser_essentials.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/conftest.py new file mode 100644 index 000000000..e20ccbb5a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/conftest.py @@ -0,0 +1,74 @@ +""" +Pytest configuration for laser_essentials builtin addon tests. + +This conftest ensures that producers, steps, and assemblers are registered +with their respective registries before tests run. +""" + +from unittest.mock import MagicMock + +import pluggy +import pytest +from laser_essentials.steps import ( + ContourStep, + EngraveStep, + FrameStep, + MaterialTestStep, + ShrinkWrapStep, +) + +from rayforge.addon_mgr.addon_manager import AddonManager +from rayforge.config import BUILTIN_ADDONS_DIR +from rayforge.core.hooks import RayforgeSpecs +from rayforge.core.step_registry import step_registry +from rayforge.machine.models.laser import LaserHead +from rayforge.pipeline.transformer.registry import transformer_registry + + +@pytest.fixture +def machine(): + """A machine whose resolved laser defaults mirror the historical + ``machine_defaults`` fixture: laser head spot (0.1, 0.1), arc + tolerance 0.03, arcs supported, curves not supported.""" + m = MagicMock() + m.arc_tolerance = 0.03 + m.supports_arcs = True + m.supports_curves = False + head = MagicMock(spec=LaserHead) + head.uid = "laser-1" + head.spot_size_mm = (0.1, 0.1) + m.heads = [head] + return m + + +def _register_steps(): + """Register all steps from laser_essentials addon.""" + step_registry.register(ContourStep, addon_name="laser_essentials") + step_registry.register(EngraveStep, addon_name="laser_essentials") + step_registry.register(FrameStep, addon_name="laser_essentials") + step_registry.register(MaterialTestStep, addon_name="laser_essentials") + step_registry.register(ShrinkWrapStep, addon_name="laser_essentials") + + +@pytest.fixture(scope="session", autouse=True) +def register_laser_essentials(): + """ + Automatically register laser_essentials producers and steps + for all tests in this addon. + + This also prevents ensure_addons_loaded() from loading via + AddonManager, which would register classes from a different + module path (rayforge_addons.*) causing isinstance() checks + to fail in tests. + """ + plugin_mgr = pluggy.PluginManager("rayforge") + plugin_mgr.add_hookspecs(RayforgeSpecs) + + mgr = AddonManager( + [BUILTIN_ADDONS_DIR], BUILTIN_ADDONS_DIR, plugin_mgr, MagicMock() + ) + mgr.set_registries({"transformer_registry": transformer_registry}) + mgr.load_addon_by_name("post_processors", worker_only=True) + + _register_steps() + yield 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 new file mode 100644 index 000000000..1b104c01e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_contour_step.py @@ -0,0 +1,366 @@ +from unittest.mock import MagicMock + +import pytest +from laser_essentials.steps import ContourStep +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.geo import Matrix +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.contour import ContourSpec + +from rayforge.core.step import Step +from rayforge.core.step_registry import step_registry +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def mock_context(): + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + default_head = MagicMock() + default_head.uid = "test-laser-uid" + default_head.spot_size_mm = (0.1, 0.1) + machine.get_default_laser_head.return_value = default_head + context.machine = machine + return context + + +class TestContourStep: + def test_instantiation(self): + step = ContourStep(name="Test") + assert step.typelabel == "Contour" + assert step.name == "Test" + + 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_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 + + def test_serialization_includes_step_type(self): + step = ContourStep(name="Test") + data = step.to_dict() + assert data["step_type"] == "ContourStep" + + def test_deserialization_returns_contour_step(self): + step_registry.register(ContourStep) + step = ContourStep(name="Original") + data = step.to_dict() + + restored = Step.from_dict(data) + assert isinstance(restored, ContourStep) + assert restored.name == "Original" + + def test_registry_create_contour_step(self, mock_context): + StepClass = step_registry.get("ContourStep") + assert StepClass is not None + step = StepClass.create(mock_context, name="FromRegistry") + assert isinstance(step, ContourStep) + assert step.name == "FromRegistry" + + def test_from_dict_adds_new_transformers_from_old_project(self): + step_registry.register(ContourStep) + old_project_data = { + "uid": "old-step-123", + "type": "step", + "step_type": "ContourStep", + "name": "Old Contour", + "matrix": Matrix.identity().to_list(), + "typelabel": "Contour", + "visible": True, + "per_workpiece_transformers_dicts": [ + {"name": "TabOpsTransformer", "enabled": True}, + ], + "children": [], + } + + restored = Step.from_dict(old_project_data) + + wp_names = [ + t["name"] for t in restored.per_workpiece_transformers_dicts + ] + assert "TabOpsTransformer" in wp_names + assert "Smooth" in wp_names + assert "CropTransformer" in wp_names + assert "Optimize" in wp_names + assert len(restored.per_step_transformers_dicts) == 3 + step_names = [t["name"] for t in restored.per_step_transformers_dicts] + assert "MergeLinesTransformer" in step_names + assert "Optimize" in step_names + assert "MultiPassTransformer" in step_names + + def test_from_dict_preserves_existing_transformer_settings(self): + step_registry.register(ContourStep) + old_project_data = { + "uid": "old-step-456", + "type": "step", + "step_type": "ContourStep", + "name": "Old Contour", + "matrix": Matrix.identity().to_list(), + "typelabel": "Contour", + "visible": True, + "per_workpiece_transformers_dicts": [ + { + "name": "TabOpsTransformer", + "enabled": True, + "custom_setting": 42, + }, + ], + "per_step_transformers_dicts": [], + "children": [], + } + + restored = Step.from_dict(old_project_data) + + tab_transformer = next( + t + for t in restored.per_workpiece_transformers_dicts + if t["name"] == "TabOpsTransformer" + ) + assert tab_transformer["custom_setting"] == 42 + assert tab_transformer["enabled"] is True + + def test_from_dict_uses_typelabel_fallback_when_no_step_type(self): + step_registry.register(ContourStep) + old_project_data = { + "uid": "old-step-789", + "type": "step", + "name": "Old Contour", + "matrix": Matrix.identity().to_list(), + "typelabel": "Contour", + "visible": True, + "per_workpiece_transformers_dicts": [ + {"name": "TabOpsTransformer", "enabled": True}, + ], + "children": [], + } + + restored = Step.from_dict(old_project_data) + + assert isinstance(restored, ContourStep) + wp_names = [ + t["name"] for t in restored.per_workpiece_transformers_dicts + ] + assert "CropTransformer" in wp_names + + def test_optimize_dict_is_shared_between_lists(self): + step_registry.register(ContourStep) + data = { + "uid": "test-step", + "type": "step", + "step_type": "ContourStep", + "name": "Test", + "matrix": Matrix.identity().to_list(), + "typelabel": "Contour", + "visible": True, + "per_workpiece_transformers_dicts": [ + {"name": "Optimize", "enabled": True}, + ], + "per_step_transformers_dicts": [ + {"name": "Optimize", "enabled": True}, + ], + "children": [], + } + + restored = Step.from_dict(data) + + wp_optimize = next( + t + for t in restored.per_workpiece_transformers_dicts + if t["name"] == "Optimize" + ) + step_optimize = next( + t + for t in restored.per_step_transformers_dicts + if t["name"] == "Optimize" + ) + + assert wp_optimize is step_optimize + + def test_get_assembler_kwargs(self, machine): + step = ContourStep(name="Test") + workpiece = MagicMock(spec=["size"]) + workpiece.size = (100, 100) + kwargs = step.get_assembler_kwargs(machine, workpiece) + assert isinstance(kwargs, dict) + expected_keys = { + "cut_side", + "cut_order", + "remove_inner", + "offset_mm", + "overcut", + "arc_tolerance", + "allow_arcs", + "supports_curves", + } + assert set(kwargs.keys()) == expected_keys + + def test_roundtrip_serialization(self): + step_registry.register(ContourStep) + step = ContourStep(name="Test") + step.cut_side = "OUTSIDE" + step.cut_order = "OUTSIDE_INSIDE" + step.remove_inner_paths = True + step.offset_mm = 0.5 + step.overcut = 1.0 + data = step.to_dict() + restored = ContourStep.from_dict(data) + assert data == restored.to_dict() + + def test_from_dict_migrates_legacy_offset_keys(self): + """Legacy files store path_offset_mm and kerf_mm; the combined + displacement is offset_mm = path_offset_mm + kerf_mm / 2.""" + step_registry.register(ContourStep) + legacy_data = { + "uid": "legacy-step", + "type": "step", + "step_type": "ContourStep", + "name": "Legacy", + "matrix": Matrix.identity().to_list(), + "typelabel": "Contour", + "visible": True, + "path_offset_mm": 0.4, + "kerf_mm": 0.2, + "per_workpiece_transformers_dicts": [], + "per_step_transformers_dicts": [], + "children": [], + } + + restored = Step.from_dict(legacy_data) + + assert isinstance(restored, ContourStep) + assert restored.offset_mm == pytest.approx(0.5) + + def test_from_dict_new_offset_key_wins(self): + """A current file's offset_mm is used verbatim, ignoring any + legacy keys that may also be present.""" + step_registry.register(ContourStep) + data = { + "uid": "new-step", + "type": "step", + "step_type": "ContourStep", + "name": "New", + "matrix": Matrix.identity().to_list(), + "typelabel": "Contour", + "visible": True, + "path_offset_mm": 0.4, + "kerf_mm": 0.2, + "offset_mm": 1.5, + "per_workpiece_transformers_dicts": [], + "per_step_transformers_dicts": [], + "children": [], + } + + restored = Step.from_dict(data) + + assert isinstance(restored, ContourStep) + assert restored.offset_mm == pytest.approx(1.5) + + def test_from_dict_migrates_legacy_opsproducer_params(self): + """True legacy files store contour params in + ``opsproducer_dict.params``; loading must restore them.""" + step_registry.register(ContourStep) + data = ContourStep(name="Test").to_dict() + for key in ( + "cut_side", + "cut_order", + "remove_inner_paths", + "offset_mm", + "overcut", + "override_threshold", + "threshold", + ): + data.pop(key, None) + data["opsproducer_dict"] = { + "type": "ContourProducer", + "params": { + "remove_inner_paths": True, + "path_offset_mm": 0.4, + "cut_side": "OUTSIDE", + "cut_order": "OUTSIDE_INSIDE", + "override_threshold": True, + "threshold": 0.7, + "overcut": 0.2, + }, + } + + restored = ContourStep.from_dict(data) + + assert restored.cut_side == "OUTSIDE" + assert restored.cut_order == "OUTSIDE_INSIDE" + assert restored.remove_inner_paths is True + assert restored.override_threshold is True + assert restored.threshold == 0.7 + assert restored.overcut == 0.2 + assert restored.offset_mm == pytest.approx(0.4) + + def test_step_from_dict_preserves_subclass_attrs(self): + """Step.from_dict (base call) must delegate to subclass from_dict.""" + step_registry.register(ContourStep) + step = ContourStep(name="Test") + step.cut_side = "OUTSIDE" + step.offset_mm = 0.5 + step.cut_speed = 200 + step.power = 80 + data = step.to_dict() + + restored = Step.from_dict(data) + assert isinstance(restored, ContourStep) + assert restored.cut_side == "OUTSIDE" + assert restored.offset_mm == 0.5 + assert restored.cut_speed == 200 + assert restored.power == 80 + + +class TestContourComputePayload: + """Verifies ContourStep's contribution to the raygeo intent pipeline + (see target-architecture.md slice B2).""" + + def _wp(self): + return WorkPiece(name="wp") + + def test_build_compute_payload_returns_contour_spec(self, machine): + step = ContourStep(name="cut") + step.cut_side = "outside" + step.offset_mm = 0.5 + step.overcut = 0.2 + + _part, payload = step.build_compute_payload(machine, self._wp()) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, ContourSpec) + assert spec.cut_side == "outside" + assert spec.offset_mm == 0.5 + assert spec.overcut == 0.2 + assert spec.arc_tolerance == machine.arc_tolerance + assert spec.allow_arcs == machine.supports_arcs + assert spec.supports_curves == machine.supports_curves + + def test_build_compute_payload_reflects_cut_order(self, machine): + step = ContourStep(name="cut") + step.cut_order = "OUTSIDE_INSIDE" + + wp = self._wp() + _part, payload = step.build_compute_payload(machine, wp) + spec = payload.assembler.spec + assert spec.cut_order == "outside_inside" + + def test_assembler_token_params_mirrors_assembler_kwargs(self, machine): + step = ContourStep(name="cut") + step.cut_side = "inside" + wp = self._wp() + + token_params = step.assembler_token_params(machine, wp) + kwargs = step.get_assembler_kwargs(machine, wp) + assert token_params == kwargs + assert token_params is not None + assert token_params["cut_side"] == "inside" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_frame_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_frame_step.py new file mode 100644 index 000000000..03b854791 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_frame_step.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock + +import pytest +from laser_essentials.steps import FrameStep + +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def mock_context(): + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + default_head = MagicMock() + default_head.uid = "test-laser-uid" + default_head.spot_size_mm = (0.1, 0.1) + machine.get_default_laser_head.return_value = default_head + context.machine = machine + return context + + +class TestFrameStep: + def test_instantiation(self): + step = FrameStep(name="Test") + assert step.typelabel == "Frame" + + def test_create(self, mock_context): + step = FrameStep.create(mock_context) + assert isinstance(step, FrameStep) + + def test_serialization_includes_step_type(self): + step = FrameStep(name="Test") + data = step.to_dict() + assert data["step_type"] == "FrameStep" + + def test_get_assembler_kwargs(self, machine): + step = FrameStep(name="Test") + workpiece = MagicMock(spec=["size"]) + workpiece.size = (100, 100) + kwargs = step.get_assembler_kwargs(machine, workpiece) + assert isinstance(kwargs, dict) + expected_keys = {"cut_side", "offset_mm"} + assert set(kwargs.keys()) == expected_keys + + def test_roundtrip_serialization(self): + step = FrameStep(name="Test") + step.cut_side = "OUTSIDE" + step.offset_mm = 0.5 + data = step.to_dict() + restored = FrameStep.from_dict(data) + assert data == restored.to_dict() + + def test_from_dict_migrates_legacy_opsproducer_params(self): + """True legacy files store frame params in + ``opsproducer_dict.params``; loading must restore them.""" + data = FrameStep(name="Test").to_dict() + for key in ("cut_side", "offset_mm"): + data.pop(key, None) + data["opsproducer_dict"] = { + "type": "FrameProducer", + "params": { + "path_offset_mm": 0.6, + "cut_side": "OUTSIDE", + }, + } + + restored = FrameStep.from_dict(data) + + assert restored.cut_side == "OUTSIDE" + assert restored.offset_mm == pytest.approx(0.6) + + +class TestFrameComputePayload: + def test_build_compute_payload_returns_frame_spec(self, machine): + from raygeo.cnc.execution.specs import ComputePayload + from raygeo.ops.assembly import Assembler + from raygeo.ops.assembly.frame import FrameSpec + from raygeo.ops.part import Part + + step = FrameStep(name="frame") + step.cut_side = "outside" + step.offset_mm = 0.3 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + + part, payload = step.build_compute_payload(machine, wp) + assert isinstance(part, Part) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, FrameSpec) + assert spec.cut_side == "outside" + assert spec.offset_mm == 0.3 + + def test_assembler_token_params_mirrors_kwargs(self, machine): + step = FrameStep(name="frame") + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + token = step.assembler_token_params(machine, wp) + kwargs = step.get_assembler_kwargs(machine, wp) + assert token == kwargs diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py new file mode 100644 index 000000000..3e07628a5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py @@ -0,0 +1,313 @@ +"""Tests for the LaserStep domain base class.""" + +from unittest.mock import MagicMock + +import pytest +from laser_essentials.steps import ContourStep, EngraveStep, LaserStep + +from rayforge.core.step import Step +from rayforge.machine.driver.driver import PWMParams, pwm_varset +from rayforge.machine.models.laser import ( + MIN_SPOT_SIZE_MM, + LaserHead, +) +from rayforge.machine.models.spindle import SpindleHead + + +def test_contour_defaults_preserved(): + s = ContourStep(name="t") + assert s.power == 0.8, s.power + assert s.offset_mm == 0.0, s.offset_mm + assert s.cut_speed == 500, s.cut_speed + assert s.air_assist is False + assert isinstance(s, LaserStep) + + +def test_engrave_defaults(): + s = EngraveStep(name="t") + assert s.power == 0.2, s.power + assert s.cut_speed == 500, s.cut_speed + assert isinstance(s, LaserStep) + + +def test_engrave_create_derives_cut_speed_from_machine(): + """EngraveStep.create() derives the operating feed from the machine. + + The machine only exposes its ceiling, so the default is that + ceiling, bounded by engraving's typical feed rate.""" + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 600 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + head = MagicMock() + head.uid = "laser-1" + head.spot_size_mm = (0.1, 0.1) + head.get_defaults.return_value = {} + machine.get_default_laser_head.return_value = head + context.machine = machine + + s = EngraveStep.create(context, name="t") + assert s.cut_speed == 600 # machine ceiling, below the 4000 bound + + machine.max_cut_speed = 8000 + s2 = EngraveStep.create(context, name="t") + assert s2.cut_speed == 4000 # bounded by engraving's typical feed + + +def test_laser_step_serialization_roundtrip(): + s = ContourStep(name="t") + s.power = 0.6 + s.offset_mm = 0.2 + s.air_assist = True + s.frequency = 2000 + s.pulse_width = 100 + data = s.to_dict() + + r = Step.from_dict(data) + assert type(r) is ContourStep + assert r.power == 0.6 + assert r.offset_mm == 0.2 + assert r.air_assist is True + assert r.frequency == 2000 + assert r.pulse_width == 100 + + +def test_laser_step_summary(): + s = ContourStep(name="t") + assert "% power" in s.get_summary() + + +def test_laser_step_get_selected_laser(): + s = ContourStep(name="t") + machine = MagicMock() + laser = MagicMock(spec=LaserHead) + laser.uid = "laser-1" + spindle = SpindleHead() + spindle.uid = "spindle-1" + machine.heads = [laser, spindle] + assert s.get_selected_laser(machine) is laser + s.selected_head_uid = "spindle-1" + assert s.get_selected_laser(machine) is None + + +def test_laser_get_spot_size_uses_head_spot_size(): + s = ContourStep(name="t") + machine = MagicMock() + head = MagicMock(spec=LaserHead) + head.uid = "laser-1" + head.spot_size_mm = (0.08, 0.15) + machine.heads = [head] + assert LaserHead.get_spot_size(s.get_selected_laser(machine)) == ( + 0.08, + 0.15, + ) + + +def test_laser_get_spot_size_falls_back_without_head(): + """With no laser head the spot falls back to a sane minimum.""" + assert LaserHead.get_spot_size(None) == ( + MIN_SPOT_SIZE_MM, + MIN_SPOT_SIZE_MM, + ) + + +def test_laser_get_spot_size_clamps_zero(): + """A zero spot size (unconfigured head) is clamped so the raster + pipeline never divides by zero.""" + head = LaserHead() + head.spot_size_mm = (0.0, 0.0) + spot_x, spot_y = LaserHead.get_spot_size(head) + assert spot_x > 0 + assert spot_y > 0 + + +def test_laser_get_spot_size_clamps_negative(): + head = LaserHead() + head.spot_size_mm = (-0.1, 0.2) + spot_x, spot_y = LaserHead.get_spot_size(head) + assert spot_x > 0 + assert spot_y == 0.2 + + +def test_laser_step_base_defaults(): + """LaserStep declares the laser domain defaults explicitly.""" + s = LaserStep(typelabel="test") + assert s.power == 1.0 + assert s.max_power == 1000 + assert s.air_assist is False + assert s.tab_power == 0.0 + assert s.frequency == 0 + assert s.pulse_width == 0 + + +def test_set_power_validation(): + """set_power raises ValueError for out-of-range values.""" + s = ContourStep(name="t") + with pytest.raises(ValueError): + s.set_power(-0.1) + with pytest.raises(ValueError): + s.set_power(1.1) + + +def test_laser_setters_and_signals(): + """Laser setters update the value and fire the 'updated' signal.""" + s = ContourStep(name="t") + handler = MagicMock() + s.updated.connect(handler) + + s.set_power(0.75) + assert s.power == 0.75 + handler.assert_called_once_with(s) + handler.reset_mock() + + s.set_air_assist(True) + assert s.air_assist is True + handler.assert_called_once_with(s) + handler.reset_mock() + + s.set_tab_power(0.15) + assert s.tab_power == 0.15 + handler.assert_called_once_with(s) + + +def test_set_tab_power_validation(): + """set_tab_power raises ValueError for out-of-range values.""" + s = ContourStep(name="t") + with pytest.raises(ValueError): + s.set_tab_power(-0.1) + with pytest.raises(ValueError): + s.set_tab_power(1.1) + + +def test_frequency_and_pulse_width_defaults(): + s = ContourStep(name="t") + assert s.frequency == 0 + assert s.pulse_width == 0 + + +def test_set_frequency(): + s = ContourStep(name="t") + handler = MagicMock() + s.updated.connect(handler) + s.set_frequency(1000) + assert s.frequency == 1000 + handler.assert_called_once_with(s) + + +def test_set_pulse_width(): + s = ContourStep(name="t") + handler = MagicMock() + s.updated.connect(handler) + s.set_pulse_width(50) + assert s.pulse_width == 50 + handler.assert_called_once_with(s) + + +def test_setters_no_signal_on_same_value(): + s = ContourStep(name="t") + handler = MagicMock() + s.updated.connect(handler) + s.set_frequency(0) + s.set_pulse_width(0) + handler.assert_not_called() + + +def test_frequency_pulse_width_serialization_roundtrip(): + s = ContourStep(name="t") + s.set_frequency(2000) + s.set_pulse_width(100) + data = s.to_dict() + assert data["frequency"] == 2000 + assert data["pulse_width"] == 100 + + restored = ContourStep.from_dict(data) + assert restored.frequency == 2000 + assert restored.pulse_width == 100 + + +def test_frequency_pulse_width_missing_defaults(): + data = { + "uid": "step-min", + "type": "step", + "typelabel": "MinimalType", + "visible": True, + "matrix": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + "per_workpiece_transformers_dicts": [], + "per_step_transformers_dicts": [], + } + restored = ContourStep.from_dict(data) + assert restored.frequency == 0 + assert restored.pulse_width == 0 + + +def test_get_settings_includes_frequency_and_pulse_width(): + s = ContourStep(name="t") + s.set_frequency(1000) + s.set_pulse_width(50) + settings = s.get_settings() + assert settings["frequency"] == 1000 + assert settings["pulse_width"] == 50 + + +def test_machine_reports_pwm_settings(): + """A machine's PWM settings expose the driver's PWM defaults.""" + params = PWMParams(1000, 5000, 50, 5, 500) + machine = MagicMock() + machine.get_pwm_settings.return_value = pwm_varset(params) + + vs = machine.get_pwm_settings(None) + assert vs["frequency"].default == 1000 + assert vs["pulse_width"].default == 50 + + +def test_machine_reports_no_pwm_without_support(): + machine = MagicMock() + machine.get_pwm_settings.return_value = None + assert machine.get_pwm_settings(None) is None + + +def test_create_applies_head_pwm_defaults(): + """create() adopts the default head's PWM defaults.""" + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 600 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + head = MagicMock(spec=LaserHead) + head.uid = "laser-1" + head.spot_size_mm = (0.1, 0.1) + machine.get_default_laser_head.return_value = head + machine.get_pwm_params.return_value = PWMParams(1000, 5000, 50, 5, 500) + context.machine = machine + + s = ContourStep.create(context, name="t") + assert s.frequency == 1000 + assert s.pulse_width == 50 + + +def test_laser_step_uses_cut_color(): + """A cutting laser step reports the head's cut color.""" + head = MagicMock(spec=LaserHead) + head.cut_color = "#112233" + head.raster_color = "#445566" + + s = ContourStep(name="t") + assert s.get_operation_color(head) == "#112233" + + +def test_engrave_step_uses_raster_color(): + """An engraving step reports the head's raster color.""" + head = MagicMock(spec=LaserHead) + head.cut_color = "#112233" + head.raster_color = "#445566" + + s = EngraveStep(name="t") + assert s.get_operation_color(head) == "#445566" + + +def test_laser_step_color_none_for_non_laser_head(): + """A laser step reports no color for a non-laser head.""" + head = SpindleHead() + s = ContourStep(name="t") + assert s.get_operation_color(head) is None diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_material_test_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_material_test_step.py new file mode 100644 index 000000000..d8086fbf6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_material_test_step.py @@ -0,0 +1,224 @@ +from typing import TYPE_CHECKING, Protocol, cast +from unittest.mock import MagicMock + +import pytest +from laser_essentials.steps import MaterialTestStep + +from rayforge.core.workpiece import WorkPiece + +if TYPE_CHECKING: + + class OverscanTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +@pytest.fixture +def mock_context(): + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + default_head = MagicMock() + default_head.uid = "test-laser-uid" + default_head.spot_size_mm = (0.1, 0.1) + machine.get_default_laser_head.return_value = default_head + context.machine = machine + return context + + +class TestMaterialTestStep: + def test_instantiation(self): + step = MaterialTestStep(name="Test") + assert step.typelabel == "Material Test Grid" + + def test_create(self, mock_context): + step = MaterialTestStep.create(mock_context) + assert isinstance(step, MaterialTestStep) + + def test_serialization_includes_step_type(self): + step = MaterialTestStep(name="Test") + data = step.to_dict() + assert data["step_type"] == "MaterialTestStep" + + def test_get_assembler_kwargs(self, machine): + step = MaterialTestStep(name="Test") + workpiece = MagicMock(spec=["size"]) + workpiece.size = (100, 100) + kwargs = step.get_assembler_kwargs(machine, workpiece) + assert isinstance(kwargs, dict) + expected_keys = { + "size_mm", + "cols", + "rows", + "min_speed", + "max_speed", + "min_power", + "max_power", + "min_passes", + "max_passes", + "min_offset", + "max_offset", + "mode", + "grid_mode", + "fixed_speed", + "fixed_power", + "shape_size", + "spacing", + "include_labels", + "label_power_percent", + "label_speed", + "line_interval_mm", + } + assert set(kwargs.keys()) == expected_keys + + def test_roundtrip_serialization(self): + step = MaterialTestStep(name="Test") + step.test_type = "Engrave" + step.grid_mode = "Power vs Passes" + step.shape_size = 5.0 + data = step.to_dict() + restored = MaterialTestStep.from_dict(data) + assert data == restored.to_dict() + + def test_from_dict_migrates_legacy_opsproducer_params(self): + """True legacy files store material-test params in + ``opsproducer_dict.params``; loading must restore them.""" + data = MaterialTestStep(name="Test").to_dict() + for key in ( + "test_type", + "grid_mode", + "speed_range", + "power_range", + "passes_range", + "offset_range", + "fixed_speed", + "fixed_power", + "grid_dimensions", + "shape_size", + "spacing", + "include_labels", + "label_power_percent", + "label_speed", + "line_interval_mm", + ): + data.pop(key, None) + data["opsproducer_dict"] = { + "type": "MaterialTestGridProducer", + "params": { + "test_type": "Engrave", + "grid_mode": "Power vs Passes", + "speed_range": [200.0, 800.0], + "power_range": [20.0, 90.0], + "passes_range": [2, 4], + "fixed_speed": 1500.0, + "fixed_power": 60.0, + "grid_dimensions": [3, 4], + "shape_size": 5.0, + "spacing": 1.5, + "include_labels": False, + "label_power_percent": 15.0, + "label_speed": 1200.0, + "line_interval_mm": 0.3, + }, + } + + restored = MaterialTestStep.from_dict(data) + + assert restored.test_type == "Engrave" + assert restored.grid_mode == "Power vs Passes" + assert restored.speed_range == (200.0, 800.0) + assert restored.power_range == (20.0, 90.0) + assert restored.passes_range == (2, 4) + assert restored.fixed_speed == 1500.0 + assert restored.fixed_power == 60.0 + assert restored.grid_dimensions == (3, 4) + assert restored.shape_size == 5.0 + assert restored.spacing == 1.5 + assert restored.include_labels is False + assert restored.label_power_percent == 15.0 + assert restored.label_speed == 1200.0 + assert restored.line_interval_mm == 0.3 + + def test_optimize_present_but_disabled_by_default(self, mock_context): + """Optimize must be off by default: its nearest-neighbor travel + reordering has no concept of cell boundaries and can interleave + lines from different cells instead of engraving each one fully + before moving to the next. Left toggleable (not removed) so it's + easy to compare with/without.""" + step = MaterialTestStep.create(mock_context) + per_wp = { + t.get("name"): t for t in step.per_workpiece_transformers_dicts + } + per_step = {t.get("name"): t for t in step.per_step_transformers_dicts} + assert "Optimize" in per_wp + assert per_wp["Optimize"]["enabled"] is False + assert "Optimize" in per_step + assert per_step["Optimize"]["enabled"] is False + + def test_overscan_distance_is_doubled(self, mock_context): + """Individual test blocks get double the usual auto-overscan + distance, so backlash settling happens outside the visible + engrave area.""" + from rayforge.pipeline.transformer.registry import ( + transformer_registry, + ) + + OverscanTransformer = cast( + "OverscanTransformerType", + transformer_registry.get("OverscanTransformer"), + ) + assert OverscanTransformer is not None + + step = MaterialTestStep.create(mock_context) + overscan_dict = next( + t + for t in step.per_workpiece_transformers_dicts + if t.get("name") == "OverscanTransformer" + ) + expected_base = OverscanTransformer.calculate_auto_distance( + step.cut_speed, mock_context.machine.acceleration + ) + assert overscan_dict["distance_mm"] == pytest.approx(expected_base * 2) + + def test_includes_bidir_scan_offset_transformer(self, mock_context): + step = MaterialTestStep.create(mock_context) + per_wp_names = { + t.get("name") for t in step.per_workpiece_transformers_dicts + } + assert "BidirScanOffsetTransformer" in per_wp_names + + +class TestMaterialTestComputePayload: + def test_build_compute_payload_returns_material_test_spec(self, machine): + from raygeo.cnc.execution.specs import ComputePayload + from raygeo.ops.assembly import Assembler + from raygeo.ops.assembly.material_test_grid import ( + MaterialTestGridSpec, + ) + from raygeo.ops.part import Part + + step = MaterialTestStep(name="mtg") + step.test_type = "Cut" + wp = WorkPiece(name="wp") + wp.set_size(100.0, 100.0) + + part, payload = step.build_compute_payload(machine, wp) + assert isinstance(part, Part) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, MaterialTestGridSpec) + assert spec.mode == "cut" + assert spec.size_mm == (100.0, 100.0) + + def test_assembler_token_params_mirrors_kwargs(self, machine): + step = MaterialTestStep(name="mtg") + wp = WorkPiece(name="wp") + wp.set_size(100.0, 100.0) + token = step.assembler_token_params(machine, wp) + kwargs = step.get_assembler_kwargs(machine, wp) + assert token == kwargs 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 new file mode 100644 index 000000000..bb5ddab87 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py @@ -0,0 +1,229 @@ +from unittest.mock import MagicMock, patch + +import pytest +from laser_essentials.steps import EngraveStep +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.raster import RasterSpec +from raygeo.ops.part import Part + +from rayforge.core.step_registry import step_registry +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def mock_context(): + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + default_head = MagicMock() + default_head.uid = "test-laser-uid" + default_head.spot_size_mm = (0.1, 0.1) + machine.get_default_laser_head.return_value = default_head + context.machine = machine + return context + + +class TestEngraveStep: + def test_instantiation(self): + step = EngraveStep(name="Test") + assert step.typelabel == "Engrave" + + def test_create(self, mock_context): + step = EngraveStep.create(mock_context, name="Created") + assert isinstance(step, EngraveStep) + assert len(step.per_workpiece_transformers_dicts) == 3 + transformer_names = { + t.get("name") for t in step.per_workpiece_transformers_dicts + } + assert "BidirScanOffsetTransformer" in transformer_names + assert step.selected_head_uid == "test-laser-uid" + + def test_serialization_includes_step_type(self): + step = EngraveStep(name="Test") + data = step.to_dict() + assert data["step_type"] == "EngraveStep" + + def test_registry_create_engrave_step(self, mock_context): + StepClass = step_registry.get("EngraveStep") + assert StepClass is not None + step = StepClass.create(mock_context, name="FromRegistry") + assert type(step).__name__ == "EngraveStep" + + def test_get_assembler_kwargs(self, machine): + step = EngraveStep(name="Test") + workpiece = MagicMock(spec=["size"]) + workpiece.size = (100, 100) + kwargs = step.get_assembler_kwargs(machine, workpiece) + assert isinstance(kwargs, dict) + expected_keys = { + "mode", + "line_interval_mm", + "sample_interval_mm", + "dot_width_correction_mm", + "min_power", + "max_power", + "step_power", + "num_power_levels", + "angle", + "offset_x_mm", + "offset_y_mm", + "scan_mode", + "cross_hatch", + "num_depth_levels", + "z_step_down", + "angle_increment", + } + assert set(kwargs.keys()) == expected_keys + + def test_roundtrip_serialization(self): + step = EngraveStep(name="Test") + step.scan_angle = 45.0 + step.depth_mode = "MULTI_PASS" + step.line_interval_mm = 0.2 # type: ignore[assignment] + step.dot_width_correction_mm = 0.05 # type: ignore[assignment] + data = step.to_dict() + restored = EngraveStep.from_dict(data) + assert data == restored.to_dict() + assert restored.dot_width_correction_mm == 0.05 + + def test_legacy_power_keys_migrate(self): + """Old files keyed the raster power range as min_power/max_power. + + Those must load into min_power_level/max_power_level and must not + pollute extra. The hardware max_power slot is restored to its + default rather than inheriting the old raster ceiling. + """ + step = EngraveStep(name="Test") + data = step.to_dict() + data["min_power"] = data.pop("min_power_level") + data["max_power"] = data.pop("max_power_level") + data["min_power"] = 0.2 + data["max_power"] = 1.0 + + restored = EngraveStep.from_dict(data) + + assert restored.min_power_level == 0.2 + assert restored.max_power_level == 1.0 + assert restored.max_power == 1000 + assert "min_power" not in restored.extra + assert "max_power" not in restored.extra + + def test_from_dict_migrates_legacy_opsproducer_params(self): + """True legacy files store raster params in + ``opsproducer_dict.params``; loading must restore them.""" + step = EngraveStep(name="Test") + data = step.to_dict() + for key in ( + "scan_angle", + "depth_mode", + "invert", + "auto_levels", + "black_point", + "white_point", + "threshold", + "line_interval_mm", + "sample_interval_mm", + "min_power_level", + "max_power_level", + "num_power_levels", + "scan_mode", + "cross_hatch", + "num_depth_levels", + "z_step_down", + "angle_increment", + "dither_algorithm", + ): + data.pop(key, None) + data["opsproducer_dict"] = { + "type": "Rasterizer", + "params": { + "direction_degrees": 45.0, + "scan_mode": "FullSweep", + "threshold": 100, + "dither_algorithm": "bayer4", + "cross_hatch": True, + "min_power": 0.2, + "max_power": 0.9, + "num_depth_levels": 3, + "num_power_levels": 10, + "z_step_down": 0.5, + "invert": True, + "auto_levels": False, + "black_point": 20, + "white_point": 200, + "angle_increment": 30.0, + "line_interval_mm": 0.4, + }, + } + + restored = EngraveStep.from_dict(data) + + assert restored.depth_mode == "CONSTANT_POWER" + assert restored.scan_angle == 45.0 + assert restored.scan_mode == "FULL_SWEEP" + assert restored.threshold == 100 + assert restored.dither_algorithm is not None + assert restored.dither_algorithm.name == "BAYER4" + assert restored.cross_hatch is True + assert restored.min_power_level == 0.2 + assert restored.max_power_level == 0.9 + 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.auto_levels is False + assert restored.black_point == 20 + assert restored.white_point == 200 + assert restored.angle_increment == 30.0 + assert restored.line_interval_mm == 0.4 + assert restored.max_power == 1000 + + def test_from_dict_dither_rasterizer_uses_dither_mode(self): + """The legacy ``DitherRasterizer`` type implies DITHER mode.""" + step = EngraveStep(name="Test") + data = step.to_dict() + for key in ("depth_mode", "scan_angle", "threshold"): + data.pop(key, None) + data["opsproducer_dict"] = { + "type": "DitherRasterizer", + "params": {"threshold": 150}, + } + + restored = EngraveStep.from_dict(data) + + assert restored.depth_mode == "DITHER" + assert restored.threshold == 150 + + +class TestEngraveComputePayload: + """Verifies EngraveStep's build_compute_payload (B3).""" + + def test_build_compute_payload_returns_raster_spec(self, machine): + step = EngraveStep(name="engrave") + step.min_power_level = 0.1 + step.max_power_level = 0.9 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + + with patch.object(WorkPiece, "render_to_pixels", return_value=None): + part, payload = step.build_compute_payload(machine, wp) + + assert isinstance(part, Part) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, RasterSpec) + assert spec.min_power == 0.1 + assert spec.max_power == 0.9 + assert spec.mode == "power_modulated" + + def test_assembler_token_params_mirrors_kwargs(self, machine): + step = EngraveStep(name="engrave") + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + token = step.assembler_token_params(machine, wp) + kwargs = step.get_assembler_kwargs(machine, wp) + assert token == kwargs 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 new file mode 100644 index 000000000..345a60854 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py @@ -0,0 +1,109 @@ +"""Tests for step-declared recipe keys and recipe varsets. + +Verifies that each step's recipe_keys() is composed correctly through +the inheritance hierarchy and that recipe_varset() exposes the keys +needed by the recipe editor. +""" + +from laser_essentials.steps import ( + ContourStep, + EngraveStep, + FrameStep, + ShrinkWrapStep, +) +from laser_essentials.steps.laser_step import LaserStep + +from rayforge.core.step import Step + + +class TestRecipeKeys: + """recipe_keys() composition through the step hierarchy.""" + + def test_base_step_keys(self): + assert "cut_speed" in Step.recipe_keys() + assert "travel_speed" in Step.recipe_keys() + + def test_laser_step_extends_base(self): + assert set(Step.recipe_keys()).issubset(set(LaserStep.recipe_keys())) + for key in ("power", "air_assist", "tab_power"): + assert key in LaserStep.recipe_keys() + + def test_contour_step_extends_laser(self): + assert set(LaserStep.recipe_keys()).issubset( + set(ContourStep.recipe_keys()) + ) + for key in ( + "cut_side", + "cut_order", + "remove_inner_paths", + "offset_mm", + "overcut", + ): + assert key in ContourStep.recipe_keys() + + def test_engrave_step_extends_laser(self): + assert set(LaserStep.recipe_keys()).issubset( + set(EngraveStep.recipe_keys()) + ) + for key in ( + "scan_angle", + "depth_mode", + "invert", + "min_power_level", + "max_power_level", + ): + assert key in EngraveStep.recipe_keys() + + def test_frame_step_extends_laser(self): + assert set(LaserStep.recipe_keys()).issubset( + set(FrameStep.recipe_keys()) + ) + for key in ("cut_side", "offset_mm"): + assert key in FrameStep.recipe_keys() + + def test_shrinkwrap_step_extends_laser(self): + assert set(LaserStep.recipe_keys()).issubset( + set(ShrinkWrapStep.recipe_keys()) + ) + for key in ("cut_side", "offset_mm", "gravity"): + assert key in ShrinkWrapStep.recipe_keys() + + +class TestRecipeVarsetKeys: + """recipe_varset() keys are consistent with recipe_keys(). + + The base Step varset is domain-neutral and does not render the + head picker, so it only covers the motion keys. Laser steps add the + laser-domain head var, so their varset covers the full recipe keys. + """ + + def test_base_step_varset(self): + keys = [var.key for var in Step.recipe_varset()] + assert "cut_speed" in keys + assert "travel_speed" in keys + + def test_laser_step_varset_includes_head(self): + keys = [var.key for var in LaserStep.recipe_varset()] + assert "selected_head_uid" in keys + for key in ("power", "air_assist", "tab_power"): + assert key in keys + + def test_contour_step_varset_covers_step_keys(self): + keys = [var.key for var in ContourStep.recipe_varset()] + for key in ContourStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_engrave_step_varset_covers_step_keys(self): + keys = [var.key for var in EngraveStep.recipe_varset()] + for key in EngraveStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_frame_step_varset_covers_step_keys(self): + keys = [var.key for var in FrameStep.recipe_varset()] + for key in FrameStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" + + def test_shrinkwrap_step_varset_covers_step_keys(self): + keys = [var.key for var in ShrinkWrapStep.recipe_varset()] + for key in ShrinkWrapStep.recipe_keys(): + assert key in keys, f"Missing var for recipe key '{key}'" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_shrinkwrap_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_shrinkwrap_step.py new file mode 100644 index 000000000..a39c95e17 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_shrinkwrap_step.py @@ -0,0 +1,114 @@ +from unittest.mock import MagicMock + +import pytest +from laser_essentials.steps import ShrinkWrapStep + +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def mock_context(): + context = MagicMock() + machine = MagicMock() + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + machine.acceleration = 3000 + default_head = MagicMock() + default_head.uid = "test-laser-uid" + default_head.spot_size_mm = (0.1, 0.1) + machine.get_default_laser_head.return_value = default_head + context.machine = machine + return context + + +class TestShrinkWrapStep: + def test_instantiation(self): + step = ShrinkWrapStep(name="Test") + assert step.typelabel == "Shrink Wrap" + + def test_create(self, mock_context): + step = ShrinkWrapStep.create(mock_context) + assert isinstance(step, ShrinkWrapStep) + + def test_serialization_includes_step_type(self): + step = ShrinkWrapStep(name="Test") + data = step.to_dict() + assert data["step_type"] == "ShrinkWrapStep" + + def test_get_assembler_kwargs(self, machine): + step = ShrinkWrapStep(name="Test") + workpiece = MagicMock(spec=["size"]) + workpiece.size = (100, 100) + kwargs = step.get_assembler_kwargs(machine, workpiece) + assert isinstance(kwargs, dict) + expected_keys = { + "cut_side", + "gravity", + "offset_mm", + "arc_tolerance", + "allow_arcs", + "supports_curves", + } + assert set(kwargs.keys()) == expected_keys + + def test_roundtrip_serialization(self): + step = ShrinkWrapStep(name="Test") + step.cut_side = "OUTSIDE" + step.offset_mm = 0.5 + step.gravity = 0.5 + data = step.to_dict() + restored = ShrinkWrapStep.from_dict(data) + assert data == restored.to_dict() + + def test_from_dict_migrates_legacy_opsproducer_params(self): + """True legacy files store shrink-wrap params in + ``opsproducer_dict.params``; loading must restore them.""" + data = ShrinkWrapStep(name="Test").to_dict() + for key in ("cut_side", "offset_mm", "gravity"): + data.pop(key, None) + data["opsproducer_dict"] = { + "type": "ShrinkWrapProducer", + "params": { + "gravity": 0.75, + "path_offset_mm": 0.2, + "cut_side": "INSIDE", + }, + } + + restored = ShrinkWrapStep.from_dict(data) + + assert restored.cut_side == "INSIDE" + assert restored.gravity == 0.75 + assert restored.offset_mm == pytest.approx(0.2) + + +class TestShrinkWrapComputePayload: + def test_build_compute_payload_returns_shrinkwrap_spec(self, machine): + from raygeo.cnc.execution.specs import ComputePayload + from raygeo.ops.assembly import Assembler + from raygeo.ops.assembly.shrinkwrap import ShrinkwrapSpec + from raygeo.ops.part import Part + + step = ShrinkWrapStep(name="sw") + step.cut_side = "outside" + step.gravity = 0.3 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + + part, payload = step.build_compute_payload(machine, wp) + assert isinstance(part, Part) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, ShrinkwrapSpec) + assert spec.cut_side == "outside" + assert spec.gravity == 0.3 + assert spec.offset_mm == step.offset_mm + + def test_assembler_token_params_mirrors_kwargs(self, machine): + step = ShrinkWrapStep(name="sw") + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + token = step.assembler_token_params(machine, wp) + kwargs = step.get_assembler_kwargs(machine, wp) + assert token == kwargs diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py new file mode 100644 index 000000000..e09317cb5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py @@ -0,0 +1,207 @@ +from unittest.mock import patch + +import cairo +from laser_essentials.steps import WavefrontStep + +from rayforge.core.workpiece import WorkPiece + + +def _make_disjoint_loops(): + """Two widely-separated square pockets as a single Geometry.""" + from raygeo.geo import Geometry + + loops = Geometry() + left = [(-30.0, -20.0), (30.0, -20.0), (30.0, 20.0), (-30.0, 20.0)] + right = [(70.0, -20.0), (110.0, -20.0), (110.0, 20.0), (70.0, 20.0)] + for loop in (left, right): + loops.move_to(*loop[0]) + for p in loop[1:]: + loops.line_to(*p) + loops.close_path() + return loops + + +class _FakeProvider: + """A minimal IGeometryProvider returning a fixed Geometry.""" + + def __init__(self, geometry, name="fake"): + from blinker import Signal + + self._geometry = geometry + self.name = name + self.updated = Signal() + + @property + def uid(self) -> str: + return "fake-provider-uid" + + @property + def provider_type_name(self) -> str: + return "fake" + + @property + def renderer(self): + return None + + def get_geometry(self, params=None, *, resolved_text_cache=None): + return self._geometry.copy(), [] + + def to_dict(self): + return {} + + +def _make_workpiece(): + return WorkPiece.from_geometry_provider( + _FakeProvider(_make_disjoint_loops()) + ) + + +class TestWavefrontComputePayload: + def test_build_compute_payload_returns_wavefront_spec(self, machine): + from raygeo.cnc.execution.specs import ComputePayload + from raygeo.ops.assembly import Assembler + from raygeo.ops.assembly.wavefront import AdaptiveWavefrontSpec + from raygeo.ops.part import Part + + step = WavefrontStep(name="wf") + step.step_over_mm = 0.5 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + + part, payload = step.build_compute_payload(machine, wp) + assert isinstance(part, Part) + assert isinstance(payload, ComputePayload) + assert isinstance(payload.assembler, Assembler) + spec = payload.assembler.spec + assert isinstance(spec, AdaptiveWavefrontSpec) + assert spec.step_over == 0.5 + + def test_assembler_token_params_mirrors_kwargs(self, machine): + step = WavefrontStep(name="wf") + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + token = step.assembler_token_params(machine, wp) + kwargs = step.get_assembler_kwargs(machine, wp) + assert token == kwargs + + def test_disjoint_pockets_become_separate_faces(self, machine): + """A workpiece with two pockets maps to two wavefront faces.""" + step = WavefrontStep(name="wf") + step.step_over_mm = 2.0 + wp = _make_workpiece() + + part, _ = step.build_compute_payload(machine, wp) + + assert part is not None + assert len(part.face_ids) == 2 + assert "" in part.face_ids + + def test_single_pocket_keeps_default_face(self, machine): + """A single-pocket workpiece keeps the default face ``""``.""" + step = WavefrontStep(name="wf") + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + + part, _ = step.build_compute_payload(machine, wp) + + assert part is not None + assert part.face_ids == [""] + + def test_wavefront_clears_all_faces(self, machine): + """Running the payload through the pipeline clears every pocket, + not just the largest one.""" + from raygeo.pipeline.execute import clear_cache, execute_stages + from raygeo.pipeline.request import NodeRequest + from raygeo.pipeline.stage import StageSpec + + step = WavefrontStep(name="wf") + step.step_over_mm = 2.0 + wp = _make_workpiece() + + part, payload = step.build_compute_payload(machine, wp) + assert len(part.face_ids) == 2 + + clear_cache() + completed = [] + node = NodeRequest( + key="wf", + generation_id=1, + stage=StageSpec.Compute(part=part, params=payload), + ) + execute_stages([node], completed.append, None) + assert len(completed) == 1 + out = completed[0].output + assert getattr(out, "warnings", None) == [] + assert out.ops.len() > 0 + assert any(out.ops.is_cutting(i) for i in range(out.ops.len())) + + def test_vectorless_workpiece_uses_raster_fallback(self, machine): + """A workpiece without vector boundaries falls back to tracing + its rendered surface into geometry for the wavefront assembler.""" + + def _render(width, height): + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + ctx.set_source_rgb(1, 1, 1) + ctx.paint() + ctx.set_source_rgb(0, 0, 0) + ctx.rectangle( + int(width * 0.1), + int(height * 0.1), + int(width * 0.8), + int(height * 0.8), + ) + ctx.fill() + return surface + + step = WavefrontStep(name="wf") + step.step_over_mm = 0.5 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + assert wp.boundaries is None + + with patch.object(WorkPiece, "render_to_pixels", side_effect=_render): + part, _payload = step.build_compute_payload(machine, wp) + + assert part is not None + assert part.has_geometry() + assert part.face_ids == [""] + + def test_from_dict_migrates_legacy_producer_params(self): + """Projects saved before the raygeo-pipeline refactor stored the + step-over inside ``opsproducer_dict.params``. Loading must + restore it so the fill density does not fall back to the laser + spot size.""" + data = WavefrontStep(name="wf").to_dict() + # The legacy format has no top-level step-over keys. + del data["step_over_mm"] + del data["offset_mm"] + del data["area_tolerance"] + data["opsproducer_dict"] = { + "type": "WavefrontProducer", + "params": { + "step_over_mm": 0.3, + "offset_mm": 0.25, + "area_tolerance": 0.02, + }, + } + + step = WavefrontStep.from_dict(data) + + assert step.step_over_mm == 0.3 + assert step.offset_mm == 0.25 + assert step.area_tolerance == 0.02 + + def test_from_dict_prefers_current_format(self): + """When the step-over is present at the top level (current + format), it wins over any legacy producer params.""" + data = WavefrontStep(name="wf").to_dict() + data["step_over_mm"] = 0.7 + data["opsproducer_dict"] = { + "type": "WavefrontProducer", + "params": {"step_over_mm": 0.3}, + } + + step = WavefrontStep.from_dict(data) + + assert step.step_over_mm == 0.7 diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/conftest.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/conftest.py new file mode 100644 index 000000000..4e577bfee --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/conftest.py @@ -0,0 +1,87 @@ +"""UI fixtures for laser_essentials page tests.""" + +import asyncio +import logging + +import pytest + +from rayforge import config as config_module +from rayforge import context as context_module +from rayforge.context import get_context +from rayforge.doceditor.editor import DocEditor +from rayforge.machine.models.laser import Laser +from rayforge.machine.models.machine import Machine +from rayforge.shared import tasker +from rayforge.shared.tasker.manager import TaskManager +from rayforge.shared.util.glib import idle_add + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def ui_task_mgr(): + """A test-isolated TaskManager for sync UI tests.""" + tm = TaskManager(main_thread_scheduler=idle_add) + yield tm + if tm.has_tasks(): + logger.warning( + "Task manager still has tasks at end of test. Shutting down." + ) + tm.shutdown() + + +@pytest.fixture +def ui_context(ui_task_mgr, monkeypatch, tmp_path): + """A UI context for laser addon tests.""" + temp_config_dir = tmp_path / "config" + temp_dialect_dir = temp_config_dir / "dialects" + temp_machine_dir = temp_config_dir / "machines" + temp_addons_dir = temp_config_dir / "addons" + monkeypatch.setattr(config_module, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(config_module, "DIALECT_DIR", temp_dialect_dir) + monkeypatch.setattr(config_module, "MACHINE_DIR", temp_machine_dir) + monkeypatch.setattr(config_module, "ADDONS_DIR", temp_addons_dir) + monkeypatch.setattr( + config_module, "CONFIG_FILE", temp_config_dir / "config.yaml" + ) + monkeypatch.setattr( + config_module, "AI_CONFIG_FILE", temp_config_dir / "ai.yaml" + ) + monkeypatch.setattr(tasker.task_mgr, "_instance", ui_task_mgr) + + context = get_context() + yield context + + asyncio.run(context.shutdown()) + context_module._context_instance = None + + +@pytest.fixture +def editor(ui_context, ui_task_mgr): + editor = DocEditor(task_manager=ui_task_mgr, context=ui_context) + yield editor + editor.cleanup() + + +@pytest.fixture +def laser_machine(ui_context): + """A machine with two laser heads, set as the active machine.""" + machine = Machine(ui_context) + machine.set_axis_extents(200, 150) + machine.max_cut_speed = 5000 + machine.max_travel_speed = 10000 + + laser1 = Laser() + laser1.name = "Laser 1" + laser1.spot_size_mm = (0.1, 0.2) + laser2 = Laser() + laser2.name = "Laser 2" + laser2.spot_size_mm = (0.3, 0.4) + machine.heads.clear() + machine.add_head(laser1) + machine.add_head(laser2) + + ui_context.machine_mgr.machines.clear() + ui_context.machine_mgr.add_machine(machine) + ui_context.config.set_machine(machine) + return machine 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 new file mode 100644 index 000000000..7f5c01249 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py @@ -0,0 +1,168 @@ +# flake8: noqa: E402 +"""UI tests for the laser step settings pages.""" + +from typing import Any + +import gi +import pytest + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw +from laser_essentials.widgets.contour_page import ( + ContourStepSettingsPage, + ThresholdRow, +) +from laser_essentials.widgets.material_test_grid_page import ( + MaterialTestGridSettingsPage, +) +from laser_essentials.widgets.raster_page import RasterSettingsPage +from laser_essentials.widgets.rows import ( + AirAssistRow, + OffsetRow, + PowerRow, +) + +from rayforge.core.step_registry import step_registry +from rayforge.ui_gtk.doceditor.step_settings.dialog import StepSettingsDialog +from rayforge.ui_gtk.doceditor.step_settings.pages import StepSettingsPage +from rayforge.ui_gtk.doceditor.step_settings.rows import ( + CutSpeedRow, + HeadRow, + TravelSpeedRow, +) + + +def _find(widget, cls): + for row in widget._rows: + if isinstance(row, cls): + return row + raise AssertionError(f"row {cls.__name__} not found in page") + + +def _contour_step(ui_context) -> Any: + step_cls = step_registry.get("ContourStep") + assert step_cls is not None + return step_cls.create(ui_context) + + +@pytest.mark.ui +def test_contour_page_composes_step_and_laser_rows( + editor, laser_machine, ui_context +): + step = _contour_step(ui_context) + page = ContourStepSettingsPage(editor, step) + laser_page = page.laser_page() + + assert isinstance(page, StepSettingsPage) + assert isinstance(page, Adw.PreferencesPage) + assert isinstance(laser_page, StepSettingsPage) + + for cls in (OffsetRow, ThresholdRow): + _find(page, cls) + for cls in ( + PowerRow, + CutSpeedRow, + TravelSpeedRow, + AirAssistRow, + HeadRow, + ): + _find(laser_page, cls) + + +@pytest.mark.ui +def test_path_offset_insensitive_on_centerline( + editor, laser_machine, ui_context +): + step = _contour_step(ui_context) + page = ContourStepSettingsPage(editor, step) + offset = _find(page, OffsetRow) + + assert step.cut_side == "CENTERLINE" + assert offset.widget.get_sensitive() is False + + step.cut_side = "OUTSIDE" + step.updated.send(step) + assert offset.widget.get_sensitive() is True + + +@pytest.mark.ui +def test_threshold_visible_only_when_rescanning( + editor, laser_machine, ui_context +): + step = _contour_step(ui_context) + page = ContourStepSettingsPage(editor, step) + threshold = _find(page, ThresholdRow) + + step.override_threshold = False + step.updated.send(step) + assert threshold.widget.get_visible() is False + + step.override_threshold = True + step.updated.send(step) + assert threshold.widget.get_visible() is True + + +@pytest.mark.ui +def test_head_change_does_not_touch_offset(editor, laser_machine, ui_context): + step = _contour_step(ui_context) + page = ContourStepSettingsPage(editor, step) + laser_page = page.laser_page() + offset_before = step.offset_mm + + target = laser_machine.heads[1] + laser_page.head_row.head_changed.send( + laser_page.head_row, head_uid=target.uid + ) + assert step.selected_head_uid == target.uid + assert step.offset_mm == offset_before + + +@pytest.mark.ui +def test_offset_row_uses_user_units(editor, laser_machine, ui_context): + ui_context.config.unit_preferences["length"] = "in" + step = _contour_step(ui_context) + page = ContourStepSettingsPage(editor, step) + offset = _find(page, OffsetRow) + + step.offset_mm = 25.4 + step.updated.send(step) + + assert offset.widget is not None + assert offset.widget.get_value_in_base_units() == pytest.approx(25.4) + assert offset.widget.get_value() == pytest.approx(1.0, abs=1e-2) + + +@pytest.mark.ui +def test_material_test_page_builds(editor, laser_machine, ui_context): + step_cls = step_registry.get("MaterialTestStep") + assert step_cls is not None + page = MaterialTestGridSettingsPage(editor, step_cls.create(ui_context)) + assert isinstance(page, StepSettingsPage) + + +@pytest.mark.ui +def test_raster_page_builds(editor, laser_machine, ui_context): + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + page = RasterSettingsPage(editor, step_cls.create(ui_context)) + assert isinstance(page, StepSettingsPage) + + +@pytest.mark.ui +def test_dialog_uses_contour_page(editor, laser_machine, ui_context): + dialog = StepSettingsDialog(editor, _contour_step(ui_context)) + assert type(dialog.general_view).__name__ == "ContourStepSettingsPage" + assert [title for title, _, _ in dialog._extra_pages] == ["Laser"] + assert len(dialog._extra_buttons) == 1 + dialog.close() + + +@pytest.mark.ui +def test_dialog_initial_laser_page(editor, laser_machine, ui_context): + dialog = StepSettingsDialog(editor, _contour_step(ui_context)) + dialog.set_initial_page("laser") + assert dialog._extra_buttons[0].get_active() is True + assert dialog.btn_step_settings.get_active() is False + dialog.close() diff --git a/rayforge/opsencoder/__init__.py b/rayforge/builtin_addons/rayforge-addon-materials/core_materials/__init__.py similarity index 100% rename from rayforge/opsencoder/__init__.py rename to rayforge/builtin_addons/rayforge-addon-materials/core_materials/__init__.py diff --git a/rayforge/builtin_addons/rayforge-addon-materials/core_materials/worker.py b/rayforge/builtin_addons/rayforge-addon-materials/core_materials/worker.py new file mode 100644 index 000000000..b9e53d702 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/core_materials/worker.py @@ -0,0 +1,16 @@ +import logging +from pathlib import Path + +from rayforge.core.hooks import hookimpl + +logger = logging.getLogger(__name__) + + +@hookimpl +def register_material_libraries(library_manager): + materials_dir = Path(__file__).parent.parent / "materials" + logger.debug(f"Registering materials from {materials_dir}") + if materials_dir.exists(): + library_manager.add_library_from_path( + materials_dir, read_only=True, addon_name="core_materials" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/__library__.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/__library__.yaml new file mode 100644 index 000000000..1d45135b1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/__library__.yaml @@ -0,0 +1,3 @@ +name: Core Materials +id: 550e8400-e29b-41d4-a716-446655440000 +editable: false \ No newline at end of file diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml new file mode 100644 index 000000000..83e3b88e5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml @@ -0,0 +1,28 @@ +uid: abs +name: + default: ABS + de: ABS + es: ABS + fr: ABS + pt: ABS + uk: ABS + zh_CN: ABS +description: + default: Acrylonitrile butadiene styrene thermoplastic + de: Acrylnitril-Butadien-Styrol-Thermoplast + es: Termoplástico de acrilonitrilo butadieno estireno + fr: Thermoplastique acrylonitrile butadiène styrène + pt: Termoplástico de acrilonitrila butadieno estireno + uk: Термопластик акрилонітрилбутадієнстирол + zh_CN: 丙烯腈-丁二烯-苯乙烯热塑性塑料 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#F5F5DC" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml new file mode 100644 index 000000000..e90eefba6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml @@ -0,0 +1,28 @@ +uid: acrylic +name: + default: Acrylic (PMMA) + de: Acryl (PMMA) + es: Acrílico (PMMA) + fr: Acrylique (PMMA) + pt: Acrílico (PMMA) + uk: Акрил (ПММА) + zh_CN: 亚克力 (PMMA) +description: + default: Transparent thermoplastic, often known by brand names like Plexiglas or Lucite + de: Transparenter Kunststoff, oft bekannt unter Markennamen wie Plexiglas oder Lucite + es: Termoplástico transparente, a menudo conocido por nombres comerciales como Plexiglás o Lucite + fr: Thermoplastique transparent, souvent connu sous les noms commerciaux comme Plexiglas ou Lucite + pt: Termoplástico transparente, frequentemente conhecido por nomes comerciais como Plexiglas ou Lucite + uk: Прозорий термопластик, часто відомий під торговими марками, такими як Plexiglas або Lucite + zh_CN: 透明热塑性塑料,通常以 Plexiglas 或 Lucite 等品牌名称闻名 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#E0FFFF" + pattern: transparent diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_black.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_black.yaml new file mode 100644 index 000000000..09f67590f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_black.yaml @@ -0,0 +1,28 @@ +uid: acrylic_black +name: + default: Black Acrylic + de: Schwarzer Acryl + es: Acrílico Negro + fr: Acrylique Noir + pt: Acrílico Preto + uk: Чорний акрил + zh_CN: 黑色亚克力 +description: + default: Opaque black thermoplastic sheet + de: Undurchsichtiges schwarzes Thermoplastikblech + es: Lámina de termoplástico negro opaco + fr: Feuille de thermoplastique noir opaque + pt: Chapa de termoplástico preto opaco + uk: Непрозорий чорний термопластиковий лист + zh_CN: 不透明黑色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#1A1A1A" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_blue.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_blue.yaml new file mode 100644 index 000000000..c32f52165 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_blue.yaml @@ -0,0 +1,28 @@ +uid: acrylic_blue +name: + default: Blue Acrylic + de: Blauer Acryl + es: Acrílico Azul + fr: Acrylique Bleu + pt: Acrílico Azul + uk: Синій акрил + zh_CN: 蓝色亚克力 +description: + default: Opaque blue thermoplastic sheet + de: Undurchsichtiges blaues Thermoplastikblech + es: Lámina de termoplástico azul opaco + fr: Feuille de thermoplastique bleu opaque + pt: Chapa de termoplástico azul opaco + uk: Непрозорий синій термопластиковий лист + zh_CN: 不透明蓝色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#0000FF" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_gray.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_gray.yaml new file mode 100644 index 000000000..e887b7a1b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_gray.yaml @@ -0,0 +1,28 @@ +uid: acrylic_gray +name: + default: Gray Acrylic + de: Grauer Acryl + es: Acrílico Gris + fr: Acrylique Gris + pt: Acrílico Cinza + uk: Сірий акрил + zh_CN: 灰色亚克力 +description: + default: Opaque gray thermoplastic sheet + de: Undurchsichtiges graues Thermoplastikblech + es: Lámina de termoplástico gris opaco + fr: Feuille de thermoplastique gris opaque + pt: Chapa de termoplástico cinza opaco + uk: Непрозорий сірий термопластиковий лист + zh_CN: 不透明灰色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#808080" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_green.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_green.yaml new file mode 100644 index 000000000..e9acedc6e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_green.yaml @@ -0,0 +1,28 @@ +uid: acrylic_green +name: + default: Green Acrylic + de: Grüner Acryl + es: Acrílico Verde + fr: Acrylique Vert + pt: Acrílico Verde + uk: Зелений акрил + zh_CN: 绿色亚克力 +description: + default: Opaque green thermoplastic sheet + de: Undurchsichtiges grünes Thermoplastikblech + es: Lámina de termoplástico verde opaco + fr: Feuille de thermoplastique vert opaque + pt: Chapa de termoplástico verde opaco + uk: Непрозорий зелений термопластиковий лист + zh_CN: 不透明绿色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#008000" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_orange.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_orange.yaml new file mode 100644 index 000000000..1ebb812ca --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_orange.yaml @@ -0,0 +1,28 @@ +uid: acrylic_orange +name: + default: Orange Acrylic + de: Oranger Acryl + es: Acrílico Naranja + fr: Acrylique Orange + pt: Acrílico Laranja + uk: Помаранчевий акрил + zh_CN: 橙色亚克力 +description: + default: Opaque orange thermoplastic sheet + de: Undurchsichtiges oranges Thermoplastikblech + es: Lámina de termoplástico naranja opaco + fr: Feuille de thermoplastique orange opaque + pt: Chapa de termoplástico laranja opaco + uk: Непрозорий помаранчевий термопластиковий лист + zh_CN: 不透明橙色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFA500" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_pink.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_pink.yaml new file mode 100644 index 000000000..56193db63 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_pink.yaml @@ -0,0 +1,28 @@ +uid: acrylic_pink +name: + default: Pink Acrylic + de: Pinker Acryl + es: Acrílico Rosa + fr: Acrylique Rose + pt: Acrílico Rosa + uk: Рожевий акрил + zh_CN: 粉色亚克力 +description: + default: Opaque pink thermoplastic sheet + de: Undurchsichtiges pinke Thermoplastikblech + es: Lámina de termoplástico rosa opaco + fr: Feuille de thermoplastique rose opaque + pt: Chapa de termoplástico rosa opaco + uk: Непрозорий рожевий термопластиковий лист + zh_CN: 不透明粉色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFC0CB" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_purple.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_purple.yaml new file mode 100644 index 000000000..a1ac9dbbf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_purple.yaml @@ -0,0 +1,28 @@ +uid: acrylic_purple +name: + default: Purple Acrylic + de: Lila Acryl + es: Acrílico Púrpura + fr: Acrylique Violet + pt: Acrílico Roxo + uk: Фіолетовий акрил + zh_CN: 紫色亚克力 +description: + default: Opaque purple thermoplastic sheet + de: Undurchsichtiges lila Thermoplastikblech + es: Lámina de termoplástico púrpura opaco + fr: Feuille de thermoplastique violet opaque + pt: Chapa de termoplástico roxo opaco + uk: Непрозорий фіолетовий термопластиковий лист + zh_CN: 不透明紫色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#800080" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_red.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_red.yaml new file mode 100644 index 000000000..6e55a7674 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_red.yaml @@ -0,0 +1,28 @@ +uid: acrylic_red +name: + default: Red Acrylic + de: Roter Acryl + es: Acrílico Rojo + fr: Acrylique Rouge + pt: Acrílico Vermelho + uk: Червоний акрил + zh_CN: 红色亚克力 +description: + default: Opaque red thermoplastic sheet + de: Undurchsichtiges rotes Thermoplastikblech + es: Lámina de termoplástico rojo opaco + fr: Feuille de thermoplastique rouge opaque + pt: Chapa de termoplástico vermelho opaco + uk: Непрозорий червоний термопластиковий лист + zh_CN: 不透明红色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FF0000" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_white.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_white.yaml new file mode 100644 index 000000000..0d4a01f05 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_white.yaml @@ -0,0 +1,28 @@ +uid: acrylic_white +name: + default: White Acrylic + de: Weißer Acryl + es: Acrílico Blanco + fr: Acrylique Blanc + pt: Acrílico Branco + uk: Білий акрил + zh_CN: 白色亚克力 +description: + default: Opaque white thermoplastic sheet + de: Undurchsichtiges weißes Thermoplastikblech + es: Lámina de termoplástico blanco opaco + fr: Feuille de thermoplastique blanc opaque + pt: Chapa de termoplástico branco opaco + uk: Непрозорий білий термопластиковий лист + zh_CN: 不透明白色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFFFFF" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_yellow.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_yellow.yaml new file mode 100644 index 000000000..d872981d9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_yellow.yaml @@ -0,0 +1,28 @@ +uid: acrylic_yellow +name: + default: Yellow Acrylic + de: Gelber Acryl + es: Acrílico Amarillo + fr: Acrylique Jaune + pt: Acrílico Amarelo + uk: Жовтий акрил + zh_CN: 黄色亚克力 +description: + default: Opaque yellow thermoplastic sheet + de: Undurchsichtiges gelbes Thermoplastikblech + es: Lámina de termoplástico amarillo opaco + fr: Feuille de thermoplastique jaune opaque + pt: Chapa de termoplástico amarelo opaco + uk: Непрозорий жовтий термопластиковий лист + zh_CN: 不透明黄色热塑性塑料板 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFFF00" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml new file mode 100644 index 000000000..3de988cbe --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml @@ -0,0 +1,28 @@ +uid: aluminum +name: + default: Aluminum + de: Aluminium + es: Aluminio + fr: Aluminium + pt: Alumínio + uk: Алюміній + zh_CN: 铝 +description: + default: Lightweight metal commonly used in laser cutting applications + de: Leichtmetall, das häufig in Laserschneidanwendungen verwendet wird + es: Metal ligero comúnmente utilizado en aplicaciones de corte láser + fr: Métal léger couramment utilisé dans les applications de découpe laser + pt: Metal leve comumente usado em aplicações de corte a laser + uk: Легкий метал, що часто використовується в лазерній різці + zh_CN: 轻金属,常用于激光切割应用 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#C0C0C0" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml new file mode 100644 index 000000000..65835e238 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml @@ -0,0 +1,28 @@ +uid: ash +name: + default: Ash + de: Esche + es: Fresno + fr: Frêne + pt: Freixo + uk: Ясень + zh_CN: 白蜡木 +description: + default: Light-colored hardwood with straight grain + de: Helles Hartholz mit gerader Maserung + es: Madera dura de color claro con veta recta + fr: Bois dur de couleur claire avec un grain droit + pt: Madeira dura de cor clara com grão reto + uk: Світла тверда деревина з прямим візерунком + zh_CN: 浅色硬木,纹理直 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#F0E68C" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml new file mode 100644 index 000000000..98ffdc36a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml @@ -0,0 +1,28 @@ +uid: bamboo +name: + default: Bamboo + de: Bambus + es: Bambú + fr: Bambou + pt: Bambu + uk: Бамбук + zh_CN: 竹子 +description: + default: Fast-growing woody grass + de: Schnell wachsendes holziges Gras + es: Hierba leñosa de rápido crecimiento + fr: Herbe ligneuse à croissance rapide + pt: Grama lenhosa de rápido crescimento + uk: Швидкозростаюча дерев'яниста трава + zh_CN: 快速生长的木质草 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#D2B48C" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml new file mode 100644 index 000000000..cd2e9e881 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml @@ -0,0 +1,28 @@ +uid: birch +name: + default: Birch + de: Birke + es: Abedul + fr: Bouleau + pt: Bétula + uk: Береза + zh_CN: 桦木 +description: + default: Pale hardwood with smooth grain + de: Blasses Hartholz mit glatter Maserung + es: Madera dura pálida con veta suave + fr: Bois dur pâle avec un grain lisse + pt: Madeira dura pálida com grão suave + uk: Блідий тверда деревина з гладким візерунком + zh_CN: 浅色硬木,纹理光滑 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#FFE4C4" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml new file mode 100644 index 000000000..326072735 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml @@ -0,0 +1,28 @@ +uid: brass +name: + default: Brass + de: Messing + es: Latón + fr: Laiton + pt: Latão + uk: Латунь + zh_CN: 黄铜 +description: + default: Copper-zinc alloy with golden appearance + de: Kupfer-Zink-Legierung mit goldenem Aussehen + es: Aleación de cobre y zinc con apariencia dorada + fr: Alliage cuivre-zinc avec une apparence dorée + pt: Liga de cobre e zinco com aparência dourada + uk: Сплав міді та цинку з золотистим виглядом + zh_CN: 具有金色外观的铜锌合金 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#B5A642" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml new file mode 100644 index 000000000..6d983ca25 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml @@ -0,0 +1,28 @@ +uid: bronze +name: + default: Bronze + de: Bronze + es: Bronce + fr: Bronze + pt: Bronze + uk: Бронза + zh_CN: 青铜 +description: + default: Copper-tin alloy with brownish color + de: Kupfer-Zinn-Legierung mit bräunlicher Farbe + es: Aleación de cobre y estaño de color marrón + fr: Alliage cuivre-étain de couleur brune + pt: Liga de cobre e estanho de cor marrom + uk: Сплав міді та олова коричневого кольору + zh_CN: 铜锡合金,呈棕色 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#CD7F32" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml new file mode 100644 index 000000000..40a86ef78 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml @@ -0,0 +1,28 @@ +uid: cardboard +name: + default: Cardboard + de: Pappe + es: Cartón + fr: Carton + pt: Papelão + uk: Картон + zh_CN: 纸板 +description: + default: Thick paper-based material + de: Dickes papierbasiertes Material + es: Material grueso basado en papel + fr: Matériau épais à base de papier + pt: Material espesso à base de papel + uk: Щільний паперовий матеріал + zh_CN: 厚纸基材料 +category: + default: Paper + de: Papier + es: Papel + fr: Papier + pt: Papel + uk: Папір + zh_CN: 纸张 +appearance: + color: "#D3D3D3" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml new file mode 100644 index 000000000..d9969d724 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml @@ -0,0 +1,28 @@ +uid: cedar +name: + default: Cedar + de: Zeder + es: Cedro + fr: Cèdre + pt: Cedro + uk: Кедр + zh_CN: 雪松 +description: + default: Aromatic reddish softwood + de: Aromatisches rötliches Nadelholz + es: Madera blanda aromática de color rojizo + fr: Bois tendre aromatique de couleur rougeâtre + pt: Madeira macia aromática de cor avermelhada + uk: Ароматична червонувата м'яка деревина + zh_CN: 芳香的红松软木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#E6C288" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml new file mode 100644 index 000000000..711ba98ca --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml @@ -0,0 +1,28 @@ +uid: ceramic +name: + default: Ceramic + de: Keramik + es: Cerámica + fr: Céramique + pt: Cerâmica + uk: Кераміка + zh_CN: 陶瓷 +description: + default: Hard brittle non-metallic material + de: Hartes sprödes nicht-metallisches Material + es: Material no metálico duro y frágil + fr: Matériau non métallique dur et cassant + pt: Material não metálico duro e frágil + uk: Тверда крихка неметалева речовина + zh_CN: 硬质脆性非金属材料 +category: + default: Ceramic + de: Keramik + es: Cerámica + fr: Céramique + pt: Cerâmica + uk: Кераміка + zh_CN: 陶瓷 +appearance: + color: "#F5F5F5" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml new file mode 100644 index 000000000..434c478ad --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml @@ -0,0 +1,28 @@ +uid: cherry +name: + default: Cherry + de: Kirsche + es: Cerezo + fr: Merisier + pt: Cerejeira + uk: Вишня + zh_CN: 樱桃木 +description: + default: Warm reddish-brown hardwood + de: Warmes rötlich-braunes Hartholz + es: Madera dura cálida de color marrón rojizo + fr: Bois dur chaud de couleur brun rougeâtre + pt: Madeira dura quente de cor marrom avermelhado + uk: Тепла тверда деревина червонувато-коричневого кольору + zh_CN: 温暖的红棕色硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#CD5C5C" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml new file mode 100644 index 000000000..cfac0b4d1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml @@ -0,0 +1,28 @@ +uid: copper +name: + default: Copper + de: Kupfer + es: Cobre + fr: Cuivre + pt: Cobre + uk: Мідь + zh_CN: 铜 +description: + default: Reddish-orange conductive metal + de: Rötlich-oranges leitfähiges Metall + es: Metal conductor de color rojo anaranjado + fr: Métal conducteur rouge orangé + pt: Metal condutor de cor laranja avermelhado + uk: Червонувато-помаранчевий провідний метал + zh_CN: 红橙色的导电金属 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#B87333" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml new file mode 100644 index 000000000..be7cd2e20 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml @@ -0,0 +1,28 @@ +uid: cork +name: + default: Cork + de: Kork + es: Corcho + fr: Liège + pt: Cortiça + uk: Корк + zh_CN: 软木 +description: + default: Lightweight porous material from oak bark + de: Leichtes poröses Material aus Eichenrinde + es: Material ligero y poroso de la corteza de roble + fr: Matériau léger et poreux issu de l'écorce de chêne + pt: Material leve poroso da casca de carvalho + uk: Легкий пористий матеріал з кори дуба + zh_CN: 源自橡树皮的轻质多孔材料 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#DEB887" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml new file mode 100644 index 000000000..8f0450835 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml @@ -0,0 +1,28 @@ +uid: ebony +name: + default: Ebony + de: Ebenholz + es: Ébano + fr: Ébène + pt: Ébano + uk: Ебен + zh_CN: 乌木 +description: + default: Very dense black hardwood + de: Sehr dichtes schwarzes Hartholz + es: Madera dura negra muy densa + fr: Bois dur noir très dense + pt: Madeira dura preta muito densa + uk: Дуже щільна чорна тверда деревина + zh_CN: 非常致密的黑色硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#2F4F4F" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml new file mode 100644 index 000000000..930f29b8c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml @@ -0,0 +1,28 @@ +uid: elm +name: + default: Elm + de: Ulme + es: Olmo + fr: Orme + pt: Ulmeiro + uk: В'яз + zh_CN: 榆木 +description: + default: Interlocked grain hardwood + de: Hartholz mit verwobener Maserung + es: Madera dura con veta entrelazada + fr: Bois dur à grain entrelacé + pt: Madeira dura com grão entrelaçado + uk: Тверда деревина з переплетеним візерунком + zh_CN: 纹理交错的硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#CD853F" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml new file mode 100644 index 000000000..612c605e9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml @@ -0,0 +1,28 @@ +uid: fabric_canvas +name: + default: Canvas + de: Leinwand + es: Lienzo + fr: Toile + pt: Lona + uk: Парусина + zh_CN: 帆布 +description: + default: Heavy-duty plain-woven fabric + de: Robuster, glatt gewebter Stoff + es: Tela resistente tejida llana + fr: Tissu lourd tissé plat + pt: Tecido resistente tecido plano + uk: Міцна гладкоткана тканина + zh_CN: 重型平纹织物 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#D2B48C" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml new file mode 100644 index 000000000..ae87ce57e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml @@ -0,0 +1,28 @@ +uid: fabric_cotton +name: + default: Cotton Fabric + de: Baumwollstoff + es: Tela de Algodón + fr: Tissu en Coton + pt: Tecido de Algodão + uk: Бавовняна тканина + zh_CN: 棉织物 +description: + default: Natural fiber woven fabric + de: Gewebter Stoff aus Naturfasern + es: Tela tejida de fibra natural + fr: Tissu tissé en fibres naturelles + pt: Tecido tecido de fibra natural + uk: Тканина з натуральних волокон + zh_CN: 天然纤维编织织物 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#F5F5DC" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml new file mode 100644 index 000000000..f7c8d3e66 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml @@ -0,0 +1,28 @@ +uid: fabric_denim +name: + default: Denim + de: Denim + es: Denim + fr: Denim + pt: Denim + uk: Денім + zh_CN: 丹宁布 +description: + default: Durable cotton twill fabric + de: Langlebiger Baumwoll-Satin-Stoff + es: Tela de algodón sarga duradera + fr: Tissu de coton sergé durable + pt: Tecido de algodão sarja durável + uk: Міцна бавовняна саржева тканина + zh_CN: 耐用的棉斜纹织物 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#4169E1" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml new file mode 100644 index 000000000..8c0227f15 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml @@ -0,0 +1,28 @@ +uid: fabric_felt +name: + default: Felt + de: Filz + es: Fieltro + fr: Feutre + pt: Feltro + uk: Фетр + zh_CN: 毛毡 +description: + default: Non-woven matted fabric + de: Nicht gewebtes, verfilztes Material + es: Tela no tejida de fibras enmarañadas + fr: Tissu non tissé feutré + pt: Tecido não tecido feltrado + uk: Нетканий валяний матеріал + zh_CN: 无纺毡合织物 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#9370DB" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml new file mode 100644 index 000000000..b0572fcc2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml @@ -0,0 +1,28 @@ +uid: foam +name: + default: Foam + de: Schaum + es: Espuma + fr: Mousse + pt: Espuma + uk: Піна + zh_CN: 泡沫 +description: + default: Lightweight cellular material + de: Leichtes zellulares Material + es: Material celular ligero + fr: Matériau cellulaire léger + pt: Material celular leve + uk: Легкий клітинний матеріал + zh_CN: 轻质多孔材料 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#FFF8DC" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml new file mode 100644 index 000000000..1fb9c4420 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml @@ -0,0 +1,28 @@ +uid: galvanized_steel +name: + default: Galvanized Steel + de: Verzinkter Stahl + es: Acero Galvanizado + fr: Acier Galvanisé + pt: Aço Galvanizado + uk: Оцинкована сталь + zh_CN: 镀锌钢 +description: + default: Steel coated with zinc for corrosion resistance + de: Mit Zink beschichteter Stahl für Korrosionsschutz + es: Acero recubierto de zinc para resistencia a la corrosión + fr: Acier recouvert de zinc pour résistance à la corrosion + pt: Aço revestido de zinco para resistência à corrosão + uk: Сталь, покрита цинком для захисту від корозії + zh_CN: 镀锌钢材,耐腐蚀 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#B0C4DE" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml new file mode 100644 index 000000000..9a66c8b71 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml @@ -0,0 +1,28 @@ +uid: glass +name: + default: Glass + de: Glas + es: Vidrio + fr: Verre + pt: Vidro + uk: Скло + zh_CN: 玻璃 +description: + default: Transparent brittle material + de: Transparentes sprödes Material + es: Material transparente y frágil + fr: Matériau transparent et cassant + pt: Material transparente e frágil + uk: Прозорий крихкий матеріал + zh_CN: 透明易碎材料 +category: + default: Glass + de: Glas + es: Vidrio + fr: Verre + pt: Vidro + uk: Скло + zh_CN: 玻璃 +appearance: + color: "#ADD8E6" + pattern: transparent diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml new file mode 100644 index 000000000..3268d6f2b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml @@ -0,0 +1,28 @@ +uid: granite +name: + default: Granite + de: Granit + es: Granito + fr: Granit + pt: Granito + uk: Граніт + zh_CN: 花岗岩 +description: + default: Coarse-grained igneous rock + de: Grobkörniges magmatisches Gestein + es: Roca ígnea de grano grueso + fr: Roche ignée à grain grossier + pt: Rocha ígnea de grão grosso + uk: Грубозерниста магматична порода + zh_CN: 粗粒火成岩 +category: + default: Stone + de: Stein + es: Piedra + fr: Pierre + pt: Pedra + uk: Камінь + zh_CN: 石材 +appearance: + color: "#DCDCDC" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml new file mode 100644 index 000000000..036aab81e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml @@ -0,0 +1,28 @@ +uid: hdpe +name: + default: HDPE + de: HDPE + es: HDPE + fr: HDPE + pt: HDPE + uk: HDPE + zh_CN: HDPE +description: + default: High-density polyethylene plastic + de: Polyethylen hoher Dichte + es: Polietileno de alta densidad + fr: Polyéthylène haute densité + pt: Polietileno de alta densidade + uk: Поліетилен високої щільності + zh_CN: 高密度聚乙烯塑料 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#F5F5F5" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml new file mode 100644 index 000000000..a1390572c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml @@ -0,0 +1,28 @@ +uid: hickory +name: + default: Hickory + de: Hickory + es: Nogal Americano + fr: Hickory + pt: Nogueira Americana + uk: Гікорі + zh_CN: 山核桃木 +description: + default: Tough hard hardwood + de: Zähes hartes Hartholz + es: Madera dura y resistente + fr: Bois dur et résistant + pt: Madeira dura e resistente + uk: Міцна тверда деревина + zh_CN: 坚韧的硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#DAA520" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml new file mode 100644 index 000000000..c6ad88252 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml @@ -0,0 +1,28 @@ +uid: lead +name: + default: Lead + de: Blei + es: Plomo + fr: Plomb + pt: Chumbo + uk: Свинець + zh_CN: 铅 +description: + default: Dense, soft, bluish-gray metal + de: Dichtes, weiches, bläulich-graues Metall + es: Metal denso, blando, de color gris azulado + fr: Métal dense, mou, de couleur gris bleuté + pt: Metal denso, macio, de cor cinza azulado + uk: Густина, м'який, блакитно-сірий метал + zh_CN: 致密、柔软的蓝灰色金属 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#434B4D" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml new file mode 100644 index 000000000..c3e75f6df --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml @@ -0,0 +1,28 @@ +uid: leather +name: + default: Leather + de: Leder + es: Cuero + fr: Cuir + pt: Couro + uk: Шкіра + zh_CN: 皮革 +description: + default: Durable flexible material from animal hide + de: Langlebiges flexibles Material aus Tierhaut + es: Material flexible y duradero de piel animal + fr: Matériau flexible et durable en peau animale + pt: Material flexível e durável de pele animal + uk: Міцний гнучкий матеріал зі шкіри тварин + zh_CN: 源自动物皮的耐用柔韧材料 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#8B4513" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml new file mode 100644 index 000000000..59eb5929e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml @@ -0,0 +1,28 @@ +uid: mahogany +name: + default: Mahogany + de: Mahagoni + es: Caoba + fr: Acajou + pt: Mogno + uk: Махагон + zh_CN: 桃花心木 +description: + default: Rich reddish-brown tropical hardwood + de: Reiches rötlich-braunes tropisches Hartholz + es: Madera dura tropical de color marrón rojizo rico + fr: Bois dur tropical riche de couleur brun rougeâtre + pt: Madeira dura tropical rica de cor marrom avermelhado + uk: Багата червонувато-коричнева тропічна тверда деревина + zh_CN: 丰富的红棕色热带硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#8B4513" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml new file mode 100644 index 000000000..88cf3ce2c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml @@ -0,0 +1,28 @@ +uid: maple +name: + default: Maple + de: Ahorn + es: Arce + fr: Érable + pt: Bordo + uk: Клен + zh_CN: 枫木 +description: + default: Light hardwood with fine, even grain + de: Helles Hartholz mit feiner, gleichmäßiger Maserung + es: Madera dura clara con veta fina y uniforme + fr: Bois dur clair avec un grain fin et régulier + pt: Madeira dura clara com grão fino e uniforme + uk: Світла тверда деревина з дрібним рівномірним візерунком + zh_CN: 浅色硬木,纹理细腻均匀 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#F5DEB3" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml new file mode 100644 index 000000000..d46f91d3f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml @@ -0,0 +1,28 @@ +uid: marble +name: + default: Marble + de: Marmor + es: Mármol + fr: Marbre + pt: Mármore + uk: Мармур + zh_CN: 大理石 +description: + default: Metamorphic rock with veined patterns + de: Metamorphes Gestein mit gemusterten Adern + es: Roca metamórfica con patrones veteados + fr: Roche métamorphique avec des motifs veinés + pt: Rocha metamórfica com padrões veiados + uk: Метаморфічна порода з прожилковим візерунком + zh_CN: 具有纹理图案的变质岩 +category: + default: Stone + de: Stein + es: Piedra + fr: Pierre + pt: Pedra + uk: Камінь + zh_CN: 石材 +appearance: + color: "#F5F5F5" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml new file mode 100644 index 000000000..d06ad824d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml @@ -0,0 +1,28 @@ +uid: mdf +name: + default: MDF (Medium-Density Fiberboard) + de: MDF (Mitteldichte Faserplatte) + es: MDF (Tablero de fibra de densidad media) + fr: MDF (Panneau de fibres de moyenne densité) + pt: MDF (Painel de fibra de média densidade) + uk: МДФ (плита середньої щільності) + zh_CN: MDF (中密度纤维板) +description: + default: Medium-density fiberboard, a common engineered wood product + de: Mitteldichte Faserplatte, ein häufiges Holzwerkstoffprodukt + es: Tablero de fibra de densidad media, un producto común de madera procesada + fr: Panneau de fibres de moyenne densité, un produit courant en bois reconstitué + pt: Painel de fibra de média densidade, um produto comum de madeira processada + uk: Плита середньої щільності, поширений продукт з інженерної деревини + zh_CN: 中密度纤维板,一种常见的工程木制品 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#D2691E" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml new file mode 100644 index 000000000..9dbd6e845 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml @@ -0,0 +1,28 @@ +uid: nickel +name: + default: Nickel + de: Nickel + es: Níquel + fr: Nickel + pt: Níquel + uk: Нікель + zh_CN: 镍 +description: + default: Silvery-white metal with slight golden tinge + de: Silber-weißes Metall mit leichtem goldenen Schimmer + es: Metal blanco plateado con ligero tinte dorado + fr: Métal blanc argenté avec une légère teinte dorée + pt: Metal branco prateado com leve tom dourado + uk: Сріблясто-білий метал з легким золотистим відтінком + zh_CN: 银白色金属,略带金色 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#E0E0E0" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml new file mode 100644 index 000000000..b86c2cee8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml @@ -0,0 +1,28 @@ +uid: nylon +name: + default: Nylon + de: Nylon + es: Nailon + fr: Nylon + pt: Nylon + uk: Нейлон + zh_CN: 尼龙 +description: + default: Strong synthetic polymer + de: Starker synthetischer Kunststoff + es: Polímero sintético fuerte + fr: Polymère synthétique fort + pt: Polímero sintético forte + uk: Міцний синтетичний полімер + zh_CN: 强韧的合成聚合物 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFFACD" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml new file mode 100644 index 000000000..af8e5baa9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml @@ -0,0 +1,28 @@ +uid: oak +name: + default: Oak + de: Eiche + es: Roble + fr: Chêne + pt: Carvalho + uk: Дуб + zh_CN: 橡木 +description: + default: Hardwood with distinctive grain pattern + de: Hartholz mit ausgeprägter Maserung + es: Madera dura con patrón de veta distintivo + fr: Bois dur avec un motif de grain distinctif + pt: Madeira dura com padrão de grão distinto + uk: Тверда деревина з характерним візерунком + zh_CN: 具有独特纹理的硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#A0522D" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml new file mode 100644 index 000000000..63d83e9db --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml @@ -0,0 +1,28 @@ +uid: paper +name: + default: Paper + de: Papier + es: Papel + fr: Papier + pt: Papel + uk: Папір + zh_CN: 纸 +description: + default: Thin flexible sheet material + de: Dünnes flexibles Blattmaterial + es: Material de hoja delgada y flexible + fr: Matériau en feuille mince et flexible + pt: Material de folha fina e flexível + uk: Тонкий гнучкий листовий матеріал + zh_CN: 薄而柔韧的片状材料 +category: + default: Paper + de: Papier + es: Papel + fr: Papier + pt: Papel + uk: Папір + zh_CN: 纸张 +appearance: + color: "#FFFFFF" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml new file mode 100644 index 000000000..1d3f80662 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml @@ -0,0 +1,28 @@ +uid: petg +name: + default: PETG + de: PETG + es: PETG + fr: PETG + pt: PETG + uk: PETG + zh_CN: PETG +description: + default: Polyethylene terephthalate glycol-modified plastic + de: Glykol-modifiziertes Polyethylenterephthalat-Kunststoff + es: Plástico de tereftalato de polietileno modificado con glicol + fr: Plastique de polytéréphtalate d'éthylène modifié au glycol + pt: Plástico de tereftalato de polietileno modificado com glicol + uk: Пластик з модифікованим гліколем поліетилентерефталат + zh_CN: 乙二醇改性聚对苯二甲酸乙二醇酯塑料 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#F0F8FF" + pattern: transparent diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml new file mode 100644 index 000000000..3b0bf1e2d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml @@ -0,0 +1,28 @@ +uid: pine +name: + default: Pine + de: Kiefer + es: Pino + fr: Pin + pt: Pinheiro + uk: Сосна + zh_CN: 松木 +description: + default: Softwood with pale yellow color + de: Nadelholz mit blassgelber Farbe + es: Madera blanda de color amarillo pálido + fr: Bois tendre de couleur jaune pâle + pt: Madeira macia de cor amarela pálida + uk: М'яка деревина блідо-жовтого кольору + zh_CN: 浅黄色的软木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#FAEBD7" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml new file mode 100644 index 000000000..3f2498f09 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml @@ -0,0 +1,28 @@ +uid: pla +name: + default: PLA + de: PLA + es: PLA + fr: PLA + pt: PLA + uk: PLA + zh_CN: PLA +description: + default: Polylactic acid biodegradable thermoplastic + de: Polymilchsäure biologisch abbaubarer Thermoplast + es: Ácido poliláctico termoplástico biodegradable + fr: Acide polylactique thermoplastique biodégradable + pt: Ácido polilático termoplástico biodegradável + uk: Полімолочна кислота біорозкладний термопластик + zh_CN: 聚乳酸生物可降解热塑性塑料 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFFACD" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml new file mode 100644 index 000000000..fc94e3a61 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml @@ -0,0 +1,28 @@ +uid: plywood +name: + default: Plywood + de: Sperrholz + es: Contrachapado + fr: Contreplaqué + pt: Compensado + uk: Фанера + zh_CN: 胶合板 +description: + default: Engineered wood product made from layers of wood veneer + de: Holzwerkstoffprodukt aus Schichten von Furnierholz + es: Producto de madera procesada hecho de capas de chapa de madera + fr: Produit en bois reconstitué fabriqué à partir de couches de placage de bois + pt: Produto de madeira processada feito de camadas de lâmina de madeira + uk: Продукт з інженерної деревини, виготовлений із шарів шпону + zh_CN: 由木单板层制成的工程木制品 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#DEB887" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml new file mode 100644 index 000000000..162bd2787 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml @@ -0,0 +1,28 @@ +uid: polycarbonate +name: + default: Polycarbonate + de: Polycarbonat + es: Policarbonato + fr: Polycarbonate + pt: Policarbonato + uk: Полікарбонат + zh_CN: 聚碳酸酯 +description: + default: Durable transparent thermoplastic + de: Langlebiger transparenter Thermoplast + es: Termoplástico transparente duradero + fr: Thermoplastique transparent durable + pt: Termoplástico transparente durável + uk: Міцний прозорий термопластик + zh_CN: 耐用的透明热塑性塑料 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#E0FFFF" + pattern: transparent diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml new file mode 100644 index 000000000..33c83b589 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml @@ -0,0 +1,28 @@ +uid: polyethylene +name: + default: Polyethylene + de: Polyethylen + es: Polietileno + fr: Polyéthylène + pt: Polietileno + uk: Поліетилен + zh_CN: 聚乙烯 +description: + default: Common thermoplastic polymer + de: Häufiger thermoplastischer Kunststoff + es: Polímero termoplástico común + fr: Polymère thermoplastique courant + pt: Polímero termoplástico comum + uk: Звичайний термопластичний полімер + zh_CN: 常见的热塑性聚合物 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#F8F8FF" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml new file mode 100644 index 000000000..6e3b10cec --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml @@ -0,0 +1,28 @@ +uid: polypropylene +name: + default: Polypropylene + de: Polypropylen + es: Polipropileno + fr: Polypropylène + pt: Polipropileno + uk: Поліпропілен + zh_CN: 聚丙烯 +description: + default: Semi-crystalline thermoplastic polymer + de: Halbkristalliner thermoplastischer Kunststoff + es: Polímero termoplástico semicristalino + fr: Polymère thermoplastique semi-cristallin + pt: Polímero termoplástico semicristalino + uk: Напівкристалічний термопластичний полімер + zh_CN: 半结晶热塑性聚合物 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#F0F0F0" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml new file mode 100644 index 000000000..d27c456c5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml @@ -0,0 +1,28 @@ +uid: ptfe +name: + default: PTFE + de: PTFE + es: PTFE + fr: PTFE + pt: PTFE + uk: ПТФЕ + zh_CN: PTFE +description: + default: Polytetrafluoroethylene, known as Teflon + de: Polytetrafluorethylen, bekannt als Teflon + es: Politetrafluoroetileno, conocido como Teflón + fr: Polytétrafluoroéthylène, connu sous le nom de Teflon + pt: Politetrafluoretileno, conhecido como Teflon + uk: Політетрафторетилен, відомий як тефлон + zh_CN: 聚四氟乙烯,即特氟龙 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#FFFFFF" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml new file mode 100644 index 000000000..06d6f0b3d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml @@ -0,0 +1,28 @@ +uid: pvc +name: + default: PVC + de: PVC + es: PVC + fr: PVC + pt: PVC + uk: ПВХ + zh_CN: PVC +description: + default: Polyvinyl chloride plastic + de: Polyvinylchlorid-Kunststoff + es: Plástico de cloruro de polivinilo + fr: Plastique de chlorure de polyvinyle + pt: Plástico de cloreto de polivinila + uk: Полівінілхлоридний пластик + zh_CN: 聚氯乙烯塑料 +category: + default: Plastic + de: Kunststoff + es: Plástico + fr: Plastique + pt: Plástico + uk: Пластик + zh_CN: 塑料 +appearance: + color: "#E0FFFF" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml new file mode 100644 index 000000000..0d0d617a2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml @@ -0,0 +1,28 @@ +uid: rosewood +name: + default: Rosewood + de: Palisander + es: Palo de rosa + fr: Palissandre + pt: Jacarandá + uk: Палисандр + zh_CN: 红木 +description: + default: Dark hardwood with reddish-brown hue + de: Dunkles Hartholz mit rötlich-braunem Farbton + es: Madera dura oscura con tono marrón rojizo + fr: Bois dur foncé avec une teinte brun rougeâtre + pt: Madeira dura escura com tom marrom avermelhado + uk: Темна тверда деревина з червонувато-коричневим відтінком + zh_CN: 深色硬木,带有红棕色调 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#800000" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml new file mode 100644 index 000000000..2aea922f0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml @@ -0,0 +1,28 @@ +uid: rubber +name: + default: Rubber + de: Gummi + es: Caucho + fr: Caoutchouc + pt: Borracha + uk: Гума + zh_CN: 橡胶 +description: + default: Elastic polymer material + de: Elastisches Polymermaterial + es: Material polimérico elástico + fr: Matériau polymère élastique + pt: Material polimérico elástico + uk: Еластичний полімерний матеріал + zh_CN: 弹性聚合物材料 +category: + default: Organic + de: Organisch + es: Orgánico + fr: Organique + pt: Orgânico + uk: Органічний + zh_CN: 有机材料 +appearance: + color: "#2F4F4F" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml new file mode 100644 index 000000000..ffe9f4fcd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml @@ -0,0 +1,28 @@ +uid: slate +name: + default: Slate + de: Schiefer + es: Pizarra + fr: Ardoise + pt: Ardósia + uk: Сланець + zh_CN: 板岩 +description: + default: Fine-grained metamorphic rock + de: Feinkörniges metamorphes Gestein + es: Roca metamórfica de grano fino + fr: Roche métamorphique à grain fin + pt: Rocha metamórfica de grão fino + uk: Дрібнозерниста метаморфічна порода + zh_CN: 细粒变质岩 +category: + default: Stone + de: Stein + es: Piedra + fr: Pierre + pt: Pedra + uk: Камінь + zh_CN: 石材 +appearance: + color: "#696969" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml new file mode 100644 index 000000000..9b5ee3539 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml @@ -0,0 +1,28 @@ +uid: slate_blue +name: + default: Blue Slate + de: Blauer Schiefer + es: Pizarra Azul + fr: Ardoise Bleue + pt: Ardósia Azul + uk: Блакитний сланець + zh_CN: 蓝板岩 +description: + default: Blue-colored fine-grained metamorphic rock + de: Blauer feinkörniger metamorpher Fels + es: Roca metamórfica de grano fino de color azul + fr: Roche métamorphique à grain fin de couleur bleue + pt: Rocha metamórfica de grão fino de cor azul + uk: Блакитний дрібнозернистий метаморфічний камінь + zh_CN: 蓝色细粒变质岩 +category: + default: Stone + de: Stein + es: Piedra + fr: Pierre + pt: Pedra + uk: Камінь + zh_CN: 石材 +appearance: + color: "#6A5ACD" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml new file mode 100644 index 000000000..e9051b985 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml @@ -0,0 +1,28 @@ +uid: spruce +name: + default: Spruce + de: Fichte + es: Abeto + fr: Épicéa + pt: Picea + uk: Ялина + zh_CN: 云杉 +description: + default: Light-colored softwood + de: Helles Nadelholz + es: Madera blanda de color claro + fr: Bois tendre de couleur claire + pt: Madeira macia de cor clara + uk: Світла м'яка деревина + zh_CN: 浅色软木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#FFF5E1" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml new file mode 100644 index 000000000..7df8caa96 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml @@ -0,0 +1,28 @@ +uid: stainless_steel +name: + default: Stainless Steel + de: Edelstahl + es: Acero Inoxidable + fr: Acier Inoxydable + pt: Aço Inoxidável + uk: Нержавіюча сталь + zh_CN: 不锈钢 +description: + default: Corrosion-resistant steel alloy + de: Korrosionsbeständige Stahllegierung + es: Aleación de acero resistente a la corrosión + fr: Alliage d'acier résistant à la corrosion + pt: Liga de aço resistente à corrosão + uk: Корозійностійка сталь + zh_CN: 耐腐蚀的钢合金 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#A9A9A9" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml new file mode 100644 index 000000000..65115eeaa --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml @@ -0,0 +1,28 @@ +uid: steel +name: + default: Steel + de: Stahl + es: Acero + fr: Acier + pt: Aço + uk: Сталь + zh_CN: 钢 +description: + default: Strong ferrous alloy with metallic luster + de: Starke Eisenlegierung mit metallischem Glanz + es: Aleación férrea fuerte con brillo metálico + fr: Alliage ferreux fort avec un éclat métallique + pt: Liga férea forte com brilho metálico + uk: Міцна залізна сплав з металевим блиском + zh_CN: 具有金属光泽的强铁合金 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#708090" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml new file mode 100644 index 000000000..b7ce37268 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml @@ -0,0 +1,28 @@ +uid: stone +name: + default: Stone + de: Stein + es: Piedra + fr: Pierre + pt: Pedra + uk: Камінь + zh_CN: 石材 +description: + default: Natural hard mineral material + de: Natürliches hartes Mineralmaterial + es: Material mineral duro natural + fr: Matériau minéral dur naturel + pt: Material mineral duro natural + uk: Природна тверда мінеральна речовина + zh_CN: 天然硬质矿物材料 +category: + default: Stone + de: Stein + es: Piedra + fr: Pierre + pt: Pedra + uk: Камінь + zh_CN: 石材 +appearance: + color: "#808080" + pattern: solid diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml new file mode 100644 index 000000000..cffedeec4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml @@ -0,0 +1,28 @@ +uid: teak +name: + default: Teak + de: Teak + es: Teca + fr: Teck + pt: Teca + uk: Тік + zh_CN: 柚木 +description: + default: Durable tropical hardwood + de: Langlebiges tropisches Hartholz + es: Madera dura tropical duradera + fr: Bois dur tropical durable + pt: Madeira dura tropical durável + uk: Міцна тропічна тверда деревина + zh_CN: 耐用的热带硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#D2691E" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml new file mode 100644 index 000000000..b66f1fd29 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml @@ -0,0 +1,28 @@ +uid: titanium +name: + default: Titanium + de: Titan + es: Titanio + fr: Titane + pt: Titânio + uk: Титан + zh_CN: 钛 +description: + default: Lightweight strong metal with silver-gray color + de: Leichtes starkes Metall mit silbergrauer Farbe + es: Metal ligero y fuerte de color gris plateado + fr: Métal léger et fort de couleur gris argenté + pt: Metal leve e forte de cor cinza prateado + uk: Легкий міцний метал сріблясто-сірого кольору + zh_CN: 轻质强金属,呈银灰色 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#71797E" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml new file mode 100644 index 000000000..1fa8e4565 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml @@ -0,0 +1,28 @@ +uid: walnut +name: + default: Walnut + de: Walnuss + es: Nogal + fr: Noyer + pt: Nogueira + uk: Горіх + zh_CN: 胡桃木 +description: + default: Dark hardwood with rich brown tones + de: Dunkles Hartholz mit reichen brauntönen + es: Madera dura oscura con tonos marrones ricos + fr: Bois dur foncé avec des tons bruns riches + pt: Madeira dura escura com tons marrons ricos + uk: Темна тверда деревина з багатими коричневими відтінками + zh_CN: 深色硬木,具有丰富的棕色调 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + color: "#5D4037" + pattern: wood_grain diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml new file mode 100644 index 000000000..180440d52 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml @@ -0,0 +1,28 @@ +uid: zinc +name: + default: Zinc + de: Zink + es: Zinc + fr: Zinc + pt: Zinco + uk: Цинк + zh_CN: 锌 +description: + default: Bluish-white metal used for galvanizing + de: Bläulich-weißes Metall für die Verzinkung + es: Metal blanquecino azulado utilizado para galvanizado + fr: Métal blanc bleuté utilisé pour la galvanisation + pt: Metal branco azulado usado para galvanização + uk: Блакитно-білий метал для оцинковування + zh_CN: 蓝白色金属,用于镀锌 +category: + default: Metal + de: Metall + es: Metal + fr: Métal + pt: Metal + uk: Метал + zh_CN: 金属 +appearance: + color: "#A0A0A0" + pattern: metallic diff --git a/rayforge/builtin_addons/rayforge-addon-materials/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-materials/rayforge-addon.yaml new file mode 100644 index 000000000..91e4b402a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-materials/rayforge-addon.yaml @@ -0,0 +1,11 @@ +name: core_materials +display_name: "Core Materials" +description: "Built-in material library with common laser cutting materials" +api_version: 18 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "core_materials.worker" +license: + name: "MIT" 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 new file mode 100644 index 000000000..b78d7d7c9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po @@ -0,0 +1,272 @@ +# German translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "Umkehrung zulassen" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "Umkehrung der Pfadrichtung für kürzeren Verfahrweg zulassen" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "Erstes Werkstück beibehalten" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "Erstes Werkstück an seiner ursprünglichen Position behalten" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "Umkehrung umschalten" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "Erstes Werkstück beibehalten umschalten" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "" +"Diese Maschine fügt Überlauf automatisch hinzu; die Einstellung hat keine " +"Wirkung." + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "Automatischer Abstand" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "" +"Abstand basierend auf Geschwindigkeit und Beschleunigung mit " +"Sicherheitsfaktor berechnen" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "Überlaufabstand" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "Manuelle Abstandseinstellung" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "Automatischen Überlauf umschalten" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "Überlaufabstand automatisch berechnen" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "Automatischen Überlauf deaktivieren" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "Überlaufabstand ändern" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "Glättung" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "Höhere Werte erzeugen glattere Kurven" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "Eckenschwellenwert" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "Scharfere Winkel als dieser werden als Ecken beibehalten (Grad)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "Glättung ändern" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "Eckenwinkel ändern" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "Versatz" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "Materialgrenze vor dem Zuschneiden vergrößern/verkleinern" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "Zuschnittversatz ändern" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "Anzahl Durchläufe" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "Wie oft der gesamte Schritt wiederholt werden soll" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "Z-Absenkung pro Durchlauf" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "Abstand zum Absenken der Z-Achse für jeden weiteren Durchgang" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "Anzahl Durchläufe ändern" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "Z-Absenkung ändern" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "Einlaufabstand" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "Abstand der Nullleistungsbewegung vor Schnittbeginn" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "Auslaufabstand" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "Abstand der Nullleistungsbewegung nach Schnittende" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "Automatischen Ein-/Auslauf umschalten" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "Ein-/Auslaufabstand automatisch berechnen" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "Automatischen Ein-/Auslauf deaktivieren" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "Einlaufabstand ändern" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "Auslaufabstand ändern" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "Toleranz" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "Maximaler Abstand, damit Linien als überlappend gelten" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "Zusammenführungstoleranz ändern" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "Ein-/Auslauf" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "Fügt Nullleistung-Ein- und Auslaufbewegungen zu Vektorkonturen hinzu." + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "Pfad optimieren" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "Minimiert den Verfahrweg durch Neuanordnung der Segmente." + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "Bidirektionaler Scan-Versatz" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" +"Verschiebt rechts-nach-links Rasterdurchläufe entlang X, um Scanrichtungs-" +"Schrägstellung zu korrigieren." + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "Pfad glätten" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "Glättet den Pfad durch Anwendung eines Gauß-Filters." + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "Linien zusammenführen" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "" +"Führt überlappende Linien zusammen, um doppeltes Bearbeiten zu vermeiden." + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "Überlauf" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "" +"Verlängert Rasterlinien, um eine konstante Gravurgeschwindigkeit zu " +"gewährleisten." + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "Auf Material zuschneiden" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "Schneidet Schneidlinien auf die Materialgrenze zu." + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "Mehrfachdurchlauf" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "Wiederholt den Pfad mehrmals, optional mit Absenkung in Z." + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "Haltestege" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +msgstr "" +"Erstellt Haltestege durch Hinzufügen von Lücken oder Reduzierung der " +"Leistung auf Schneidpfaden" 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 new file mode 100644 index 000000000..a86667df7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po @@ -0,0 +1,261 @@ +# English translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "Allow Flipping" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "Allow reversing path direction for shorter travel" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "Preserve First Workpiece" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "Keep the first workpiece at its original position" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "Toggle Flipping" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "Toggle Preserve First Workpiece" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "Automatic Distance" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "Calculate distance based on speed and acceleration with safety factor" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "Overscan Distance" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "Manual distance setting" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "Toggle Auto Overscan" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "Auto Calculate Overscan Distance" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "Disable Auto Overscan" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "Change Overscan Distance" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "Smoothness" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "Higher values produce smoother curves" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "Corner Angle Threshold" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "Angles sharper than this are kept as corners (degrees)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "Change smoothness" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "Change corner angle" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "Offset" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "Grow/shrink stock boundary before cropping" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "Change Crop Offset" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "Number of Passes" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "How often to repeat the entire step" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "Z Step-Down per Pass" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "Change number of passes" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "Change Z Step-Down" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "" + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "Optimize Path" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "Minimizes travel distance by reordering segments." + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "Smooth Path" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "Smooths the path by applying a Gaussian filter." + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "" + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "Overscan" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "Extends raster lines to ensure constant engraving speed." + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "Crop to Stock" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "Crops cutting lines to stock boundary." + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "Multi-Pass" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "Repeats the path multiple times, optionally stepping down in Z." + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "Tabs" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +msgstr "Creates holding tabs by adding gaps or reducing power on cut paths" 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 new file mode 100644 index 000000000..1fd8c7186 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po @@ -0,0 +1,271 @@ +# Spanish translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "Permitir inversión" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "" +"Permitir invertir la dirección de la trayectoria para viajes más cortos" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "Preservar primera pieza" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "Mantener la primera pieza en su posición original" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "Alternar inversión" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "Alternar preservación de primera pieza" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "" +"Esta máquina agrega sobrecorrido automáticamente; la configuración no tiene " +"efecto." + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "Distancia automática" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "" +"Calcular distancia basada en velocidad y aceleración con factor de seguridad" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "Distancia de sobrecorrido" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "Configuración de distancia manual" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "Alternar sobrecorrido automático" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "Calcular automáticamente distancia de sobrecorrido" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "Desactivar sobrecorrido automático" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "Cambiar distancia de sobrecorrido" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "Suavidad" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "Valores más altos producen curvas más suaves" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "Umbral de ángulo de esquina" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "Ángulos más agudos que esto se mantienen como esquinas (grados)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "Cambiar suavidad" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "Cambiar ángulo de esquina" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "Desplazamiento" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "Ampliar/reducir el límite del material antes de recortar" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "Cambiar desplazamiento de recorte" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "Número de pasadas" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "Con qué frecuencia repetir todo el paso" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "Descenso en Z por pasada" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "Distancia para bajar el eje Z en cada pasada posterior" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "Cambiar número de pasadas" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "Cambiar descenso en Z" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "Distancia de entrada" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "Distancia del movimiento sin potencia antes de iniciar el corte" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "Distancia de salida" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "Distancia del movimiento sin potencia después de finalizar el corte" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "Alternar entrada/salida automática" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "Calcular automáticamente la distancia de entrada/salida" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "Desactivar entrada/salida automática" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "Cambiar distancia de entrada" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "Cambiar distancia de salida" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "Tolerancia" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "Distancia máxima para que las líneas se consideren superpuestas" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "Cambiar tolerancia de fusión" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "Entrada/Salida" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "" +"Añade movimientos de entrada y salida sin potencia a los contornos " +"vectoriales." + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "Optimizar trayectoria" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "Minimiza la distancia de viaje reordenando segmentos." + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "Desplazamiento de escaneo bidireccional" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" +"Desplaza los barridos de rasterizado de derecha a izquierda a lo largo de X " +"para corregir la desviación de la dirección de escaneo." + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "Suavizar trayectoria" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "Suaviza la trayectoria aplicando un filtro gaussiano." + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "Fusionar líneas" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "Fusiona líneas superpuestas para evitar pases dobles." + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "Sobrecorrido" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "Extiende líneas de trama para asegurar velocidad de grabado constante." + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "Recortar al material" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "Recorta líneas de corte al límite del material." + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "Multipasada" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "Repite la trayectoria múltiples veces, opcionalmente bajando en Z." + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "Pestañas de sujeción" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +msgstr "" +"Crea pestañas de sujeción añadiendo huecos o reduciendo potencia en " +"trayectorias de corte" 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 new file mode 100644 index 000000000..d12808a26 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po @@ -0,0 +1,276 @@ +# French translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "Autoriser l'inversion" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "" +"Autoriser l'inversion de la direction du parcours pour un déplacement plus " +"court" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "Conserver la première pièce" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "Garder la première pièce à sa position d'origine" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "Activer/désactiver l'inversion" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "Activer/désactiver la conservation de la première pièce" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "" +"Cette machine ajoute le surbalayage automatiquement; le paramètre est sans " +"effet." + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "Distance automatique" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "" +"Calculer la distance en fonction de la vitesse et de l'accélération avec un " +"facteur de sécurité" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "Distance de surbalayage" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "Réglage manuel de la distance" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "Activer/désactiver le surbalayage automatique" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "Calculer automatiquement la distance de surbalayage" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "Désactiver le surbalayage automatique" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "Modifier la distance de surbalayage" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "Lissage" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "Des valeurs plus élevées produisent des courbes plus lisses" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "Seuil d'angle de coin" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "Les angles plus vifs que celui-ci sont conservés comme coins (degrés)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "Modifier le lissage" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "Modifier l'angle de coin" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "Décalage" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "Agrandir/réduire la limite du brut avant le rognage" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "Modifier le décalage de rognage" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "Nombre de passes" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "Fréquence de répétition de l'étape entière" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "Descente en Z par passe" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "Distance d'abaissement de l'axe Z pour chaque passage suivant" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "Modifier le nombre de passes" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "Modifier la descente en Z" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "Distance d'entrée" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "Distance du mouvement sans puissance avant le début de la coupe" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "Distance de sortie" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "Distance du mouvement sans puissance après la fin de la coupe" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "Basculer l'entrée/sortie automatique" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "Calculer automatiquement la distance d'entrée/sortie" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "Désactiver l'entrée/sortie automatique" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "Modifier la distance d'entrée" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "Modifier la distance de sortie" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "Tolérance" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "" +"Distance maximale pour que les lignes soient considérées comme se chevauchant" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "Modifier la tolérance de fusion" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "Entrée/Sortie" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "" +"Ajoute des mouvements d'entrée et de sortie sans puissance aux contours " +"vectoriels." + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "Optimiser le parcours" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "Minimise la distance de déplacement en réorganisant les segments." + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "Décalage de balayage bidirectionnel" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" +"Décale les passages raster de droite à gauche le long de X pour corriger " +"l'obliquité de la direction de balayage." + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "Lisser le parcours" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "Lisse le parcours en appliquant un filtre gaussien." + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "Fusionner les lignes" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "Fusionne les lignes se chevauchant pour éviter les passages doubles." + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "Surbalayage" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "" +"Étend les lignes de trame pour assurer une vitesse de gravure constante." + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "Rogner au brut" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "Rogne les lignes de coupe à la limite du brut." + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "Multi-passe" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "" +"Répète le parcours plusieurs fois, avec optionnellement une descente en Z." + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "Languettes" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +msgstr "" +"Crée des languettes de maintien en ajoutant des espaces ou en réduisant la " +"puissance sur les parcours de coupe" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot new file mode 100644 index 000000000..d9dabf214 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot @@ -0,0 +1,261 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "" + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "" + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "" + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "" + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "" + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +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 new file mode 100644 index 000000000..edf1583ab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po @@ -0,0 +1,269 @@ +# Portuguese translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "Permitir Inversão" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "Permitir inverter a direção do caminho para deslocamento mais curto" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "Preservar Primeira Peça de Trabalho" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "Manter a primeira peça de trabalho em sua posição original" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "Alternar Inversão" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "Alternar Preservação da Primeira Peça de Trabalho" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "" +"Esta máquina adiciona sobrescan automaticamente; a configuração não tem " +"efeito." + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "Distância Automática" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "" +"Calcular distância baseada em velocidade e aceleração com fator de segurança" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "Distância do Sobrescan" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "Configuração manual de distância" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "Alternar Sobrescan Automático" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "Calcular Automaticamente Distância do Sobrescan" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "Desativar Sobrescan Automático" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "Alterar Distância do Sobrescan" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "Suavidade" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "Valores mais altos produzem curvas mais suaves" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "Limiar de Ângulo de Canto" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "Ângulos mais agudos que este são mantidos como cantos (graus)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "Alterar suavidade" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "Alterar ângulo do canto" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "Deslocamento" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "Expandir/retrair limite do material antes do recorte" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "Alterar Deslocamento do Recorte" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "Número de Passagens" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "Quantas vezes repetir todo o passo" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "Descida em Z por Passagem" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "Distância para baixar o eixo Z em cada passada subsequente" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "Alterar número de passagens" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "Alterar Descida em Z" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "Distância de entrada" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "Distância do movimento sem potência antes de iniciar o corte" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "Distância de saída" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "Distância do movimento sem potência após o fim do corte" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "Alternar entrada/saída automática" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "Calcular automaticamente a distância de entrada/saída" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "Desativar entrada/saída automática" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "Alterar distância de entrada" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "Alterar distância de saída" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "Tolerância" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "Distância máxima para que as linhas sejam consideradas sobrepostas" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "Alterar tolerância de fusão" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "Entrada/Saída" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "" +"Adiciona movimentos de entrada e saída sem potência aos contornos vetoriais." + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "Otimizar Caminho" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "Minimiza a distância de deslocamento reordenando segmentos." + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "Deslocamento de varredura bidirecional" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" +"Desloca as passadas raster da direita para a esquerda ao longo de X para " +"corrigir a inclinação da direção de varredura." + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "Suavizar Caminho" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "Suaviza o caminho aplicando um filtro gaussiano." + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "Fundir linhas" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "Funde linhas sobrepostas para evitar passagens duplas." + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "Sobrescan" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "Estende linhas raster para garantir velocidade de gravação constante." + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "Recortar para Material" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "Recorta linhas de corte para o limite do material." + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "Múltiplas Passagens" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "Repete o caminho múltiplas vezes, opcionalmente descendo em Z." + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "Abas de Fixação" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +msgstr "" +"Cria abas de fixação adicionando lacunas ou reduzindo a potência em caminhos " +"de corte" 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 new file mode 100644 index 000000000..17f898149 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po @@ -0,0 +1,269 @@ +# Ukrainian translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "Дозволити перевертання" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "Дозволити зміну напрямку контуру для скорочення переміщення" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "Зберегти першу деталь" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "Залишити першу деталь на її початковій позиції" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "Перемкнути перевертання" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "Перемкнути збереження першої деталі" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "Ця машина додає перебіг автоматично; налаштування не має ефекту." + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "Автоматична відстань" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "" +"Обчислити відстань на основі швидкості та прискорення з коефіцієнтом безпеки" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "Відстань перебігу" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "Ручне налаштування відстані" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "Перемкнути автоматичний перебіг" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "Автоматично обчислити відстань перебігу" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "Вимкнути автоматичний перебіг" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "Змінити відстань перебігу" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "Згладжування" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "Більші значення створюють більш плавні криві" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "Поріг кута кута" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "Кути, гостріші за це, зберігаються як кути (градуси)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "Змінити згладжування" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "Змінити кут кута" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "Зміщення" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "Збільшити/зменшити межу заготовки перед обрізанням" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "Змінити зміщення обрізання" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "Кількість проходів" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "Як часто повторювати весь крок" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "Зниження Z за прохід" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "Відстань опускання осі Z для кожного наступного проходу" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "Змінити кількість проходів" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "Змінити зниження Z" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "Відстань в'їзду" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "Відстань руху без потужності до початку різання" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "Відстань виїзду" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "Відстань руху без потужності після завершення різання" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "Перемкнути автоматичний в'їзд/виїзд" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "Автоматичний розрахунок відстані в'їзду/виїзду" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "Вимкнути автоматичний в'їзд/виїзд" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "Змінити відстань в'їзду" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "Змінити відстань виїзду" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "Допуск" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "" +"Максимальна відстань, за якої лінії вважаються такими, що перекриваються" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "Змінити допуск об'єднання" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "В'їзд/виїзд" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "Додає рухи в'їзду та виїзду без потужності до векторних контурів." + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "Оптимізувати контур" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "Мінімізує відстань переміщення шляхом зміни порядку сегментів." + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "Двоспрямований зсув сканування" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "" +"Зміщує растрові проходи справа-наліво вздовж X для виправлення перекосу " +"напрямку сканування." + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "Згладити контур" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "Згладжує контур, застосовуючи фільтр Гауса." + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "Об'єднати лінії" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "Об'єднує лінії, що перекриваються, щоб уникнути подвійного проходу." + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "Перебіг" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "" +"Розширює растрові лінії для забезпечення постійної швидкості гравіювання." + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "Обрізати до заготовки" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "Обрізає лінії різання до межі заготовки." + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "Багатопрохідний" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "Повторює контур кілька разів, опціонально знижуючи по осі Z." + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "Закладки" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +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 new file mode 100644 index 000000000..707cb98da --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po @@ -0,0 +1,260 @@ +# Chinese translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-03-18 23:38+0100\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: post_processors/widgets/optimize_group.py +msgid "Allow Flipping" +msgstr "允许翻转" + +#: post_processors/widgets/optimize_group.py +msgid "Allow reversing path direction for shorter travel" +msgstr "允许反转路径方向以缩短移动" + +#: post_processors/widgets/optimize_group.py +msgid "Preserve First Workpiece" +msgstr "保留第一个工件" + +#: post_processors/widgets/optimize_group.py +msgid "Keep the first workpiece at its original position" +msgstr "将第一个工件保持在原始位置" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Flipping" +msgstr "切换翻转" + +#: post_processors/widgets/optimize_group.py +msgid "Toggle Preserve First Workpiece" +msgstr "切换保留第一个工件" + +#: post_processors/widgets/overscan_group.py +msgid "This machine adds overscan automatically; the setting has no effect." +msgstr "此机器会自动添加超扫描;此设置无效。" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Automatic Distance" +msgstr "自动距离" + +#: post_processors/widgets/overscan_group.py +#: post_processors/widgets/lead_in_out_group.py +msgid "Calculate distance based on speed and acceleration with safety factor" +msgstr "基于速度和加速度并考虑安全系数计算距离" + +#: post_processors/widgets/overscan_group.py +msgid "Overscan Distance" +msgstr "超扫描距离" + +#: post_processors/widgets/overscan_group.py +msgid "Manual distance setting" +msgstr "手动距离设置" + +#: post_processors/widgets/overscan_group.py +msgid "Toggle Auto Overscan" +msgstr "切换自动超扫描" + +#: post_processors/widgets/overscan_group.py +msgid "Auto Calculate Overscan Distance" +msgstr "自动计算超扫描距离" + +#: post_processors/widgets/overscan_group.py +msgid "Disable Auto Overscan" +msgstr "禁用自动超扫描" + +#: post_processors/widgets/overscan_group.py +msgid "Change Overscan Distance" +msgstr "更改超扫描距离" + +#: post_processors/widgets/smooth_group.py +msgid "Smoothness" +msgstr "平滑度" + +#: post_processors/widgets/smooth_group.py +msgid "Higher values produce smoother curves" +msgstr "较高的值产生更平滑的曲线" + +#: post_processors/widgets/smooth_group.py +msgid "Corner Angle Threshold" +msgstr "拐角角度阈值" + +#: post_processors/widgets/smooth_group.py +msgid "Angles sharper than this are kept as corners (degrees)" +msgstr "比此更尖锐的角度将保持为拐角(度)" + +#: post_processors/widgets/smooth_group.py +msgid "Change smoothness" +msgstr "更改平滑度" + +#: post_processors/widgets/smooth_group.py +msgid "Change corner angle" +msgstr "更改拐角角度" + +#: post_processors/widgets/crop_group.py +msgid "Offset" +msgstr "偏移" + +#: post_processors/widgets/crop_group.py +msgid "Grow/shrink stock boundary before cropping" +msgstr "在裁剪之前扩展/收缩材料边界" + +#: post_processors/widgets/crop_group.py +msgid "Change Crop Offset" +msgstr "更改裁剪偏移" + +#: post_processors/widgets/multipass_group.py +msgid "Number of Passes" +msgstr "通过次数" + +#: post_processors/widgets/multipass_group.py +msgid "How often to repeat the entire step" +msgstr "重复整个步骤的频率" + +#: post_processors/widgets/multipass_group.py +msgid "Z Step-Down per Pass" +msgstr "每次通过的Z轴下刀量" + +#: post_processors/widgets/multipass_group.py +msgid "Distance to lower Z-axis for each subsequent pass" +msgstr "每次后续通道降低 Z 轴的距离" + +#: post_processors/widgets/multipass_group.py +msgid "Change number of passes" +msgstr "更改通过次数" + +#: post_processors/widgets/multipass_group.py +msgid "Change Z Step-Down" +msgstr "更改Z轴下刀量" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-In Distance" +msgstr "导入距离" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move before cut starts" +msgstr "切割开始前的零功率移动距离" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Lead-Out Distance" +msgstr "导出距离" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Distance of zero-power move after cut ends" +msgstr "切割结束后的零功率移动距离" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Toggle Auto Lead-In/Out" +msgstr "切换自动导入/导出" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Auto Calculate Lead-In/Out Distance" +msgstr "自动计算导入/导出距离" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Disable Auto Lead-In/Out" +msgstr "禁用自动导入/导出" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-In Distance" +msgstr "更改导入距离" + +#: post_processors/widgets/lead_in_out_group.py +msgid "Change Lead-Out Distance" +msgstr "更改导出距离" + +#: post_processors/widgets/merge_lines_group.py +msgid "Tolerance" +msgstr "容差" + +#: post_processors/widgets/merge_lines_group.py +msgid "Maximum distance for lines to be considered overlapping" +msgstr "线条被视为重叠的最大距离" + +#: post_processors/widgets/merge_lines_group.py +msgid "Change merge tolerance" +msgstr "更改合并容差" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Lead-In/Out" +msgstr "导入/导出" + +#: post_processors/transformers/lead_in_out_transformer.py +msgid "Adds zero-power lead-in and lead-out moves to vector contours." +msgstr "为矢量轮廓添加零功率导入和导出移动。" + +#: post_processors/transformers/optimize_transformer.py +msgid "Optimize Path" +msgstr "优化路径" + +#: post_processors/transformers/optimize_transformer.py +msgid "Minimizes travel distance by reordering segments." +msgstr "通过重新排序段来最小化移动距离。" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "Bidirectional Scan Offset" +msgstr "双向扫描偏移" + +#: post_processors/transformers/bidir_scan_offset_transformer.py +msgid "" +"Shifts right-to-left raster passes along X to correct scan-direction skew." +msgstr "沿X方向移动从右到左的栅格扫描以纠正扫描方向偏差。" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooth Path" +msgstr "平滑路径" + +#: post_processors/transformers/smooth_transformer.py +msgid "Smooths the path by applying a Gaussian filter." +msgstr "通过应用高斯滤波器平滑路径。" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merge Lines" +msgstr "合并线条" + +#: post_processors/transformers/merge_lines_transformer.py +msgid "Merges overlapping lines to avoid double passing." +msgstr "合并重叠线条以避免重复通过。" + +#: post_processors/transformers/overscan_transformer.py +msgid "Overscan" +msgstr "超扫描" + +#: post_processors/transformers/overscan_transformer.py +msgid "Extends raster lines to ensure constant engraving speed." +msgstr "扩展光栅线以确保恒定的雕刻速度。" + +#: post_processors/transformers/crop_transformer.py +msgid "Crop to Stock" +msgstr "裁剪到材料" + +#: post_processors/transformers/crop_transformer.py +msgid "Crops cutting lines to stock boundary." +msgstr "将切割线裁剪到材料边界。" + +#: post_processors/transformers/multipass_transformer.py +msgid "Multi-Pass" +msgstr "多次通过" + +#: post_processors/transformers/multipass_transformer.py +msgid "Repeats the path multiple times, optionally stepping down in Z." +msgstr "多次重复路径,可选择在Z轴下刀。" + +#: post_processors/transformers/tabs_transformer.py +msgid "Tabs" +msgstr "标签" + +#: post_processors/transformers/tabs_transformer.py +msgid "Creates holding tabs by adding gaps or reducing power on cut paths" +msgstr "通过添加间隙或降低切割路径上的功率来创建保持标签" diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/__init__.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/__init__.py new file mode 100644 index 000000000..5bf52a354 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/__init__.py @@ -0,0 +1,5 @@ +""" +Post Processors Addon + +This addon provides post-processing transformers for toolpath optimization. +""" diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/frontend.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/frontend.py new file mode 100644 index 000000000..92e08e2be --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/frontend.py @@ -0,0 +1,20 @@ +""" +Frontend entry point for post_processors addon. + +Registers UI widgets for transformer settings with the main application. +""" + +from rayforge.core.hooks import hookimpl + +from .widgets import TRANSFORMER_WIDGETS + +ADDON_NAME = "post_processors" + + +@hookimpl +def register_transformer_widgets(transformer_widget_registry): + """Register transformer settings widget classes.""" + for transformer_cls, widget_cls in TRANSFORMER_WIDGETS.items(): + transformer_widget_registry.register( + transformer_cls, widget_cls, ADDON_NAME + ) 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 new file mode 100644 index 000000000..7583f8b03 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/__init__.py @@ -0,0 +1,28 @@ +""" +Post-processing transformers for toolpath optimization. + +This module provides all built-in transformers that can be applied +to Ops objects for various post-processing operations. +""" + +from .bidir_scan_offset_transformer import BidirScanOffsetTransformer +from .crop_transformer import CropTransformer +from .lead_in_out_transformer import LeadInOutTransformer +from .merge_lines_transformer import MergeLinesTransformer +from .multipass_transformer import MultiPassTransformer +from .optimize_transformer import Optimize +from .overscan_transformer import OverscanTransformer +from .smooth_transformer import Smooth +from .tabs_transformer import TabOpsTransformer + +__all__ = [ + "BidirScanOffsetTransformer", + "CropTransformer", + "LeadInOutTransformer", + "MergeLinesTransformer", + "MultiPassTransformer", + "Optimize", + "OverscanTransformer", + "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 new file mode 100644 index 000000000..43f747dc4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/bidir_scan_offset_transformer.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.ops.transform.bidir_scan_offset import BidirScanOffsetSpec + +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + from rayforge.core.workpiece import WorkPiece + + +class BidirScanOffsetTransformer(OpsTransformer): + """ + Corrects the X misalignment between left-to-right and right-to-left + raster passes seen on machines with a fixed mechanical/firmware skew + between scan directions. + + For every raster pass (a MoveTo immediately followed by a ScanLine), + if the pass runs right-to-left, both its entry MoveTo and its ScanLine + 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. + """ + + SPEC_NAME = "bidir_scan_offset" + + def __init__(self, enabled: bool = True): + super().__init__(enabled=enabled) + + @property + def label(self) -> str: + return _("Bidirectional Scan Offset") + + @property + def description(self) -> str: + return _( + "Shifts right-to-left raster passes along X to correct " + "scan-direction skew." + ) + + def to_spec( + self, + workpiece: WorkPiece | None, + 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) + + def to_dict(self) -> dict[str, Any]: + return {**super().to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> BidirScanOffsetTransformer: + return cls(enabled=data.get("enabled", True)) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/crop_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/crop_transformer.py new file mode 100644 index 000000000..10b47f31e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/crop_transformer.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Matrix +from raygeo.ops.transform.clip import CropSpec + +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + +logger = logging.getLogger(__name__) + + +class CropTransformer(OpsTransformer): + """ + Crops cutting lines to stock boundary. + + This removes any toolpath that extends beyond the stock material, + keeping only the parts that lie inside the stock boundary. + """ + + SPEC_NAME = "crop" + POSITION_SENSITIVE = True + + def __init__( + self, + enabled: bool = True, + tolerance: float = 0.03, + offset: float = 0.0, + ): + super().__init__(enabled=enabled) + self._tolerance = tolerance + self._offset = offset + logger.debug(f"CropTransformer enabled={enabled}") + + @property + def label(self) -> str: + return _("Crop to Stock") + + @property + def description(self) -> str: + return _("Crops cutting lines to stock boundary.") + + @property + def tolerance(self) -> float: + return self._tolerance + + @tolerance.setter + def tolerance(self, value: float): + if self._tolerance != value: + self._tolerance = value + self.changed.send(self) + + @property + def offset(self) -> float: + return self._offset + + @offset.setter + def offset(self, value: float): + if self._offset != value: + self._offset = value + self.changed.send(self) + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict[str, Any] | None, + ) -> CropSpec: + if not stock_geometries or workpiece is None: + return CropSpec( + tolerance=self._tolerance, + offset=self._offset, + regions=[], + ) + regions = self._resolve_regions(workpiece, stock_geometries) + return CropSpec( + tolerance=self._tolerance, + offset=self._offset, + regions=regions, + ) + + def _resolve_regions( + self, + workpiece: WorkPiece, + stock_geometries: list[Geometry], + ) -> list[list[tuple[float, float]]]: + world_to_local = workpiece.get_world_transform().invert() + regions: list[list[tuple[float, float]]] = [] + + wp_size = workpiece.size + scale_x, scale_y = wp_size if wp_size else (1.0, 1.0) + + for stock_geo in stock_geometries: + if self._offset != 0.0: + stock_geo = stock_geo.grow(self._offset) + local_geo = stock_geo.transform(world_to_local) + if wp_size: + scale_matrix = Matrix.scale(scale_x, scale_y) + local_geo = local_geo.transform(scale_matrix) + polygons = local_geo.to_polygons(self._tolerance) + for p in polygons: + if len(p) >= 3: + regions.append(p) + return regions + + def to_dict(self) -> dict[str, Any]: + return { + **super().to_dict(), + "tolerance": self.tolerance, + "offset": self.offset, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CropTransformer: + return cls( + enabled=data.get("enabled", True), + tolerance=data.get("tolerance", 0.03), + offset=data.get("offset", 0.0), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/lead_in_out_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/lead_in_out_transformer.py new file mode 100644 index 000000000..c664ddb9b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/lead_in_out_transformer.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.ops.transform.lead_in_out import LeadInOutSpec + +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + from rayforge.core.workpiece import WorkPiece + +logger = logging.getLogger(__name__) + + +class LeadInOutTransformer(OpsTransformer): + """ + Adds zero-power lead-in and lead-out moves to vector contour paths. + + For each contour within a VECTOR_OUTLINE section, this transformer + computes the tangent direction at the start and end of the path using + the geometry module, then extends the toolpath with lead-in (before + the cut starts) and lead-out (after the cut ends) segments at zero + laser power. This allows the laser head to reach constant velocity + before the actual cut begins and to decelerate after the cut ends, + improving cut quality at start/end points. + """ + + SPEC_NAME = "lead_in_out" + + def __init__( + self, + enabled: bool = True, + lead_in_mm: float = 2.0, + lead_out_mm: float = 2.0, + auto: bool = True, + ): + super().__init__(enabled=enabled) + self._lead_in_mm: float = 0.0 + self.lead_in_mm = lead_in_mm + self._lead_out_mm: float = 0.0 + self.lead_out_mm = lead_out_mm + self._auto: bool = auto + + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: + """ + Calculate the optimal lead-in/out distance based on step speed + and machine acceleration with a safety factor of 2. + + Formula: distance = (speed^2) / (2 * acceleration * safety_factor) + Where safety_factor = 2 for additional safety margin. + + Args: + step_speed: The cutting speed in mm/min + max_acceleration: The maximum machine acceleration in mm/s^2 + + Returns: + The calculated distance in millimeters + """ + speed_mm_per_sec = step_speed / 60.0 + safety_factor = 2.0 + distance_mm = (speed_mm_per_sec**2) / ( + 2 * max_acceleration * safety_factor + ) + return distance_mm + + @property + def lead_in_mm(self) -> float: + return self._lead_in_mm + + @lead_in_mm.setter + def lead_in_mm(self, value: float): + new_value = max(0.0, float(value)) + if not math.isclose(self._lead_in_mm, new_value): + self._lead_in_mm = new_value + self.changed.send(self) + + @property + def lead_out_mm(self) -> float: + return self._lead_out_mm + + @lead_out_mm.setter + def lead_out_mm(self, value: float): + new_value = max(0.0, float(value)) + if not math.isclose(self._lead_out_mm, new_value): + self._lead_out_mm = new_value + self.changed.send(self) + + @property + def auto(self) -> bool: + return self._auto + + @auto.setter + def auto(self, value: bool): + if self._auto != bool(value): + self._auto = bool(value) + self.changed.send(self) + + @property + def label(self) -> str: + return _("Lead-In/Out") + + @property + def description(self) -> str: + return _( + "Adds zero-power lead-in and lead-out moves to vector contours." + ) + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict[str, Any] | None, + ) -> LeadInOutSpec: + return LeadInOutSpec( + lead_in_mm=self.lead_in_mm, lead_out_mm=self.lead_out_mm + ) + + def to_dict(self) -> dict[str, Any]: + return { + **super().to_dict(), + "lead_in_mm": self.lead_in_mm, + "lead_out_mm": self.lead_out_mm, + "auto": self.auto, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> LeadInOutTransformer: + return cls( + enabled=data.get("enabled", True), + lead_in_mm=data.get("lead_in_mm", 2.0), + lead_out_mm=data.get("lead_out_mm", 2.0), + auto=data.get("auto", True), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/merge_lines_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/merge_lines_transformer.py new file mode 100644 index 000000000..c54e90aa1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/merge_lines_transformer.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Sequence +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, +) + +from raygeo.ops.transform.merge_lines import MergeLinesSpec + +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + +class MergeLinesTransformer(OpsTransformer): + """ + Merges overlapping/collinear line segments across all paths. + + This transformer detects line segments that are collinear and overlapping + (typically from adjacent workpieces sharing an edge) and replaces the + covered sub-segments with travel moves to avoid cutting the same line + twice. + + The transformer should run before optimization and MultiPassTransformer. + """ + + SPEC_NAME = "merge_lines" + DEFAULT_TOLERANCE = 0.01 + + def __init__( + self, enabled: bool = True, tolerance: float = DEFAULT_TOLERANCE + ): + super().__init__(enabled=enabled) + self._tolerance = tolerance + + @property + def tolerance(self) -> float: + return self._tolerance + + @tolerance.setter + def tolerance(self, value: float) -> None: + self._tolerance = max(0.001, value) + self.changed.send(self) + + @property + def label(self) -> str: + return _("Merge Lines") + + @property + def description(self) -> str: + return _("Merges overlapping lines to avoid double passing.") + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: Sequence[Geometry] | None, + settings: dict[str, Any] | None, + ) -> MergeLinesSpec: + return MergeLinesSpec(tolerance=self._tolerance) + + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() + data["tolerance"] = self._tolerance + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MergeLinesTransformer: + 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), + tolerance=data.get("tolerance", cls.DEFAULT_TOLERANCE), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/multipass_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/multipass_transformer.py new file mode 100644 index 000000000..fd09ef80c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/multipass_transformer.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.ops.transform.multipass import MultiPassSpec + +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + +class MultiPassTransformer(OpsTransformer): + """ + Repeats the sequence of operations multiple times. + + This transformer is typically used in the "post-assembly" phase of a Step + to create multiple cutting or engraving passes over the entire assembled + geometry. It can also apply a Z-axis step-down for each subsequent pass, + which is useful for cutting through thick materials. + """ + + SPEC_NAME = "multipass" + + def __init__( + self, enabled: bool = True, passes: int = 1, z_step_down: float = 0.0 + ): + """ + Initializes the MultiPassTransformer. + + Args: + enabled: Whether the transformer is active. + passes: The total number of passes to perform. Must be >= 1. + z_step_down: The distance to move down the Z-axis after each + pass. A positive value indicates downward movement. + """ + super().__init__(enabled=enabled) + self._passes: int = 1 + self._z_step_down: float = 0.0 + + # Use property setters to ensure validation logic is applied + self.passes = passes + self.z_step_down = z_step_down + + @property + def passes(self) -> int: + """The total number of passes to perform (e.g., 3 means 3 total).""" + return self._passes + + @passes.setter + def passes(self, value: int): + """Sets the total number of passes, ensuring it's at least 1.""" + new_value = max(1, int(value)) + if self._passes != new_value: + self._passes = new_value + self.changed.send(self) + + @property + def z_step_down(self) -> float: + """The amount to step down in Z for each pass after the first.""" + return self._z_step_down + + @z_step_down.setter + def z_step_down(self, value: float): + """Sets the Z step-down value.""" + new_value = float(value) + if not math.isclose(self._z_step_down, new_value): + self._z_step_down = new_value + self.changed.send(self) + + @property + def label(self) -> str: + return _("Multi-Pass") + + @property + def description(self) -> str: + return _( + "Repeats the path multiple times, optionally stepping down in Z." + ) + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict[str, Any] | None, + ) -> MultiPassSpec: + return MultiPassSpec(passes=self.passes, z_step_down=self.z_step_down) + + def to_dict(self) -> dict[str, Any]: + """Serializes the transformer's configuration to a dictionary.""" + return { + **super().to_dict(), + "passes": self.passes, + "z_step_down": self.z_step_down, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MultiPassTransformer: + """Creates a MultiPassTransformer instance from a dictionary.""" + 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), + passes=data.get("passes", 1), + z_step_down=data.get("z_step_down", 0.0), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/optimize_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/optimize_transformer.py new file mode 100644 index 000000000..ca4b298a6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/optimize_transformer.py @@ -0,0 +1,80 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.ops.transform.optimize import OptimizeSpec + +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + +logger = logging.getLogger(__name__) + + +class Optimize(OpsTransformer): + """ + Optimizes toolpaths to minimize travel distance. + + Delegates to the Rust-based ``Ops.optimize_travel()`` which performs: + 1. Workpiece-level reordering (when multiple workpieces are present). + 2. Segment-level k-d tree nearest-neighbor + 2-opt refinement. + """ + + SPEC_NAME = "optimize" + + def __init__( + self, + enabled: bool = True, + allow_flip: bool = True, + preserve_first: bool = False, + preserve_order: list[str] | None = None, + **kwargs, + ): + super().__init__(enabled=enabled, **kwargs) + self.allow_flip = allow_flip + self.preserve_first = preserve_first + self.preserve_order = preserve_order or [] + + @property + def label(self) -> str: + return _("Optimize Path") + + @property + def description(self) -> str: + return _("Minimizes travel distance by reordering segments.") + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list["Geometry"] | None, + settings: dict[str, Any] | None, + ) -> OptimizeSpec: + return OptimizeSpec( + allow_flip=self.allow_flip, + preserve_first=self.preserve_first, + preserve_order=list(self.preserve_order), + ) + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result["allow_flip"] = self.allow_flip + result["preserve_first"] = self.preserve_first + result["preserve_order"] = self.preserve_order + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Optimize": + 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), + allow_flip=data.get("allow_flip", True), + preserve_first=data.get("preserve_first", False), + preserve_order=data.get("preserve_order", []), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/overscan_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/overscan_transformer.py new file mode 100644 index 000000000..aa01fb08d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/overscan_transformer.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.ops.transform.overscan import OverscanSpec + +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + from rayforge.core.workpiece import WorkPiece + +logger = logging.getLogger(__name__) + + +class OverscanTransformer(OpsTransformer): + """ + Intelligently rewrites raster line patterns to include overscan for + machine acceleration and deceleration, ensuring constant engraving + velocity. + + This transformer operates only on commands within a `RASTER_FILL` section. + It identifies a raster line (a MoveTo followed by cutting commands) and + replaces it with a physically correct toolpath that includes lead-in and + lead-out moves at zero power. + """ + + SPEC_NAME = "overscan" + + def __init__( + self, enabled: bool = True, distance_mm: float = 2.0, auto: bool = True + ): + super().__init__(enabled=enabled) + self._distance_mm: float = 0.0 + self.distance_mm = distance_mm + self._auto: bool = auto + + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: + """ + Calculate the optimal overscan distance based on step speed and machine + acceleration with a safety factor of 2. + + Formula: distance = (speed²) / (2 * acceleration * safety_factor) + Where safety_factor = 2 for additional safety margin + + Args: + step_speed: The cutting speed in mm/min + max_acceleration: The maximum machine acceleration in mm/s² + + Returns: + The calculated overscan distance in millimeters + """ + # Convert speed from mm/min to mm/s for the calculation + speed_mm_per_sec = step_speed / 60.0 + + # Safety factor of 2 as specified in requirements + safety_factor = 2.0 + + # Calculate distance using physics formula with safety factor + # d = v² / (2 * a * safety_factor) + distance_mm = (speed_mm_per_sec**2) / ( + 2 * max_acceleration * safety_factor + ) + + return distance_mm + + @property + def distance_mm(self) -> float: + return self._distance_mm + + @distance_mm.setter + def distance_mm(self, value: float): + new_value = max(0.0, float(value)) + if not math.isclose(self._distance_mm, new_value): + self._distance_mm = new_value + self.changed.send(self) + + @property + def auto(self) -> bool: + return self._auto + + @auto.setter + def auto(self, value: bool): + if self._auto != bool(value): + self._auto = bool(value) + self.changed.send(self) + + @property + def label(self) -> str: + return _("Overscan") + + @property + def description(self) -> str: + return _("Extends raster lines to ensure constant engraving speed.") + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict[str, Any] | None, + ) -> OverscanSpec: + if settings and settings.get("driver_native_overscan"): + return OverscanSpec(distance_mm=0.0) + return OverscanSpec(distance_mm=self.distance_mm) + + def to_dict(self) -> dict[str, Any]: + return { + **super().to_dict(), + "distance_mm": self.distance_mm, + "auto": self.auto, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OverscanTransformer: + return cls( + enabled=data.get("enabled", True), + distance_mm=data.get("distance_mm", 2.0), + auto=data.get("auto", True), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/smooth_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/smooth_transformer.py new file mode 100644 index 000000000..d5b812764 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/smooth_transformer.py @@ -0,0 +1,119 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.ops.transform.smooth import SmoothSpec + +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + +class Smooth(OpsTransformer): + """Smooths path segments using a Gaussian filter. + + This transformer uses a multi-stage "divide and conquer" algorithm: + + 1. **Dynamic Subdivision:** It resamples the path into a high-density + set of points. The density is proportional to the smoothing + 'amount', ensuring perfect curves even for tiny radii. + 2. **Anchor Detection:** It identifies all "anchor" points—endpoints + and sharp corners—that must be preserved. + 3. **Split & Smooth:** The path is split into independent sub-segments + between these anchors. Each sub-segment is smoothed in isolation, + preventing smoothing from "bleeding" across sharp corners. + 4. **Reassembly:** The smoothed sub-segments are reassembled into the + final, high-quality path. + """ + + SPEC_NAME = "smooth" + + def __init__( + self, enabled: bool = True, amount=20, corner_angle_threshold=45 + ): + """Initializes the smoothing filter. + + Args: + enabled: Whether the transformer is active. + amount: The smoothing strength (0-100) controlling the curve + radius. + corner_angle_threshold: Corners with an internal angle (in + degrees) smaller than this are + preserved. + """ + super().__init__(enabled=enabled) + self._corner_angle_threshold = corner_angle_threshold + self._amount = -1 + self.amount = amount + + @property + def amount(self) -> int: + """The smoothing strength, from 0 (none) to 100 (heavy).""" + return self._amount + + @amount.setter + def amount(self, value: int) -> None: + """Updates the smoothing amount.""" + new_amount = max(0, min(100, value)) + if self._amount == new_amount: + return + self._amount = new_amount + self.changed.send(self) + + @property + def corner_angle_threshold(self) -> float: + """The corner angle threshold in degrees.""" + return self._corner_angle_threshold + + @corner_angle_threshold.setter + def corner_angle_threshold(self, value_deg: float): + """Sets the corner angle threshold from a value in degrees.""" + if self._corner_angle_threshold == value_deg: + return + self._corner_angle_threshold = value_deg + self.changed.send(self) + + @property + def label(self) -> str: + return _("Smooth Path") + + @property + def description(self) -> str: + return _("Smooths the path by applying a Gaussian filter.") + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list["Geometry"] | None, + settings: dict[str, Any] | None, + ) -> SmoothSpec: + return SmoothSpec( + amount=self.amount, + corner_angle_threshold=self.corner_angle_threshold, + ) + + def to_dict(self) -> dict[str, Any]: + """Serializes the transformer's configuration to a dictionary.""" + data = super().to_dict() + data.update( + { + "amount": self.amount, + "corner_angle_threshold": self.corner_angle_threshold, + } + ) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Smooth": + """Creates a Smooth instance from a dictionary.""" + 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), + amount=data.get("amount", 20), + corner_angle_threshold=data.get("corner_angle_threshold", 45), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/tabs_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/tabs_transformer.py new file mode 100644 index 000000000..897bd723e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/tabs_transformer.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + NamedTuple, +) + +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 + +logger = logging.getLogger(__name__) + + +class _ClipPoint(NamedTuple): + x: float + y: float + width: float + + +class TabOpsTransformer(OpsTransformer): + """ + Creates gaps in toolpaths by finding the closest point on the path for + each tab and creating a precise cut. This is robust against prior ops + transformations and avoids clipping unrelated paths that may be nearby. + """ + + SPEC_NAME = "tabs" + + def __init__(self, enabled: bool = True): + super().__init__(enabled=enabled) + + @property + def label(self) -> str: + return _("Tabs") + + @property + def description(self) -> str: + return _( + "Creates holding tabs by adding gaps or reducing power " + "on cut paths" + ) + + def _generate_tab_clip_data( + self, workpiece: WorkPiece + ) -> list[_ClipPoint]: + if not workpiece.boundaries or workpiece.boundaries.is_empty(): + logger.debug( + "TabOps: workpiece has no vectors, cannot generate clip data." + ) + return [] + + clip_data: list[_ClipPoint] = [] + vectors = workpiece.boundaries + + logger.debug( + "TabOps: Generating clip data in LOCAL space for workpiece " + f"'{workpiece.name}'" + ) + logger.debug(f"TabOps: Workpiece vectors bbox: {vectors.rect()}") + + for tab in workpiece.tabs: + if tab.segment_index >= len(vectors): + logger.warning( + f"Tab {tab.uid} has invalid segment_index " + f"{tab.segment_index}, skipping." + ) + continue + + cmd = vectors.get_typed_command_at(tab.segment_index) + if cmd is None: + logger.warning( + f"Tab {tab.uid} has invalid segment_index " + f"{tab.segment_index}, skipping." + ) + continue + + from raygeo.geo import Move + + if isinstance(cmd, Move): + continue + + point = vectors.get_point_at(tab.segment_index, tab.pos) + if point is None: + logger.warning( + f"Tab {tab.uid}: could not evaluate point on " + f"segment {tab.segment_index} at t={tab.pos}, skipping." + ) + continue + + center_x, center_y = point[0], point[1] + + logger.debug( + f"Local space tab center (from normalized vectors): " + f"({center_x:.4f}, {center_y:.4f}), " + f"width: {tab.width:.2f}mm" + ) + clip_data.append(_ClipPoint(center_x, center_y, tab.width)) + + logger.debug(f"TabOps: Finished generating clip data: {clip_data}") + return clip_data + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict | None, + ) -> TabsSpec: + tab_power = settings.get("tab_power", 0.0) if settings else 0.0 + original_power = settings.get("power", 1.0) if settings else 1.0 + + if not workpiece or not workpiece.tabs_enabled or not workpiece.tabs: + return TabsSpec( + tab_power=tab_power, + original_power=original_power, + clips=[], + ) + + tab_clip_data = self._generate_tab_clip_data(workpiece) + if not tab_clip_data: + return TabsSpec( + tab_power=tab_power, + original_power=original_power, + clips=[], + ) + + processed_clip_data = tab_clip_data + final_w, final_h = workpiece.size + if final_w > 1e-6 and final_h > 1e-6: + processed_clip_data = [ + (cp.x * final_w, cp.y * final_h, cp.width) + for cp in tab_clip_data + ] + + return TabsSpec( + tab_power=tab_power, + original_power=original_power, + clips=processed_clip_data, + ) + + @classmethod + def from_dict(cls, data: dict) -> TabOpsTransformer: + 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)) 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 new file mode 100644 index 000000000..d8facaa9a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/__init__.py @@ -0,0 +1,30 @@ +""" +Settings group classes for transformers, used in post-processing settings. +""" + +from ..transformers import ( + CropTransformer, + LeadInOutTransformer, + MergeLinesTransformer, + MultiPassTransformer, + Optimize, + OverscanTransformer, + Smooth, +) +from .crop_group import CropSettingsGroup +from .lead_in_out_group import LeadInOutSettingsGroup +from .merge_lines_group import MergeLinesSettingsGroup +from .multipass_group import MultiPassSettingsGroup +from .optimize_group import OptimizeSettingsGroup +from .overscan_group import OverscanSettingsGroup +from .smooth_group import SmoothSettingsGroup + +TRANSFORMER_WIDGETS = { + CropTransformer: CropSettingsGroup, + LeadInOutTransformer: LeadInOutSettingsGroup, + MergeLinesTransformer: MergeLinesSettingsGroup, + MultiPassTransformer: MultiPassSettingsGroup, + Optimize: OptimizeSettingsGroup, + OverscanTransformer: OverscanSettingsGroup, + Smooth: SmoothSettingsGroup, +} diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/crop_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/crop_group.py new file mode 100644 index 000000000..92ba453d4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/crop_group.py @@ -0,0 +1,47 @@ +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 CropTransformer + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class CropSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the CropTransformer.""" + + def __init__( + self, + title: str, + transformer: CropTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + self.offset_row = LengthSpinRow( + _("Offset"), + _("Grow/shrink stock boundary before cropping"), + lower=-100.0, + upper=100.0, + value_in_base=transformer.offset, + ) + self.offset_row.value_changed.connect( + lambda r: self._debounce(self._on_offset_changed, r) + ) + self.add(self.offset_row) + + def _on_offset_changed(self, row: LengthSpinRow) -> None: + new_value = row.get_value_in_base_units() + self.param_changed.send( + self, key="offset", value=new_value, name=_("Change Crop Offset") + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/lead_in_out_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/lead_in_out_group.py new file mode 100644 index 000000000..5a9a637c6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/lead_in_out_group.py @@ -0,0 +1,171 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, GObject + +from rayforge.context import get_context +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 LeadInOutTransformer + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class LeadInOutSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the LeadInOutTransformer.""" + + def __init__( + self, + title: str, + transformer: LeadInOutTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + self._auto = transformer.auto + self._previous_cut_speed = step.cut_speed if step is not None else None + if step is not None: + step.updated.connect(self._on_step_updated) + + machine = get_context().machine + if machine: + machine.changed.connect(self._on_machine_changed) + + self.auto_row = Adw.SwitchRow( + title=_("Automatic Distance"), + subtitle=_( + "Calculate distance based on speed and acceleration " + "with safety factor" + ), + ) + self.auto_row.set_active(transformer.auto) + self.add(self.auto_row) + + self.lead_in_row = LengthSpinRow( + _("Lead-In Distance"), + _("Distance of zero-power move before cut starts"), + upper=50.0, + value_in_base=transformer.lead_in_mm, + ) + self.add(self.lead_in_row) + + self.lead_out_row = LengthSpinRow( + _("Lead-Out Distance"), + _("Distance of zero-power move after cut ends"), + upper=50.0, + value_in_base=transformer.lead_out_mm, + ) + self.add(self.lead_out_row) + + self.auto_row.connect("notify::active", self._on_auto_toggled) + self.auto_row.connect( + "notify::active", + lambda w, _: self._update_sensitivity(), + ) + self.lead_in_row.value_changed.connect( + lambda r: self._debounce(self._on_lead_in_changed, r), + ) + self.lead_out_row.value_changed.connect( + lambda r: self._debounce(self._on_lead_out_changed, r), + ) + + self._update_sensitivity() + + def _update_sensitivity(self) -> None: + enabled = self._is_enabled() + auto = self.auto_row.get_active() + + self.auto_row.set_sensitive(enabled) + self.lead_in_row.set_sensitive(enabled and not auto) + self.lead_out_row.set_sensitive(enabled and not auto) + + def _on_auto_toggled( + self, row: Adw.SwitchRow, _pspec: GObject.ParamSpec + ) -> None: + self._auto = row.get_active() + self.param_changed.send( + self, + key="auto", + value=self._auto, + name=_("Toggle Auto Lead-In/Out"), + ) + if self._auto: + self._recalculate_distance() + self._update_sensitivity() + + def _recalculate_distance(self) -> None: + machine = get_context().machine + if not machine or self.step is None: + return + + new_distance = LeadInOutTransformer.calculate_auto_distance( + self.step.cut_speed, machine.acceleration + ) + + self.param_changed.send( + self, + key="lead_in_mm", + value=new_distance, + name=_("Auto Calculate Lead-In/Out Distance"), + ) + self.param_changed.send( + self, + key="lead_out_mm", + value=new_distance, + name=_("Auto Calculate Lead-In/Out Distance"), + ) + + self.lead_in_row.set_value_in_base_units(new_distance) + self.lead_out_row.set_value_in_base_units(new_distance) + + def _on_step_updated(self, step: "Step") -> None: + if self._auto and step.cut_speed != self._previous_cut_speed: + self._previous_cut_speed = step.cut_speed + self._recalculate_distance() + + def _on_machine_changed(self, machine) -> None: + if self._auto: + self._recalculate_distance() + + def _on_lead_in_changed(self, spin_row: LengthSpinRow) -> None: + new_value = spin_row.get_value_in_base_units() + if self._auto: + self._auto = False + self.param_changed.send( + self, + key="auto", + value=False, + name=_("Disable Auto Lead-In/Out"), + ) + self.param_changed.send( + self, + key="lead_in_mm", + value=new_value, + name=_("Change Lead-In Distance"), + ) + + def _on_lead_out_changed(self, spin_row: LengthSpinRow) -> None: + new_value = spin_row.get_value_in_base_units() + if self._auto: + self._auto = False + self.param_changed.send( + self, + key="auto", + value=False, + name=_("Disable Auto Lead-In/Out"), + ) + self.param_changed.send( + self, + key="lead_out_mm", + value=new_value, + name=_("Change Lead-Out Distance"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/merge_lines_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/merge_lines_group.py new file mode 100644 index 000000000..37414220b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/merge_lines_group.py @@ -0,0 +1,50 @@ +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 MergeLinesTransformer + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class MergeLinesSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the MergeLinesTransformer.""" + + def __init__( + self, + title: str, + transformer: MergeLinesTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + self.tolerance_row = LengthSpinRow( + _("Tolerance"), + _("Maximum distance for lines to be considered overlapping"), + lower=0.01, + upper=10.0, + value_in_base=transformer.tolerance, + ) + self.tolerance_row.value_changed.connect( + lambda r: self._debounce(self._on_tolerance_changed, r) + ) + self.add(self.tolerance_row) + + def _on_tolerance_changed(self, row: LengthSpinRow) -> None: + new_value = row.get_value_in_base_units() + self.param_changed.send( + self, + key="tolerance", + value=new_value, + name=_("Change merge tolerance"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/multipass_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/multipass_group.py new file mode 100644 index 000000000..67f1fd218 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/multipass_group.py @@ -0,0 +1,90 @@ +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, SpinRow + +from ..transformers import MultiPassTransformer + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class MultiPassSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the MultiPassTransformer.""" + + def __init__( + self, + title: str, + transformer: MultiPassTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + # Passes setting + self.passes_row = SpinRow( + _("Number of Passes"), + _("How often to repeat the entire step"), + lower=1, + upper=100, + value=transformer.passes, + ) + self.add(self.passes_row) + + # Z Step-down setting + self.z_step_row = LengthSpinRow( + _("Z Step-Down per Pass"), + _("Distance to lower Z-axis for each subsequent pass"), + upper=50.0, + value_in_base=transformer.z_step_down, + ) + self.add(self.z_step_row) + self.z_step_row.value_changed.connect( + lambda r: self._debounce(self._on_z_step_down_changed, r) + ) + + # Connect signals with debouncing + self.passes_row.value_changed.connect( + lambda r: self._debounce( + self._on_passes_changed, r, self.z_step_row + ), + ) + + # Z Step-down is only available with multiple passes + if transformer.passes <= 1: + self.z_step_row.set_sensitive(False) + + def _update_sensitivity(self) -> None: + enabled = self._is_enabled() + self.passes_row.set_sensitive(enabled) + self.z_step_row.set_sensitive( + enabled and self.passes_row.get_value() > 1 + ) + + def _on_passes_changed( + self, spin_row: SpinRow, z_step_row: LengthSpinRow + ) -> None: + new_value = spin_row.get_int_value() + z_step_row.set_sensitive(new_value > 1) + self.param_changed.send( + self, + key="passes", + value=new_value, + name=_("Change number of passes"), + ) + + def _on_z_step_down_changed(self, row: LengthSpinRow) -> None: + new_value = row.get_value_in_base_units() + self.param_changed.send( + self, + key="z_step_down", + value=new_value, + name=_("Change Z Step-Down"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/optimize_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/optimize_group.py new file mode 100644 index 000000000..dcf6cbdde --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/optimize_group.py @@ -0,0 +1,67 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, GObject + +from rayforge.ui_gtk.doceditor.post_processor.groups import ( + ExpanderHost, + TransformerSettingsGroup, +) + +from ..transformers import Optimize + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class OptimizeSettingsGroup(TransformerSettingsGroup): + """UI for configuring the Optimize transformer.""" + + def __init__( + self, + title: str, + transformer: Optimize, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + self.flip_row = Adw.SwitchRow( + title=_("Allow Flipping"), + subtitle=_("Allow reversing path direction for shorter travel"), + ) + self.flip_row.set_active(transformer.allow_flip) + self.add(self.flip_row) + self.flip_row.connect("notify::active", self._on_flip_toggled) + + self.preserve_row = Adw.SwitchRow( + title=_("Preserve First Workpiece"), + subtitle=_("Keep the first workpiece at its original position"), + ) + self.preserve_row.set_active(transformer.preserve_first) + self.add(self.preserve_row) + self.preserve_row.connect( + "notify::active", self._on_preserve_first_toggled + ) + + def _on_flip_toggled( + self, row: Adw.SwitchRow, _pspec: GObject.ParamSpec + ) -> None: + self.param_changed.send( + self, + key="allow_flip", + value=row.get_active(), + name=_("Toggle Flipping"), + ) + + def _on_preserve_first_toggled( + self, row: Adw.SwitchRow, _pspec: GObject.ParamSpec + ) -> None: + self.param_changed.send( + self, + key="preserve_first", + value=row.get_active(), + name=_("Toggle Preserve First Workpiece"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/overscan_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/overscan_group.py new file mode 100644 index 000000000..f7e83bdfb --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/overscan_group.py @@ -0,0 +1,187 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, GObject + +from rayforge.context import get_context +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 OverscanTransformer + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class OverscanSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the OverscanTransformer.""" + + def __init__( + self, + title: str, + transformer: OverscanTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + self._auto = transformer.auto + self._previous_cut_speed = step.cut_speed if step is not None else None + if step is not None: + step.updated.connect(self._on_step_updated) + + machine = get_context().machine + if machine: + machine.changed.connect(self._on_machine_changed) + + # Banner shown when the driver applies overscan itself. + self.native_banner = Adw.Banner( + title=_( + "This machine adds overscan automatically; the setting " + "has no effect." + ) + ) + super().add(self.native_banner) + + # Auto mode toggle + self.auto_row = Adw.SwitchRow( + title=_("Automatic Distance"), + subtitle=_( + "Calculate distance based on speed and acceleration with " + "safety factor" + ), + ) + self.auto_row.set_active(transformer.auto) + self.add(self.auto_row) + + # Distance setting with unit support + distance_row = LengthSpinRow( + _("Overscan Distance"), + _("Manual distance setting"), + upper=50.0, + value_in_base=transformer.distance_mm, + ) + self.add(distance_row) + self.distance_row = distance_row # Store reference for later access + + # Connect signals + self.auto_row.connect("notify::active", self._on_auto_toggled) + distance_row.value_changed.connect( + lambda r: self._debounce(self._on_distance_changed, r), + ) + + self.auto_row.connect( + "notify::active", + lambda w, _: self._update_sensitivity(), + ) + + self._update_sensitivity() + + def _is_native_overscan(self) -> bool: + """Whether the active machine's driver applies overscan itself.""" + machine = get_context().machine + return bool(machine and machine.driver.native_overscan) + + def is_unsupported(self) -> bool: + """Enabled overscan that the driver handles itself.""" + if self.enable_switch is None or not self.enable_switch.get_active(): + return False + return self._is_native_overscan() + + def _update_sensitivity(self) -> None: + """Update the sensitivity of UI elements based on current state.""" + enabled = self._is_enabled() + auto = self.auto_row.get_active() + + native = self._is_native_overscan() + self.native_banner.set_revealed(native) + + # Use the stored references to the rows + if self.enable_switch is not None: + self.enable_switch.set_sensitive(not native) + self.auto_row.set_sensitive(enabled and not native) + self.distance_row.set_sensitive(enabled and not auto and not native) + + def _on_auto_toggled( + self, row: Adw.SwitchRow, _pspec: GObject.ParamSpec + ) -> None: + self._auto = row.get_active() + self.param_changed.send( + self, + key="auto", + value=self._auto, + name=_("Toggle Auto Overscan"), + ) + + # If auto is enabled, recalculate the distance + if self._auto: + self._recalculate_distance() + + self._update_sensitivity() + + def _recalculate_distance(self) -> None: + """Recalculate the overscan distance based on current step settings.""" + machine = get_context().machine + if not machine or self.step is None: + return + + # Calculate new distance + new_distance = OverscanTransformer.calculate_auto_distance( + self.step.cut_speed, machine.acceleration + ) + + # Update the distance + self.param_changed.send( + self, + key="distance_mm", + value=new_distance, + name=_("Auto Calculate Overscan Distance"), + ) + + # Update the UI + self.distance_row.set_value_in_base_units(new_distance) + + def _on_step_updated(self, step: "Step") -> None: + """Handle step updates to recalculate overscan distance if needed.""" + if self._auto and step.cut_speed != self._previous_cut_speed: + self._previous_cut_speed = step.cut_speed + self._recalculate_distance() + + def _on_machine_changed(self, machine) -> None: + """ + Handle machine updates (e.g. acceleration) to recalculate overscan. + """ + if self._is_native_overscan(): + self._update_sensitivity() + return + self._update_sensitivity() + if self._auto: + self._recalculate_distance() + + def _on_distance_changed(self, spin_row: LengthSpinRow) -> None: + # Get the value in base units directly from the row + new_value = self.distance_row.get_value_in_base_units() + + # If auto is currently enabled, disable it when user manually changes + # the distance (via +/- buttons or typing) + if self._auto: + self._auto = False + self.param_changed.send( + self, + key="auto", + value=False, + name=_("Disable Auto Overscan"), + ) + + self.param_changed.send( + self, + key="distance_mm", + value=new_value, + name=_("Change Overscan Distance"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/smooth_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/smooth_group.py new file mode 100644 index 000000000..298260cff --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/smooth_group.py @@ -0,0 +1,78 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Gtk + +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 AngleSpinRow +from rayforge.ui_gtk.shared.slider import create_slider_row + +from ..transformers import Smooth + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class SmoothSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the Smooth transformer.""" + + def __init__( + self, + title: str, + transformer: Smooth, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + amount_adj = Gtk.Adjustment( + lower=0, upper=100, step_increment=1, page_increment=10 + ) + amount_adj.set_value(transformer.amount) + amount_row, _amount_scale = create_slider_row( + title=_("Smoothness"), + subtitle=_("Higher values produce smoother curves"), + adjustment=amount_adj, + digits=0, + on_value_changed=lambda s: self._debounce( + self._on_amount_changed, s + ), + ) + self.add(amount_row) + + # Corner Angle Threshold Setting + corner_row = AngleSpinRow( + _("Corner Angle Threshold"), + _("Angles sharper than this are kept as corners (degrees)"), + lower=0, + upper=179, + value=transformer.corner_angle_threshold, + ) + self.add(corner_row) + + corner_row.value_changed.connect( + lambda spin_row: self._debounce( + self._on_corner_angle_changed, spin_row + ) + ) + + def _on_amount_changed(self, scale: Gtk.Scale) -> None: + new_value = int(scale.get_value()) + self.param_changed.send( + self, key="amount", value=new_value, name=_("Change smoothness") + ) + + def _on_corner_angle_changed(self, spin_row: AngleSpinRow) -> None: + new_value = spin_row.get_int_value() + self.param_changed.send( + self, + key="corner_angle_threshold", + value=new_value, + name=_("Change corner angle"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py new file mode 100644 index 000000000..691d01aab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py @@ -0,0 +1,37 @@ +""" +Backend entry point for post_processors addon. + +Registers post-processing transformers with the main application. +""" + +from rayforge.core.hooks import hookimpl + +from .transformers import ( + BidirScanOffsetTransformer, + CropTransformer, + LeadInOutTransformer, + MergeLinesTransformer, + MultiPassTransformer, + Optimize, + OverscanTransformer, + Smooth, + TabOpsTransformer, +) + +ADDON_NAME = "post_processors" + + +@hookimpl +def register_transformers(transformer_registry): + """Register transformers with the transformer registry.""" + transformer_registry.register( + BidirScanOffsetTransformer, addon_name=ADDON_NAME + ) + transformer_registry.register(CropTransformer, addon_name=ADDON_NAME) + transformer_registry.register(LeadInOutTransformer, addon_name=ADDON_NAME) + transformer_registry.register(MergeLinesTransformer, addon_name=ADDON_NAME) + 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(Smooth, addon_name=ADDON_NAME) + transformer_registry.register(TabOpsTransformer, addon_name=ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-post/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-post/rayforge-addon.yaml new file mode 100644 index 000000000..9177a6baf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/rayforge-addon.yaml @@ -0,0 +1,12 @@ +name: post_processors +display_name: "Post Processors" +description: "Core Post-processing transformers for toolpath optimization" +api_version: 19 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "post_processors.worker" + frontend: "post_processors.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-post/tests/conftest.py new file mode 100644 index 000000000..f0c54c018 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/conftest.py @@ -0,0 +1,90 @@ +""" +Pytest configuration for post_processors builtin addon tests. +""" + +import pytest + + +@pytest.fixture +def mock_progress_context(): + """ + Provides a mock ProgressContext for testing compute functions. + """ + + class _SimpleMockProgressContext: + def __init__(self): + self.progress_calls: list[tuple[float, str]] = [] + self.message_calls: list[str] = [] + self._is_cancelled = False + self._total = 1.0 + self._sub_contexts: list[_SimpleMockProgressContext] = [] + + def is_cancelled(self) -> bool: + return self._is_cancelled + + def set_progress(self, progress: float) -> None: + normalized = ( + progress / self._total if self._total > 0 else progress + ) + self.progress_calls.append((normalized, "")) + + def set_message(self, message: str) -> None: + self.message_calls.append(message) + + def set_total(self, total: float) -> None: + if total <= 0: + self._total = 1.0 + else: + self._total = float(total) + + def sub_context( + self, + base_progress: float, + progress_range: float, + total: float = 1.0, + ) -> "_SimpleMockProgressContext": + sub_ctx = _SimpleMockProgressContext() + sub_ctx._total = total + self._sub_contexts.append(sub_ctx) + return sub_ctx + + def flush(self) -> None: + pass + + return _SimpleMockProgressContext() + + +@pytest.fixture(scope="session", autouse=True) +def register_post_processors(): + """ + Automatically register post_processors transformers for all tests. + """ + # Import and register transformers directly from the addon + from post_processors.transformers import ( + BidirScanOffsetTransformer, + CropTransformer, + LeadInOutTransformer, + MergeLinesTransformer, + MultiPassTransformer, + Optimize, + OverscanTransformer, + Smooth, + TabOpsTransformer, + ) + + from rayforge.pipeline.transformer.registry import transformer_registry + + ADDON_NAME = "post_processors" + transformer_registry.register(Smooth, addon_name=ADDON_NAME) + transformer_registry.register(Optimize, addon_name=ADDON_NAME) + transformer_registry.register(MergeLinesTransformer, addon_name=ADDON_NAME) + transformer_registry.register(OverscanTransformer, addon_name=ADDON_NAME) + transformer_registry.register(LeadInOutTransformer, addon_name=ADDON_NAME) + transformer_registry.register(MultiPassTransformer, addon_name=ADDON_NAME) + transformer_registry.register(CropTransformer, addon_name=ADDON_NAME) + transformer_registry.register(TabOpsTransformer, addon_name=ADDON_NAME) + transformer_registry.register( + BidirScanOffsetTransformer, addon_name=ADDON_NAME + ) + + yield 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 new file mode 100644 index 000000000..6169dc447 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_bidir_scan_offset_transformer.py @@ -0,0 +1,125 @@ +import pytest +from post_processors.transformers import BidirScanOffsetTransformer +from raygeo.ops import Ops +from raygeo.ops.types import CommandType + + +def _apply(transformer, ops, settings=None): + """Run a transformer through the Rust spec dispatch.""" + if not transformer.enabled: + return + specs = [transformer.to_spec(None, None, settings)] + Ops.apply_transformers(ops, specs, progress_cb=None) + + +@pytest.fixture +def transformer() -> BidirScanOffsetTransformer: + """Provides a default, enabled BidirScanOffsetTransformer instance.""" + return BidirScanOffsetTransformer(enabled=True) + + +def _build_zigzag() -> Ops: + """Three-row zigzag raster: row0 LTR, row1 RTL, row2 LTR.""" + ops = Ops() + ops.move_to(0.0, 0.6, 0.0) + ops.scan_to(2.0, 0.6, 0.0, power_values=[200, 200, 200, 200]) + ops.move_to(2.0, 0.5, 0.0) + ops.scan_to(0.0, 0.5, 0.0, power_values=[210, 210, 210, 210]) + ops.move_to(0.0, 0.4, 0.0) + ops.scan_to(2.0, 0.4, 0.0, power_values=[220, 220, 220, 220]) + return ops + + +def test_serialization_and_deserialization(): + original = BidirScanOffsetTransformer(enabled=False) + data = original.to_dict() + recreated = BidirScanOffsetTransformer.from_dict(data) + assert data["name"] == "BidirScanOffsetTransformer" + assert data["enabled"] is False + assert isinstance(recreated, BidirScanOffsetTransformer) + assert recreated.enabled is False + + +def test_no_op_when_disabled(): + ops = _build_zigzag() + original = [ops.endpoint(i) for i in range(ops.len())] + + transformer = BidirScanOffsetTransformer(enabled=False) + _apply(transformer, ops, settings={"bidir_x_offset_mm": 0.3}) + + assert [ops.endpoint(i) for i in range(ops.len())] == original + + +def test_no_op_with_zero_offset(transformer: BidirScanOffsetTransformer): + ops = _build_zigzag() + original = [ops.endpoint(i) for i in range(ops.len())] + + _apply(transformer, ops, settings={"bidir_x_offset_mm": 0.0}) + + assert [ops.endpoint(i) for i in range(ops.len())] == original + + +def test_no_op_without_settings(transformer: BidirScanOffsetTransformer): + ops = _build_zigzag() + original = [ops.endpoint(i) for i in range(ops.len())] + + _apply(transformer, ops, settings=None) + + assert [ops.endpoint(i) for i in range(ops.len())] == original + + +def test_shifts_only_right_to_left_passes( + transformer: BidirScanOffsetTransformer, +): + ops = _build_zigzag() + + _apply(transformer, ops, settings={"bidir_x_offset_mm": 0.3}) + + assert ops.len() == 6 + # Row 0 (LTR): untouched. + assert ops.command_type(0) == CommandType.MOVE_TO + assert ops.endpoint(0) == pytest.approx((0.0, 0.6, 0.0)) + assert ops.command_type(1) == CommandType.SCAN_LINE + assert ops.endpoint(1) == pytest.approx((2.0, 0.6, 0.0)) + assert list(ops.scanline_data(1)) == [200, 200, 200, 200] + # Row 1 (RTL): entry MoveTo and ScanLine endpoint both shifted by +0.3. + assert ops.command_type(2) == CommandType.MOVE_TO + assert ops.endpoint(2) == pytest.approx((2.3, 0.5, 0.0)) + assert ops.command_type(3) == CommandType.SCAN_LINE + assert ops.endpoint(3) == pytest.approx((0.3, 0.5, 0.0)) + assert list(ops.scanline_data(3)) == [210, 210, 210, 210] + # Row 2 (LTR): untouched, including the absolute MoveTo into it. + assert ops.command_type(4) == CommandType.MOVE_TO + assert ops.endpoint(4) == pytest.approx((0.0, 0.4, 0.0)) + assert ops.command_type(5) == CommandType.SCAN_LINE + assert ops.endpoint(5) == pytest.approx((2.0, 0.4, 0.0)) + assert list(ops.scanline_data(5)) == [220, 220, 220, 220] + + +def test_negative_offset_shifts_left(transformer: BidirScanOffsetTransformer): + ops = _build_zigzag() + + _apply(transformer, ops, settings={"bidir_x_offset_mm": -0.5}) + + assert ops.endpoint(2) == pytest.approx((1.5, 0.5, 0.0)) + assert ops.endpoint(3) == pytest.approx((-0.5, 0.5, 0.0)) + + +def test_preserves_intermediate_state_commands( + transformer: BidirScanOffsetTransformer, +): + """A SetPower between the entry MoveTo and the ScanLine must survive.""" + ops = Ops() + ops.move_to(2.0, 0.5, 0.0) + ops.set_power(0.5) + ops.scan_to(0.0, 0.5, 0.0, power_values=[210, 210, 210, 210]) + + _apply(transformer, ops, settings={"bidir_x_offset_mm": 0.3}) + + assert ops.len() == 3 + assert ops.command_type(0) == CommandType.MOVE_TO + assert ops.endpoint(0) == pytest.approx((2.3, 0.5, 0.0)) + assert ops.command_type(1) == CommandType.SET_POWER + 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)) diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_crop_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_crop_transformer.py new file mode 100644 index 000000000..866ac3356 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_crop_transformer.py @@ -0,0 +1,621 @@ +from unittest.mock import MagicMock, Mock + +import pytest +from post_processors.transformers import CropTransformer +from raygeo.geo import Geometry, Matrix +from raygeo.ops import Ops +from raygeo.ops.types import CommandCategory, CommandType + +from rayforge.core.workpiece import WorkPiece + + +def _apply(transformer, ops, workpiece=None, stock_geometries=None): + """Run a transformer through the Rust spec dispatch.""" + if not transformer.enabled: + return + specs = [transformer.to_spec(workpiece, stock_geometries, None)] + Ops.apply_transformers(ops, specs, progress_cb=None) + + +@pytest.fixture +def transformer() -> CropTransformer: + return CropTransformer(enabled=True, tolerance=0.03, offset=0.0) + + +@pytest.fixture +def mock_workpiece(): + wp = MagicMock(spec=WorkPiece) + wp.get_world_transform.return_value = Matrix.identity() + wp.size = (1.0, 1.0) + return wp + + +def create_rect_geometry(x, y, width, height): + geo = Geometry() + geo.move_to(x, y) + geo.line_to(x + width, y) + geo.line_to(x + width, y + height) + geo.line_to(x, y + height) + geo.close_path() + return geo + + +class TestCropTransformerInit: + def test_default_initialization(self): + t = CropTransformer() + assert t.enabled is True + assert t.tolerance == 0.03 + assert t.offset == 0.0 + + def test_custom_initialization(self): + t = CropTransformer(enabled=False, tolerance=0.1, offset=5.0) + assert t.enabled is False + assert t.tolerance == 0.1 + assert t.offset == 5.0 + + +class TestCropTransformerProperties: + def test_position_sensitive(self): + assert CropTransformer.POSITION_SENSITIVE is True + + def test_label(self, transformer): + assert transformer.label == "Crop to Stock" + + def test_description(self, transformer): + assert "crop" in transformer.description.lower() + + def test_tolerance_property_setter_triggers_signal(self, transformer): + transformer.changed = Mock() + transformer.tolerance = 0.5 + assert transformer.tolerance == 0.5 + transformer.changed.send.assert_called_once_with(transformer) + + def test_tolerance_property_no_signal_if_same_value(self, transformer): + transformer.changed = Mock() + original = transformer.tolerance + transformer.tolerance = original + transformer.changed.send.assert_not_called() + + def test_offset_property_setter_triggers_signal(self, transformer): + transformer.changed = Mock() + transformer.offset = 2.5 + assert transformer.offset == 2.5 + transformer.changed.send.assert_called_once_with(transformer) + + def test_offset_property_no_signal_if_same_value(self, transformer): + transformer.changed = Mock() + original = transformer.offset + transformer.offset = original + transformer.changed.send.assert_not_called() + + +class TestCropTransformerSerialization: + def test_to_dict(self, transformer): + data = transformer.to_dict() + assert data["name"] == "CropTransformer" + assert data["enabled"] is True + assert data["tolerance"] == 0.03 + assert data["offset"] == 0.0 + + def test_from_dict(self): + data = { + "name": "CropTransformer", + "enabled": False, + "tolerance": 0.15, + "offset": 3.0, + } + t = CropTransformer.from_dict(data) + assert isinstance(t, CropTransformer) + assert t.enabled is False + assert t.tolerance == 0.15 + assert t.offset == 3.0 + + def test_from_dict_defaults(self): + data = {"name": "CropTransformer"} + t = CropTransformer.from_dict(data) + assert t.enabled is True + assert t.tolerance == 0.03 + assert t.offset == 0.0 + + +class TestCropTransformerNoOp: + def test_no_op_when_disabled(self, transformer, mock_workpiece): + ops = Ops() + ops.move_to(0, 0) + ops.line_to(200, 0) + original_len = ops.len() + + transformer.enabled = False + stock_geo = create_rect_geometry(0, 0, 100, 100) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + assert ops.len() == original_len + + def test_no_op_when_no_stock_geometries(self, transformer, mock_workpiece): + ops = Ops() + ops.move_to(0, 0) + ops.line_to(200, 0) + original_len = ops.len() + + _apply( + transformer, ops, workpiece=mock_workpiece, stock_geometries=None + ) + + assert ops.len() == original_len + + def test_no_op_when_empty_stock_geometries( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0, 0) + ops.line_to(200, 0) + original_len = ops.len() + + _apply(transformer, ops, workpiece=mock_workpiece, stock_geometries=[]) + + assert ops.len() == original_len + + def test_no_op_when_no_workpiece(self, transformer): + ops = Ops() + ops.move_to(0, 0) + ops.line_to(200, 0) + original_len = ops.len() + + stock_geo = create_rect_geometry(0, 0, 100, 100) + _apply(transformer, ops, workpiece=None, stock_geometries=[stock_geo]) + + assert ops.len() == original_len + + +class TestCropTransformerCropping: + def test_crop_line_outside_stock(self, transformer, mock_workpiece): + ops = Ops() + ops.move_to(0, 0.5) + ops.line_to(1, 0.5) + stock_geo = create_rect_geometry(0.3, 0, 0.4, 1) + + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == 1 + segment = segments[0] + assert len(segment) >= 2 + assert ops.endpoint(segment[0]) is not None + assert ops.endpoint(segment[-1]) is not None + start_x = ops.endpoint(segment[0])[0] + end_x = ops.endpoint(segment[-1])[0] + assert start_x >= 0.3 + assert end_x <= 0.7 + + def test_crop_line_fully_inside_stock(self, transformer, mock_workpiece): + ops = Ops() + ops.move_to(0.4, 0.5) + ops.line_to(0.6, 0.5) + original_segment_count = len(list(ops.segment_indices())) + + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == original_segment_count + + def test_crop_line_fully_outside_stock(self, transformer, mock_workpiece): + ops = Ops() + ops.move_to(1.5, 1.5) + ops.line_to(2, 2) + stock_geo = create_rect_geometry(0, 0, 1, 1) + + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == 0 + + def test_crop_with_positive_offset(self, mock_workpiece): + transformer = CropTransformer(offset=0.1) + ops = Ops() + ops.move_to(0, 0.5) + ops.line_to(1, 0.5) + + stock_geo = create_rect_geometry(0.4, 0, 0.2, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == 1 + segment = segments[0] + assert ops.endpoint(segment[0]) is not None + assert ops.endpoint(segment[-1]) is not None + start_x = ops.endpoint(segment[0])[0] + end_x = ops.endpoint(segment[-1])[0] + assert start_x >= 0.3 + assert end_x <= 0.7 + + def test_crop_with_negative_offset(self, mock_workpiece): + transformer = CropTransformer(offset=-0.1) + ops = Ops() + ops.move_to(0, 0.5) + ops.line_to(1, 0.5) + + stock_geo = create_rect_geometry(0.3, 0, 0.4, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == 1 + segment = segments[0] + assert ops.endpoint(segment[0]) is not None + assert ops.endpoint(segment[-1]) is not None + start_x = ops.endpoint(segment[0])[0] + end_x = ops.endpoint(segment[-1])[0] + assert start_x >= 0.4 + assert end_x <= 0.6 + + def test_crop_with_multiple_stock_geometries(self, mock_workpiece): + transformer = CropTransformer() + ops = Ops() + ops.move_to(0, 0.5) + ops.line_to(1, 0.5) + + stock_geo1 = create_rect_geometry(0, 0, 0.4, 1) + stock_geo2 = create_rect_geometry(0.6, 0, 0.4, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo1, stock_geo2], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == 2 + seg1 = segments[0] + seg2 = segments[1] + assert ops.endpoint(seg1[0]) is not None + assert ops.endpoint(seg1[-1]) is not None + assert ops.endpoint(seg2[0]) is not None + assert ops.endpoint(seg2[-1]) is not None + assert ( + ops.endpoint(seg1[0])[0] >= 0 and ops.endpoint(seg1[-1])[0] <= 0.4 + ) + assert ( + ops.endpoint(seg2[0])[0] >= 0.6 and ops.endpoint(seg2[-1])[0] <= 1 + ) + + def test_crop_with_transformed_workpiece(self): + wp = MagicMock(spec=WorkPiece) + wp.get_world_transform.return_value = Matrix.translation(0.5, 0) + wp.size = (1.0, 1.0) + + transformer = CropTransformer() + ops = Ops() + ops.move_to(0, 0.5) + ops.line_to(1, 0.5) + + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply(transformer, ops, workpiece=wp, stock_geometries=[stock_geo]) + + segments = list(ops.segment_indices()) + assert len(segments) == 1 + + def test_crop_with_rotated_workpiece(self): + import math + + wp = MagicMock(spec=WorkPiece) + rotation = Matrix.rotation(math.pi / 4) + wp.get_world_transform.return_value = rotation + wp.size = (1.0, 1.0) + + transformer = CropTransformer() + ops = Ops() + ops.move_to(0, 0) + ops.line_to(0.5, 0.5) + + stock_geo = create_rect_geometry(-1, -1, 2, 2) + _apply(transformer, ops, workpiece=wp, stock_geometries=[stock_geo]) + + segments = list(ops.segment_indices()) + assert len(segments) >= 0 + + def test_crop_empty_ops(self, transformer, mock_workpiece): + ops = Ops() + stock_geo = create_rect_geometry(0, 0, 100, 100) + + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + assert ops.len() == 0 + + def test_crop_workpiece_with_no_size(self): + wp = MagicMock(spec=WorkPiece) + wp.get_world_transform.return_value = Matrix.identity() + wp.size = None + + transformer = CropTransformer() + ops = Ops() + ops.move_to(0, 0) + ops.line_to(100, 0) + original_len = ops.len() + + stock_geo = create_rect_geometry(0, 0, 50, 50) + _apply(transformer, ops, workpiece=wp, stock_geometries=[stock_geo]) + + assert ops.len() <= original_len + + def test_crop_with_custom_tolerance(self, mock_workpiece): + transformer = CropTransformer(tolerance=0.1) + ops = Ops() + ops.move_to(0, 0.5) + ops.line_to(1, 0.5) + + stock_geo = create_rect_geometry(0.3, 0, 0.4, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + + segments = list(ops.segment_indices()) + assert len(segments) == 1 + + +class TestCropTransformerArcPreservation: + def test_arc_fully_inside_stock_is_preserved( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.4, 0.5) + ops.arc_to(0.6, 0.5, 0.1, 0.0, clockwise=True) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + arc_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.ARC_TO + ] + assert len(arc_indices) == 1 + assert ops.endpoint(arc_indices[0]) == pytest.approx( + (0.6, 0.5, 0.0), abs=1e-6 + ) + + def test_arc_partially_outside_stock_is_refitted( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.1, 0.5) + ops.arc_to(0.9, 0.5, 0.4, 0.0, clockwise=True) + stock_geo = create_rect_geometry(0.3, 0, 0.4, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + arc_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.ARC_TO + ] + assert len(arc_indices) >= 1 + segments = list(ops.segment_indices()) + for seg in segments: + for i in seg: + if ops.category(i) == CommandCategory.MOVING: + assert 0.3 <= ops.endpoint(i)[0] <= 0.7 + + def test_arc_fully_outside_stock_is_removed( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(1.5, 0.5) + ops.arc_to(1.7, 0.5, 0.1, 0.0, clockwise=True) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + segments = list(ops.segment_indices()) + assert len(segments) == 0 + + def test_mixed_line_and_arc_inside_stock( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.2, 0.5) + ops.line_to(0.4, 0.5) + ops.arc_to(0.6, 0.5, 0.1, 0.0, clockwise=True) + ops.line_to(0.8, 0.5) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + arc_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.ARC_TO + ] + assert len(arc_indices) == 1 + segments = list(ops.segment_indices()) + assert len(segments) == 1 + + def test_rounded_rect_arcs_preserved_when_inside_stock( + self, transformer, mock_workpiece + ): + r = 0.05 + x, y = 0.2, 0.3 + w, h = 0.6, 0.4 + ops = Ops() + ops.move_to(x + r, y) + ops.line_to(x + w - r, y) + ops.arc_to(x + w, y + r, 0.0, r, clockwise=True) + ops.line_to(x + w, y + h - r) + ops.arc_to(x + w - r, y + h, -r, 0.0, clockwise=True) + ops.line_to(x + r, y + h) + ops.arc_to(x, y + h - r, 0.0, -r, clockwise=True) + ops.line_to(x, y + r) + ops.arc_to(x + r, y, r, 0.0, clockwise=True) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + arc_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.ARC_TO + ] + assert len(arc_indices) == 4 + + +class TestCropTransformerBezierPreservation: + def test_bezier_fully_inside_stock_is_preserved( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.3, 0.5) + ops.bezier_to((0.4, 0.3, 0.0), (0.6, 0.7, 0.0), (0.7, 0.5, 0.0)) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 1 + assert ops.endpoint(bezier_indices[0]) == pytest.approx( + (0.7, 0.5, 0.0), abs=1e-6 + ) + + def test_bezier_partially_outside_stock_is_refitted( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.1, 0.5) + ops.bezier_to((0.3, 0.3, 0.0), (0.7, 0.7, 0.0), (0.9, 0.5, 0.0)) + stock_geo = create_rect_geometry(0.3, 0, 0.4, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + segments = list(ops.segment_indices()) + assert len(segments) >= 1 + for seg in segments: + for i in seg: + if ops.category(i) == CommandCategory.MOVING: + assert 0.3 <= ops.endpoint(i)[0] <= 0.7 + + def test_bezier_fully_outside_stock_is_removed( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(1.5, 0.5) + ops.bezier_to((1.6, 0.3, 0.0), (1.7, 0.7, 0.0), (1.8, 0.5, 0.0)) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + segments = list(ops.segment_indices()) + assert len(segments) == 0 + + def test_mixed_line_and_bezier_inside_stock( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.2, 0.5) + ops.line_to(0.3, 0.5) + ops.bezier_to((0.35, 0.3, 0.0), (0.55, 0.7, 0.0), (0.7, 0.5, 0.0)) + ops.line_to(0.8, 0.5) + stock_geo = create_rect_geometry(0, 0, 1, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 1 + segments = list(ops.segment_indices()) + assert len(segments) == 1 + + def test_bezier_state_preserved_after_refit( + self, transformer, mock_workpiece + ): + ops = Ops() + ops.move_to(0.1, 0.5) + ops.set_power(0.8) + ops.bezier_to((0.3, 0.3, 0.0), (0.7, 0.7, 0.0), (0.9, 0.5, 0.0)) + stock_geo = create_rect_geometry(0.3, 0, 0.4, 1) + _apply( + transformer, + ops, + workpiece=mock_workpiece, + stock_geometries=[stock_geo], + ) + cutting_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) + in (CommandType.LINE_TO, CommandType.BEZIER_TO) + ] + ops.preload_state() + for i in cutting_indices: + state = ops.inspect(i).state + assert state is not None + assert state.power == 0.8 diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_lead_in_out_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_lead_in_out_transformer.py new file mode 100644 index 000000000..2bc670f1c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_lead_in_out_transformer.py @@ -0,0 +1,350 @@ +import math + +import pytest +from post_processors.transformers import LeadInOutTransformer +from raygeo.ops import Ops +from raygeo.ops.types import CommandType, RasterMode, SectionType + + +def _apply(transformer, ops): + """Run a transformer through the Rust spec dispatch.""" + if not transformer.enabled: + return + specs = [transformer.to_spec(None, None, None)] + Ops.apply_transformers(ops, specs, progress_cb=None) + + +@pytest.fixture +def transformer() -> LeadInOutTransformer: + return LeadInOutTransformer(enabled=True, lead_in_mm=5.0, lead_out_mm=5.0) + + +def test_initialization_and_properties(): + t = LeadInOutTransformer(enabled=True, lead_in_mm=2.5, lead_out_mm=3.5) + assert t.enabled is True + assert t.lead_in_mm == 2.5 + assert t.lead_out_mm == 3.5 + t.lead_in_mm = -10.0 + assert t.lead_in_mm == 0.0 + t.lead_out_mm = -5.0 + assert t.lead_out_mm == 0.0 + t.lead_in_mm = 7.0 + t.lead_out_mm = 8.0 + assert t.lead_in_mm == 7.0 + assert t.lead_out_mm == 8.0 + + +def test_serialization_and_deserialization(): + original = LeadInOutTransformer( + enabled=False, lead_in_mm=3.14, lead_out_mm=2.71, auto=False + ) + data = original.to_dict() + recreated = LeadInOutTransformer.from_dict(data) + assert data["name"] == "LeadInOutTransformer" + assert data["enabled"] is False + assert data["lead_in_mm"] == 3.14 + assert data["lead_out_mm"] == 2.71 + assert isinstance(recreated, LeadInOutTransformer) + assert recreated.enabled is False + assert recreated.lead_in_mm == 3.14 + assert recreated.lead_out_mm == 2.71 + + +def test_no_op_when_disabled(transformer: LeadInOutTransformer): + ops = Ops() + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.line_to(30, 30, 0) + ops.line_to(10, 30, 0) + ops.line_to(10, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + original_len = ops.len() + + transformer.enabled = False + _apply(transformer, ops) + + assert ops.len() == original_len + + +def test_no_op_with_zero_distances(transformer: LeadInOutTransformer): + ops = Ops() + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + original_len = ops.len() + + transformer.lead_in_mm = 0.0 + transformer.lead_out_mm = 0.0 + _apply(transformer, ops) + + assert ops.len() == original_len + + +def test_square_contour_with_both_lead_in_out( + transformer: LeadInOutTransformer, +): + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.line_to(30, 30, 0) + ops.line_to(10, 30, 0) + ops.line_to(10, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + # Expected: SP(0.8), Start, Move, SP(0), Line, SP(0.8), Line*4, + # SP(0), Line, End + assert ops.command_type(1) == CommandType.OPS_SECTION_START + assert ops.command_type(2) == CommandType.MOVE_TO + # Lead-in start: 10-5=5, 10 (opposite of first segment direction) + assert ops.endpoint(2) == pytest.approx((5.0, 10.0, 0.0)) + # Lead-in line to original start + assert ops.command_type(3) == CommandType.SET_POWER and ops.power(3) == 0 + assert ops.command_type(4) == CommandType.LINE_TO + assert ops.endpoint(4) == pytest.approx((10.0, 10.0, 0.0)) + # Content + assert ops.command_type(5) == CommandType.SET_POWER and ops.power(5) == 0.8 + # Lead-out at end + lead_out_idx = ops.len() - 2 + assert ops.command_type(lead_out_idx) == CommandType.LINE_TO + # Last segment direction is (10-10, 10-30) = (0, -20) normalized + # = (0, -1) + # Lead-out end: (10, 10) + 5*(0, -1) = (10, 5) + assert ops.endpoint(lead_out_idx) == pytest.approx((10.0, 5.0, 0.0)) + assert ops.command_type(ops.len() - 1) == CommandType.OPS_SECTION_END + + +def test_lead_in_only(transformer: LeadInOutTransformer): + transformer.lead_out_mm = 0.0 + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + move_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ] + assert len(move_indices) == 1 + assert ops.endpoint(move_indices[0]) == pytest.approx((5.0, 10.0, 0.0)) + + # No lead-out: last command before section end should be a content + # LineTo, not a zero-power one + assert ops.command_type(ops.len() - 2) == CommandType.LINE_TO + assert ops.endpoint(ops.len() - 2) == pytest.approx((30.0, 10.0, 0.0)) + + +def test_lead_out_only(transformer: LeadInOutTransformer): + transformer.lead_in_mm = 0.0 + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + # MoveTo should be unchanged + assert ops.command_type(2) == CommandType.MOVE_TO + assert ops.endpoint(2) == pytest.approx((10.0, 10.0, 0.0)) + + # Lead-out at end + lead_out_idx = ops.len() - 2 + assert ops.command_type(lead_out_idx) == CommandType.LINE_TO + assert ops.endpoint(lead_out_idx) == pytest.approx((35.0, 10.0, 0.0)) + + +def test_diagonal_contour(transformer: LeadInOutTransformer): + ops = Ops() + ops.set_power(0.5) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(0, 0, 0) + ops.line_to(10, 10, 0) + ops.line_to(0, 0, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + # First segment: (10,10) - (0,0) = (10,10), normalized = (1/√2, 1/√2) + # Lead-in start: (0,0) - 5*(1/√2, 1/√2) = (-5/√2, -5/√2) + assert ops.command_type(2) == CommandType.MOVE_TO + norm = 1.0 / math.sqrt(2) + assert ops.endpoint(2) == pytest.approx((-5.0 * norm, -5.0 * norm, 0.0)) + + lead_out_idx = ops.len() - 2 + assert ops.command_type(lead_out_idx) == CommandType.LINE_TO + # Last segment: (0,0) - (10,10) = (-10,-10), normalized = (-1/√2, -1/√2) + # Lead-out end: (0,0) + 5*(-1/√2, -1/√2) = (-5/√2, -5/√2) + assert ops.endpoint(lead_out_idx) == pytest.approx( + (-5.0 * norm, -5.0 * norm, 0.0) + ) + + +def test_does_not_modify_commands_outside_vector_section( + transformer: LeadInOutTransformer, +): + ops = Ops() + ops.move_to(0, 0, 0) + ops.line_to(5, 5, 0) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(20, 10, 0) + ops.line_to(10, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + original_ep0 = ops.endpoint(0) + original_ep1 = ops.endpoint(1) + + _apply(transformer, ops) + + assert ops.endpoint(0) == original_ep0 + assert ops.endpoint(1) == original_ep1 + assert ops.len() > 8 + + +def test_does_not_modify_raster_sections(transformer: LeadInOutTransformer): + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + original_len = ops.len() + + _apply(transformer, ops) + + assert ops.len() == original_len + + +def test_handles_zero_length_first_segment(transformer: LeadInOutTransformer): + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + # Lead-in is skipped because first segment has zero length, + # but lead-out should still be applied using the last segment's + # tangent. + move_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ] + assert len(move_indices) == 1 + assert ops.endpoint(move_indices[0]) == pytest.approx((10.0, 10.0, 0.0)) + + # Lead-out: last segment is (10,10)->(30,10), tangent=(1,0) + # Lead-out end: (30,10) + 5*(1,0) = (35, 10) + lead_out_idx = ops.len() - 2 + assert ops.command_type(lead_out_idx) == CommandType.LINE_TO + assert ops.endpoint(lead_out_idx) == pytest.approx((35.0, 10.0, 0.0)) + + +def test_handles_multiple_contours_in_section( + transformer: LeadInOutTransformer, +): + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + # First contour + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.line_to(10, 10, 0) + # Second contour (separate MoveTo) + ops.move_to(50, 50, 0) + ops.line_to(70, 50, 0) + ops.line_to(50, 50, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + move_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ] + assert len(move_indices) == 2 + # First contour lead-in: (10,10) - 5*(1,0) = (5, 10) + assert ops.endpoint(move_indices[0]) == pytest.approx((5.0, 10.0, 0.0)) + # Second contour lead-in: (50,50) - 5*(1,0) = (45, 50) + assert ops.endpoint(move_indices[1]) == pytest.approx((45.0, 50.0, 0.0)) + + +def test_auto_distance_calculation(): + distance = LeadInOutTransformer.calculate_auto_distance( + step_speed=3000, max_acceleration=1000 + ) + # speed = 50 mm/s, d = 50^2 / (2 * 1000 * 2) = 2500 / 4000 = 0.625 + assert distance == pytest.approx(0.625) + + distance = LeadInOutTransformer.calculate_auto_distance( + step_speed=6000, max_acceleration=500 + ) + # speed = 100 mm/s, d = 10000 / 2000 = 5.0 + assert distance == pytest.approx(5.0) + + +def test_auto_distance_minimum(): + distance = LeadInOutTransformer.calculate_auto_distance( + step_speed=100, max_acceleration=5000 + ) + # speed = 1.667 mm/s, d = 2.78 / 20000 = 0.000139 + assert distance == pytest.approx(0.000139, abs=1e-6) + + +def test_with_z_height(transformer: LeadInOutTransformer): + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 3.0) + ops.line_to(30, 10, 3.0) + ops.line_to(10, 10, 3.0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(transformer, ops) + + assert ops.command_type(2) == CommandType.MOVE_TO + assert ops.endpoint(2) == pytest.approx((5.0, 10.0, 3.0)) + + lead_out_idx = ops.len() - 2 + assert ops.command_type(lead_out_idx) == CommandType.LINE_TO + assert ops.endpoint(lead_out_idx)[2] == pytest.approx(3.0) + + +def test_separate_lead_in_out_distances(): + t = LeadInOutTransformer(enabled=True, lead_in_mm=3.0, lead_out_mm=7.0) + ops = Ops() + ops.set_power(0.8) + ops.ops_section_start(SectionType.VECTOR_OUTLINE, "wp_123") + ops.move_to(10, 10, 0) + ops.line_to(30, 10, 0) + ops.line_to(10, 10, 0) + ops.ops_section_end(SectionType.VECTOR_OUTLINE) + _apply(t, ops) + + move_idx = next( + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ) + # Lead-in: 10 - 3 = 7 + assert ops.endpoint(move_idx) == pytest.approx((7.0, 10.0, 0.0)) + + lead_out_idx = ops.len() - 2 + assert ops.command_type(lead_out_idx) == CommandType.LINE_TO + # Lead-out: 10 - 7 = 3 (last segment goes right to left) + assert ops.endpoint(lead_out_idx) == pytest.approx((3.0, 10.0, 0.0)) diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_merge_lines_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_merge_lines_transformer.py new file mode 100644 index 000000000..f67c1b7d6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_merge_lines_transformer.py @@ -0,0 +1,291 @@ +import math + +from post_processors.transformers import MergeLinesTransformer +from raygeo.ops import Ops +from raygeo.ops.types import CommandType + + +def _apply(transformer, ops): + """Run a transformer through the Rust spec dispatch.""" + if not transformer.enabled: + return + specs = [transformer.to_spec(None, None, None)] + Ops.apply_transformers(ops, specs, progress_cb=None) + + +def test_no_duplicate_lines(): + """Test that non-overlapping lines are preserved.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + ops.move_to(20, 0) + ops.line_to(30, 0) + + original_move_count = len(ops.indices_of(CommandType.MOVE_TO)) + original_line_count = len(ops.indices_of(CommandType.LINE_TO)) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + move_count = len(ops.indices_of(CommandType.MOVE_TO)) + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert move_count == original_move_count + assert line_count == original_line_count + + +def test_identical_duplicate_lines_removed(): + """Test that identical overlapping lines have one removed.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert line_count == 1 + + +def test_opposite_direction_duplicate_lines_removed(): + """Test that overlapping lines in opposite directions are merged.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + ops.move_to(10, 0) + ops.line_to(0, 0) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert line_count == 1 + + +def test_tolerance_affects_merging(): + """Test tolerance parameter affects which lines are merged.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + ops.move_to(0, 0.05) + ops.line_to(10, 0.05) + + transformer_tight = MergeLinesTransformer(enabled=True, tolerance=0.01) + ops_copy_tight = ops.copy() + _apply(transformer_tight, ops_copy_tight) + line_count_tight = len(ops_copy_tight.indices_of(CommandType.LINE_TO)) + assert line_count_tight == 2 + + transformer_loose = MergeLinesTransformer(enabled=True, tolerance=0.2) + ops_copy_loose = ops.copy() + _apply(transformer_loose, ops_copy_loose) + line_count_loose = len(ops_copy_loose.indices_of(CommandType.LINE_TO)) + assert line_count_loose == 1 + + +def test_adjacent_rectangles_shared_edge(): + """Test merging shared edge between two adjacent rectangles.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + ops.line_to(10, 10) + ops.line_to(0, 10) + ops.line_to(0, 0) + + ops.move_to(10, 0) + ops.line_to(20, 0) + ops.line_to(20, 10) + ops.line_to(10, 10) + ops.line_to(10, 0) + + original_line_count = len(ops.indices_of(CommandType.LINE_TO)) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert line_count < original_line_count + + +def test_disabled_transformer(): + """Test that disabled transformer doesn't modify ops.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + ops.move_to(0, 0) + ops.line_to(10, 0) + + original_line_count = len(ops.indices_of(CommandType.LINE_TO)) + + transformer = MergeLinesTransformer(enabled=False) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert line_count == original_line_count + + +def test_empty_ops(): + """Test that empty ops is handled gracefully.""" + ops = Ops() + + transformer = MergeLinesTransformer(enabled=True) + _apply(transformer, ops) + + assert ops.is_empty() + + +def test_serialization(): + """Test to_dict and from_dict methods.""" + transformer1 = MergeLinesTransformer(enabled=True, tolerance=0.5) + data = transformer1.to_dict() + + transformer2 = MergeLinesTransformer.from_dict(data) + + assert transformer2.enabled == transformer1.enabled + assert transformer2.tolerance == transformer1.tolerance + + +def test_serialization_default_values(): + """Test deserialization with missing values uses defaults.""" + data = {"name": "MergeLinesTransformer"} + transformer = MergeLinesTransformer.from_dict(data) + + assert transformer.enabled is True + assert transformer.tolerance == MergeLinesTransformer.DEFAULT_TOLERANCE + + +def test_overlapping_collinear_segments(): + """ + Test that partially overlapping collinear segments are sliced + correctly. + """ + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + ops.move_to(5, 0) + ops.line_to(15, 0) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + # Under the 1D boolean union logic with horizontal tolerance + # expansion, the second segment is trimmed so we have exactly two + # LineToCommands. The total cut length is perfectly merged, minus + # the tolerance padding. + assert line_count == 2 + assert math.isclose(ops.cut_distance(), 15.0 - transformer.tolerance) + + +def test_perpendicular_lines_not_merged(): + """Test that perpendicular lines are not merged.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + ops.move_to(5, -5) + ops.line_to(5, 5) + + original_line_count = len(ops.indices_of(CommandType.LINE_TO)) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert line_count == original_line_count + + +def test_triangle_shared_edge(): + """Test merging shared edges between two triangles.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + ops.line_to(5, 10) + ops.line_to(0, 0) + + ops.move_to(10, 0) + ops.line_to(0, 0) + ops.line_to(5, -10) + ops.line_to(10, 0) + + original_line_count = len(ops.indices_of(CommandType.LINE_TO)) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + + assert line_count < original_line_count + + +def test_bezier_passes_through_unchanged(): + """Test that BezierToCommand passes through without modification.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0, 0) + ops.bezier_to((3.0, 5.0, 0.0), (7.0, 5.0, 0.0), (10.0, 0.0, 0.0)) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + bezier_indices = ops.indices_of(CommandType.BEZIER_TO) + assert len(bezier_indices) == 1 + idx = bezier_indices[0] + assert ops.endpoint(idx) == (10.0, 0.0, 0.0) + c1, c2 = ops.bezier_params(idx) + assert c1 == (3.0, 5.0, 0.0) + assert c2 == (7.0, 5.0, 0.0) + + +def test_mixed_lines_and_bezier(): + """Test that lines are merged while bezier passes through.""" + ops = Ops() + ops.set_power(1.0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + ops.bezier_to((12.0, 5.0, 0.0), (18.0, 5.0, 0.0), (20.0, 0.0, 0.0)) + ops.line_to(30, 0) + + ops.move_to(0, 0) + ops.line_to(10, 0) + + transformer = MergeLinesTransformer(enabled=True, tolerance=0.1) + _apply(transformer, ops) + + bezier_indices = ops.indices_of(CommandType.BEZIER_TO) + assert len(bezier_indices) == 1 + idx = bezier_indices[0] + c1, _ = ops.bezier_params(idx) + assert c1 == (12.0, 5.0, 0.0) diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_multipass_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_multipass_transformer.py new file mode 100644 index 000000000..7ec040662 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_multipass_transformer.py @@ -0,0 +1,211 @@ +import pytest +from post_processors.transformers import MultiPassTransformer +from raygeo.ops import Ops +from raygeo.ops.types import CommandType + + +def _apply(transformer, ops): + """Run a transformer through the Rust spec dispatch.""" + if not transformer.enabled: + return + specs = [transformer.to_spec(None, None, None)] + Ops.apply_transformers(ops, specs, progress_cb=None) + + +class TestMultiPassTransformer: + """ + Tests the functionality of the MultiPassTransformer, which repeats + an Ops object's commands to create multiple passes. + """ + + def test_duplicates_commands_without_z_step(self): + """ + Tests that commands are duplicated the correct number of times when + no z_step_down is applied. + """ + # Arrange + ops = Ops() + ops.move_to(10, 10, 0) + ops.line_to(20, 20, 0) + + transformer = MultiPassTransformer(passes=3, z_step_down=0.0) + + # Act + _apply(transformer, ops) + + # Assert + # Original commands + 2 copies = 3 total passes + assert ops.len() == 6 + + # Verify the sequence and types of commands + assert ops.command_type(0) == CommandType.MOVE_TO + assert ops.command_type(1) == CommandType.LINE_TO + assert ops.command_type(2) == CommandType.MOVE_TO + assert ops.command_type(3) == CommandType.LINE_TO + assert ops.command_type(4) == CommandType.MOVE_TO + assert ops.command_type(5) == CommandType.LINE_TO + + # The first pass has original positions + assert ops.endpoint(0) == (10, 10, 0) + assert ops.endpoint(1) == (20, 20, 0) + # All subsequent passes also have original positions (no z-step) + assert ops.endpoint(2) == (10, 10, 0) + assert ops.endpoint(3) == (20, 20, 0) + assert ops.endpoint(4) == (10, 10, 0) + assert ops.endpoint(5) == (20, 20, 0) + + def test_applies_z_step_down_for_each_pass(self): + """ + Tests that z_step_down correctly modifies the Z coordinate on + each subsequent pass. + """ + # Arrange + ops = Ops() + ops.line_to(10, 10, 5.0) + num_passes = 3 + z_step = 0.5 + + transformer = MultiPassTransformer( + passes=num_passes, z_step_down=z_step + ) + + # Act + _apply(transformer, ops) + + # Assert + assert ops.len() == 3 + + # Add assertions to assure the type checker that .end is not None + assert ops.endpoint(0) is not None + assert ops.endpoint(1) is not None + assert ops.endpoint(2) is not None + + # Pass 1 (original): Z should be untouched + assert ops.endpoint(0)[2] == 5.0 + # Pass 2: Z should be original_z - (1 * z_step) + assert ops.endpoint(1)[2] == pytest.approx(5.0 - 0.5) + # Pass 3: Z should be original_z - (2 * z_step) + assert ops.endpoint(2)[2] == pytest.approx(5.0 - 1.0) + + def test_no_op_for_single_pass_and_no_z_step(self): + """ + Tests the optimization that if passes=1 and z_step_down=0, the + dispatch does nothing. + """ + # Arrange + ops = Ops() + ops.move_to(0, 0, 0) + original_len = ops.len() + + transformer = MultiPassTransformer(passes=1, z_step_down=0.0) + + # Act + _apply(transformer, ops) + + # Assert + # The ops should not have been modified. + assert ops.len() == original_len + assert ops.len() == 1 + + def test_no_op_for_empty_commands(self): + """ + Tests that if the initial Ops object has no commands, the + dispatch does nothing. + """ + # Arrange + ops = Ops() + transformer = MultiPassTransformer(passes=5) + + # Act + _apply(transformer, ops) + + # Assert + assert ops.len() == 0 + + def test_passes_property_validation(self): + """ + Tests that the 'passes' property setter enforces a minimum + value of 1. + """ + # Arrange + transformer = MultiPassTransformer() + + # Act & Assert + transformer.passes = 5 + assert transformer.passes == 5 + + transformer.passes = 0 + assert transformer.passes == 1 + + transformer.passes = -10 + assert transformer.passes == 1 + + transformer.passes = 1 + assert transformer.passes == 1 + + def test_serialization_and_deserialization(self): + """ + Tests that the transformer can be serialized to a dict and + recreated from that dict. + """ + # Arrange + transformer = MultiPassTransformer( + enabled=False, passes=4, z_step_down=1.23 + ) + + # Act + data = transformer.to_dict() + recreated_transformer = MultiPassTransformer.from_dict(data) + + # Assert + assert data["name"] == "MultiPassTransformer" + assert data["enabled"] is False + assert data["passes"] == 4 + assert data["z_step_down"] == 1.23 + + assert isinstance(recreated_transformer, MultiPassTransformer) + assert recreated_transformer.enabled is False + assert recreated_transformer.passes == 4 + assert recreated_transformer.z_step_down == 1.23 + + def test_bezier_duplicated_without_z_step(self): + ops = Ops() + ops.move_to(0, 0, 0) + ops.bezier_to((3.0, 5.0, 0.0), (7.0, 5.0, 0.0), (10.0, 0.0, 0.0)) + + transformer = MultiPassTransformer(passes=2, z_step_down=0.0) + _apply(transformer, ops) + + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 2 + c1_0, _ = ops.bezier_params(bezier_indices[0]) + c1_1, _ = ops.bezier_params(bezier_indices[1]) + assert c1_0 == (3.0, 5.0, 0.0) + assert c1_1 == (3.0, 5.0, 0.0) + + def test_bezier_with_z_step_down(self): + ops = Ops() + ops.move_to(0, 0, 2.0) + ops.bezier_to((3.0, 5.0, 2.0), (7.0, 5.0, 2.0), (10.0, 0.0, 2.0)) + + transformer = MultiPassTransformer(passes=2, z_step_down=0.5) + _apply(transformer, ops) + + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 2 + c1_0, c2_0 = ops.bezier_params(bezier_indices[0]) + c1_1, c2_1 = ops.bezier_params(bezier_indices[1]) + assert c1_0[2] == pytest.approx(2.0) + assert c1_1[2] == pytest.approx(1.5) + assert c2_0[2] == pytest.approx(2.0) + assert c2_1[2] == pytest.approx(1.5) + assert ops.endpoint(bezier_indices[0])[2] == pytest.approx(2.0) + assert ops.endpoint(bezier_indices[1])[2] == pytest.approx(1.5) diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_optimize_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_optimize_transformer.py new file mode 100644 index 000000000..7a3557e4f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_optimize_transformer.py @@ -0,0 +1,944 @@ +import pytest +from post_processors.transformers import Optimize +from raygeo.ops import Ops +from raygeo.ops.state import AirAssistMode +from raygeo.ops.types import CommandCategory, CommandType + + +class _ProgressCallback: + def __init__(self, context): + self._context = context + + def __call__(self, progress, message): + self._context.set_progress(progress) + if message: + self._context.set_message(message) + + def is_cancelled(self): + return self._context.is_cancelled() + + +def _apply(optimizer, ops, context=None): + """Run the optimizer through the Rust spec dispatch.""" + specs = [optimizer.to_spec(None, None, None)] + cb = _ProgressCallback(context) if context else None + Ops.apply_transformers(ops, specs, progress_cb=cb) + + +def _make_seg(start: tuple, end: tuple) -> Ops: + """Create a 2-point segment: move_to then line_to.""" + ops = Ops() + ops.move_to(*start) + ops.line_to(*end) + return ops + + +def _build_ops(segments: list[tuple], power: float = 1.0) -> Ops: + """Build an Ops from a list of (start, end) segment tuples.""" + ops = Ops() + ops.set_power(power) + for start, end in segments: + ops.move_to(*start) + ops.line_to(*end) + return ops + + +def _travel_distance(ops: Ops) -> float: + ops.preload_state() + return ops.distance() - ops.cut_distance() + + +def _cut_endpoints(ops: Ops) -> list[tuple[float, float, float]]: + """Return endpoints of all cutting commands in order.""" + return [ops.endpoint(i) for i in range(ops.len()) if ops.is_cutting(i)] + + +def _line_endpoints(ops: Ops) -> list[tuple[float, float, float]]: + """Return endpoints of all LINE_TO commands in order.""" + return [ + ops.endpoint(i) + for i in range(ops.len()) + if ops.command_type(i) == CommandType.LINE_TO + ] + + +def _count_cuts(ops: Ops) -> int: + return sum(1 for i in range(ops.len()) if ops.is_cutting(i)) + + +@pytest.fixture +def ctx(mock_progress_context) -> object: + """Provides a dummy execution context for functions that require it.""" + return mock_progress_context + + +def test_greedy_order_segments(mock_progress_context): + """ + Test the optimizer reorders segments so that close cuts appear together. + Segments: s1(0,0->10,0), s2(100,100->110,100), s3(10,0->10,10). + Expected: s1, s3 (connects at 10,0), s2 (far away). + """ + ops = _build_ops( + [ + ((0, 0, 0), (10, 0, 0)), + ((100, 100, 0), (110, 100, 0)), + ((10, 0, 0), (10, 10, 0)), + ] + ) + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + travel_after = _travel_distance(ops) + # s1(0->10) then s3(10->10) connect at (10,0) with zero travel. + # Only one jump: from (10,10) to (100,100) ≈ 127. + assert travel_after < 130 + # s2 should remain last (three cuts) + assert _count_cuts(ops) == 3 + # Verify cut order: s1→(10,0), s3→(10,10), s2→(110,100) + ends = _line_endpoints(ops) + assert len(ends) == 3 + assert ends[0] == pytest.approx((10, 0, 0)) + assert ends[1] == pytest.approx((10, 10, 0)) + assert ends[2] == pytest.approx((110, 100, 0)) + + +def test_greedy_order_with_flip(mock_progress_context): + """ + Test the optimizer flips s3 so it connects directly to s1. + s3 original: (10,10)→(10,0), flipped: (10,0)→(10,10). + """ + ops = _build_ops( + [ + ((0, 0, 0), (10, 0, 0)), + ((100, 100, 0), (110, 100, 0)), + ((10, 10, 0), (10, 0, 0)), + ] + ) + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + travel_after = _travel_distance(ops) + # s1(0→10) then s3_flipped(10→10) connect with zero travel. + # One jump: (10,10) → (100,100) ≈ 127. + assert travel_after < 130 + assert _count_cuts(ops) == 3 + # After flipping: s1→(10,0), s3→(10,10), s2→(110,100) + ends = _line_endpoints(ops) + assert len(ends) == 3 + assert ends[0] == pytest.approx((10, 0, 0)) + assert ends[1] == pytest.approx((10, 10, 0)) + assert ends[2] == pytest.approx((110, 100, 0)) + + +def test_kdtree_order_segments(mock_progress_context): + """ + Test the optimizer orders all four segments by nearest-neighbor. + A(0,0→10,0), B(100,0→110,0), C(10,10→10,0 flipped to 10,0→10,10), + D(110,0→110,10). + Expected: A, flipped(C), B, D — connecting at (10,0), (10,10), + (100,0→110,0), (110,0→110,10). + """ + ops = _build_ops( + [ + ((0, 0, 0), (10, 0, 0)), + ((100, 0, 0), (110, 0, 0)), + ((10, 10, 0), (10, 0, 0)), + ((110, 0, 0), (110, 10, 0)), + ] + ) + travel_before = _travel_distance(ops.copy()) + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + travel_after = _travel_distance(ops) + # Original: (10,0)→(100,0)=90, (110,0)→(10,10)≈100.5, (110,10)→(110,0)=10 + # Optimized: no travel between A→C (connect at 10,0), + # C(10,10)→B(100,0)≈90, B(110,0)→D(110,0)=0 + # Total optimized travel ≈ 90. Original ≈ 200. + assert travel_after < travel_before * 0.75 + assert _count_cuts(ops) == 4 + ends = _line_endpoints(ops) + assert len(ends) == 4 + assert ends[0] == pytest.approx((10, 0, 0)) # A → (10,0) + assert ends[1] == pytest.approx((10, 10, 0)) # flipped(C) → (10,10) + assert ends[2] == pytest.approx((110, 0, 0)) # B → (110,0) + assert ends[3] == pytest.approx((110, 10, 0)) # D → (110,10) + + +def test_two_opt(mock_progress_context): + """ + Test 2-opt refinement un-crosses paths. + A(0,0→1,0), B(10,10→11,10), C(2,0→1,0 reversed), D(11,10→12,10). + KDTree should produce A, flipped(C), B, D. + """ + ops = _build_ops( + [ + ((0, 0, 0), (1, 0, 0)), + ((10, 10, 0), (11, 10, 0)), + ((2, 0, 0), (1, 0, 0)), + ((11, 10, 0), (12, 10, 0)), + ] + ) + travel_before = _travel_distance(ops.copy()) + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + travel_after = _travel_distance(ops) + # Original: (1,0)→(10,10)≈12.8, (11,10)→(2,0)≈10.5, (1,0)→(11,10)≈10.5 + # Optimized A,C,B,D: (1,0)→(1,0)=0, (2,0)→(10,10)≈12.8, (11,10)→(11,10)=0 + assert travel_after < travel_before + assert _count_cuts(ops) == 4 + ends = _line_endpoints(ops) + assert len(ends) == 4 + # After optimization: A→(1,0), flipped(C)→(2,0), B→(11,10), D→(12,10) + assert ends[0] == pytest.approx((1, 0, 0)) + assert ends[1] == pytest.approx((2, 0, 0)) + assert ends[2] == pytest.approx((11, 10, 0)) + assert ends[3] == pytest.approx((12, 10, 0)) + + +def _calculate_travel_distance(ops: Ops) -> float: + """Helper to calculate only the travel distance.""" + return ops.distance() - ops.cut_distance() + + +def test_run_optimization(mock_progress_context): + """Test the full optimization process on a sample Ops object.""" + # Create an inefficient path + # It draws two separate squares, but jumps between them for each segment + ops = Ops() + ops.set_power(1.0) + + # Square 1 (at 0,0) + ops.move_to(0, 0) + ops.line_to(10, 0) # Seg 1 + # Square 2 (at 100,100) + ops.move_to(100, 100) + ops.line_to(110, 100) # Seg 2 + # Square 1 + ops.move_to(10, 0) + ops.line_to(10, 10) # Seg 3 + # Square 2 + ops.move_to(110, 100) + ops.line_to(110, 110) # Seg 4 + + # Calculate travel distance before optimization + ops_copy = ops.copy() + ops_copy.preload_state() + travel_before = _calculate_travel_distance(ops_copy) + + # Run the optimizer + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + + # Calculate travel distance after optimization + ops.preload_state() + travel_after = _calculate_travel_distance(ops) + + # The optimizer should significantly reduce travel distance + assert travel_before > 250, "Initial travel should be large" + assert travel_after < travel_before, "Optimized travel should be smaller" + assert travel_after < 150, "Optimized travel should be just one jump" + + # Check that the number of cutting commands is the same + cuts_after = sum(1 for i in range(ops.len()) if ops.is_cutting(i)) + assert cuts_after == 4 + + +def test_run_with_air_assist_change(mock_progress_context): + """ + Verify that segments with different air assist states are not reordered. + """ + ops = Ops() + ops.set_power(1.0) + + # Part 1: Air Assist OFF - Inefficient path + ops.move_to(0, 0) + ops.line_to(10, 0) # Seg A1 + ops.move_to(0, 10) + ops.line_to(10, 10) # Seg A2 + + ops.set_air_assist(AirAssistMode.ON) + + # Part 2: Air Assist ON - Inefficient path + ops.move_to(100, 100) + ops.line_to(110, 100) # Seg B1 + ops.move_to(100, 110) + ops.line_to(110, 110) # Seg B2 + + # Run optimizer + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + + ops.preload_state() + + # After optimization, find the first command with air assist ON. + air_on_idx = -1 + for i in range(ops.len()): + if ops.category(i) == CommandCategory.MOVING: + state = ops.state(i) + if state is not None and state.air_assist == AirAssistMode.ON: + air_on_idx = i + break + + assert air_on_idx != -1, "A segment with air assist ON should exist" + + # All points before this index should be from Part 1 + for i in range(air_on_idx): + if ops.category(i) == CommandCategory.MOVING: + assert ops.endpoint(i)[0] < 50, ( + "Points from Part 1 should be in first half" + ) + state = ops.state(i) + assert state is None or state.air_assist != AirAssistMode.ON, ( + "State should be air OFF" + ) + + # All points from this index on should be from Part 2 + for i in range(air_on_idx, ops.len()): + if ops.category(i) == CommandCategory.MOVING: + assert ops.endpoint(i)[0] > 50, ( + "Points from Part 2 should be second half" + ) + state = ops.state(i) + assert ( + state is not None and state.air_assist == AirAssistMode.ON + ), "State should be air ON" + + +def test_run_preserves_markers(mock_progress_context): + """Verify that marker commands act as optimization boundaries.""" + ops = Ops() + ops.set_power(1.0) + + # Inefficient path with a marker in the middle + ops.move_to(0, 0) + ops.line_to(10, 0) # Seg 1 + ops.move_to(100, 100) + ops.line_to(110, 100) # Seg 2 + ops.job_start() # Marker + ops.move_to(10, 0) + ops.line_to(10, 10) # Seg 3 + ops.move_to(110, 100) + ops.line_to(110, 110) # Seg 4 + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + + # Find the marker + marker_idx = -1 + for i in range(ops.len()): + if ops.command_type(i) == CommandType.JOB_START: + marker_idx = i + break + + assert marker_idx != -1, "Marker command should be preserved" + + # Check that segments before the marker were optimized together + moving_before = [ + ops.endpoint(i) + for i in range(marker_idx) + if ops.category(i) == CommandCategory.MOVING + ] + assert len(moving_before) == 4 + starts_before = { + ops.endpoint(i) for i in range(marker_idx) if ops.is_travel(i) + } + # After optimization, there will be one travel to the start of the first + # segment, and one travel between segments. The exact points depend on + # the optimizer's choice, so we check that the original start points exist. + assert (0, 0, 0) in starts_before or (100, 100, 0) in starts_before + + # Check that segments after the marker were optimized together + moving_after = [ + ops.endpoint(i) + for i in range(marker_idx + 1, ops.len()) + if ops.category(i) == CommandCategory.MOVING + ] + assert len(moving_after) == 4 + starts_after = { + ops.endpoint(i) + for i in range(marker_idx + 1, ops.len()) + if ops.is_travel(i) + } + assert (10, 0, 0) in starts_after or (110, 100, 0) in starts_after + + +def test_run_optimization_with_unsplit_scanline(mock_progress_context): + """ + Verify the optimizer can flip a fully "on" ScanLinePowerCommand + without splitting it. + """ + ops = Ops() + ops.set_power(1.0) + + # Path 1: A simple vector line from (0,0) to (10,0) + ops.move_to(0, 0, 0) + ops.line_to(10, 0, 0) + + # Path 2: A raster line that is fully "on". It should be flipped. + ops.move_to(20, 0, 0) + ops.scan_to(10, 0, 0, power_values=bytearray([10, 20, 30])) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + travel_after = _calculate_travel_distance(ops) + + # Travel should be zero after flipping. + assert travel_after == pytest.approx(0.0) + + moving_indices = [ + i + for i in range(ops.len()) + if ops.category(i) == CommandCategory.MOVING + ] + + # Original unoptimized: M, L, M, S (4) + # Optimized: M, L, M, S_flipped (4) + assert len(moving_indices) == 4 + + # Check the final flipped segment + flipped_move_idx = moving_indices[2] + flipped_scan_idx = moving_indices[3] + assert ops.command_type(flipped_move_idx) == CommandType.MOVE_TO + assert ops.command_type(flipped_scan_idx) == CommandType.SCAN_LINE + + # The new segment should start where the old one ended + assert ops.endpoint(flipped_move_idx) == pytest.approx((10.0, 0.0, 0.0)) + # The scan command's geometry should reflect the flipped segment + assert ops.endpoint(flipped_scan_idx) == pytest.approx((20.0, 0.0, 0.0)) + # Power values should be reversed + assert bytearray(ops.scanline_data(flipped_scan_idx)) == bytearray( + [30, 20, 10] + ) + + +def test_run_optimization_with_split_scanline(mock_progress_context): + """ + Verify the optimizer keeps a ScanLine with blank areas as a single + atomic segment — scanlines represent continuous sweeps and must not + be fragmented. + """ + ops = Ops() + ops.set_power(1.0) + + # Path A: A vector line that ends at x=108. + ops.move_to(0, 5, 0) + ops.line_to(108, 5, 0) + + # Path B: A raster line from (100, 5) to (110, 5) with a blank middle. + # The scanline must not be split — it stays as one atomic sweep. + ops.move_to(100, 5, 0) + ops.scan_to(110, 5, 0, power_values=bytearray([50, 50, 0, 0, 0, 60, 60])) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + moving_indices = [ + i + for i in range(ops.len()) + if ops.category(i) == CommandCategory.MOVING + ] + + # Original: M, L, M, S (4 commands) + # Scanline stays intact: still 4 commands (may be reordered) + assert len(moving_indices) == 4 + + # Find the scanline — it should have all original power values + scan_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.SCAN_LINE + ] + assert len(scan_indices) == 1 + scan_idx = scan_indices[0] + original_power = bytearray([50, 50, 0, 0, 0, 60, 60]) + actual = bytearray(ops.scanline_data(scan_idx)) + # May be flipped, but must contain every original byte + assert actual == original_power or actual == original_power[::-1] + + +def test_optimizer_does_not_split_overscanned_scanline( + mock_progress_context, +): + """ + Tests that the optimizer does not split a ScanLinePowerCommand that has + been padded with zero-power values by the OverscanTransformer. + + Scanlines represent continuous sweeps and must never be fragmented + by the optimizer. This is especially important for overscanned lines + with zero-power lead-in/outs and full-sweep engraving. + """ + # Arrange: Create an Ops object that simulates the output of an + # OverscanTransformer. This is a single scanline with zero-power padding. + ops = Ops() + ops.set_power(1.0) + + # This represents a 10mm content line (15-5) with 5mm overscan on each side + start_pt = (0.0, 10.0, 0.0) + end_pt = (20.0, 10.0, 0.0) + # Padded power values: 2 bytes for lead-in, 3 for content, 2 for lead-out + power_values = bytearray([0, 0] + [50, 100, 150] + [0, 0]) + + ops.move_to(*start_pt) + ops.scan_to(*end_pt, power_values=power_values) + + # Act: Run the optimizer + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + + # Assert: The optimizer should NOT have split the scanline. + scan_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.SCAN_LINE + ] + move_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ] + + # 1. There should still be exactly one ScanLinePowerCommand + assert len(scan_indices) == 1 + final_scan_idx = scan_indices[0] + + # 2. The move command preceding it should still start at the overscan point + assert len(move_indices) == 1 + assert ops.endpoint(move_indices[0]) == pytest.approx(start_pt) + + # 3. The scanline's geometry should be unchanged. If it were split, the + # endpoint would be shortened to the end of the content area. + assert ops.endpoint(final_scan_idx) == pytest.approx(end_pt) + + # 4. The power values should still contain the zero-power padding. + assert bytearray(ops.scanline_data(final_scan_idx)) == power_values + + +def test_run_optimization_scanline_flip_preserves_state( + mock_progress_context, +): + """ + Verify that when a ScanLine segment is flipped, the new commands + (MoveTo, ScanLinePowerCommand) correctly inherit the state. + """ + ops = Ops() + ops.set_power(0.85) + ops.set_feed_rate(1234) + ops.set_air_assist(AirAssistMode.ON) + + # Path 1: A vector line from (0,0) to (10,0) + ops.move_to(0, 0) + ops.line_to(10, 0) + + # Path 2: A raster line that should be flipped to minimize travel + ops.move_to(20, 0) + ops.scan_to(10, 0, power_values=bytearray([10, 20, 30])) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + scan_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.SCAN_LINE + ] + assert len(scan_indices) == 1 + scan_idx = scan_indices[0] + + move_idx = scan_idx - 1 + assert ops.command_type(move_idx) == CommandType.MOVE_TO + + # Check state on the new MoveTo for the flipped segment + move_state = ops.state(move_idx) + assert move_state is not None + assert move_state.power == pytest.approx(0.85) + assert move_state.feed_rate == pytest.approx(1234) + assert move_state.air_assist == AirAssistMode.ON + + # Check state on the flipped ScanLinePowerCommand + scan_state = ops.state(scan_idx) + assert scan_state is not None + assert scan_state.power == pytest.approx(0.85) + assert scan_state.feed_rate == pytest.approx(1234) + assert scan_state.air_assist == AirAssistMode.ON + + +def test_run_optimization_scanline_split_preserves_state( + mock_progress_context, +): + """ + Verify that a ScanLine kept as a single atomic segment correctly + inherits the original state. + """ + ops = Ops() + ops.set_power(0.77) + ops.set_rapid_rate(5678) + ops.set_air_assist(AirAssistMode.OFF) + + # A raster line that stays as a single atomic sweep + ops.move_to(0, 0) + ops.scan_to(10, 0, power_values=bytearray([50, 50, 0, 0, 60, 60])) + # A far away vector line to ensure no reordering happens + ops.move_to(100, 100) + ops.line_to(101, 101) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + # The original ScanLine stays as a single command + scan_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.SCAN_LINE + ] + assert len(scan_indices) == 1 + + for scan_idx in scan_indices: + move_idx = scan_idx - 1 + assert ops.command_type(move_idx) == CommandType.MOVE_TO + + # Verify state on the new MoveTo for the sub-segment + move_state = ops.state(move_idx) + assert move_state is not None + assert move_state.power == pytest.approx(0.77) + assert move_state.rapid_rate == pytest.approx(5678) + assert move_state.air_assist == AirAssistMode.OFF + + # Verify state on the new ScanLinePowerCommand for the sub-segment + scan_state = ops.state(scan_idx) + assert scan_state is not None + assert scan_state.power == pytest.approx(0.77) + assert scan_state.rapid_rate == pytest.approx(5678) + assert scan_state.air_assist == AirAssistMode.OFF + + +def test_run_with_state_change_and_scanlines(mock_progress_context): + """ + Verify that ScanLine segments with different states are not reordered + across state boundaries, and that each optimized block has the correct + state. + """ + ops = Ops() + + # Part 1: Power 0.4 - Inefficient path with scanlines + ops.set_power(0.4) + ops.move_to(0, 0) + ops.scan_to(10, 0, power_values=bytearray([10])) + ops.move_to(0, 10) + ops.scan_to(10, 10, power_values=bytearray([20])) + + # State change acts as an optimization boundary + ops.set_power(0.9) + + # Part 2: Power 0.9 - Inefficient path with scanlines + ops.move_to(100, 100) + ops.scan_to(110, 100, power_values=bytearray([30])) + ops.move_to(100, 110) + ops.scan_to(110, 110, power_values=bytearray([40])) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + # Find the index where the state changes + power_change_idx = -1 + for i in range(ops.len()): + if ops.category(i) == CommandCategory.MOVING: + state = ops.state(i) + if state is not None and state.power == pytest.approx(0.9): + power_change_idx = i + break + + assert power_change_idx != -1, "A segment with power 0.9 should exist" + + # Check all moving commands before the state change + for i in range(power_change_idx): + if ops.category(i) == CommandCategory.MOVING: + state = ops.state(i) + assert ops.endpoint(i)[0] < 50, ( + "Points from Part 1 should be in first half" + ) + assert state is not None and state.power == pytest.approx(0.4), ( + "State should be power 0.4" + ) + + # Check all moving commands at and after the state change + for i in range(power_change_idx, ops.len()): + if ops.category(i) == CommandCategory.MOVING: + state = ops.state(i) + assert ops.endpoint(i)[0] > 50, ( + "Points from Part 2 should be in second half" + ) + assert state is not None and state.power == pytest.approx(0.9), ( + "State should be power 0.9" + ) + + # Also check that optimization occurred within the first block + # Initial travel: move(0,0)->scan(10,0) -> move(0,10)->scan(10,10) + # Travel is from (10,0) to (0,10) = sqrt(10^2 + 10^2) ~= 14.14 + # Optimized travel: move(0,0)->scan(10,0) -> move(10,10)->scan(0,10) + # Travel is from (10,0) to (10,10) = 10. + # We can verify this by checking the order of the Y coordinates. + y_coords = [ + ops.endpoint(i)[1] + for i in range(power_change_idx) + if ops.command_type(i) == CommandType.SCAN_LINE + ] + assert y_coords == [0, 10] or y_coords == [10, 0], ( + "Optimization should order by y-coord" + ) + + +def test_run_optimization_with_overscan_and_flip_preserves_state( + mock_progress_context, +): + """ + Tests that an overscanned ScanLine that gets flipped by the optimizer + correctly preserves its state. This simulates the real-world scenario + where the error was observed. + """ + ops = Ops() + ops.set_power(0.66) + ops.set_feed_rate(2000) + + # Path 1: A vector line from (0,0) to (10,10) + ops.move_to(0, 0) + ops.line_to(10, 10) + + # Path 2: An overscanned raster line that should be flipped. + # The content is from (30,10) to (20,10), but overscan extends it. + start_pt_overscan = (35.0, 10.0, 0.0) + end_pt_overscan = (15.0, 10.0, 0.0) + power_values = bytearray([0, 0] + [100, 120, 140] + [0, 0]) + ops.move_to(*start_pt_overscan) + ops.scan_to(*end_pt_overscan, power_values=power_values) + + # The optimizer should connect Path 1's end (10,10) to the nearest + # point on Path 2, which is its end (15,10), causing a flip. + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + # Find the scan command after optimization + scan_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.SCAN_LINE + ] + assert len(scan_indices) == 1 + flipped_scan_idx = scan_indices[0] + + # Find its preceding MoveTo command + move_idx = flipped_scan_idx - 1 + assert ops.command_type(move_idx) == CommandType.MOVE_TO + + # Check state on the new MoveTo for the flipped segment + move_state = ops.state(move_idx) + assert move_state is not None + assert move_state.power == pytest.approx(0.66) + assert move_state.feed_rate == pytest.approx(2000) + + # Check state on the flipped ScanLinePowerCommand + scan_state = ops.state(flipped_scan_idx) + assert scan_state is not None + assert scan_state.power == pytest.approx(0.66) + assert scan_state.feed_rate == pytest.approx(2000) + + # Verify the geometry and power values were flipped correctly + assert ops.endpoint(move_idx) == pytest.approx(end_pt_overscan) + assert ops.endpoint(flipped_scan_idx) == pytest.approx(start_pt_overscan) + assert bytearray(ops.scanline_data(flipped_scan_idx)) == power_values[::-1] + + +def test_workpiece_level_optimization(mock_progress_context): + """ + Test that workpiece-level optimization reorders workpieces to minimize + travel when run at per-step level (workpiece=None). + """ + ops = Ops() + ops.set_power(1.0) + + # Workpiece A at (0,0) + ops.workpiece_start("wp-a") + ops.move_to(0, 0) + ops.line_to(10, 0) + ops.workpiece_end("wp-a") + + # Workpiece C at (200, 200) - far away + ops.workpiece_start("wp-c") + ops.move_to(200, 200) + ops.line_to(210, 200) + ops.workpiece_end("wp-c") + + # Workpiece B at (10, 0) - close to A + ops.workpiece_start("wp-b") + ops.move_to(10, 0) + ops.line_to(10, 10) + ops.workpiece_end("wp-b") + + # Calculate travel before optimization + ops_copy = ops.copy() + ops_copy.preload_state() + travel_before = ops_copy.distance() - ops_copy.cut_distance() + + # Run optimizer at per-step level (workpiece=None) + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + + ops.preload_state() + travel_after = ops.distance() - ops.cut_distance() + + # Travel should be reduced + assert travel_after < travel_before, ( + f"Travel should be reduced: {travel_before} -> {travel_after}" + ) + + # Extract workpiece order after optimization + wp_order = [] + for i in range(ops.len()): + if ops.command_type(i) == CommandType.WORKPIECE_START: + wp_order.append(ops.workpiece_uid(i)) + + # Should be reordered: A, B, C (not A, C, B) + assert wp_order == ["wp-a", "wp-b", "wp-c"], ( + f"Workpieces should be reordered to A, B, C, got {wp_order}" + ) + + +def test_bezier_passes_through_optimizer(mock_progress_context): + """ + Verify that the optimizer correctly handles segments containing + BezierToCommand and does not corrupt them. + """ + ops = Ops() + ops.set_power(1.0) + + # Path 1: vector line + ops.move_to(0, 0) + ops.line_to(10, 0) + + # Path 2: bezier curve + ops.move_to(100, 0) + ops.bezier_to((110, 10, 0), (120, 10, 0), (130, 0, 0)) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 1 + c1, c2 = ops.bezier_params(bezier_indices[0]) + assert c1 == (110, 10, 0) + assert c2 == (120, 10, 0) + assert ops.endpoint(bezier_indices[0]) == (130, 0, 0) + + line_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.LINE_TO + ] + assert len(line_indices) == 1 + + moving_count = sum( + 1 + for i in range(ops.len()) + if ops.category(i) == CommandCategory.MOVING + ) + assert moving_count == 4 + + +def test_bezier_segment_flip(mock_progress_context): + """ + Verify that when the optimizer flips a segment containing a + BezierToCommand, the control points are correctly swapped. + """ + ops = Ops() + ops.set_power(1.0) + + # Path 1: ends at (10, 0) + ops.move_to(0, 0) + ops.line_to(10, 0) + + # Path 2: bezier that ends near (10, 0) — should be flipped + # to connect (10, 0) → (30, 0) via the bezier + ops.move_to(30, 0) + ops.bezier_to((25, 5, 0), (15, 5, 0), (10, 0, 0)) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + ops.preload_state() + + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 1 + idx = bezier_indices[0] + + # The bezier should be flipped: + # - end changes from original (10, 0) to (30, 0) + # - control1/control2 are swapped + assert ops.endpoint(idx) == pytest.approx((30, 0, 0)) + c1, c2 = ops.bezier_params(idx) + assert c1 == pytest.approx((15, 5, 0)) + assert c2 == pytest.approx((25, 5, 0)) + + +def test_mixed_lines_and_bezier(mock_progress_context): + """ + Verify the optimizer handles a mix of line and bezier segments, + correctly reordering them by proximity. + """ + ops = Ops() + ops.set_power(1.0) + + # Line segment at origin + ops.move_to(0, 0) + ops.line_to(10, 0) + + # Bezier segment far away + ops.move_to(100, 100) + ops.bezier_to((105, 105, 0), (115, 105, 0), (120, 100, 0)) + + # Line segment close to the first one + ops.move_to(10, 0) + ops.line_to(10, 10) + + optimizer = Optimize() + _apply(optimizer, ops, mock_progress_context) + + ops.preload_state() + travel_after = ops.distance() - ops.cut_distance() + + ops_unoptimized = Ops() + ops_unoptimized.set_power(1.0) + ops_unoptimized.move_to(0, 0) + ops_unoptimized.line_to(10, 0) + ops_unoptimized.move_to(100, 100) + ops_unoptimized.bezier_to((105, 105, 0), (115, 105, 0), (120, 100, 0)) + ops_unoptimized.move_to(10, 0) + ops_unoptimized.line_to(10, 10) + ops_unoptimized.preload_state() + travel_before = ops_unoptimized.distance() - ops_unoptimized.cut_distance() + + assert travel_after < travel_before + + bezier_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ] + assert len(bezier_indices) == 1 + line_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.LINE_TO + ] + assert len(line_indices) == 2 diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_overscan_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_overscan_transformer.py new file mode 100644 index 000000000..d73d9b8e7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_overscan_transformer.py @@ -0,0 +1,446 @@ +import math + +import pytest +from post_processors.transformers import OverscanTransformer +from raygeo.ops import Ops +from raygeo.ops.state import AirAssistMode +from raygeo.ops.types import CommandType, RasterMode, SectionType + + +def _apply(transformer, ops, settings=None): + """Run a transformer through the Rust spec dispatch.""" + if not transformer.enabled: + return + specs = [transformer.to_spec(None, None, settings)] + Ops.apply_transformers(ops, specs, progress_cb=None) + + +@pytest.fixture +def transformer() -> OverscanTransformer: + """Provides a default OverscanTransformer instance.""" + return OverscanTransformer(enabled=True, distance_mm=5.0) + + +def test_initialization_and_properties(): + """Tests the constructor and property setters.""" + t = OverscanTransformer(enabled=True, distance_mm=2.5) + assert t.enabled is True + assert t.distance_mm == 2.5 + t.distance_mm = -10.0 + assert t.distance_mm == 0.0 + t.distance_mm = 7.0 + assert t.distance_mm == 7.0 + + +def test_serialization_and_deserialization(): + """ + Tests that the transformer can be serialized to a dict and recreated. + """ + original = OverscanTransformer(enabled=False, distance_mm=3.14) + data = original.to_dict() + recreated = OverscanTransformer.from_dict(data) + assert data["name"] == "OverscanTransformer" + assert data["enabled"] is False + assert data["distance_mm"] == 3.14 + assert isinstance(recreated, OverscanTransformer) + assert recreated.enabled is False + assert recreated.distance_mm == 3.14 + + +def test_no_op_when_disabled(transformer: OverscanTransformer): + """Verify the dispatch does nothing if the transformer is disabled.""" + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 10, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + original_len = ops.len() + + transformer.enabled = False + _apply(transformer, ops) + + assert ops.len() == original_len + + +def test_no_op_with_native_overscan(transformer: OverscanTransformer): + """Verify the transformer is skipped when the driver does overscan.""" + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 10, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + original_len = ops.len() + + _apply(transformer, ops, settings={"driver_native_overscan": True}) + + assert ops.len() == original_len + + +def test_no_op_with_zero_distance(transformer: OverscanTransformer): + """Verify the dispatch does nothing if the distance is zero.""" + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 10, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + original_len = ops.len() + + transformer.distance_mm = 0.0 + _apply(transformer, ops) + + assert ops.len() == original_len + + +def test_run_with_constant_power_lines_from_rasterizer( + transformer: OverscanTransformer, +): + """ + Tests overscan on a simple constant-power line, typical of output + from the Rasterizer producer. + """ + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 20, 5) # Horizontal line, length 20mm, at z=5 + ops.line_to(30, 20, 5) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + _apply(transformer, ops) + + # Expected: Start, [Move, SP(0), Line, SP(orig), Line, SP(0), Line], End + assert ops.len() == 9 + assert ops.command_type(1) == CommandType.MOVE_TO + assert ops.endpoint(1) == pytest.approx((5.0, 20.0, 5.0)) # 10 - 5 + assert ops.command_type(5) == CommandType.LINE_TO + assert ops.endpoint(5) == pytest.approx((30.0, 20.0, 5.0)) # Original end + assert ops.command_type(7) == CommandType.LINE_TO + assert ops.endpoint(7) == pytest.approx((35.0, 20.0, 5.0)) # 30 + 5 + + +def test_preserves_state_for_constant_power_lines( + transformer: OverscanTransformer, +): + """ + Verify the overscan transformation for LineToCommands is precise, + checking for correct power state management and geometry without + relying on preload_state. This test includes an intermediate power + change to validate handling of more complex sequences. + """ + # Arrange: A sequence with two raster lines. The second line has a + # SetPower command between its MoveTo and LineTo. + ops = Ops() + ops.set_power(0.8) + ops.set_air_assist(AirAssistMode.ON) + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + # Line 1: Standard + ops.move_to(10, 20, 0) + ops.line_to(20, 20, 0) + # Line 2: With intermediate state change + ops.move_to(30, 20, 0) + ops.set_power(0.4) + ops.line_to(40, 20, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + + # Act + _apply(transformer, ops) + + # --- Verification for Line 1 --- + # Expected sequence: Move, SP(0), Line, SP(0.8), Line, SP(0), Line + assert ops.command_type(3) == CommandType.MOVE_TO + assert ops.endpoint(3) == pytest.approx((5.0, 20.0, 0.0)) + assert ops.command_type(4) == CommandType.SET_POWER and ops.power(4) == 0 + assert ops.command_type(5) == CommandType.LINE_TO + assert ops.endpoint(5) == pytest.approx((10.0, 20.0, 0.0)) + assert ops.command_type(6) == CommandType.SET_POWER and ops.power(6) == 0.8 + assert ops.command_type(7) == CommandType.LINE_TO + assert ops.endpoint(7) == pytest.approx((20.0, 20.0, 0.0)) + assert ops.command_type(8) == CommandType.SET_POWER and ops.power(8) == 0 + assert ops.command_type(9) == CommandType.LINE_TO + assert ops.endpoint(9) == pytest.approx((25.0, 20.0, 0.0)) + + # --- Verification for Line 2 --- + # The intermediate SetPower(0.4) must be preserved inside the + # overscan wrap. + # Expected sequence: Move, SP(0), Line, SP(0.4), Line, SP(0), Line + assert ops.command_type(10) == CommandType.MOVE_TO + assert ops.endpoint(10) == pytest.approx((25.0, 20.0, 0.0)) + assert ops.command_type(11) == CommandType.SET_POWER and ops.power(11) == 0 + assert ops.command_type(12) == CommandType.LINE_TO + assert ops.endpoint(12) == pytest.approx((30.0, 20.0, 0.0)) + # This is the critical check: the original intermediate SetPower + # is preserved. + # Note: Because the original buffer is extended, the power command + # is at index 3, and the original LineTo is at index 4. + assert ( + ops.command_type(13) == CommandType.SET_POWER and ops.power(13) == 0.4 + ) + assert ops.command_type(14) == CommandType.LINE_TO + assert ops.endpoint(14) == pytest.approx((40.0, 20.0, 0.0)) + assert ops.command_type(15) == CommandType.SET_POWER and ops.power(15) == 0 + assert ops.command_type(16) == CommandType.LINE_TO + assert ops.endpoint(16) == pytest.approx((45.0, 20.0, 0.0)) + + # --- Final structure check --- + # Total commands: + # 2 (header) + 1 (start) + 7 (line 1) + 7 (line 2) + 1 (end) = 18 + assert ops.len() == 18 + assert ops.command_type(0) == CommandType.SET_POWER and ops.power(0) == 0.8 + assert ops.command_type(1) == CommandType.SET_AIR_ASSIST + assert ops.command_type(2) == CommandType.OPS_SECTION_START + assert ops.command_type(17) == CommandType.OPS_SECTION_END + + +def test_run_with_variable_power_scanlines_from_depth( + transformer: OverscanTransformer, +): + """ + Tests overscan on a variable-power scanline, typical of output from + the Rasterizer producer in POWER_MODULATION mode. + """ + power_vals = bytearray(range(1, 41)) + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 20, 0) + ops.scan_to(30, 20, 0, power_values=power_vals) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + _apply(transformer, ops) + + assert ops.len() == 4 # Start, Move, ScanLine, End + + assert ops.command_type(1) == CommandType.MOVE_TO + assert ops.endpoint(1) == pytest.approx((5.0, 20.0, 0.0)) + + assert ops.command_type(2) == CommandType.SCAN_LINE + assert ops.endpoint(2) == pytest.approx((35.0, 20.0, 0.0)) + + num_pad_pixels = 10 # 5mm distance * (40px / 20mm) = 10px + pad_bytes = bytearray([0] * num_pad_pixels) + expected_power = pad_bytes + power_vals + pad_bytes + assert ops.scanline_data(2) == expected_power + + +def test_preserves_state_for_scanline_commands( + transformer: OverscanTransformer, +): + """ + Verify the overscan transformation for ScanLinePowerCommands is + precise and does not rely on preload_state. Checks for correct + geometry extension and power value padding, while preserving + preceding state commands. + """ + # Arrange: A master power setting followed by a raster section with + # a single ScanLine. This simulates a Rasterizer output. + ops = Ops() + ops.set_power(0.5) # Master power setting + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 20, 0) + ops.scan_to(20, 20, 0, power_values=bytearray([100, 200])) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + + # The transformer should have a 5mm distance from the fixture + assert transformer.distance_mm == 5.0 + + # Act + _apply(transformer, ops) + + # Assert: Manually verify the exact command sequence and their + # properties without using preload_state, which could mask bugs. + # Expected output structure: + # [0] SetPower(0.5) - Preserved from before the section + # [1] OpsSectionStart - Preserved + # [2] MoveTo(5, 20, 0) - New overscan start point + # [3] ScanLinePowerCommand - Modified with new geometry and padded power + # [4] OpsSectionEnd - Preserved + + assert ops.len() == 5 + + # 1. Check preserved master power command + assert ops.command_type(0) == CommandType.SET_POWER + assert ops.power(0) == 0.5 + + # 2. Check preserved section start + assert ops.command_type(1) == CommandType.OPS_SECTION_START + + # 3. Check new overscan MoveTo command + assert ops.command_type(2) == CommandType.MOVE_TO + assert ops.endpoint(2) == pytest.approx( + (5.0, 20.0, 0.0) + ) # Original start (10) - 5mm + + # 4. Check modified ScanLinePowerCommand + assert ops.command_type(3) == CommandType.SCAN_LINE + assert ops.endpoint(3) == pytest.approx( + (25.0, 20.0, 0.0) + ) # Original end (20) + 5mm + + # Calculate expected padding. + # Line length = 10mm. Power values length = 2. + # Pixels per mm = 2 / 10 = 0.2 + # Pad pixels = round(5.0mm * 0.2px/mm) = round(1.0) = 1 + num_pad_pixels = 1 + pad_bytes = bytearray([0] * num_pad_pixels) + expected_power_values = pad_bytes + bytearray([100, 200]) + pad_bytes + assert ops.scanline_data(3) == expected_power_values + + # 5. Check preserved section end + assert ops.command_type(4) == CommandType.OPS_SECTION_END + + +def test_does_not_modify_commands_outside_raster_section( + transformer: OverscanTransformer, +): + """ + Ensures that only commands inside a RASTER_FILL section are modified. + """ + ops = Ops() + ops.move_to(0, 0, 0) + ops.line_to(5, 5, 0) + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 10, 0) + ops.line_to(20, 10, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + original_ep0 = ops.endpoint(0) + original_ep1 = ops.endpoint(1) + + _apply(transformer, ops) + + assert ops.endpoint(0) == original_ep0 + assert ops.endpoint(1) == original_ep1 + assert ops.len() > 5 + + +def test_handles_multiple_bidirectional_lines( + transformer: OverscanTransformer, +): + """ + Tests overscan on a typical bidirectional raster pattern. + """ + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 20, 0) + ops.line_to(30, 20, 0) + ops.move_to(30, 22, 0) + ops.line_to(10, 22, 0) + ops.move_to(5, 30, 0) + ops.line_to(15, 40, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + dist = transformer.distance_mm + + _apply(transformer, ops) + + # Each line is rewritten from 2 moving commands to 4 + state changes + # so we can't just count moving commands easily. + # We check the final endpoints instead. + move_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ] + line_indices = [ + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.LINE_TO + ] + + # Expected moves: to start of overscan for each of the 3 lines + assert len(move_indices) == 3 + # Expected lines: 3 lead-in + 3 content + 3 lead-out = 9 + assert len(line_indices) == 9 + + # Check endpoints of the rewritten lines + # Line 1 + assert ops.endpoint(move_indices[0]) == pytest.approx((10 - dist, 20, 0)) + assert ops.endpoint(line_indices[2]) == pytest.approx((30 + dist, 20, 0)) + # Line 2 + assert ops.endpoint(move_indices[1]) == pytest.approx((30 + dist, 22, 0)) + assert ops.endpoint(line_indices[5]) == pytest.approx((10 - dist, 22, 0)) + # Line 3 (diagonal) + norm_v = 1 / math.sqrt(2) + offset_x = offset_y = dist * norm_v + assert ops.endpoint(move_indices[2]) == pytest.approx( + (5 - offset_x, 30 - offset_y, 0) + ) + assert ops.endpoint(line_indices[8]) == pytest.approx( + (15 + offset_x, 40 + offset_y, 0) + ) + + +def test_handles_zero_length_line(transformer: OverscanTransformer): + """ + Tests that a raster "line" that is just a point is not modified. + """ + ops = Ops() + ops.ops_section_start( + SectionType.RASTER_FILL, + "wp_123", + raster_mode=RasterMode.CONSTANT_POWER, + ) + ops.move_to(10, 10, 0) + ops.line_to(10, 10, 0) + ops.ops_section_end( + SectionType.RASTER_FILL, + raster_mode=RasterMode.CONSTANT_POWER, + ) + original_len = ops.len() + + _apply(transformer, ops) + + assert ops.len() == original_len diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_smooth_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_smooth_transformer.py new file mode 100644 index 000000000..7c789ce0a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_smooth_transformer.py @@ -0,0 +1,221 @@ +import math +from unittest.mock import Mock + +import pytest +from post_processors.transformers import Smooth +from raygeo.ops import Ops +from raygeo.ops.types import CommandType + +from tests.conftest import MockProgressContext + + +class _ProgressCallback: + def __init__(self, context): + self._context = context + + def __call__(self, progress, message): + self._context.set_progress(progress) + if message: + self._context.set_message(message) + + def is_cancelled(self): + return self._context.is_cancelled() + + +def _apply(transformer, ops, context=None): + """Run a transformer through the Rust spec dispatch.""" + specs = [transformer.to_spec(None, None, None)] + cb = _ProgressCallback(context) if context else None + Ops.apply_transformers(ops, specs, progress_cb=cb) + + +def assert_points_almost_equal(p1: tuple, p2: tuple, places=5, msg=None): + """Asserts that two 3D points are almost equal.""" + assert abs(p1[0] - p2[0]) < 10 ** (-places), ( + f"{msg} (x-coord): {p1[0]} != {p2[0]}" + ) + assert abs(p1[1] - p2[1]) < 10 ** (-places), ( + f"{msg} (y-coord): {p1[1]} != {p2[1]}" + ) + assert abs(p1[2] - p2[2]) < 10 ** (-places), ( + f"{msg} (z-coord): {p1[2]} != {p2[2]}" + ) + + +def distance_2d(p1: tuple, p2: tuple) -> float: + """Helper to calculate 2D distance.""" + return math.hypot(p1[0] - p2[0], p1[1] - p2[1]) + + +def test_initialization_and_properties(): + """Tests constructor and property setters trigger signals.""" + smoother = Smooth(enabled=True, amount=50, corner_angle_threshold=60) + smoother.changed = Mock() + + assert smoother.enabled + smoother.amount = 120 + assert smoother.amount == 100 + smoother.corner_angle_threshold = 90 + assert abs(smoother.corner_angle_threshold - 90) < 1e-9 + smoother.changed.send.assert_called() + + +def test_run_with_zero_amount(): + """Tests that the dispatch is a no-op if amount is zero.""" + ops = Ops() + ops.move_to(0, 0) + ops.line_to(10, 0) + original_ops = ops.copy() + smoother = Smooth(amount=0) + _apply(smoother, ops) + assert ops.len() == original_ops.len() + + +def test_arcs_are_linearized_and_smoothed(): + """Tests that segments with arcs are linearized and smoothed.""" + ops = Ops() + ops.move_to(0, 0) + ops.arc_to(10, 10, 5, 0, True) + smoother = Smooth(amount=50) + _apply(smoother, ops) + assert ops.len() > 2 + assert ops.command_type(0) == CommandType.MOVE_TO + assert all( + ops.command_type(i) == CommandType.LINE_TO for i in range(1, ops.len()) + ) + + +def test_smooth_open_path(): + """Tests smoothing a simple open line segment.""" + ops = Ops() + ops.move_to(0, 0, 5) + ops.line_to(50, 0, 5) + ops.line_to(100, 50, 5) + + smoother = Smooth(amount=50) + _apply(smoother, ops) + + assert ops.len() > 3, "Path should be subdivided" + + output_points = [ops.endpoint(i) for i in range(ops.len())] + + assert_points_almost_equal(output_points[0], (0, 0, 5)) + assert_points_almost_equal(output_points[-1], (100, 50, 5)) + + original_corner = 50, 0, 5 + closest_point = min( + output_points, key=lambda p: math.dist(p, original_corner) + ) + + assert closest_point[1] > 1e-9 + + +def test_corner_preservation(): + """ + Tests that sharp corners are preserved while dull ones are smoothed. + """ + ops = Ops() + ops.move_to(0, 50) + ops.line_to(50, 0) + ops.line_to(100, 50) + ops.line_to(150, 50) + + smoother = Smooth(amount=40, corner_angle_threshold=95) + _apply(smoother, ops) + + output_points = [ops.endpoint(i) for i in range(ops.len())] + + found_sharp = any(distance_2d(p, (50, 0, 0)) < 1e-5 for p in output_points) + assert found_sharp, "Sharp corner was not preserved" + + found_dull = any( + distance_2d(p, (100, 50, 0)) < 1e-5 for p in output_points + ) + assert not found_dull, "Dull corner was not smoothed" + + +def test_context_cancellation_and_progress(): + """ + Tests that progress is reported during dispatch. + """ + ops = Ops() + for i in range(10): + ops.move_to(i * 10, 0) + ops.line_to(i * 10 + 5, 5) + + context = MockProgressContext() + context._inner._progress_context.set_wrapper(context._inner) + smoother = Smooth(amount=50) + + _apply(smoother, ops, context=context) + + assert len(context.progress_calls) > 0 + + +def test_context_cancellation_skips(): + """Tests that a cancelled context aborts the dispatch.""" + ops = Ops() + ops.move_to(0, 0) + ops.line_to(10, 0) + + context = MockProgressContext() + context.set_cancelled(True) + smoother = Smooth(amount=50) + with pytest.raises(RuntimeError, match="cancelled"): + _apply(smoother, ops, context=context) + + +def test_bezier_passes_through_unchanged(): + """ + Bezier commands should pass through the smooth transformer + without being modified — they're already smooth curves. + """ + ops = Ops() + ops.move_to(0, 0) + ops.bezier_to((10, 20, 0), (30, 20, 0), (40, 0, 0)) + + smoother = Smooth(amount=50) + _apply(smoother, ops) + + bezier_indices = ops.indices_of(CommandType.BEZIER_TO) + assert len(bezier_indices) == 1 + bezier_idx = bezier_indices[0] + c1, c2 = ops.bezier_params(bezier_idx) + assert c1 == (10, 20, 0) + assert c2 == (30, 20, 0) + assert ops.endpoint(bezier_idx) == (40, 0, 0) + + +def test_mixed_lines_and_bezier(): + """ + A segment with both lines and bezier should pass through unchanged. + A line-only segment in the same ops should still be smoothed. + """ + ops = Ops() + ops.set_power(1.0) + + # Segment 1: line-only (should be smoothed) + ops.move_to(0, 0, 0) + ops.line_to(50, 0, 0) + ops.line_to(100, 50, 0) + + # Segment 2: contains a bezier (should pass through) + ops.move_to(0, 0, 0) + ops.line_to(10, 0, 0) + ops.bezier_to((20, 10, 0), (30, 10, 0), (40, 0, 0)) + + smoother = Smooth(amount=50) + _apply(smoother, ops) + + bezier_indices = ops.indices_of(CommandType.BEZIER_TO) + assert len(bezier_indices) == 1 + bezier_idx = bezier_indices[0] + c1, c2 = ops.bezier_params(bezier_idx) + assert c1 == (20, 10, 0) + assert c2 == (30, 10, 0) + assert ops.endpoint(bezier_idx) == (40, 0, 0) + + line_count = len(ops.indices_of(CommandType.LINE_TO)) + # Segment 1 was smoothed (subdivided into more lines) + # Segment 2's line is preserved but not smoothed + assert line_count > 2 diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_tabs_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_tabs_transformer.py new file mode 100644 index 000000000..ae690ad0a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_tabs_transformer.py @@ -0,0 +1,231 @@ +import math + +from post_processors.transformers.tabs_transformer import _ClipPoint +from raygeo.geo.shape.bezier import get_bezier_point_at +from raygeo.ops import Ops +from raygeo.ops.types import CommandCategory, CommandType, SectionType + +_P0 = (0.0, 0.0, 0.0) +_C1 = (33.0, 10.0, 0.0) +_C2 = (66.0, 10.0, 0.0) +_P1 = (100.0, 0.0, 0.0) + + +def _bezier_point_2d(t): + return get_bezier_point_at(_P0[:2], _C1[:2], _C2[:2], _P1[:2], t) + + +def _make_sectioned_bezier_ops(): + ops = Ops() + uid = "test-wp" + ops.ops_section_start( + section_type=SectionType.VECTOR_OUTLINE, + workpiece_uid=uid, + ) + ops.move_to(*_P0) + ops.bezier_to(_C1, _C2, _P1) + ops.ops_section_end(section_type=SectionType.VECTOR_OUTLINE) + return ops + + +def _make_sectioned_mixed_ops(): + ops = Ops() + uid = "test-wp" + ops.ops_section_start( + section_type=SectionType.VECTOR_OUTLINE, + workpiece_uid=uid, + ) + ops.move_to(0.0, 0.0, 0.0) + ops.line_to(30.0, 0.0, 0.0) + ops.bezier_to( + (40.0, 10.0, 0.0), + (60.0, 10.0, 0.0), + (70.0, 0.0, 0.0), + ) + ops.line_to(100.0, 0.0, 0.0) + ops.ops_section_end(section_type=SectionType.VECTOR_OUTLINE) + return ops + + +def _count_in_ops(ops, cmd_type): + return sum(1 for i in range(ops.len()) if ops.command_type(i) == cmd_type) + + +# ------------------------------------------------------------------ +# Gap mode tests via ops.apply_tab_gaps +# ------------------------------------------------------------------ + + +def test_bezier_no_clips_passes_through(): + ops = _make_sectioned_bezier_ops() + ops.apply_tab_gaps([]) + + assert _count_in_ops(ops, CommandType.BEZIER_TO) == 1 + assert _count_in_ops(ops, CommandType.MOVE_TO) >= 1 + + +def test_bezier_gap_splits_into_two_beziers(): + ops = _make_sectioned_bezier_ops() + mid = _bezier_point_2d(0.5) + clips = [_ClipPoint(x=mid[0], y=mid[1], width=10.0)] + ops.apply_tab_gaps(clips) + + bezier_count = _count_in_ops(ops, CommandType.BEZIER_TO) + assert bezier_count == 2, f"Expected 2 bezier segments, got {bezier_count}" + + +def test_bezier_gap_preserves_start_and_end(): + ops = _make_sectioned_bezier_ops() + mid = _bezier_point_2d(0.5) + clips = [_ClipPoint(x=mid[0], y=mid[1], width=10.0)] + ops.apply_tab_gaps(clips) + + first_moving = None + last_moving = None + for i in range(ops.len()): + if ops.category(i) == CommandCategory.MOVING: + pt = ops.endpoint(i) + if first_moving is None: + first_moving = pt + last_moving = pt + + assert first_moving is not None + assert math.dist(first_moving[:2], _P0[:2]) < 1e-3 + + assert last_moving is not None + assert math.dist(last_moving[:2], _P1[:2]) < 1e-3 + + +def test_bezier_gap_multiple_clips(): + ops = _make_sectioned_bezier_ops() + pt25 = _bezier_point_2d(0.25) + pt75 = _bezier_point_2d(0.75) + clips = [ + _ClipPoint(x=pt25[0], y=pt25[1], width=6.0), + _ClipPoint(x=pt75[0], y=pt75[1], width=6.0), + ] + ops.apply_tab_gaps(clips) + + bezier_count = _count_in_ops(ops, CommandType.BEZIER_TO) + assert bezier_count >= 3 + + last_end = None + for ri in range(ops.len() - 1, -1, -1): + ct = ops.command_type(ri) + if ct in (CommandType.BEZIER_TO, CommandType.LINE_TO): + last_end = ops.endpoint(ri) + break + assert last_end is not None + assert math.dist(last_end[:2], _P1[:2]) < 1e-3 + + +def test_mixed_lines_and_bezier_gap(): + ops = _make_sectioned_mixed_ops() + mid = get_bezier_point_at( + (30.0, 0.0), (40.0, 10.0), (60.0, 10.0), (70.0, 0.0), 0.5 + ) + clips = [_ClipPoint(x=mid[0], y=mid[1], width=10.0)] + ops.apply_tab_gaps(clips) + + has_bezier = _count_in_ops(ops, CommandType.BEZIER_TO) > 0 + has_line = _count_in_ops(ops, CommandType.LINE_TO) > 0 + assert has_bezier + assert has_line + + +def test_bezier_gap_via_apply_tab_gaps(): + ops = _make_sectioned_bezier_ops() + mid = _bezier_point_2d(0.5) + clips = [_ClipPoint(x=mid[0], y=mid[1], width=10.0)] + ops.apply_tab_gaps(clips) + + bezier_count = _count_in_ops(ops, CommandType.BEZIER_TO) + assert bezier_count == 2 + + +# ------------------------------------------------------------------ +# Power mode tests via ops.apply_tab_power +# ------------------------------------------------------------------ + + +def test_bezier_power_mode_splits_and_inserts_power(): + ops = _make_sectioned_bezier_ops() + mid = _bezier_point_2d(0.5) + clips = [_ClipPoint(x=mid[0], y=mid[1], width=10.0)] + tab_power = 0.3 + original_power = 1.0 + ops.apply_tab_power(clips, tab_power, original_power) + + bezier_count = _count_in_ops(ops, CommandType.BEZIER_TO) + power_count = _count_in_ops(ops, CommandType.SET_POWER) + + assert bezier_count >= 2 + assert power_count >= 2 + + powers = [ + ops.power(i) + for i in range(ops.len()) + if ops.command_type(i) == CommandType.SET_POWER + ] + assert tab_power in powers + assert original_power in powers + + +def test_bezier_power_mode_no_overlap(): + ops = _make_sectioned_bezier_ops() + clips = [_ClipPoint(x=200.0, y=200.0, width=10.0)] + tab_power = 0.3 + original_power = 1.0 + ops.apply_tab_power(clips, tab_power, original_power) + + bezier_count = _count_in_ops(ops, CommandType.BEZIER_TO) + assert bezier_count == 1 + + bezier_idx = next( + i + for i in range(ops.len()) + if ops.command_type(i) == CommandType.BEZIER_TO + ) + assert ops.endpoint(bezier_idx) == _P1 + + +def test_mixed_lines_and_bezier_power(): + ops = _make_sectioned_mixed_ops() + mid = get_bezier_point_at( + (30.0, 0.0), (40.0, 10.0), (60.0, 10.0), (70.0, 0.0), 0.5 + ) + clips = [_ClipPoint(x=mid[0], y=mid[1], width=10.0)] + tab_power = 0.3 + original_power = 1.0 + ops.apply_tab_power(clips, tab_power, original_power) + + bezier_count = _count_in_ops(ops, CommandType.BEZIER_TO) + power_count = _count_in_ops(ops, CommandType.SET_POWER) + + assert bezier_count >= 1 + assert power_count >= 1 + + +# ------------------------------------------------------------------ +# Non-curve path tests +# ------------------------------------------------------------------ + + +def test_non_curve_path_unchanged_behavior(): + ops = Ops() + ops.ops_section_start( + section_type=SectionType.VECTOR_OUTLINE, + workpiece_uid="test", + ) + ops.move_to(0, 0) + ops.line_to(100, 0) + ops.ops_section_end(section_type=SectionType.VECTOR_OUTLINE) + + clips = [_ClipPoint(x=50.0, y=0.0, width=10.0)] + ops.apply_tab_gaps(clips) + + line_count = _count_in_ops(ops, CommandType.LINE_TO) + move_count = _count_in_ops(ops, CommandType.MOVE_TO) + + assert line_count == 2 + assert move_count >= 2 diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/conftest.py b/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/conftest.py new file mode 100644 index 000000000..0a35f6f7a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/conftest.py @@ -0,0 +1,47 @@ +"""UI fixtures for post_processors addon UI 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 +from rayforge.shared import tasker +from rayforge.shared.tasker.manager import TaskManager +from rayforge.shared.util.glib import idle_add + + +@pytest.fixture +def ui_task_mgr(): + """A test-isolated TaskManager for sync UI tests.""" + tm = TaskManager(main_thread_scheduler=idle_add) + yield tm + if tm.has_tasks(): + tm.shutdown() + + +@pytest.fixture +def ui_context(ui_task_mgr, monkeypatch, tmp_path): + """A UI context for post_processors addon tests.""" + temp_config_dir = tmp_path / "config" + temp_dialect_dir = temp_config_dir / "dialects" + temp_machine_dir = temp_config_dir / "machines" + temp_addons_dir = temp_config_dir / "addons" + monkeypatch.setattr(config_module, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(config_module, "DIALECT_DIR", temp_dialect_dir) + monkeypatch.setattr(config_module, "MACHINE_DIR", temp_machine_dir) + monkeypatch.setattr(config_module, "ADDONS_DIR", temp_addons_dir) + monkeypatch.setattr( + config_module, "CONFIG_FILE", temp_config_dir / "config.yaml" + ) + monkeypatch.setattr( + config_module, "AI_CONFIG_FILE", temp_config_dir / "ai.yaml" + ) + monkeypatch.setattr(tasker.task_mgr, "_instance", ui_task_mgr) + + context = get_context() + yield context + + asyncio.run(context.shutdown()) + context_module._context_instance = None diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_crop_group_units.py b/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_crop_group_units.py new file mode 100644 index 000000000..b28e589a0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_crop_group_units.py @@ -0,0 +1,42 @@ +# flake8: noqa: E402 +"""UI tests: post-processor transformer settings groups show units. + +The crop offset (and other length rows) must display and convert in the +user's length unit via LengthSpinRow. +""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest +from post_processors.transformers import CropTransformer +from post_processors.widgets.crop_group import CropSettingsGroup + + +class _Page: + use_expanders = True + + +def _build_group(ui_context): + transformer = CropTransformer(offset=25.4) + group = CropSettingsGroup("Crop", transformer, _Page()) + return group, transformer + + +@pytest.mark.ui +def test_crop_offset_shows_mm_by_default(ui_context): + group, _transformer = _build_group(ui_context) + + assert group.offset_row.get_value_in_base_units() == pytest.approx(25.4) + assert group.offset_row.get_value() == pytest.approx(25.4, abs=1e-2) + + +@pytest.mark.ui +def test_crop_offset_shows_inches_when_imperial(ui_context): + ui_context.config.unit_preferences["length"] = "in" + group, _transformer = _build_group(ui_context) + + assert group.offset_row.get_value_in_base_units() == pytest.approx(25.4) + assert group.offset_row.get_value() == pytest.approx(1.0, abs=1e-2) diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/de/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/de/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..3272e75fd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/de/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,206 @@ +# German translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "An physischer Position ausrichten" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "Druck & Schnitt" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "Zurück" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "Zurücksetzen" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "Abbrechen" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "Weiter" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "Anwenden" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "Anleitung" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" +"Klicke auf zwei Ausrichtungspunkte im Design. Diese werden im nächsten " +"Schritt physischen Positionen zugeordnet." + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "Ausgewählte Punkte" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "Punkt 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "Kein Punkt ausgewählt" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "Punkt 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "Status" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "Klicke auf den ersten Ausrichtungspunkt im Design." + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" +"Bewege den Laser zur physischen Position jedes Punktes und nimm die Position " +"auf." + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "Verfahrdistanz" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "Abstand" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "Distanz-Vorlagen" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "Fokuslaser umschalten" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "Fokuslaser" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "Positionen" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "Aufzeichnen" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "Zur aufgezeichneten Position fahren" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "Position 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "Nicht aufgezeichnet" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "Position 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "Laserposition" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X: --- Y: ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "Transformationsvorschau" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "Überprüfe die berechnete Ausrichtungstransformation vor dem Anwenden." + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "Transformation" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "Verschiebung" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "Drehung" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "Skalierung zulassen" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "Skalierung" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "Punkt ausgewählt" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "Klicke auf den zweiten Ausrichtungspunkt im Design." + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "Beide Punkte ausgewählt. Klicke auf Weiter zum Fortfahren." + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "Punkte sind zu nah beieinander. Wähle Punkte weiter auseinander." + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "Laser mit Fokusleistung einschalten, um Position zu finden" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "Fokusleistung in den Lasereinstellungen festlegen, um zu aktivieren" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" +"Hinweis: Skalierung ist gesperrt. Punkt 2 stimmt möglicherweise nicht exakt " +"überein, wenn die physische Distanz von der Design-Distanz abweicht." + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "Ausrichtung angewendet" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/en/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/en/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..1f8e65426 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/en/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,206 @@ +# English translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "Align to Physical Position" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "Print & Cut" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "Back" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "Reset" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "Cancel" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "Next" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "Apply" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "Instructions" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "Picked Points" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "Point 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "No point picked" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "Point 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "Status" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "Click on the first alignment point on the design." + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" +"Jog the laser to the physical location of each point, then record the " +"position." + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "Jog Distance" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "Distance Presets" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "Toggle focus laser" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "Focus Laser" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "Positions" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "Record" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "Go to recorded position" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "Position 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "Not recorded" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "Position 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "Laser Position" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X: --- Y: ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "Transform Preview" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "Review the computed alignment transform before applying it." + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "Transform" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "Translation" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "Rotation" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "Allow scaling" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "Scale" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "Point picked" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "Click on the second alignment point on the design." + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "Both points selected. Click Next to continue." + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "Points are too close. Pick points further apart." + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "Turn on laser at focus power to locate position" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "Set a focus power in laser preferences to enable" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "Alignment applied" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/es/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/es/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..c20ebc13d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/es/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,207 @@ +# Spanish translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "Alinear a posición física" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "Imprimir y Cortar" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "Atrás" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "Restablecer" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "Cancelar" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "Siguiente" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "Aplicar" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "Instrucciones" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" +"Haz clic en dos puntos de alineación en el diseño. Estos se correspondrán " +"con ubicaciones físicas en el siguiente paso." + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "Puntos seleccionados" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "Punto 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "Ningún punto seleccionado" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "Punto 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "Estado" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "Haz clic en el primer punto de alineación en el diseño." + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" +"Mueve el láser a la ubicación física de cada punto, luego registra la " +"posición." + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "Distancia de desplazamiento" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "Distancia" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "Distancias predefinidas" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "Alternar láser de enfoque" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "Láser de enfoque" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "Posiciones" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "Registrar" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "Ir a la posición registrada" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "Posición 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "No registrado" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "Posición 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "Posición del láser" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X: --- Y: ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "Vista previa de transformación" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "Revisa la transformación de alineación calculada antes de aplicarla." + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "Transformación" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "Traslación" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "Rotación" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "Permitir escalado" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "Escala" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "Punto seleccionado" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "Haz clic en el segundo punto de alineación en el diseño." + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "Ambos puntos seleccionados. Haz clic en Siguiente para continuar." + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "Los puntos están demasiado cerca. Selecciona puntos más separados." + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "Encender láser con potencia de enfoque para ubicar posición" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "" +"Establece una potencia de enfoque en preferencias del láser para activar" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" +"Nota: Escala bloqueada. El punto 2 puede no alinearse exactamente si la " +"distancia física difiere de la distancia del diseño." + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "Alineación aplicada" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/fr/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/fr/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..0c563a6e1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/fr/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,209 @@ +# French translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "Aligner à la position physique" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "Imprimer et Couper" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "Retour" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "Réinitialiser" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "Annuler" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "Suivant" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "Appliquer" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "Instructions" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" +"Cliquez sur deux points d'alignement sur le design. Ils seront associés aux " +"emplacements physiques à l'étape suivante." + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "Points sélectionnés" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "Point 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "Aucun point sélectionné" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "Point 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "État" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "Cliquez sur le premier point d'alignement sur le design." + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" +"Déplacez le laser à l'emplacement physique de chaque point, puis enregistrez " +"la position." + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "Distance de déplacement" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "Distance" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "Préréglages de distance" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "Basculer le laser de focalisation" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "Laser de focalisation" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "Positions" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "Enregistrer" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "Aller à la position enregistrée" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "Position 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "Non enregistrée" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "Position 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "Position du laser" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X : --- Y : ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "Aperçu de la transformation" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "Vérifiez la transformation d'alignement calculée avant de l'appliquer." + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "Transformation" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "Translation" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "Rotation" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "Autoriser la mise à l'échelle" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "Échelle" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "Point sélectionné" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "Cliquez sur le deuxième point d'alignement sur le design." + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "Les deux points sont sélectionnés. Cliquez sur Suivant pour continuer." + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "Les points sont trop proches. Sélectionnez des points plus éloignés." + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "" +"Allumer le laser à la puissance de focalisation pour repérer la position" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "" +"Définissez une puissance de focalisation dans les préférences du laser pour " +"activer" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" +"Note : L'échelle est verrouillée. Le point 2 peut ne pas s'aligner " +"exactement si la distance physique diffère de la distance du design." + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "Alignement appliqué" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/print_and_cut.pot b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/print_and_cut.pot new file mode 100644 index 000000000..f020b7149 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/print_and_cut.pot @@ -0,0 +1,200 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "" + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/pt/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/pt/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..4e1c5aa5c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/pt/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,205 @@ +# Portuguese translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "Alinhar à posição física" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "Imprimir e Cortar" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "Voltar" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "Redefinir" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "Cancelar" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "Avançar" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "Aplicar" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "Instruções" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" +"Clique em dois pontos de alinhamento no design. Eles serão correspondidos a " +"localizações físicas na próxima etapa." + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "Pontos selecionados" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "Ponto 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "Nenhum ponto selecionado" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "Ponto 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "Status" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "Clique no primeiro ponto de alinhamento no design." + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" +"Mova o laser para a localização física de cada ponto e registre a posição." + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "Distância de deslocamento" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "Distância" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "Distâncias predefinidas" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "Alternar laser de foco" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "Laser de foco" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "Posições" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "Registrar" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "Ir para a posição registrada" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "Posição 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "Não registrado" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "Posição 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "Posição do laser" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X: --- Y: ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "Pré-visualização da transformação" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "Revise a transformação de alinhamento calculada antes de aplicá-la." + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "Transformação" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "Translação" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "Rotação" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "Permitir escalonamento" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "Escala" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "Ponto selecionado" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "Clique no segundo ponto de alinhamento no design." + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "Ambos os pontos selecionados. Clique em Avançar para continuar." + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "Os pontos estão muito próximos. Selecione pontos mais distantes." + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "Ligar laser na potência de foco para localizar a posição" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "Defina uma potência de foco nas preferências do laser para ativar" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" +"Nota: Escala bloqueada. O ponto 2 pode não se alinhar exatamente se a " +"distância física diferir da distância do design." + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "Alinhamento aplicado" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/uk/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/uk/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..57e1398ba --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/uk/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,207 @@ +# Ukrainian translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "Вирівняти за фізичною позицією" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "Друк і різання" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "Назад" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "Скинути" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "Скасувати" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "Далі" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "Застосувати" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "Інструкції" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "" +"Натисніть на дві точки вирівнювання на дизайні. Вони будуть зіставлені з " +"фізичними позиціями на наступному кроці." + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "Вибрані точки" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "Точка 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "Точку не вибрано" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "Точка 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "Статус" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "Натисніть на першу точку вирівнювання на дизайні." + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "" +"Перемістіть лазер до фізичної позиції кожної точки, потім запишіть позицію." + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "Відстань переміщення" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "Відстань" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "Шаблони відстані" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "Перемкнути лазер фокусування" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "Лазер фокусування" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "Позиції" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "Записати" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "Перейти до записаної позиції" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "Позиція 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "Не записано" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "Позиція 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "Позиція лазера" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X: --- Y: ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "Попередній перегляд перетворення" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "Перегляньте обчислене перетворення вирівнювання перед застосуванням." + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "Перетворення" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "Зміщення" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "Обертання" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "Дозволити масштабування" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "Масштаб" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "Точку вибрано" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "Натисніть на другу точку вирівнювання на дизайні." + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "Обидві точки вибрано. Натисніть Далі, щоб продовжити." + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "Точки занадто близько. Виберіть точки далі одна від одної." + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "Увімкнути лазер на потужності фокусування для визначення позиції" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "" +"Встановіть потужність фокусування у налаштуваннях лазера, щоб увімкнути" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "" +"Примітка: Масштаб заблоковано. Точка 2 може не вирівнятися точно, якщо " +"фізична відстань відрізняється від відстані дизайну." + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "Вирівнювання застосовано" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/zh_CN/LC_MESSAGES/print_and_cut.po b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/zh_CN/LC_MESSAGES/print_and_cut.po new file mode 100644 index 000000000..488d58934 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/locale/zh_CN/LC_MESSAGES/print_and_cut.po @@ -0,0 +1,199 @@ +# Chinese translations for PACKAGE package. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Automatically generated, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-06 00:18+0200\n" +"PO-Revision-Date: 2026-05-17 02:10+0200\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: print_and_cut/frontend.py +msgid "Align to Physical Position" +msgstr "对齐到物理位置" + +#: print_and_cut/wizard.py +msgid "Print & Cut" +msgstr "打印与切割" + +#: print_and_cut/wizard.py +msgid "Back" +msgstr "返回" + +#: print_and_cut/wizard.py +msgid "Reset" +msgstr "重置" + +#: print_and_cut/wizard.py +msgid "Cancel" +msgstr "取消" + +#: print_and_cut/wizard.py +msgid "Next" +msgstr "下一步" + +#: print_and_cut/wizard.py +msgid "Apply" +msgstr "应用" + +#: print_and_cut/wizard.py +msgid "Instructions" +msgstr "说明" + +#: print_and_cut/wizard.py +msgid "" +"Click on two alignment points on the design. These will be matched to " +"physical locations in the next step." +msgstr "在设计中点击两个对齐点。这些点将在下一步中与物理位置匹配。" + +#: print_and_cut/wizard.py +msgid "Picked Points" +msgstr "已选点" + +#: print_and_cut/wizard.py +msgid "Point 1" +msgstr "点 1" + +#: print_and_cut/wizard.py +msgid "No point picked" +msgstr "未选取点" + +#: print_and_cut/wizard.py +msgid "Point 2" +msgstr "点 2" + +#: print_and_cut/wizard.py +msgid "Status" +msgstr "状态" + +#: print_and_cut/wizard.py +msgid "Click on the first alignment point on the design." +msgstr "在设计中点击第一个对齐点。" + +#: print_and_cut/wizard.py +msgid "" +"Jog the laser to the physical location of each point, then record the " +"position." +msgstr "将激光移动到每个点的物理位置,然后记录该位置。" + +#: print_and_cut/wizard.py +msgid "Jog Distance" +msgstr "移动距离" + +#: print_and_cut/wizard.py +msgid "Distance" +msgstr "距离" + +#: print_and_cut/wizard.py +msgid "Distance Presets" +msgstr "距离预设" + +#: print_and_cut/wizard.py +msgid "Toggle focus laser" +msgstr "切换聚焦激光" + +#: print_and_cut/wizard.py +msgid "Focus Laser" +msgstr "聚焦激光" + +#: print_and_cut/wizard.py +msgid "Positions" +msgstr "位置" + +#: print_and_cut/wizard.py +msgid "Record" +msgstr "记录" + +#: print_and_cut/wizard.py +msgid "Go to recorded position" +msgstr "前往已记录的位置" + +#: print_and_cut/wizard.py +msgid "Position 1" +msgstr "位置 1" + +#: print_and_cut/wizard.py +msgid "Not recorded" +msgstr "未记录" + +#: print_and_cut/wizard.py +msgid "Position 2" +msgstr "位置 2" + +#: print_and_cut/wizard.py +msgid "Laser Position" +msgstr "激光位置" + +#: print_and_cut/wizard.py +msgid "X: --- Y: ---" +msgstr "X: --- Y: ---" + +#: print_and_cut/wizard.py +msgid "Transform Preview" +msgstr "变换预览" + +#: print_and_cut/wizard.py +msgid "Review the computed alignment transform before applying it." +msgstr "在应用之前检查计算的对齐变换。" + +#: print_and_cut/wizard.py +msgid "Transform" +msgstr "变换" + +#: print_and_cut/wizard.py +msgid "Translation" +msgstr "平移" + +#: print_and_cut/wizard.py +msgid "Rotation" +msgstr "旋转" + +#: print_and_cut/wizard.py +msgid "Allow scaling" +msgstr "允许缩放" + +#: print_and_cut/wizard.py +msgid "Scale" +msgstr "缩放" + +#: print_and_cut/wizard.py +msgid "Point picked" +msgstr "已选取点" + +#: print_and_cut/wizard.py +msgid "Click on the second alignment point on the design." +msgstr "在设计中点击第二个对齐点。" + +#: print_and_cut/wizard.py +msgid "Both points selected. Click Next to continue." +msgstr "两个点均已选取。点击下一步继续。" + +#: print_and_cut/wizard.py +msgid "Points are too close. Pick points further apart." +msgstr "点之间距离太近。请选择距离更远的点。" + +#: print_and_cut/wizard.py +msgid "Turn on laser at focus power to locate position" +msgstr "以聚焦功率开启激光以定位" + +#: print_and_cut/wizard.py +msgid "Set a focus power in laser preferences to enable" +msgstr "在激光偏好设置中设定聚焦功率以启用" + +#: print_and_cut/wizard.py +msgid "" +"Note: Scale is locked. Point 2 may not align exactly if the physical " +"distance differs from the design distance." +msgstr "注意:缩放已锁定。如果物理距离与设计距离不同,点2可能无法精确对齐。" + +#: print_and_cut/wizard.py +msgid "Alignment applied" +msgstr "对齐已应用" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/__init__.py b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/__init__.py new file mode 100644 index 000000000..9cafe58c3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/__init__.py @@ -0,0 +1,3 @@ +""" +Print & Cut - Align workpieces to physical objects using registration marks. +""" diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/frontend.py b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/frontend.py new file mode 100644 index 000000000..c8c9a66b2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/frontend.py @@ -0,0 +1,75 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Gio + +from rayforge.core.group import Group +from rayforge.core.hooks import hookimpl +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.action_registry import ( + MenuPlacement, + action_registry, +) +from rayforge.ui_gtk.actions import action_extension_registry + +ADDON_NAME = "print_and_cut" +logger = logging.getLogger(__name__) + + +def _register_actions(action_manager): + action = Gio.SimpleAction.new("align-workpiece", None) + + def on_activate(action, param): + window = action_manager.win + surface = window.surface + selected = surface.get_selected_top_level_items() + if len(selected) != 1: + return + item = selected[0] + if not isinstance(item, (WorkPiece, Group)): + return + from rayforge.context import get_context + + from .wizard import PrintAndCutWizard + + ctx = get_context() + machine = ctx.machine + if not machine: + return + wizard = PrintAndCutWizard( + parent=window, + item=item, + machine=machine, + machine_cmd=window.machine_cmd, + editor=window.doc_editor, + ) + wizard.present() + + action.connect("activate", on_activate) + action_manager.win.add_action(action) + action_manager.actions["align-workpiece"] = action + action_registry.register( + action_name="align-workpiece", + action=action, + addon_name=ADDON_NAME, + label=_("Align to Physical Position"), + menu=MenuPlacement(menu_id="tools", priority=50), + ) + + +def _update_action_states(action_manager): + selected = action_manager.win.surface.get_selected_top_level_items() + action = action_manager.actions.get("align-workpiece") + if action: + enabled = len(selected) == 1 and isinstance( + selected[0], (WorkPiece, Group) + ) + action.set_enabled(enabled) + + +@hookimpl +def register_actions(action_registry): + action_extension_registry.register_setup(_register_actions, ADDON_NAME) + action_extension_registry.register_state_update( + _update_action_states, ADDON_NAME + ) diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/pick_surface.py b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/pick_surface.py new file mode 100644 index 000000000..36de18483 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/pick_surface.py @@ -0,0 +1,374 @@ +import logging +import math + +import cairo +from blinker import Signal +from gi.repository import Gdk, Graphene, Gtk +from raygeo.geo import Matrix + +from rayforge.core.group import Group +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.canvas import Canvas + +logger = logging.getLogger(__name__) + +MARKER_RADIUS = 4.0 +HIT_RADIUS = 12.0 +POINT_COLOR_1 = (0.18, 0.80, 0.44, 0.9) +POINT_COLOR_2 = (0.29, 0.56, 0.85, 0.9) +DASH_COLOR = (0.5, 0.5, 0.5, 0.7) + +MIN_ZOOM_FACTOR = 0.1 +MAX_PIXELS_PER_MM = 100.0 + + +class PickSurface(Canvas): + """A canvas for picking alignment points on a workpiece or group image. + + Displays the item at its natural size fitted to view and reports + click coordinates in normalized item-local space (0-1, origin at + bottom-left, Y-up). Supports scroll-to-zoom and middle-button panning. + """ + + def __init__(self, item: WorkPiece | Group, **kwargs): + super().__init__(**kwargs) + self._item = item + self._is_group = isinstance(item, Group) + nw, nh = item.natural_size + self._width_mm = max(nw, 1e-9) + self._height_mm = max(nh, 1e-9) + + self.zoom_level: float = 1.0 + self.pan_x_mm: float = 0.0 + self.pan_y_mm: float = 0.0 + self._base_scale: float = 0.0 + self._base_offset_x: float = 0.0 + self._base_offset_y: float = 0.0 + self._base_img_h: float = 0.0 + + self._pick_phase: int = 0 + self._point1: tuple[float, float] | None = None + self._point2: tuple[float, float] | None = None + self._dragging: int | None = None + + self.point_picked = Signal() + self.points_reset = Signal() + self.points_changed = Signal() + + self._click_gesture = Gtk.GestureClick() + self._click_gesture.set_button(Gdk.BUTTON_PRIMARY) + self._click_gesture.connect("pressed", self._on_click) + self._click_gesture.set_propagation_phase(Gtk.PropagationPhase.BUBBLE) + self.add_controller(self._click_gesture) + + self._pick_drag_gesture = Gtk.GestureDrag.new() + self._pick_drag_gesture.set_button(Gdk.BUTTON_PRIMARY) + self._pick_drag_gesture.connect("drag-begin", self._on_drag_begin) + self._pick_drag_gesture.connect("drag-update", self._on_drag_update) + self._pick_drag_gesture.connect("drag-end", self._on_drag_end) + self.add_controller(self._pick_drag_gesture) + + self._pan_gesture = Gtk.GestureDrag.new() + self._pan_gesture.set_button(Gdk.BUTTON_MIDDLE) + self._pan_gesture.connect("drag-begin", self._on_pan_begin) + self._pan_gesture.connect("drag-update", self._on_pan_update) + self.add_controller(self._pan_gesture) + + self._scroll_controller = Gtk.EventControllerScroll.new( + Gtk.EventControllerScrollFlags.VERTICAL + ) + self._scroll_controller.connect("scroll", self._on_scroll) + self.add_controller(self._scroll_controller) + + self._motion_controller = Gtk.EventControllerMotion() + self._motion_controller.connect("motion", self._on_motion) + self.add_controller(self._motion_controller) + + self._hover_pos: tuple[float, float] | None = None + self._drag_start_px: tuple[float, float] | None = None + self._cached_surface: cairo.ImageSurface | None = None + self._cached_ppmm: float = 0.0 + self._pan_start_x_mm: float = 0.0 + self._pan_start_y_mm: float = 0.0 + + self.set_cursor(Gdk.Cursor.new_from_name("crosshair")) + + def _rebuild_view_transform(self): + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w == 0 or widget_h == 0: + return + + scale_x = widget_w / self._width_mm + scale_y = widget_h / self._height_mm + base_scale = min(scale_x, scale_y) + + img_w = self._width_mm * base_scale + img_h = self._height_mm * base_scale + base_offset_x = (widget_w - img_w) / 2 + base_offset_y = (widget_h - img_h) / 2 + + self._base_scale = base_scale + self._base_offset_x = base_offset_x + self._base_offset_y = base_offset_y + self._base_img_h = img_h + + m_pan = Matrix.translation(-self.pan_x_mm, -self.pan_y_mm) + m_scale = Matrix.translation(0, img_h) @ Matrix.scale( + base_scale, -base_scale + ) + m_zoom = Matrix.scale(self.zoom_level, self.zoom_level) + m_offset = Matrix.translation(base_offset_x, base_offset_y) + + self.view_transform = m_offset @ m_zoom @ m_scale @ m_pan + + new_ppmm = base_scale * self.zoom_level + if abs(new_ppmm - self._cached_ppmm) > 0.01: + self._cached_surface = None + self._cached_ppmm = new_ppmm + + self.queue_draw() + + def _get_image_surface(self) -> cairo.ImageSurface | None: + if self._cached_surface is not None: + return self._cached_surface + ppmm = self._cached_ppmm + if ppmm <= 0: + return None + w = max(int(self._width_mm * ppmm), 1) + h = max(int(self._height_mm * ppmm), 1) + self._cached_surface = self._item.render_to_pixels(w, h) + return self._cached_surface + + def do_size_allocate(self, width, height, baseline): + super().do_size_allocate(width, height, baseline) + self._rebuild_view_transform() + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + width = self.get_width() + height = self.get_height() + ctx = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) + + img_surface = self._get_image_surface() + if img_surface is not None: + ctx.save() + cairo_matrix = cairo.Matrix(*self.view_transform.for_cairo()) + ctx.transform(cairo_matrix) + ctx.translate(0, self._height_mm) + ctx.scale(1, -1) + img_w = img_surface.get_width() + img_h = img_surface.get_height() + if img_w > 0 and img_h > 0: + ctx.scale( + self._width_mm / img_w, + self._height_mm / img_h, + ) + ctx.set_source_surface(img_surface, 0, 0) + ctx.paint() + ctx.restore() + + if self._point1 is not None: + self._draw_marker(ctx, self._point1, POINT_COLOR_1) + if self._point2 is not None: + self._draw_marker(ctx, self._point2, POINT_COLOR_2) + if self._point1 is not None and self._point2 is not None: + self._draw_dashed_line(ctx, self._point1, self._point2) + if ( + self._hover_pos is not None + and self._pick_phase < 2 + and self._dragging is None + ): + hx, hy = self._hover_pos + mm_x, mm_y = self._get_world_coords(hx, hy) + norm_x = mm_x / self._width_mm + norm_y = mm_y / self._height_mm + self._draw_crosshair(ctx, norm_x, norm_y) + + @property + def point1(self) -> tuple[float, float] | None: + return self._point1 + + @property + def point2(self) -> tuple[float, float] | None: + return self._point2 + + @property + def is_complete(self) -> bool: + return self._pick_phase >= 2 + + def reset(self): + self._pick_phase = 0 + self._point1 = None + self._point2 = None + self._dragging = None + self.points_reset.send(self) + self.queue_draw() + + def set_points( + self, + p1: tuple[float, float] | None, + p2: tuple[float, float] | None, + ): + self._point1 = p1 + self._point2 = p2 + if p1 is not None and p2 is not None: + self._pick_phase = 2 + elif p1 is not None: + self._pick_phase = 1 + else: + self._pick_phase = 0 + self.queue_draw() + + def _on_scroll(self, controller, dx, dy): + zoom_speed = 0.1 + desired_zoom = self.zoom_level * ( + (1 - zoom_speed) if dy > 0 else (1 + zoom_speed) + ) + + if self._base_scale <= 0: + return + base_ppm = self._base_scale + min_ppm = base_ppm * MIN_ZOOM_FACTOR + max_ppm = MAX_PIXELS_PER_MM + clamped_ppm = max(min_ppm, min(base_ppm * desired_zoom, max_ppm)) + final_zoom = clamped_ppm / base_ppm + if abs(final_zoom - self.zoom_level) < 1e-9: + return + + if self._hover_pos is not None: + mx, my = self._hover_pos + focus_x, focus_y = self._get_world_coords(mx, my) + self.zoom_level = final_zoom + self._rebuild_view_transform() + new_x, new_y = self._get_world_coords(mx, my) + self.pan_x_mm += focus_x - new_x + self.pan_y_mm += focus_y - new_y + else: + self.zoom_level = final_zoom + + self._rebuild_view_transform() + + def _on_pan_begin(self, gesture, start_x, start_y): + self._pan_start_x_mm = self.pan_x_mm + self._pan_start_y_mm = self.pan_y_mm + + def _on_pan_update(self, gesture, offset_x, offset_y): + if self._base_scale <= 0: + return + scale = self._base_scale * self.zoom_level + self.pan_x_mm = self._pan_start_x_mm - offset_x / scale + self.pan_y_mm = self._pan_start_y_mm + offset_y / scale + self._rebuild_view_transform() + + def _hit_test_point(self, px: float, py: float) -> int | None: + if self._point1 is not None: + sx, sy = self._local_to_pixel(*self._point1) + if math.hypot(px - sx, py - sy) <= HIT_RADIUS: + return 0 + if self._point2 is not None: + sx, sy = self._local_to_pixel(*self._point2) + if math.hypot(px - sx, py - sy) <= HIT_RADIUS: + return 1 + return None + + def _on_click(self, gesture, n_press, x, y): + if self._dragging is not None: + return + + hit = self._hit_test_point(x, y) + if hit is not None: + return + + mm_x, mm_y = self._get_world_coords(x, y) + norm_x = mm_x / self._width_mm + norm_y = mm_y / self._height_mm + + if self._pick_phase == 0: + self._point1 = (norm_x, norm_y) + self._pick_phase = 1 + self.point_picked.send(self, index=0, x=norm_x, y=norm_y) + elif self._pick_phase == 1: + self._point2 = (norm_x, norm_y) + self._pick_phase = 2 + self.point_picked.send(self, index=1, x=norm_x, y=norm_y) + + self.queue_draw() + + def _on_drag_begin(self, gesture, start_x, start_y): + hit = self._hit_test_point(start_x, start_y) + if hit is not None: + self._dragging = hit + self._drag_start_px = (start_x, start_y) + + def _on_drag_update(self, gesture, offset_x, offset_y): + if self._dragging is None or self._drag_start_px is None: + return + + sx, sy = self._drag_start_px + current_x = sx + offset_x + current_y = sy + offset_y + mm_x, mm_y = self._get_world_coords(current_x, current_y) + norm_x = mm_x / self._width_mm + norm_y = mm_y / self._height_mm + + if self._dragging == 0: + self._point1 = (norm_x, norm_y) + else: + self._point2 = (norm_x, norm_y) + + self.points_changed.send(self) + self.queue_draw() + + def _on_drag_end(self, gesture, offset_x, offset_y): + if self._dragging is not None: + self._on_drag_update(gesture, offset_x, offset_y) + self._dragging = None + self._drag_start_px = None + + def _on_motion(self, controller, x, y): + self._hover_pos = (x, y) + self.queue_draw() + + def _local_to_pixel(self, norm_x, norm_y): + mm_x = norm_x * self._width_mm + mm_y = norm_y * self._height_mm + return self.view_transform.transform_point((mm_x, mm_y)) + + def _draw_marker(self, ctx, local_pos, color): + px, py = self._local_to_pixel(*local_pos) + r = MARKER_RADIUS + + ctx.save() + ctx.arc(px, py, r, 0, 2 * math.pi) + ctx.set_source_rgba(*color) + ctx.fill_preserve() + ctx.set_source_rgba(1, 1, 1, 1) + ctx.set_line_width(1.5) + ctx.stroke() + ctx.restore() + + def _draw_dashed_line(self, ctx, p1, p2): + px1, py1 = self._local_to_pixel(*p1) + px2, py2 = self._local_to_pixel(*p2) + + ctx.save() + ctx.set_source_rgba(*DASH_COLOR) + ctx.set_line_width(1.5) + ctx.set_dash((6, 4)) + ctx.move_to(px1, py1) + ctx.line_to(px2, py2) + ctx.stroke() + ctx.restore() + + def _draw_crosshair(self, ctx, lx, ly): + px, py = self._local_to_pixel(lx, ly) + size = 10 + + ctx.save() + ctx.set_source_rgba(0.9, 0.9, 0.9, 0.8) + ctx.set_line_width(1.0) + ctx.move_to(px - size, py) + ctx.line_to(px + size, py) + ctx.move_to(px, py - size) + ctx.line_to(px, py + size) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py new file mode 100644 index 000000000..15c6811f0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py @@ -0,0 +1,822 @@ +import logging +import math +from gettext import gettext as _ + +from gi.repository import Adw, Gtk +from raygeo.geo import Matrix + +from rayforge.core.group import Group +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.editor import DocEditor +from rayforge.machine.cmd import MachineCmd +from rayforge.machine.models.machine import Machine +from rayforge.ui_gtk.icons import get_icon +from rayforge.ui_gtk.machine.jog_widget import JogWidget +from rayforge.ui_gtk.shared.patched_dialog_window import ( + PatchedDialogWindow, +) +from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow + +from .pick_surface import PickSurface + +logger = logging.getLogger(__name__) + +MIN_POINT_DISTANCE = 0.5 + +_session_state: dict = {} + + +def calculate_alignment_transform( + d1: tuple[float, float], + d2: tuple[float, float], + p1: tuple[float, float], + p2: tuple[float, float], + allow_scale: bool = False, +) -> Matrix: + angle_d = math.atan2(d2[1] - d1[1], d2[0] - d1[0]) + angle_p = math.atan2(p2[1] - p1[1], p2[0] - p1[0]) + angle = math.degrees(angle_p - angle_d) + + dist_d = math.hypot(d2[0] - d1[0], d2[1] - d1[1]) + dist_p = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) + scale = dist_p / dist_d if allow_scale and dist_d > 0 else 1.0 + + return ( + Matrix.translation(*p1) + @ Matrix.rotation(angle) + @ Matrix.scale(scale, scale) + @ Matrix.translation(-d1[0], -d1[1]) + ) + + +class PrintAndCutWizard(PatchedDialogWindow): + def __init__( + self, + parent, + item: WorkPiece | Group, + machine: Machine, + machine_cmd: MachineCmd, + editor: DocEditor, + **kwargs, + ): + super().__init__( + transient_for=parent, + default_width=1150, + default_height=780, + title=_("Print & Cut"), + **kwargs, + ) + + self._item = item + self._machine = machine + self._machine_cmd = machine_cmd + self._editor = editor + + self._design_point1: tuple[float, float] | None = None + self._design_point2: tuple[float, float] | None = None + + self._physical_point1: tuple[float, float] | None = None + self._physical_point2: tuple[float, float] | None = None + + self._allow_scale: bool = False + + self._setup_ui() + self._restore_session_state() + + def _setup_ui(self): + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.toast_overlay.set_child(content) + + header = Adw.HeaderBar() + content.append(header) + + self._main_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=16, + margin_start=12, + margin_top=12, + margin_bottom=12, + ) + content.append(self._main_box) + + self._left_panel = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + ) + self._left_panel.set_hexpand(True) + self._left_panel.set_vexpand(True) + self._main_box.append(self._left_panel) + + self._right_stack = Gtk.Stack() + self._right_stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + ) + self._right_stack.set_hexpand(False) + self._main_box.append(self._right_stack) + + self._setup_left_panel() + self._setup_right_stack() + + self._button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + halign=Gtk.Align.END, + margin_end=12, + margin_bottom=12, + ) + content.append(self._button_box) + + self._back_btn = Gtk.Button(label=_("Back")) + self._back_btn.add_css_class("flat") + self._back_btn.connect("clicked", self._on_back_clicked) + self._back_btn.set_visible(False) + self._button_box.append(self._back_btn) + + self._reset_btn = Gtk.Button(label=_("Reset")) + self._reset_btn.add_css_class("flat") + self._reset_btn.connect("clicked", self._on_reset_clicked) + self._button_box.append(self._reset_btn) + + self._cancel_btn = Gtk.Button(label=_("Cancel")) + self._cancel_btn.add_css_class("flat") + self._cancel_btn.connect("clicked", lambda _: self.close()) + self._button_box.append(self._cancel_btn) + + self._next_btn = Gtk.Button(label=_("Next")) + self._next_btn.add_css_class("suggested-action") + self._next_btn.connect("clicked", self._on_next_clicked) + self._next_btn.set_sensitive(False) + self._button_box.append(self._next_btn) + + self._apply_btn = Gtk.Button(label=_("Apply")) + self._apply_btn.add_css_class("suggested-action") + self._apply_btn.connect("clicked", self._on_apply_clicked) + self._apply_btn.set_visible(False) + self._button_box.append(self._apply_btn) + + self._right_stack.connect( + "notify::visible-child", self._on_page_changed + ) + + def _setup_left_panel(self): + self._pick_surface = PickSurface( + item=self._item, + ) + self._pick_surface.set_hexpand(True) + self._pick_surface.set_vexpand(True) + self._pick_surface.set_halign(Gtk.Align.FILL) + self._pick_surface.point_picked.connect(self._on_design_point_picked) + self._pick_surface.points_reset.connect(self._on_design_points_reset) + self._pick_surface.points_changed.connect( + self._on_design_points_changed + ) + self._left_panel.append(self._pick_surface) + + def _setup_right_stack(self): + self._setup_pick_panel() + self._setup_jog_panel() + self._setup_apply_panel() + + def _setup_pick_panel(self): + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._right_stack.add_named(scroll, "pick") + + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + width_request=500, + hexpand=False, + ) + box.set_margin_start(12) + box.set_margin_end(32) + box.set_margin_top(4) + box.set_margin_bottom(12) + scroll.set_child(box) + + intro_group = Adw.PreferencesGroup( + title=_("Instructions"), + description=_( + "Click on two alignment points on the design. " + "These will be matched to physical locations in " + "the next step." + ), + ) + box.append(intro_group) + + points_group = Adw.PreferencesGroup(title=_("Picked Points")) + box.append(points_group) + + self._point1_row = Adw.ActionRow(title=_("Point 1")) + self._point1_row.set_subtitle(_("No point picked")) + points_group.add(self._point1_row) + + self._point2_row = Adw.ActionRow(title=_("Point 2")) + self._point2_row.set_subtitle(_("No point picked")) + points_group.add(self._point2_row) + + self._pick_status_row = Adw.ActionRow(title=_("Status")) + self._pick_status_row.set_subtitle( + _("Click on the first alignment point on the design.") + ) + points_group.add(self._pick_status_row) + + def _setup_jog_panel(self): + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._right_stack.add_named(scroll, "jog") + + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + width_request=500, + hexpand=False, + ) + box.set_margin_start(12) + box.set_margin_end(32) + box.set_margin_top(4) + box.set_margin_bottom(12) + scroll.set_child(box) + + intro_group = Adw.PreferencesGroup( + title=_("Instructions"), + description=_( + "Jog the laser to the physical location of each " + "point, then record the position." + ), + ) + box.append(intro_group) + + jog_frame = Gtk.Frame(halign=Gtk.Align.CENTER) + jog_frame.add_css_class("card") + box.append(jog_frame) + + jog_inner = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=6, + margin_top=6, + margin_bottom=6, + margin_start=6, + margin_end=6, + halign=Gtk.Align.CENTER, + ) + jog_frame.set_child(jog_inner) + + self._jog_widget = JogWidget(show_actions=False) + self._jog_widget.set_machine(self._machine, self._machine_cmd) + jog_inner.append(self._jog_widget) + + controls_group = Adw.PreferencesGroup() + box.append(controls_group) + + self._distance_row = LengthSpinRow( + _("Jog Distance"), + _("Distance"), + lower=0.1, + upper=1000.0, + value_in_base=10.0, + ) + self._distance_row.value_changed.connect(self._on_distance_changed) + controls_group.add(self._distance_row) + + presets_row = Adw.ActionRow(title=_("Distance Presets")) + controls_group.add(presets_row) + + for value in [0.1, 1.0, 10.0]: + btn = Gtk.Button(label=str(value), valign=Gtk.Align.CENTER) + btn.connect("clicked", self._on_preset_clicked, value) + presets_row.add_suffix(btn) + + self._focus_active = False + self._focus_on_icon = get_icon("laser-on-symbolic") + self._focus_off_icon = get_icon("laser-off-symbolic") + self._focus_btn = Gtk.ToggleButton( + tooltip_text=_("Toggle focus laser"), + valign=Gtk.Align.CENTER, + ) + self._focus_btn.set_child(self._focus_on_icon) + self._focus_btn.connect("toggled", self._on_focus_toggled) + + self._focus_row = Adw.ActionRow(title=_("Focus Laser")) + self._focus_row.add_suffix(self._focus_btn) + controls_group.add(self._focus_row) + + head = ( + self._machine.get_default_laser_head() if self._machine else None + ) + if head: + head.changed.connect(self._on_head_changed) + self._update_focus_sensitivity() + + positions_group = Adw.PreferencesGroup(title=_("Positions")) + box.append(positions_group) + + self._record1_btn = Gtk.Button( + label=_("Record"), valign=Gtk.Align.CENTER + ) + self._record1_btn.add_css_class("suggested-action") + self._record1_btn.connect("clicked", self._on_record_clicked, 0) + + self._goto1_btn = Gtk.Button( + child=get_icon("zero-here-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Go to recorded position"), + ) + self._goto1_btn.add_css_class("flat") + self._goto1_btn.connect("clicked", self._on_goto_clicked, 0) + self._goto1_btn.set_sensitive(False) + + self._pos1_row = Adw.ActionRow(title=_("Position 1")) + self._pos1_row.set_subtitle(_("Not recorded")) + self._pos1_row.add_suffix(self._goto1_btn) + self._pos1_row.add_suffix(self._record1_btn) + positions_group.add(self._pos1_row) + + self._record2_btn = Gtk.Button( + label=_("Record"), valign=Gtk.Align.CENTER + ) + self._record2_btn.add_css_class("suggested-action") + self._record2_btn.connect("clicked", self._on_record_clicked, 1) + + self._goto2_btn = Gtk.Button( + child=get_icon("zero-here-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Go to recorded position"), + ) + self._goto2_btn.add_css_class("flat") + self._goto2_btn.connect("clicked", self._on_goto_clicked, 1) + self._goto2_btn.set_sensitive(False) + + self._pos2_row = Adw.ActionRow(title=_("Position 2")) + self._pos2_row.set_subtitle(_("Not recorded")) + self._pos2_row.add_suffix(self._goto2_btn) + self._pos2_row.add_suffix(self._record2_btn) + positions_group.add(self._pos2_row) + + self._laser_row = Adw.ActionRow(title=_("Laser Position")) + self._laser_row.set_subtitle(_("X: --- Y: ---")) + positions_group.add(self._laser_row) + + if self._machine: + self._machine.state_changed.connect(self._on_machine_state_changed) + self._machine.connection_status_changed.connect( + self._on_connection_status_changed + ) + self._update_connection_sensitive() + self._update_laser_position() + + def _setup_apply_panel(self): + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._right_stack.add_named(scroll, "apply") + + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + hexpand=True, + ) + box.set_margin_start(24) + box.set_margin_end(24) + box.set_margin_top(12) + box.set_margin_bottom(12) + scroll.set_child(box) + + intro_group = Adw.PreferencesGroup( + title=_("Transform Preview"), + description=_( + "Review the computed alignment transform before applying it." + ), + ) + box.append(intro_group) + + transform_group = Adw.PreferencesGroup(title=_("Transform")) + box.append(transform_group) + + self._translation_row = Adw.ActionRow(title=_("Translation")) + self._translation_row.set_subtitle("---") + transform_group.add(self._translation_row) + + self._rotation_row = Adw.ActionRow(title=_("Rotation")) + self._rotation_row.set_subtitle("---") + transform_group.add(self._rotation_row) + + self._scale_check = Adw.SwitchRow(title=_("Allow scaling")) + self._scale_check.set_active(False) + self._scale_check.connect("notify::active", self._on_scale_toggled) + transform_group.add(self._scale_check) + + self._scale_row = Adw.ActionRow(title=_("Scale")) + self._scale_row.set_subtitle("---") + transform_group.add(self._scale_row) + + self._warning_label = Gtk.Label(label="") + self._warning_label.add_css_class("warning-label") + self._warning_label.set_visible(False) + self._warning_label.set_wrap(True) + self._warning_label.set_xalign(0.0) + box.append(self._warning_label) + + def _on_design_point_picked(self, sender, **kwargs): + index = kwargs.get("index", 0) + x = kwargs.get("x", 0.0) + y = kwargs.get("y", 0.0) + + if index == 0: + self._design_point1 = (x, y) + self._point1_row.set_subtitle(_("Point picked")) + self._pick_status_row.set_subtitle( + _("Click on the second alignment point on the design.") + ) + elif index == 1: + self._design_point2 = (x, y) + self._point2_row.set_subtitle(_("Point picked")) + self._pick_status_row.set_subtitle( + _("Both points selected. Click Next to continue.") + ) + + self._update_pick_next_btn() + self._save_session_state() + + def _on_design_points_changed(self, sender): + p1 = self._pick_surface.point1 + p2 = self._pick_surface.point2 + self._design_point1 = p1 + self._design_point2 = p2 + if p1 is not None: + self._point1_row.set_subtitle(_("Point picked")) + if p2 is not None: + self._point2_row.set_subtitle(_("Point picked")) + self._update_pick_next_btn() + self._save_session_state() + + def _on_design_points_reset(self, sender): + self._design_point1 = None + self._design_point2 = None + self._physical_point1 = None + self._physical_point2 = None + self._point1_row.set_subtitle(_("No point picked")) + self._point2_row.set_subtitle(_("No point picked")) + self._pos1_row.set_subtitle(_("Not recorded")) + self._pos2_row.set_subtitle(_("Not recorded")) + self._goto1_btn.set_sensitive(False) + self._goto2_btn.set_sensitive(False) + self._pick_status_row.set_subtitle( + _("Click on the first alignment point on the design.") + ) + self._update_pick_next_btn() + self._save_session_state() + + def _update_pick_next_btn(self): + p1 = self._design_point1 + p2 = self._design_point2 + if p1 is not None and p2 is not None: + dist = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) + self._next_btn.set_sensitive(dist >= MIN_POINT_DISTANCE) + if dist < MIN_POINT_DISTANCE: + self._pick_status_row.set_subtitle( + _("Points are too close. Pick points further apart.") + ) + else: + self._next_btn.set_sensitive(False) + + def _restore_session_state(self): + item_id = self._item.uid + state = _session_state.get(item_id) + if state is None: + return + + dp1 = state.get("design_point1") + dp2 = state.get("design_point2") + pp1 = state.get("physical_point1") + pp2 = state.get("physical_point2") + + if dp1 is not None or dp2 is not None: + self._pick_surface.set_points(dp1, dp2) + self._design_point1 = dp1 + self._design_point2 = dp2 + if dp1 is not None: + self._point1_row.set_subtitle(_("Point picked")) + if dp2 is not None: + self._point2_row.set_subtitle(_("Point picked")) + if dp1 is not None and dp2 is not None: + self._pick_status_row.set_subtitle( + _("Both points selected. Click Next to continue.") + ) + elif dp1 is not None: + self._pick_status_row.set_subtitle( + _("Click on the second alignment point on the design.") + ) + + if pp1 is not None: + self._physical_point1 = pp1 + self._pos1_row.set_subtitle(f"({pp1[0]:.2f}, {pp1[1]:.2f})") + if pp2 is not None: + self._physical_point2 = pp2 + self._pos2_row.set_subtitle(f"({pp2[0]:.2f}, {pp2[1]:.2f})") + + connected = self._machine and self._machine.is_connected() + if pp1 is not None: + self._goto1_btn.set_sensitive(connected) + if pp2 is not None: + self._goto2_btn.set_sensitive(connected) + + self._update_pick_next_btn() + + def _save_session_state(self): + item_id = self._item.uid + _session_state[item_id] = { + "design_point1": self._design_point1, + "design_point2": self._design_point2, + "physical_point1": self._physical_point1, + "physical_point2": self._physical_point2, + } + + def _on_record_clicked(self, button, pos_index): + if not self._machine or not self._machine.is_connected(): + return + + pos = self._machine.get_current_position() + if pos is None: + return + + x_val, y_val, _z_val = pos + if x_val is None or y_val is None: + return + + subtitle = f"({x_val:.2f}, {y_val:.2f})" + if pos_index == 0: + self._physical_point1 = (x_val, y_val) + self._pos1_row.set_subtitle(subtitle) + self._goto1_btn.set_sensitive(True) + else: + self._physical_point2 = (x_val, y_val) + self._pos2_row.set_subtitle(subtitle) + self._goto2_btn.set_sensitive(True) + + self._update_jog_next_btn() + self._save_session_state() + + def _on_goto_clicked(self, button, pos_index): + if not self._machine or not self._machine.is_connected(): + return + if pos_index == 0: + point = self._physical_point1 + else: + point = self._physical_point2 + if point is None: + return + self._machine_cmd.move_to(self._machine, point[0], point[1]) + + def _update_jog_next_btn(self): + p1 = self._physical_point1 + p2 = self._physical_point2 + if p1 is not None and p2 is not None: + dist = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) + self._next_btn.set_sensitive(dist >= MIN_POINT_DISTANCE) + else: + self._next_btn.set_sensitive(False) + + def _on_machine_state_changed(self, machine, state): + self._update_laser_position() + + def _on_connection_status_changed(self, sender, **kwargs): + self._update_connection_sensitive() + + def _update_connection_sensitive(self): + connected = self._machine and self._machine.is_connected() + self._record1_btn.set_sensitive(connected) + self._record2_btn.set_sensitive(connected) + self._goto1_btn.set_sensitive( + connected and self._physical_point1 is not None + ) + self._goto2_btn.set_sensitive( + connected and self._physical_point2 is not None + ) + self._update_focus_sensitivity() + if connected: + self._update_laser_position() + + def _update_laser_position(self): + if not self._machine: + return + pos = self._machine.get_current_position() + if pos: + x_val, y_val, _z_val = pos + if x_val is not None and y_val is not None: + self._laser_row.set_subtitle(f"X: {x_val:.2f} Y: {y_val:.2f}") + + def _on_distance_changed(self, row): + self._jog_widget.jog_distance = row.get_value_in_base_units() + + def _on_preset_clicked(self, button, value): + self._distance_row.set_value_in_base_units(value) + self._jog_widget.jog_distance = value + + def _on_focus_toggled(self, button): + if not self._machine or not self._machine_cmd: + return + head = self._machine.get_default_laser_head() + if not head: + return + self._focus_active = button.get_active() + if self._focus_active: + self._machine_cmd.set_focus_power(head, head.focus_power_percent) + button.set_child(self._focus_off_icon) + else: + self._machine_cmd.set_focus_power(head, 0) + button.set_child(self._focus_on_icon) + + def _disable_focus(self): + if self._focus_active and self._machine and self._machine_cmd: + head = self._machine.get_default_laser_head() + if head: + self._machine_cmd.set_focus_power(head, 0) + self._focus_active = False + if self._focus_btn: + self._focus_btn.set_active(False) + + def _on_head_changed(self, head, *args): + self._update_focus_sensitivity() + + def _update_focus_sensitivity(self): + head = ( + self._machine.get_default_laser_head() if self._machine else None + ) + if head and head.focus_power_percent > 0: + self._focus_btn.set_sensitive(True) + self._focus_row.set_subtitle( + _("Turn on laser at focus power to locate position") + ) + else: + self._focus_row.set_subtitle( + _("Set a focus power in laser preferences to enable") + ) + self._focus_btn.set_sensitive(False) + if self._focus_active: + self._disable_focus() + + def _on_scale_toggled(self, switch_row, _param): + self._allow_scale = switch_row.get_active() + self._update_apply_preview() + + def _local_to_world( + self, norm_x: float, norm_y: float + ) -> tuple[float, float]: + return self._item.get_world_transform().transform_point( + (norm_x, norm_y) + ) + + def _machine_to_world(self, wx: float, wy: float) -> tuple[float, float]: + wcs_x, wcs_y, _wcs_z = self._machine.get_active_wcs_offset() + return self._machine.panel.machine_point_to_world( + wx + wcs_x, wy + wcs_y + ) + + def _get_world_design_points(self): + assert self._design_point1 is not None + assert self._design_point2 is not None + d1 = self._local_to_world(*self._design_point1) + d2 = self._local_to_world(*self._design_point2) + return d1, d2 + + def _get_world_physical_points(self): + assert self._physical_point1 is not None + assert self._physical_point2 is not None + p1 = self._machine_to_world(*self._physical_point1) + p2 = self._machine_to_world(*self._physical_point2) + return p1, p2 + + def _update_apply_preview(self): + if ( + self._design_point1 is None + or self._design_point2 is None + or self._physical_point1 is None + or self._physical_point2 is None + ): + return + + d1, d2 = self._get_world_design_points() + p1, p2 = self._get_world_physical_points() + + T = calculate_alignment_transform( + d1, d2, p1, p2, allow_scale=self._allow_scale + ) + + _tx, _ty, angle, sx, sy, _skew = T.decompose() + tx, ty = T.get_translation() + + self._translation_row.set_subtitle(f"({tx:.2f}, {ty:.2f})") + self._rotation_row.set_subtitle(f"{angle:.2f}\u00b0") + + if self._allow_scale: + self._scale_row.set_subtitle(f"{sx:.4f} x {sy:.4f}") + self._warning_label.set_visible(False) + else: + dist_d = math.hypot( + d2[0] - d1[0], + d2[1] - d1[1], + ) + dist_p = math.hypot( + self._physical_point2[0] - self._physical_point1[0], + self._physical_point2[1] - self._physical_point1[1], + ) + self._scale_row.set_subtitle("1.0 (locked)") + if abs(dist_d - dist_p) > 0.1: + self._warning_label.set_text( + _( + "Note: Scale is locked. Point 2 may " + "not align exactly if the physical " + "distance differs from the design " + "distance." + ) + ) + self._warning_label.set_visible(True) + else: + self._warning_label.set_visible(False) + + def _on_page_changed(self, stack, pspec): + visible = stack.get_visible_child_name() + if visible == "pick": + self._left_panel.set_visible(True) + self._back_btn.set_visible(False) + self._reset_btn.set_visible(True) + self._cancel_btn.set_visible(True) + self._next_btn.set_visible(True) + self._apply_btn.set_visible(False) + self._update_pick_next_btn() + elif visible == "jog": + self._left_panel.set_visible(True) + self._back_btn.set_visible(True) + self._reset_btn.set_visible(False) + self._cancel_btn.set_visible(True) + self._next_btn.set_visible(True) + self._apply_btn.set_visible(False) + self._update_jog_next_btn() + elif visible == "apply": + self._left_panel.set_visible(True) + self._back_btn.set_visible(True) + self._reset_btn.set_visible(False) + self._cancel_btn.set_visible(True) + self._next_btn.set_visible(False) + self._apply_btn.set_visible(True) + self._apply_btn.set_sensitive(True) + self._update_apply_preview() + + def _on_back_clicked(self, button): + visible = self._right_stack.get_visible_child_name() + if visible == "jog": + self._right_stack.set_visible_child_name("pick") + elif visible == "apply": + self._right_stack.set_visible_child_name("jog") + + def _on_next_clicked(self, button): + visible = self._right_stack.get_visible_child_name() + if visible == "pick": + self._right_stack.set_visible_child_name("jog") + elif visible == "jog": + self._right_stack.set_visible_child_name("apply") + + def _on_reset_clicked(self, button): + self._pick_surface.reset() + + def _on_apply_clicked(self, button): + if ( + self._design_point1 is None + or self._design_point2 is None + or self._physical_point1 is None + or self._physical_point2 is None + ): + return + + d1, d2 = self._get_world_design_points() + p1, p2 = self._get_world_physical_points() + + T = calculate_alignment_transform( + d1, d2, p1, p2, allow_scale=self._allow_scale + ) + + old_matrix = self._item.matrix.copy() + new_matrix = T @ old_matrix + + self._editor.transform.create_transform_transaction( + [(self._item, old_matrix, new_matrix)] + ) + + toast = Adw.Toast(title=_("Alignment applied")) + self.toast_overlay.add_toast(toast) + self.close() + + def close(self): + self._disable_focus() + if self._machine: + self._machine.state_changed.disconnect( + self._on_machine_state_changed + ) + self._machine.connection_status_changed.disconnect( + self._on_connection_status_changed + ) + head = self._machine.get_default_head() + head.changed.disconnect(self._on_head_changed) + super().close() diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-print-and-cut/rayforge-addon.yaml new file mode 100644 index 000000000..eb88bd57d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/rayforge-addon.yaml @@ -0,0 +1,11 @@ +name: print_and_cut +display_name: "Print & Cut" +description: "Align workpieces to physical objects using registration marks" +api_version: 19 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + frontend: "print_and_cut.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/de/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/de/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..613354c7a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/de/LC_MESSAGES/sketcher.po @@ -0,0 +1,681 @@ +# German translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# Samuel Abels , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2025-07-24 22:08+0200\n" +"Last-Translator: Samuel Abels \n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "Skizzenparameter" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "Parameter, die die Geometrie dieser Skizze steuern" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "Skizze" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "Gleiche Länge" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "{} Elemente" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "Parallelogramm" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "Ursprung bei {}" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "Punkt auf Linie" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "Punkt bei {}" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "Kollinear" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "{}, {}, {}" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "Tangente" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "Linie zu {} bei {}" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "Abstand" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "Von {} bis {}" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "Vertikal" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "Gleiche Abstand" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "{}-{} und {}-{}" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "Senkrecht" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "Zwischen {} und {}" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "Symmetrie" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "Horizontal" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "Winkel" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "Zwischen zwei Linien" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "Durchmesser" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "Kreis bei {}" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "Koinzident" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "Bei {}" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "Radius" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "{} bei {}" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "Seitenverhältnis" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "Kreis hinzufügen" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "Abrundung hinzufügen" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "Textfeld hinzufügen" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "Füllung hinzufügen" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "Füllung entfernen" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "Textfüllung festlegen" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "Bedingung bearbeiten" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "Linie hinzufügen" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "Bezier-Kurve hinzufügen" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "Bedingung hinzufügen" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "{} hinzufügen" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "Raster hinzufügen" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "Fase hinzufügen" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "Bogen hinzufügen" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "Geraderichten" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "Abgerundetes Rechteck hinzufügen" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "Rechteck hinzufügen" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "Texteigenschaft ändern" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "Wegpunkttyp festlegen" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "Punkt verschieben" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "Kontrollpunkt verschieben" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "Verbindung lösen" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "Ellipse hinzufügen" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "Text bearbeiten" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "Skizzenparameter ändern" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "Neue Skizze" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "Skizzendefinition erstellen" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "Das ausgewählte Element ist keine bearbeitbare Skizze." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "Bitte wähle eine einzelne Skizze zum Bearbeiten aus." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "Bitte wähle ein einzelnes Objekt zum Exportieren aus." + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "Skizze bearbeiten" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "Objekt exportieren..." + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "Bedingungen umschalten" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "Konstruktionsgeometrie umschalten" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "Füllfarbe:" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "Abbrechen" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "Fertigstellen" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "Eigenschaften" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "Skizzennamen und grundlegende Eigenschaften konfigurieren" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "Name" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "Kameraansicht umschalten" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "Skizze umbenennen" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "Symmetrisch" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "Symmetriebedingung hinzufügen" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "Auswählen" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "An Achse binden" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "Magnetisches Einrasten umschalten" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "Verbundene auswählen" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "Abrundung" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "Anwenden" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "Löschen" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "Nächstes Feld" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "Vorheriges Feld" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "Pfad" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "Am Raster ausrichten" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "Scharf" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "Rechteck" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "B" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "H" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "Maße eingeben (B H)" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "Seitenverhältnisbedingung hinzufügen" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "Winkelbedingung hinzufügen" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "Füllung" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "Bogen" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "Radius eingeben" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "Gitter" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "Raster erstellen" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "Zeilen" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "Spalten" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "Erstellen" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "Tangentiale Bedingung hinzufügen" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "Ellipse" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "Mittelpunkt auf Startpunkt" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "Auf Kreis beschränken" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "Vertikale Bedingung hinzufügen" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "Auswahl löschen" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "Abgerundetes Rechteck" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "R" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "Maße eingeben (B H R)" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "Konstruktion" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "Konstruktion umschalten" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "Radiusbedingung hinzufügen" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "Textfeld" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "Durchmesserbedingung hinzufügen" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "Glatt" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "Deckungsgleiche Bedingung hinzufügen" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "Punkt auf Form hinzufügen" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "Rechtwinklige Bedingung hinzufügen" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "Horizontale Bedingung hinzufügen" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "Fase" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "Gleich" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "Gleiche-Länge-Bedingung hinzufügen" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "Abstandsbedingung hinzufügen" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "Widersprüchliche Bedingungen" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "Diese Bedingungen können nicht gleichzeitig erfüllt werden" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "Bedingung löschen" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "Bedingung löschen" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "Keine Parameter" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "Gemischte Werte" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "Skizze fertigstellen" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "Skizze abbrechen" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "_Datei" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "Rückgängig" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "Wiederholen" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "_Bearbeiten" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "Kreis" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "Fläche füllen" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "Werkzeuge" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "Ecke abfasen" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "Ändern" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "_Skizze" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "Ansicht anpassen" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "_Ansicht" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "Schriftarteigenschaften" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "Schriftfamilie, Größe und Stil für Textfelder konfigurieren" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "Schriftfamilie" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "Schriftgröße" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "Fett" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "Kursiv" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "Schriftart auswählen" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "Radius oder Ausdruck eingeben (z. B. 'Breite/2')." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "Durchmesser oder Ausdruck eingeben." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "Winkel in Grad oder Ausdruck eingeben." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "Länge oder Ausdruck eingeben." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "Wert oder Ausdruck eingeben." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "OK" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "{app_name}-Skizze" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "Skizzen-Datei ist ungültiges JSON: {}" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "Fehler beim Laden der Skizzenstruktur: {}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/en/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/en/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..02d1f16c2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/en/LC_MESSAGES/sketcher.po @@ -0,0 +1,681 @@ +# English translations for PACKAGE package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Samuel , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2025-07-13 11:49+0200\n" +"Last-Translator: Samuel \n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "" + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/es/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/es/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..e48033037 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/es/LC_MESSAGES/sketcher.po @@ -0,0 +1,681 @@ +# Spanish translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2025-08-08 10:00+0200\n" +"Last-Translator: Samuel Abels\n" +"Language-Team: Spanish \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "Parámetros del boceto" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "Parámetros que controlan la geometría de este boceto" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "Boceto" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "Longitud Igual" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "{} entidades" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "Paralelogramo" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "Origen en {}" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "Punto en Línea" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "Punto en {}" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "Colineal" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "{}, {}, {}" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "Tangente" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "Línea a {} en {}" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "Distancia" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "De {} a {}" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "Vertical" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "Distancia Igual" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "{}-{} y {}-{}" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "Perpendicular" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "Entre {} y {}" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "Simetría" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "Horizontal" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "Ángulo" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "Entre dos líneas" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "Diámetro" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "Círculo en {}" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "Coincidente" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "En {}" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "Radio" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "{} en {}" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "Relación de aspecto" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "Añadir círculo" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "Añadir redondeo" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "Añadir cuadro de texto" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "Añadir relleno" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "Eliminar relleno" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "Establecer relleno de texto" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "Editar restricción" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "Añadir línea" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "Añadir curva de Bézier" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "Añadir restricción" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "Añadir {}" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "Añadir cuadrícula" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "Añadir chaflán" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "Añadir arco" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "Enderezar" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "Añadir rectángulo redondeado" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "Añadir rectángulo" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "Modificar propiedad de texto" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "Establecer tipo de punto de ruta" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "Mover punto" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "Mover punto de control" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "Despegar unión" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "Añadir elipse" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "Editar texto" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "Cambiar parámetros del boceto" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "Nuevo boceto" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "Crear definición de boceto" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "El elemento seleccionado no es un boceto editable." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "Por favor, selecciona un único boceto para editar." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "Por favor, selecciona un único objeto para exportar." + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "Editar boceto" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "Exportar objeto..." + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "Alternar restricciones" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "Alternar geometría de construcción" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "Color de relleno:" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "Cancelar" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "Finalizar" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "Propiedades" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "Configurar el nombre y las propiedades básicas del boceto" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "Nombre" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "Alternar vista de cámara" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "Renombrar boceto" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "Simétrico" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "Añadir restricción de simetría" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "Seleccionar" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "Restringir al eje" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "Alternar ajuste magnético" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "Seleccionar conectados" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "Redondeo" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "Aplicar" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "Eliminar" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "Campo siguiente" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "Campo anterior" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "Ruta" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "Ajustar a la cuadrícula" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "Angular" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "Rectángulo" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "Ancho" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "Alto" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "Escribir dimensiones (Ancho Alto)" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "Añadir restricción de relación de aspecto" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "Añadir restricción de ángulo" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "Relleno" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "Arco" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "Escribir radio" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "Cuadrícula" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "Crear cuadrícula" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "Filas" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "Columnas" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "Crear" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "Añadir restricción de tangencia" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "Elipse" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "Centrar en punto de inicio" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "Restringir a círculo" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "Añadir restricción vertical" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "Eliminar selección" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "Rectángulo redondeado" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "Radio" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "Escribir dimensiones (Ancho Alto Radio)" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "Construcción" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "Alternar construcción" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "Añadir restricción de radio" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "Cuadro de texto" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "Añadir restricción de diámetro" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "Suave" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "Añadir restricción de coincidencia" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "Añadir punto en forma" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "Añadir restricción perpendicular" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "Añadir restricción horizontal" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "Chaflán" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "Igual" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "Añadir restricción de igualdad" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "Añadir restricción de distancia" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "Restricciones en conflicto" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "Estas restricciones no pueden satisfacerse simultáneamente" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "Eliminar restricción" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "Eliminar Restricción" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "Sin parámetros" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "Valores mixtos" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "Finalizar boceto" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "Cancelar boceto" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "_Archivo" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "Deshacer" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "Rehacer" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "_Editar" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "Círculo" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "Rellenar área" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "Herramientas" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "Achaflanar esquina" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "Modificar" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "_Boceto" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "Ajustar vista" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "_Ver" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "Propiedades de fuente" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "Configurar la familia de fuente, tamaño y estilo para cuadros de texto" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "Familia de fuente" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "Tamaño de fuente" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "Negrita" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "Cursiva" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "Seleccionar fuente" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "Introduce el radio o una expresión (p. ej., 'ancho/2')." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "Introduce el diámetro o una expresión." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "Introduce el ángulo en grados o una expresión." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "Introduce la longitud o una expresión." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "Introduce un valor o una expresión." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "Aceptar" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "Boceto {app_name}" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "El archivo de boceto es JSON no válido: {}" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "Error al cargar la estructura del boceto: {}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/fr/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/fr/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..992fa1a74 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/fr/LC_MESSAGES/sketcher.po @@ -0,0 +1,684 @@ +# French translations for Rayforge package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Samuel , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2025-10-15 07:10+0200\n" +"Last-Translator: Samuel \n" +"Language-Team: French\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 3.4.2\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "Paramètres de l'esquisse" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "Paramètres qui contrôlent la géométrie de cette esquisse" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "Esquisse" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "Longueur Égale" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "{} entités" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "Parallélogramme" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "Origine à {}" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "Point sur Ligne" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "Point à {}" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "Colinéaire" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "{}, {}, {}" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "Tangente" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "Ligne vers {} à {}" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "Distance" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "De {} à {}" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "Vertical" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "Distance Égale" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "{}-{} et {}-{}" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "Perpendiculaire" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "Entre {} et {}" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "Symétrie" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "Horizontal" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "Angle" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "Entre deux lignes" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "Diamètre" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "Cercle à {}" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "Confondu" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "À {}" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "Rayon" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "{} à {}" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "Rapport d'aspect" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "Ajouter un cercle" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "Ajouter un congé" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "Ajouter une zone de texte" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "Ajouter un remplissage" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "Supprimer le remplissage" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "Définir le remplissage du texte" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "Modifier la contrainte" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "Ajouter une ligne" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "Ajouter une courbe de Bézier" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "Ajouter une contrainte" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "Ajouter {}" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "Ajouter une grille" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "Ajouter un chanfrein" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "Ajouter un arc" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "Redresser" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "Ajouter un rectangle arrondi" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "Ajouter un rectangle" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "Modifier la propriété de texte" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "Définir le type de point de passage" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "Déplacer le point" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "Déplacer le point de contrôle" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "Détacher la jonction" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "Ajouter une ellipse" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "Modifier le texte" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "Modifier les paramètres de l'esquisse" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "Nouvelle esquisse" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "Créer la définition de l'esquisse" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "L'élément sélectionné n'est pas une esquisse modifiable." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "Veuillez sélectionner une seule esquisse à modifier." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "Veuillez sélectionner un seul objet à exporter." + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "Modifier l'esquisse" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "Exporter l'objet..." + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "Afficher/Masquer les contraintes" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "Afficher/Masquer la géométrie de construction" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "Couleur de remplissage :" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "Annuler" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "Terminer" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "Propriétés" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "Configurer le nom et les propriétés de base du croquis" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "Nom" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "Activer/désactiver la vue caméra" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "Renommer l'esquisse" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "Symétrique" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "Ajouter une contrainte de symétrie" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "Sélectionner" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "Contraindre à l'axe" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "Basculer l'aimantation" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "Sélectionner les éléments connectés" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "Arrondi" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "Appliquer" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "Supprimer" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "Champ suivant" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "Champ précédent" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "Parcours" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "Aligner sur la grille" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "Vif" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "Rectangle" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "L" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "H" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "Saisir les dimensions (L H)" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "Ajouter une contrainte de rapport d'aspect" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "Ajouter une contrainte d'angle" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "Remplissage" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "Arc" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "Saisir le rayon" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "Grille" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "Créer une grille" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "Lignes" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "Colonnes" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "Créer" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "Ajouter une contrainte de tangence" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "Ellipse" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "Centrer sur le point de départ" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "Contraindre à un cercle" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "Ajouter une contrainte verticale" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "Supprimer la sélection" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "Rectangle arrondi" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "R" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "Saisir les dimensions (L H R)" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "Construction" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "Basculer en mode construction" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "Ajouter une contrainte de rayon" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "Zone de texte" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "Ajouter une contrainte de diamètre" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "Lisse" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "Ajouter une contrainte de coïncidence" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "Ajouter un point sur la forme" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "Ajouter une contrainte de perpendicularité" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "Ajouter une contrainte horizontale" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "Chanfrein" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "Égal" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "Ajouter une contrainte d'égalité" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "Ajouter une contrainte de distance" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "Contraintes en conflit" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "Ces contraintes ne peuvent pas être satisfaites simultanément" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "Supprimer la contrainte" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "Supprimer la contrainte" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "Aucun paramètre" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "Valeurs mixtes" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "Terminer l'esquisse" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "Annuler l'esquisse" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "_Fichier" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "Annuler" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "Rétablir" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "_Édition" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "Cercle" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "Zone de remplissage" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "Outils" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "Chanfreiner le coin" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "Modifier" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "_Esquisse" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "Ajuster la vue" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "_Vue" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "Propriétés de la police" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "" +"Configurer la famille de police, la taille et le style pour les zones de " +"texte" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "Famille de police" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "Taille de la police" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "Gras" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "Italique" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "Sélectionner la police" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "Saisir le rayon ou une expression (p. ex., « largeur/2 »)." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "Saisir le diamètre ou une expression." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "Entrez l'angle en degrés ou une expression." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "Saisir la longueur ou une expression." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "Saisir la valeur ou une expression." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "OK" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "Esquisse {app_name}" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "Le fichier d'esquisse est un JSON non valide : {}" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "Échec du chargement de la structure de l'esquisse : {}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/pt/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/pt/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..ac5a02e87 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/pt/LC_MESSAGES/sketcher.po @@ -0,0 +1,681 @@ +# Portuguese translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# Samuel Abels , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2025-07-24 22:09+0200\n" +"Last-Translator: Samuel Abels \n" +"Language-Team: Portuguese \n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "Parâmetros do Esboço" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "Parâmetros que controlam a geometria deste esboço" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "Esboço" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "Comprimento Igual" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "{} entidades" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "Paralelogramo" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "Origem em {}" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "Ponto na Linha" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "Ponto em {}" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "Colinear" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "{}, {}, {}" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "Tangente" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "Linha para {} em {}" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "Distância" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "De {} até {}" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "Vertical" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "Distância Igual" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "{}-{} e {}-{}" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "Perpendicular" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "Entre {} e {}" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "Simetria" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "Horizontal" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "Ângulo" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "Entre duas linhas" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "Diâmetro" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "Círculo em {}" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "Coincidente" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "Em {}" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "Raio" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "{} em {}" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "Proporção" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "Adicionar Círculo" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "Adicionar Filete" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "Adicionar Caixa de Texto" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "Adicionar Preenchimento" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "Remover Preenchimento" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "Definir preenchimento do texto" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "Editar Restrição" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "Adicionar Linha" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "Adicionar Bézier" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "Adicionar Restrição" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "Adicionar {}" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "Adicionar Grade" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "Adicionar Chanfro" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "Adicionar Arco" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "Endireitar" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "Adicionar Retângulo Arredondado" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "Adicionar Retângulo" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "Modificar Propriedade de Texto" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "Definir Tipo de Ponto de Passagem" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "Mover Ponto" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "Mover Ponto de Controle" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "Descolar Junção" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "Adicionar elipse" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "Editar Texto" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "Alterar Parâmetros do Esboço" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "Novo Esboço" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "Criar Definição de Esboço" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "O item selecionado não é um esboço editável." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "Por favor, selecione um único esboço para editar." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "Por favor, selecione um único objeto para exportar." + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "Editar Esboço" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "Exportar objeto..." + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "Mostrar/Ocultar restrições" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "Mostrar/Ocultar geometria de construção" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "Cor de preenchimento:" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "Cancelar" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "Concluir" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "Propriedades" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "Configurar o nome e as propriedades básicas do esboço" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "Nome" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "Alternar visualização da câmera" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "Renomear Esboço" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "Simétrico" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "Adicionar Restrição de Simetria" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "Selecionar" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "Restringir ao Eixo" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "Alternar ajuste magnético" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "Selecionar Conectados" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "Arredondamento" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "Aplicar" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "Excluir" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "Próximo campo" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "Campo anterior" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "Caminho" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "Ajustar à Grade" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "Afiado" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "Retângulo" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "L" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "A" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "Digitar dimensões (L A)" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "Adicionar Restrição de Proporção" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "Adicionar Restrição de Ângulo" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "Preenchimento" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "Arco" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "Digitar raio" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "Grade" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "Criar Grade" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "Linhas" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "Colunas" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "Criar" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "Adicionar Restrição de Tangente" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "Elipse" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "Centralizar no ponto inicial" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "Restringir a círculo" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "Adicionar Restrição Vertical" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "Excluir Seleção" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "Retângulo Arredondado" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "R" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "Digitar dimensões (L A R)" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "Construção" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "Alternar Construção" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "Adicionar Restrição de Raio" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "Caixa de texto" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "Adicionar Restrição de Diâmetro" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "Suave" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "Adicionar Restrição de Coincidência" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "Adicionar Ponto na Forma" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "Adicionar Restrição Perpendicular" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "Adicionar Restrição Horizontal" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "Chanfro" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "Igual" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "Adicionar Restrição de Igualdade" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "Adicionar Restrição de Distância" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "Restrições em Conflito" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "Estas restrições não podem ser satisfeitas simultaneamente" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "Excluir restrição" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "Excluir Restrição" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "Sem parâmetros" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "Valores Mistos" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "Concluir Esboço" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "Cancelar Esboço" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "_Arquivo" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "Desfazer" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "Refazer" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "_Editar" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "Círculo" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "Área de Preenchimento" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "Ferramentas" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "Chanfrar Canto" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "Modificar" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "_Esboço" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "Ajustar à Vista" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "_Exibir" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "Propriedades da Fonte" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "Configurar a família, tamanho e estilo da fonte para caixas de texto" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "Família da Fonte" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "Tamanho da Fonte" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "Negrito" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "Itálico" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "Selecionar Fonte" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "Insira o raio ou uma expressão (ex: 'largura/2')." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "Insira o diâmetro ou uma expressão." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "Insira o ângulo em graus ou uma expressão." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "Insira o comprimento ou uma expressão." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "Insira um valor ou uma expressão." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "OK" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "Esboço {app_name}" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "O arquivo de esboço é JSON inválido: {}" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "Falha ao carregar a estrutura do esboço: {}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/sketcher.pot b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/sketcher.pot new file mode 100644 index 000000000..4f482e8ce --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/sketcher.pot @@ -0,0 +1,700 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: sketcher/core/sketch.py +#: sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "" + +#: sketcher/core/constraints/distance.py +#: sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "" + +#: sketcher/core/commands/text_box.py +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "" + +#: sketcher/core/commands/constraint.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "" + +#: sketcher/core/commands/bezier.py +#: sketcher/core/commands/line.py +msgid "Add Line" +msgstr "" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +#: sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "" + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "" + +#: sketcher/ui_gtk/studio.py +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py +#: sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "" + +#: sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "" + +#: sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "" + +#: sketcher/ui_gtk/tools/construction_tool.py +#: sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "" + +#: sketcher/image/exporter.py +#: sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/uk/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/uk/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..a1f4989ef --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/uk/LC_MESSAGES/sketcher.po @@ -0,0 +1,682 @@ +# Ukrainian translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2026-02-23 01:17+0100\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Ukrainian\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ?0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ?1 :2);\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "Параметри ескізу" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "Параметри, що керують геометрією цього ескізу" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "Ескіз" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "Рівна довжина" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "{} об'єктів" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "Паралелограм" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "Початок у {}" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "Точка на лінії" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "Точка у {}" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "Колінеарність" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "{}, {}, {}" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "Дотична" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "Лінія до {} у {}" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "Відстань" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "Від {} до {}" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "Вертикаль" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "Рівна відстань" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "{}-{} та {}-{}" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "Перпендикуляр" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "Між {} та {}" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "Симетрія" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "Горизонталь" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "Кут" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "Між двома лініями" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "Діаметр" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "Коло у {}" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "Співпадіння" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "У {}" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "Радіус" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "{} у {}" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "Співвідношення сторін" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "Додати коло" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "Додати заокруглення" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "Додати текстове поле" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "Додати заливку" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "Видалити заливку" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "Встановити заливку тексту" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "Редагувати обмеження" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "Додати лінію" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "Додати криву Безьє" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "Додати обмеження" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "Додати {}" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "Додати сітку" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "Додати фаску" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "Додати дугу" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "Випрямити" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "Додати заокруглений прямокутник" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "Додати прямокутник" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "Змінити властивість тексту" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "Встановити тип опорної точки" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "Перемістити точку" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "Перемістити контрольну точку" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "Від'єднати вузол" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "Додати еліпс" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "Редагувати текст" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "Змінити параметри ескізу" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "Новий ескіз" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "Створити визначення ескізу" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "Вибраний елемент не є редагованим ескізом." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "Будь ласка, виберіть один ескіз для редагування." + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "Будь ласка, виберіть один об'єкт для експорту." + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "Редагувати ескіз" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "Експортувати об'єкт..." + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "Перемкнути обмеження" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "Перемкнути допоміжну геометрію" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "Колір заливки:" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "Скасувати" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "Завершити" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "Властивості" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "Налаштувати назву ескізу та основні властивості" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "Назва" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "Перемкнути перегляд камери" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "Перейменувати ескіз" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "Симетрична" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "Додати обмеження симетрії" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "Вибрати" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "Обмежити до осі" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "Перемкнути магнітне прив'язування" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "Вибрати з'єднані" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "Скруглення" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "Застосувати" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "Видалити" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "Наступне поле" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "Попереднє поле" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "Контур" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "Прив'язати до сітки" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "Гостра" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "Прямокутник" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "Ш" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "В" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "Введіть розміри (Ш В)" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "Додати обмеження співвідношення сторін" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "Додати обмеження кута" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "Заповнення" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "Дуга" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "Введіть радіус" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "Сітка" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "Створити сітку" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "Рядки" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "Стовпці" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "Створити" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "Додати обмеження дотику" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "Еліпс" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "Центр на початковій точці" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "Обмежити до кола" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "Додати вертикальне обмеження" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "Видалити вибране" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "Закруглений прямокутник" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "Р" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "Введіть розміри (Ш В Р)" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "Конструкція" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "Перемкнути конструкцію" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "Додати обмеження радіуса" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "Текстове поле" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "Додати обмеження діаметра" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "Плавна" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "Додати обмеження збігу" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "Додати точку на фігуру" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "Додати перпендикулярне обмеження" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "Додати горизонтальне обмеження" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "Фаска" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "Рівний" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "Додати обмеження рівності" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "Додати обмеження відстані" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "Конфліктуючі обмеження" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "Ці обмеження не можуть бути задоволені одночасно" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "Видалити обмеження" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "Видалити обмеження" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "Немає параметрів" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "Змішані значення" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "Завершити ескіз" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "Скасувати ескіз" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "_Файл" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "Скасувати" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "Повторити" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "_Редагування" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "Коло" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "Заповнити область" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "Інструменти" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "Фаска кута" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "Змінити" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "_Ескіз" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "Вмістити вид" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "_Вигляд" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "Властивості шрифту" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "Налаштувати гарнітуру, розмір та стиль шрифту для текстових полів" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "Гарнітура шрифту" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "Розмір шрифту" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "Жирний" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "Курсив" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "Вибрати шрифт" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "Введіть радіус або вираз (напр. 'width/2')." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "Введіть діаметр або вираз." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "Введіть кут у градусах або вираз." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "Введіть довжину або вираз." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "Введіть значення або вираз." + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "Гаразд" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "Ескіз {app_name}" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "Файл ескізу містить недійсний JSON: {}" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "Не вдалося завантажити структуру ескізу: {}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/locale/zh_CN/LC_MESSAGES/sketcher.po b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/zh_CN/LC_MESSAGES/sketcher.po new file mode 100644 index 000000000..c84171c0e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/locale/zh_CN/LC_MESSAGES/sketcher.po @@ -0,0 +1,681 @@ +# Chinese (Simplified) translations for Rayforge package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-09 02:18+0200\n" +"PO-Revision-Date: 2025-07-13 11:49+0200\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Chinese (Simplified)\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: sketcher/core/sketch.py sketcher/ui_gtk/property_provider.py +msgid "Sketch Parameters" +msgstr "草图参数" + +#: sketcher/core/sketch.py +msgid "Parameters that control this sketch's geometry" +msgstr "控制此草图几何形状的参数" + +#: sketcher/core/sketch.py +msgid "Sketch" +msgstr "草图" + +#: sketcher/core/constraints/equal_length.py +msgid "Equal Length" +msgstr "等长" + +#: sketcher/core/constraints/equal_length.py +msgid "{} entities" +msgstr "{} 个实体" + +#: sketcher/core/constraints/parallelogram.py +msgid "Parallelogram" +msgstr "平行四边形" + +#: sketcher/core/constraints/parallelogram.py +msgid "Origin at {}" +msgstr "原点在 {}" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point on Line" +msgstr "点在线上" + +#: sketcher/core/constraints/point_on_line.py +msgid "Point at {}" +msgstr "点在 {}" + +#: sketcher/core/constraints/collinear.py +msgid "Collinear" +msgstr "共线" + +#: sketcher/core/constraints/collinear.py +msgid "{}, {}, {}" +msgstr "{}, {}, {}" + +#: sketcher/core/constraints/tangent.py +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Tangent" +msgstr "相切" + +#: sketcher/core/constraints/tangent.py +msgid "Line to {} at {}" +msgstr "直线到 {} 在 {}" + +#: sketcher/core/constraints/distance.py +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Distance" +msgstr "距离" + +#: sketcher/core/constraints/distance.py sketcher/core/constraints/vertical.py +#: sketcher/core/constraints/symmetry.py +#: sketcher/core/constraints/horizontal.py +#: sketcher/core/constraints/aspect_ratio.py +msgid "From {} to {}" +msgstr "从 {} 到 {}" + +#: sketcher/core/constraints/vertical.py +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Vertical" +msgstr "垂直" + +#: sketcher/core/constraints/equal_distance.py +msgid "Equal Distance" +msgstr "等距" + +#: sketcher/core/constraints/equal_distance.py +msgid "{}-{} and {}-{}" +msgstr "{}-{} 和 {}-{}" + +#: sketcher/core/constraints/perpendicular.py +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Perpendicular" +msgstr "垂直" + +#: sketcher/core/constraints/perpendicular.py +msgid "Between {} and {}" +msgstr "在 {} 和 {} 之间" + +#: sketcher/core/constraints/symmetry.py +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Symmetry" +msgstr "对称" + +#: sketcher/core/constraints/horizontal.py +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Horizontal" +msgstr "水平" + +#: sketcher/core/constraints/angle.py +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Angle" +msgstr "角度" + +#: sketcher/core/constraints/angle.py +msgid "Between two lines" +msgstr "在两条直线之间" + +#: sketcher/core/constraints/diameter.py +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Diameter" +msgstr "直径" + +#: sketcher/core/constraints/diameter.py +msgid "Circle at {}" +msgstr "圆在 {}" + +#: sketcher/core/constraints/coincident.py +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Coincident" +msgstr "重合" + +#: sketcher/core/constraints/coincident.py +msgid "At {}" +msgstr "在 {}" + +#: sketcher/core/constraints/radius.py +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Radius" +msgstr "半径" + +#: sketcher/core/constraints/radius.py +msgid "{} at {}" +msgstr "{} 在 {}" + +#: sketcher/core/constraints/aspect_ratio.py +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Aspect Ratio" +msgstr "宽高比" + +#: sketcher/core/commands/circle.py +msgid "Add Circle" +msgstr "添加圆形" + +#: sketcher/core/commands/fillet.py +msgid "Add Fillet" +msgstr "添加圆角" + +#: sketcher/core/commands/text_box.py sketcher/ui_gtk/tools/text_box_tool.py +msgid "Add Text Box" +msgstr "添加文本框" + +#: sketcher/core/commands/fill.py +msgid "Add Fill" +msgstr "添加填充" + +#: sketcher/core/commands/fill.py +msgid "Remove Fill" +msgstr "移除填充" + +#: sketcher/core/commands/fill.py +msgid "Set Text Fill" +msgstr "设置文本填充" + +#: sketcher/core/commands/constraint.py sketcher/ui_gtk/sketchcanvas.py +msgid "Edit Constraint" +msgstr "编辑约束" + +#: sketcher/core/commands/bezier.py sketcher/core/commands/line.py +msgid "Add Line" +msgstr "添加直线" + +#: sketcher/core/commands/bezier.py +msgid "Add Bezier" +msgstr "添加贝塞尔曲线" + +#: sketcher/core/commands/constraint_create.py +msgid "Add Constraint" +msgstr "添加约束" + +#: sketcher/core/commands/constraint_create.py +msgid "Add {}" +msgstr "添加 {}" + +#: sketcher/core/commands/grid.py +msgid "Add Grid" +msgstr "添加网格" + +#: sketcher/core/commands/chamfer.py +msgid "Add Chamfer" +msgstr "添加倒角" + +#: sketcher/core/commands/arc.py +msgid "Add Arc" +msgstr "添加圆弧" + +#: sketcher/core/commands/straighten.py +#: sketcher/ui_gtk/tools/straighten_tool.py +msgid "Straighten" +msgstr "拉直" + +#: sketcher/core/commands/rounded_rect.py +msgid "Add Rounded Rectangle" +msgstr "添加圆角矩形" + +#: sketcher/core/commands/rectangle.py +msgid "Add Rectangle" +msgstr "添加矩形" + +#: sketcher/core/commands/text_property.py +msgid "Modify Text Property" +msgstr "修改文本属性" + +#: sketcher/core/commands/waypoint.py +msgid "Set Waypoint Type" +msgstr "设置路点类型" + +#: sketcher/core/commands/point.py +msgid "Move Point" +msgstr "移动点" + +#: sketcher/core/commands/point.py +msgid "Move Control Point" +msgstr "移动控制点" + +#: sketcher/core/commands/point.py +msgid "Unstick Junction" +msgstr "取消连接点" + +#: sketcher/core/commands/ellipse.py +msgid "Add Ellipse" +msgstr "添加椭圆" + +#: sketcher/core/commands/live_text_edit.py +msgid "Edit Text" +msgstr "编辑文本" + +#: sketcher/ui_gtk/sketch_cmd.py +msgid "Change Sketch Parameters" +msgstr "更改草图参数" + +#: sketcher/ui_gtk/sketch_mode_cmd.py sketcher/ui_gtk/__init__.py +msgid "New Sketch" +msgstr "新建草图" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Create Sketch Definition" +msgstr "创建草图定义" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Selected item is not an editable sketch." +msgstr "选定的项目不是可编辑的草图。" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single sketch to edit." +msgstr "请选择一个草图进行编辑。" + +#: sketcher/ui_gtk/sketch_mode_cmd.py +msgid "Please select a single object to export." +msgstr "请选择一个对象进行导出。" + +#: sketcher/ui_gtk/__init__.py +msgid "Edit Sketch" +msgstr "编辑草图" + +#: sketcher/ui_gtk/__init__.py +msgid "Export Object..." +msgstr "导出对象..." + +#: sketcher/ui_gtk/studio.py +msgid "Toggle constraints" +msgstr "切换约束" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle construction geometry" +msgstr "切换辅助几何" + +#: sketcher/ui_gtk/studio.py +msgid "Fill color:" +msgstr "填充颜色:" + +#: sketcher/ui_gtk/studio.py sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/grid_tool.py sketcher/ui_gtk/tools/circle_tool.py +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Cancel" +msgstr "取消" + +#: sketcher/ui_gtk/studio.py +msgid "Finish" +msgstr "完成" + +#: sketcher/ui_gtk/studio.py +msgid "Properties" +msgstr "属性" + +#: sketcher/ui_gtk/studio.py +msgid "Configure the sketch name and basic properties" +msgstr "配置草图名称和基本属性" + +#: sketcher/ui_gtk/studio.py +msgid "Name" +msgstr "名称" + +#: sketcher/ui_gtk/studio.py +msgid "Toggle camera view" +msgstr "切换相机视图" + +#: sketcher/ui_gtk/studio.py +msgid "Rename Sketch" +msgstr "重命名草图" + +#: sketcher/ui_gtk/tools/waypoint_symmetric_tool.py +msgid "Symmetric" +msgstr "对称" + +#: sketcher/ui_gtk/tools/symmetry_constraint_tool.py +msgid "Add Symmetry Constraint" +msgstr "添加对称约束" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/menu.py +msgid "Select" +msgstr "选择" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +msgid "Constrain to Axis" +msgstr "约束到轴" + +#: sketcher/ui_gtk/tools/select_tool.py sketcher/ui_gtk/tools/path_tool.py +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/tools/arc_tool.py +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Toggle Magnetic Snap" +msgstr "切换磁力吸附" + +#: sketcher/ui_gtk/tools/select_tool.py +msgid "Select Connected" +msgstr "选择相连对象" + +#: sketcher/ui_gtk/tools/fillet_tool.py +msgid "Fillet" +msgstr "圆角" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Apply" +msgstr "应用" + +#: sketcher/ui_gtk/tools/dimension_input.py +#: sketcher/ui_gtk/tools/delete_tool.py sketcher/ui_gtk/menu.py +msgid "Delete" +msgstr "删除" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Next field" +msgstr "下一个字段" + +#: sketcher/ui_gtk/tools/dimension_input.py +msgid "Prev field" +msgstr "上一个字段" + +#: sketcher/ui_gtk/tools/path_tool.py sketcher/ui_gtk/menu.py +msgid "Path" +msgstr "路径" + +#: sketcher/ui_gtk/tools/path_tool.py +msgid "Snap to Grid" +msgstr "对齐网格" + +#: sketcher/ui_gtk/tools/waypoint_sharp_tool.py +msgid "Sharp" +msgstr "尖锐" + +#: sketcher/ui_gtk/tools/rectangle_tool.py sketcher/ui_gtk/menu.py +msgid "Rectangle" +msgstr "矩形" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "W" +msgstr "宽" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "H" +msgstr "高" + +#: sketcher/ui_gtk/tools/rectangle_tool.py +msgid "Type dimensions (W H)" +msgstr "输入尺寸(宽 高)" + +#: sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py +msgid "Add Aspect Ratio Constraint" +msgstr "添加宽高比约束" + +#: sketcher/ui_gtk/tools/angle_constraint_tool.py +msgid "Add Angle Constraint" +msgstr "添加角度约束" + +#: sketcher/ui_gtk/tools/fill_tool.py +msgid "Fill" +msgstr "填充" + +#: sketcher/ui_gtk/tools/arc_tool.py sketcher/ui_gtk/menu.py +msgid "Arc" +msgstr "圆弧" + +#: sketcher/ui_gtk/tools/arc_tool.py +msgid "Type radius" +msgstr "输入半径" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Grid" +msgstr "网格" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create Grid" +msgstr "创建网格" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Rows" +msgstr "行" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Columns" +msgstr "列" + +#: sketcher/ui_gtk/tools/grid_tool.py +msgid "Create" +msgstr "创建" + +#: sketcher/ui_gtk/tools/tangent_constraint_tool.py +msgid "Add Tangent Constraint" +msgstr "添加相切约束" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Ellipse" +msgstr "椭圆" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Center on start point" +msgstr "以起点为中心" + +#: sketcher/ui_gtk/tools/circle_tool.py +msgid "Constrain to circle" +msgstr "约束为圆形" + +#: sketcher/ui_gtk/tools/vertical_constraint_tool.py +msgid "Add Vertical Constraint" +msgstr "添加垂直约束" + +#: sketcher/ui_gtk/tools/delete_tool.py +msgid "Delete Selection" +msgstr "删除选中项" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py sketcher/ui_gtk/menu.py +msgid "Rounded Rectangle" +msgstr "圆角矩形" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "R" +msgstr "R" + +#: sketcher/ui_gtk/tools/rounded_rect_tool.py +msgid "Type dimensions (W H R)" +msgstr "输入尺寸(宽 高 R)" + +#: sketcher/ui_gtk/tools/construction_tool.py +msgid "Construction" +msgstr "构造" + +#: sketcher/ui_gtk/tools/construction_tool.py sketcher/ui_gtk/menu.py +msgid "Toggle Construction" +msgstr "切换构造线" + +#: sketcher/ui_gtk/tools/radius_constraint_tool.py +msgid "Add Radius Constraint" +msgstr "添加半径约束" + +#: sketcher/ui_gtk/tools/text_box_tool.py +msgid "Text Box" +msgstr "文本框" + +#: sketcher/ui_gtk/tools/diameter_constraint_tool.py +msgid "Add Diameter Constraint" +msgstr "添加直径约束" + +#: sketcher/ui_gtk/tools/waypoint_smooth_tool.py +msgid "Smooth" +msgstr "平滑" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Coincident Constraint" +msgstr "添加重合约束" + +#: sketcher/ui_gtk/tools/coincident_constraint_tool.py +msgid "Add Point On Shape" +msgstr "添加点在形状上" + +#: sketcher/ui_gtk/tools/perpendicular_constraint_tool.py +msgid "Add Perpendicular Constraint" +msgstr "添加垂直约束" + +#: sketcher/ui_gtk/tools/horizontal_constraint_tool.py +msgid "Add Horizontal Constraint" +msgstr "添加水平约束" + +#: sketcher/ui_gtk/tools/chamfer_tool.py +msgid "Chamfer" +msgstr "倒角" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Equal" +msgstr "相等" + +#: sketcher/ui_gtk/tools/equal_constraint_tool.py +msgid "Add Equal Constraint" +msgstr "添加相等约束" + +#: sketcher/ui_gtk/tools/distance_constraint_tool.py +msgid "Add Distance Constraint" +msgstr "添加距离约束" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Conflicting Constraints" +msgstr "冲突的约束" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "These constraints cannot be satisfied simultaneously" +msgstr "这些约束无法同时满足" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete constraint" +msgstr "删除约束" + +#: sketcher/ui_gtk/conflicts_widget.py +msgid "Delete Constraint" +msgstr "删除约束" + +#: sketcher/ui_gtk/property_provider.py +msgid "No parameters" +msgstr "无参数" + +#: sketcher/ui_gtk/property_provider.py +msgid "Mixed Values" +msgstr "混合值" + +#: sketcher/ui_gtk/menu.py +msgid "Finish Sketch" +msgstr "完成草图" + +#: sketcher/ui_gtk/menu.py +msgid "Cancel Sketch" +msgstr "取消草图" + +#: sketcher/ui_gtk/menu.py +msgid "_File" +msgstr "文件(_F)" + +#: sketcher/ui_gtk/menu.py +msgid "Undo" +msgstr "撤销" + +#: sketcher/ui_gtk/menu.py +msgid "Redo" +msgstr "重做" + +#: sketcher/ui_gtk/menu.py +msgid "_Edit" +msgstr "编辑(_E)" + +#: sketcher/ui_gtk/menu.py +msgid "Circle" +msgstr "圆形" + +#: sketcher/ui_gtk/menu.py +msgid "Fill Area" +msgstr "填充区域" + +#: sketcher/ui_gtk/menu.py +msgid "Tools" +msgstr "工具" + +#: sketcher/ui_gtk/menu.py +msgid "Chamfer Corner" +msgstr "倒角" + +#: sketcher/ui_gtk/menu.py +msgid "Modify" +msgstr "修改" + +#: sketcher/ui_gtk/menu.py +msgid "_Sketch" +msgstr "草图(_S)" + +#: sketcher/ui_gtk/menu.py +msgid "Fit View" +msgstr "适应视图" + +#: sketcher/ui_gtk/menu.py +msgid "_View" +msgstr "视图(_V)" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Properties" +msgstr "字体属性" + +#: sketcher/ui_gtk/font_properties.py +msgid "Configure font family, size, and style for text boxes" +msgstr "配置文本框的字体系列、大小和样式" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Family" +msgstr "字体系列" + +#: sketcher/ui_gtk/font_properties.py +msgid "Font Size" +msgstr "字体大小" + +#: sketcher/ui_gtk/font_properties.py +msgid "Bold" +msgstr "粗体" + +#: sketcher/ui_gtk/font_properties.py +msgid "Italic" +msgstr "斜体" + +#: sketcher/ui_gtk/font_properties.py +msgid "Select Font" +msgstr "选择字体" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter radius or expression (e.g. 'width/2')." +msgstr "输入半径或表达式(例如 'width/2')。" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter diameter or expression." +msgstr "输入直径或表达式。" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter angle in degrees or expression." +msgstr "输入角度(度)或表达式。" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter length or expression." +msgstr "输入长度或表达式。" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "Enter value or expression." +msgstr "输入值或表达式。" + +#: sketcher/ui_gtk/sketchcanvas.py +msgid "OK" +msgstr "确定" + +#: sketcher/image/exporter.py sketcher/image/importer.py +#, python-brace-format +msgid "{app_name} Sketch" +msgstr "{app_name} 草图" + +#: sketcher/image/importer.py +msgid "Sketch file is invalid JSON: {}" +msgstr "草图文件是无效的JSON:{}" + +#: sketcher/image/importer.py +msgid "Failed to load sketch structure: {}" +msgstr "加载草图结构失败:{}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-sketcher/rayforge-addon.yaml new file mode 100644 index 000000000..187a97ccc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/rayforge-addon.yaml @@ -0,0 +1,12 @@ +name: sketcher +display_name: "Sketcher" +description: "Vector sketch editing functionality for creating and editing vector graphics" +api_version: 19 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "sketcher.worker" + frontend: "sketcher.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/__init__.py new file mode 100644 index 000000000..844b38555 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/__init__.py @@ -0,0 +1,5 @@ +"""Sketcher addon for vector graphics editing.""" + +from .core.sketch import Sketch + +__all__ = ["Sketch"] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/__init__.py new file mode 100644 index 000000000..ff93cf62b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/__init__.py @@ -0,0 +1,3 @@ +from .sketch import Sketch + +__all__ = ["Sketch"] 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 new file mode 100644 index 000000000..693aebc61 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/__init__.py @@ -0,0 +1,92 @@ +from .angle_constraint import AngleConstraintCommand, AngleConstraintParams +from .arc import ArcCommand, ArcPreviewState +from .base import PreviewState, SketchChangeCommand +from .bezier import BezierCommand, BezierPreviewState +from .chamfer import ChamferCommand +from .circle import CircleCommand, CirclePreviewState +from .constraint import ModifyConstraintCommand +from .constraint_create import CreateOrEditConstraintCommand +from .construction import ToggleConstructionCommand +from .dimension import DimensionData +from .distance_constraint import ( + DistanceConstraintCommand, + DistanceConstraintParams, +) +from .ellipse import EllipseCommand, EllipsePreviewState +from .equal_constraint import ( + EqualConstraintCommand, + EqualConstraintMergeResult, +) +from .fill import AddFillCommand, RemoveFillCommand, SetTextFillCommand +from .fillet import FilletCommand +from .grid import GridCommand +from .items import AddItemsCommand +from .line import LineCommand, LinePreviewState +from .live_text_edit import LiveTextEditCommand +from .point import ( + MoveControlPointCommand, + MovePointCommand, + UnstickJunctionCommand, +) +from .rectangle import RectangleCommand, RectanglePreviewState +from .rounded_rect import RoundedRectCommand, RoundedRectPreviewState +from .straighten import StraightenBezierCommand +from .symmetry_constraint import ( + SymmetryConstraintCommand, + SymmetryConstraintParams, +) +from .tangent_constraint import ( + TangentConstraintCommand, + TangentConstraintParams, +) +from .text_box import TextBoxCommand +from .text_property import ModifyTextPropertyCommand +from .waypoint import SetWaypointTypeCommand + +__all__ = [ + "AddFillCommand", + "AddItemsCommand", + "AngleConstraintCommand", + "AngleConstraintParams", + "ArcCommand", + "ArcPreviewState", + "BezierCommand", + "BezierPreviewState", + "ChamferCommand", + "CircleCommand", + "CirclePreviewState", + "CreateOrEditConstraintCommand", + "DimensionData", + "DistanceConstraintCommand", + "DistanceConstraintParams", + "EllipseCommand", + "EllipsePreviewState", + "EqualConstraintCommand", + "EqualConstraintMergeResult", + "FilletCommand", + "GridCommand", + "LineCommand", + "LinePreviewState", + "LiveTextEditCommand", + "ModifyConstraintCommand", + "ModifyTextPropertyCommand", + "MoveControlPointCommand", + "MovePointCommand", + "PreviewState", + "RectangleCommand", + "RectanglePreviewState", + "RemoveFillCommand", + "RoundedRectCommand", + "RoundedRectPreviewState", + "SetTextFillCommand", + "SetWaypointTypeCommand", + "SketchChangeCommand", + "StraightenBezierCommand", + "SymmetryConstraintCommand", + "SymmetryConstraintParams", + "TangentConstraintCommand", + "TangentConstraintParams", + "TextBoxCommand", + "ToggleConstructionCommand", + "UnstickJunctionCommand", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/angle_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/angle_constraint.py new file mode 100644 index 000000000..16b047775 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/angle_constraint.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from raygeo.geo.shape.arc import normalize_angle +from raygeo.geo.shape.line import get_line_line_intersection + +from ..entities import Line +from ..types import EntityID + +if TYPE_CHECKING: + from ..registry import EntityRegistry + +logger = logging.getLogger(__name__) + + +@dataclass +class AngleConstraintParams: + anchor_id: EntityID + other_id: EntityID + value_deg: float + anchor_far_idx: EntityID + other_far_idx: EntityID + + +class AngleConstraintCommand: + @staticmethod + def calculate_constraint_params( + registry: EntityRegistry, + e1_id: EntityID, + e2_id: EntityID, + ) -> AngleConstraintParams | None: + e1 = registry.get_entity(e1_id) + e2 = registry.get_entity(e2_id) + + if not (isinstance(e1, Line) and isinstance(e2, Line)): + logger.warning("Angle constraint requires exactly 2 lines.") + return None + + p1 = registry.get_point(e1.p1_idx) + p2 = registry.get_point(e1.p2_idx) + p3 = registry.get_point(e2.p1_idx) + p4 = registry.get_point(e2.p2_idx) + + if not (p1 and p2 and p3 and p4): + return None + + intersection = get_line_line_intersection( + (p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y), (p4.x, p4.y) + ) + + if intersection is None: + logger.warning( + "Lines are parallel, cannot create angle constraint." + ) + return None + + ix, iy = intersection + + def get_far_point(px1, px2): + d1 = (px1.x - ix) ** 2 + (px1.y - iy) ** 2 + d2 = (px2.x - ix) ** 2 + (px2.y - iy) ** 2 + return px1 if d1 > d2 else px2 + + far1 = get_far_point(p1, p2) + far2 = get_far_point(p3, p4) + + dir1 = math.atan2(far1.y - iy, far1.x - ix) + dir2 = math.atan2(far2.y - iy, far2.x - ix) + + cw_e1_to_e2 = normalize_angle(dir1 - dir2) + cw_e1_to_e2_deg = math.degrees(cw_e1_to_e2) + + if cw_e1_to_e2 <= math.pi: + anchor_id = e1_id + other_id = e2_id + value_deg = cw_e1_to_e2_deg + anchor_far_idx = far1.id + other_far_idx = far2.id + else: + anchor_id = e2_id + other_id = e1_id + value_deg = 360 - cw_e1_to_e2_deg + anchor_far_idx = far2.id + other_far_idx = far1.id + + logger.debug( + f"calculate_constraint_params: e1_id={e1_id}, e2_id={e2_id}, " + f"e1.p1_idx={e1.p1_idx}, e1.p2_idx={e1.p2_idx}, " + f"e2.p1_idx={e2.p1_idx}, e2.p2_idx={e2.p2_idx}, " + f"far1.id={far1.id}, far2.id={far2.id}, " + f"cw_e1_to_e2_deg={cw_e1_to_e2_deg:.1f}°, " + f"anchor_id={anchor_id}, other_id={other_id}, " + f"value_deg={value_deg:.1f}°, " + f"anchor_far_idx={anchor_far_idx}, other_far_idx={other_far_idx}" + ) + + return AngleConstraintParams( + anchor_id=anchor_id, + other_id=other_id, + value_deg=value_deg, + anchor_far_idx=anchor_far_idx, + other_far_idx=other_far_idx, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/arc.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/arc.py new file mode 100644 index 000000000..c4c4a969a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/arc.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.shape.arc import get_arc_direction +from raygeo.geo.shape.circle import project_point_onto_circle +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import EqualDistanceConstraint, RadiusConstraint +from ..entities import Arc, 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 + + +class ArcPreviewState(PreviewState): + """Preview state for arc tool's 3-click workflow.""" + + def __init__( + self, + center_id: EntityID, + center_temp: bool, + start_id: EntityID | None = None, + start_temp: bool = False, + temp_end_id: EntityID | None = None, + temp_entity_id: EntityID | None = None, + ): + self.center_id = center_id + self.center_temp = center_temp + self.start_id = start_id + self.start_temp = start_temp + self.temp_end_id = temp_end_id + self.temp_entity_id = temp_entity_id + self.clockwise = False + self.locked_radius: float | None = None + + def get_preview_point_ids(self) -> set[EntityID]: + """ + Returns IDs of temp preview points that shouldn't be snapped to. + + Excludes center and start points since they may be permanent. + """ + result: set[EntityID] = set() + if self.temp_end_id is not None: + result.add(self.temp_end_id) + return result + + @property + def has_start_point(self) -> bool: + """Returns True if start point has been set.""" + return self.start_id is not None + + def set_radius(self, registry: EntityRegistry, radius: float) -> None: + """ + Sets the arc radius from numeric input. + + Args: + registry: The entity registry to modify. + radius: The radius to apply. + """ + if self.start_id is None or self.temp_end_id is None: + return + + self.locked_radius = radius + + try: + center = registry.get_point(self.center_id) + start = registry.get_point(self.start_id) + end = registry.get_point(self.temp_end_id) + except IndexError: + return + + start_angle = math.atan2(start.y - center.y, start.x - center.x) + end_angle = math.atan2(end.y - center.y, end.x - center.x) + + start.x = center.x + radius * math.cos(start_angle) + start.y = center.y + radius * math.sin(start_angle) + end.x = center.x + radius * math.cos(end_angle) + end.y = center.y + radius * math.sin(end_angle) + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """ + Returns the arc radius dimension for preview. + + Args: + registry: The entity registry to query for point positions. + + Returns: + List containing a single DimensionData for the arc radius. + """ + if self.start_id is None or self.temp_end_id is None: + return [] + try: + center = registry.get_point(self.center_id) + start = registry.get_point(self.start_id) + end = registry.get_point(self.temp_end_id) + except IndexError: + return [] + radius = math.hypot(start.x - center.x, start.y - center.y) + arc = Arc( + -1, + self.start_id, + self.temp_end_id, + self.center_id, + clockwise=self.clockwise, + ) + midpoint = arc.get_midpoint(registry) + if not midpoint: + start_angle = math.atan2(start.y - center.y, start.x - center.x) + end_angle = math.atan2(end.y - center.y, end.x - center.x) + mid_angle = (start_angle + end_angle) / 2 + else: + mid_angle = math.atan2( + midpoint[1] - center.y, midpoint[0] - center.x + ) + arc_mid_x = center.x + radius * math.cos(mid_angle) + arc_mid_y = center.y + radius * math.sin(mid_angle) + return [ + DimensionData( + label=f"R{DimensionData.format_length(radius)}", + position=(arc_mid_x, arc_mid_y), + ) + ] + + +class ArcCommand(SketchChangeCommand): + """A command to create an arc with center, start, and end points.""" + + def __init__( + self, + sketch: Sketch, + center_id: EntityID, + start_id: EntityID, + end_pos: GeoPoint, + end_pid: EntityID | None = None, + is_center_temp: bool = False, + is_start_temp: bool = False, + clockwise: bool = False, + fixed_radius: float | None = None, + ): + super().__init__(sketch, _("Add Arc")) + self.center_id = center_id + self.start_id = start_id + self.end_pos = end_pos + self.end_pid = end_pid + self.is_center_temp = is_center_temp + self.is_start_temp = is_start_temp + self.clockwise = clockwise + self.fixed_radius = fixed_radius + self.add_cmd: AddItemsCommand | None = None + self._committed_end_id: EntityID | None = None + + @property + def committed_end_id(self) -> EntityID | None: + """The final end point ID after execute(), or None.""" + return self._committed_end_id + + @staticmethod + def start_center_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + **kwargs, + ) -> ArcPreviewState: + """ + Creates preview state after first click (center point). + + Args: + registry: The entity registry to modify. + x, y: The initial coordinates. + snapped_pid: An existing point ID to snap to, or None. + + Returns: + ArcPreviewState with center point set. + """ + if snapped_pid is not None: + center_id = snapped_pid + center_temp = False + else: + center_id = registry.add_point(x, y) + center_temp = True + + return ArcPreviewState( + center_id=center_id, + center_temp=center_temp, + ) + + @staticmethod + def set_start_point( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + snapped_pid: EntityID | None = None, + ) -> None: + """ + Sets the start point and creates the preview arc entity. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_center_preview. + x, y: The start point coordinates. + snapped_pid: An existing point ID to snap to, or None. + + Raises: + TypeError: If preview_state is not an ArcPreviewState. + """ + if not isinstance(preview_state, ArcPreviewState): + raise TypeError("Expected ArcPreviewState") + + if snapped_pid is not None and snapped_pid != preview_state.center_id: + start_id = snapped_pid + start_temp = False + else: + start_id = registry.add_point(x, y) + start_temp = True + + temp_end_id = registry.add_point(x, y) + temp_entity_id = registry.add_arc( + start_id, temp_end_id, preview_state.center_id + ) + + preview_state.start_id = start_id + preview_state.start_temp = start_temp + preview_state.temp_end_id = temp_end_id + preview_state.temp_entity_id = temp_entity_id + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + **kwargs, + ) -> ArcPreviewState: + """ + Creates preview arc entity after start point is set. + + This method expects center_id, center_temp, start_id, and start_temp + to be passed via kwargs since the arc tool has a 3-click workflow + where center and start are already established. + + Args: + registry: The entity registry to modify. + x, y: Initial end point coordinates. + snapped_pid: Not used for arc preview. + **kwargs: Must include center_id, center_temp, + start_id, start_temp. + + Returns: + ArcPreviewState for use with update_preview and cleanup_preview. + """ + center_id = kwargs["center_id"] + center_temp = kwargs["center_temp"] + start_id = kwargs["start_id"] + start_temp = kwargs["start_temp"] + + temp_end_id = registry.add_point(x, y) + temp_entity_id = registry.add_arc(start_id, temp_end_id, center_id) + + return ArcPreviewState( + center_id=center_id, + center_temp=center_temp, + start_id=start_id, + start_temp=start_temp, + temp_end_id=temp_end_id, + temp_entity_id=temp_entity_id, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """ + Updates the preview arc's end point position and direction. + + Args: + registry: The entity registry. + preview_state: The preview state from start_preview. + x, y: The new cursor coordinates. + + Raises: + TypeError: If preview_state is not an ArcPreviewState. + """ + if not isinstance(preview_state, ArcPreviewState): + raise TypeError("Expected ArcPreviewState") + if ( + preview_state.temp_end_id is None + or preview_state.temp_entity_id is None + or preview_state.start_id is None + ): + return + + try: + center = registry.get_point(preview_state.center_id) + start = registry.get_point(preview_state.start_id) + end = registry.get_point(preview_state.temp_end_id) + arc_ent = registry.get_entity(preview_state.temp_entity_id) + except IndexError: + return + + if not isinstance(arc_ent, Arc): + return + + if preview_state.locked_radius is not None: + cursor_radius = preview_state.locked_radius + else: + cursor_radius = math.hypot(x - center.x, y - center.y) + + start_angle = math.atan2(start.y - center.y, start.x - center.x) + start.x = center.x + cursor_radius * math.cos(start_angle) + start.y = center.y + cursor_radius * math.sin(start_angle) + end.x = center.x + cursor_radius * math.cos( + math.atan2(y - center.y, x - center.x) + ) + end.y = center.y + cursor_radius * math.sin( + math.atan2(y - center.y, x - center.x) + ) + + arc_ent.clockwise = get_arc_direction( + (center.x, center.y), (start.x, start.y), (x, y) + ) + preview_state.clockwise = arc_ent.clockwise + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes preview entities from the registry. + + Stores the final clockwise direction in preview_state.clockwise + before cleanup for the tool to read. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + + Raises: + TypeError: If preview_state is not an ArcPreviewState. + """ + if not isinstance(preview_state, ArcPreviewState): + raise TypeError("Expected ArcPreviewState") + + if preview_state.temp_entity_id is not None: + try: + arc_ent = registry.get_entity(preview_state.temp_entity_id) + if isinstance(arc_ent, Arc): + preview_state.clockwise = arc_ent.clockwise + except IndexError: + pass + + registry.entities = [ + e + for e in registry.entities + if e.id != preview_state.temp_entity_id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + if preview_state.temp_end_id is not None: + registry.points = [ + p for p in registry.points if p.id != preview_state.temp_end_id + ] + + @staticmethod + def cleanup_center_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes center point preview (when only center is set). + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_center_preview. + + Raises: + TypeError: If preview_state is not an ArcPreviewState. + """ + if not isinstance(preview_state, ArcPreviewState): + raise TypeError("Expected ArcPreviewState") + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + registry = self.sketch.registry + + try: + center_p = registry.get_point(self.center_id) + start_p = registry.get_point(self.start_id) + except IndexError: + return + + final_x, final_y = self.end_pos + if self.end_pid is not None: + try: + end_p = registry.get_point(self.end_pid) + final_x, final_y = end_p.x, end_p.y + except IndexError: + pass + + new_point = None + end_pid = self.end_pid + + if end_pid is None: + radius = math.hypot(start_p.x - center_p.x, start_p.y - center_p.y) + projected = project_point_onto_circle( + (final_x, final_y), (center_p.x, center_p.y), radius + ) + if projected: + final_x, final_y = projected + + temp_id = registry._id_counter + end_pid = temp_id + new_point = Point(temp_id, final_x, final_y) + + if end_pid == self.start_id or end_pid == self.center_id: + if self.is_center_temp: + self.sketch.remove_point_if_unused(self.center_id) + if self.is_start_temp: + self.sketch.remove_point_if_unused(self.start_id) + return + + temp_arc_id = registry._id_counter + (1 if new_point else 0) + new_arc = Arc( + temp_arc_id, + self.start_id, + end_pid, + self.center_id, + clockwise=self.clockwise, + ) + + constraints: list = [ + EqualDistanceConstraint( + self.center_id, self.start_id, self.center_id, end_pid + ) + ] + + if self.fixed_radius is not None: + constraints.append( + RadiusConstraint(temp_arc_id, self.fixed_radius) + ) + + points_to_add: list[Point] = [new_point] if new_point else [] + + if self.is_center_temp: + try: + p = registry.get_point(self.center_id) + registry.points.remove(p) + points_to_add.append(p) + except (IndexError, ValueError): + pass + + if self.is_start_temp: + try: + p = registry.get_point(self.start_id) + registry.points.remove(p) + points_to_add.append(p) + except (IndexError, ValueError): + pass + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=[new_arc], + constraints=constraints, + ) + self.add_cmd._do_execute() + self._committed_end_id = end_pid + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/base.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/base.py new file mode 100644 index 000000000..e8d6a2f08 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/base.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from rayforge.core.undo.command import Command + +if TYPE_CHECKING: + from ..registry import EntityRegistry + from ..sketch import Sketch + from .dimension import DimensionData + +logger = logging.getLogger(__name__) + + +class PreviewState: + """ + Base class for preview state returned by start_preview(). + + Subclass this to store command-specific preview data. + All preview state should be stored as attributes for the tool to read + after calling cleanup_preview(). + """ + + def get_preview_point_ids(self) -> set[int]: + """ + Returns IDs of temporary preview points that shouldn't be snapped to. + + Subclasses should override this to return the IDs of points that + were created during preview and should be excluded from hit testing. + The start/center point is typically excluded since it may be permanent. + + Returns: + Set of temporary point IDs that tools should ignore during + hit testing for snap purposes. + """ + return set() + + def get_hidden_point_ids(self) -> set[int]: + """ + Returns IDs of points that should be hidden during preview. + + Subclasses should override this to return the IDs of points that + should not be rendered during the preview phase (e.g., the start + point of a drag operation). + + Returns: + Set of point IDs that should be hidden from rendering. + """ + return set() + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """ + Returns dimension data for live preview rendering. + + Subclasses should override this to provide dimension information + (lengths, radii, angles, etc.) that should be displayed during + the preview phase. + + Args: + registry: The entity registry to query for point positions. + + Returns: + List of DimensionData objects representing dimensions to display. + """ + return [] + + +class SketchChangeCommand(Command): + """ + Base class for commands that modify a sketch and need to trigger a solve. + Includes functionality to snapshot geometry state for precise undo. + """ + + def __init__(self, sketch: Sketch, name: str): + super().__init__(name) + self.sketch = sketch + # Stores ( {point_id: (x, y)}, {entity_id: state_dict} ) + self._snapshot: tuple[dict[int, Point], dict[int, Any]] | None = None + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: int | None = None, + **kwargs, + ) -> PreviewState: + """ + Creates initial preview state with start point(s). + + Args: + registry: The entity registry to modify. + x, y: The initial coordinates. + snapped_pid: An existing point ID to snap to, or None. + **kwargs: Additional command-specific parameters. + + Returns: + PreviewState containing preview state for use with update_preview + and cleanup_preview. + + Raises: + NotImplementedError: If the command does not support preview. + """ + raise NotImplementedError("This command does not support preview") + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """ + Updates the preview geometry based on new cursor position. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + x, y: The new cursor coordinates. + + Raises: + NotImplementedError: If the command does not support preview. + """ + raise NotImplementedError("This command does not support preview") + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes all preview entities and points from the registry. + + The preview_state is modified in place if needed (e.g., to store + final computed values like direction). The tool reads all necessary + values from preview_state after calling this method. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + + Raises: + NotImplementedError: If the command does not support preview. + """ + raise NotImplementedError("This command does not support preview") + + def capture_snapshot(self): + """Captures the current coordinates of all points and entity states.""" + points = {p.id: (p.x, p.y) for p in self.sketch.registry.points} + entities = {} + for e in self.sketch.registry.entities: + state = e.get_state() + if state is not None: + entities[e.id] = state + + self._snapshot = (points, entities) + + def restore_snapshot(self): + """Restores coordinates and entity states from the snapshot.""" + if self._snapshot is None: + return + + points, entities = self._snapshot + registry = self.sketch.registry + + # Restore Points + for pid, (x, y) in points.items(): + try: + p = registry.get_point(pid) + p.x = x + p.y = y + except IndexError: + pass + + # Restore Entities + for eid, state in entities.items(): + entity = registry.get_entity(eid) + if entity: + entity.set_state(state) + + def execute(self) -> None: + # If a snapshot wasn't provided during initialization, capture it now. + if self._snapshot is None: + self.capture_snapshot() + + self._do_execute() + self.sketch.notify_update() + + def undo(self) -> None: + self._do_undo() + # Restore the exact geometric positions from before the command. + # This prevents the solver from jumping to an alternative solution + # (e.g., triangle flip) when constraints are reapplied. + self.restore_snapshot() + self.sketch.notify_update() + + def _do_execute(self) -> None: + raise NotImplementedError + + def _do_undo(self) -> None: + raise NotImplementedError diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/bezier.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/bezier.py new file mode 100644 index 000000000..4a4ba1748 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/bezier.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..entities import Bezier, Line, Point +from ..entities.point import WaypointType +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 + +logger = logging.getLogger(__name__) + + +class BezierPreviewState(PreviewState): + """ + Preview state for unified line/bezier tool workflow. + + Workflow: + - Click once: starts line preview from start to cursor + - Click without drag: creates line segment, starts next preview + - Click with drag: creates bezier segment where drag controls the "bow" + + Control points are stored on Bezier entity (cp1, cp2 as relative offsets). + virtual_cp tracks the "outgoing" handle for the end point - it will become + cp1 of the next bezier segment. + """ + + def __init__( + self, + start_id: EntityID, + start_temp: bool, + end_id: EntityID | None = None, + end_temp: bool = False, + temp_entity_id: EntityID | None = None, + is_line_preview: bool = True, + virtual_cp: GeoPoint | None = None, + ): + self.start_id = start_id + self.start_temp = start_temp + self.end_id = end_id + self.end_temp = end_temp + self.temp_entity_id = temp_entity_id + self.is_line_preview = is_line_preview + self.virtual_cp = virtual_cp + + def get_preview_point_ids(self) -> set[EntityID]: + result: set[int] = set() + if self.end_temp and self.end_id is not None: + result.add(self.end_id) + return result + + def get_virtual_cp_absolute( + self, registry: EntityRegistry + ) -> GeoPoint | None: + if self.virtual_cp is None or self.end_id is None: + return None + end_pt = registry.get_point(self.end_id) + if end_pt is None: + return None + return (end_pt.x + self.virtual_cp[0], end_pt.y + self.virtual_cp[1]) + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + return [] + + +class BezierCommand(SketchChangeCommand): + """ + A command to create a cubic bezier curve or line segment. + + Control points are stored on Bezier entities (cp1, cp2). + This command only creates the segment entity - it does not modify CPs. + """ + + def __init__( + self, + sketch: Sketch, + start_id: EntityID, + end_pos: GeoPoint, + end_pid: EntityID | None = None, + is_start_temp: bool = False, + is_line: bool = True, + cp1: GeoPoint | None = None, + cp2: GeoPoint | None = None, + constraints: list | None = None, + ): + label = _("Add Line") if is_line else _("Add Bezier") + super().__init__(sketch, label) + self.start_id = start_id + self.end_pos = end_pos + self.end_pid = end_pid + self.is_start_temp = is_start_temp + self.is_line = is_line + self.cp1 = cp1 + self.cp2 = cp2 + self.constraints = constraints or [] + self.add_cmd: AddItemsCommand | None = None + self._committed_end_id: EntityID | None = None + + @property + def committed_end_id(self) -> EntityID | None: + return self._committed_end_id + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: int | None = None, + virtual_cp: GeoPoint | None = None, + **kwargs, + ) -> BezierPreviewState: + if snapped_pid is not None: + start_id = snapped_pid + start_temp = False + try: + start_pt = registry.get_point(snapped_pid) + x, y = start_pt.x, start_pt.y + except IndexError: + pass + else: + start_id = registry.add_point(x, y) + start_temp = True + + end_id = registry.add_point(x, y) + + effective_virtual_cp = virtual_cp + if snapped_pid is not None and virtual_cp is None: + try: + start_pt = registry.get_point(snapped_pid) + if not start_pt.is_sharp(): + connected = start_pt.get_connected_beziers(registry) + for other_b in connected: + if other_b.end_idx == snapped_pid and ( + other_b.cp2 is not None + ): + effective_virtual_cp = ( + -other_b.cp2[0], + -other_b.cp2[1], + ) + elif other_b.start_idx == snapped_pid and ( + other_b.cp1 is not None + ): + effective_virtual_cp = ( + -other_b.cp1[0], + -other_b.cp1[1], + ) + break + except (IndexError, ValueError): + pass + + if effective_virtual_cp is not None: + entity_id = registry.add_bezier(start_id, end_id) + temp_entity = registry.get_entity(entity_id) + if isinstance(temp_entity, Bezier): + temp_entity.cp1 = effective_virtual_cp + is_line = False + else: + entity_id = registry.add_line(start_id, end_id) + is_line = True + + return BezierPreviewState( + start_id=start_id, + start_temp=start_temp, + end_id=end_id, + end_temp=True, + temp_entity_id=entity_id, + is_line_preview=is_line, + virtual_cp=None, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + if not isinstance(preview_state, BezierPreviewState): + raise TypeError("Expected BezierPreviewState") + + if preview_state.end_id is None: + return + + try: + end_pt = registry.get_point(preview_state.end_id) + except IndexError: + return + + end_pt.x = x + end_pt.y = y + + @staticmethod + def convert_to_bezier( + registry: EntityRegistry, + preview_state: BezierPreviewState, + waypoint_x: float, + waypoint_y: float, + drag_x: float, + drag_y: float, + mirror_cp_offset: GeoPoint | None = None, + ) -> None: + """ + Converts a line preview to a bezier preview. + + Control points belong to BEZIER entity: + - Drag controls the bezier's cp2 (incoming to end point) + - virtual_cp tracks the outgoing handle (will be cp1 of next) + - cp1 comes from previous segment's virtual_cp or segment default + """ + if not preview_state.is_line_preview: + return + + try: + start_pt = registry.get_point(preview_state.start_id) + except IndexError: + return + + preview_state.is_line_preview = False + + registry.entities = [ + e + for e in registry.entities + if e.id != preview_state.temp_entity_id + ] + registry._entity_map = { + k: v + for k, v in registry._entity_map.items() + if k != preview_state.temp_entity_id + } + + if preview_state.end_id is None: + return + end_pt = registry.get_point(preview_state.end_id) + end_pt.x = waypoint_x + end_pt.y = waypoint_y + + drag_offset = (drag_x - waypoint_x, drag_y - waypoint_y) + + preview_state.virtual_cp = drag_offset + + preview_state.temp_entity_id = registry.add_bezier( + preview_state.start_id, + preview_state.end_id, + ) + temp_entity = registry.get_entity(preview_state.temp_entity_id) + if isinstance(temp_entity, Bezier): + cp2_val = (-drag_offset[0], -drag_offset[1]) + temp_entity.cp2 = cp2_val + + cp1_val: GeoPoint | None = None + if mirror_cp_offset is not None: + cp1_val = mirror_cp_offset + elif not start_pt.is_sharp(): + connected = start_pt.get_connected_beziers(registry) + for other_b in connected: + if other_b.id == preview_state.temp_entity_id: + continue + if other_b.end_idx == preview_state.start_id and ( + other_b.cp2 is not None + ): + cp1_val = (-other_b.cp2[0], -other_b.cp2[1]) + elif other_b.start_idx == preview_state.start_id and ( + other_b.cp1 is not None + ): + cp1_val = (-other_b.cp1[0], -other_b.cp1[1]) + break + + if cp1_val is None: + seg_dx = waypoint_x - start_pt.x + seg_dy = waypoint_y - start_pt.y + seg_len = (seg_dx * seg_dx + seg_dy * seg_dy) ** 0.5 + if seg_len > 1e-9: + third = seg_len / 3.0 + cp1_val = ( + seg_dx / seg_len * third, + seg_dy / seg_len * third, + ) + else: + cp1_val = (drag_offset[0], drag_offset[1]) + temp_entity.cp1 = cp1_val + + logger.debug( + f"convert_to_bezier: cp1={cp1_val}, " + f"cp2={cp2_val}, virtual_cp={preview_state.virtual_cp}" + ) + + @staticmethod + def update_control_point( + registry: EntityRegistry, + preview_state: BezierPreviewState, + x: float, + y: float, + ) -> None: + """Update control points during bezier drag. + + The drag controls the bezier's cp2 (incoming to end point). + virtual_cp tracks the outgoing handle for the next segment. + """ + if preview_state.is_line_preview or preview_state.end_id is None: + return + + try: + end_pt = registry.get_point(preview_state.end_id) + except IndexError: + return + + drag_offset = (x - end_pt.x, y - end_pt.y) + preview_state.virtual_cp = drag_offset + + if preview_state.temp_entity_id is not None: + temp_entity = registry.get_entity(preview_state.temp_entity_id) + if isinstance(temp_entity, Bezier): + temp_entity.cp2 = (-drag_offset[0], -drag_offset[1]) + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + if not isinstance(preview_state, BezierPreviewState): + raise TypeError("Expected BezierPreviewState") + + logger.debug( + f"cleanup_preview: temp_entity_id={preview_state.temp_entity_id}, " + f"end_id={preview_state.end_id}, end_temp={preview_state.end_temp}" + ) + + if preview_state.temp_entity_id is not None: + registry.entities = [ + e + for e in registry.entities + if e.id != preview_state.temp_entity_id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + if preview_state.end_temp and preview_state.end_id is not None: + registry.points = [ + p for p in registry.points if p.id != preview_state.end_id + ] + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + registry = self.sketch.registry + + final_x, final_y = self.end_pos + if self.end_pid is not None: + try: + end_p = registry.get_point(self.end_pid) + final_x, final_y = end_p.x, end_p.y + except IndexError: + pass + + new_point = None + end_pid = self.end_pid + + if end_pid is None: + temp_id = registry._id_counter + end_pid = temp_id + new_point = Point(temp_id, final_x, final_y) + + points_to_add: list[Point] = [new_point] if new_point else [] + + if self.is_start_temp: + try: + p = registry.get_point(self.start_id) + registry.points.remove(p) + points_to_add.append(p) + except (IndexError, ValueError): + pass + + if self.is_line: + temp_entity_id = registry._id_counter + (1 if new_point else 0) + new_entity = Line(temp_entity_id, self.start_id, end_pid) + else: + temp_entity_id = registry._id_counter + (1 if new_point else 0) + new_entity = Bezier( + temp_entity_id, + self.start_id, + end_pid, + cp1=self.cp1, + cp2=self.cp2, + ) + + try: + start_pt = registry.get_point(self.start_id) + start_pt.waypoint_type = WaypointType.SYMMETRIC + except (IndexError, ValueError): + pass + + if new_point: + new_point.waypoint_type = WaypointType.SYMMETRIC + elif end_pid is not None: + try: + end_pt = registry.get_point(end_pid) + end_pt.waypoint_type = WaypointType.SYMMETRIC + except (IndexError, ValueError): + pass + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=[new_entity], + constraints=self.constraints, + ) + self.add_cmd._do_execute() + self._committed_end_id = end_pid + + if not self.is_line and isinstance(new_entity, Bezier): + try: + start_pt = registry.get_point(self.start_id) + if not start_pt.is_sharp(): + connected = start_pt.get_connected_beziers(registry) + for other_b in connected: + if ( + other_b.id != new_entity.id + and other_b.end_idx == self.start_id + and other_b.cp2 is not None + ): + new_entity.cp1 = ( + -other_b.cp2[0], + -other_b.cp2[1], + ) + elif ( + other_b.start_idx == self.start_id + and other_b.cp1 is not None + ): + new_entity.cp1 = ( + -other_b.cp1[0], + -other_b.cp1[1], + ) + break + except (IndexError, ValueError): + pass + + try: + end_pt = registry.get_point(end_pid) + if end_pt is not None and not end_pt.is_sharp(): + connected = end_pt.get_connected_beziers(registry) + for other_b in connected: + if ( + other_b.id != new_entity.id + and other_b.start_idx == end_pid + and other_b.cp1 is not None + ): + new_entity.cp2 = ( + -other_b.cp1[0], + -other_b.cp1[1], + ) + elif ( + other_b.end_idx == end_pid + and other_b.cp2 is not None + ): + new_entity.cp2 = ( + -other_b.cp2[0], + -other_b.cp2[1], + ) + break + except (IndexError, ValueError): + pass + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/chamfer.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/chamfer.py new file mode 100644 index 000000000..7e545ba7c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/chamfer.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from ..constraints import ( + CollinearConstraint, + EqualDistanceConstraint, +) +from ..entities import Line, Point +from ..types import EntityID +from .base import SketchChangeCommand +from .items import AddItemsCommand, RemoveItemsCommand + +if TYPE_CHECKING: + from ..registry import EntityRegistry + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class ChamferCommand(SketchChangeCommand): + """Command to add a chamfer to a corner by replacing the corner lines.""" + + def __init__( + self, + sketch: Sketch, + corner_pid: EntityID, + line1_id: EntityID, + line2_id: EntityID, + distance: float, + ): + super().__init__(sketch, _("Add Chamfer")) + self.corner_pid = corner_pid + self.line1_id = line1_id + self.line2_id = line2_id + self.distance = distance + + # State for undo/redo + self.add_cmd: AddItemsCommand | None = None + self.remove_cmd: RemoveItemsCommand | None = None + self._prepared = False + + @staticmethod + def calculate_geometry( + reg: EntityRegistry, + corner_pid: EntityID, + line1_id: EntityID, + line2_id: EntityID, + distance: float, + ) -> dict[str, Any] | None: + """ + Calculates the points, entities, and constraints for a chamfer. + This is a pure function for testability and reusability. + """ + line1 = reg.get_entity(line1_id) + line2 = reg.get_entity(line2_id) + if not isinstance(line1, Line) or not isinstance(line2, Line): + return None + + try: + corner_point = reg.get_point(corner_pid) + other1_pid = ( + line1.p2_idx if line1.p1_idx == corner_pid else line1.p1_idx + ) + other2_pid = ( + line2.p2_idx if line2.p1_idx == corner_pid else line2.p1_idx + ) + other1_pt = reg.get_point(other1_pid) + other2_pt = reg.get_point(other2_pid) + except IndexError: + return None + + v1 = (other1_pt.x - corner_point.x, other1_pt.y - corner_point.y) + len1 = math.hypot(v1[0], v1[1]) + if len1 < 1e-6 or len1 < distance: # Not enough length for chamfer + return None + u1 = (v1[0] / len1, v1[1] / len1) if len1 > 1e-9 else (0.0, 0.0) + p_new1_pos = ( + corner_point.x + distance * u1[0], + corner_point.y + distance * u1[1], + ) + + v2 = (other2_pt.x - corner_point.x, other2_pt.y - corner_point.y) + len2 = math.hypot(v2[0], v2[1]) + if len2 < 1e-6 or len2 < distance: # Not enough length for chamfer + return None + u2 = (v2[0] / len2, v2[1] / len2) if len2 > 1e-9 else (0.0, 0.0) + p_new2_pos = ( + corner_point.x + distance * u2[0], + corner_point.y + distance * u2[1], + ) + + # Define new items with temporary IDs + p1 = Point(-1, p_new1_pos[0], p_new1_pos[1]) + p2 = Point(-2, p_new2_pos[0], p_new2_pos[1]) + + added_entities = [ + Line(-3, p1.id, p2.id), # chamfer_line + Line(-4, other1_pid, p1.id), # new_segment1 + Line(-5, other2_pid, p2.id), # new_segment2 + ] + + added_constraints = [ + CollinearConstraint(other1_pid, corner_pid, p1.id), + CollinearConstraint(other2_pid, corner_pid, p2.id), + EqualDistanceConstraint(corner_pid, p1.id, corner_pid, p2.id), + ] + + return { + "points": [p1, p2], + "entities": added_entities, + "constraints": added_constraints, + "removed_entities": [line1, line2], + } + + def _prepare(self) -> bool: + """Prepares internal commands on first execution.""" + if self._prepared: + return True + + result = self.calculate_geometry( + self.sketch.registry, + self.corner_pid, + self.line1_id, + self.line2_id, + self.distance, + ) + + if result is None: + return False + + self.remove_cmd = RemoveItemsCommand( + self.sketch, "", entities=result["removed_entities"] + ) + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=result["points"], + entities=result["entities"], + constraints=result["constraints"], + ) + self._prepared = True + return True + + def _do_execute(self) -> None: + if not self._prepare(): + return + + # Use composition to apply changes + if self.remove_cmd: + self.remove_cmd._do_execute() + if self.add_cmd: + self.add_cmd._do_execute() + + def _do_undo(self) -> None: + if not self.add_cmd or not self.remove_cmd: + return + + # Undo in reverse order + self.add_cmd._do_undo() + self.remove_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/circle.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/circle.py new file mode 100644 index 000000000..48449e334 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/circle.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import DiameterConstraint +from ..entities import Circle, 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 + + +class CirclePreviewState(PreviewState): + """Preview state for circle tool's 2-click workflow.""" + + def __init__( + self, + center_id: EntityID, + center_temp: bool, + radius_id: EntityID, + entity_id: EntityID, + ): + self.center_id = center_id + self.center_temp = center_temp + self.radius_id = radius_id + self.entity_id = entity_id + self.locked_diameter: float | None = None + + def get_preview_point_ids(self) -> set[EntityID]: + """ + Returns IDs of temporary preview points that shouldn't be snapped to. + + Excludes the center point since it may be permanent. + """ + return {self.radius_id} + + def set_diameter(self, registry: EntityRegistry, diameter: float) -> None: + """ + Sets the circle diameter from numeric input. + + Args: + registry: The entity registry to modify. + diameter: The diameter to apply. + """ + self.locked_diameter = diameter + + try: + center = registry.get_point(self.center_id) + radius_pt = registry.get_point(self.radius_id) + except IndexError: + return + + dx = radius_pt.x - center.x + dy = radius_pt.y - center.y + current_radius = math.hypot(dx, dy) + target_radius = diameter / 2.0 + + if current_radius < 1e-9: + radius_pt.x = center.x + target_radius + return + + scale = target_radius / current_radius + radius_pt.x = center.x + dx * scale + radius_pt.y = center.y + dy * scale + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """ + Returns the circle diameter dimension for preview. + + Args: + registry: The entity registry to query for point positions. + + Returns: + List containing a single DimensionData for the circle diameter. + """ + try: + center = registry.get_point(self.center_id) + radius_pt = registry.get_point(self.radius_id) + except IndexError: + return [] + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + diameter = radius * 2 + dx = radius_pt.x - center.x + dy = radius_pt.y - center.y + dist = math.hypot(dx, dy) + angle = math.atan2(dy, dx) if dist > 1e-9 else 0 + arc_mid_x = center.x + radius * math.cos(angle) + arc_mid_y = center.y + radius * math.sin(angle) + return [ + DimensionData( + label=f"Ø{DimensionData.format_length(diameter)}", + position=(arc_mid_x, arc_mid_y), + ) + ] + + +class CircleCommand(SketchChangeCommand): + """A command to create a circle with center and radius points.""" + + def __init__( + self, + sketch: Sketch, + center_id: EntityID, + end_pos: GeoPoint, + end_pid: EntityID | None = None, + is_center_temp: bool = False, + fixed_diameter: float | None = None, + ): + super().__init__(sketch, _("Add Circle")) + self.center_id = center_id + self.end_pos = end_pos + self.end_pid = end_pid + self.is_center_temp = is_center_temp + self.fixed_diameter = fixed_diameter + self.add_cmd: AddItemsCommand | None = None + self._committed_end_id: EntityID | None = None + + @property + def committed_end_id(self) -> EntityID | None: + """ + The final end point ID after execute(), or None if not applicable. + """ + return self._committed_end_id + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + **kwargs, + ) -> CirclePreviewState: + """ + Creates initial preview state with center, radius point, and circle. + + Args: + registry: The entity registry to modify. + x, y: The initial coordinates. + snapped_pid: An existing point ID to snap to, or None. + + Returns: + CirclePreviewState for use with update_preview and cleanup_preview. + """ + if snapped_pid is not None: + center_id = snapped_pid + center_temp = False + else: + center_id = registry.add_point(x, y) + center_temp = True + + radius_id = registry.add_point(x, y) + entity_id = registry.add_circle(center_id, radius_id) + + return CirclePreviewState( + center_id=center_id, + center_temp=center_temp, + radius_id=radius_id, + entity_id=entity_id, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """ + Updates the preview radius point position. + + Args: + registry: The entity registry. + preview_state: The preview state from start_preview. + x, y: The new cursor coordinates. + + Raises: + TypeError: If preview_state is not a CirclePreviewState. + """ + if not isinstance(preview_state, CirclePreviewState): + raise TypeError("Expected CirclePreviewState") + + if preview_state.locked_diameter is not None: + return + + try: + radius_p = registry.get_point(preview_state.radius_id) + except IndexError: + return + radius_p.x = x + radius_p.y = y + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes preview entities from the registry. + + Note: This does NOT remove the center point if center_temp=True. + The tool is responsible for removing it if the user cancels. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + + Raises: + TypeError: If preview_state is not a CirclePreviewState. + """ + if not isinstance(preview_state, CirclePreviewState): + raise TypeError("Expected CirclePreviewState") + + if preview_state.entity_id is not None: + registry.entities = [ + e for e in registry.entities if e.id != preview_state.entity_id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + if preview_state.radius_id is not None: + registry.points = [ + p for p in registry.points if p.id != preview_state.radius_id + ] + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + registry = self.sketch.registry + + try: + registry.get_point(self.center_id) + except IndexError: + return + + final_x, final_y = self.end_pos + if self.end_pid is not None: + try: + end_p = registry.get_point(self.end_pid) + final_x, final_y = end_p.x, end_p.y + except IndexError: + pass + + new_point = None + end_pid = self.end_pid + + if end_pid is None: + temp_id = registry._id_counter + end_pid = temp_id + new_point = Point(temp_id, final_x, final_y) + + if end_pid == self.center_id: + if self.is_center_temp: + self.sketch.remove_point_if_unused(self.center_id) + return + + temp_circle_id = registry._id_counter + (1 if new_point else 0) + new_circle = Circle(temp_circle_id, self.center_id, end_pid) + + points_to_add: list[Point] = [new_point] if new_point else [] + + if self.is_center_temp: + try: + p = registry.get_point(self.center_id) + registry.points.remove(p) + points_to_add.append(p) + except (IndexError, ValueError): + pass + + constraints_to_add = [] + if self.fixed_diameter is not None: + diameter_constr = DiameterConstraint( + temp_circle_id, self.fixed_diameter, user_visible=True + ) + constraints_to_add.append(diameter_constr) + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=[new_circle], + constraints=constraints_to_add, + ) + self.add_cmd._do_execute() + self._committed_end_id = end_pid + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/constraint.py new file mode 100644 index 000000000..7ef30e963 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/constraint.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class ModifyConstraintCommand(SketchChangeCommand): + """ + Command to modify the value or expression of a constraint. + """ + + def __init__( + self, + sketch: Sketch, + constraint: Constraint, + new_value: float, + new_expression: str | None = None, + name: str = _("Edit Constraint"), + ): + super().__init__(sketch, name) + self.constraint = constraint + self.new_value = float(new_value) + self.new_expression = new_expression + + self.old_value = float(constraint.value) + self.old_expression = getattr(constraint, "expression", None) + + def _do_execute(self) -> None: + self.constraint.value = self.new_value + self.constraint.expression = self.new_expression + + def _do_undo(self) -> None: + self.constraint.value = self.old_value + self.constraint.expression = self.old_expression diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/constraint_create.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/constraint_create.py new file mode 100644 index 000000000..12a22c8ed --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/constraint_create.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..constraints import ( + DiameterConstraint, + DistanceConstraint, + RadiusConstraint, +) +from ..entities import Arc, Circle, Entity, Line +from .base import SketchChangeCommand +from .items import AddItemsCommand + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..sketch import Sketch + + +class CreateOrEditConstraintCommand(SketchChangeCommand): + """ + Creates a constraint for an entity, or returns existing one for editing. + + This command is used for double-click interactions on entities where + the user wants to add or edit a dimensional constraint. + """ + + def __init__( + self, + sketch: Sketch, + entity: Entity, + name: str = _("Add Constraint"), + ): + super().__init__(sketch, name) + self.entity = entity + self._existing_constraint: Constraint | None = None + self._created_constraint: Constraint | None = None + self._add_cmd: AddItemsCommand | None = None + + @staticmethod + def get_constraint_for_entity( + sketch: Sketch, + entity: Entity, + ) -> Constraint | None: + """ + Returns existing constraint for entity, or None. + + Args: + sketch: The sketch containing the entity. + entity: The entity to find a constraint for. + + Returns: + The existing constraint if found, or None. + """ + constraints = sketch.constraints or [] + + if isinstance(entity, Arc): + for constr in constraints: + if ( + isinstance(constr, RadiusConstraint) + and constr.entity_id == entity.id + ): + return constr + + elif isinstance(entity, Line): + p1_id, p2_id = entity.p1_idx, entity.p2_idx + for constr in constraints: + if isinstance(constr, DistanceConstraint) and { + constr.p1, + constr.p2, + } == {p1_id, p2_id}: + return constr + + elif isinstance(entity, Circle): + for constr in constraints: + if ( + isinstance(constr, DiameterConstraint) + and constr.circle_id == entity.id + ): + return constr + + return None + + @staticmethod + def create_constraint_for_entity( + sketch: Sketch, + entity: Entity, + initial_value: float | None = None, + ) -> Constraint | None: + """ + Creates and returns a new constraint for the entity. + + Args: + sketch: The sketch containing the entity. + entity: The entity to create a constraint for. + initial_value: Optional initial value for the constraint. + If None, the value is calculated from current geometry. + + Returns: + The newly created constraint, or None if entity type + doesn't support constraint creation. + """ + registry = sketch.registry + + if isinstance(entity, Arc): + start = registry.get_point(entity.start_idx) + center = registry.get_point(entity.center_idx) + if start and center: + radius = math.hypot(start.x - center.x, start.y - center.y) + value = initial_value if initial_value is not None else radius + return RadiusConstraint(entity.id, value) + + elif isinstance(entity, Line): + p1 = registry.get_point(entity.p1_idx) + p2 = registry.get_point(entity.p2_idx) + if p1 and p2: + dist = math.hypot(p1.x - p2.x, p1.y - p2.y) + value = initial_value if initial_value is not None else dist + return DistanceConstraint(entity.p1_idx, entity.p2_idx, value) + + elif isinstance(entity, Circle): + center = registry.get_point(entity.center_idx) + radius_pt = registry.get_point(entity.radius_pt_idx) + if center and radius_pt: + radius = math.hypot( + radius_pt.x - center.x, radius_pt.y - center.y + ) + value = ( + initial_value if initial_value is not None else radius * 2 + ) + return DiameterConstraint(entity.id, value) + + return None + + @property + def constraint(self) -> Constraint | None: + """ + Returns the constraint involved in this operation. + + After execute(), this returns either the existing constraint + (if one was found) or the newly created constraint. + """ + if self._existing_constraint is not None: + return self._existing_constraint + return self._created_constraint + + @property + def is_new_constraint(self) -> bool: + """Returns True if a new constraint was created, False if existing.""" + return ( + self._existing_constraint is None + and self._created_constraint is not None + ) + + def _do_execute(self) -> None: + if self._add_cmd is not None: + return self._add_cmd._do_execute() + + existing = self.get_constraint_for_entity(self.sketch, self.entity) + + if existing is not None: + self._existing_constraint = existing + return + + new_constr = self.create_constraint_for_entity( + self.sketch, self.entity + ) + + if new_constr is None: + return + + self._created_constraint = new_constr + + label = self._get_command_label(new_constr) + self._add_cmd = AddItemsCommand( + self.sketch, + label, + constraints=[new_constr], + ) + self._add_cmd._do_execute() + + def _do_undo(self) -> None: + if self._add_cmd is not None: + self._add_cmd._do_undo() + + def _get_command_label(self, constraint: Constraint) -> str: + return _("Add {}").format(constraint.get_type_name()) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/construction.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/construction.py new file mode 100644 index 000000000..497e7282f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/construction.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class ToggleConstructionCommand(SketchChangeCommand): + """Command to toggle the construction state of multiple entities.""" + + def __init__(self, sketch: Sketch, name: str, entity_ids: list[int]): + super().__init__(sketch, name) + self.entity_ids = entity_ids + self.original_states: dict[int, bool] = {} + self.new_state: bool | None = None + + def _do_execute(self) -> None: + self.original_states.clear() + entities_to_modify = [] + for eid in self.entity_ids: + ent = self.sketch.registry.get_entity(eid) + if ent: + entities_to_modify.append(ent) + self.original_states[eid] = ent.construction + + if not entities_to_modify: + return + + # Logic: If any selected entity is NOT construction, set all to + # construction. + # Otherwise (all are construction), set all to normal. + if self.new_state is None: + has_normal = any(not e.construction for e in entities_to_modify) + self.new_state = has_normal + + for e in entities_to_modify: + e.construction = self.new_state + + def _do_undo(self) -> None: + for eid, old_state in self.original_states.items(): + ent = self.sketch.registry.get_entity(eid) + if ent: + ent.construction = old_state diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/dimension.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/dimension.py new file mode 100644 index 000000000..432c4c56c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/dimension.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from raygeo.geo.types import Point + + +@dataclass +class DimensionData: + label: str + position: Point + leader_end: Point | None = None + + @staticmethod + def format_length(value: float) -> str: + if abs(value) < 0.01: + return "0.00" + return f"{value:.2f}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/distance_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/distance_constraint.py new file mode 100644 index 000000000..c73ca57fd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/distance_constraint.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ..entities import Line +from ..types import EntityID + +if TYPE_CHECKING: + from ..entities import Point + from ..registry import EntityRegistry + + +@dataclass +class DistanceConstraintParams: + p1_id: EntityID + p2_id: EntityID + distance: float + + +class DistanceConstraintCommand: + @staticmethod + def calculate_distance( + registry: EntityRegistry, + point_ids: list[EntityID], + entity_ids: list[EntityID], + ) -> DistanceConstraintParams | None: + if len(point_ids) == 2: + p1 = registry.get_point(point_ids[0]) + p2 = registry.get_point(point_ids[1]) + if p1 and p2: + dist = math.hypot(p1.x - p2.x, p1.y - p2.y) + return DistanceConstraintParams( + p1_id=p1.id, + p2_id=p2.id, + distance=dist, + ) + + if len(entity_ids) == 1: + eid = entity_ids[0] + e = registry.get_entity(eid) + if isinstance(e, Line): + p1 = registry.get_point(e.p1_idx) + p2 = registry.get_point(e.p2_idx) + if p1 and p2: + dist = math.hypot(p1.x - p2.x, p1.y - p2.y) + return DistanceConstraintParams( + p1_id=p1.id, + p2_id=p2.id, + distance=dist, + ) + + return None + + @staticmethod + def calculate_distance_from_points( + p1: Point, + p2: Point, + ) -> float: + return math.hypot(p1.x - p2.x, p1.y - p2.y) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/ellipse.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/ellipse.py new file mode 100644 index 000000000..581f59ef2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/ellipse.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import ( + Constraint, + EqualDistanceConstraint, + PerpendicularConstraint, +) +from ..entities import Ellipse, Line, Point +from ..types import EntityID +from .base import PreviewState, SketchChangeCommand +from .items import AddItemsCommand + +if TYPE_CHECKING: + from ..registry import EntityRegistry + from ..sketch import Sketch + + +class EllipsePreviewState(PreviewState): + """Preview state for ellipse tool's drag-to-create workflow.""" + + def __init__( + self, + start_id: EntityID, + start_temp: bool, + center_id: EntityID, + radius_x_id: EntityID, + radius_y_id: EntityID, + entity_id: EntityID, + ): + self.start_id = start_id + self.start_temp = start_temp + self.center_id = center_id + self.radius_x_id = radius_x_id + self.radius_y_id = radius_y_id + self.entity_id = entity_id + + def get_preview_point_ids(self) -> set[EntityID]: + return {self.center_id, self.radius_x_id, self.radius_y_id} + + def get_hidden_point_ids(self) -> set[EntityID]: + return {self.start_id} + + +class EllipseCommand(SketchChangeCommand): + """A command to create an ellipse.""" + + def __init__( + self, + sketch: Sketch, + start_id: EntityID, + end_pos: GeoPoint, + end_pid: EntityID | None = None, + is_start_temp: bool = False, + center_on_start: bool = False, + constrain_circle: bool = False, + ): + super().__init__(sketch, _("Add Ellipse")) + self.start_id = start_id + self.end_pos = end_pos + self.end_pid = end_pid + self.is_start_temp = is_start_temp + self.center_on_start = center_on_start + self.constrain_circle = constrain_circle + self.add_cmd: AddItemsCommand | None = None + + @staticmethod + def _calculate_ellipse_params( + x1: float, + y1: float, + x2: float, + y2: float, + center_on_start: bool, + constrain_circle: bool, + ) -> tuple[float, float, float, float]: + if center_on_start: + cx, cy = x1, y1 + rx = abs(x2 - x1) + ry = abs(y2 - y1) + if constrain_circle: + r = min(rx, ry) + rx = r + ry = r + else: + if constrain_circle: + width = abs(x2 - x1) + height = abs(y2 - y1) + size = min(width, height) + rx = size / 2 + ry = rx + dx = 1 if x2 >= x1 else -1 + dy = 1 if y2 >= y1 else -1 + cx = x1 + dx * rx + cy = y1 + dy * ry + else: + cx = (x1 + x2) / 2 + cy = (y1 + y2) / 2 + rx = abs(x2 - x1) / 2 + ry = abs(y2 - y1) / 2 + + return cx, cy, rx, ry + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + **kwargs, + ) -> EllipsePreviewState: + if snapped_pid is not None: + start_id = snapped_pid + start_temp = False + else: + start_id = registry.add_point(x, y) + start_temp = True + + center_id = registry.add_point(x, y) + radius_x_id = registry.add_point(x, y) + radius_y_id = registry.add_point(x, y) + entity_id = registry.add_ellipse(center_id, radius_x_id, radius_y_id) + + return EllipsePreviewState( + start_id=start_id, + start_temp=start_temp, + center_id=center_id, + radius_x_id=radius_x_id, + radius_y_id=radius_y_id, + entity_id=entity_id, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + center_on_start: bool = False, + constrain_circle: bool = False, + ) -> None: + if not isinstance(preview_state, EllipsePreviewState): + raise TypeError("Expected EllipsePreviewState") + + try: + start_p = registry.get_point(preview_state.start_id) + center_p = registry.get_point(preview_state.center_id) + radius_x_p = registry.get_point(preview_state.radius_x_id) + radius_y_p = registry.get_point(preview_state.radius_y_id) + except IndexError: + return + + cx, cy, rx, ry = EllipseCommand._calculate_ellipse_params( + start_p.x, start_p.y, x, y, center_on_start, constrain_circle + ) + + center_p.x = cx + center_p.y = cy + radius_x_p.x = cx + rx + radius_x_p.y = cy + radius_y_p.x = cx + radius_y_p.y = cy + ry + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + if not isinstance(preview_state, EllipsePreviewState): + raise TypeError("Expected EllipsePreviewState") + + if preview_state.entity_id is not None: + registry.entities = [ + e for e in registry.entities if e.id != preview_state.entity_id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + point_ids = { + preview_state.center_id, + preview_state.radius_x_id, + preview_state.radius_y_id, + } + registry.points = [p for p in registry.points if p.id not in point_ids] + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + registry = self.sketch.registry + + try: + start_p = registry.get_point(self.start_id) + except IndexError: + return + + final_x, final_y = self.end_pos + if self.end_pid is not None: + try: + end_p = registry.get_point(self.end_pid) + final_x, final_y = end_p.x, end_p.y + except IndexError: + pass + + cx, cy, rx, ry = self._calculate_ellipse_params( + start_p.x, + start_p.y, + final_x, + final_y, + self.center_on_start, + self.constrain_circle, + ) + + if rx < 1e-6 or ry < 1e-6: + if self.is_start_temp: + self.sketch.remove_point_if_unused(self.start_id) + return + + temp_id_counter = -1 + + def next_temp_id(): + nonlocal temp_id_counter + temp_id_counter -= 1 + return temp_id_counter + + center_id = next_temp_id() + radius_x_id = next_temp_id() + radius_y_id = next_temp_id() + + new_center = Point(center_id, cx, cy) + new_radius_x = Point(radius_x_id, cx + rx, cy) + new_radius_y = Point(radius_y_id, cx, cy + ry) + + line_x_id = next_temp_id() + line_x = Line(line_x_id, center_id, radius_x_id) + line_x.invisible = True + + line_y_id = next_temp_id() + line_y = Line(line_y_id, center_id, radius_y_id) + line_y.invisible = True + + visible_line_x_id = next_temp_id() + visible_line_x = Line( + visible_line_x_id, center_id, radius_x_id, construction=True + ) + + visible_line_y_id = next_temp_id() + visible_line_y = Line( + visible_line_y_id, center_id, radius_y_id, construction=True + ) + + ellipse_id = next_temp_id() + new_ellipse = Ellipse( + ellipse_id, + center_id, + radius_x_id, + radius_y_id, + helper_line_ids=[line_x_id, line_y_id], + ) + + perp_constraint = PerpendicularConstraint( + line_x_id, line_y_id, user_visible=False + ) + + constraints: list[Constraint] = [perp_constraint] + if self.constrain_circle: + constraints.append( + EqualDistanceConstraint( + center_id, + radius_x_id, + center_id, + radius_y_id, + user_visible=True, + ) + ) + + points_to_add = [new_center, new_radius_x, new_radius_y] + + if self.is_start_temp: + registry.points.remove(start_p) + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=[ + new_ellipse, + line_x, + line_y, + visible_line_x, + visible_line_y, + ], + 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/core/commands/equal_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/equal_constraint.py new file mode 100644 index 000000000..383cf885e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/equal_constraint.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ..constraints import EqualLengthConstraint + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..sketch import Sketch + + +@dataclass +class EqualConstraintMergeResult: + final_entity_ids: list[int] + constraints_to_remove: list[Constraint] + + +class EqualConstraintCommand: + @staticmethod + def find_and_merge_constraints( + sketch: Sketch, + selected_entity_ids: list[int], + ) -> EqualConstraintMergeResult | None: + selected_ids = set(selected_entity_ids) + existing_constraints_to_merge: list[Constraint] = [] + final_ids = set(selected_ids) + + for constr in sketch.constraints: + if isinstance( + constr, EqualLengthConstraint + ) and not selected_ids.isdisjoint(constr.entity_ids): + existing_constraints_to_merge.append(constr) + final_ids.update(constr.entity_ids) + + return EqualConstraintMergeResult( + final_entity_ids=list(final_ids), + constraints_to_remove=existing_constraints_to_merge, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/fill.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/fill.py new file mode 100644 index 000000000..7e6d02b97 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/fill.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import logging +import uuid +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.core.color import ColorRGBA +from rayforge.image.structures import FillStyle + +from ..entities.text_box import TextBoxEntity +from ..sketch import DEFAULT_FILL_COLOR, Fill +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class AddFillCommand(SketchChangeCommand): + """Command to add a Fill to a sketch.""" + + def __init__( + self, + sketch: Sketch, + boundary: list[tuple[int, bool]], + style: FillStyle = FillStyle.SOLID, + color: ColorRGBA = DEFAULT_FILL_COLOR, + gradient_stops: list[tuple[float, ColorRGBA]] | None = None, + gradient_angle: float = 0.0, + name: str = _("Add Fill"), + ): + super().__init__(sketch, name) + self.fill: Fill | None = None + self._boundary = boundary + self._style = style + self._color = color + self._gradient_stops = gradient_stops + self._gradient_angle = gradient_angle + + def _do_execute(self) -> None: + if self.fill is None: + self.fill = Fill( + uid=str(uuid.uuid4()), + boundary=self._boundary, + style=self._style, + color=self._color, + gradient_stops=self._gradient_stops, + gradient_angle=self._gradient_angle, + ) + self.sketch.fills.append(self.fill) + + def _do_undo(self) -> None: + if self.fill and self.fill in self.sketch.fills: + self.sketch.fills.remove(self.fill) + + +class RemoveFillCommand(SketchChangeCommand): + """Command to remove a Fill from a sketch.""" + + def __init__( + self, + sketch: Sketch, + fill: Fill, + name: str = _("Remove Fill"), + ): + super().__init__(sketch, name) + self.fill = fill + + def _do_execute(self) -> None: + if self.fill in self.sketch.fills: + self.sketch.fills.remove(self.fill) + + def _do_undo(self) -> None: + self.sketch.fills.append(self.fill) + + +class SetTextFillCommand(SketchChangeCommand): + """Command to set or toggle the fill color on a TextBoxEntity.""" + + def __init__( + self, + sketch: Sketch, + entity_id: int, + fill_color: ColorRGBA | None, + name: str = _("Set Text Fill"), + ): + super().__init__(sketch, name) + self.entity_id = entity_id + self.fill_color = fill_color + + def _do_execute(self) -> None: + entity = self.sketch.registry.get_entity(self.entity_id) + if isinstance(entity, TextBoxEntity): + entity.fill_color = self.fill_color + + def _do_undo(self) -> None: + pass diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/fillet.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/fillet.py new file mode 100644 index 000000000..34cfbcc76 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/fillet.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from ..constraints import ( + CollinearConstraint, + EqualDistanceConstraint, + TangentConstraint, +) +from ..entities import Arc, Line, Point +from ..types import EntityID +from .base import SketchChangeCommand +from .items import AddItemsCommand, RemoveItemsCommand + +if TYPE_CHECKING: + from ..registry import EntityRegistry + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class FilletCommand(SketchChangeCommand): + """Command to add a fillet (rounded corner) between two lines.""" + + def __init__( + self, + sketch: Sketch, + corner_pid: EntityID, + line1_id: EntityID, + line2_id: EntityID, + radius: float, + ): + super().__init__(sketch, _("Add Fillet")) + self.corner_pid = corner_pid + self.line1_id = line1_id + self.line2_id = line2_id + self.radius = radius + + # State for undo/redo + self.add_cmd: AddItemsCommand | None = None + self.remove_cmd: RemoveItemsCommand | None = None + self._prepared = False + + @staticmethod + def calculate_geometry( + reg: EntityRegistry, + corner_pid: EntityID, + line1_id: EntityID, + line2_id: EntityID, + radius: float, + ) -> dict[str, Any] | None: + """ + Calculates the points, entities, and constraints for a fillet. + This is a pure function for testability and reusability. + """ + line1 = reg.get_entity(line1_id) + line2 = reg.get_entity(line2_id) + if not isinstance(line1, Line) or not isinstance(line2, Line): + return None + + try: + corner_point = reg.get_point(corner_pid) + other1_pid = ( + line1.p2_idx if line1.p1_idx == corner_pid else line1.p1_idx + ) + other2_pid = ( + line2.p2_idx if line2.p1_idx == corner_pid else line2.p1_idx + ) + other1_pt = reg.get_point(other1_pid) + other2_pt = reg.get_point(other2_pid) + except IndexError: + return None + + v1 = (other1_pt.x - corner_point.x, other1_pt.y - corner_point.y) + v2 = (other2_pt.x - corner_point.x, other2_pt.y - corner_point.y) + len1, len2 = math.hypot(v1[0], v1[1]), math.hypot(v2[0], v2[1]) + + if len1 < 1e-6 or len2 < 1e-6: + return None + + u1 = (v1[0] / len1, v1[1] / len1) + u2 = (v2[0] / len2, v2[1] / len2) + dot = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + angle = math.acos(dot) + + if angle < 1e-3 or abs(angle - math.pi) < 1e-3: + return None + + tan_half = math.tan(angle / 2.0) + if abs(tan_half) < 1e-9: + return None + dist_to_tangent = radius / tan_half + + if dist_to_tangent > len1 or dist_to_tangent > len2: + return None # Fillet too large for lines + + p_tan1_pos = ( + corner_point.x + dist_to_tangent * u1[0], + corner_point.y + dist_to_tangent * u1[1], + ) + p_tan2_pos = ( + corner_point.x + dist_to_tangent * u2[0], + corner_point.y + dist_to_tangent * u2[1], + ) + + bisector_len = math.hypot(u1[0] + u2[0], u1[1] + u2[1]) + if bisector_len < 1e-9: + return None + + u_bisector = ( + (u1[0] + u2[0]) / bisector_len, + (u1[1] + u2[1]) / bisector_len, + ) + dist_to_center = radius / math.sin(angle / 2.0) + p_center_pos = ( + corner_point.x + dist_to_center * u_bisector[0], + corner_point.y + dist_to_center * u_bisector[1], + ) + + cross = u1[0] * u2[1] - u1[1] * u2[0] + is_cw = cross > 0 + + p_tan1 = Point(-1, p_tan1_pos[0], p_tan1_pos[1]) + p_tan2 = Point(-2, p_tan2_pos[0], p_tan2_pos[1]) + p_center = Point(-3, p_center_pos[0], p_center_pos[1]) + + new_line1 = Line(-4, other1_pid, p_tan1.id) + new_line2 = Line(-5, other2_pid, p_tan2.id) + fillet_arc = Arc( + -6, p_tan1.id, p_tan2.id, p_center.id, clockwise=is_cw + ) + + added_constraints = [ + TangentConstraint(new_line1.id, fillet_arc.id), + TangentConstraint(new_line2.id, fillet_arc.id), + CollinearConstraint(other1_pid, corner_pid, p_tan1.id), + CollinearConstraint(other2_pid, corner_pid, p_tan2.id), + EqualDistanceConstraint( + corner_pid, p_tan1.id, corner_pid, p_tan2.id + ), + ] + + return { + "points": [p_tan1, p_tan2, p_center], + "entities": [new_line1, new_line2, fillet_arc], + "constraints": added_constraints, + "removed_entities": [line1, line2], + } + + def _prepare(self) -> bool: + """Prepares internal commands on first execution.""" + if self._prepared: + return True + + result = self.calculate_geometry( + self.sketch.registry, + self.corner_pid, + self.line1_id, + self.line2_id, + self.radius, + ) + + if result is None: + return False + + self.remove_cmd = RemoveItemsCommand( + self.sketch, "", entities=result["removed_entities"] + ) + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=result["points"], + entities=result["entities"], + constraints=result["constraints"], + ) + self._prepared = True + return True + + def _do_execute(self) -> None: + if not self._prepare(): + return + + if self.remove_cmd: + self.remove_cmd._do_execute() + if self.add_cmd: + self.add_cmd._do_execute() + + def _do_undo(self) -> None: + if not self.add_cmd or not self.remove_cmd: + return + + self.add_cmd._do_undo() + self.remove_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/grid.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/grid.py new file mode 100644 index 000000000..d2b7663e8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/grid.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from ..constraints import HorizontalConstraint, VerticalConstraint +from ..entities import Line, Point +from .base import SketchChangeCommand +from .items import AddItemsCommand + +if TYPE_CHECKING: + from ..sketch import Sketch + + +class GridCommand(SketchChangeCommand): + """A command to create a homogeneous grid of construction lines.""" + + def __init__( + self, + sketch: Sketch, + rows: int, + cols: int, + origin: tuple[float, float] = (0, 0), + cell_width: float = 10.0, + cell_height: float = 10.0, + construction: bool = True, + ): + super().__init__(sketch, _("Add Grid")) + self.rows = rows + self.cols = cols + self.origin = origin + self.cell_width = cell_width + self.cell_height = cell_height + self.construction = construction + self.add_cmd: AddItemsCommand | None = None + + @staticmethod + def calculate_geometry( + rows: int, + cols: int, + origin: tuple[float, float], + cell_width: float, + cell_height: float, + construction: bool = True, + ) -> dict[str, Any] | None: + """ + Calculates points, entities, and constraints for a grid. + + Args: + rows: Number of cell rows (horizontal bands) + cols: Number of cell columns (vertical bands) + origin: Bottom-left corner of the grid + cell_width: Width of each cell + cell_height: Height of each cell + construction: Whether to create as construction geometry + + Returns: + Dict with 'points', 'entities', and 'constraints' keys, or None + if invalid. + """ + if rows < 1 or cols < 1: + return None + if cell_width <= 0 or cell_height <= 0: + return None + + rows = rows + 1 + cols = cols + 1 + ox, oy = origin + temp_id_counter = -1 + + def next_temp_id(): + nonlocal temp_id_counter + temp_id_counter -= 1 + return temp_id_counter + + points: list[Point] = [] + point_ids: list[int] = [] + + for row in range(rows): + for col in range(cols): + pid = next_temp_id() + x = ox + col * cell_width + y = oy + row * cell_height + points.append(Point(pid, x, y)) + point_ids.append(pid) + + def get_point_id(row: int, col: int) -> int: + return point_ids[row * cols + col] + + entities: list[Line] = [] + constraints: list[Any] = [] + + for row in range(rows): + for col in range(cols - 1): + p1_id = get_point_id(row, col) + p2_id = get_point_id(row, col + 1) + entities.append( + Line( + next_temp_id(), p1_id, p2_id, construction=construction + ) + ) + constraints.append(HorizontalConstraint(p1_id, p2_id)) + + for col in range(cols): + for row in range(rows - 1): + p1_id = get_point_id(row, col) + p2_id = get_point_id(row + 1, col) + entities.append( + Line( + next_temp_id(), p1_id, p2_id, construction=construction + ) + ) + constraints.append(VerticalConstraint(p1_id, p2_id)) + + return { + "points": points, + "entities": entities, + "constraints": constraints, + } + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + result = self.calculate_geometry( + self.rows, + self.cols, + self.origin, + self.cell_width, + self.cell_height, + self.construction, + ) + if not result: + return + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=result["points"], + entities=result["entities"], + constraints=result["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/core/commands/items.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/items.py new file mode 100644 index 000000000..b7fd5563c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/items.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from ..entities import Arc, Ellipse, TextBoxEntity +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..entities import Entity, Point + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class AddItemsCommand(SketchChangeCommand): + """Command to add points, entities, and constraints to a sketch.""" + + def __init__( + self, + sketch: Sketch, + name: str, + points: Sequence[Point] | None = None, + entities: Sequence[Entity] | None = None, + constraints: Sequence[Constraint] | None = None, + ): + super().__init__(sketch, name) + self.points = list(points) if points else [] + self.entities = list(entities) if entities else [] + self.constraints = list(constraints) if constraints else [] + + def _do_execute(self) -> None: + registry = self.sketch.registry + new_points = [] + id_map: dict[int, int] = {} # Map old temp IDs to new final IDs + + for p in self.points: + old_id = p.id + # Assign a real ID if it's a temp ID (negative or >= counter) + if p.id < 0 or p.id >= registry._id_counter: + p.id = registry._id_counter + registry._id_counter += 1 + if old_id != p.id: + id_map[old_id] = p.id + new_points.append(p) + registry.points.extend(new_points) + + new_entities = [] + for e in self.entities: + old_id = e.id + if e.id < 0 or e.id >= registry._id_counter: + e.id = registry._id_counter + registry._id_counter += 1 + if old_id != e.id: + id_map[old_id] = e.id + new_entities.append(e) + + # Update point references within the entity + for e in new_entities: + for attr, value in vars(e).items(): + if isinstance(value, int) and value in id_map: + setattr(e, attr, id_map[value]) + # Handle lists of IDs, like in TextBoxEntity + elif isinstance(value, list) and attr.endswith("_ids"): + new_ids = [id_map.get(old, old) for old in value] + setattr(e, attr, new_ids) + registry.entities.extend(new_entities) + + # Update point and entity references within constraints + for c in self.constraints: + for attr, value in vars(c).items(): + if isinstance(value, int) and value in id_map: + setattr(c, attr, id_map[value]) + elif isinstance(value, list): + # Handle lists of IDs, like in EqualLengthConstraint + new_ids = [id_map.get(old, old) for old in value] + setattr(c, attr, new_ids) + + # Rebuild entity map after adding + registry._entity_map = {e.id: e for e in registry.entities} + self.sketch.constraints.extend(self.constraints) + + def _do_undo(self) -> None: + registry = self.sketch.registry + point_ids = {p.id for p in self.points} + entity_ids = {e.id for e in self.entities} + + registry.points = [p for p in registry.points if p.id not in point_ids] + registry.entities = [ + e for e in registry.entities if e.id not in entity_ids + ] + registry._entity_map = {e.id: e for e in registry.entities} + for c in self.constraints: + if c in self.sketch.constraints: + self.sketch.constraints.remove(c) + + +class RemoveItemsCommand(SketchChangeCommand): + """Command to remove points, entities, and constraints from a sketch.""" + + def __init__( + self, + sketch: Sketch, + name: str, + points: list[Point] | None = None, + entities: Sequence[Entity] | None = None, + constraints: list[Constraint] | None = None, + ): + super().__init__(sketch, name) + self.points = points or [] + self.entities = list(entities) if entities else [] + self.constraints = constraints or [] + + @staticmethod + def calculate_dependencies( + sketch: Sketch, selection + ) -> tuple[list[Point], list[Entity], list[Constraint]]: + """ + Calculates the full set of items to be deleted based on the current + selection, including dependent items. + """ + to_delete_constraints: list[Constraint] = [] + to_delete_entity_ids = set(selection.entity_ids) + to_delete_point_ids = set(selection.point_ids) + + # 1. Selected Constraints + if ( + selection.constraint_idx is not None + and sketch.constraints + and (0 <= selection.constraint_idx < len(sketch.constraints)) + ): + to_delete_constraints.append( + sketch.constraints[selection.constraint_idx] + ) + + # Iteratively find all dependencies until no new items are added + while True: + num_points_before = len(to_delete_point_ids) + num_entities_before = len(to_delete_entity_ids) + + # A. Unity Logic for compound objects like TextBoxEntity + all_entities = list(sketch.registry.entities) + for e in all_entities: + helper_ids = None + if isinstance(e, TextBoxEntity): + helper_ids = e.construction_line_ids + elif isinstance(e, Ellipse): + helper_ids = e.helper_line_ids + + if helper_ids: + is_part_of_delete_set = ( + e.id in to_delete_entity_ids + or not to_delete_entity_ids.isdisjoint(helper_ids) + or not to_delete_point_ids.isdisjoint( + e.get_point_ids() + ) + ) + if is_part_of_delete_set: + to_delete_entity_ids.add(e.id) + to_delete_entity_ids.update(helper_ids) + to_delete_point_ids.update(e.get_point_ids()) + + # B. Cascading: If points are deleted, find entities that use them + for e in sketch.registry.entities: + if e.id in to_delete_entity_ids: + continue + p_ids: list[int] = e.get_point_ids() + if any(pid in to_delete_point_ids for pid in p_ids): + to_delete_entity_ids.add(e.id) + + # C. Orphan Points + if to_delete_entity_ids: + used_points_by_remaining = set() + points_of_deleted_entities = set() + + for e in sketch.registry.entities: + p_ids = e.get_point_ids() + if e.id in to_delete_entity_ids: + points_of_deleted_entities.update(p_ids) + else: + used_points_by_remaining.update(p_ids) + + orphans = points_of_deleted_entities - used_points_by_remaining + to_delete_point_ids.update(orphans) + + if ( + len(to_delete_point_ids) == num_points_before + and len(to_delete_entity_ids) == num_entities_before + ): + break # Stable state reached + + # 2.5. Cleanup Implicit Constraints for Deleted Entities (Arc geometry) + entity_map = {e.id: e for e in sketch.registry.entities} + for eid in to_delete_entity_ids: + e = entity_map.get(eid) + if isinstance(e, Arc): + c, s, end = e.center_idx, e.start_idx, e.end_idx + for constr in sketch.constraints: + from ..constraints import EqualDistanceConstraint + + if isinstance(constr, EqualDistanceConstraint): + set1 = {constr.p1, constr.p2} + set2 = {constr.p3, constr.p4} + target1, target2 = {c, s}, {c, end} + if ( + (set1 == target1 and set2 == target2) + or (set1 == target2 and set2 == target1) + ) and constr not in to_delete_constraints: + to_delete_constraints.append(constr) + + # 4. Cleanup Constraints (Dependencies) + for constr in sketch.constraints: + if constr in to_delete_constraints: + continue + if ( + constr.depends_on_points(to_delete_point_ids) + or constr.depends_on_entities(to_delete_entity_ids) + ) and constr not in to_delete_constraints: + to_delete_constraints.append(constr) + # 5. Get actual objects from IDs + final_points = [ + p + for p in sketch.registry.points + if p.id in to_delete_point_ids and not p.fixed + ] + final_entities = [ + e for e in sketch.registry.entities if e.id in to_delete_entity_ids + ] + + return final_points, final_entities, to_delete_constraints + + def _do_execute(self) -> None: + registry = self.sketch.registry + point_ids = {p.id for p in self.points} + entity_ids = {e.id for e in self.entities} + + registry.points = [p for p in registry.points if p.id not in point_ids] + registry.entities = [ + e for e in registry.entities if e.id not in entity_ids + ] + registry._entity_map = {e.id: e for e in registry.entities} + for c in self.constraints: + if c in self.sketch.constraints: + self.sketch.constraints.remove(c) + + def _do_undo(self) -> None: + registry = self.sketch.registry + registry.points.extend(self.points) + registry.entities.extend(self.entities) + registry._entity_map = {e.id: e for e in registry.entities} + self.sketch.constraints.extend(self.constraints) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/line.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/line.py new file mode 100644 index 000000000..4a477be71 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/line.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import DistanceConstraint +from ..entities import 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 + + +class LinePreviewState(PreviewState): + """Preview state for line tool's 2-click workflow.""" + + def __init__( + self, + start_id: EntityID, + start_temp: bool, + end_id: EntityID, + entity_id: EntityID, + ): + self.start_id = start_id + self.start_temp = start_temp + self.end_id = end_id + self.entity_id = entity_id + self.locked_length: float | None = None + + def get_preview_point_ids(self) -> set[EntityID]: + """ + Returns IDs of temporary preview points that shouldn't be snapped to. + + Excludes the start point since it may be permanent. + """ + return {self.end_id} + + def set_length(self, registry: EntityRegistry, length: float) -> None: + """ + Sets the line length from numeric input. + + Args: + registry: The entity registry to modify. + length: The length to apply. + """ + self.locked_length = length + + try: + start_p = registry.get_point(self.start_id) + end_p = registry.get_point(self.end_id) + except IndexError: + return + + dx = end_p.x - start_p.x + dy = end_p.y - start_p.y + current_length = math.hypot(dx, dy) + + if current_length < 1e-9: + end_p.x = start_p.x + length + return + + scale = length / current_length + end_p.x = start_p.x + dx * scale + end_p.y = start_p.y + dy * scale + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """ + Returns the line length dimension for preview. + + Args: + registry: The entity registry to query for point positions. + + Returns: + List containing a single DimensionData for the line length. + """ + try: + p1 = registry.get_point(self.start_id) + p2 = registry.get_point(self.end_id) + except IndexError: + return [] + length = math.hypot(p2.x - p1.x, p2.y - p1.y) + mid_x = (p1.x + p2.x) / 2 + mid_y = (p1.y + p2.y) / 2 + return [ + DimensionData( + label=DimensionData.format_length(length), + position=(mid_x, mid_y), + ) + ] + + +class LineCommand(SketchChangeCommand): + """A command to create a line between two points.""" + + def __init__( + self, + sketch: Sketch, + start_id: EntityID, + end_pos: GeoPoint, + end_pid: EntityID | None = None, + is_start_temp: bool = False, + fixed_length: float | None = None, + ): + super().__init__(sketch, _("Add Line")) + self.start_id = start_id + self.end_pos = end_pos + self.end_pid = end_pid + self.is_start_temp = is_start_temp + self.fixed_length = fixed_length + self.add_cmd: AddItemsCommand | None = None + self._committed_end_id: EntityID | None = None + + @property + def committed_end_id(self) -> EntityID | None: + """ + The final end point ID after execute(), or None if not applicable. + """ + return self._committed_end_id + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + **kwargs, + ) -> LinePreviewState: + """ + Creates initial preview state with start point, end point, and line. + + Args: + registry: The entity registry to modify. + x, y: The initial coordinates. + snapped_pid: An existing point ID to snap to, or None. + + Returns: + LinePreviewState for use with update_preview and cleanup_preview. + """ + if snapped_pid is not None: + start_id = snapped_pid + start_temp = False + else: + start_id = registry.add_point(x, y) + start_temp = True + + end_id = registry.add_point(x, y) + entity_id = registry.add_line(start_id, end_id) + + return LinePreviewState( + start_id=start_id, + start_temp=start_temp, + end_id=end_id, + entity_id=entity_id, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """ + Updates the preview end point position. + + Args: + registry: The entity registry. + preview_state: The preview state from start_preview. + x, y: The new cursor coordinates. + + Raises: + TypeError: If preview_state is not a LinePreviewState. + """ + if not isinstance(preview_state, LinePreviewState): + raise TypeError("Expected LinePreviewState") + + if preview_state.locked_length is not None: + return + + try: + end_p = registry.get_point(preview_state.end_id) + except IndexError: + return + end_p.x = x + end_p.y = y + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes preview entities from the registry. + + Note: This does NOT remove the start point if start_temp=True. + The tool is responsible for removing it if the user cancels. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + + Raises: + TypeError: If preview_state is not a LinePreviewState. + """ + if not isinstance(preview_state, LinePreviewState): + raise TypeError("Expected LinePreviewState") + + if preview_state.entity_id is not None: + registry.entities = [ + e for e in registry.entities if e.id != preview_state.entity_id + ] + registry._entity_map = { + k: v + for k, v in registry._entity_map.items() + if k != preview_state.entity_id + } + + if preview_state.end_id is not None: + registry.points = [ + p for p in registry.points if p.id != preview_state.end_id + ] + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + registry = self.sketch.registry + + try: + registry.get_point(self.start_id) + except IndexError: + return + + final_x, final_y = self.end_pos + if self.end_pid is not None: + try: + end_p = registry.get_point(self.end_pid) + final_x, final_y = end_p.x, end_p.y + except IndexError: + pass + + new_point = None + end_pid = self.end_pid + + if end_pid is None: + temp_id = registry._id_counter + end_pid = temp_id + new_point = Point(temp_id, final_x, final_y) + + if end_pid == self.start_id: + if self.is_start_temp: + self.sketch.remove_point_if_unused(self.start_id) + return + + temp_line_id = registry._id_counter + (1 if new_point else 0) + new_line = Line(temp_line_id, self.start_id, end_pid) + + points_to_add: list[Point] = [new_point] if new_point else [] + + if self.is_start_temp: + try: + p = registry.get_point(self.start_id) + registry.points.remove(p) + points_to_add.append(p) + except (IndexError, ValueError): + pass + + constraints = [] + if self.fixed_length is not None: + constraints.append( + DistanceConstraint(self.start_id, end_pid, self.fixed_length) + ) + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=[new_line], + constraints=constraints, + ) + self.add_cmd._do_execute() + self._committed_end_id = end_pid + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/live_text_edit.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/live_text_edit.py new file mode 100644 index 000000000..02c2b79a2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/live_text_edit.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import time +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.core.undo.command import Command +from rayforge.core.undo.history import COALESCE_THRESHOLD + +from ..entities.text_box import TextBoxEntity +from ..types import EntityID + +if TYPE_CHECKING: + from ..sketch import Sketch + + +class LiveTextEditCommand(Command): + def __init__( + self, + sketch: Sketch, + text_entity_id: EntityID, + ): + super().__init__(_("Edit Text")) + self.text_entity_id = text_entity_id + self._sketch = sketch + # History stores tuples of (content, cursor_pos, timestamp) + self.history: list[tuple[str, int, float]] = [] + self.current_index = -1 + # Maintained for attribute compatibility with tests + self.cursor_pos = 0 + self._last_capture_time: float = 0.0 + + def execute(self) -> None: + entity = self._sketch.registry.get_entity(self.text_entity_id) + if not isinstance(entity, TextBoxEntity): + return + + # Initialize history with the current (initial) state + self.history = [(entity.content, 0, time.time())] + self.current_index = 0 + self._last_capture_time = time.time() + + def undo(self) -> None: + if self.current_index > 0: + self.current_index -= 1 + self._restore_state(self.current_index) + # Force a break in coalescing so the next type action creates a + # new entry rather than overwriting the state we just undid to. + self._last_capture_time = 0.0 + + def redo(self) -> None: + if self.current_index < len(self.history) - 1: + self.current_index += 1 + self._restore_state(self.current_index) + # Force a break in coalescing on redo as well + self._last_capture_time = 0.0 + + def _restore_state(self, index: int) -> None: + if 0 <= index < len(self.history): + content, _, _ = self.history[index] + entity = self._sketch.registry.get_entity(self.text_entity_id) + if isinstance(entity, TextBoxEntity): + entity.content = content + + def capture_state(self, content: str, cursor_pos: int) -> None: + now = time.time() + + # 1. Handle Branching (The Fix for Duplicates) + # If we have undid some actions and are now typing, we must discard + # the old "future". + if self.current_index < len(self.history) - 1: + self.history = self.history[: self.current_index + 1] + + time_delta = now - self._last_capture_time + + # 2. Coalescing Logic + # If typing is fast enough, update the current tip of history in-place. + # This includes index 0 if the user starts typing immediately after + # execute. + if time_delta < COALESCE_THRESHOLD and self.history: + self.history[self.current_index] = (content, cursor_pos, now) + else: + self.history.append((content, cursor_pos, now)) + self.current_index = len(self.history) - 1 + + self._last_capture_time = now + + def get_current_content(self) -> str: + if 0 <= self.current_index < len(self.history): + return self.history[self.current_index][0] + return "" + + def get_current_cursor_pos(self) -> int: + if 0 <= self.current_index < len(self.history): + return self.history[self.current_index][1] + return 0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/point.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/point.py new file mode 100644 index 000000000..7e51dd9b8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/point.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point as GeoPoint + +from ..entities import Arc, Bezier, Circle, Line, Point +from ..types import EntityID +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from rayforge.core.undo.command import Command + + from ..constraints import Constraint + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + + +class MovePointCommand(SketchChangeCommand): + """An undoable command for moving a sketch point, with coalescing.""" + + def __init__( + self, + sketch: Sketch, + point_id: EntityID, + start_pos: GeoPoint, + end_pos: GeoPoint, + # snapshot is: (points_dict, entities_dict) + snapshot: tuple[dict[EntityID, GeoPoint], dict[EntityID, Any]] + | None = None, + snap_constraints: list[Constraint] | None = None, + ): + super().__init__(sketch, _("Move Point")) + self.point_id = point_id + self.start_pos = start_pos + self.end_pos = end_pos + self._point_ref: Point | None = None + self._snap_constraints: list[Constraint] = snap_constraints or [] + self._created_constraints: list[Constraint] = [] + + # If we are provided a snapshot (from the tool), use it. + # This is critical because the drag operation changes coordinates + # *before* the command is executed. + if snapshot: + self._snapshot = snapshot + + def _get_point(self) -> Point | None: + """Gets a live reference to the point object.""" + # Check cache first + if self._point_ref and self._point_ref.id == self.point_id: + return self._point_ref + # Find in registry if not cached or mismatched + try: + self._point_ref = self.sketch.registry.get_point(self.point_id) + return self._point_ref + except IndexError: + return None + + def _do_execute(self) -> None: + # Just ensure the specific point ends up where intended. + # The base class's capture_snapshot logic handles the rest if needed. + p = self._get_point() + if p: + p.x, p.y = self.end_pos + + for constraint in self._snap_constraints: + self.sketch.constraints.append(constraint) + self._created_constraints.append(constraint) + + def _do_undo(self) -> None: + # Revert the specific point (though restore_snapshot does this for + # all). + p = self._get_point() + if p: + p.x, p.y = self.start_pos + + for constraint in self._created_constraints: + if constraint in self.sketch.constraints: + self.sketch.constraints.remove(constraint) + self._created_constraints.clear() + + def can_coalesce_with(self, next_command: Command) -> bool: + return ( + isinstance(next_command, MovePointCommand) + and self.point_id == next_command.point_id + ) + + def coalesce_with(self, next_command: Command) -> bool: + if not self.can_coalesce_with(next_command): + return False + + # Update our end position to the newest position + self.end_pos = next_command.end_pos # type: ignore + self.timestamp = next_command.timestamp + # We do NOT update self._snapshot; we keep the state from before + # the FIRST move. + return True + + +class MoveControlPointCommand(SketchChangeCommand): + """An undoable command for moving a control point offset.""" + + def __init__( + self, + sketch: Sketch, + bezier_id: EntityID, + cp_index: int, + start_offset: GeoPoint | None, + end_offset: GeoPoint | None, + ): + label = _("Move Control Point") + super().__init__(sketch, label) + self.bezier_id = bezier_id + self.cp_index = cp_index + self.start_offset = start_offset + self.end_offset = end_offset + + def _get_bezier(self) -> Bezier | None: + entity = self.sketch.registry.get_entity(self.bezier_id) + if isinstance(entity, Bezier): + return entity + return None + + def _do_execute(self) -> None: + bezier = self._get_bezier() + if bezier: + if self.cp_index == 1: + bezier.cp1 = self.end_offset + else: + bezier.cp2 = self.end_offset + + def _do_undo(self) -> None: + bezier = self._get_bezier() + if bezier: + if self.cp_index == 1: + bezier.cp1 = self.start_offset + else: + bezier.cp2 = self.start_offset + + +class UnstickJunctionCommand(SketchChangeCommand): + """Command to separate entities at a shared point.""" + + def __init__(self, sketch: Sketch, junction_pid: EntityID): + super().__init__(sketch, _("Unstick Junction")) + self.junction_pid = junction_pid + self.new_point: Point | None = None + # Stores {entity_id: (attribute_name, old_pid)} + self.modified_map: dict[EntityID, tuple[str, EntityID]] = {} + + def _do_execute(self) -> None: + try: + junction_pt = self.sketch.registry.get_point(self.junction_pid) + except IndexError: + return + + entities_at_junction = [] + for e in self.sketch.registry.entities: + if isinstance(e, Line): + if self.junction_pid in [e.p1_idx, e.p2_idx]: + entities_at_junction.append(e) + elif isinstance(e, Arc): + if self.junction_pid in [ + e.start_idx, + e.end_idx, + e.center_idx, + ]: + entities_at_junction.append(e) + elif isinstance(e, Circle) and self.junction_pid in [ + e.center_idx, + e.radius_pt_idx, + ]: + entities_at_junction.append(e) + + if len(entities_at_junction) < 2: + return + + # Create a new point, add it to the registry, and store it + new_pid = self.sketch.add_point(junction_pt.x, junction_pt.y) + self.new_point = self.sketch.registry.get_point(new_pid) + + # Keep the first entity, modify the rest + is_first = True + for e in entities_at_junction: + if is_first: + is_first = False + continue + + if isinstance(e, Line): + if e.p1_idx == self.junction_pid: + self.modified_map[e.id] = ("p1_idx", e.p1_idx) + e.p1_idx = new_pid + if e.p2_idx == self.junction_pid: + self.modified_map[e.id] = ("p2_idx", e.p2_idx) + e.p2_idx = new_pid + elif isinstance(e, Arc): + if e.start_idx == self.junction_pid: + self.modified_map[e.id] = ("start_idx", e.start_idx) + e.start_idx = new_pid + if e.end_idx == self.junction_pid: + self.modified_map[e.id] = ("end_idx", e.end_idx) + e.end_idx = new_pid + if e.center_idx == self.junction_pid: + self.modified_map[e.id] = ("center_idx", e.center_idx) + e.center_idx = new_pid + elif isinstance(e, Circle): + if e.center_idx == self.junction_pid: + self.modified_map[e.id] = ("center_idx", e.center_idx) + e.center_idx = new_pid + if e.radius_pt_idx == self.junction_pid: + self.modified_map[e.id] = ( + "radius_pt_idx", + e.radius_pt_idx, + ) + e.radius_pt_idx = new_pid + + def _do_undo(self) -> None: + # Revert changes to entities + for eid, (attr, old_pid) in self.modified_map.items(): + e = self.sketch.registry.get_entity(eid) + if e: + setattr(e, attr, old_pid) + + # Remove the added point + if self.new_point: + registry = self.sketch.registry + registry.points = [ + p for p in registry.points if p.id != self.new_point.id + ] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/rectangle.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/rectangle.py new file mode 100644 index 000000000..b9f676417 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/rectangle.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import ( + DistanceConstraint, + HorizontalConstraint, + VerticalConstraint, +) +from ..entities import 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 + + +class RectanglePreviewState(PreviewState): + """Preview state for rectangle tool's 2-click workflow.""" + + def __init__( + self, + start_id: EntityID, + start_temp: bool, + p_end_id: EntityID, + preview_ids: dict[str, EntityID], + ): + self.start_id = start_id + self.start_temp = start_temp + self.p_end_id = p_end_id + self.preview_ids = preview_ids + self.locked_width: float | None = None + self.locked_height: float | None = None + + def get_preview_point_ids(self) -> set[EntityID]: + """ + Returns IDs of temporary preview points that shouldn't be snapped to. + + Excludes the start point since that may be permanent. + """ + result = {self.p_end_id} + for key in ["p2", "p4"]: + pid = self.preview_ids.get(key) + if pid is not None: + result.add(pid) + return result + + def set_dimensions( + self, + registry: EntityRegistry, + width: float | None = None, + height: float | None = None, + ) -> None: + """ + Sets the rectangle dimensions from numeric input. + + Args: + registry: The entity registry to modify. + width: The width to apply (or None to keep current). + height: The height to apply (or None to keep current). + """ + if width is not None: + self.locked_width = width + if height is not None: + self.locked_height = height + + try: + start_p = registry.get_point(self.start_id) + end_p = registry.get_point(self.p_end_id) + except IndexError: + return + + dx = end_p.x - start_p.x + dy = end_p.y - start_p.y + + sign_x = 1.0 if dx >= 0 else -1.0 + sign_y = 1.0 if dy >= 0 else -1.0 + + new_width = ( + self.locked_width if self.locked_width is not None else abs(dx) + ) + new_height = ( + self.locked_height if self.locked_height is not None else abs(dy) + ) + + end_p.x = start_p.x + sign_x * new_width + end_p.y = start_p.y + sign_y * new_height + + RectangleCommand.create_preview( + registry, + self.start_id, + self.p_end_id, + preview_ids=self.preview_ids, + ) + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """ + Returns width and height dimensions for preview. + + Args: + registry: The entity registry to query for point positions. + + Returns: + List containing DimensionData for width and height. + """ + try: + p1 = registry.get_point(self.start_id) + p2 = registry.get_point(self.p_end_id) + except IndexError: + return [] + width = abs(p2.x - p1.x) + height = abs(p2.y - p1.y) + mid_x = (p1.x + p2.x) / 2 + top_y = min(p1.y, p2.y) + right_x = max(p1.x, p2.x) + return [ + DimensionData( + label=DimensionData.format_length(width), + position=(mid_x, top_y), + ), + DimensionData( + label=DimensionData.format_length(height), + position=(right_x, (p1.y + p2.y) / 2), + ), + ] + + +class RectangleCommand(SketchChangeCommand): + """A smart command to create a fully constrained rectangle.""" + + def __init__( + self, + sketch: Sketch, + start_pid: EntityID, + end_pos: GeoPoint, + end_pid: EntityID | None = None, + is_start_temp: bool = False, + fixed_width: float | None = None, + fixed_height: float | None = None, + ): + super().__init__(sketch, _("Add Rectangle")) + self.start_pid = start_pid + self.end_pos = end_pos + self.end_pid = end_pid + self.is_start_temp = is_start_temp + self.fixed_width = fixed_width + self.fixed_height = fixed_height + self.add_cmd: AddItemsCommand | None = None + self._committed_end_id: EntityID | None = None + + @property + def committed_end_id(self) -> EntityID | None: + """ + The final end point ID after execute(), or None if not applicable. + """ + return self._committed_end_id + + @staticmethod + def calculate_geometry( + x1: float, + y1: float, + x2: float, + y2: float, + start_pid: EntityID, + end_pid: EntityID | None, + fixed_width: float | None = None, + fixed_height: float | None = None, + ) -> dict[str, Any] | None: + """Calculates the points, entities, and constraints for a rectangle.""" + if abs(x2 - x1) < 1e-6 or abs(y2 - y1) < 1e-6: + return None + + temp_id_counter = -1 + + def next_temp_id(): + nonlocal temp_id_counter + temp_id_counter -= 1 + return temp_id_counter + + p3_id = end_pid if end_pid is not None else next_temp_id() + + points = { + "p1_id": start_pid, + "p2": Point(next_temp_id(), x2, y1), + "p3": Point(p3_id, x2, y2), + "p4": Point(next_temp_id(), x1, y2), + } + + entities = [ + Line(next_temp_id(), points["p1_id"], points["p2"].id), + Line(next_temp_id(), points["p2"].id, points["p3"].id), + Line(next_temp_id(), points["p3"].id, points["p4"].id), + Line(next_temp_id(), points["p4"].id, points["p1_id"]), + ] + + constraints: list[Any] = [ + HorizontalConstraint(points["p1_id"], points["p2"].id), + VerticalConstraint(points["p2"].id, points["p3"].id), + HorizontalConstraint(points["p4"].id, points["p3"].id), + VerticalConstraint(points["p1_id"], points["p4"].id), + ] + + top_edge_y = min(y1, y2) + right_edge_x = max(x1, x2) + + if top_edge_y == y1: + top_edge_p1 = points["p1_id"] + top_edge_p2 = points["p2"].id + else: + top_edge_p1 = points["p4"].id + top_edge_p2 = points["p3"].id + + if right_edge_x == x2: + right_edge_p1 = points["p2"].id + right_edge_p2 = points["p3"].id + else: + right_edge_p1 = points["p1_id"] + right_edge_p2 = points["p4"].id + + if fixed_width is not None: + constraints.append( + DistanceConstraint(top_edge_p1, top_edge_p2, fixed_width) + ) + + if fixed_height is not None: + constraints.append( + DistanceConstraint(right_edge_p1, right_edge_p2, fixed_height) + ) + + return { + "points": points, + "entities": entities, + "constraints": constraints, + } + + @staticmethod + def create_preview( + registry: EntityRegistry, + start_pid: EntityID, + end_pid: EntityID, + preview_ids: dict[str, EntityID] | None = None, + ) -> dict[str, EntityID] | None: + """ + Creates or updates preview geometry in the registry. + + Args: + registry: The entity registry to modify. + start_pid: The ID of the start corner point. + end_pid: The ID of the end corner point (preview corner). + preview_ids: Existing preview IDs to update, or None to create new. + + Returns: + Dict of preview IDs, or None if geometry is invalid. + """ + try: + start_p = registry.get_point(start_pid) + end_p = registry.get_point(end_pid) + except IndexError: + return None + + coords = { + "p2": (end_p.x, start_p.y), + "p4": (start_p.x, end_p.y), + } + + if preview_ids is None: + # Create new preview geometry + preview_ids = {} + for name, (px, py) in coords.items(): + preview_ids[name] = registry.add_point(px, py) + + # Create lines + preview_ids["line1"] = registry.add_line( + start_pid, preview_ids["p2"] + ) + preview_ids["line2"] = registry.add_line( + preview_ids["p2"], end_pid + ) + preview_ids["line3"] = registry.add_line( + end_pid, preview_ids["p4"] + ) + preview_ids["line4"] = registry.add_line( + preview_ids["p4"], start_pid + ) + else: + # Update existing preview geometry + for name, (px, py) in coords.items(): + p = registry.get_point(preview_ids[name]) + p.x, p.y = px, py + + return preview_ids + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + **kwargs, + ) -> RectanglePreviewState: + """ + Creates initial preview state with start and end points. + + Args: + registry: The entity registry to modify. + x, y: The initial coordinates. + snapped_pid: An existing point ID to snap to, or None. + + Returns: + RectanglePreviewState for use with update_preview and + cleanup_preview. + """ + if snapped_pid is not None: + start_id = snapped_pid + start_temp = False + else: + start_id = registry.add_point(x, y) + start_temp = True + + p_end_id = registry.add_point(x, y) + + preview_ids = RectangleCommand.create_preview( + registry, start_id, p_end_id + ) + assert preview_ids is not None + + return RectanglePreviewState( + start_id=start_id, + start_temp=start_temp, + p_end_id=p_end_id, + preview_ids=preview_ids, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """ + Updates the end point position and refreshes preview geometry. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + x, y: The new end point coordinates. + + Raises: + TypeError: If preview_state is not a RectanglePreviewState. + """ + if not isinstance(preview_state, RectanglePreviewState): + raise TypeError("Expected RectanglePreviewState") + try: + p_end = registry.get_point(preview_state.p_end_id) + p_start = registry.get_point(preview_state.start_id) + except IndexError: + return + + if ( + preview_state.locked_width is not None + or preview_state.locked_height is not None + ): + dx = p_end.x - p_start.x + dy = p_end.y - p_start.y + sign_x = 1.0 if dx >= 0 else -1.0 + sign_y = 1.0 if dy >= 0 else -1.0 + + new_x = ( + p_start.x + sign_x * preview_state.locked_width + if preview_state.locked_width is not None + else x + ) + new_y = ( + p_start.y + sign_y * preview_state.locked_height + if preview_state.locked_height is not None + else y + ) + p_end.x = new_x + p_end.y = new_y + else: + p_end.x = x + p_end.y = y + + RectangleCommand.create_preview( + registry, + preview_state.start_id, + preview_state.p_end_id, + preview_ids=preview_state.preview_ids, + ) + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes all preview entities and points from the registry. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + + Raises: + TypeError: If preview_state is not a RectanglePreviewState. + """ + if not isinstance(preview_state, RectanglePreviewState): + raise TypeError("Expected RectanglePreviewState") + preview_ids = preview_state.preview_ids + p_end_id = preview_state.p_end_id + + # Collect all point IDs to remove + point_ids = set(preview_ids.values()) + point_ids.add(p_end_id) + + # Find and remove entities that use these points + entity_ids_to_remove = { + e.id + for e in registry.entities + if any(pid in point_ids for pid in e.get_point_ids()) + } + registry.remove_entities_by_id(list(entity_ids_to_remove)) + + # Remove points + registry.points = [p for p in registry.points if p.id not in point_ids] + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + reg = self.sketch.registry + try: + start_p = reg.get_point(self.start_pid) + except IndexError: + return + + final_mx, final_my = self.end_pos + if self.end_pid is not None: + try: + end_p = reg.get_point(self.end_pid) + final_mx, final_my = end_p.x, end_p.y + except IndexError: + pass # Use mouse coords if pid is invalid + + result = self.calculate_geometry( + start_p.x, + start_p.y, + final_mx, + final_my, + self.start_pid, + self.end_pid, + fixed_width=self.fixed_width, + fixed_height=self.fixed_height, + ) + if not result: + if self.is_start_temp: + self.sketch.remove_point_if_unused(self.start_pid) + return + + points_dict = result["points"] + points_to_add = [] + # These points are always new + points_to_add.extend([points_dict["p2"], points_dict["p4"]]) + + # Add p3 only if it wasn't an existing snapped point + if self.end_pid is None: + points_to_add.append(points_dict["p3"]) + + # If the start point was temporary, remove it from the registry + # and add its object to the command to be re-added properly. + if self.is_start_temp: + reg.points.remove(start_p) + points_to_add.append(start_p) + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=result["entities"], + constraints=result["constraints"], + ) + self.add_cmd._do_execute() + self._committed_end_id = self.end_pid + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/rounded_rect.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/rounded_rect.py new file mode 100644 index 000000000..6be7a2906 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/rounded_rect.py @@ -0,0 +1,640 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import ( + DistanceConstraint, + EqualDistanceConstraint, + EqualLengthConstraint, + HorizontalConstraint, + RadiusConstraint, + TangentConstraint, + VerticalConstraint, +) +from ..entities import Arc, 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 + + +class RoundedRectPreviewState(PreviewState): + """Preview state for rounded rectangle tool's 2-click workflow.""" + + def __init__( + self, + start_id: EntityID, + start_temp: bool, + p_end_id: EntityID, + preview_ids: dict[str, EntityID], + radius: float, + ): + self.start_id = start_id + self.start_temp = start_temp + self.p_end_id = p_end_id + self.preview_ids = preview_ids + self.radius = radius + self.locked_width: float | None = None + self.locked_height: float | None = None + self.locked_radius: float | None = None + + def get_preview_point_ids(self) -> set[EntityID]: + """ + Returns IDs of temporary preview points that shouldn't be snapped to. + + Excludes the start point since that may be permanent. + """ + result = {self.p_end_id} + for key in ["t2", "t4", "t6", "t8", "c1", "c2", "c3", "c4"]: + pid = self.preview_ids.get(key) + if pid is not None: + result.add(pid) + return result + + def set_dimensions( + self, + registry: EntityRegistry, + width: float | None = None, + height: float | None = None, + radius: float | None = None, + ) -> None: + """ + Sets the rounded rectangle dimensions from numeric input. + + Args: + registry: The entity registry to modify. + width: The width to apply (or None to keep current). + height: The height to apply (or None to keep current). + radius: The corner radius to apply (or None to keep current). + """ + if width is not None: + self.locked_width = width + if height is not None: + self.locked_height = height + if radius is not None: + self.locked_radius = radius + self.radius = radius + + try: + start_p = registry.get_point(self.start_id) + end_p = registry.get_point(self.p_end_id) + except IndexError: + return + + dx = end_p.x - start_p.x + dy = end_p.y - start_p.y + + sign_x = 1.0 if dx >= 0 else -1.0 + sign_y = 1.0 if dy >= 0 else -1.0 + + new_width = ( + self.locked_width if self.locked_width is not None else abs(dx) + ) + new_height = ( + self.locked_height if self.locked_height is not None else abs(dy) + ) + + end_p.x = start_p.x + sign_x * new_width + end_p.y = start_p.y + sign_y * new_height + + RoundedRectCommand.create_preview( + registry, + self.start_id, + self.p_end_id, + self.radius, + preview_ids=self.preview_ids, + ) + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """ + Returns width, height and radius dimensions for preview. + + Args: + registry: The entity registry to query for point positions. + + Returns: + List containing DimensionData for width, height, and corner radius. + """ + try: + p1 = registry.get_point(self.start_id) + p2 = registry.get_point(self.p_end_id) + except IndexError: + return [] + width = ( + self.locked_width + if self.locked_width is not None + else abs(p2.x - p1.x) + ) + height = ( + self.locked_height + if self.locked_height is not None + else abs(p2.y - p1.y) + ) + mid_x = (p1.x + p2.x) / 2 + top_y = min(p1.y, p2.y) + right_x = max(p1.x, p2.x) + dimensions = [ + DimensionData( + label=DimensionData.format_length(width), + position=(mid_x, top_y), + ), + DimensionData( + label=DimensionData.format_length(height), + position=(right_x, (p1.y + p2.y) / 2), + ), + ] + if self.radius > 0: + dimensions.append( + DimensionData( + label=f"R{DimensionData.format_length(self.radius)}", + position=(p1.x + self.radius / 2, p1.y), + ) + ) + return dimensions + + +class RoundedRectCommand(SketchChangeCommand): + """A smart command to create a fully constrained rounded rectangle.""" + + def __init__( + self, + sketch: Sketch, + start_pid: EntityID, + end_pos: GeoPoint, + radius: float, + is_start_temp: bool = False, + fixed_width: float | None = None, + fixed_height: float | None = None, + fixed_radius: float | None = None, + ): + super().__init__(sketch, _("Add Rounded Rectangle")) + self.start_pid = start_pid + self.end_pos = end_pos + self.radius = radius + self.is_start_temp = is_start_temp + self.fixed_width = fixed_width + self.fixed_height = fixed_height + self.fixed_radius = fixed_radius + self.add_cmd: AddItemsCommand | None = None + self._committed_end_id: EntityID | None = None + + @property + def committed_end_id(self) -> EntityID | None: + """ + The final end point ID after execute(), or None if not applicable. + """ + return self._committed_end_id + + @staticmethod + def calculate_geometry( + x1: float, + y1: float, + x2: float, + y2: float, + radius: float, + fixed_width: float | None = None, + fixed_height: float | None = None, + fixed_radius: float | None = None, + ) -> dict[str, Any] | None: + """Calculates geometry for a rounded rectangle.""" + width, height = abs(x2 - x1), abs(y2 - y1) + if width < 1e-6 or height < 1e-6: + return None + + radius = min(radius, width / 2.0, height / 2.0) + sx, sy = (1 if x2 > x1 else -1), (1 if y2 > y1 else -1) + + temp_id_counter = -1 + + def next_temp_id(): + nonlocal temp_id_counter + temp_id_counter -= 1 + return temp_id_counter + + points = { + "t1": Point(next_temp_id(), x1 + sx * radius, y1), + "t2": Point(next_temp_id(), x2 - sx * radius, y1), + "t3": Point(next_temp_id(), x2, y1 + sy * radius), + "t4": Point(next_temp_id(), x2, y2 - sy * radius), + "t5": Point(next_temp_id(), x2 - sx * radius, y2), + "t6": Point(next_temp_id(), x1 + sx * radius, y2), + "t7": Point(next_temp_id(), x1, y2 - sy * radius), + "t8": Point(next_temp_id(), x1, y1 + sy * radius), + "c1": Point(next_temp_id(), x1 + sx * radius, y1 + sy * radius), + "c2": Point(next_temp_id(), x2 - sx * radius, y1 + sy * radius), + "c3": Point(next_temp_id(), x2 - sx * radius, y2 - sy * radius), + "c4": Point(next_temp_id(), x1 + sx * radius, y2 - sy * radius), + } + + is_cw = sx * sy < 0 + entities = [ + Line(next_temp_id(), points["t1"].id, points["t2"].id), + Line(next_temp_id(), points["t3"].id, points["t4"].id), + Line(next_temp_id(), points["t5"].id, points["t6"].id), + Line(next_temp_id(), points["t7"].id, points["t8"].id), + Arc( + next_temp_id(), + points["t8"].id, + points["t1"].id, + points["c1"].id, + clockwise=is_cw, + ), + Arc( + next_temp_id(), + points["t2"].id, + points["t3"].id, + points["c2"].id, + clockwise=is_cw, + ), + Arc( + next_temp_id(), + points["t4"].id, + points["t5"].id, + points["c3"].id, + clockwise=is_cw, + ), + Arc( + next_temp_id(), + points["t6"].id, + points["t7"].id, + points["c4"].id, + clockwise=is_cw, + ), + ] + + constraints = [ + HorizontalConstraint(points["t1"].id, points["t2"].id), + VerticalConstraint(points["t3"].id, points["t4"].id), + HorizontalConstraint(points["t5"].id, points["t6"].id), + VerticalConstraint(points["t7"].id, points["t8"].id), + TangentConstraint(entities[0].id, entities[4].id), + TangentConstraint(entities[3].id, entities[4].id), + TangentConstraint(entities[0].id, entities[5].id), + TangentConstraint(entities[1].id, entities[5].id), + TangentConstraint(entities[1].id, entities[6].id), + TangentConstraint(entities[2].id, entities[6].id), + TangentConstraint(entities[2].id, entities[7].id), + TangentConstraint(entities[3].id, entities[7].id), + EqualLengthConstraint([e.id for e in entities[4:]]), + EqualDistanceConstraint( + points["c1"].id, + points["t8"].id, + points["c1"].id, + points["t1"].id, + ), + EqualDistanceConstraint( + points["c2"].id, + points["t2"].id, + points["c2"].id, + points["t3"].id, + ), + EqualDistanceConstraint( + points["c3"].id, + points["t4"].id, + points["c3"].id, + points["t5"].id, + ), + EqualDistanceConstraint( + points["c4"].id, + points["t6"].id, + points["c4"].id, + points["t7"].id, + ), + ] + + top_edge_y = min(y1, y2) + right_edge_x = max(x1, x2) + + if top_edge_y == y1: + top_edge_p1 = points["t1"].id + top_edge_p2 = points["t2"].id + else: + top_edge_p1 = points["t5"].id + top_edge_p2 = points["t6"].id + + if right_edge_x == x2: + right_edge_p1 = points["t3"].id + right_edge_p2 = points["t4"].id + else: + right_edge_p1 = points["t7"].id + right_edge_p2 = points["t8"].id + + if fixed_width is not None: + constraints.append( + DistanceConstraint(top_edge_p1, top_edge_p2, fixed_width) + ) + + if fixed_height is not None: + constraints.append( + DistanceConstraint(right_edge_p1, right_edge_p2, fixed_height) + ) + + if fixed_radius is not None: + constraints.append(RadiusConstraint(entities[4].id, fixed_radius)) + + return { + "points": list(points.values()), + "entities": entities, + "constraints": constraints, + } + + @staticmethod + def create_preview( + registry: EntityRegistry, + start_pid: EntityID, + end_pid: EntityID, + radius: float, + preview_ids: dict[str, EntityID] | None = None, + ) -> dict[str, EntityID] | None: + """ + Creates or updates preview geometry in the registry. + + Args: + registry: The entity registry to modify. + start_pid: The ID of the start corner point. + end_pid: The ID of the end corner point (preview corner). + radius: The corner radius. + preview_ids: Existing preview IDs to update, or None to create new. + + Returns: + Dict of preview IDs, or None if geometry is invalid. + """ + try: + start_p = registry.get_point(start_pid) + end_p = registry.get_point(end_pid) + except IndexError: + return None + + x1, y1 = start_p.x, start_p.y + x2, y2 = end_p.x, end_p.y + width, height = abs(x2 - x1), abs(y2 - y1) + + if width > 1e-6 and height > 1e-6: + radius = min(radius, width / 2.0, height / 2.0) + else: + radius = 0.0 + + sx, sy = (1 if x2 > x1 else -1), (1 if y2 > y1 else -1) + is_cw = sx * sy < 0 + + coords = { + "t1": (x1 + sx * radius, y1), + "t2": (x2 - sx * radius, y1), + "t3": (x2, y1 + sy * radius), + "t4": (x2, y2 - sy * radius), + "t5": (x2 - sx * radius, y2), + "t6": (x1 + sx * radius, y2), + "t7": (x1, y2 - sy * radius), + "t8": (x1, y1 + sy * radius), + "c1": (x1 + sx * radius, y1 + sy * radius), + "c2": (x2 - sx * radius, y1 + sy * radius), + "c3": (x2 - sx * radius, y2 - sy * radius), + "c4": (x1 + sx * radius, y2 - sy * radius), + } + + if preview_ids is None: + # Create new preview geometry + preview_ids = {} + + # Create all points + for name, (px, py) in coords.items(): + preview_ids[name] = registry.add_point(px, py) + + # Lines + preview_ids["line1"] = registry.add_line( + preview_ids["t1"], preview_ids["t2"] + ) + preview_ids["line2"] = registry.add_line( + preview_ids["t3"], preview_ids["t4"] + ) + preview_ids["line3"] = registry.add_line( + preview_ids["t5"], preview_ids["t6"] + ) + preview_ids["line4"] = registry.add_line( + preview_ids["t7"], preview_ids["t8"] + ) + + # Arcs + preview_ids["arc1"] = registry.add_arc( + preview_ids["t8"], + preview_ids["t1"], + preview_ids["c1"], + cw=is_cw, + ) + preview_ids["arc2"] = registry.add_arc( + preview_ids["t2"], + preview_ids["t3"], + preview_ids["c2"], + cw=is_cw, + ) + preview_ids["arc3"] = registry.add_arc( + preview_ids["t4"], + preview_ids["t5"], + preview_ids["c3"], + cw=is_cw, + ) + preview_ids["arc4"] = registry.add_arc( + preview_ids["t6"], + preview_ids["t7"], + preview_ids["c4"], + cw=is_cw, + ) + else: + # Update existing preview geometry + for name, (px, py) in coords.items(): + p = registry.get_point(preview_ids[name]) + p.x, p.y = px, py + + # Update arc directions + for key in ["arc1", "arc2", "arc3", "arc4"]: + arc_entity = registry.get_entity(preview_ids[key]) + if isinstance(arc_entity, Arc): + arc_entity.clockwise = is_cw + + return preview_ids + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + radius: float = 10.0, + **kwargs, + ) -> RoundedRectPreviewState: + """ + Creates initial preview state with start and end points. + + Args: + registry: The entity registry to modify. + x, y: The initial coordinates. + snapped_pid: An existing point ID to snap to, or None. + radius: The corner radius. + + Returns: + RoundedRectPreviewState for use with update_preview and + cleanup_preview. + """ + if snapped_pid is not None: + start_id = snapped_pid + start_temp = False + else: + start_id = registry.add_point(x, y) + start_temp = True + + p_end_id = registry.add_point(x, y) + + preview_ids = RoundedRectCommand.create_preview( + registry, start_id, p_end_id, radius + ) + assert preview_ids is not None + + return RoundedRectPreviewState( + start_id=start_id, + start_temp=start_temp, + p_end_id=p_end_id, + preview_ids=preview_ids, + radius=radius, + ) + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """ + Updates the end point position and refreshes preview geometry. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + x, y: The new end point coordinates. + + Raises: + TypeError: If preview_state is not a RoundedRectPreviewState. + """ + if not isinstance(preview_state, RoundedRectPreviewState): + raise TypeError("Expected RoundedRectPreviewState") + try: + p_end = registry.get_point(preview_state.p_end_id) + p_start = registry.get_point(preview_state.start_id) + except IndexError: + return + + if ( + preview_state.locked_width is not None + or preview_state.locked_height is not None + ): + dx = p_end.x - p_start.x + dy = p_end.y - p_start.y + sign_x = 1.0 if dx >= 0 else -1.0 + sign_y = 1.0 if dy >= 0 else -1.0 + + new_x = ( + p_start.x + sign_x * preview_state.locked_width + if preview_state.locked_width is not None + else x + ) + new_y = ( + p_start.y + sign_y * preview_state.locked_height + if preview_state.locked_height is not None + else y + ) + p_end.x = new_x + p_end.y = new_y + else: + p_end.x = x + p_end.y = y + + RoundedRectCommand.create_preview( + registry, + preview_state.start_id, + preview_state.p_end_id, + preview_state.radius, + preview_ids=preview_state.preview_ids, + ) + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes all preview entities and points from the registry. + + Args: + registry: The entity registry to modify. + preview_state: The preview state from start_preview. + + Raises: + TypeError: If preview_state is not a RoundedRectPreviewState. + """ + if not isinstance(preview_state, RoundedRectPreviewState): + raise TypeError("Expected RoundedRectPreviewState") + preview_ids = preview_state.preview_ids + p_end_id = preview_state.p_end_id + + point_ids = set(preview_ids.values()) + point_ids.add(p_end_id) + + entity_ids_to_remove = { + e.id + for e in registry.entities + if any(pid in point_ids for pid in e.get_point_ids()) + } + registry.remove_entities_by_id(list(entity_ids_to_remove)) + + registry.points = [p for p in registry.points if p.id not in point_ids] + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + + reg = self.sketch.registry + try: + start_p = reg.get_point(self.start_pid) + except IndexError: + return + + result = self.calculate_geometry( + start_p.x, + start_p.y, + self.end_pos[0], + self.end_pos[1], + self.radius, + fixed_width=self.fixed_width, + fixed_height=self.fixed_height, + fixed_radius=self.fixed_radius, + ) + if not result: + if self.is_start_temp: + self.sketch.remove_point_if_unused(self.start_pid) + return + + points_to_add = result["points"] + if self.is_start_temp: + reg.points.remove(start_p) + # Unlike Rectangle, RoundedRect doesn't use the start_pid in its + # final geometry, so we just remove it. + + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points_to_add, + entities=result["entities"], + constraints=result["constraints"], + ) + self.add_cmd._do_execute() + self._committed_end_id = None + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/straighten.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/straighten.py new file mode 100644 index 000000000..7bb22e77a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/straighten.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..entities import Bezier, Line +from ..types import EntityID +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..sketch import Sketch + + +class StraightenBezierCommand(SketchChangeCommand): + """ + Command to convert a Bezier curve to a straight Line. + + Removes control points and replaces the Bezier entity with a Line entity. + """ + + def __init__(self, sketch: Sketch, bezier_id: EntityID): + label = _("Straighten") + super().__init__(sketch, label) + self.bezier_id = bezier_id + self._old_bezier: ( + tuple[ + EntityID, + EntityID, + EntityID, + bool, + tuple[float, float] | None, + tuple[float, float] | None, + ] + | None + ) = None + + def _do_execute(self) -> None: + registry = self.sketch.registry + bezier = registry.get_entity(self.bezier_id) + if not isinstance(bezier, Bezier): + return + + self._old_bezier = ( + bezier.id, + bezier.start_idx, + bezier.end_idx, + bezier.construction, + bezier.cp1, + bezier.cp2, + ) + + line = Line( + bezier.id, + bezier.start_idx, + bezier.end_idx, + bezier.construction, + ) + + registry.remove_entities_by_id([bezier.id]) + registry.entities.append(line) + registry._entity_map[line.id] = line + + def _do_undo(self) -> None: + if self._old_bezier is None: + return + + registry = self.sketch.registry + ( + bezier_id, + start_idx, + end_idx, + construction, + cp1, + cp2, + ) = self._old_bezier + + line = registry.get_entity(bezier_id) + if not isinstance(line, Line): + return + + bezier = Bezier(bezier_id, start_idx, end_idx, construction, cp1, cp2) + + registry.entities.remove(line) + del registry._entity_map[bezier_id] + registry.entities.append(bezier) + registry._entity_map[bezier_id] = bezier diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/symmetry_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/symmetry_constraint.py new file mode 100644 index 000000000..8afe4ceb1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/symmetry_constraint.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from ..types import EntityID + + +@dataclass +class SymmetryConstraintParams: + p1_id: EntityID + p2_id: EntityID + center_id: EntityID | None = None + axis_id: EntityID | None = None + + +class SymmetryConstraintCommand: + @staticmethod + def determine_constraint_params( + point_ids: list[EntityID], + entity_ids: list[EntityID], + ) -> SymmetryConstraintParams | None: + if len(point_ids) == 3 and not entity_ids: + return SymmetryConstraintParams( + p1_id=point_ids[0], + p2_id=point_ids[1], + center_id=point_ids[2], + ) + elif len(point_ids) == 2 and len(entity_ids) == 1: + return SymmetryConstraintParams( + p1_id=point_ids[0], + p2_id=point_ids[1], + axis_id=entity_ids[0], + ) + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/tangent_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/tangent_constraint.py new file mode 100644 index 000000000..d4e264ef8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/tangent_constraint.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ..entities import Arc, Circle, Line +from ..types import EntityID + +if TYPE_CHECKING: + from ..registry import EntityRegistry + + +@dataclass +class TangentConstraintParams: + line_id: EntityID + shape_id: EntityID + + +class TangentConstraintCommand: + @staticmethod + def identify_entities( + registry: EntityRegistry, + entity_ids: list[EntityID], + ) -> TangentConstraintParams | None: + sel_line: Line | None = None + sel_shape: Arc | Circle | None = None + + for eid in entity_ids: + e = registry.get_entity(eid) + if isinstance(e, Line): + sel_line = e + elif isinstance(e, (Arc, Circle)): + sel_shape = e + + if sel_line and sel_shape: + return TangentConstraintParams( + line_id=sel_line.id, + shape_id=sel_shape.id, + ) + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/text_box.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/text_box.py new file mode 100644 index 000000000..5eab461c9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/text_box.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import ( + AspectRatioConstraint, + HorizontalConstraint, + ParallelogramConstraint, + PerpendicularConstraint, +) +from ..entities import Line, Point, TextBoxEntity +from .base import SketchChangeCommand +from .items import AddItemsCommand + +if TYPE_CHECKING: + from ..sketch import Sketch + + +class TextBoxCommand(SketchChangeCommand): + """A command to create a text box with its default constraints.""" + + def __init__( + self, + sketch: Sketch, + origin: GeoPoint, + width: float = 10.0, + height: float = 10.0, + ): + super().__init__(sketch, _("Add Text Box")) + self.origin = origin + self.width = width + self.height = height + self.add_cmd: AddItemsCommand | None = None + self.text_box_id: int | None = None + + @staticmethod + def calculate_geometry( + origin: GeoPoint, width: float, height: float + ) -> dict[str, Any]: + """Calculates all points, entities, and constraints for a text box.""" + mx, my = origin + + # Use temporary negative IDs + p_origin = Point(-1, mx, my) + p_width = Point(-2, mx + width, my) + p_height = Point(-3, mx, my + height) + p4 = Point(-4, mx + width, my + height) + + points_to_add = [p_origin, p_width, p_height, p4] + + # Construction lines + bottom_line = Line(-5, p_origin.id, p_width.id, construction=True) + right_line = Line(-6, p_width.id, p4.id, construction=True) + top_line = Line(-7, p4.id, p_height.id, construction=True) + left_line = Line(-8, p_height.id, p_origin.id, construction=True) + + lines_to_add = [bottom_line, right_line, top_line, left_line] + line_ids = [line.id for line in lines_to_add] + + text_box = TextBoxEntity( + -9, + p_origin.id, + p_width.id, + p_height.id, + content="", + construction_line_ids=line_ids, + ) + + entities_to_add = [*lines_to_add, text_box] + + constraints_to_add = [ + # Aspect ratio constraint for live text resizing + AspectRatioConstraint( + p_origin.id, + p_width.id, + p_origin.id, + p_height.id, + 1.0, + user_visible=True, + ), + # Structural integrity constraint (hidden) + ParallelogramConstraint( + p_origin.id, + p_width.id, + p_height.id, + p4.id, + user_visible=False, + ), + # Default constraints for user interaction (visible) + HorizontalConstraint(p_origin.id, p_width.id), + PerpendicularConstraint(bottom_line.id, left_line.id), + ] + + return { + "points": points_to_add, + "entities": entities_to_add, + "constraints": constraints_to_add, + "text_box_id": text_box.id, + } + + def _do_execute(self) -> None: + if not self.add_cmd: + geom = self.calculate_geometry( + self.origin, self.width, self.height + ) + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=geom["points"], + entities=geom["entities"], + constraints=geom["constraints"], + ) + + self.add_cmd._do_execute() + + # After execution, the temporary ID is resolved. Find the new ID. + for entity in reversed(self.sketch.registry.entities): + if isinstance(entity, TextBoxEntity): + self.text_box_id = entity.id + break + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/text_property.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/text_property.py new file mode 100644 index 000000000..5d64be75f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/text_property.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.shape.text import FontConfig +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import ( + AspectRatioConstraint, + DistanceConstraint, + EqualLengthConstraint, +) +from ..entities.line import Line +from ..entities.point import Point +from ..entities.text_box import TextBoxEntity +from ..types import EntityID +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..sketch import Sketch + + +class ModifyTextPropertyCommand(SketchChangeCommand): + def __init__( + self, + sketch: Sketch, + text_entity_id: EntityID, + new_content: str, + new_font_config: FontConfig, + ): + super().__init__(sketch, _("Modify Text Property")) + self.text_entity_id = text_entity_id + self.new_content = new_content + self.new_font_config = new_font_config + self.old_content = "" + self.old_font_config: FontConfig | None = None + self.old_point_positions: dict[EntityID, GeoPoint] = {} + self.old_aspect_ratio: float | None = None + self.aspect_ratio_constraint_idx: int | None = None + self._added_constraints: list[Constraint] = [] + + self._entity_was_removed = False + self._removed_entity: TextBoxEntity | None = None + self._removed_points: list[Point] = [] + self._removed_entities: list[Any] = [] + self._removed_constraints: list[Constraint] = [] + self._modified_equal_length_constraints: list[ + tuple[EntityID, list[EntityID]] + ] = [] + + def _shed_size_constraints(self, text_entity: TextBoxEntity) -> None: + """ + Removes previously added hidden distance constraints and any user-added + dimensional constraints on the text box frame to prevent over- + constraining. + """ + self._removed_constraints.clear() + self._modified_equal_length_constraints.clear() + + # Find any constraints that define the length of the box sides. + constraints_to_remove = [] + for eid in text_entity.construction_line_ids: + entity = self.sketch.registry.get_entity(eid) + if not isinstance(entity, Line): + continue + + for constr in self.sketch.constraints: + if constr in constraints_to_remove: + continue + + is_target = False + if isinstance(constr, EqualLengthConstraint): + if eid in constr.entity_ids: + self._modified_equal_length_constraints.append( + ( + self.sketch.constraints.index(constr), + list(constr.entity_ids), + ) + ) + constr.entity_ids.remove(eid) + # This specifically targets hidden constraints added by this + # command, or user-added distance constraints. + elif constr.targets_segment(entity.p1_idx, entity.p2_idx, eid): + is_target = True + + if is_target: + constraints_to_remove.append(constr) + + self._removed_constraints.extend(constraints_to_remove) + for c in self._removed_constraints: + if c in self.sketch.constraints: + self.sketch.constraints.remove(c) + + def _do_execute(self) -> None: + if self._entity_was_removed: + self._restore_text_entity() + self._entity_was_removed = False + + entity = self.sketch.registry.get_entity(self.text_entity_id) + if not isinstance(entity, TextBoxEntity): + return + + text_entity = entity + + if not self.old_content and not self.old_point_positions: + self.old_content = text_entity.content + self.old_font_config = text_entity.font_config.copy() + p_width = self.sketch.registry.get_point(text_entity.width_id) + p_height = self.sketch.registry.get_point(text_entity.height_id) + self.old_point_positions = { + text_entity.width_id: (p_width.x, p_width.y), + text_entity.height_id: (p_height.x, p_height.y), + } + # Find and store the old aspect ratio constraint + for idx, constr in enumerate(self.sketch.constraints or []): + if ( + isinstance(constr, AspectRatioConstraint) + and constr.p1 == text_entity.origin_id + and constr.p2 == text_entity.width_id + and constr.p3 == text_entity.origin_id + and constr.p4 == text_entity.height_id + ): + self.aspect_ratio_constraint_idx = idx + self.old_aspect_ratio = constr.ratio + break + + # Update the entity's content first. + text_entity.content = self.new_content + text_entity.font_config = self.new_font_config.copy() + + # If the content is now empty, remove the entire entity. + if not self.new_content: + self._remove_text_entity(text_entity) + return + + # Always clear any temporary constraints added by this command. + self._added_constraints.clear() + + # Remove any existing dimensional constraints on the box frame to + # prevent conflicts before we re-establish the width. + self._shed_size_constraints(text_entity) + + # Get natural dimensions of the new text. + natural_width, natural_height = text_entity.get_natural_size( + self.new_content + ) + + # Always add a hidden constraint for the width. This is authoritative. + width_constr = DistanceConstraint( + text_entity.origin_id, + text_entity.width_id, + natural_width, + user_visible=False, + ) + self._added_constraints.append(width_constr) + self.sketch.constraints.append(width_constr) + + # Find the Aspect Ratio constraint, if it exists. + active_ar_constraint = None + for constr in self.sketch.constraints: + if ( + isinstance(constr, AspectRatioConstraint) + and constr.p1 == text_entity.origin_id + and constr.p2 == text_entity.width_id + ): + active_ar_constraint = constr + break + + # If the AR constraint exists, update its ratio. The solver will handle + # the height automatically. If not, the height remains unconstrained. + if active_ar_constraint and natural_height > 1e-9: + new_ratio = natural_width / natural_height + active_ar_constraint.ratio = new_ratio + + def _remove_text_entity(self, text_entity: TextBoxEntity) -> None: + """Removes the text entity and its associated points/constraints.""" + registry = self.sketch.registry + + self._removed_entity = text_entity + + p_origin = registry.get_point(text_entity.origin_id) + p_width = registry.get_point(text_entity.width_id) + p_height = registry.get_point(text_entity.height_id) + + self._removed_points = [p_origin, p_width, p_height] + + p4_id = text_entity.get_fourth_corner_id(registry) + if p4_id: + p4 = registry.get_point(p4_id) + self._removed_points.append(p4) + + for eid in text_entity.construction_line_ids: + e = registry.get_entity(eid) + if e: + self._removed_entities.append(e) + + if self.aspect_ratio_constraint_idx is not None: + constr = self.sketch.constraints[self.aspect_ratio_constraint_idx] + self._removed_constraints.append(constr) + + point_ids = {pt.id for pt in self._removed_points} + + for constr in self.sketch.constraints: + if ( + constr not in self._removed_constraints + and constr.depends_on_points(point_ids) + ): + self._removed_constraints.append(constr) + + registry.entities = [ + e for e in registry.entities if e.id != text_entity.id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + registry.points = [p for p in registry.points if p.id not in point_ids] + + for e in self._removed_entities: + registry.entities = [ + ent for ent in registry.entities if ent.id != e.id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + for c in self._removed_constraints: + if c in self.sketch.constraints: + self.sketch.constraints.remove(c) + + self._entity_was_removed = True + + def _remove_text_entity_for_undo(self, text_entity: TextBoxEntity) -> None: + """ + Removes the text entity when undoing to an empty content state. + Saves state for redo. + """ + registry = self.sketch.registry + + self._removed_entity = text_entity + self._removed_points = [] + self._removed_entities = [] + self._removed_constraints = [] + + p_origin = registry.get_point(text_entity.origin_id) + p_width = registry.get_point(text_entity.width_id) + p_height = registry.get_point(text_entity.height_id) + + self._removed_points = [p_origin, p_width, p_height] + + p4_id = text_entity.get_fourth_corner_id(registry) + if p4_id: + p4 = registry.get_point(p4_id) + self._removed_points.append(p4) + + for eid in text_entity.construction_line_ids: + e = registry.get_entity(eid) + if e: + self._removed_entities.append(e) + + if self.aspect_ratio_constraint_idx is not None: + constr = self.sketch.constraints[self.aspect_ratio_constraint_idx] + self._removed_constraints.append(constr) + + point_ids = {pt.id for pt in self._removed_points} + + for constr in self.sketch.constraints: + if ( + constr not in self._removed_constraints + and constr.depends_on_points(point_ids) + ): + self._removed_constraints.append(constr) + + registry.entities = [ + e for e in registry.entities if e.id != text_entity.id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + registry.points = [p for p in registry.points if p.id not in point_ids] + + for e in self._removed_entities: + registry.entities = [ + ent for ent in registry.entities if ent.id != e.id + ] + registry._entity_map = {e.id: e for e in registry.entities} + + for c in self._removed_constraints: + if c in self.sketch.constraints: + self.sketch.constraints.remove(c) + + self._entity_was_removed = True + + def _do_undo(self) -> None: + if self._entity_was_removed: + self._restore_text_entity() + return + + entity = self.sketch.registry.get_entity(self.text_entity_id) + if not isinstance(entity, TextBoxEntity): + return + + text_entity = entity + + if not self.old_content: + self._remove_text_entity_for_undo(text_entity) + return + + text_entity.content = self.old_content + if self.old_font_config is not None: + text_entity.font_config = self.old_font_config.copy() + + for pid, (x, y) in self.old_point_positions.items(): + p = self.sketch.registry.get_point(pid) + p.x = x + p.y = y + + # Restore old aspect ratio + if ( + self.aspect_ratio_constraint_idx is not None + and self.old_aspect_ratio is not None + ) and self.aspect_ratio_constraint_idx < len(self.sketch.constraints): + constr = self.sketch.constraints[self.aspect_ratio_constraint_idx] + if isinstance(constr, AspectRatioConstraint): + constr.ratio = self.old_aspect_ratio + + # Remove constraints added by this command + for c in self._added_constraints: + if c in self.sketch.constraints: + self.sketch.constraints.remove(c) + self._added_constraints.clear() + + # Restore removed constraints + self.sketch.constraints.extend(self._removed_constraints) + + # Restore modified EqualLength constraints + for idx, old_entity_ids in reversed( + self._modified_equal_length_constraints + ): + if idx < len(self.sketch.constraints): + constr = self.sketch.constraints[idx] + if isinstance(constr, EqualLengthConstraint): + constr.entity_ids = old_entity_ids + + def undo(self) -> None: + """ + Override undo to handle entity removal properly. + When reverting to empty content, we remove the entity and should + not restore the snapshot (which would restore the removed points). + """ + self._do_undo() + if not self._entity_was_removed: + self.restore_snapshot() + self.sketch.notify_update() + + def _restore_text_entity(self) -> None: + """Restores the text entity and its associated points/constraints.""" + registry = self.sketch.registry + + for p in self._removed_points: + registry.points.append(p) + + for e in self._removed_entities: + registry.entities.append(e) + + if self._removed_entity: + registry.entities.append(self._removed_entity) + + registry._entity_map = {e.id: e for e in registry.entities} + + for c in self._removed_constraints: + self.sketch.constraints.append(c) + + self._entity_was_removed = False + + def should_skip_undo(self) -> bool: + """ + Returns True if the text was empty before and after editing, + indicating this is a no-op that should not be added to the undo stack. + """ + return not self.old_content and not self.new_content diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/waypoint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/waypoint.py new file mode 100644 index 000000000..4e2d77640 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/waypoint.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..entities import Bezier, Line +from ..entities.point import WaypointType +from ..types import EntityID +from .base import SketchChangeCommand + +if TYPE_CHECKING: + from ..entities.point import Point + from ..registry import EntityRegistry + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + +DEFAULT_CP_LENGTH = 30.0 + + +class SetWaypointTypeCommand(SketchChangeCommand): + """ + Command to change a waypoint's type (sharp/smooth/symmetric). + + When converting from SHARP to SMOOTH or SYMMETRIC: + - Creates control point offsets on connected beziers if they don't exist + - Positions them appropriately based on connected segments + - Converts connected Line entities to Bezier entities + + When converting from SMOOTH/SYMMETRIC to SHARP: + - Control points are preserved, but co-linearity is no longer enforced + """ + + def __init__( + self, + sketch: Sketch, + waypoint_id: EntityID, + new_type: WaypointType, + ): + label = _("Set Waypoint Type") + super().__init__(sketch, label) + self.waypoint_id = waypoint_id + self.new_type = new_type + self._old_waypoint_type: WaypointType | None = None + self._old_bezier_states: ( + dict[int, tuple[GeoPoint | None, GeoPoint | None]] | None + ) = None + self._converted_lines: list[tuple[int, int, int]] | None = None + self._added_bezier_ids: list[int] | None = None + + def _get_segment_directions( + self, registry: EntityRegistry, waypoint: Point + ) -> tuple[GeoPoint | None, GeoPoint | None]: + """ + Get the incoming and outgoing direction vectors at this waypoint. + + Returns (incoming_dir, outgoing_dir) as normalized vectors. + """ + incoming_dir: GeoPoint | None = None + outgoing_dir: GeoPoint | None = None + + point_ids = {waypoint.id} + point_ids.update(self.sketch.get_coincident_points(waypoint.id)) + + for entity in registry.entities: + if isinstance(entity, Line): + if entity.p2_idx in point_ids: + p1 = registry.get_point(entity.p1_idx) + if p1: + dx = waypoint.x - p1.x + dy = waypoint.y - p1.y + length = math.hypot(dx, dy) + if length > 1e-9: + incoming_dir = (dx / length, dy / length) + elif entity.p1_idx in point_ids: + p2 = registry.get_point(entity.p2_idx) + if p2: + dx = p2.x - waypoint.x + dy = p2.y - waypoint.y + length = math.hypot(dx, dy) + if length > 1e-9: + outgoing_dir = (dx / length, dy / length) + elif isinstance(entity, Bezier): + if entity.end_idx in point_ids: + if entity.cp2 is not None: + cp_abs = ( + waypoint.x + entity.cp2[0], + waypoint.y + entity.cp2[1], + ) + dx = waypoint.x - cp_abs[0] + dy = waypoint.y - cp_abs[1] + length = math.hypot(dx, dy) + if length > 1e-9: + incoming_dir = (dx / length, dy / length) + else: + start = registry.get_point(entity.start_idx) + if start: + dx = waypoint.x - start.x + dy = waypoint.y - start.y + length = math.hypot(dx, dy) + if length > 1e-9: + incoming_dir = (dx / length, dy / length) + elif entity.start_idx in point_ids: + if entity.cp1 is not None: + cp_abs = ( + waypoint.x + entity.cp1[0], + waypoint.y + entity.cp1[1], + ) + dx = cp_abs[0] - waypoint.x + dy = cp_abs[1] - waypoint.y + length = math.hypot(dx, dy) + if length > 1e-9: + outgoing_dir = (dx / length, dy / length) + else: + end = registry.get_point(entity.end_idx) + if end: + dx = end.x - waypoint.x + dy = end.y - waypoint.y + length = math.hypot(dx, dy) + if length > 1e-9: + outgoing_dir = (dx / length, dy / length) + + return incoming_dir, outgoing_dir + + def _find_connected_lines( + self, registry: EntityRegistry, waypoint_id: EntityID + ) -> list[tuple[EntityID, EntityID, EntityID]]: + """Find Line entities connected to this waypoint. + + Returns list of (line_id, p1_idx, p2_idx). + """ + point_ids = {waypoint_id} + point_ids.update(self.sketch.get_coincident_points(waypoint_id)) + + connected = [] + for entity in registry.entities: + if isinstance(entity, Line) and ( + entity.p1_idx in point_ids or entity.p2_idx in point_ids + ): + connected.append((entity.id, entity.p1_idx, entity.p2_idx)) + return connected + + def _convert_lines_to_beziers( + self, + registry: EntityRegistry, + lines: list[tuple[EntityID, EntityID, EntityID]], + ) -> list[EntityID]: + """Remove Line entities and add Bezier entities in their place.""" + bezier_ids = [] + line_ids_to_remove = [lid for lid, unused1, unused2 in lines] + registry.remove_entities_by_id(line_ids_to_remove) + + for unused, p1_idx, p2_idx in lines: + bezier_id = registry.add_bezier(p1_idx, p2_idx) + bezier_ids.append(bezier_id) + + return bezier_ids + + def _restore_lines( + self, + registry: EntityRegistry, + lines: list[tuple[EntityID, EntityID, EntityID]], + bezier_ids: list[EntityID], + ): + """Remove Bezier entities and restore Line entities.""" + registry.remove_entities_by_id(bezier_ids) + for line_id, p1_idx, p2_idx in lines: + new_line = Line(line_id, p1_idx, p2_idx) + registry.entities.append(new_line) + registry._entity_map[line_id] = new_line + + def _do_execute(self) -> None: + registry = self.sketch.registry + try: + waypoint = registry.get_point(self.waypoint_id) + except IndexError: + return + + connected_beziers = waypoint.get_connected_beziers( + registry, self.sketch + ) + self._old_bezier_states = {} + for b in connected_beziers: + self._old_bezier_states[b.id] = (b.cp1, b.cp2) + + self._old_waypoint_type = waypoint.waypoint_type + + if self.new_type in (WaypointType.SMOOTH, WaypointType.SYMMETRIC): + connected_lines = self._find_connected_lines(registry, waypoint.id) + if connected_lines: + self._converted_lines = connected_lines + self._added_bezier_ids = self._convert_lines_to_beziers( + registry, connected_lines + ) + connected_beziers = waypoint.get_connected_beziers( + registry, self.sketch + ) + + incoming_dir, outgoing_dir = self._get_segment_directions( + registry, waypoint + ) + + avg_dir: GeoPoint | None = None + if incoming_dir is not None and outgoing_dir is not None: + avg_dir = ( + (incoming_dir[0] + outgoing_dir[0]) / 2, + (incoming_dir[1] + outgoing_dir[1]) / 2, + ) + length = math.hypot(avg_dir[0], avg_dir[1]) + if length > 1e-9: + avg_dir = (avg_dir[0] / length, avg_dir[1] / length) + else: + avg_dir = outgoing_dir + elif outgoing_dir is not None: + avg_dir = outgoing_dir + elif incoming_dir is not None: + avg_dir = (-incoming_dir[0], -incoming_dir[1]) + + if avg_dir is None: + avg_dir = (1.0, 0.0) + + cp_length = DEFAULT_CP_LENGTH / 3.0 + cp_in = (-avg_dir[0] * cp_length, -avg_dir[1] * cp_length) + cp_out = (avg_dir[0] * cp_length, avg_dir[1] * cp_length) + + point_ids = {waypoint.id} + point_ids.update(self.sketch.get_coincident_points(waypoint.id)) + + for b in connected_beziers: + if b.start_idx in point_ids and b.cp1 is None: + b.cp1 = cp_out + if b.end_idx in point_ids and b.cp2 is None: + b.cp2 = cp_in + + waypoint.waypoint_type = self.new_type + waypoint.enforce_constraint(registry, self.sketch) + + def _do_undo(self) -> None: + if self._old_waypoint_type is None: + return + + registry = self.sketch.registry + try: + waypoint = registry.get_point(self.waypoint_id) + except IndexError: + return + + waypoint.waypoint_type = self._old_waypoint_type + + if self._old_bezier_states is not None: + for bezier_id, (cp1, cp2) in self._old_bezier_states.items(): + bezier = registry.get_entity(bezier_id) + if isinstance(bezier, Bezier): + bezier.cp1 = cp1 + bezier.cp2 = cp2 + + if ( + self._converted_lines is not None + and self._added_bezier_ids is not None + ): + self._restore_lines( + registry, self._converted_lines, self._added_bezier_ids + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/__init__.py new file mode 100644 index 000000000..b9549bd12 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/__init__.py @@ -0,0 +1,63 @@ +""" +Geometric constraints for the 2D CAD sketcher. +""" + +from .angle import ANGLE_WEIGHT, AngleConstraint +from .aspect_ratio import AspectRatioConstraint +from .base import Constraint, ConstraintStatus +from .coincident import CoincidentConstraint +from .collinear import CollinearConstraint +from .diameter import DiameterConstraint +from .distance import DistanceConstraint +from .drag import DragConstraint +from .equal_distance import EqualDistanceConstraint +from .equal_length import EqualLengthConstraint +from .horizontal import HorizontalConstraint +from .parallelogram import ParallelogramConstraint +from .perpendicular import PerpendicularConstraint +from .point_on_line import PointOnLineConstraint +from .radius import RadiusConstraint +from .symmetry import SymmetryConstraint +from .tangent import TangentConstraint +from .vertical import VerticalConstraint + +CONSTRAINT_TYPE_MAP: dict[str, type[Constraint]] = { + "horiz": HorizontalConstraint, + "vert": VerticalConstraint, + "dist": DistanceConstraint, + "radius": RadiusConstraint, + "diameter": DiameterConstraint, + "perp": PerpendicularConstraint, + "tangent": TangentConstraint, + "equal": EqualLengthConstraint, + "coincident": CoincidentConstraint, + "point_on_line": PointOnLineConstraint, + "symmetry": SymmetryConstraint, + "aspect_ratio": AspectRatioConstraint, + "angle": AngleConstraint, +} + + +__all__ = [ + "ANGLE_WEIGHT", + "CONSTRAINT_TYPE_MAP", + "AngleConstraint", + "AspectRatioConstraint", + "CoincidentConstraint", + "CollinearConstraint", + "Constraint", + "ConstraintStatus", + "DiameterConstraint", + "DistanceConstraint", + "DragConstraint", + "EqualDistanceConstraint", + "EqualLengthConstraint", + "HorizontalConstraint", + "ParallelogramConstraint", + "PerpendicularConstraint", + "PointOnLineConstraint", + "RadiusConstraint", + "SymmetryConstraint", + "TangentConstraint", + "VerticalConstraint", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/angle.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/angle.py new file mode 100644 index 000000000..30ddc80a4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/angle.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +import logging +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, +) + +from raygeo.geo.shape.arc import normalize_angle +from raygeo.geo.shape.line import get_line_line_intersection +from raygeo.geo.types import Point + +from ..entities import Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + +logger = logging.getLogger(__name__) + +ANGLE_WEIGHT = 10.0 +ARC_RADIUS = 35.0 +LABEL_RADIUS = ARC_RADIUS - 10.0 + + +def _get_far_point(ix, iy, p1, p2): + d1 = (p1.x - ix) ** 2 + (p1.y - iy) ** 2 + d2 = (p2.x - ix) ** 2 + (p2.y - iy) ** 2 + return p1 if d1 > d2 else p2 + + +class AngleConstraint(Constraint): + """ + Enforces a specific angle between two lines. + + e1 is the anchor line, e2 is the other line. + The angle is measured CW from anchor direction to other direction, + using directions pointing away from the lines' intersection toward + the stored far points. + """ + + def __init__( + self, + e1_id: EntityID, + e2_id: EntityID, + value: str | float, + expression: str | None = None, + user_visible: bool = True, + e1_far_idx: EntityID | None = None, + e2_far_idx: EntityID | None = None, + ): + super().__init__(user_visible=user_visible) + self.e1_id: EntityID = e1_id + self.e2_id: EntityID = e2_id + self.e1_far_idx: EntityID | None = e1_far_idx + self.e2_far_idx: EntityID | None = e2_far_idx + + if expression is not None: + self.expression = expression + self.value = float(value) + elif isinstance(value, str): + self.expression = value + self.value = 0.0 + else: + self.expression = None + self.value = float(value) + + @classmethod + def get_type_key(cls) -> str: + return "angle" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) != 2: + return False + if sketch is None: + return False + e1 = sketch.registry.get_entity(selection.entity_ids[0]) + e2 = sketch.registry.get_entity(selection.entity_ids[1]) + return isinstance(e1, Line) and isinstance(e2, Line) + + @staticmethod + def get_type_name() -> str: + return _("Angle") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return f"{self.get_type_name()} {self._format_value()}°" + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained entities.""" + e1 = registry.get_entity(self.e1_id) + e2 = registry.get_entity(self.e2_id) + if isinstance(e1, Line) and isinstance(e2, Line): + return _("Between two lines") + return "" + + def to_dict(self) -> dict[str, Any]: + data = { + "type": "AngleConstraint", + "e1_id": self.e1_id, + "e2_id": self.e2_id, + "value": self.value, + "user_visible": self.user_visible, + "e1_far_idx": self.e1_far_idx, + "e2_far_idx": self.e2_far_idx, + } + if self.expression: + data["expression"] = self.expression + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> AngleConstraint: + return cls( + e1_id=data["e1_id"], + e2_id=data["e2_id"], + value=data["value"], + expression=data.get("expression"), + user_visible=data.get("user_visible", True), + e1_far_idx=data.get("e1_far_idx"), + e2_far_idx=data.get("e2_far_idx"), + ) + + def _get_line_params( + self, reg: EntityRegistry + ) -> tuple[Line, Line, Any, Any, Any, Any] | None: + e1 = reg.get_entity(self.e1_id) + e2 = reg.get_entity(self.e2_id) + if not (isinstance(e1, Line) and isinstance(e2, Line)): + return None + + p1 = reg.get_point(e1.p1_idx) + p2 = reg.get_point(e1.p2_idx) + p3 = reg.get_point(e2.p1_idx) + p4 = reg.get_point(e2.p2_idx) + + if not (p1 and p2 and p3 and p4): + return None + + return e1, e2, p1, p2, p3, p4 + + def _get_far_points( + self, e1: Line, e2: Line, p1, p2, p3, p4, ix: float, iy: float + ): + if self.e1_far_idx == e1.p1_idx: + far1 = p1 + elif self.e1_far_idx == e1.p2_idx: + far1 = p2 + else: + far1 = _get_far_point(ix, iy, p1, p2) + + if self.e2_far_idx == e2.p1_idx: + far2 = p3 + elif self.e2_far_idx == e2.p2_idx: + far2 = p4 + else: + far2 = _get_far_point(ix, iy, p3, p4) + + return far1, far2 + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + result = self._get_line_params(reg) + if result is None: + logger.warning("_get_line_params returned None") + return 0.0 + + e1, e2, p1, p2, p3, p4 = result + + intersection = get_line_line_intersection( + (p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y), (p4.x, p4.y) + ) + + if intersection is None: + logger.warning("no intersection (parallel lines)") + return 0.0 + + ix, iy = intersection + + far1, far2 = self._get_far_points(e1, e2, p1, p2, p3, p4, ix, iy) + + dx1 = far1.x - ix + dy1 = far1.y - iy + dx2 = far2.x - ix + dy2 = far2.y - iy + + len1_sq = dx1 * dx1 + dy1 * dy1 + len2_sq = dx2 * dx2 + dy2 * dy2 + + if len1_sq < 1e-12 or len2_sq < 1e-12: + logger.debug( + f"error: zero length, len1_sq={len1_sq}, len2_sq={len2_sq}" + ) + return 0.0 + + anchor_dir = math.atan2(dy1, dx1) + other_dir = math.atan2(dy2, dx2) + + current = normalize_angle(anchor_dir - other_dir) + + target = math.radians(self.value) + + diff = current - target + while diff > math.pi: + diff -= 2 * math.pi + while diff <= -math.pi: + diff += 2 * math.pi + + return diff * ANGLE_WEIGHT + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + result = self._get_line_params(reg) + if result is None: + return {} + + e1, e2, p1, p2, p3, p4 = result + + intersection = get_line_line_intersection( + (p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y), (p4.x, p4.y) + ) + + if intersection is None: + return {} + + ix, iy = intersection + + far1, far2 = self._get_far_points(e1, e2, p1, p2, p3, p4, ix, iy) + + dx1 = far1.x - ix + dy1 = far1.y - iy + dx2 = far2.x - ix + dy2 = far2.y - iy + + len1_sq = dx1 * dx1 + dy1 * dy1 + len2_sq = dx2 * dx2 + dy2 * dy2 + + if len1_sq < 1e-12 or len2_sq < 1e-12: + return {} + + w = ANGLE_WEIGHT + + d_dir1_d_far1x = -dy1 / len1_sq + d_dir1_d_far1y = dx1 / len1_sq + d_dir1_d_near1x = dy1 / len1_sq + d_dir1_d_near1y = -dx1 / len1_sq + + d_dir2_d_far2x = -dy2 / len2_sq + d_dir2_d_far2y = dx2 / len2_sq + d_dir2_d_near2x = dy2 / len2_sq + d_dir2_d_near2y = -dx2 / len2_sq + + d_error_d_dir1 = w + d_error_d_dir2 = -w + + grads = {} + + if far1 == p1: + grads[e1.p1_idx] = [ + ( + d_error_d_dir1 * d_dir1_d_far1x, + d_error_d_dir1 * d_dir1_d_far1y, + ) + ] + grads[e1.p2_idx] = [ + ( + d_error_d_dir1 * d_dir1_d_near1x, + d_error_d_dir1 * d_dir1_d_near1y, + ) + ] + else: + grads[e1.p1_idx] = [ + ( + d_error_d_dir1 * d_dir1_d_near1x, + d_error_d_dir1 * d_dir1_d_near1y, + ) + ] + grads[e1.p2_idx] = [ + ( + d_error_d_dir1 * d_dir1_d_far1x, + d_error_d_dir1 * d_dir1_d_far1y, + ) + ] + + if far2 == p3: + grads[e2.p1_idx] = [ + ( + d_error_d_dir2 * d_dir2_d_far2x, + d_error_d_dir2 * d_dir2_d_far2y, + ) + ] + grads[e2.p2_idx] = [ + ( + d_error_d_dir2 * d_dir2_d_near2x, + d_error_d_dir2 * d_dir2_d_near2y, + ) + ] + else: + grads[e2.p1_idx] = [ + ( + d_error_d_dir2 * d_dir2_d_near2x, + d_error_d_dir2 * d_dir2_d_near2y, + ) + ] + grads[e2.p2_idx] = [ + ( + d_error_d_dir2 * d_dir2_d_far2x, + d_error_d_dir2 * d_dir2_d_far2y, + ) + ] + + return grads + + def get_visuals( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + ): + result = self._get_line_params(reg) + if result is None: + return None + + e1, e2, p1, p2, p3, p4 = result + + intersection = get_line_line_intersection( + (p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y), (p4.x, p4.y) + ) + + if intersection is None: + mx1 = (p1.x + p2.x) / 2 + my1 = (p1.y + p2.y) / 2 + mx2 = (p3.x + p4.x) / 2 + my2 = (p3.y + p4.y) / 2 + intersection = ((mx1 + mx2) / 2, (my1 + my2) / 2) + + ix, iy = intersection + sx, sy = to_screen((ix, iy)) + + far1, far2 = self._get_far_points(e1, e2, p1, p2, p3, p4, ix, iy) + + far1_screen = to_screen((far1.x, far1.y)) + far2_screen = to_screen((far2.x, far2.y)) + + anchor_ang = math.atan2(far1_screen[1] - sy, far1_screen[0] - sx) + other_ang = math.atan2(far2_screen[1] - sy, far2_screen[0] - sx) + + return sx, sy, anchor_ang, other_ang + + def get_label_pos( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + ): + visuals = self.get_visuals(reg, to_screen) + if visuals is None: + return None + + sx, sy, anchor_ang, other_ang = visuals + + cw_diff = normalize_angle(anchor_ang - other_ang) + + mid_angle = other_ang + cw_diff / 2 + radius = 28.0 + label_x = sx + math.cos(mid_angle) * radius + label_y = sy + math.sin(mid_angle) * radius + + return label_x, label_y + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + visuals = self.get_visuals(reg, to_screen) + if visuals is None: + return False + + cx, cy, anchor_ang, other_ang = visuals + + dist = math.hypot(sx - cx, sy - cy) + if abs(dist - ARC_RADIUS) > threshold: + return False + + click_ang = math.atan2(sy - cy, sx - cx) + + ccw_diff = normalize_angle(other_ang - anchor_ang) + + click_from_anchor = normalize_angle(click_ang - anchor_ang) + + return click_from_anchor <= ccw_diff + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + visuals = self.get_visuals(registry, to_screen) + if visuals is None: + return + + sx, sy, anchor_ang, other_ang = visuals + + ctx.save() + ctx.set_line_width(1.5) + + ctx.new_sub_path() + ctx.arc(sx, sy, ARC_RADIUS, anchor_ang, other_ang) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + + label = self._format_value() + "°" + ext = ctx.text_extents(label) + + ccw_diff = normalize_angle(other_ang - anchor_ang) + + mid = anchor_ang + ccw_diff / 2 + label_x = sx + math.cos(mid) * LABEL_RADIUS + label_y = sy + math.sin(mid) * LABEL_RADIUS + + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + elif is_hovered: + ctx.set_source_rgba(1.0, 0.95, 0.85, 0.9) + elif self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.6, 0.6, 0.9) + elif self.status == ConstraintStatus.ERROR: + ctx.set_source_rgba(1.0, 0.8, 0.8, 0.9) + elif self.status == ConstraintStatus.EXPRESSION_BASED: + ctx.set_source_rgba(1.0, 0.9, 0.7, 0.9) + else: + ctx.set_source_rgba(1, 1, 1, 0.8) + + bg_x = label_x - ext.width / 2 - 4 + bg_y = label_y - ext.height / 2 - 4 + ctx.rectangle(bg_x, bg_y, ext.width + 8, ext.height + 8) + ctx.fill() + ctx.new_path() + + if self.status in ( + ConstraintStatus.ERROR, + ConstraintStatus.CONFLICTING, + ): + ctx.set_source_rgb(0.8, 0.0, 0.0) + else: + ctx.set_source_rgb(0, 0, 0.5) + + ctx.move_to(label_x - ext.width / 2, label_y + ext.height / 2 - 2) + ctx.show_text(label) + ctx.new_path() + + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/aspect_ratio.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/aspect_ratio.py new file mode 100644 index 000000000..55f69e0f2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/aspect_ratio.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.types import Point + +from ..entities import Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class AspectRatioConstraint(Constraint): + """Enforces that distance(p1, p2) / distance(p3, p4) equals ratio.""" + + def __init__( + self, + p1: EntityID, + p2: EntityID, + p3: EntityID, + p4: EntityID, + ratio: float, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + self.p3: EntityID = p3 + self.p4: EntityID = p4 + self.ratio = ratio + + @classmethod + def get_type_key(cls) -> str: + return "aspect_ratio" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) != 2: + return False + if sketch is None: + return False + e1 = sketch.registry.get_entity(selection.entity_ids[0]) + e2 = sketch.registry.get_entity(selection.entity_ids[1]) + return isinstance(e1, Line) and isinstance(e2, Line) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Aspect Ratio") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return f"{self.get_type_name()} {self.ratio:.2f}" + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained segments.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + if p1 and p2: + return _("From {} to {}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "AspectRatioConstraint", + "p1": self.p1, + "p2": self.p2, + "p3": self.p3, + "p4": self.p4, + "ratio": self.ratio, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> AspectRatioConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + p3=data["p3"], + p4=data["p4"], + ratio=data["ratio"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + dist1 = math.hypot(pt2.x - pt1.x, pt2.y - pt1.y) + + pt3 = reg.get_point(self.p3) + pt4 = reg.get_point(self.p4) + dist2 = math.hypot(pt4.x - pt3.x, pt4.y - pt3.y) + + if dist2 < 1e-9: + return dist1 + return dist1 - dist2 * self.ratio + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + pt3 = reg.get_point(self.p3) + pt4 = reg.get_point(self.p4) + + dx1 = pt2.x - pt1.x + dy1 = pt2.y - pt1.y + dist1 = math.hypot(dx1, dy1) + + dx2 = pt4.x - pt3.x + dy2 = pt4.y - pt3.y + dist2 = math.hypot(dx2, dy2) + + grad = {} + + def add(pid, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] + cx, cy = grad[pid][0] + grad[pid][0] = (cx + gx, cy + gy) + + if dist1 > 1e-9: + u1x, u1y = dx1 / dist1, dy1 / dist1 + add(self.p1, -u1x, -u1y) + add(self.p2, u1x, u1y) + + if dist2 > 1e-9: + u2x, u2y = dx2 / dist2, dy2 / dist2 + add(self.p3, self.ratio * u2x, self.ratio * u2y) + add(self.p4, -self.ratio * u2x, -self.ratio * u2y) + + return grad + + def _get_icon_pos( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + ) -> Point | None: + """Calculates the screen position of the constraint icon.""" + p_ids1 = {self.p1, self.p2} + p_ids2 = {self.p3, self.p4} + junction_ids = p_ids1.intersection(p_ids2) + + if len(junction_ids) == 1: + # Position near the common junction point + junction_id = junction_ids.pop() + p_junc = reg.get_point(junction_id) + + p_other1_id = self.p1 if self.p2 == junction_id else self.p2 + p_other2_id = self.p3 if self.p4 == junction_id else self.p4 + p_other1 = reg.get_point(p_other1_id) + p_other2 = reg.get_point(p_other2_id) + + # Calculate vectors in screen space for a consistent offset + s_junc = to_screen((p_junc.x, p_junc.y)) + s_other1 = to_screen((p_other1.x, p_other1.y)) + s_other2 = to_screen((p_other2.x, p_other2.y)) + + sv1 = (s_other1[0] - s_junc[0], s_other1[1] - s_junc[1]) + sv2 = (s_other2[0] - s_junc[0], s_other2[1] - s_junc[1]) + slen1 = math.hypot(sv1[0], sv1[1]) + slen2 = math.hypot(sv2[0], sv2[1]) + + if slen1 < 1e-9 or slen2 < 1e-9: + return s_junc + + su1 = (sv1[0] / slen1, sv1[1] / slen1) + su2 = (sv2[0] / slen2, sv2[1] / slen2) + + # External angle bisector direction in screen space + s_bisector = (-(su1[0] + su2[0]), -(su1[1] + su2[1])) + len_sb = math.hypot(s_bisector[0], s_bisector[1]) + + if len_sb < 1e-9: + # Fallback for parallel/opposite vectors + s_bisector = (-su1[1], su1[0]) + len_sb = 1.0 + + su_bisector = (s_bisector[0] / len_sb, s_bisector[1] / len_sb) + + offset = 18.0 # screen pixels + return ( + s_junc[0] + offset * su_bisector[0], + s_junc[1] + offset * su_bisector[1], + ) + else: + # Fallback for non-adjoining lines: position at center of midpoints + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + p3 = reg.get_point(self.p3) + p4 = reg.get_point(self.p4) + m1_x, m1_y = (p1.x + p2.x) / 2.0, (p1.y + p2.y) / 2.0 + m2_x, m2_y = (p3.x + p4.x) / 2.0, (p3.y + p4.y) / 2.0 + center_mx, center_my = (m1_x + m2_x) / 2.0, (m1_y + m2_y) / 2.0 + return to_screen((center_mx, center_my)) + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + icon_pos = self._get_icon_pos(reg, to_screen) + if icon_pos: + cx, cy = icon_pos + return math.hypot(sx - cx, sy - cy) < threshold + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + icon_pos = self._get_icon_pos(registry, to_screen) + if not icon_pos: + return + + cx, cy = icon_pos + + ctx.save() + + icon_size = 16.0 + + # Draw a circular underlay for selection, similar to other icons + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + # Use a slightly larger radius for the glow effect + ctx.arc(cx, cy, icon_size / 2.0 + 4.0, 0, 2 * math.pi) + ctx.fill() + + if self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.2, 0.2, 0.5) + ctx.arc(cx, cy, icon_size / 2.0 + 4.0, 0, 2 * math.pi) + ctx.fill() + + # Translate to the icon's anchor point for easier drawing + ctx.translate(cx, cy) + + hs = icon_size / 2.0 + + ctx.set_line_width(2.0) + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + ctx.set_line_join(cairo.LINE_JOIN_ROUND) + + ctx.new_path() + + # Top-right corner bracket (L-shape pointing into the corner) + ctx.move_to(hs * 0.4, hs) + ctx.line_to(hs, hs) + ctx.line_to(hs, hs * 0.4) + + # Bottom-left corner bracket + ctx.move_to(-hs * 0.4, -hs) + ctx.line_to(-hs, -hs) + ctx.line_to(-hs, -hs * 0.4) + + # Set color and draw the icon + self._set_color(ctx, is_hovered) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/base.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/base.py new file mode 100644 index 000000000..c12c0b5df --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/base.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum, auto +from locale import format_string +from typing import ( + TYPE_CHECKING, + Any, +) + +from raygeo.geo.types import Point + +from rayforge.core.expression import safe_evaluate + +from ..types import EntityID + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class ConstraintStatus(Enum): + """Represents the validation status of a constraint.""" + + VALID = auto() + EXPRESSION_BASED = auto() + ERROR = auto() + CONFLICTING = auto() + + +class Constraint: + """Base class for all geometric constraints.""" + + # These attributes are expected on dimensional constraints + value: float = 0.0 + expression: str | None = None + status: ConstraintStatus = ConstraintStatus.VALID + user_visible: bool = True + + def __init__(self, user_visible: bool = True): + self.user_visible = user_visible + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + """ + Returns True if this constraint can be applied to the current + selection. + Subclasses should override this method. + """ + return False + + @classmethod + def get_type_key(cls) -> str | None: + """ + Returns the string key used to identify this constraint type. + Returns None for constraints that cannot be created by users. + """ + return None + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + raise NotImplementedError() + + def targets_segment( + self, p1: EntityID, p2: EntityID, entity_id: EntityID | None + ) -> bool: + """ + Returns True if this constraint restricts the length/distance of the + segment defined by points (p1, p2) or the given entity_id. + """ + return False + + def error( + self, reg: EntityRegistry, params: ParameterContext + ) -> float | tuple[float, ...] | list[float]: + """Calculates the error of the constraint.""" + return 0.0 + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + """ + Calculates the partial derivatives (Jacobian entries) of the error. + Returns a map: point_id -> list of (d_error/dx, d_error/dy). + The list length matches the number of scalar errors returned by + error(). + """ + return {} + + def constrains_radius( + self, registry: EntityRegistry, entity_id: EntityID + ) -> bool: + """ + Returns True if this constraint explicitly defines or links the + radius/length of the specified entity. + Used by the Solver to determine visual feedback (green color). + The registry is provided to allow checking related point status. + """ + return False + + def to_dict(self) -> dict[str, Any]: + """Serializes the constraint to a dictionary.""" + return {} # Default for non-serializable constraints like Drag + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + """Checks if the constraint's visual representation is hit.""" + return False + + def _set_color(self, ctx: cairo.Context, is_hovered: bool) -> None: + """ + Sets the standard drawing color for constraints based on hover and + status. + """ + if is_hovered: + ctx.set_source_rgb(1.0, 0.8, 0.0) # Yellow for hover + elif self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgb(1.0, 0.2, 0.2) # Red for conflicting + elif self.status == ConstraintStatus.ERROR: + ctx.set_source_rgb(1.0, 0.2, 0.2) # Red for error + elif self.status == ConstraintStatus.EXPRESSION_BASED: + ctx.set_source_rgb(1.0, 0.6, 0.0) # Orange for expression + else: # VALID + ctx.set_source_rgb(0.0, 0.6, 0.0) # Green for valid + + def _draw_selection_underlay( + self, ctx: cairo.Context, width_scale: float = 3.0 + ) -> None: + """Draws a semi-transparent blue underlay for the current path.""" + ctx.save() + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + ctx.set_line_width(ctx.get_line_width() * width_scale) + ctx.stroke_preserve() + ctx.restore() + + def _draw_conflict_underlay( + self, ctx: cairo.Context, width_scale: float = 3.5 + ) -> None: + """Draws a semi-transparent red underlay for conflicting items.""" + ctx.save() + ctx.set_source_rgba(1.0, 0.2, 0.2, 0.5) + ctx.set_line_width(ctx.get_line_width() * width_scale) + ctx.stroke_preserve() + ctx.restore() + + def _format_value(self) -> str: + """Helper to format the value string for constraints.""" + return f"{float(self.value):.1f}" + + def get_title(self) -> str: + """ + Returns a human-readable title for this constraint. + Subclasses should override to include the value. + """ + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """ + Returns a human-readable subtitle describing the constrained entities. + Subclasses should override to provide meaningful descriptions. + """ + return "" + + def _format_coord(self, x: float, y: float) -> str: + """Formats coordinates respecting the user's locale.""" + return format_string("%.1f/%.1f", (x, y), grouping=True) + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + """ + Draws the visual representation of the constraint on the canvas. + Default implementation does nothing. + """ + + def update_from_context(self, context: dict[str, Any]): + """ + Re-evaluates the expression (if present) using the provided context + and updates self.value and self.status. + """ + if self.expression: + try: + self.value = safe_evaluate(self.expression, context) + self.status = ConstraintStatus.EXPRESSION_BASED + except (ValueError, SyntaxError, NameError, TypeError): + # Keep old value on failure to prevent geometry collapse + # during invalid typing. Set status to error. + self.status = ConstraintStatus.ERROR + else: + # If there's no expression, it's just a valid numeric constraint. + self.status = ConstraintStatus.VALID + + def depends_on_points(self, point_ids: set[EntityID]) -> bool: + """Checks if the constraint references any of the given point IDs.""" + for attr in ["p1", "p2", "p3", "p4", "center", "point_id"]: + if hasattr(self, attr): + pid = getattr(self, attr) + if pid is not None and pid in point_ids: + return True + return False + + def depends_on_entities(self, entity_ids: set[EntityID]) -> bool: + """Checks if the constraint references any of the given entity IDs.""" + for attr in [ + "e1_id", + "e2_id", + "line_id", + "shape_id", + "entity_id", + "circle_id", + "axis", + ]: + if hasattr(self, attr): + eid = getattr(self, attr) + if eid is not None and eid in entity_ids: + return True + # Special case for lists of entities + for attr in ["entity_ids"]: + if hasattr(self, attr): + eids = getattr(self, attr) + if eids and not entity_ids.isdisjoint(eids): + return True + return False + + def get_draggable_point(self) -> EntityID | None: + """ + Returns a point ID that can be dragged to manipulate this constraint. + + Override in subclasses that represent point-like constraints. + Returns None by default. + """ + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/coincident.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/coincident.py new file mode 100644 index 000000000..fcd7e8a50 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/coincident.py @@ -0,0 +1,154 @@ +# constraints/coincident.py + +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class CoincidentConstraint(Constraint): + """Enforces two points are at the same location.""" + + def __init__(self, p1: EntityID, p2: EntityID, user_visible: bool = True): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + + @classmethod + def get_type_key(cls) -> str: + return "coincident" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + return len(selection.point_ids) == 2 and not selection.entity_ids + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Coincident") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a human-readable subtitle describing constrained points.""" + p1 = registry.get_point(self.p1) + if p1: + return _("At {}").format(self._format_coord(p1.x, p1.y)) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "CoincidentConstraint", + "p1": self.p1, + "p2": self.p2, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CoincidentConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> Point: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + return (pt1.x - pt2.x, pt1.y - pt2.y) + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + return { + self.p1: [(1.0, 0.0), (0.0, 1.0)], + self.p2: [(-1.0, 0.0), (0.0, -1.0)], + } + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + origin_id = getattr(element.sketch, "origin_id", -1) + pid_to_check = self.p1 + if self.p1 == origin_id and origin_id != -1: + pid_to_check = self.p2 + + pt_to_check = reg.get_point(pid_to_check) + if pt_to_check: + s_pt = to_screen((pt_to_check.x, pt_to_check.y)) + return math.hypot(sx - s_pt[0], sy - s_pt[1]) < threshold + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + # Determine which point to draw on. Prefer the non-origin point if + # one is the origin, to avoid clutter on the origin. + # We don't have access to sketch.origin_id directly here without the + # sketch object, but usually point 0 is origin. + # Assuming p1 or p2 exists in registry. + try: + p = registry.get_point(self.p1) + except IndexError: + try: + p = registry.get_point(self.p2) + except IndexError: + return + + # Heuristic: if p1 is fixed (likely origin), draw on p2. + if p.fixed and not registry.get_point(self.p2).fixed: + p = registry.get_point(self.p2) + + sx, sy = to_screen((p.x, p.y)) + + ctx.save() + ctx.set_line_width(1.5) + + radius = point_radius + 4 + ctx.new_sub_path() + ctx.arc(sx, sy, radius, 0, 2 * math.pi) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() + + def get_draggable_point(self) -> EntityID: + """Returns p1 as the draggable point for coincident constraints.""" + return self.p1 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/collinear.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/collinear.py new file mode 100644 index 000000000..ef0a85d04 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/collinear.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from ..types import EntityID +from .base import Constraint + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class CollinearConstraint(Constraint): + """Enforces that three points (p1, p2, p3) lie on the same line.""" + + def __init__( + self, + p1: EntityID, + p2: EntityID, + p3: EntityID, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + self.p3: EntityID = p3 + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + return len(selection.point_ids) == 3 and not selection.entity_ids + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Collinear") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a human-readable subtitle describing constrained points.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + p3 = registry.get_point(self.p3) + if p1 and p2 and p3: + return _("{}, {}, {}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + self._format_coord(p3.x, p3.y), + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "CollinearConstraint", + "p1": self.p1, + "p2": self.p2, + "p3": self.p3, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CollinearConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + p3=data["p3"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + pt3 = reg.get_point(self.p3) + # Cross product of (p2 - p1) and (p3 - p1) + return (pt2.x - pt1.x) * (pt3.y - pt1.y) - (pt2.y - pt1.y) * ( + pt3.x - pt1.x + ) + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + p3 = reg.get_point(self.p3) + + # E = (p2x - p1x) * (p3y - p1y) - (p2y - p1y) * (p3x - p1x) + # dE/dp1x = -(p3y - p1y) + (p2y - p1y) = p2y - p3y + # dE/dp1y = -(p2x - p1x) + (p3x - p1x) = p3x - p2x + # dE/dp2x = (p3y - p1y) + # dE/dp2y = -(p3x - p1x) + # dE/dp3x = -(p2y - p1y) + # dE/dp3y = (p2x - p1x) + + return { + self.p1: [(p2.y - p3.y, p3.x - p2.x)], + self.p2: [(p3.y - p1.y, -(p3.x - p1.x))], + self.p3: [(-(p2.y - p1.y), p2.x - p1.x)], + } diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/diameter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/diameter.py new file mode 100644 index 000000000..300635dab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/diameter.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from ..entities import Circle +from ..types import EntityID +from .base import Constraint, ConstraintStatus +from .radius import RadiusConstraint + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class DiameterConstraint(Constraint): + """Enforces the diameter of a Circle.""" + + def __init__( + self, + circle_id: EntityID, + value: str | float, + expression: str | None = None, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.circle_id: EntityID = circle_id + + if expression is not None: + self.expression = expression + self.value = float(value) + elif isinstance(value, str): + self.expression = value + self.value = 0.0 + else: + self.expression = None + self.value = float(value) + + @classmethod + def get_type_key(cls) -> str: + return "diameter" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) != 1: + return False + if sketch is None: + return False + entity = sketch.registry.get_entity(selection.entity_ids[0]) + return isinstance(entity, Circle) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Diameter") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return f"{self.get_type_name()} {self._format_value()}" + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a human-readable subtitle describing constrained entity.""" + entity = registry.get_entity(self.circle_id) + if isinstance(entity, Circle): + center = registry.get_point(entity.center_idx) + if center: + return _("Circle at {}").format( + self._format_coord(center.x, center.y) + ) + return "" + + def targets_segment( + self, p1: EntityID, p2: EntityID, entity_id: EntityID | None + ) -> bool: + return entity_id is not None and self.circle_id == entity_id + + def to_dict(self) -> dict[str, Any]: + data = { + "type": "DiameterConstraint", + "circle_id": self.circle_id, + "value": self.value, + "user_visible": self.user_visible, + } + if self.expression: + data["expression"] = self.expression + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DiameterConstraint: + return cls( + circle_id=data["circle_id"], + value=data["value"], + expression=data.get("expression"), + user_visible=data.get("user_visible", True), + ) + + def constrains_radius( + self, registry: EntityRegistry, entity_id: EntityID + ) -> bool: + return self.circle_id == entity_id + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + circle_entity = reg.get_entity(self.circle_id) + + if not isinstance(circle_entity, Circle): + return 0.0 + + center = reg.get_point(circle_entity.center_idx) + radius_pt = reg.get_point(circle_entity.radius_pt_idx) + target_diameter = self.value + + curr_r = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + return 2 * curr_r - target_diameter + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + entity = reg.get_entity(self.circle_id) + if isinstance(entity, Circle): + c = reg.get_point(entity.center_idx) + p = reg.get_point(entity.radius_pt_idx) + dx, dy = p.x - c.x, p.y - c.y + dist = math.hypot(dx, dy) + + ux, uy = 1.0, 0.0 # Default if points are coincident + if dist > 1e-9: + ux, uy = dx / dist, dy / dist + + # Error = 2*r - d. d(2r)/dp = 2 * u + return { + entity.radius_pt_idx: [(2 * ux, 2 * uy)], + entity.center_idx: [(-2 * ux, -2 * uy)], + } + return {} + + def get_label_pos( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + ): + # Delegate to RadiusConstraint's logic as it is identical + temp_radius_constr = RadiusConstraint(self.circle_id, 0) + return temp_radius_constr.get_label_pos(reg, to_screen, element) + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + pos_data = self.get_label_pos(reg, to_screen, element) + if pos_data: + label_sx, label_sy, _, _ = pos_data + + # Check if click is within label rectangle area + # Label is drawn with background rectangle at label position + # Use conservative size to match test expectations + label_width = 20.0 + label_height = 20.0 + half_w = label_width / 2.0 + half_h = label_height / 2.0 + + # Rectangle bounds (with padding as in renderer) + x_min = label_sx - half_w - 4.0 + x_max = label_sx + half_w + 4.0 + y_min = label_sy - half_h - 4.0 + y_max = label_sy + half_h + 4.0 + + return x_min <= sx <= x_max and y_min <= sy <= y_max + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + p0 = to_screen((0, 0)) + p1 = to_screen((1, 0)) + scale = math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + if scale < 1e-9: + scale = 1.0 + + # Logic from RadiusConstraint.get_label_pos adapted here + entity = registry.get_entity(self.circle_id) + if not isinstance(entity, Circle): + return + + center = registry.get_point(entity.center_idx) + radius_pt = registry.get_point(entity.radius_pt_idx) + if not (center and radius_pt): + return + + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + mid_angle = math.atan2(radius_pt.y - center.y, radius_pt.x - center.x) + + if radius == 0.0: + return + + label_dist_screen = 20.0 # Pixels + label_dist_model = label_dist_screen / scale + total_dist_model = radius + label_dist_model + + label_mx = center.x + total_dist_model * math.cos(mid_angle) + label_my = center.y + total_dist_model * math.sin(mid_angle) + sx, sy = to_screen((label_mx, label_my)) + + arc_mid_mx = center.x + radius * math.cos(mid_angle) + arc_mid_my = center.y + radius * math.sin(mid_angle) + arc_mid_sx, arc_mid_sy = to_screen((arc_mid_mx, arc_mid_my)) + + label = f"Ø{self._format_value()}" + ext = ctx.text_extents(label) + + ctx.save() + # Set background color based on selection, hover, and status + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) # Blue selection + elif is_hovered: + ctx.set_source_rgba(1.0, 0.95, 0.85, 0.9) # Light yellow hover + elif self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.6, 0.6, 0.9) # Red background + elif self.status == ConstraintStatus.ERROR: + ctx.set_source_rgba(1.0, 0.8, 0.8, 0.9) # Light red background + elif self.status == ConstraintStatus.EXPRESSION_BASED: + ctx.set_source_rgba(1.0, 0.9, 0.7, 0.9) # Light orange background + else: # VALID + ctx.set_source_rgba(1, 1, 1, 0.8) # Default white background + + bg_x = sx - ext.width / 2 - 4 + bg_y = sy - ext.height / 2 - 4 + ctx.rectangle(bg_x, bg_y, ext.width + 8, ext.height + 8) + ctx.fill() + ctx.new_path() + + # Set text color based on status + if self.status in ( + ConstraintStatus.ERROR, + ConstraintStatus.CONFLICTING, + ): + ctx.set_source_rgb(0.8, 0.0, 0.0) # Red text for error/conflict + else: + ctx.set_source_rgb(0, 0, 0.5) # Dark blue otherwise + + ctx.move_to(sx - ext.width / 2, sy + ext.height / 2 - 2) + ctx.show_text(label) + + ctx.set_line_width(1) + ctx.set_dash([4, 4]) + ctx.move_to(sx, sy) + ctx.line_to(arc_mid_sx, arc_mid_sy) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/distance.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/distance.py new file mode 100644 index 000000000..80dc6846e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/distance.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, +) + +from raygeo.geo.shape.line import get_line_segment_closest_point +from raygeo.geo.types import Point + +from ..entities import Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class DistanceConstraint(Constraint): + """Enforces distance between two points.""" + + def __init__( + self, + p1: EntityID, + p2: EntityID, + value: str | float, + expression: str | None = None, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + + if expression is not None: + self.expression = expression + self.value = float(value) + elif isinstance(value, str): + self.expression = value + self.value = 0.0 + else: + self.expression = None + self.value = float(value) + + @classmethod + def get_type_key(cls) -> str: + return "dist" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if len(selection.point_ids) == 2 and not selection.entity_ids: + return True + if selection.point_ids: + return False + if sketch is None: + return False + from ..entities import Line + + lines = [ + sketch.registry.get_entity(eid) + for eid in selection.entity_ids + if isinstance(sketch.registry.get_entity(eid), Line) + ] + return len(lines) == 1 and len(lines) == len(selection.entity_ids) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Distance") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return f"{self.get_type_name()} {self._format_value()}" + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a subtitle describing the constrained points.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + if p1 and p2: + return _("From {} to {}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + ) + return "" + + def targets_segment( + self, p1: EntityID, p2: EntityID, entity_id: EntityID | None + ) -> bool: + return {self.p1, self.p2} == {p1, p2} + + def to_dict(self) -> dict[str, Any]: + data = { + "type": "DistanceConstraint", + "p1": self.p1, + "p2": self.p2, + "value": self.value, + "user_visible": self.user_visible, + } + if self.expression: + data["expression"] = self.expression + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DistanceConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + value=data["value"], + expression=data.get("expression"), + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + # We use self.value which is cached/updated via update_from_context + target = self.value + dist = math.hypot(pt2.x - pt1.x, pt2.y - pt1.y) + return dist - target + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + dx = pt2.x - pt1.x + dy = pt2.y - pt1.y + dist = math.hypot(dx, dy) + + ux, uy = 1.0, 0.0 # Default if points are coincident + if dist > 1e-9: + # Gradient is the unit vector + ux, uy = dx / dist, dy / dist + + return { + self.p1: [(-ux, -uy)], + self.p2: [(ux, uy)], + } + + def get_label_pos( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + ): + """Calculates screen position for distance constraint label.""" + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + if not (p1 and p2): + return None + + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + mx = (s1[0] + s2[0]) / 2 + my = (s1[1] + s2[1]) / 2 + + return mx, my + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + + has_geometry = False + if p1 and p2: + entities = reg.entities or [] + for entity in entities: + if isinstance(entity, Line) and { + entity.p1_idx, + entity.p2_idx, + } == { + self.p1, + self.p2, + }: + has_geometry = True + break + + if not has_geometry: + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + + _, _, dist_sq = get_line_segment_closest_point(s1, s2, sx, sy) + + if dist_sq < threshold**2: + return True + + pos_data = self.get_label_pos(reg, to_screen, element) + if pos_data: + label_sx, label_sy = pos_data + + label_width = 20.0 + label_height = 20.0 + half_w = label_width / 2.0 + half_h = label_height / 2.0 + + x_min = label_sx - half_w - 4.0 + x_max = label_sx + half_w + 4.0 + y_min = label_sy - half_h - 4.0 + y_max = label_sy + half_h + 4.0 + + return x_min <= sx <= x_max and y_min <= sy <= y_max + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + from ..entities import Line + + try: + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + except IndexError: + return + + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + mx, my = (s1[0] + s2[0]) / 2, (s1[1] + s2[1]) / 2 + + label = self._format_value() + ext = ctx.text_extents(label) + + ctx.save() + # Set background color based on selection, hover, and status + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) # Blue selection + elif is_hovered: + ctx.set_source_rgba(1.0, 0.95, 0.85, 0.9) # Light yellow hover + elif self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.6, 0.6, 0.9) # Red background + elif self.status == ConstraintStatus.ERROR: + ctx.set_source_rgba(1.0, 0.8, 0.8, 0.9) # Light red background + elif self.status == ConstraintStatus.EXPRESSION_BASED: + ctx.set_source_rgba(1.0, 0.9, 0.7, 0.9) # Light orange background + else: # VALID + ctx.set_source_rgba(1, 1, 1, 0.8) # Default white background + + # Draw label background + bg_x = mx - ext.width / 2 - 4 + bg_y = my - ext.height / 2 - 4 + ctx.rectangle(bg_x, bg_y, ext.width + 8, ext.height + 8) + ctx.fill() + ctx.new_path() + + # Set text color based on status + if self.status in ( + ConstraintStatus.ERROR, + ConstraintStatus.CONFLICTING, + ): + ctx.set_source_rgb(0.8, 0.0, 0.0) # Red text for error/conflict + else: + ctx.set_source_rgb(0, 0, 0.5) # Dark blue otherwise + + ctx.move_to(mx - ext.width / 2, my + ext.height / 2 - 2) + ctx.show_text(label) + ctx.new_path() + + # Draw Dash Line - only if no solid line entity connects these points + has_geometry = False + entities = registry.entities or [] + for entity in entities: + if isinstance(entity, Line) and {entity.p1_idx, entity.p2_idx} == { + self.p1, + self.p2, + }: + has_geometry = True + break + + if not has_geometry: + ctx.set_line_width(1) + ctx.set_dash([4, 4]) + ctx.move_to(s1[0], s1[1]) + ctx.line_to(s2[0], s2[1]) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/drag.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/drag.py new file mode 100644 index 000000000..425d086dc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/drag.py @@ -0,0 +1,56 @@ +# constraints/drag.py + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point + +from ..types import EntityID +from .base import Constraint + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class DragConstraint(Constraint): + """ + A transient constraint used only during interaction. + It pulls a point toward a target (mouse) coordinate. + """ + + def __init__( + self, + point_id: EntityID, + target_x: float, + target_y: float, + weight: float = 0.1, + user_visible: bool = True, + ): + self.point_id = point_id + self.target_x = target_x + self.target_y = target_y + self.weight = weight + super().__init__(user_visible=user_visible) + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + return False + + def error(self, reg: EntityRegistry, params: ParameterContext) -> Point: + p = reg.get_point(self.point_id) + err_x = (p.x - self.target_x) * self.weight + err_y = (p.y - self.target_y) * self.weight + return err_x, err_y + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + return { + self.point_id: [(self.weight, 0.0), (0.0, self.weight)], + } diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/equal_distance.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/equal_distance.py new file mode 100644 index 000000000..0ba50edde --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/equal_distance.py @@ -0,0 +1,237 @@ +# constraints/equal_distance.py + +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from ..entities import Arc, Circle, Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class EqualDistanceConstraint(Constraint): + """Enforces that distance(p1, p2) equals distance(p3, p4).""" + + def __init__( + self, + p1: EntityID, + p2: EntityID, + p3: EntityID, + p4: EntityID, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + self.p3: EntityID = p3 + self.p4: EntityID = p4 + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) < 2: + return False + if sketch is None: + return False + for eid in selection.entity_ids: + entity = sketch.registry.get_entity(eid) + if not isinstance(entity, (Line, Arc, Circle)): + return False + return True + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Equal Distance") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained segments.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + p3 = registry.get_point(self.p3) + p4 = registry.get_point(self.p4) + if p1 and p2 and p3 and p4: + return _("{}-{} and {}-{}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + self._format_coord(p3.x, p3.y), + self._format_coord(p4.x, p4.y), + ) + return "" + + def targets_segment( + self, p1: EntityID, p2: EntityID, entity_id: EntityID | None + ) -> bool: + target = {p1, p2} + return target == {self.p1, self.p2} or target == {self.p3, self.p4} + + def to_dict(self) -> dict[str, Any]: + return { + "type": "EqualDistanceConstraint", + "p1": self.p1, + "p2": self.p2, + "p3": self.p3, + "p4": self.p4, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> EqualDistanceConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + p3=data["p3"], + p4=data["p4"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + dist1 = math.hypot(pt2.x - pt1.x, pt2.y - pt1.y) + + pt3 = reg.get_point(self.p3) + pt4 = reg.get_point(self.p4) + dist2 = math.hypot(pt4.x - pt3.x, pt4.y - pt3.y) + + return dist1 - dist2 + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + pt3 = reg.get_point(self.p3) + pt4 = reg.get_point(self.p4) + + dx1 = pt2.x - pt1.x + dy1 = pt2.y - pt1.y + dist1 = math.hypot(dx1, dy1) + + dx2 = pt4.x - pt3.x + dy2 = pt4.y - pt3.y + dist2 = math.hypot(dx2, dy2) + + grad: dict[EntityID, list[Point]] = {} + + def add(pid, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] + cx, cy = grad[pid][0] + grad[pid][0] = (cx + gx, cy + gy) + + if dist1 > 1e-9: + u1x, u1y = dx1 / dist1, dy1 / dist1 + add(self.p1, -u1x, -u1y) + add(self.p2, u1x, u1y) + + if dist2 > 1e-9: + u2x, u2y = dx2 / dist2, dy2 / dist2 + # Subtracting dist2, so flip signs + add(self.p3, u2x, u2y) # -(-u2) + add(self.p4, -u2x, -u2y) # -(u2) + + return grad + + def _get_symbol_pos( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + ): + """Calculates screen position for the equality symbol.""" + pa = reg.get_point(self.p1) + pb = reg.get_point(self.p2) + pc = reg.get_point(self.p3) + pd = reg.get_point(self.p4) + if not (pa and pb and pc and pd): + return None + + mid1_x = (pa.x + pb.x) / 2.0 + mid1_y = (pa.y + pb.y) / 2.0 + mid2_x = (pc.x + pd.x) / 2.0 + mid2_y = (pc.y + pd.y) / 2.0 + + sym_x = (mid1_x + mid2_x) / 2.0 + sym_y = (mid1_y + mid2_y) / 2.0 + + dx = pb.x - pa.x + dy = pb.y - pa.y + tangent_angle = math.atan2(dy, dx) + normal_angle = tangent_angle - (math.pi / 2.0) + + p0 = to_screen((0, 0)) + p1 = to_screen((1, 0)) + scale = math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + if scale < 1e-9: + scale = 1.0 + + offset_dist_model = 15.0 / scale + final_x = sym_x + offset_dist_model * math.cos(normal_angle) + final_y = sym_y + offset_dist_model * math.sin(normal_angle) + return to_screen((final_x, final_y)) + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + pos = self._get_symbol_pos(reg, to_screen) + if pos: + return math.hypot(sx - pos[0], sy - pos[1]) < threshold + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + pos = self._get_symbol_pos(registry, to_screen) + if not pos: + return + + sx, sy = pos + ctx.save() + + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + ctx.arc(sx, sy, 10, 0, 2 * math.pi) + ctx.fill() + + if self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.2, 0.2, 0.5) + ctx.arc(sx, sy, 12, 0, 2 * math.pi) + ctx.fill() + + self._set_color(ctx, is_hovered) + ctx.set_font_size(16) + ext = ctx.text_extents("=") + ctx.move_to(sx - ext.width / 2, sy + ext.height / 2) + ctx.show_text("=") + ctx.restore() + ctx.new_path() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/equal_length.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/equal_length.py new file mode 100644 index 000000000..43015110b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/equal_length.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +from raygeo.geo.types import Point + +from ..entities import Arc, Circle, Ellipse, Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class EqualLengthConstraint(Constraint): + """ + Enforces that all entities in a set have the same characteristic length. + - Line: Length + - Arc/Circle: Radius + - Ellipse: Both radii (X and Y) + """ + + def __init__(self, entity_ids: list[EntityID], user_visible: bool = True): + super().__init__(user_visible=user_visible) + self.entity_ids = entity_ids + + @classmethod + def get_type_key(cls) -> str: + return "equal" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) < 2: + return False + if sketch is None: + return False + entities = [ + sketch.registry.get_entity(eid) for eid in selection.entity_ids + ] + return all( + isinstance(e, (Line, Arc, Circle, Ellipse)) and e is not None + for e in entities + ) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Equal Length") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained entities.""" + if len(self.entity_ids) >= 2: + return _("{} entities").format(len(self.entity_ids)) + return "" + + def targets_segment( + self, p1: EntityID, p2: EntityID, entity_id: EntityID | None + ) -> bool: + if entity_id is not None: + return entity_id in self.entity_ids + return False + + def to_dict(self) -> dict[str, Any]: + return { + "type": "EqualLengthConstraint", + "entity_ids": self.entity_ids, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> EqualLengthConstraint: + return cls( + entity_ids=data["entity_ids"], + user_visible=data.get("user_visible", True), + ) + + def constrains_radius( + self, registry: EntityRegistry, entity_id: EntityID + ) -> bool: + return entity_id in self.entity_ids + + @staticmethod + def _get_length_pairs(entity): + """Returns point-index pairs defining the entity's length(s).""" + if isinstance(entity, Line): + return [(entity.p1_idx, entity.p2_idx)] + elif isinstance(entity, Arc): + return [(entity.center_idx, entity.start_idx)] + elif isinstance(entity, Circle): + return [(entity.center_idx, entity.radius_pt_idx)] + elif isinstance(entity, Ellipse): + return [ + (entity.center_idx, entity.radius_x_pt_idx), + (entity.center_idx, entity.radius_y_pt_idx), + ] + return [] + + @staticmethod + def _pair_dist(pa_idx, pb_idx, reg): + pa = reg.get_point(pa_idx) + pb = reg.get_point(pb_idx) + return math.hypot(pb.x - pa.x, pb.y - pa.y) + + def _get_length(self, entity, reg: EntityRegistry) -> float: + if isinstance(entity, Line): + p1 = reg.get_point(entity.p1_idx) + p2 = reg.get_point(entity.p2_idx) + return math.hypot(p2.x - p1.x, p2.y - p1.y) + elif isinstance(entity, Arc): + c = reg.get_point(entity.center_idx) + s = reg.get_point(entity.start_idx) + return math.hypot(s.x - c.x, s.y - c.y) + elif isinstance(entity, Circle): + c = reg.get_point(entity.center_idx) + r = reg.get_point(entity.radius_pt_idx) + return math.hypot(r.x - c.x, r.y - c.y) + elif isinstance(entity, Ellipse): + rx, ry = entity._get_radii(reg) + return (rx + ry) / 2.0 + return 0.0 + + def error( + self, reg: EntityRegistry, params: ParameterContext + ) -> list[float]: + if len(self.entity_ids) < 2: + return [] + + entities = [reg.get_entity(eid) for eid in self.entity_ids] + if any(e is None for e in entities): + return [] + + errors = [] + base = entities[0] + base_pairs = self._get_length_pairs(base) + base_lens = [self._pair_dist(pa, pb, reg) for pa, pb in base_pairs] + + for i in range(1, len(entities)): + ent = entities[i] + ent_pairs = self._get_length_pairs(ent) + ent_lens = [self._pair_dist(pa, pb, reg) for pa, pb in ent_pairs] + + n = max(len(base_pairs), len(ent_pairs)) + for j in range(n): + bl = base_lens[j] if j < len(base_lens) else base_lens[0] + el = ent_lens[j] if j < len(ent_lens) else ent_lens[0] + errors.append(el - bl) + return errors + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + if len(self.entity_ids) < 2: + return {} + + entities = [ + cast(Line | Arc | Circle | Ellipse, reg.get_entity(eid)) + for eid in self.entity_ids + ] + if any(e is None for e in entities): + return {} + + base_pairs = self._get_length_pairs(entities[0]) + num_residuals = 0 + for i in range(1, len(entities)): + ent_pairs = self._get_length_pairs(entities[i]) + num_residuals += max(len(base_pairs), len(ent_pairs)) + + grad = {} + + def add_grad(pid, r_idx, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] * num_residuals + curr = list(grad[pid]) + ox, oy = curr[r_idx] + curr[r_idx] = (ox + gx, oy + gy) + grad[pid] = curr + + row = 0 + for i in range(1, len(entities)): + ent = entities[i] + ent_pairs = self._get_length_pairs(ent) + n = max(len(base_pairs), len(ent_pairs)) + + for j in range(n): + bp = base_pairs[j] if j < len(base_pairs) else base_pairs[0] + ep = ent_pairs[j] if j < len(ent_pairs) else ent_pairs[0] + + b_pta = reg.get_point(bp[0]) + b_ptb = reg.get_point(bp[1]) + b_dx = b_ptb.x - b_pta.x + b_dy = b_ptb.y - b_pta.y + b_len = math.hypot(b_dx, b_dy) + if b_len > 1e-9: + b_ux, b_uy = b_dx / b_len, b_dy / b_len + else: + b_ux, b_uy = 0.0, 0.0 + + e_pta = reg.get_point(ep[0]) + e_ptb = reg.get_point(ep[1]) + e_dx = e_ptb.x - e_pta.x + e_dy = e_ptb.y - e_pta.y + e_len = math.hypot(e_dx, e_dy) + if e_len > 1e-9: + e_ux, e_uy = e_dx / e_len, e_dy / e_len + else: + e_ux, e_uy = 0.0, 0.0 + + add_grad(bp[0], row, b_ux, b_uy) + add_grad(bp[1], row, -b_ux, -b_uy) + add_grad(ep[0], row, -e_ux, -e_uy) + add_grad(ep[1], row, e_ux, e_uy) + + row += 1 + + return grad + + def _get_symbol_pos( + self, + entity, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + ): + """Calculates screen pos for an equality symbol on an entity.""" + # 1. Get anchor point (mid_x, mid_y) and normal_angle in MODEL space + mid_x, mid_y, normal_angle = 0.0, 0.0, 0.0 + + if isinstance(entity, Line): + p1 = reg.get_point(entity.p1_idx) + p2 = reg.get_point(entity.p2_idx) + mid_x = (p1.x + p2.x) / 2.0 + mid_y = (p1.y + p2.y) / 2.0 + tangent_angle = math.atan2(p2.y - p1.y, p2.x - p1.x) + normal_angle = tangent_angle - (math.pi / 2.0) + elif isinstance(entity, (Arc, Circle, Ellipse)): + midpoint = entity.get_midpoint(reg) + if not midpoint: + return None + mid_x, mid_y = midpoint + center = reg.get_point(entity.center_idx) + normal_angle = math.atan2(mid_y - center.y, mid_x - center.x) + + # Estimate scale from transform + p0 = to_screen((0, 0)) + p1 = to_screen((1, 0)) + scale = math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + if scale < 1e-9: + scale = 1.0 + + offset_dist_model = 15.0 / scale + final_x = mid_x + offset_dist_model * math.cos(normal_angle) + final_y = mid_y + offset_dist_model * math.sin(normal_angle) + return to_screen((final_x, final_y)) + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + for entity_id in self.entity_ids: + entity = reg.get_entity(entity_id) + if not entity: + continue + pos = self._get_symbol_pos(entity, reg, to_screen) + if pos: + esx, esy = pos + if math.hypot(sx - esx, sy - esy) < threshold: + return True + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + for entity_id in self.entity_ids: + entity = registry.get_entity(entity_id) + if not entity: + continue + + pos = self._get_symbol_pos(entity, registry, to_screen) + if not pos: + continue + + sx, sy = pos + ctx.save() + + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + ctx.arc(sx, sy, 10, 0, 2 * math.pi) + ctx.fill() + + if self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.2, 0.2, 0.5) + ctx.arc(sx, sy, 12, 0, 2 * math.pi) + ctx.fill() + + self._set_color(ctx, is_hovered) + ctx.set_font_size(16) + ext = ctx.text_extents("=") + ctx.move_to(sx - ext.width / 2, sy + ext.height / 2) + ctx.show_text("=") + ctx.restore() + ctx.new_path() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/horizontal.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/horizontal.py new file mode 100644 index 000000000..e77de5004 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/horizontal.py @@ -0,0 +1,161 @@ +# constraints/horizontal.py + +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from ..entities import Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class HorizontalConstraint(Constraint): + """Enforces two points have the same Y coordinate.""" + + def __init__(self, p1: EntityID, p2: EntityID, user_visible: bool = True): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + + @classmethod + def get_type_key(cls) -> str: + return "horiz" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if len(selection.point_ids) == 2 and not selection.entity_ids: + return True + if selection.point_ids: + return False + if sketch is None: + return False + + lines = [ + sketch.registry.get_entity(eid) + for eid in selection.entity_ids + if isinstance(sketch.registry.get_entity(eid), Line) + ] + return len(lines) > 0 and len(lines) == len(selection.entity_ids) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Horizontal") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a subtitle describing the constrained points.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + if p1 and p2: + return _("From {} to {}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "HorizontalConstraint", + "p1": self.p1, + "p2": self.p2, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> HorizontalConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + return reg.get_point(self.p1).y - reg.get_point(self.p2).y + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + return { + self.p1: [(0.0, 1.0)], + self.p2: [(0.0, -1.0)], + } + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + if p1 and p2: + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + + t = 0.2 + mx = s1[0] + (s2[0] - s1[0]) * t + my = s1[1] + (s2[1] - s1[1]) * t + cx = mx + cy = my - 10 + return math.hypot(sx - cx, sy - cy) < threshold + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + try: + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + except IndexError: + return + + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + + t_marker = 0.2 + mx = s1[0] + (s2[0] - s1[0]) * t_marker + my = s1[1] + (s2[1] - s1[1]) * t_marker + + size = 8 + ctx.save() + ctx.set_line_width(2) + ctx.move_to(mx - size, my - 10) + ctx.line_to(mx + size, my - 10) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/parallelogram.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/parallelogram.py new file mode 100644 index 000000000..eaf9d79b2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/parallelogram.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo.types import Point + +from ..types import EntityID +from .base import Constraint + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class ParallelogramConstraint(Constraint): + """Enforces four points form a parallelogram.""" + + def __init__( + self, + p_origin: EntityID, + p_width: EntityID, + p_height: EntityID, + p4: EntityID, + user_visible: bool = False, + ): + super().__init__(user_visible=user_visible) + self.p_origin: EntityID = p_origin + self.p_width: EntityID = p_width + self.p_height: EntityID = p_height + self.p4: EntityID = p4 + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + return len(selection.point_ids) == 4 and not selection.entity_ids + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Parallelogram") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a human-readable subtitle describing constrained points.""" + p_origin = registry.get_point(self.p_origin) + if p_origin: + return _("Origin at {}").format( + self._format_coord(p_origin.x, p_origin.y) + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "ParallelogramConstraint", + "p_origin": self.p_origin, + "p_width": self.p_width, + "p_height": self.p_height, + "p4": self.p4, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ParallelogramConstraint: + return cls( + p_origin=data["p_origin"], + p_width=data["p_width"], + p_height=data["p_height"], + p4=data["p4"], + user_visible=data.get("user_visible", False), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> Point: + """Returns the difference between vectors (p_width-p_origin) and + (p4-p_height). + """ + p_origin = reg.get_point(self.p_origin) + p_width = reg.get_point(self.p_width) + p_height = reg.get_point(self.p_height) + p4 = reg.get_point(self.p4) + + v1_x = p_width.x - p_origin.x + v1_y = p_width.y - p_origin.y + + v2_x = p4.x - p_height.x + v2_y = p4.y - p_height.y + + return (v1_x - v2_x, v1_y - v2_y) + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + """Returns the gradient of the error with respect to each point.""" + return { + self.p_origin: [(-1.0, 0.0), (0.0, -1.0)], + self.p_width: [(1.0, 0.0), (0.0, 1.0)], + self.p_height: [(1.0, 0.0), (0.0, 1.0)], + self.p4: [(-1.0, 0.0), (0.0, -1.0)], + } diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/perpendicular.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/perpendicular.py new file mode 100644 index 000000000..929b7fed7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/perpendicular.py @@ -0,0 +1,548 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +from raygeo.geo.shape.circle import ( + get_circle_circle_intersections, +) +from raygeo.geo.shape.line import ( + get_line_line_intersection, + is_point_on_line_segment, +) +from raygeo.geo.types import Point + +from ..entities import Arc, Circle, Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + import cairo + + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class PerpendicularConstraint(Constraint): + """ + Enforces perpendicularity between two entities. + - Line/Line: Vectors are at 90 degrees. + - Line/Arc, Line/Circle: Line passes through the shape's center. + - Arc/Arc, Arc/Circle, Circle/Circle: Shapes intersect at a right angle. + """ + + def __init__( + self, e1_id: EntityID, e2_id: EntityID, user_visible: bool = True + ): + super().__init__(user_visible=user_visible) + self.e1_id: EntityID = e1_id + self.e2_id: EntityID = e2_id + + @classmethod + def get_type_key(cls) -> str: + return "perp" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) != 2: + return False + if sketch is None: + return False + e1 = sketch.registry.get_entity(selection.entity_ids[0]) + e2 = sketch.registry.get_entity(selection.entity_ids[1]) + if not isinstance(e1, (Line, Arc, Circle)): + return False + return isinstance(e2, (Line, Arc, Circle)) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Perpendicular") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained entities.""" + e1 = registry.get_entity(self.e1_id) + e2 = registry.get_entity(self.e2_id) + if e1 and e2: + return _("Between {} and {}").format( + type(e1).__name__, type(e2).__name__ + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "PerpendicularConstraint", + "e1_id": self.e1_id, + "e2_id": self.e2_id, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PerpendicularConstraint: + return cls( + e1_id=data["e1_id"], + e2_id=data["e2_id"], + user_visible=data.get("user_visible", True), + ) + + def _get_radius_sq( + self, shape: Arc | Circle, reg: EntityRegistry + ) -> float: + """Helper to get squared radius of an Arc or Circle.""" + center = reg.get_point(shape.center_idx) + if isinstance(shape, Arc): + start = reg.get_point(shape.start_idx) + return (start.x - center.x) ** 2 + (start.y - center.y) ** 2 + elif isinstance(shape, Circle): + radius_pt = reg.get_point(shape.radius_pt_idx) + return (radius_pt.x - center.x) ** 2 + ( + radius_pt.y - center.y + ) ** 2 + return 0.0 + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + e1 = reg.get_entity(self.e1_id) + e2 = reg.get_entity(self.e2_id) + + if e1 is None or e2 is None: + return 0.0 + + # Case 1: Line-Line + if isinstance(e1, Line) and isinstance(e2, Line): + p1 = reg.get_point(e1.p1_idx) + p2 = reg.get_point(e1.p2_idx) + p3 = reg.get_point(e2.p1_idx) + p4 = reg.get_point(e2.p2_idx) + + dx1, dy1 = p2.x - p1.x, p2.y - p1.y + dx2, dy2 = p4.x - p3.x, p4.y - p3.y + # Dot product + return dx1 * dx2 + dy1 * dy2 + + # Case 2: Line-Arc/Circle + line, shape = None, None + if isinstance(e1, Line) and isinstance(e2, (Arc, Circle)): + line, shape = e1, e2 + elif isinstance(e2, Line) and isinstance(e1, (Arc, Circle)): + line, shape = e2, e1 + + if line and shape: + # Constraint: Line must pass through the shape's center + # (i.e., line points and center are collinear) + lp1 = reg.get_point(line.p1_idx) + lp2 = reg.get_point(line.p2_idx) + # This cast is safe due to the isinstance checks above + shape_with_center = cast(Arc | Circle, shape) + center = reg.get_point(shape_with_center.center_idx) + # Cross product (lp2-lp1) x (center-lp1) + return (lp2.x - lp1.x) * (center.y - lp1.y) - ( + center.x - lp1.x + ) * (lp2.y - lp1.y) + + # Case 3: Arc/Circle - Arc/Circle + shape1, shape2 = None, None + if isinstance(e1, (Arc, Circle)) and isinstance(e2, (Arc, Circle)): + shape1, shape2 = e1, e2 + + if shape1 and shape2: + # Constraint: The circles intersect at a right angle. + # Geometric property: r1^2 + r2^2 = d^2, where d is distance + # between centers. + c1 = reg.get_point(shape1.center_idx) + c2 = reg.get_point(shape2.center_idx) + + r1_sq = self._get_radius_sq(shape1, reg) + r2_sq = self._get_radius_sq(shape2, reg) + + dist_centers_sq = (c2.x - c1.x) ** 2 + (c2.y - c1.y) ** 2 + + return r1_sq + r2_sq - dist_centers_sq + + return 0.0 + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + e1 = reg.get_entity(self.e1_id) + e2 = reg.get_entity(self.e2_id) + grad = {} + + if e1 is None or e2 is None: + return {} + + def add_grad(pid, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] + cx, cy = grad[pid][0] + grad[pid][0] = (cx + gx, cy + gy) + + if isinstance(e1, Line) and isinstance(e2, Line): + p1 = reg.get_point(e1.p1_idx) + p2 = reg.get_point(e1.p2_idx) + p3 = reg.get_point(e2.p1_idx) + p4 = reg.get_point(e2.p2_idx) + dx1, dy1 = p2.x - p1.x, p2.y - p1.y + dx2, dy2 = p4.x - p3.x, p4.y - p3.y + # E = dx1*dx2 + dy1*dy2 + add_grad(e1.p1_idx, -dx2, -dy2) + add_grad(e1.p2_idx, dx2, dy2) + add_grad(e2.p1_idx, -dx1, -dy1) + add_grad(e2.p2_idx, dx1, dy1) + + elif (isinstance(e1, Line) and isinstance(e2, (Arc, Circle))) or ( + isinstance(e2, Line) and isinstance(e1, (Arc, Circle)) + ): + line, shape = (e1, e2) if isinstance(e1, Line) else (e2, e1) + # Ensure type safety + if isinstance(line, Line) and isinstance(shape, (Arc, Circle)): + l1 = reg.get_point(line.p1_idx) + l2 = reg.get_point(line.p2_idx) + c = reg.get_point(shape.center_idx) + dx = l2.x - l1.x + dy = l2.y - l1.y + # E = dx*(c.y - l1.y) - (c.x - l1.x)*dy + add_grad(shape.center_idx, -dy, dx) + add_grad(line.p1_idx, dy - (c.y - l1.y), -dx + (c.x - l1.x)) + add_grad(line.p2_idx, c.y - l1.y, -(c.x - l1.x)) + + elif isinstance(e1, (Arc, Circle)) and isinstance(e2, (Arc, Circle)): + # r1^2 + r2^2 - dist_sq + s1, s2 = e1, e2 + c1 = reg.get_point(s1.center_idx) + c2 = reg.get_point(s2.center_idx) + + # Center 1 part: + # -d(dist)/dc1 + d(r1)/dc1 (if arc/circle) + d_dist_x = 2 * (c2.x - c1.x) + d_dist_y = 2 * (c2.y - c1.y) + + # R1 derivs + dr1_c_x, dr1_c_y = 0.0, 0.0 + if isinstance(s1, Arc): + p1 = reg.get_point(s1.start_idx) + dr1_c_x = -2 * (p1.x - c1.x) + dr1_c_y = -2 * (p1.y - c1.y) + add_grad(s1.start_idx, 2 * (p1.x - c1.x), 2 * (p1.y - c1.y)) + elif isinstance(s1, Circle): + p1 = reg.get_point(s1.radius_pt_idx) + dr1_c_x = -2 * (p1.x - c1.x) + dr1_c_y = -2 * (p1.y - c1.y) + add_grad( + s1.radius_pt_idx, + 2 * (p1.x - c1.x), + 2 * (p1.y - c1.y), + ) + + # R2 derivs + dr2_c_x, dr2_c_y = 0.0, 0.0 + if isinstance(s2, Arc): + p2 = reg.get_point(s2.start_idx) + dr2_c_x = -2 * (p2.x - c2.x) + dr2_c_y = -2 * (p2.y - c2.y) + add_grad(s2.start_idx, 2 * (p2.x - c2.x), 2 * (p2.y - c2.y)) + elif isinstance(s2, Circle): + p2 = reg.get_point(s2.radius_pt_idx) + dr2_c_x = -2 * (p2.x - c2.x) + dr2_c_y = -2 * (p2.y - c2.y) + add_grad( + s2.radius_pt_idx, + 2 * (p2.x - c2.x), + 2 * (p2.y - c2.y), + ) + + # Dist term for C1: -(-2(c2-c1)) = 2(c2-c1) = d_dist_x + add_grad(s1.center_idx, d_dist_x + dr1_c_x, d_dist_y + dr1_c_y) + # Dist term for C2: -(2(c2-c1)) = -d_dist_x + add_grad(s2.center_idx, -d_dist_x + dr2_c_x, -d_dist_y + dr2_c_y) + + return grad + + def get_visuals( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + ) -> tuple[float, float, float | None, float | None] | None: + """Calculates screen position and angles for visualization.""" + e1 = reg.get_entity(self.e1_id) + e2 = reg.get_entity(self.e2_id) + if not (e1 and e2): + return None + + # --- Case 1: Line-Line --- + if isinstance(e1, Line) and isinstance(e2, Line): + return self._get_line_line_visuals(e1, e2, reg, to_screen) + + # --- Case 2: Line-Shape --- + line, shape = (e1, e2) if isinstance(e1, Line) else (e2, e1) + if isinstance(line, Line) and isinstance(shape, (Arc, Circle)): + return self._get_line_shape_visuals(line, shape, reg, to_screen) + + # --- Case 3: Shape-Shape --- + if isinstance(e1, (Arc, Circle)) and isinstance(e2, (Arc, Circle)): + return self._get_shape_shape_visuals(e1, e2, reg, to_screen) + + return None + + def _get_line_line_visuals(self, l1, l2, reg, to_screen): + p1 = reg.get_point(l1.p1_idx) + p2 = reg.get_point(l1.p2_idx) + p3 = reg.get_point(l2.p1_idx) + p4 = reg.get_point(l2.p2_idx) + pt = get_line_line_intersection( + (p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y), (p4.x, p4.y) + ) + if not pt: + m1x, m1y = (p1.x + p2.x) / 2, (p1.y + p2.y) / 2 + m2x, m2y = (p3.x + p4.x) / 2, (p3.y + p4.y) / 2 + pt = ((m1x + m2x) / 2, (m1y + m2y) / 2) + + ix, iy = pt + sx, sy = to_screen((ix, iy)) + s_p1, s_p2 = to_screen((p1.x, p1.y)), to_screen((p2.x, p2.y)) + s_p3, s_p4 = to_screen((p3.x, p3.y)), to_screen((p4.x, p4.y)) + + def dist_sq(a, b): + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + + v1p = ( + s_p1 if dist_sq(s_p1, (sx, sy)) > dist_sq(s_p2, (sx, sy)) else s_p2 + ) + v2p = ( + s_p3 if dist_sq(s_p3, (sx, sy)) > dist_sq(s_p4, (sx, sy)) else s_p4 + ) + ang1 = math.atan2(v1p[1] - sy, v1p[0] - sx) + ang2 = math.atan2(v2p[1] - sy, v2p[0] - sx) + return sx, sy, ang1, ang2 + + def _get_line_shape_visuals(self, line, shape, reg, to_screen): + center = reg.get_point(shape.center_idx) + lp1, lp2 = reg.get_point(line.p1_idx), reg.get_point(line.p2_idx) + dxL, dyL = lp2.x - lp1.x, lp2.y - lp1.y + if math.hypot(dxL, dyL) < 1e-9: + return None + ux, uy = dxL / math.hypot(dxL, dyL), dyL / math.hypot(dxL, dyL) + + if isinstance(shape, Arc): + sp = reg.get_point(shape.start_idx) + else: + sp = reg.get_point(shape.radius_pt_idx) + radius = math.hypot(sp.x - center.x, sp.y - center.y) + + ix1, iy1 = center.x + radius * ux, center.y + radius * uy + ix2, iy2 = center.x - radius * ux, center.y - radius * uy + + valid_points = [] + for ix, iy in [(ix1, iy1), (ix2, iy2)]: + on_line = is_point_on_line_segment( + (ix, iy), (lp1.x, lp1.y), (lp2.x, lp2.y) + ) + on_arc = True + if isinstance(shape, Arc): + angle = math.atan2(iy - center.y, ix - center.x) + on_arc = shape.is_angle_within_sweep(angle, reg) + if on_line and on_arc: + valid_points.append((ix, iy)) + + if valid_points: + best_pt = valid_points[0] + if len(valid_points) > 1: + lmx, lmy = (lp1.x + lp2.x) / 2, (lp1.y + lp2.y) / 2 + d1 = (best_pt[0] - lmx) ** 2 + (best_pt[1] - lmy) ** 2 + d2 = (valid_points[1][0] - lmx) ** 2 + ( + valid_points[1][1] - lmy + ) ** 2 + if d2 < d1: + best_pt = valid_points[1] + sx, sy = to_screen(best_pt) + return sx, sy, None, None + + sx, sy = to_screen((center.x, center.y)) + return sx, sy, None, None + + def _get_shape_shape_visuals(self, s1, s2, reg, to_screen): + c1, c2 = reg.get_point(s1.center_idx), reg.get_point(s2.center_idx) + r1 = math.hypot( + reg.get_point( + s1.start_idx if isinstance(s1, Arc) else s1.radius_pt_idx + ).x + - c1.x, + reg.get_point( + s1.start_idx if isinstance(s1, Arc) else s1.radius_pt_idx + ).y + - c1.y, + ) + r2 = math.hypot( + reg.get_point( + s2.start_idx if isinstance(s2, Arc) else s2.radius_pt_idx + ).x + - c2.x, + reg.get_point( + s2.start_idx if isinstance(s2, Arc) else s2.radius_pt_idx + ).y + - c2.y, + ) + + intersections = get_circle_circle_intersections( + (c1.x, c1.y), r1, (c2.x, c2.y), r2 + ) + if not intersections: + return None + + valid_points = [] + for ix, iy in intersections: + on_s1 = ( + s1.is_angle_within_sweep(math.atan2(iy - c1.y, ix - c1.x), reg) + if isinstance(s1, Arc) + else True + ) + on_s2 = ( + s2.is_angle_within_sweep(math.atan2(iy - c2.y, ix - c2.x), reg) + if isinstance(s2, Arc) + else True + ) + if on_s1 and on_s2: + valid_points.append((ix, iy)) + + if not valid_points: + sx, sy = to_screen(intersections[0]) + return sx, sy, None, None + + best_pt = valid_points[0] + if len(valid_points) > 1: + m1 = s1.get_midpoint(reg) if isinstance(s1, Arc) else None + m2 = s2.get_midpoint(reg) if isinstance(s2, Arc) else None + if m1 and m2: + d1 = ( + (valid_points[0][0] - m1[0]) ** 2 + + (valid_points[0][1] - m1[1]) ** 2 + + (valid_points[0][0] - m2[0]) ** 2 + + (valid_points[0][1] - m2[1]) ** 2 + ) + d2 = ( + (valid_points[1][0] - m1[0]) ** 2 + + (valid_points[1][1] - m1[1]) ** 2 + + (valid_points[1][0] - m2[0]) ** 2 + + (valid_points[1][1] - m2[1]) ** 2 + ) + if d2 < d1: + best_pt = valid_points[1] + + sx, sy = to_screen(best_pt) + return sx, sy, None, None + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + data = self.get_visuals(reg, to_screen) + if not data: + return False + + cx, cy, ang1, ang2 = data + + if ang1 is not None and ang2 is not None: + # Case 1: Line-Line (Angles are provided) + # The marker is an arc with a dot. We hit-test the specific dot + # location. + visual_radius = 16.0 # Matches renderer.py + diff = ang2 - ang1 + + # Normalize angle difference to [-pi, pi] + while diff <= -math.pi: + diff += 2 * math.pi + while diff > math.pi: + diff -= 2 * math.pi + + mid_angle = ang1 + diff / 2 + # The dot is drawn at 0.6 * radius + target_x = cx + math.cos(mid_angle) * visual_radius * 0.6 + target_y = cy + math.sin(mid_angle) * visual_radius * 0.6 + + return math.hypot(sx - target_x, sy - target_y) < threshold + else: + # Case 2: Line-Arc / Arc-Arc (Box style) + # The marker is a square box at the intersection/anchor. + return math.hypot(sx - cx, sy - cy) < threshold + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + data = self.get_visuals(registry, to_screen) + if not data: + return + + sx, sy, ang1, ang2 = data + + ctx.save() + ctx.set_line_width(1.5) + + if ang1 is not None and ang2 is not None: + radius = 16.0 + diff = ang2 - ang1 + while diff <= -math.pi: + diff += 2 * math.pi + while diff > math.pi: + diff -= 2 * math.pi + + ctx.new_sub_path() + if diff > 0: + ctx.arc(sx, sy, radius, ang1, ang2) + else: + ctx.arc_negative(sx, sy, radius, ang1, ang2) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + + # Dot + mid = ang1 + diff / 2 + dx = sx + math.cos(mid) * radius * 0.6 + dy = sy + math.sin(mid) * radius * 0.6 + ctx.new_sub_path() + ctx.arc(dx, dy, 2.0, 0, 2 * math.pi) + ctx.fill() + else: + sz = 8.0 + ctx.new_sub_path() + ctx.rectangle(sx - sz, sy - sz, sz * 2, sz * 2) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/point_on_line.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/point_on_line.py new file mode 100644 index 000000000..ca94d80b0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/point_on_line.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.types import Point + +from ..entities import Arc, Circle, Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class PointOnLineConstraint(Constraint): + """Enforces a point lies on the infinite geometry of a shape.""" + + def __init__( + self, point_id: EntityID, shape_id: EntityID, user_visible: bool = True + ): + super().__init__(user_visible=user_visible) + self.point_id: EntityID = point_id + self.shape_id: EntityID = shape_id + + @classmethod + def get_type_key(cls) -> str: + return "point_on_line" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if len(selection.point_ids) != 1 or len(selection.entity_ids) != 1: + return False + if sketch is None: + return False + entity = sketch.registry.get_entity(selection.entity_ids[0]) + if not isinstance(entity, (Line, Arc, Circle)): + return False + pid = selection.point_ids[0] + control_points = set() + if isinstance(entity, Line): + control_points = {entity.p1_idx, entity.p2_idx} + elif isinstance(entity, Arc): + control_points = { + entity.start_idx, + entity.end_idx, + entity.center_idx, + } + elif isinstance(entity, Circle): + control_points = { + entity.center_idx, + entity.radius_pt_idx, + } + return pid not in control_points + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Point on Line") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained entities.""" + pt = registry.get_point(self.point_id) + if pt: + return _("Point at {}").format(self._format_coord(pt.x, pt.y)) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "PointOnLineConstraint", + "point_id": self.point_id, + "shape_id": self.shape_id, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PointOnLineConstraint: + return cls( + point_id=data["point_id"], + shape_id=data["shape_id"], + user_visible=data.get("user_visible", True), + ) + + def constrains_radius( + self, registry: EntityRegistry, entity_id: EntityID + ) -> bool: + """ + If this constraint forces a point onto the target entity (circle/arc), + and that point is itself constrained, then the radius of the entity + is determined. + """ + if self.shape_id != entity_id: + return False + + try: + pt = registry.get_point(self.point_id) + return pt.constrained + except IndexError: + return False + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + pt = reg.get_point(self.point_id) + shape = reg.get_entity(self.shape_id) + + if shape is None: + return 0.0 + + if isinstance(shape, Line): + l1 = reg.get_point(shape.p1_idx) + l2 = reg.get_point(shape.p2_idx) + dx = l2.x - l1.x + dy = l2.y - l1.y + length = math.hypot(dx, dy) + if length < 1e-9: + return math.hypot(pt.x - l1.x, pt.y - l1.y) + + # Cross product (signed area) divided by length = distance + cross = (l2.x - l1.x) * (pt.y - l1.y) - (pt.x - l1.x) * ( + l2.y - l1.y + ) + return cross / length + + elif isinstance(shape, (Arc, Circle)): + center = reg.get_point(shape.center_idx) + radius = 0.0 + if isinstance(shape, Arc): + start = reg.get_point(shape.start_idx) + radius = math.hypot(start.x - center.x, start.y - center.y) + elif isinstance(shape, Circle): + radius_pt = reg.get_point(shape.radius_pt_idx) + radius = math.hypot( + radius_pt.x - center.x, radius_pt.y - center.y + ) + + dist_to_point = math.hypot(pt.x - center.x, pt.y - center.y) + return dist_to_point - radius + + return 0.0 + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + pt = reg.get_point(self.point_id) + shape = reg.get_entity(self.shape_id) + + if shape is None: + return {} + + grad = {} + + def add_grad(pid, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] + cx, cy = grad[pid][0] + grad[pid][0] = (cx + gx, cy + gy) + + if isinstance(shape, Line): + l1 = reg.get_point(shape.p1_idx) + l2 = reg.get_point(shape.p2_idx) + dx = l2.x - l1.x + dy = l2.y - l1.y + len_sq = dx * dx + dy * dy + + if len_sq < 1e-18: + d_pt_l1_x = pt.x - l1.x + d_pt_l1_y = pt.y - l1.y + dist = math.hypot(d_pt_l1_x, d_pt_l1_y) + if dist < 1e-9: + return {} + ux = d_pt_l1_x / dist + uy = d_pt_l1_y / dist + add_grad(self.point_id, ux, uy) + add_grad(shape.p1_idx, -ux, -uy) + return grad + + length = math.sqrt(len_sq) + inv_len = 1.0 / length + inv_len_sq = inv_len * inv_len + + # Normal vector to line + nx = -dy * inv_len + ny = dx * inv_len + + # Gradient for Point P is the normal vector + add_grad(self.point_id, nx, ny) + + err = self.error(reg, params) + + # Gradient for L2 + grad_l2_x = (pt.y - l1.y) * inv_len - err * dx * inv_len_sq + grad_l2_y = -(pt.x - l1.x) * inv_len - err * dy * inv_len_sq + add_grad(shape.p2_idx, grad_l2_x, grad_l2_y) + + # Gradient for L1 is -(grad_p + grad_l2) due to translation + # invariance + grad_l1_x = -nx - grad_l2_x + grad_l1_y = -ny - grad_l2_y + add_grad(shape.p1_idx, grad_l1_x, grad_l1_y) + return grad + + elif isinstance(shape, (Arc, Circle)): + center = reg.get_point(shape.center_idx) + dist_pc = math.hypot(pt.x - center.x, pt.y - center.y) + + ux, uy = 0.0, 0.0 + if dist_pc > 1e-9: + ux = (pt.x - center.x) / dist_pc + uy = (pt.y - center.y) / dist_pc + + add_grad(self.point_id, ux, uy) + + sp, rad_idx = (None, -1) + if isinstance(shape, Arc): + sp = reg.get_point(shape.start_idx) + rad_idx = shape.start_idx + else: # Circle + sp = reg.get_point(shape.radius_pt_idx) + rad_idx = shape.radius_pt_idx + + radius = math.hypot(sp.x - center.x, sp.y - center.y) + rux, ruy = 0.0, 0.0 + if radius > 1e-9: + rux = (sp.x - center.x) / radius + ruy = (sp.y - center.y) / radius + + add_grad(shape.center_idx, rux - ux, ruy - uy) + add_grad(rad_idx, -rux, -ruy) + + return grad + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + pt = reg.get_point(self.point_id) + if pt: + s_pt = to_screen((pt.x, pt.y)) + return math.hypot(sx - s_pt[0], sy - s_pt[1]) < threshold + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + from ..entities import TextBoxEntity + + # Hide constraint if its point is part of a text box + text_box_point_ids = set() + for entity in registry.entities: + if isinstance(entity, TextBoxEntity): + text_box_point_ids.update( + entity.get_all_frame_point_ids(registry) + ) + if self.point_id in text_box_point_ids: + return + + try: + p = registry.get_point(self.point_id) + except IndexError: + return + + sx, sy = to_screen((p.x, p.y)) + + ctx.save() + ctx.set_line_width(1.5) + + radius = point_radius + 4 + ctx.new_sub_path() + ctx.arc(sx, sy, radius, 0, 2 * math.pi) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() + + def get_draggable_point(self) -> EntityID: + """Returns the point that lies on the line/shape.""" + return self.point_id diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/radius.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/radius.py new file mode 100644 index 000000000..2b62e76b9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/radius.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.types import Point + +from ..entities import Arc, Circle +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class RadiusConstraint(Constraint): + """Enforces radius of an Arc or Circle.""" + + def __init__( + self, + entity_id: EntityID, + value: str | float, + expression: str | None = None, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.entity_id: EntityID = entity_id + + if expression is not None: + self.expression = expression + self.value = float(value) + elif isinstance(value, str): + self.expression = value + self.value = 0.0 + else: + self.expression = None + self.value = float(value) + + @classmethod + def get_type_key(cls) -> str: + return "radius" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) != 1: + return False + if sketch is None: + return False + entity = sketch.registry.get_entity(selection.entity_ids[0]) + return isinstance(entity, (Arc, Circle)) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Radius") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return f"{self.get_type_name()} {self._format_value()}" + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a human-readable subtitle describing constrained entity.""" + entity = registry.get_entity(self.entity_id) + if isinstance(entity, (Arc, Circle)): + center = registry.get_point(entity.center_idx) + if center: + return _("{} at {}").format( + type(entity).__name__, + self._format_coord(center.x, center.y), + ) + return "" + + def targets_segment( + self, p1: EntityID, p2: EntityID, entity_id: EntityID | None + ) -> bool: + return entity_id is not None and self.entity_id == entity_id + + def to_dict(self) -> dict[str, Any]: + data = { + "type": "RadiusConstraint", + "entity_id": self.entity_id, + "value": self.value, + "user_visible": self.user_visible, + } + if self.expression: + data["expression"] = self.expression + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RadiusConstraint: + return cls( + entity_id=data["entity_id"], + value=data["value"], + expression=data.get("expression"), + user_visible=data.get("user_visible", True), + ) + + def constrains_radius( + self, registry: EntityRegistry, entity_id: EntityID + ) -> bool: + return self.entity_id == entity_id + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + entity = reg.get_entity(self.entity_id) + if entity is None: + return 0.0 + + target = self.value + curr_r = 0.0 + + if isinstance(entity, Arc): + center = reg.get_point(entity.center_idx) + start = reg.get_point(entity.start_idx) + curr_r = math.hypot(start.x - center.x, start.y - center.y) + elif isinstance(entity, Circle): + center = reg.get_point(entity.center_idx) + radius_pt = reg.get_point(entity.radius_pt_idx) + curr_r = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + else: + return 0.0 + + return curr_r - target + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + entity = reg.get_entity(self.entity_id) + + # Type narrowing for Pylance + if not isinstance(entity, (Arc, Circle)): + return {} + + center_idx = entity.center_idx + c = reg.get_point(center_idx) + p, pt_idx = None, -1 + + if isinstance(entity, Arc): + pt_idx = entity.start_idx + p = reg.get_point(pt_idx) + else: # Circle + pt_idx = entity.radius_pt_idx + p = reg.get_point(pt_idx) + + if c and p: + dx, dy = p.x - c.x, p.y - c.y + dist = math.hypot(dx, dy) + + ux, uy = 1.0, 0.0 # Default if points are coincident + if dist > 1e-9: + ux, uy = dx / dist, dy / dist + + return { + pt_idx: [(ux, uy)], + center_idx: [(-ux, -uy)], + } + return {} + + def get_label_pos( + self, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + ): + """Calculates screen position for Radius/Diameter constraint labels.""" + entity = reg.get_entity(self.entity_id) + if not isinstance(entity, (Arc, Circle)): + return None + + center = reg.get_point(entity.center_idx) + if not center: + return None + + radius, mid_angle = 0.0, 0.0 + + if isinstance(entity, Arc): + start = reg.get_point(entity.start_idx) + if not start: + return None + radius = math.hypot(start.x - center.x, start.y - center.y) + midpoint = entity.get_midpoint(reg) + if not midpoint: + return None + mid_angle = math.atan2( + midpoint[1] - center.y, midpoint[0] - center.x + ) + + elif isinstance(entity, Circle): + radius_pt = reg.get_point(entity.radius_pt_idx) + if not radius_pt: + return None + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + mid_angle = math.atan2( + radius_pt.y - center.y, radius_pt.x - center.x + ) + + if radius == 0.0: + return None + + scale = 1.0 + if element and element.canvas: + scale_x, _ = element.canvas.get_view_scale() + scale = scale_x if scale_x > 1e-9 else 1.0 + elif element is None: + # Try to infer scale from to_screen if element is not passed + # This is a fallback + p0 = to_screen((0, 0)) + p1 = to_screen((1, 0)) + scale = math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + if scale < 1e-9: + scale = 1.0 + + label_dist = radius + 20 / scale + label_mx = center.x + label_dist * math.cos(mid_angle) + label_my = center.y + label_dist * math.sin(mid_angle) + label_sx, label_sy = to_screen((label_mx, label_my)) + + # Position on the arc for the leader line + arc_mid_mx = center.x + radius * math.cos(mid_angle) + arc_mid_my = center.y + radius * math.sin(mid_angle) + arc_mid_sx, arc_mid_sy = to_screen((arc_mid_mx, arc_mid_my)) + + return label_sx, label_sy, arc_mid_sx, arc_mid_sy + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + pos_data = self.get_label_pos(reg, to_screen, element) + if pos_data: + label_sx, label_sy, _, _ = pos_data + + # Check if click is within label rectangle area + # Label is drawn with background rectangle at label position + # Use conservative size to match test expectations + label_width = 20.0 + label_height = 20.0 + half_w = label_width / 2.0 + half_h = label_height / 2.0 + + # Rectangle bounds (with padding as in renderer) + x_min = label_sx - half_w - 4.0 + x_max = label_sx + half_w + 4.0 + y_min = label_sy - half_h - 4.0 + y_max = label_sy + half_h + 4.0 + + return x_min <= sx <= x_max and y_min <= sy <= y_max + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + pos_data = self.get_label_pos( + registry, + to_screen, + None, # Pass None as element, get_label_pos will infer scale + ) + if not pos_data: + return + sx, sy, arc_mid_sx, arc_mid_sy = pos_data + + label = f"R{self._format_value()}" + ext = ctx.text_extents(label) + + ctx.save() + # Set background color based on selection, hover, and status + if is_selected: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) # Blue selection + elif is_hovered: + ctx.set_source_rgba(1.0, 0.95, 0.85, 0.9) # Light yellow hover + elif self.status == ConstraintStatus.CONFLICTING: + ctx.set_source_rgba(1.0, 0.6, 0.6, 0.9) # Red background + elif self.status == ConstraintStatus.ERROR: + ctx.set_source_rgba(1.0, 0.8, 0.8, 0.9) # Light red background + elif self.status == ConstraintStatus.EXPRESSION_BASED: + ctx.set_source_rgba(1.0, 0.9, 0.7, 0.9) # Light orange background + else: # VALID + ctx.set_source_rgba(1, 1, 1, 0.8) # Default white background + + bg_x = sx - ext.width / 2 - 4 + bg_y = sy - ext.height / 2 - 4 + ctx.rectangle(bg_x, bg_y, ext.width + 8, ext.height + 8) + ctx.fill() + ctx.new_path() + + # Set text color based on status + if self.status in ( + ConstraintStatus.ERROR, + ConstraintStatus.CONFLICTING, + ): + ctx.set_source_rgb(0.8, 0.0, 0.0) # Red text for error/conflict + else: + ctx.set_source_rgb(0, 0, 0.5) # Dark blue otherwise + + ctx.move_to(sx - ext.width / 2, sy + ext.height / 2 - 2) + ctx.show_text(label) + + ctx.set_line_width(1) + ctx.set_dash([4, 4]) + ctx.move_to(sx, sy) + ctx.line_to(arc_mid_sx, arc_mid_sy) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/symmetry.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/symmetry.py new file mode 100644 index 000000000..fff47a8f1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/symmetry.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.types import Point + +from ..entities import Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + + +def draw_symmetry_arrows( + ctx: cairo.Context, + s1: Point, + s2: Point, +) -> None: + mx = (s1[0] + s2[0]) / 2.0 + my = (s1[1] + s2[1]) / 2.0 + angle = math.atan2(s2[1] - s1[1], s2[0] - s1[0]) + offset = 12.0 + + lx = mx - offset * math.cos(angle) + ly = my - offset * math.sin(angle) + + ctx.save() + ctx.translate(lx, ly) + ctx.rotate(angle) + ctx.move_to(-3, -4) + ctx.line_to(3, 0) + ctx.line_to(-3, 4) + ctx.restore() + + rx = mx + offset * math.cos(angle) + ry = my + offset * math.sin(angle) + + ctx.save() + ctx.translate(rx, ry) + ctx.rotate(angle) + ctx.move_to(3, -4) + ctx.line_to(-3, 0) + ctx.line_to(3, 4) + ctx.restore() + + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class SymmetryConstraint(Constraint): + """ + Enforces symmetry between two points (p1, p2) with respect to: + 1. A Center Point (Point Symmetry) + 2. An Axis Line (Line Symmetry) + """ + + def __init__( + self, + p1: EntityID, + p2: EntityID, + center: EntityID | None = None, + axis: EntityID | None = None, + user_visible: bool = True, + ): + super().__init__(user_visible=user_visible) + self.p1 = p1 + self.p2 = p2 + self.center = center + self.axis = axis + + @classmethod + def get_type_key(cls) -> str: + return "symmetry" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if len(selection.point_ids) == 3 and not selection.entity_ids: + return True + if len(selection.point_ids) == 2 and len(selection.entity_ids) == 1: + if sketch is None: + return False + entity = sketch.registry.get_entity(selection.entity_ids[0]) + return isinstance(entity, Line) + return False + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Symmetry") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a human-readable subtitle describing constrained points.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + if p1 and p2: + return _("From {} to {}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "SymmetryConstraint", + "p1": self.p1, + "p2": self.p2, + "center": self.center, + "axis": self.axis, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SymmetryConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + center=data.get("center"), + axis=data.get("axis"), + user_visible=data.get("user_visible", True), + ) + + def error( + self, reg: EntityRegistry, params: ParameterContext + ) -> list[float]: + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + + if self.center is not None: + # Case 1: Point Symmetry + # Constraint: Center is the midpoint of P1 and P2 + # (P1 + P2) / 2 = Center => P1 + P2 - 2*Center = 0 + s = reg.get_point(self.center) + return [ + (pt1.x + pt2.x) - 2 * s.x, + (pt1.y + pt2.y) - 2 * s.y, + ] + + elif self.axis is not None: + # Case 2: Line Symmetry + # Constraint A: The segment P1-P2 is perpendicular to the Axis Line + # Constraint B: The midpoint of P1-P2 lies on the Axis Line + line = reg.get_entity(self.axis) + if not isinstance(line, Line): + return [0.0, 0.0] + + l1 = reg.get_point(line.p1_idx) + l2 = reg.get_point(line.p2_idx) + + # Vector of the Axis Line + dx_l = l2.x - l1.x + dy_l = l2.y - l1.y + + # 1. Perpendicularity: Dot product (P2 - P1) . (L2 - L1) = 0 + dx_p = pt2.x - pt1.x + dy_p = pt2.y - pt1.y + err_perp = dx_p * dx_l + dy_p * dy_l + + # 2. Midpoint on Line: Cross product (Mid - L1) x (L2 - L1) = 0 + mx = (pt1.x + pt2.x) * 0.5 + my = (pt1.y + pt2.y) * 0.5 + err_collinear = (mx - l1.x) * dy_l - (my - l1.y) * dx_l + + return [err_perp, err_collinear] + + return [0.0, 0.0] + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + if self.center is not None: + # P1+P2 - 2C = 0. Safe to use dict literal as keys are distinct. + return { + self.p1: [(1, 0), (0, 1)], + self.p2: [(1, 0), (0, 1)], + self.center: [(-2, 0), (0, -2)], + } + elif self.axis is not None: + line = reg.get_entity(self.axis) + if not isinstance(line, Line): + return {} + + l1 = reg.get_point(line.p1_idx) + l2 = reg.get_point(line.p2_idx) + dxl = l2.x - l1.x + dyl = l2.y - l1.y + pt1 = reg.get_point(self.p1) + pt2 = reg.get_point(self.p2) + dxp = pt2.x - pt1.x + dyp = pt2.y - pt1.y + mx = (pt1.x + pt2.x) * 0.5 + my = (pt1.y + pt2.y) * 0.5 + + grad = {} + num_residuals = 2 + + def add(pid, row, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] * num_residuals + # Tuples are immutable, so we must replace it + px, py = grad[pid][row] + grad[pid][row] = (px + gx, py + gy) + + # Row 0: Perpendicularity: dxp*dxl + dyp*dyl + add(self.p1, 0, -dxl, -dyl) + add(self.p2, 0, dxl, dyl) + add(line.p1_idx, 0, -dxp, -dyp) + add(line.p2_idx, 0, dxp, dyp) + + # Row 1: Collinearity: (mx - l1x)*dyl - (my - l1y)*dxl + add(self.p1, 1, 0.5 * dyl, -0.5 * dxl) + add(self.p2, 1, 0.5 * dyl, -0.5 * dxl) + add(line.p1_idx, 1, my - l1.y - dyl, dxl - (mx - l1.x)) + add(line.p2_idx, 1, -(my - l1.y), mx - l1.x) + + return grad + + return {} + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + if not (p1 and p2): + return False + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + mx = (s1[0] + s2[0]) / 2.0 + my = (s1[1] + s2[1]) / 2.0 + angle = math.atan2(s2[1] - s1[1], s2[0] - s1[0]) + offset = 12.0 + lx = mx - offset * math.cos(angle) + ly = my - offset * math.sin(angle) + rx = mx + offset * math.cos(angle) + ry = my + offset * math.sin(angle) + if math.hypot(sx - lx, sy - ly) < threshold: + return True + return math.hypot(sx - rx, sy - ry) < threshold + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + try: + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + except IndexError: + return + + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + + ctx.save() + ctx.set_line_width(1.5) + ctx.new_sub_path() + + draw_symmetry_arrows(ctx, s1, s2) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/tangent.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/tangent.py new file mode 100644 index 000000000..566176670 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/tangent.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.shape.line import get_line_closest_point +from raygeo.geo.types import Point + +from ..entities import Arc, Circle, Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class TangentConstraint(Constraint): + """ + Enforces tangency between a Line and an Arc/Circle. + Logic: Distance from shape center to Line equals shape Radius. + """ + + def __init__( + self, line_id: EntityID, shape_id: EntityID, user_visible: bool = True + ): + super().__init__(user_visible=user_visible) + self.line_id: EntityID = line_id + self.shape_id: EntityID = shape_id + + @classmethod + def get_type_key(cls) -> str: + return "tangent" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if selection.point_ids or len(selection.entity_ids) != 2: + return False + if sketch is None: + return False + e1 = sketch.registry.get_entity(selection.entity_ids[0]) + e2 = sketch.registry.get_entity(selection.entity_ids[1]) + has_line = isinstance(e1, Line) or isinstance(e2, Line) + has_shape = isinstance(e1, (Arc, Circle)) or isinstance( + e2, (Arc, Circle) + ) + return has_line and has_shape + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Tangent") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns subtitle describing constrained entities.""" + line = registry.get_entity(self.line_id) + shape = registry.get_entity(self.shape_id) + if isinstance(line, Line) and isinstance(shape, (Arc, Circle)): + center = registry.get_point(shape.center_idx) + if center: + return _("Line to {} at {}").format( + type(shape).__name__, + self._format_coord(center.x, center.y), + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "TangentConstraint", + "line_id": self.line_id, + "shape_id": self.shape_id, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TangentConstraint: + return cls( + line_id=data["line_id"], + shape_id=data["shape_id"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + line = reg.get_entity(self.line_id) + shape = reg.get_entity(self.shape_id) + + if not isinstance(line, Line) or not isinstance(shape, (Arc, Circle)): + return 0.0 + + center = reg.get_point(shape.center_idx) + radius = 0.0 + if isinstance(shape, Arc): + start = reg.get_point(shape.start_idx) + radius = math.hypot(start.x - center.x, start.y - center.y) + elif isinstance(shape, Circle): + radius_pt = reg.get_point(shape.radius_pt_idx) + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + + lp1 = reg.get_point(line.p1_idx) + lp2 = reg.get_point(line.p2_idx) + line_dx = lp2.x - lp1.x + line_dy = lp2.y - lp1.y + line_len = math.hypot(line_dx, line_dy) + + if line_len < 1e-9: + dist_to_pt = math.hypot(lp1.x - center.x, lp1.y - center.y) + return dist_to_pt - radius + + cross_product = line_dx * (center.y - lp1.y) - line_dy * ( + center.x - lp1.x + ) + dist_val = abs(cross_product) / line_len + return dist_val - radius + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + line = reg.get_entity(self.line_id) + shape = reg.get_entity(self.shape_id) + grad = {} + + if not isinstance(line, Line) or not isinstance(shape, (Arc, Circle)): + return grad + + def add_grad(pid, gx, gy): + if pid not in grad: + grad[pid] = [(0.0, 0.0)] + cx, cy = grad[pid][0] + grad[pid][0] = (cx + gx, cy + gy) + + c = reg.get_point(shape.center_idx) + + # Part 1: Gradient of -radius + p_rad, p_rad_idx = (None, -1) + if isinstance(shape, Arc): + p_rad = reg.get_point(shape.start_idx) + p_rad_idx = shape.start_idx + elif isinstance(shape, Circle): + p_rad = reg.get_point(shape.radius_pt_idx) + p_rad_idx = shape.radius_pt_idx + if not (c and p_rad): + return {} + + rad_dx, rad_dy = p_rad.x - c.x, p_rad.y - c.y + radius = math.hypot(rad_dx, rad_dy) + rad_ux, rad_uy = (0.0, 0.0) + if radius > 1e-9: + rad_ux, rad_uy = rad_dx / radius, rad_dy / radius + + add_grad(p_rad_idx, -rad_ux, -rad_uy) + add_grad(shape.center_idx, rad_ux, rad_uy) + + # Part 2: Gradient of dist_to_line + lp1 = reg.get_point(line.p1_idx) + lp2 = reg.get_point(line.p2_idx) + line_dx = lp2.x - lp1.x + line_dy = lp2.y - lp1.y + len_sq = line_dx * line_dx + line_dy * line_dy + + if len_sq < 1e-18: + dist_c_l1 = math.hypot(c.x - lp1.x, c.y - lp1.y) + if dist_c_l1 < 1e-9: + return grad + ux = (c.x - lp1.x) / dist_c_l1 + uy = (c.y - lp1.y) / dist_c_l1 + add_grad(shape.center_idx, ux, uy) + add_grad(line.p1_idx, -ux, -uy) + return grad + + length = math.sqrt(len_sq) + inv_len = 1.0 / length + cross = line_dx * (c.y - lp1.y) - line_dy * (c.x - lp1.x) + sign = math.copysign(1.0, cross) + + # Start calculating gradient of signed distance from C to Line. + # This is equivalent to PointOnLineConstraint.gradient for point C. + nx = -line_dy * inv_len + ny = line_dx * inv_len + pol_err = cross * inv_len + inv_len_sq = inv_len * inv_len + + # grad_pol w.r.t center C + grad_pol_c_x, grad_pol_c_y = nx, ny + + # grad_pol w.r.t line point L2 + grad_pol_l2_x = ( + c.y - lp1.y + ) * inv_len - pol_err * line_dx * inv_len_sq + grad_pol_l2_y = ( + -(c.x - lp1.x) * inv_len - pol_err * line_dy * inv_len_sq + ) + + # grad_pol w.r.t line point L1 + grad_pol_l1_x = -grad_pol_c_x - grad_pol_l2_x + grad_pol_l1_y = -grad_pol_c_y - grad_pol_l2_y + + # Now combine with sign factor and radius gradients + add_grad(shape.center_idx, sign * grad_pol_c_x, sign * grad_pol_c_y) + add_grad(line.p1_idx, sign * grad_pol_l1_x, sign * grad_pol_l1_y) + add_grad(line.p2_idx, sign * grad_pol_l2_x, sign * grad_pol_l2_y) + + return grad + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + line = reg.get_entity(self.line_id) + shape = reg.get_entity(self.shape_id) + + if not ( + line + and shape + and isinstance(line, Line) + and isinstance(shape, (Arc, Circle)) + ): + return False + + p1 = reg.get_point(line.p1_idx) + p2 = reg.get_point(line.p2_idx) + center = reg.get_point(shape.center_idx) + + if not (p1 and p2 and center): + return False + + # Find closest point on infinite line from center + tangent_mx, tangent_my = get_line_closest_point( + (p1.x, p1.y), (p2.x, p2.y), center.x, center.y + ) + + # Convert to Screen Space first + sx_tangent, sy_tangent = to_screen((tangent_mx, tangent_my)) + sx_center, sy_center = to_screen((center.x, center.y)) + + # Calculate angle in screen space + angle = math.atan2(sy_tangent - sy_center, sx_tangent - sx_center) + + # Apply offset in screen pixels + offset = 15.0 + symbol_sx = sx_tangent + offset * math.cos(angle) + symbol_sy = sy_tangent + offset * math.sin(angle) + + return math.hypot(sx - symbol_sx, sy - symbol_sy) < threshold + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + line = registry.get_entity(self.line_id) + shape = registry.get_entity(self.shape_id) + + if not ( + line + and shape + and isinstance(line, Line) + and isinstance(shape, (Arc, Circle)) + ): + return + + p1 = registry.get_point(line.p1_idx) + p2 = registry.get_point(line.p2_idx) + center = registry.get_point(shape.center_idx) + + # Find closest point on infinite line from center (in model space) + tangent_mx, tangent_my = get_line_closest_point( + (p1.x, p1.y), (p2.x, p2.y), center.x, center.y + ) + + sx_tangent, sy_tangent = to_screen((tangent_mx, tangent_my)) + sx_center, sy_center = to_screen((center.x, center.y)) + + angle = math.atan2(sy_tangent - sy_center, sx_tangent - sx_center) + + offset = 15.0 + sx = sx_tangent + offset * math.cos(angle) + sy = sy_tangent + offset * math.sin(angle) + + ctx.save() + ctx.set_line_width(1.5) + + ctx.translate(sx, sy) + ctx.rotate(angle + math.pi / 2.0) + + radius = 6.0 + + ctx.new_sub_path() + ctx.arc( + 0, + -radius, + radius, + math.pi / 2 - math.pi / 3, + math.pi / 2 + math.pi / 3, + ) + ctx.move_to(-radius * 1.2, 0) + ctx.line_to(radius * 1.2, 0) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/vertical.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/vertical.py new file mode 100644 index 000000000..fd3a2e186 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/constraints/vertical.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.types import Point + +from ..entities import Line +from ..types import EntityID +from .base import Constraint, ConstraintStatus + +if TYPE_CHECKING: + from ..params import ParameterContext + from ..registry import EntityRegistry + from ..selection import SketchSelection + from ..sketch import Sketch + + +class VerticalConstraint(Constraint): + """Enforces two points have the same X coordinate.""" + + def __init__(self, p1: EntityID, p2: EntityID, user_visible: bool = True): + super().__init__(user_visible=user_visible) + self.p1: EntityID = p1 + self.p2: EntityID = p2 + + @classmethod + def get_type_key(cls) -> str: + return "vert" + + @classmethod + def can_apply_to( + cls, selection: SketchSelection, sketch: Sketch | None = None + ) -> bool: + if len(selection.point_ids) == 2 and not selection.entity_ids: + return True + if selection.point_ids: + return False + if sketch is None: + return False + + lines = [ + sketch.registry.get_entity(eid) + for eid in selection.entity_ids + if isinstance(sketch.registry.get_entity(eid), Line) + ] + return len(lines) > 0 and len(lines) == len(selection.entity_ids) + + @staticmethod + def get_type_name() -> str: + """Returns to human-readable name of this constraint type.""" + return _("Vertical") + + def get_title(self) -> str: + """Returns a human-readable title for this constraint.""" + return self.get_type_name() + + def get_subtitle(self, registry: EntityRegistry) -> str: + """Returns a subtitle describing the constrained points.""" + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + if p1 and p2: + return _("From {} to {}").format( + self._format_coord(p1.x, p1.y), + self._format_coord(p2.x, p2.y), + ) + return "" + + def to_dict(self) -> dict[str, Any]: + return { + "type": "VerticalConstraint", + "p1": self.p1, + "p2": self.p2, + "user_visible": self.user_visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> VerticalConstraint: + return cls( + p1=data["p1"], + p2=data["p2"], + user_visible=data.get("user_visible", True), + ) + + def error(self, reg: EntityRegistry, params: ParameterContext) -> float: + return reg.get_point(self.p1).x - reg.get_point(self.p2).x + + def gradient( + self, reg: EntityRegistry, params: ParameterContext + ) -> dict[EntityID, list[Point]]: + return { + self.p1: [(1.0, 0.0)], + self.p2: [(-1.0, 0.0)], + } + + def is_hit( + self, + sx: float, + sy: float, + reg: EntityRegistry, + to_screen: Callable[[Point], Point], + element: Any, + threshold: float, + ) -> bool: + p1 = reg.get_point(self.p1) + p2 = reg.get_point(self.p2) + if p1 and p2: + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + + t = 0.2 + mx = s1[0] + (s2[0] - s1[0]) * t + my = s1[1] + (s2[1] - s1[1]) * t + cx = mx + 10 + cy = my + return math.hypot(sx - cx, sy - cy) < threshold + return False + + def draw( + self, + ctx: cairo.Context, + registry: EntityRegistry, + to_screen: Callable[[Point], Point], + is_selected: bool = False, + is_hovered: bool = False, + point_radius: float = 5.0, + ) -> None: + try: + p1 = registry.get_point(self.p1) + p2 = registry.get_point(self.p2) + except IndexError: + return + + s1 = to_screen((p1.x, p1.y)) + s2 = to_screen((p2.x, p2.y)) + + t_marker = 0.2 + mx = s1[0] + (s2[0] - s1[0]) * t_marker + my = s1[1] + (s2[1] - s1[1]) * t_marker + + size = 8 + ctx.save() + ctx.set_line_width(2) + ctx.move_to(mx + 10, my - size) + ctx.line_to(mx + 10, my + size) + + if is_selected: + self._draw_selection_underlay(ctx) + + if self.status == ConstraintStatus.CONFLICTING: + self._draw_conflict_underlay(ctx) + + self._set_color(ctx, is_hovered) + ctx.stroke() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/__init__.py new file mode 100644 index 000000000..5c0dffa67 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/__init__.py @@ -0,0 +1,19 @@ +from .arc import Arc +from .bezier import Bezier +from .circle import Circle +from .ellipse import Ellipse +from .entity import Entity +from .line import Line +from .point import Point +from .text_box import TextBoxEntity + +__all__ = [ + "Arc", + "Bezier", + "Circle", + "Ellipse", + "Entity", + "Line", + "Point", + "TextBoxEntity", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/arc.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/arc.py new file mode 100644 index 000000000..a6f7d4823 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/arc.py @@ -0,0 +1,274 @@ +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.shape.arc import ( + does_arc_intersect_rect, + get_arc_bounds, + get_arc_midpoint, + is_angle_between, +) +from raygeo.geo.shape.rect import does_rect_contain_rect +from raygeo.geo.types import Point, Polygon, Rect + +from ..types import EntityID +from .entity import Entity + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + + +class Arc(Entity): + def __init__( + self, + id: EntityID, + start_idx: EntityID, + end_idx: EntityID, + center_idx: EntityID, + clockwise: bool = False, + construction: bool = False, + ): + super().__init__(id, construction) + self.start_idx: EntityID = start_idx + self.end_idx: EntityID = end_idx + self.center_idx: EntityID = center_idx + self.clockwise = clockwise + self.type = "arc" + + def get_state(self) -> dict[str, Any] | None: + state = super().get_state() or {} + state["clockwise"] = self.clockwise + return state + + def set_state(self, state: dict[str, Any]) -> None: + super().set_state(state) + if "clockwise" in state: + self.clockwise = state["clockwise"] + + def get_point_ids(self) -> list[EntityID]: + return [self.start_idx, self.end_idx, self.center_idx] + + def get_endpoint_ids(self) -> list[EntityID]: + return [self.start_idx, self.end_idx] + + def get_junction_point_ids(self) -> list[EntityID]: + return [self.start_idx, self.end_idx, self.center_idx] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + start = registry.get_point(self.start_idx) + if not (center and start): + return False + + radius = math.hypot(start.x - center.x, start.y - center.y) + if radius == 0.0: + return False + + dist_mouse = math.hypot(mx - center.x, my - center.y) + if abs(dist_mouse - radius) >= threshold: + return False + + angle_mouse = math.atan2(my - center.y, mx - center.x) + return self.is_angle_within_sweep(angle_mouse, registry) + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + s = registry.get_point(self.start_idx) + e = registry.get_point(self.end_idx) + c = registry.get_point(self.center_idx) + self.constrained = s.constrained and e.constrained and c.constrained + + def _get_bbox(self, registry: "EntityRegistry") -> Rect: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + center = registry.get_point(self.center_idx) + + # Reuse core primitive utility for exact arc bounding box + # Note: primitive expects center_offset relative to start, so: + # center = start + offset. Here center is absolute. + # offset = center - start. + return get_arc_bounds( + start.pos(), + end.pos(), + (center.x - start.x, center.y - start.y), + self.clockwise, + ) + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + # For an arc to be strictly inside, its entire bounding box must be + # inside + arc_box = self._get_bbox(registry) + return does_rect_contain_rect(rect, arc_box) + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + center = registry.get_point(self.center_idx) + return does_arc_intersect_rect( + start.pos(), end.pos(), center.pos(), self.clockwise, rect + ) + + def to_geometry(self, registry: "EntityRegistry") -> Geometry: + """Converts the arc to a Geometry object.""" + geo = Geometry() + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + center = registry.get_point(self.center_idx) + i = center.x - start.x + j = center.y - start.y + geo.move_to(start.x, start.y) + geo.arc_to(end.x, end.y, i, j, clockwise=self.clockwise) + return geo + + def append_to_geometry( + self, + geo: Geometry, + registry: "EntityRegistry", + forward: bool, + ) -> None: + """Appends this arc to an existing geometry object.""" + arc_start_pt = registry.get_point(self.start_idx) + arc_end_pt = registry.get_point(self.end_idx) + center_pt = registry.get_point(self.center_idx) + + target_pt = arc_end_pt if forward else arc_start_pt + current_pt = arc_start_pt if forward else arc_end_pt + + offset_x = center_pt.x - current_pt.x + offset_y = center_pt.y - current_pt.y + + is_cw = self.clockwise if forward else not self.clockwise + + geo.arc_to( + target_pt.x, + target_pt.y, + offset_x, + offset_y, + clockwise=is_cw, + ) + + def to_polygon_vertices( + self, + registry: "EntityRegistry", + forward: bool, + ) -> Polygon: + start_pt = registry.get_point(self.start_idx) + end_pt = registry.get_point(self.end_idx) + center_pt = registry.get_point(self.center_idx) + if not (start_pt and end_pt and center_pt): + return [] + + radius = math.hypot(start_pt.x - center_pt.x, start_pt.y - center_pt.y) + start_a = math.atan2( + start_pt.y - center_pt.y, start_pt.x - center_pt.x + ) + end_a = math.atan2(end_pt.y - center_pt.y, end_pt.x - center_pt.x) + + vertices: Polygon = [] + num_segments = 16 + + if forward: + if self.clockwise: + if end_a > start_a: + end_a -= 2 * math.pi + else: + if end_a < start_a: + end_a += 2 * math.pi + for i in range(num_segments + 1): + t = i / num_segments + a = start_a + t * (end_a - start_a) + px = center_pt.x + radius * math.cos(a) + py = center_pt.y + radius * math.sin(a) + vertices.append((px, py)) + else: + if self.clockwise: + if start_a > end_a: + start_a -= 2 * math.pi + else: + if start_a < end_a: + start_a += 2 * math.pi + for i in range(num_segments + 1): + t = i / num_segments + a = end_a + t * (start_a - end_a) + px = center_pt.x + radius * math.cos(a) + py = center_pt.y + radius * math.sin(a) + vertices.append((px, py)) + + return vertices + + def to_dict(self) -> dict[str, Any]: + """Serializes the Arc to a dictionary.""" + data = super().to_dict() + data.update( + { + "start_idx": self.start_idx, + "end_idx": self.end_idx, + "center_idx": self.center_idx, + "clockwise": self.clockwise, + } + ) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Arc": + """Deserializes a dictionary into an Arc instance.""" + return cls( + id=data["id"], + start_idx=data["start_idx"], + end_idx=data["end_idx"], + center_idx=data["center_idx"], + clockwise=data.get("clockwise", False), + construction=data.get("construction", False), + ) + + def get_midpoint(self, registry: "EntityRegistry") -> Point | None: + """ + Calculates the midpoint coordinates along the arc's circumference. + """ + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + center = registry.get_point(self.center_idx) + if not (start and end and center): + return None + return get_arc_midpoint( + start.pos(), end.pos(), center.pos(), self.clockwise + ) + + def is_angle_within_sweep( + self, angle: float, registry: "EntityRegistry" + ) -> bool: + """Checks if a given angle is within the arc's sweep.""" + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + center = registry.get_point(self.center_idx) + if not (start and end and center): + return False + + start_angle = math.atan2(start.y - center.y, start.x - center.x) + end_angle = math.atan2(end.y - center.y, end.x - center.x) + + return is_angle_between(angle, start_angle, end_angle, self.clockwise) + + def __repr__(self) -> str: + return ( + f"Arc(id={self.id}, start={self.start_idx}, end={self.end_idx}, " + f"center={self.center_idx}, cw={self.clockwise}, " + f"construction={self.construction})" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/bezier.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/bezier.py new file mode 100644 index 000000000..e4a53d7e3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/bezier.py @@ -0,0 +1,327 @@ +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.shape.line import ( + does_line_segment_intersect_rect, + get_line_segment_closest_point, +) +from raygeo.geo.shape.rect import does_rect_contain_rect +from raygeo.geo.types import Point as GeoPoint +from raygeo.geo.types import Polygon, Rect + +from ..types import EntityID +from .entity import Entity + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + + +class Bezier(Entity): + def __init__( + self, + id: EntityID, + start_idx: EntityID, + end_idx: EntityID, + construction: bool = False, + cp1: GeoPoint | None = None, + cp2: GeoPoint | None = None, + ): + super().__init__(id, construction) + self.start_idx: EntityID = start_idx + self.end_idx: EntityID = end_idx + self.type = "bezier" + self.cp1 = cp1 + self.cp2 = cp2 + + def get_control_points( + self, registry: "EntityRegistry" + ) -> tuple[float | None, float | None, float | None, float | None]: + cp1_x, cp1_y = None, None + cp2_x, cp2_y = None, None + if self.cp1 is not None: + start = registry.get_point(self.start_idx) + if start: + cp1_x = start.x + self.cp1[0] + cp1_y = start.y + self.cp1[1] + if self.cp2 is not None: + end = registry.get_point(self.end_idx) + if end: + cp2_x = end.x + self.cp2[0] + cp2_y = end.y + self.cp2[1] + return cp1_x, cp1_y, cp2_x, cp2_y + + def get_control_points_or_endpoints( + self, registry: "EntityRegistry" + ) -> tuple[float, float, float, float]: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + cp1_x_opt, cp1_y_opt, cp2_x_opt, cp2_y_opt = self.get_control_points( + registry + ) + cp1_x: float = cp1_x_opt if cp1_x_opt is not None else start.x + cp1_y: float = cp1_y_opt if cp1_y_opt is not None else start.y + cp2_x: float = cp2_x_opt if cp2_x_opt is not None else end.x + cp2_y: float = cp2_y_opt if cp2_y_opt is not None else end.y + return cp1_x, cp1_y, cp2_x, cp2_y + + def is_line(self, registry: "EntityRegistry") -> bool: + cp1_x, _cp1_y, cp2_x, _cp2_y = self.get_control_points(registry) + return cp1_x is None and cp2_x is None + + def get_point_ids(self) -> list[EntityID]: + return [self.start_idx, self.end_idx] + + def get_endpoint_ids(self) -> list[EntityID]: + return [self.start_idx, self.end_idx] + + def get_junction_point_ids(self) -> list[EntityID]: + return [self.start_idx, self.end_idx] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + if not (start and end): + return False + + if self.is_line(registry): + _, _, dist_sq = get_line_segment_closest_point( + (start.x, start.y), (end.x, end.y), mx, my + ) + return dist_sq < threshold**2 + + cp1_x, cp1_y, cp2_x, cp2_y = self.get_control_points_or_endpoints( + registry + ) + points = self._sample_bezier( + start.x, start.y, cp1_x, cp1_y, cp2_x, cp2_y, end.x, end.y, 20 + ) + + min_dist_sq = float("inf") + for i in range(len(points) - 1): + _, _, dist_sq = get_line_segment_closest_point( + points[i], points[i + 1], mx, my + ) + min_dist_sq = min(min_dist_sq, dist_sq) + + return min_dist_sq < threshold**2 + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + self.constrained = start.constrained and end.constrained + + def _get_bbox(self, registry: "EntityRegistry") -> Rect: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + if not (start and end): + return (0.0, 0.0, 0.0, 0.0) + + if self.is_line(registry): + min_x = min(start.x, end.x) + max_x = max(start.x, end.x) + min_y = min(start.y, end.y) + max_y = max(start.y, end.y) + return (min_x, min_y, max_x, max_y) + + cp1_x, cp1_y, cp2_x, cp2_y = self.get_control_points_or_endpoints( + registry + ) + points = self._sample_bezier( + start.x, start.y, cp1_x, cp1_y, cp2_x, cp2_y, end.x, end.y, 20 + ) + if not points: + return (0.0, 0.0, 0.0, 0.0) + + min_x = min(p[0] for p in points) + max_x = max(p[0] for p in points) + min_y = min(p[1] for p in points) + max_y = max(p[1] for p in points) + return (min_x, min_y, max_x, max_y) + + def _sample_bezier( + self, + x0: float, + y0: float, + x1: float, + y1: float, + x2: float, + y2: float, + x3: float, + y3: float, + num_samples: int, + ) -> list[tuple]: + points = [] + for i in range(num_samples + 1): + t = i / num_samples + mt = 1 - t + mt2 = mt * mt + mt3 = mt2 * mt + t2 = t * t + t3 = t2 * t + + x = mt3 * x0 + 3 * mt2 * t * x1 + 3 * mt * t2 * x2 + t3 * x3 + y = mt3 * y0 + 3 * mt2 * t * y1 + 3 * mt * t2 * y2 + t3 * y3 + points.append((x, y)) + return points + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + bezier_box = self._get_bbox(registry) + return does_rect_contain_rect(rect, bezier_box) + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + if not (start and end): + return False + + if self.is_line(registry): + return does_line_segment_intersect_rect( + start.pos(), end.pos(), rect + ) + + cp1_x, cp1_y, cp2_x, cp2_y = self.get_control_points_or_endpoints( + registry + ) + points = self._sample_bezier( + start.x, start.y, cp1_x, cp1_y, cp2_x, cp2_y, end.x, end.y, 20 + ) + + for i in range(len(points) - 1): + if does_line_segment_intersect_rect( + points[i], points[i + 1], rect + ): + return True + + min_x, min_y, max_x, max_y = rect + for px, py in points: + if min_x <= px <= max_x and min_y <= py <= max_y: + return True + + return False + + def to_geometry(self, registry: "EntityRegistry") -> Geometry: + geo = Geometry() + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + if not (start and end): + return geo + + geo.move_to(start.x, start.y) + + if self.is_line(registry): + geo.line_to(end.x, end.y) + else: + cp1_x, cp1_y, cp2_x, cp2_y = self.get_control_points_or_endpoints( + registry + ) + geo.bezier_to(end.x, end.y, cp1_x, cp1_y, cp2_x, cp2_y) + return geo + + def append_to_geometry( + self, + geo: Geometry, + registry: "EntityRegistry", + forward: bool, + ) -> None: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + if not (start and end): + return + + if self.is_line(registry): + if forward: + geo.line_to(end.x, end.y) + else: + geo.line_to(start.x, start.y) + else: + cp1_x, cp1_y, cp2_x, cp2_y = self.get_control_points_or_endpoints( + registry + ) + if forward: + geo.bezier_to(end.x, end.y, cp1_x, cp1_y, cp2_x, cp2_y) + else: + geo.bezier_to(start.x, start.y, cp2_x, cp2_y, cp1_x, cp1_y) + + def to_polygon_vertices( + self, + registry: "EntityRegistry", + forward: bool, + ) -> Polygon: + start = registry.get_point(self.start_idx) + end = registry.get_point(self.end_idx) + if not (start and end): + return [] + + if self.is_line(registry): + if forward: + return [(end.x, end.y)] + else: + return [(start.x, start.y)] + + cp1_x, cp1_y, cp2_x, cp2_y = self.get_control_points_or_endpoints( + registry + ) + points = self._sample_bezier( + start.x, start.y, cp1_x, cp1_y, cp2_x, cp2_y, end.x, end.y, 20 + ) + if not forward: + points = list(reversed(points)) + return points + + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() + data.update( + { + "start_idx": self.start_idx, + "end_idx": self.end_idx, + } + ) + if self.cp1 is not None: + data["cp1_dx"] = self.cp1[0] + data["cp1_dy"] = self.cp1[1] + if self.cp2 is not None: + data["cp2_dx"] = self.cp2[0] + data["cp2_dy"] = self.cp2[1] + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Bezier": + cp1 = None + if "cp1_dx" in data and "cp1_dy" in data: + cp1 = (data["cp1_dx"], data["cp1_dy"]) + cp2 = None + if "cp2_dx" in data and "cp2_dy" in data: + cp2 = (data["cp2_dx"], data["cp2_dy"]) + return cls( + id=data["id"], + start_idx=data["start_idx"], + end_idx=data["end_idx"], + construction=data.get("construction", False), + cp1=cp1, + cp2=cp2, + ) + + def __repr__(self) -> str: + return ( + f"Bezier(id={self.id}, start={self.start_idx}, " + f"end={self.end_idx}, construction={self.construction}, " + f"cp1={self.cp1}, cp2={self.cp2})" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/circle.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/circle.py new file mode 100644 index 000000000..380efd0f1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/circle.py @@ -0,0 +1,184 @@ +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.shape.circle import ( + does_circle_intersect_rect, + is_circle_inside_rect, +) +from raygeo.geo.types import Point, Rect + +from ..types import EntityID +from .entity import Entity + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + + +class Circle(Entity): + def __init__( + self, + id: EntityID, + center_idx: EntityID, + radius_pt_idx: EntityID, + construction: bool = False, + ): + super().__init__(id, construction) + self.center_idx: EntityID = center_idx + self.radius_pt_idx: EntityID = radius_pt_idx + self.type = "circle" + + def get_point_ids(self) -> list[EntityID]: + return [self.center_idx, self.radius_pt_idx] + + def get_endpoint_ids(self) -> list[EntityID]: + return [] + + def get_junction_point_ids(self) -> list[EntityID]: + return [self.center_idx, self.radius_pt_idx] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + radius_pt = registry.get_point(self.radius_pt_idx) + if not (center and radius_pt): + return False + + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + if radius == 0.0: + return False + + dist_mouse = math.hypot(mx - center.x, my - center.y) + return abs(dist_mouse - radius) < threshold + + def get_ignorable_unconstrained_points(self) -> list[EntityID]: + """ + If the circle is geometrically constrained, the radius point (which + acts only as a handle for the radius value) does not need to be + constrained rotationally. + """ + if self.constrained: + return [self.radius_pt_idx] + return [] + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + center_pt = registry.get_point(self.center_idx) + radius_pt = registry.get_point(self.radius_pt_idx) + + # A circle's geometry is defined by its center and radius. + center_is_constrained = center_pt.constrained + + # The radius is defined if: + # 1. The radius point itself is fully constrained. + # 2. Or, a constraint explicitly defines the radius. + radius_is_defined = radius_pt.constrained + if not radius_is_defined: + for constr in constraints: + if constr.constrains_radius(registry, self.id): + radius_is_defined = True + break + + self.constrained = center_is_constrained and radius_is_defined + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + radius_pt = registry.get_point(self.radius_pt_idx) + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + return is_circle_inside_rect(center.pos(), radius, rect) + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + radius_pt = registry.get_point(self.radius_pt_idx) + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + + # A circle that is contained by a rect also intersects it. + # The primitive for intersection seems to miss this case, so we check + # for containment first. + if is_circle_inside_rect(center.pos(), radius, rect): + return True + + return does_circle_intersect_rect(center.pos(), radius, rect) + + def to_geometry(self, registry: "EntityRegistry") -> Geometry: + """Converts the circle to a Geometry object.""" + geo = Geometry() + center = registry.get_point(self.center_idx) + radius_pt = registry.get_point(self.radius_pt_idx) + dx = radius_pt.x - center.x + dy = radius_pt.y - center.y + opposite_pt_x = center.x - dx + opposite_pt_y = center.y - dy + i1, j1 = -dx, -dy + i2, j2 = dx, dy + geo.move_to(radius_pt.x, radius_pt.y) + geo.arc_to(opposite_pt_x, opposite_pt_y, i1, j1, clockwise=False) + geo.arc_to(radius_pt.x, radius_pt.y, i2, j2, clockwise=False) + return geo + + def create_fill_geometry( + self, registry: "EntityRegistry" + ) -> Geometry | None: + """Creates a fill geometry for a single-entity circle loop.""" + geo = Geometry() + center = registry.get_point(self.center_idx) + radius_pt = registry.get_point(self.radius_pt_idx) + dx = radius_pt.x - center.x + dy = radius_pt.y - center.y + opposite_pt_x = center.x - dx + opposite_pt_y = center.y - dy + geo.move_to(radius_pt.x, radius_pt.y) + geo.arc_to(opposite_pt_x, opposite_pt_y, -dx, -dy, clockwise=False) + geo.arc_to(radius_pt.x, radius_pt.y, dx, dy, clockwise=False) + return geo + + def to_dict(self) -> dict[str, Any]: + """Serializes the Circle to a dictionary.""" + data = super().to_dict() + data.update( + { + "center_idx": self.center_idx, + "radius_pt_idx": self.radius_pt_idx, + } + ) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Circle": + """Deserializes a dictionary into a Circle instance.""" + return cls( + id=data["id"], + center_idx=data["center_idx"], + radius_pt_idx=data["radius_pt_idx"], + construction=data.get("construction", False), + ) + + def get_midpoint(self, registry: "EntityRegistry") -> Point | None: + """Returns a point on the circumference (the radius point).""" + radius_pt = registry.get_point(self.radius_pt_idx) + if not radius_pt: + return None + return radius_pt.pos() + + def __repr__(self) -> str: + return ( + f"Circle(id={self.id}, center={self.center_idx}, " + f"radius_pt={self.radius_pt_idx}, " + f"construction={self.construction})" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/ellipse.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/ellipse.py new file mode 100644 index 000000000..e6c8a02a9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/ellipse.py @@ -0,0 +1,263 @@ +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.types import Point, Rect + +from ..types import EntityID +from .entity import Entity + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + + +class Ellipse(Entity): + def __init__( + self, + id: EntityID, + center_idx: EntityID, + radius_x_pt_idx: EntityID, + radius_y_pt_idx: EntityID, + construction: bool = False, + helper_line_ids: list[EntityID] | None = None, + ): + super().__init__(id, construction) + self.center_idx: EntityID = center_idx + self.radius_x_pt_idx: EntityID = radius_x_pt_idx + self.radius_y_pt_idx: EntityID = radius_y_pt_idx + self.helper_line_ids: list[EntityID] = helper_line_ids or [] + self.type = "ellipse" + + def get_point_ids(self) -> list[EntityID]: + return [self.center_idx, self.radius_x_pt_idx, self.radius_y_pt_idx] + + def get_endpoint_ids(self) -> list[EntityID]: + return [] + + def get_junction_point_ids(self) -> list[EntityID]: + return [self.center_idx, self.radius_x_pt_idx, self.radius_y_pt_idx] + + def get_rigidly_connected_points( + self, point_id: EntityID + ) -> list[EntityID]: + if point_id == self.center_idx: + return [ + self.center_idx, + self.radius_x_pt_idx, + self.radius_y_pt_idx, + ] + return [] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + radius_x_pt = registry.get_point(self.radius_x_pt_idx) + radius_y_pt = registry.get_point(self.radius_y_pt_idx) + if not (center and radius_x_pt and radius_y_pt): + return False + + rx = math.hypot(radius_x_pt.x - center.x, radius_x_pt.y - center.y) + ry = math.hypot(radius_y_pt.x - center.x, radius_y_pt.y - center.y) + if rx < 1e-9 or ry < 1e-9: + return False + + rotation = self._get_rotation(registry) + cos_a = math.cos(-rotation) + sin_a = math.sin(-rotation) + + dx = mx - center.x + dy = my - center.y + local_x = dx * cos_a - dy * sin_a + local_y = dx * sin_a + dy * cos_a + + dist = math.sqrt((local_x / rx) ** 2 + (local_y / ry) ** 2) + return abs(dist - 1.0) < (threshold / min(rx, ry)) + + def get_ignorable_unconstrained_points(self) -> list[EntityID]: + if self.constrained: + return [self.radius_x_pt_idx, self.radius_y_pt_idx] + return [] + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + center_pt = registry.get_point(self.center_idx) + radius_x_pt = registry.get_point(self.radius_x_pt_idx) + radius_y_pt = registry.get_point(self.radius_y_pt_idx) + + center_is_constrained = center_pt.constrained + radii_are_defined = radius_x_pt.constrained and radius_y_pt.constrained + + if not radii_are_defined: + for constr in constraints: + if constr.constrains_radius(registry, self.id): + radii_are_defined = True + break + + self.constrained = center_is_constrained and radii_are_defined + + def _get_radii(self, registry: "EntityRegistry") -> tuple[float, float]: + center = registry.get_point(self.center_idx) + radius_x_pt = registry.get_point(self.radius_x_pt_idx) + radius_y_pt = registry.get_point(self.radius_y_pt_idx) + rx = math.hypot(radius_x_pt.x - center.x, radius_x_pt.y - center.y) + ry = math.hypot(radius_y_pt.x - center.x, radius_y_pt.y - center.y) + return rx, ry + + def _get_rotation(self, registry: "EntityRegistry") -> float: + center = registry.get_point(self.center_idx) + radius_x_pt = registry.get_point(self.radius_x_pt_idx) + return math.atan2(radius_x_pt.y - center.y, radius_x_pt.x - center.x) + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + rx, ry = self._get_radii(registry) + return ( + (center.x - rx) >= rect[0] + and (center.y - ry) >= rect[1] + and (center.x + rx) <= rect[2] + and (center.y + ry) <= rect[3] + ) + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + center = registry.get_point(self.center_idx) + rx, ry = self._get_radii(registry) + + if self.is_contained_by(rect, registry): + return True + + closest_x = max(rect[0], min(center.x, rect[2])) + closest_y = max(rect[1], min(center.y, rect[3])) + dx = closest_x - center.x + dy = closest_y - center.y + dist_sq = ( + (dx / rx) ** 2 + (dy / ry) ** 2 + if rx > 0 and ry > 0 + else float("inf") + ) + + if dist_sq > 1.0: + return False + + dx_far = max(abs(rect[0] - center.x), abs(rect[2] - center.x)) + dy_far = max(abs(rect[1] - center.y), abs(rect[3] - center.y)) + dist_sq_far = ( + (dx_far / rx) ** 2 + (dy_far / ry) ** 2 + if rx > 0 and ry > 0 + else float("inf") + ) + return dist_sq_far >= 1.0 + + def to_geometry(self, registry: "EntityRegistry") -> Geometry: + geo = Geometry() + center = registry.get_point(self.center_idx) + radius_x_pt = registry.get_point(self.radius_x_pt_idx) + radius_y_pt = registry.get_point(self.radius_y_pt_idx) + + rx = math.hypot(radius_x_pt.x - center.x, radius_x_pt.y - center.y) + ry = math.hypot(radius_y_pt.x - center.x, radius_y_pt.y - center.y) + + if rx < 1e-9 or ry < 1e-9: + return geo + + cx, cy = center.x, center.y + rotation = self._get_rotation(registry) + cos_a = math.cos(rotation) + sin_a = math.sin(rotation) + + if abs(rx - ry) < 1e-9: + return self._circle_geometry(cx, cy, rx, cos_a, sin_a) + + num_segments = max(32, int(64 * max(rx, ry) / min(rx, ry))) + for i in range(num_segments): + angle = 2 * math.pi * i / num_segments + local_x = rx * math.cos(angle) + local_y = ry * math.sin(angle) + x = cx + local_x * cos_a - local_y * sin_a + y = cy + local_x * sin_a + local_y * cos_a + if i == 0: + geo.move_to(x, y) + else: + geo.line_to(x, y) + geo.close_path() + geo.fit_arcs(0.1) + return geo + + @staticmethod + def _circle_geometry( + cx: float, + cy: float, + r: float, + cos_a: float, + sin_a: float, + ) -> Geometry: + geo = Geometry() + start_x = cx + r * cos_a + start_y = cy + r * sin_a + mid_x = cx - r * cos_a + mid_y = cy - r * sin_a + i1 = -r * cos_a + j1 = -r * sin_a + i2 = r * cos_a + j2 = r * sin_a + geo.move_to(start_x, start_y) + geo.arc_to(mid_x, mid_y, i1, j1, clockwise=False) + geo.arc_to(start_x, start_y, i2, j2, clockwise=False) + return geo + + def create_fill_geometry( + self, registry: "EntityRegistry" + ) -> Geometry | None: + return self.to_geometry(registry) + + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() + data.update( + { + "center_idx": self.center_idx, + "radius_x_pt_idx": self.radius_x_pt_idx, + "radius_y_pt_idx": self.radius_y_pt_idx, + "helper_line_ids": self.helper_line_ids, + } + ) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Ellipse": + return cls( + id=data["id"], + center_idx=data["center_idx"], + radius_x_pt_idx=data["radius_x_pt_idx"], + radius_y_pt_idx=data["radius_y_pt_idx"], + construction=data.get("construction", False), + helper_line_ids=data.get("helper_line_ids"), + ) + + def get_midpoint(self, registry: "EntityRegistry") -> Point | None: + radius_x_pt = registry.get_point(self.radius_x_pt_idx) + if not radius_x_pt: + return None + return radius_x_pt.pos() + + def __repr__(self) -> str: + return ( + f"Ellipse(id={self.id}, center={self.center_idx}, " + f"radius_x_pt={self.radius_x_pt_idx}, " + f"radius_y_pt={self.radius_y_pt_idx}, " + f"construction={self.construction})" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/entity.py new file mode 100644 index 000000000..ccbd50adc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/entity.py @@ -0,0 +1,187 @@ +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.types import Polygon, Rect + +from ..types import EntityID + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + + +class Entity: + """Base class for geometric primitives.""" + + def __init__(self, id: EntityID, construction: bool = False): + self.id: EntityID = id + self.construction = construction + self.invisible = False + self.type = "entity" + # Constrained state is calculated by solver + self.constrained = False + + def get_state(self) -> dict[str, Any] | None: + """ + Returns a dictionary of solver-relevant discrete state (e.g. winding), + or None if the entity has no mutable discrete state. + Used for Undo/Redo snapshots. + """ + # Construction state affects solver topology/rendering, so we capture + # it. Subclasses should call super().get_state() or merge dicts if + # they have more state. + return {"construction": self.construction} + + def set_state(self, state: dict[str, Any]) -> None: + """Restores state from a snapshot.""" + if "construction" in state: + self.construction = state["construction"] + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + """ + Updates self.constrained based on the status of defining points + and relevant constraints. + """ + self.constrained = False + + def get_point_ids(self) -> list[EntityID]: + """Returns IDs of all control points used by this entity.""" + return [] + + def get_endpoint_ids(self) -> list[EntityID]: + """ + Returns IDs of the two endpoints for path/loop traversal. + Returns empty list for single-point entities (Circle). + Index 0 is the start, index 1 is the end. + """ + return [] + + def get_ignorable_unconstrained_points(self) -> list[EntityID]: + """ + Returns IDs of points that can remain unconstrained if this entity + is constrained (e.g. radius handles). + """ + return [] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + """ + Returns True if the point (mx, my) is within threshold distance of + this entity in model coordinates. + """ + return False + + def get_junction_point_ids(self) -> list[EntityID]: + """ + Returns point IDs that should be counted for junction detection. + These are typically the endpoints of geometric entities. + """ + return [] + + def get_rigidly_connected_points( + self, point_id: EntityID + ) -> list[EntityID]: + """ + Returns point IDs that should move together with the given point + as a rigid body during dragging. Used for entities where certain + points should maintain their relative positions (e.g., ellipse center + should drag all points together). + """ + return [] + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + """ + Returns True if the entity is fully strictly contained within the rect. + Used for Window Selection. + """ + return False + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + """ + Returns True if the entity intersects the rect or is contained by it. + Used for Crossing Selection. + """ + return False + + def to_geometry(self, registry: "EntityRegistry") -> Geometry: + """Converts the entity to a Geometry object.""" + return Geometry() + + def create_fill_geometry( + self, registry: "EntityRegistry" + ) -> Geometry | None: + """ + Creates a fill geometry for single-entity loops. + Returns None if the entity does not support fill geometry. + """ + return None + + def append_to_geometry( + self, + geo: Geometry, + registry: "EntityRegistry", + forward: bool, + ) -> None: + """ + Appends this entity to an existing geometry object. + Used for multi-segment loops. + """ + + def to_polygon_vertices( + self, + registry: "EntityRegistry", + forward: bool, + ) -> Polygon: + """ + Converts this entity to a list of polygon vertices for hit testing. + Curves should be sampled/linearized appropriately. + """ + return [] + + def create_text_fill_geometry( + self, registry: "EntityRegistry" + ) -> Geometry | None: + """ + Creates a fill geometry for text entities. + Returns None if the entity does not support text fill geometry. + """ + return None + + def to_dict(self) -> dict[str, Any]: + """Base serialization method for entities.""" + data = { + "id": self.id, + "type": self.type, + "construction": self.construction, + } + if self.invisible: + data["invisible"] = True + return data + + @classmethod + def _from_dict_base(cls, data: dict[str, Any]) -> dict[str, Any]: + """Extract base entity attributes from a dictionary.""" + return { + "id": data["id"], + "construction": data.get("construction", False), + "invisible": data.get("invisible", False), + } + + def __repr__(self) -> str: + return f"Entity(id={self.id}, type={self.type})" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/line.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/line.py new file mode 100644 index 000000000..021f085ab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/line.py @@ -0,0 +1,135 @@ +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.shape.line import ( + does_line_segment_intersect_rect, + get_line_segment_closest_point, +) +from raygeo.geo.types import Polygon, Rect + +from ..types import EntityID +from .entity import Entity + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + + +class Line(Entity): + def __init__( + self, + id: EntityID, + p1_idx: EntityID, + p2_idx: EntityID, + construction: bool = False, + ): + super().__init__(id, construction) + self.p1_idx: EntityID = p1_idx + self.p2_idx: EntityID = p2_idx + self.type = "line" + + def get_point_ids(self) -> list[EntityID]: + return [self.p1_idx, self.p2_idx] + + def get_endpoint_ids(self) -> list[EntityID]: + return [self.p1_idx, self.p2_idx] + + def get_junction_point_ids(self) -> list[EntityID]: + return [self.p1_idx, self.p2_idx] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + p1 = registry.get_point(self.p1_idx) + p2 = registry.get_point(self.p2_idx) + if not (p1 and p2): + return False + _, _, dist_sq = get_line_segment_closest_point( + (p1.x, p1.y), (p2.x, p2.y), mx, my + ) + return dist_sq < threshold**2 + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + p1 = registry.get_point(self.p1_idx) + p2 = registry.get_point(self.p2_idx) + self.constrained = p1.constrained and p2.constrained + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + p1 = registry.get_point(self.p1_idx) + p2 = registry.get_point(self.p2_idx) + return p1.is_in_rect(rect) and p2.is_in_rect(rect) + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + p1 = registry.get_point(self.p1_idx) + p2 = registry.get_point(self.p2_idx) + return does_line_segment_intersect_rect(p1.pos(), p2.pos(), rect) + + def to_geometry(self, registry: "EntityRegistry") -> Geometry: + """Converts the line to a Geometry object.""" + geo = Geometry() + p1 = registry.get_point(self.p1_idx) + p2 = registry.get_point(self.p2_idx) + geo.move_to(p1.x, p1.y) + geo.line_to(p2.x, p2.y) + return geo + + def append_to_geometry( + self, + geo: Geometry, + registry: "EntityRegistry", + forward: bool, + ) -> None: + """Appends this line to an existing geometry object.""" + p_ids = self.get_point_ids() + end_pid = p_ids[1] if forward else p_ids[0] + end_pt = registry.get_point(end_pid) + geo.line_to(end_pt.x, end_pt.y) + + def to_polygon_vertices( + self, + registry: "EntityRegistry", + forward: bool, + ) -> Polygon: + p_ids = self.get_endpoint_ids() + start_pid = p_ids[0] if forward else p_ids[1] + p = registry.get_point(start_pid) + return [(p.x, p.y)] + + def to_dict(self) -> dict[str, Any]: + """Serializes the Line to a dictionary.""" + data = super().to_dict() + data.update({"p1_idx": self.p1_idx, "p2_idx": self.p2_idx}) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Line": + """Deserializes a dictionary into a Line instance.""" + line = cls( + id=data["id"], + p1_idx=data["p1_idx"], + p2_idx=data["p2_idx"], + construction=data.get("construction", False), + ) + line.invisible = data.get("invisible", False) + return line + + def __repr__(self) -> str: + return ( + f"Line(id={self.id}, p1={self.p1_idx}, p2={self.p2_idx}, " + f"construction={self.construction})" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/point.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/point.py new file mode 100644 index 000000000..ccd79035a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/point.py @@ -0,0 +1,245 @@ +import math +from enum import Enum +from typing import TYPE_CHECKING, Any, Optional + +from raygeo.geo.shape.rect import is_point_inside_rect +from raygeo.geo.types import Point as GeoPoint +from raygeo.geo.types import Rect + +from ..types import EntityID +from .bezier import Bezier + +if TYPE_CHECKING: + from ..registry import EntityRegistry + from ..sketch import Sketch + + +class WaypointType(Enum): + SHARP = "sharp" + SMOOTH = "smooth" + SYMMETRIC = "symmetric" + + +class Point: + def __init__( + self, + id: EntityID, + x: float, + y: float, + fixed: bool = False, + waypoint_type: WaypointType = WaypointType.SHARP, + ): + self.id: EntityID = id + self.x = x + self.y = y + self.fixed = fixed + self.waypoint_type = waypoint_type + self.constrained: bool = False + + def is_sharp(self) -> bool: + return self.waypoint_type == WaypointType.SHARP + + def is_smooth(self) -> bool: + return self.waypoint_type == WaypointType.SMOOTH + + def is_symmetric(self) -> bool: + return self.waypoint_type == WaypointType.SYMMETRIC + + def get_connected_beziers( + self, + registry: "EntityRegistry", + sketch: Optional["Sketch"] = None, + ) -> list["Bezier"]: + connected = [] + point_ids = {self.id} + if sketch is not None: + point_ids.update(sketch.get_coincident_points(self.id)) + for entity in registry.entities: + if isinstance(entity, Bezier) and ( + entity.start_idx in point_ids or entity.end_idx in point_ids + ): + connected.append(entity) + return connected + + def get_paired_beziers( + self, + registry: "EntityRegistry", + sketch: Optional["Sketch"] = None, + ) -> tuple[Optional["Bezier"], Optional["Bezier"]]: + connected = self.get_connected_beziers(registry, sketch) + if len(connected) >= 2: + return connected[0], connected[1] + elif len(connected) == 1: + return connected[0], None + return None, None + + def apply_constraint( + self, + registry: "EntityRegistry", + bezier: "Bezier", + cp_index: int, + sketch: Optional["Sketch"] = None, + ) -> None: + if self.is_sharp(): + return + + b1, b2 = self.get_paired_beziers(registry, sketch) + if b1 is None or b2 is None: + return + + other_bezier = b2 if bezier == b1 else b1 + + point_ids = {self.id} + if sketch is not None: + point_ids.update(sketch.get_coincident_points(self.id)) + + if bezier.start_idx in point_ids and cp_index == 1: + cp_out = bezier.cp1 + if cp_out is not None: + if other_bezier.end_idx in point_ids: + other_bezier.cp2 = self._compute_constrained_cp( + cp_out, + other_bezier.cp2, + symmetric=self.is_symmetric(), + ) + elif other_bezier.start_idx in point_ids: + other_bezier.cp1 = self._compute_constrained_cp( + cp_out, + other_bezier.cp1, + symmetric=self.is_symmetric(), + ) + elif bezier.end_idx in point_ids and cp_index == 2: + cp_in = bezier.cp2 + if cp_in is not None: + if other_bezier.start_idx in point_ids: + other_bezier.cp1 = self._compute_constrained_cp( + cp_in, + other_bezier.cp1, + symmetric=self.is_symmetric(), + ) + elif other_bezier.end_idx in point_ids: + other_bezier.cp2 = self._compute_constrained_cp( + cp_in, + other_bezier.cp2, + symmetric=self.is_symmetric(), + ) + + def _compute_constrained_cp( + self, + modified_cp: tuple[float, float], + other_cp: tuple[float, float] | None, + symmetric: bool, + ) -> tuple[float, float]: + if symmetric: + return (-modified_cp[0], -modified_cp[1]) + else: + if other_cp is None: + return (-modified_cp[0], -modified_cp[1]) + other_length = math.sqrt(other_cp[0] ** 2 + other_cp[1] ** 2) + if other_length < 1e-10: + return (-modified_cp[0], -modified_cp[1]) + modified_length = math.sqrt( + modified_cp[0] ** 2 + modified_cp[1] ** 2 + ) + if modified_length < 1e-10: + return (0.0, 0.0) + direction = ( + -modified_cp[0] / modified_length, + -modified_cp[1] / modified_length, + ) + return ( + direction[0] * other_length, + direction[1] * other_length, + ) + + def enforce_constraint( + self, + registry: "EntityRegistry", + sketch: Optional["Sketch"] = None, + ) -> None: + if self.is_sharp(): + return + + beziers = self.get_connected_beziers(registry, sketch) + if len(beziers) < 2: + return + + point_ids = {self.id} + if sketch is not None: + point_ids.update(sketch.get_coincident_points(self.id)) + + cp_data = [] + for b in beziers: + if b.start_idx in point_ids and b.cp1 is not None: + cp_data.append((b, "cp1", b.cp1)) + elif b.end_idx in point_ids and b.cp2 is not None: + cp_data.append((b, "cp2", b.cp2)) + + if len(cp_data) < 2: + return + + b1, attr1, cp1 = cp_data[0] + b2, attr2, cp2 = cp_data[1] + + if self.is_symmetric(): + avg_length = ( + math.sqrt(cp1[0] ** 2 + cp1[1] ** 2) + + math.sqrt(cp2[0] ** 2 + cp2[1] ** 2) + ) / 2 + if avg_length < 1e-10: + return + + direction = ( + cp1[0] - cp2[0], + cp1[1] - cp2[1], + ) + length = math.sqrt(direction[0] ** 2 + direction[1] ** 2) + if length < 1e-10: + return + direction = (direction[0] / length, direction[1] / length) + + setattr( + b1, + attr1, + (direction[0] * avg_length, direction[1] * avg_length), + ) + setattr( + b2, + attr2, + (-direction[0] * avg_length, -direction[1] * avg_length), + ) + + def pos(self) -> GeoPoint: + return (self.x, self.y) + + def to_dict(self) -> dict[str, Any]: + data = { + "id": self.id, + "x": self.x, + "y": self.y, + "fixed": self.fixed, + } + if self.waypoint_type != WaypointType.SHARP: + data["waypoint_type"] = self.waypoint_type.value + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Point": + wp_type_str = data.get("waypoint_type", "sharp") + wp_type = WaypointType(wp_type_str) + return cls( + id=data["id"], + x=data["x"], + y=data["y"], + fixed=data.get("fixed", False), + waypoint_type=wp_type, + ) + + def is_in_rect(self, rect: Rect) -> bool: + return is_point_inside_rect(self.pos(), rect) + + def __repr__(self) -> str: + return ( + f"Point(id={self.id}, x={self.x}, y={self.y}, " + f"type={self.waypoint_type.value})" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/text_box.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/text_box.py new file mode 100644 index 000000000..07bade10f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/entities/text_box.py @@ -0,0 +1,380 @@ +import logging +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry +from raygeo.geo.shape.line import does_line_segment_intersect_rect +from raygeo.geo.shape.polygon import is_point_inside_polygon +from raygeo.geo.shape.text import FontConfig, text_to_geometry +from raygeo.geo.types import Rect + +from rayforge.core.color import ColorRGBA + +from ..types import EntityID +from .entity import Entity +from .line import Line + +if TYPE_CHECKING: + from ..constraints import Constraint + from ..registry import EntityRegistry + +logger = logging.getLogger(__name__) + + +class TextBoxEntity(Entity): + def __init__( + self, + id: EntityID, + origin_id: EntityID, + width_id: EntityID, + height_id: EntityID, + content: str = "", + font_config: FontConfig | None = None, + construction: bool = False, + construction_line_ids: list[EntityID] | None = None, + ): + super().__init__(id, construction) + self.origin_id: EntityID = origin_id + self.width_id: EntityID = width_id + self.height_id: EntityID = height_id + self.content = content + self.font_config = font_config or FontConfig() + self.construction_line_ids: list[EntityID] = ( + construction_line_ids or [] + ) + self.fill_color: ColorRGBA | None = None + self.type = "text_box" + + def get_point_ids(self) -> list[EntityID]: + return [self.origin_id, self.width_id, self.height_id] + + def get_endpoint_ids(self) -> list[EntityID]: + return [] + + def get_junction_point_ids(self) -> list[EntityID]: + return [] + + def hit_test( + self, + mx: float, + my: float, + threshold: float, + registry: "EntityRegistry", + ) -> bool: + p_origin = registry.get_point(self.origin_id) + p_width = registry.get_point(self.width_id) + p_height = registry.get_point(self.height_id) + if not (p_origin and p_width and p_height): + return False + + p4_x = p_width.x + p_height.x - p_origin.x + p4_y = p_width.y + p_height.y - p_origin.y + + polygon = [ + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p4_x, p4_y), + (p_height.x, p_height.y), + ] + + return is_point_inside_polygon((mx, my), polygon) + + def get_all_frame_point_ids( + self, registry: "EntityRegistry" + ) -> list[EntityID]: + """Returns all 4 corner points of the text box frame.""" + ids = [self.origin_id, self.width_id, self.height_id] + p4_id = self.get_fourth_corner_id(registry) + if p4_id is not None: + ids.append(p4_id) + return ids + + def get_font_metrics(self) -> tuple[float, float, float]: + return self.font_config.get_font_metrics() + + def get_natural_size( + self, content: str | None = None + ) -> tuple[float, float]: + """ + Returns the natural (width, height) of the text content. + + If *content* is omitted, uses ``self.content``. An empty or + None content yields a minimum width of 10. + + The height is derived from the actual geometry bounding box + so the frame matches the ink extent exactly and map_to_frame + does not stretch the text. raygeo returns Y-UP geometry.` + """ + text = content if content is not None else self.content + + if not text: + return 10.0, self.font_config.size + + geo = text_to_geometry(text, font_config=self.font_config) + _, geo_min_y, _, geo_max_y = geo.rect() + geo_height = geo_max_y - geo_min_y + + return ( + max(self.font_config.get_text_width(text) or 0, 1.0), + max(geo_height, 1.0), + ) + + def get_fourth_corner_id( + self, registry: "EntityRegistry" + ) -> EntityID | None: + """Finds the 4th point ID of the text box.""" + for eid in self.construction_line_ids: + entity = registry.get_entity(eid) + if isinstance(entity, Line): + if entity.p1_idx == self.width_id and ( + entity.p2_idx != self.origin_id + and entity.p2_idx != self.height_id + ): + return entity.p2_idx + if entity.p2_idx == self.width_id and ( + entity.p1_idx != self.origin_id + and entity.p1_idx != self.height_id + ): + return entity.p1_idx + return None + + def get_state(self) -> dict[str, Any]: + state = super().get_state() + if state is not None: + state["fill_color"] = self.fill_color + else: + state = {"fill_color": self.fill_color} + return state + + def set_state(self, state: dict[str, Any]) -> None: + super().set_state(state) + if "fill_color" in state: + self.fill_color = state["fill_color"] + + def update_constrained_status( + self, registry: "EntityRegistry", constraints: Sequence["Constraint"] + ) -> None: + p_origin = registry.get_point(self.origin_id) + p_width = registry.get_point(self.width_id) + p_height = registry.get_point(self.height_id) + self.constrained = ( + p_origin.constrained + and p_width.constrained + and p_height.constrained + ) + + def is_contained_by( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + p_origin = registry.get_point(self.origin_id) + p_width = registry.get_point(self.width_id) + p_height = registry.get_point(self.height_id) + + p4_x = p_width.x + p_height.x - p_origin.x + p4_y = p_width.y + p_height.y - p_origin.y + + points = [ + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p4_x, p4_y), + (p_height.x, p_height.y), + ] + + return all( + rect[0] <= px <= rect[2] and rect[1] <= py <= rect[3] + for px, py in points + ) + + def intersects_rect( + self, + rect: Rect, + registry: "EntityRegistry", + ) -> bool: + p_origin = registry.get_point(self.origin_id) + p_width = registry.get_point(self.width_id) + p_height = registry.get_point(self.height_id) + + p4_x = p_width.x + p_height.x - p_origin.x + p4_y = p_width.y + p_height.y - p_origin.y + + points = [ + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p4_x, p4_y), + (p_height.x, p_height.y), + ] + + for i in range(4): + p1 = points[i] + p2 = points[(i + 1) % 4] + if does_line_segment_intersect_rect(p1, p2, rect): + return True + + return any( + rect[0] <= px <= rect[2] and rect[1] <= py <= rect[3] + for px, py in points + ) + + def _build_frame_for_content( + self, + registry: "EntityRegistry", + content: str, + ) -> tuple[ + tuple[float, float], + tuple[float, float], + tuple[float, float], + ]: + """ + Builds a frame (origin, p_width, p_height) whose dimensions match + *content*'s natural size, preserving direction vectors from the + current frame. + + Returns (origin, p_width, p_height). + """ + p_origin = registry.get_point(self.origin_id) + p_width = registry.get_point(self.width_id) + p_height = registry.get_point(self.height_id) + + if content == self.content or not self.content: + logger.debug( + f"_build_frame: no scaling, content == self.content " + f"({content!r} == {self.content!r}) or empty" + ) + return ( + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p_height.x, p_height.y), + ) + + nat_w, nat_h = self.get_natural_size(content) + + dx = p_width.x - p_origin.x + dy = p_width.y - p_origin.y + frame_w = math.hypot(dx, dy) + + if frame_w < 1e-9: + w_scale = 1.0 + else: + w_scale = nat_w / frame_w + + logger.debug( + f"_build_frame: scaling content={content!r} " + f"nat_w={nat_w:.2f} frame_w={frame_w:.2f} " + f"w_scale={w_scale:.4f}" + ) + + scaled_width = ( + p_origin.x + dx * w_scale, + p_origin.y + dy * w_scale, + ) + + hx = p_height.x - p_origin.x + hy = p_height.y - p_origin.y + frame_h = math.hypot(hx, hy) + + if frame_h < 1e-9: + h_scale = 1.0 + else: + h_scale = nat_h / frame_h + + scaled_height = ( + p_origin.x + hx * h_scale, + p_origin.y + hy * h_scale, + ) + + return ( + (p_origin.x, p_origin.y), + scaled_width, + scaled_height, + ) + + def to_geometry( + self, + registry: "EntityRegistry", + resolved_content: str | None = None, + ) -> Geometry: + """Converts the text box to a Geometry object.""" + text = ( + resolved_content if resolved_content is not None else self.content + ) + origin, pw, ph = self._build_frame_for_content(registry, text) + txt_geo = text_to_geometry(text, font_config=self.font_config) + _, geo_min_y, _, geo_max_y = txt_geo.rect() + advance_width = self.font_config.get_text_width(text) or 1.0 + return txt_geo.map_to_frame( + origin, + pw, + ph, + anchor_x=0.0, + stable_src_width=advance_width, + anchor_y=geo_min_y, + stable_src_height=geo_max_y - geo_min_y, + ) + + def create_text_fill_geometry( + self, + registry: "EntityRegistry", + resolved_content: str | None = None, + ) -> Geometry | None: + """Creates a fill geometry for text entities.""" + text = ( + resolved_content if resolved_content is not None else self.content + ) + origin, pw, ph = self._build_frame_for_content(registry, text) + txt_geo = text_to_geometry(text, font_config=self.font_config) + _, geo_min_y, _, geo_max_y = txt_geo.rect() + advance_width = self.font_config.get_text_width(text) or 1.0 + return txt_geo.map_to_frame( + origin, + pw, + ph, + anchor_x=0.0, + stable_src_width=advance_width, + anchor_y=geo_min_y, + stable_src_height=geo_max_y - geo_min_y, + ) + + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() + data.update( + { + "origin_id": self.origin_id, + "width_id": self.width_id, + "height_id": self.height_id, + "content": self.content, + "font_config": self.font_config.to_dict(), + "construction_line_ids": self.construction_line_ids, + } + ) + if self.fill_color is not None: + data["fill_color"] = list(self.fill_color) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TextBoxEntity": + fill_color_raw = data.get("fill_color") + fill_color = ( + tuple(fill_color_raw) if fill_color_raw is not None else None + ) + entity = cls( + id=data["id"], + origin_id=data["origin_id"], + width_id=data["width_id"], + height_id=data["height_id"], + content=data.get("content", ""), + font_config=FontConfig.from_dict(data.get("font_config")), + construction=data.get("construction", False), + construction_line_ids=data.get("construction_line_ids"), + ) + entity.fill_color = fill_color + return entity + + def __repr__(self) -> str: + return ( + f"TextBoxEntity(id={self.id}, origin={self.origin_id}, " + f"width={self.width_id}, height={self.height_id}, " + f"content='{self.content}')" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/params.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/params.py new file mode 100644 index 000000000..1801e0206 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/params.py @@ -0,0 +1,117 @@ +import math +from typing import Any + + +class ParameterContext: + """ + Manages named parameters and evaluates string expressions + (e.g. 'width / 2'). + """ + + def __init__(self) -> None: + self._expressions: dict[str, str] = {} + self._cache: dict[str, Any] = {} + self._dirty: bool = False + + # Safe math context + self._math_context = { + k: v for k, v in vars(math).items() if not k.startswith("_") + } + + def to_dict(self) -> dict[str, Any]: + """Serializes the parameter context to a dictionary.""" + return {"expressions": self._expressions.copy()} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ParameterContext": + """Deserializes a dictionary into a ParameterContext instance.""" + new_context = cls() + new_context._expressions = data.get("expressions", {}) + new_context._dirty = True # Force re-evaluation on next get + return new_context + + def set(self, name: str, value: float | str) -> None: + """Sets a parameter. Can be a float or a math string.""" + self._expressions[name] = str(value) + self._dirty = True + + def get(self, name: str, default: Any = 0.0) -> Any: + """Gets the evaluated value of a parameter.""" + if self._dirty: + self.evaluate_all() + return self._cache.get(name, default) + + def get_all_values(self) -> dict[str, Any]: + """Evaluates all expressions and returns a dictionary of all values.""" + if self._dirty: + self.evaluate_all() + return self._cache.copy() + + def evaluate(self, expression: str | float) -> Any: + """Evaluates an arbitrary expression string using current context.""" + if isinstance(expression, (int, float)): + return float(expression) + + if self._dirty: + self.evaluate_all() + + # Check if it's just a variable name + if expression in self._cache: + return self._cache[expression] + + # Merge math context with current variable values + ctx = self._math_context.copy() + if self._cache: + ctx.update(self._cache) + + try: + return eval(str(expression), {"__builtins__": None}, ctx) + except Exception: # noqa: BLE001 - arbitrary user expression eval + return 0.0 + + def evaluate_all( + self, initial_values: dict[str, Any] | None = None + ) -> None: + """ + Iteratively resolves dependencies. + Simple multi-pass solver to handle out-of-order definitions. + + Args: + initial_values: An optional dictionary of pre-set values to seed + the evaluation cache with. These have the highest + precedence. + """ + self._cache.clear() + if initial_values: + self._cache.update(initial_values) + + # Max iterations equal to number of params to prevent infinite loops + max_passes = len(self._expressions) + 1 + + for _ in range(max_passes): + progress = False + # Always start with a fresh context for each pass + ctx = self._math_context.copy() + # The cache may already contain initial_values + if self._cache: + ctx.update(self._cache) + + for name, expr in self._expressions.items(): + if name in self._cache: + continue + + try: + # The context for eval needs math and solved variables + eval_ctx = self._math_context.copy() + eval_ctx.update(self._cache) + val = eval(expr, {"__builtins__": None}, eval_ctx) + self._cache[name] = val + progress = True + except (NameError, TypeError, SyntaxError): + # Dependency missing, try next pass + pass + + if not progress: + break + + self._dirty = False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/registry.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/registry.py new file mode 100644 index 000000000..a385be460 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/registry.py @@ -0,0 +1,253 @@ +from typing import Any + +from raygeo.geo.shape.text import FontConfig +from raygeo.geo.types import Point as GeoPoint + +from .entities.arc import Arc +from .entities.bezier import Bezier +from .entities.circle import Circle +from .entities.ellipse import Ellipse +from .entities.entity import Entity +from .entities.line import Line +from .entities.point import Point +from .entities.text_box import TextBoxEntity +from .types import EntityID + +_ENTITY_CLASSES = { + "arc": Arc, + "bezier": Bezier, + "circle": Circle, + "ellipse": Ellipse, + "line": Line, + "text_box": TextBoxEntity, +} + + +class EntityRegistry: + """Stores all points and primitives.""" + + def __init__(self) -> None: + self.points: list[Point] = [] + self.entities: list[Entity] = [] + self._entity_map: dict[EntityID, Entity] = {} + self._id_counter: EntityID = 0 + + def to_dict(self) -> dict[str, Any]: + """Serializes the registry to a dictionary.""" + return { + "points": [p.to_dict() for p in self.points], + "entities": [e.to_dict() for e in self.entities], + "id_counter": self._id_counter, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "EntityRegistry": + """Deserializes a dictionary into an EntityRegistry instance.""" + new_reg = cls() + new_reg.points = [ + Point.from_dict(p_data) for p_data in data.get("points", []) + ] + entities_data = data.get("entities", []) + for e_data in entities_data: + e_type = e_data.get("type") + e_cls = _ENTITY_CLASSES.get(e_type) + if e_cls: + entity = e_cls.from_dict(e_data) + new_reg.entities.append(entity) + new_reg._entity_map[entity.id] = entity + + new_reg._id_counter = data.get("id_counter", 0) + return new_reg + + def add_arc( + self, + start: EntityID, + end: EntityID, + center: EntityID, + cw: bool = False, + construction: bool = False, + ) -> EntityID: + eid = self._id_counter + entity = Arc( + eid, start, end, center, clockwise=cw, construction=construction + ) + self.entities.append(entity) + self._entity_map[eid] = entity + self._id_counter += 1 + return eid + + def add_bezier( + self, + start_idx: EntityID, + end_idx: EntityID, + construction: bool = False, + cp1: GeoPoint | None = None, + cp2: GeoPoint | None = None, + ) -> EntityID: + eid = self._id_counter + entity = Bezier( + eid, + start_idx, + end_idx, + construction=construction, + cp1=cp1, + cp2=cp2, + ) + self.entities.append(entity) + self._entity_map[eid] = entity + self._id_counter += 1 + return eid + + def add_circle( + self, + center_idx: EntityID, + radius_pt_idx: EntityID, + construction: bool = False, + ) -> EntityID: + eid = self._id_counter + entity = Circle( + eid, center_idx, radius_pt_idx, construction=construction + ) + self.entities.append(entity) + self._entity_map[eid] = entity + self._id_counter += 1 + return eid + + def add_ellipse( + self, + center_idx: EntityID, + radius_x_pt_idx: EntityID, + radius_y_pt_idx: EntityID, + construction: bool = False, + ) -> EntityID: + eid = self._id_counter + entity = Ellipse( + eid, + center_idx, + radius_x_pt_idx, + radius_y_pt_idx, + construction=construction, + ) + self.entities.append(entity) + self._entity_map[eid] = entity + self._id_counter += 1 + return eid + + def add_line( + self, p1_idx: EntityID, p2_idx: EntityID, construction: bool = False + ) -> EntityID: + eid = self._id_counter + entity = Line(eid, p1_idx, p2_idx, construction=construction) + self.entities.append(entity) + self._entity_map[eid] = entity + self._id_counter += 1 + return eid + + def add_point(self, x: float, y: float, fixed: bool = False) -> EntityID: + pid = self._id_counter + self.points.append(Point(pid, x, y, fixed)) + self._id_counter += 1 + return pid + + def add_text_box( + self, + origin_id: EntityID, + width_id: EntityID, + height_id: EntityID, + content: str = "", + font_config: FontConfig | None = None, + ) -> EntityID: + eid = self._id_counter + entity = TextBoxEntity( + eid, + origin_id, + width_id, + height_id, + content=content, + font_config=font_config, + ) + self.entities.append(entity) + self._entity_map[eid] = entity + self._id_counter += 1 + return eid + + def remove_entities_by_id(self, entity_ids: list[EntityID]): + """Removes one or more entities from the registry by their IDs.""" + ids_to_remove = set(entity_ids) + self.entities = [e for e in self.entities if e.id not in ids_to_remove] + self._entity_map = {e.id: e for e in self.entities} + + def is_point_used(self, pid: EntityID) -> bool: + """Checks if a point is used by any entity in the sketch.""" + for e in self.entities: + if pid in e.get_point_ids(): + return True + return False + + def get_point(self, idx: EntityID) -> Point: + """Retrieves a point by its ID.""" + if 0 <= idx < len(self.points) and self.points[idx].id == idx: + return self.points[idx] + + for p in self.points: + if p.id == idx: + return p + raise IndexError(f"Point with ID {idx} not found") + + def get_entity(self, idx: EntityID) -> Entity | None: + """Retrieves a geometric entity (Line/Arc/Circle) by ID in O(1).""" + return self._entity_map.get(idx) + + def get_connected_entity_ids( + self, start_entity_id: EntityID + ) -> set[EntityID]: + """ + Finds all entities transitively connected to the start entity + through shared points using BFS. + + Args: + start_entity_id: The ID of the entity to start from. + + Returns: + A set of entity IDs that are connected to the start entity, + including the start entity itself. + """ + start_entity = self.get_entity(start_entity_id) + if start_entity is None: + return set() + + connected_entities: set[EntityID] = {start_entity_id} + points_to_visit: set[EntityID] = set(start_entity.get_point_ids()) + visited_points: set[EntityID] = set() + + while points_to_visit: + pid = points_to_visit.pop() + if pid in visited_points: + continue + visited_points.add(pid) + + for entity in self.entities: + if entity.id in connected_entities: + continue + entity_points = entity.get_point_ids() + if pid in entity_points: + connected_entities.add(entity.id) + for ep in entity_points: + if ep not in visited_points: + points_to_visit.add(ep) + + return connected_entities + + def get_rigidly_connected_points( + self, point_id: EntityID + ) -> list[EntityID]: + """ + Returns point IDs that should move together with the given point + as a rigid body during dragging. Iterates over all entities to find + any that have rigid connections for this point. + """ + result = [] + for entity in self.entities: + rigid_points = entity.get_rigidly_connected_points(point_id) + result.extend(rigid_points) + return list(set(result)) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/selection.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/selection.py new file mode 100644 index 000000000..e4e2dcf84 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/selection.py @@ -0,0 +1,121 @@ +from typing import TYPE_CHECKING + +from blinker import Signal + +from .types import EntityID + +if TYPE_CHECKING: + from .registry import EntityRegistry + + +class SketchSelection: + """Manages the selection state of the sketch editor.""" + + def __init__(self): + self.point_ids: list[EntityID] = [] + self.entity_ids: list[EntityID] = [] + self.constraint_idx: int | None = None + self.junction_pid: EntityID | None = None + self.changed = Signal() + + def clear(self): + """Clears all selections.""" + self.point_ids.clear() + self.entity_ids.clear() + self.constraint_idx = None + self.junction_pid = None + self.changed.send(self) + + def copy(self) -> "SketchSelection": + """Creates a shallow copy of the selection state.""" + new_sel = SketchSelection() + new_sel.point_ids = self.point_ids[:] + new_sel.entity_ids = self.entity_ids[:] + new_sel.constraint_idx = self.constraint_idx + new_sel.junction_pid = self.junction_pid + return new_sel + + def is_empty(self) -> bool: + """Returns True if nothing is selected.""" + return ( + not self.point_ids + and not self.entity_ids + and self.constraint_idx is None + and self.junction_pid is None + ) + + def select_constraint(self, idx: int, is_multi: bool): + """Selects a constraint by index.""" + self.constraint_idx = idx + if not is_multi: + self.point_ids.clear() + self.entity_ids.clear() + self.junction_pid = None + self.changed.send(self) + + def select_junction(self, pid: EntityID, is_multi: bool): + """Selects an implicit junction point.""" + self.junction_pid = pid + if not is_multi: + self.point_ids.clear() + self.entity_ids.clear() + self.constraint_idx = None + self.changed.send(self) + + def select_point(self, pid: EntityID, is_multi: bool): + """Selects a point by ID.""" + self._update_list(self.point_ids, pid, is_multi) + if not is_multi: + self.entity_ids.clear() + self.constraint_idx = None + self.junction_pid = None + self.changed.send(self) + + def select_entity(self, entity, is_multi: bool): + """Selects a geometric entity (Line or Arc).""" + self._update_list(self.entity_ids, entity.id, is_multi) + + # Note: We do NOT select the control points here anymore. + # This prevents deletion of an entity from cascading to its points + # and destroying shared geometry. + # Visual highlighting is handled by the renderer. + + if not is_multi: + self.point_ids.clear() + self.constraint_idx = None + self.junction_pid = None + self.changed.send(self) + + def _update_list( + self, collection: list[EntityID], item_id: EntityID, is_multi: bool + ): + """Helper to handle toggle vs replace selection logic.""" + if is_multi: + if item_id in collection: + collection.remove(item_id) + else: + collection.append(item_id) + else: + if item_id not in collection: + collection.clear() + collection.append(item_id) + + def select_connected_entities( + self, entity_id: EntityID, registry: "EntityRegistry" + ): + """ + Adds all entities connected to the given entity through shared points + to the current selection. + + Args: + entity_id: The ID of the starting entity. + registry: The EntityRegistry to query for connected entities. + """ + connected_ids = registry.get_connected_entity_ids(entity_id) + for eid in connected_ids: + if eid not in self.entity_ids: + self.entity_ids.append(eid) + self.point_ids.clear() + self.constraint_idx = None + self.junction_pid = None + self.changed.send(self) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py new file mode 100644 index 000000000..7871e5512 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -0,0 +1,1584 @@ +import json +import logging +import math +import uuid +from collections import defaultdict +from datetime import datetime, timezone +from gettext import gettext as _ +from pathlib import Path +from typing import Any, ClassVar + +from blinker import Signal +from raygeo.geo import ( + Arc as GeoArc, +) +from raygeo.geo import ( + Bezier as GeoBezier, +) +from raygeo.geo import Geometry +from raygeo.geo import ( + Line as GeoLine, +) +from raygeo.geo import ( + Move as GeoMove, +) +from raygeo.geo.shape.polygon import is_point_inside_polygon + +from rayforge.core.asset import IAsset +from rayforge.core.color import ColorRGBA +from rayforge.core.expression import ExpressionMap +from rayforge.core.geometry_provider import IGeometryProvider +from rayforge.core.varset import VarSet +from rayforge.image.geo_renderer import render_geometry_to_png +from rayforge.image.structures import FillRenderData, FillStyle + +from .constraints import ( + AngleConstraint, + AspectRatioConstraint, + CoincidentConstraint, + CollinearConstraint, + Constraint, + ConstraintStatus, + DiameterConstraint, + DistanceConstraint, + EqualDistanceConstraint, + EqualLengthConstraint, + HorizontalConstraint, + ParallelogramConstraint, + PerpendicularConstraint, + PointOnLineConstraint, + RadiusConstraint, + SymmetryConstraint, + TangentConstraint, + VerticalConstraint, +) +from .constraints.drag import DragConstraint +from .entities import ( + Arc, + Bezier, + Circle, + Ellipse, + Entity, + Line, + TextBoxEntity, +) +from .entities.point import WaypointType +from .params import ParameterContext +from .registry import EntityRegistry +from .solver import Solver +from .types import EntityID + +DEFAULT_FILL_COLOR: ColorRGBA = (0.85, 0.85, 0.85, 0.7) +_DEFAULT_VARSET_TITLE = _("Sketch Parameters") +_DEFAULT_VARSET_DESCRIPTION = _( + "Parameters that control this sketch's geometry" +) + +logger = logging.getLogger(__name__) + + +_CONSTRAINT_CLASSES = { + "AngleConstraint": AngleConstraint, + "AspectRatioConstraint": AspectRatioConstraint, + "CoincidentConstraint": CoincidentConstraint, + "CollinearConstraint": CollinearConstraint, + "DiameterConstraint": DiameterConstraint, + "DistanceConstraint": DistanceConstraint, + "EqualDistanceConstraint": EqualDistanceConstraint, + "EqualLengthConstraint": EqualLengthConstraint, + "HorizontalConstraint": HorizontalConstraint, + "ParallelogramConstraint": ParallelogramConstraint, + "PerpendicularConstraint": PerpendicularConstraint, + "PointOnLineConstraint": PointOnLineConstraint, + "RadiusConstraint": RadiusConstraint, + "SymmetryConstraint": SymmetryConstraint, + "TangentConstraint": TangentConstraint, + "VerticalConstraint": VerticalConstraint, +} + + +class Fill: + """Represents a filled area bounded by sketch entities.""" + + def __init__( + self, + uid: str, + boundary: list[tuple[EntityID, bool]], + style: FillStyle = FillStyle.SOLID, + color: ColorRGBA = DEFAULT_FILL_COLOR, + gradient_stops: list[tuple[float, ColorRGBA]] | None = None, + gradient_angle: float = 0.0, + ): + self.uid = uid + self.boundary: list[tuple[EntityID, bool]] = boundary + self.style = style + self.color = color + self.gradient_stops = gradient_stops or [] + self.gradient_angle = gradient_angle + + def to_dict(self) -> dict[str, Any]: + data = { + "uid": self.uid, + "boundary": [list(item) for item in self.boundary], + "style": self.style.value, + "color": list(self.color), + } + if self.gradient_stops: + data["gradient_stops"] = [ + [pos, list(c)] for pos, c in self.gradient_stops + ] + if self.gradient_angle != 0.0: + data["gradient_angle"] = self.gradient_angle + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Fill": + boundary = [tuple(item) for item in data["boundary"]] + style = FillStyle(data.get("style", "solid")) + color = tuple(data.get("color", list(DEFAULT_FILL_COLOR))) + gradient_stops = None + if "gradient_stops" in data: + gradient_stops = [ + (pos, tuple(c)) for pos, c in data["gradient_stops"] + ] + gradient_angle = data.get("gradient_angle", 0.0) + return cls( + uid=data.get("uid", str(uuid.uuid4())), + boundary=boundary, + style=style, + color=color, + gradient_stops=gradient_stops, + gradient_angle=gradient_angle, + ) + + +class Sketch(IAsset, IGeometryProvider): + """ + A parametric sketcher that allows defining geometry via constraints + and expressions. + """ + + is_addable: ClassVar[bool] = True + asset_type_name: ClassVar[str] = "sketch" + display_icon_name: ClassVar[str] = "sketch-edit-symbolic" + is_reorderable: ClassVar[bool] = False + is_draggable_to_canvas: ClassVar[bool] = True + type_display_name: ClassVar[str] = _("Sketch") + can_edit: ClassVar[bool] = True + add_action: ClassVar[str | None] = "add-sketch" + activate_action: ClassVar[str | None] = "activate-sketch" + edit_item_action: ClassVar[str | None] = "edit-sketch-item" + + def __init__(self, name: str = "New Sketch") -> None: + self._uid: str = str(uuid.uuid4()) + self._name = name + self.params = ParameterContext() + self.registry = EntityRegistry() + self.constraints: list[Constraint] = [] + self.fills: list[Fill] = [] + self.input_parameters = VarSet( + title=_DEFAULT_VARSET_TITLE, + description=_DEFAULT_VARSET_DESCRIPTION, + ) + self._updated = Signal() + self._hidden: bool = False + self._last_solve_values: dict[str, Any] = {} + self._resolved_text_cache: dict[EntityID, str | None] = {} + + # Initialize the Origin Point (Fixed Anchor) + self.origin_id: EntityID = self.registry.add_point( + 0.0, 0.0, fixed=True + ) + + def notify_update(self): + """Public method to signal that the sketch has been modified.""" + self._updated.send(self) + + def _validate_and_cleanup_fills(self): + """ + Removes any Fill objects whose boundary entities no longer form a + valid, closed loop (e.g., if an entity was deleted). + """ + valid_fills = [] + # Find all currently valid loops to check against + current_loops = self._find_all_closed_loops() + # For efficient lookup, convert lists to sets of tuples + current_loop_sets = {frozenset(loop) for loop in current_loops} + + for fill in self.fills: + fill_boundary_set = frozenset(fill.boundary) + if fill_boundary_set in current_loop_sets: + valid_fills.append(fill) + + self.fills = valid_fills + + @property + def uid(self) -> str: + """The unique identifier of the asset instance.""" + return self._uid + + @uid.setter + def uid(self, value: str) -> None: + """Set the unique identifier. Used for deserialization.""" + self._uid = value + + @property + def updated(self) -> "Signal": + """Signal emitted when the sketch changes.""" + return self._updated + + @property + def name(self) -> str: + """The user-facing name of the asset.""" + return self._name + + @name.setter + def name(self, value: str): + """Sets the asset name and sends an update signal if changed.""" + if self._name != value: + self._name = value + self._updated.send(self) + + @property + def provider_type_name(self) -> str: + """The type name for geometry provider identification.""" + return "sketch" + + @property + def renderer(self): + """The renderer to use for rendering this sketch's geometry.""" + from ..image.renderer import SKETCH_RENDERER + + return SKETCH_RENDERER + + def get_geometry( + self, + params: dict[str, Any] | None = None, + *, + resolved_text_cache: dict | None = None, + ) -> tuple[Geometry, list[FillRenderData]]: + """ + Generate geometry with optional parameter overrides. + + Creates a clone, solves it with the given parameters, and returns + the stroke and fill geometries. + + When *resolved_text_cache* is supplied the clone is seeded with + those values so that volatile template expressions (e.g. + ``uuid4()``) produce the same value across calls. The dict is + updated in-place with any newly resolved values so callers can + persist the cache. + + Args: + params: Optional dictionary of parameter values to override. + resolved_text_cache: Optional mutable dict (entity_id → text) + that carries resolved text across calls. + + Returns: + A tuple of (stroke_geometry, fill_render_data). + """ + clone = Sketch.from_dict(self.to_dict()) + if resolved_text_cache is not None: + clone._resolved_text_cache = dict(resolved_text_cache) + clone.solve(variable_overrides=params) + if resolved_text_cache is not None: + for k, v in resolved_text_cache.items(): + if k not in clone._resolved_text_cache: + clone._resolved_text_cache[k] = v + geo = clone.to_geometry() + fills = clone.get_fill_render_data() + if resolved_text_cache is not None: + resolved_text_cache.update(clone._resolved_text_cache) + return geo, fills + + @property + def hidden(self) -> bool: + """Indicates if this asset should be hidden from the UI.""" + return self._hidden + + @hidden.setter + def hidden(self, value: bool): + """Sets the hidden state and sends an update signal if changed.""" + if self._hidden != value: + self._hidden = value + self._updated.send(self) + + def set_hidden(self, value: bool): + """Setter method for use with undo commands.""" + self.hidden = value + + def get_thumbnail(self, size: int) -> bytes | None: + """Returns a PNG thumbnail of the sketch geometry.""" + try: + return render_geometry_to_png(self.to_geometry(), size) + except Exception: + logger.exception("Failed to generate sketch thumbnail") + return None + + @property + def is_empty(self) -> bool: + """Returns True if the sketch has no drawable entities.""" + # We check entities rather than points, because an empty sketch + # always contains at least one point (the origin). + return len(self.registry.entities) == 0 + + @property + def is_fully_constrained(self) -> bool: + """ + Returns True if every point and every entity in the sketch + is fully constrained. + + Exception: Points that serve solely as internal handles for fully + constrained entities (e.g., Circle radius point) are ignored if they + are not constrained, provided they are not used by any other entity. + """ + # An empty sketch (just origin) is considered fully constrained + if not self.registry.points: + return True + + # 1. All entities must be constrained + if not all(e.constrained for e in self.registry.entities): + return False + + # 2. Calculate point usage counts to ensure exclusive ownership + usage_count: dict[EntityID, int] = {} + for e in self.registry.entities: + for pid in e.get_point_ids(): + usage_count[pid] = usage_count.get(pid, 0) + 1 + + # 3. Collect allowed exemptions polymorphically + allowed_unconstrained_ids = set() + for e in self.registry.entities: + candidates = e.get_ignorable_unconstrained_points() + for pid in candidates: + # Only allow exemption if the point is used exclusively by this + # entity (usage count == 1) + if usage_count.get(pid, 0) == 1: + allowed_unconstrained_ids.add(pid) + + # 4. Check all points + for p in self.registry.points: + if not p.constrained and p.id not in allowed_unconstrained_ids: + # Unconstrained points must be in the exempt list + return False + return True + + @property + def conflicting_constraints(self) -> list[Constraint]: + """ + Returns a list of constraints that are currently marked as CONFLICTING. + """ + return [ + c + for c in self.constraints + if c.status == ConstraintStatus.CONFLICTING + ] + + @property + def has_conflicts(self) -> bool: + """Returns True if any constraint has CONFLICTING status.""" + return any( + c.status == ConstraintStatus.CONFLICTING for c in self.constraints + ) + + def to_dict(self, include_input_values: bool = False) -> dict[str, Any]: + """Serializes the Sketch to a dictionary.""" + return { + "uid": self.uid, + "type": self.asset_type_name, + "name": self.name, + "input_parameters": self.input_parameters.to_dict( + include_value=include_input_values, include_metadata=False + ), + "params": self.params.to_dict(), + "registry": self.registry.to_dict(), + "constraints": [c.to_dict() for c in self.constraints], + "fills": [f.to_dict() for f in self.fills], + "origin_id": self.origin_id, + "hidden": self._hidden, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Sketch": + """Deserializes a dictionary into a Sketch instance.""" + required_keys = ["params", "registry", "constraints", "origin_id"] + if not all(key in data for key in required_keys): + raise KeyError( + "Sketch data is missing one of the required keys: " + f"{required_keys}." + ) + + new_sketch = cls() + new_sketch._uid = data.get("uid", str(uuid.uuid4())) + new_sketch.name = data.get("name", "") + + # Handle backward compatibility for input_parameters + if "input_parameters" in data: + new_sketch.input_parameters = VarSet.from_dict( + data["input_parameters"] + ) + # Re-apply the default title and description, as they are not + # serialized in the file. + new_sketch.input_parameters.title = _DEFAULT_VARSET_TITLE + new_sketch.input_parameters.description = ( + _DEFAULT_VARSET_DESCRIPTION + ) + + new_sketch.params = ParameterContext.from_dict(data["params"]) + new_sketch.registry = EntityRegistry.from_dict(data["registry"]) + new_sketch.origin_id = data["origin_id"] + new_sketch.constraints = [] + for c_data in data["constraints"]: + c_type = c_data.get("type") + c_cls = _CONSTRAINT_CLASSES.get(c_type) + if c_cls: + new_sketch.constraints.append(c_cls.from_dict(c_data)) + + new_sketch.fills = [] + for f_data in data.get("fills", []): + new_sketch.fills.append(Fill.from_dict(f_data)) + + new_sketch._hidden = data.get("hidden", False) + return new_sketch + + @classmethod + def from_file(cls, file_path: str | Path) -> "Sketch": + """Deserializes a sketch from a JSON file (.rfs).""" + with open(file_path, "r") as f: + data = json.load(f) + return cls.from_dict(data) + + @classmethod + def from_geometry(cls, geometry: Geometry) -> "Sketch": + """ + Creates a Sketch from a Geometry object. + + The geometry can contain lines, arcs, and bezier curves. + + Args: + geometry: The Geometry object to convert. + + Returns: + A new Sketch instance with entities created from the geometry. + """ + sketch = cls() + + if geometry.data is None or len(geometry.data) == 0: + return sketch + + point_map: dict[tuple[float, float], EntityID] = {} + + def get_or_add_point(x: float, y: float) -> EntityID: + key = (round(x, 6), round(y, 6)) + if key not in point_map: + point_map[key] = sketch.add_point(x, y) + return point_map[key] + + current_x, current_y = 0.0, 0.0 + current_pid: EntityID | None = None + + for cmd in geometry.iter_typed_commands(): + end_x, end_y = cmd.end[0], cmd.end[1] + + if isinstance(cmd, GeoMove): + current_x, current_y = end_x, end_y + current_pid = get_or_add_point(end_x, end_y) + elif isinstance(cmd, GeoLine): + if current_pid is None: + current_pid = get_or_add_point(current_x, current_y) + end_pid = get_or_add_point(end_x, end_y) + sketch.add_line(current_pid, end_pid) + current_pid = end_pid + current_x, current_y = end_x, end_y + elif isinstance(cmd, GeoArc): + if current_pid is None: + current_pid = get_or_add_point(current_x, current_y) + end_pid = get_or_add_point(end_x, end_y) + + i_offset, j_offset, _ = cmd.center_offset + clockwise = cmd.clockwise + + center_x = current_x + i_offset + center_y = current_y + j_offset + center_pid = get_or_add_point(center_x, center_y) + + sketch.add_arc( + current_pid, end_pid, center_pid, clockwise=clockwise + ) + current_pid = end_pid + current_x, current_y = end_x, end_y + elif isinstance(cmd, GeoBezier): + if current_pid is None: + current_pid = get_or_add_point(current_x, current_y) + + cp1_x, cp1_y, _ = cmd.control1 + cp2_x, cp2_y, _ = cmd.control2 + + start_pt = sketch.registry.get_point(current_pid) + if start_pt: + start_pt.waypoint_type = WaypointType.SMOOTH + + end_pid = get_or_add_point(end_x, end_y) + end_pt = sketch.registry.get_point(end_pid) + if end_pt: + end_pt.waypoint_type = WaypointType.SMOOTH + + cp1_offset = (cp1_x - start_pt.x, cp1_y - start_pt.y) + cp2_offset = (cp2_x - end_pt.x, cp2_y - end_pt.y) + sketch.add_bezier( + current_pid, end_pid, cp1=cp1_offset, cp2=cp2_offset + ) + current_pid = end_pid + current_x, current_y = end_x, end_y + + return sketch + + def set_param(self, name: str, value: str | float) -> None: + """Define a parameter like 'width'=100 or 'height'='width/2'.""" + self.params.set(name, value) + + def add_point(self, x: float, y: float, fixed: bool = False) -> EntityID: + """Adds a point. Returns its ID.""" + return self.registry.add_point(x, y, fixed) + + def add_line( + self, p1: EntityID, p2: EntityID, construction: bool = False + ) -> EntityID: + """Adds a line segment between two point IDs.""" + return self.registry.add_line(p1, p2, construction) + + def add_arc( + self, + start: EntityID, + end: EntityID, + center: EntityID, + clockwise: bool = False, + construction: bool = False, + ) -> EntityID: + """Adds an arc defined by start, end, and center point IDs.""" + return self.registry.add_arc( + start, end, center, clockwise, construction + ) + + def add_bezier( + self, + start: EntityID, + end: EntityID, + construction: bool = False, + cp1: tuple[float, float] | None = None, + cp2: tuple[float, float] | None = None, + ) -> EntityID: + """Adds a cubic bezier curve defined by start and end point IDs. + + Control points cp1 and cp2 are relative offsets from start and end + points respectively. + """ + return self.registry.add_bezier(start, end, construction, cp1, cp2) + + def add_circle( + self, center: EntityID, radius_pt: EntityID, construction: bool = False + ) -> EntityID: + """Adds a circle defined by a center and a point on its radius.""" + return self.registry.add_circle(center, radius_pt, construction) + + def remove_entities(self, entities_to_remove: list[Entity]): + """ + Removes entities from the sketch and automatically cleans up any + dependent fills. + """ + if not entities_to_remove: + return + ids_to_remove = [e.id for e in entities_to_remove] + self.registry.remove_entities_by_id(ids_to_remove) + self._validate_and_cleanup_fills() + + def remove_point_if_unused(self, pid: EntityID | None) -> bool: + """ + Removes a point from the registry if it's not part of any entity. + + Args: + pid: The point ID to remove. If None, returns False. + + Returns: + True if the point was removed, False otherwise. + """ + if pid is None: + return False + if not self.registry.is_point_used(pid): + self.registry.points = [ + p for p in self.registry.points if p.id != pid + ] + return True + return False + + def _get_edge_tangent_at_start( + self, entity: Any, start_pid: EntityID + ) -> tuple[float, float]: + """Helper to get the tangent vector for an entity at a given point.""" + if isinstance(entity, Line): + p1 = self.registry.get_point(entity.p1_idx) + p2 = self.registry.get_point(entity.p2_idx) + if start_pid == p1.id: + return (p2.x - p1.x, p2.y - p1.y) + else: + return (p1.x - p2.x, p1.y - p2.y) + + elif isinstance(entity, Arc): + start = self.registry.get_point(entity.start_idx) + center = self.registry.get_point(entity.center_idx) + if start_pid == start.id: + # Traversing forward from the arc's start point + # Tangent of circle at P is perp to Radius CP. + # If CCW: (-dy, dx). If CW: (dy, -dx). + dx, dy = start.x - center.x, start.y - center.y + return (dy, -dx) if entity.clockwise else (-dy, dx) + else: + # Traversing backward from the arc's end point + end = self.registry.get_point(entity.end_idx) + dx, dy = end.x - center.x, end.y - center.y + # Tangent of curve at End is T. Traversal is -T. + # T_ccw = (-dy, dx). Traversal = (dy, -dx). + # T_cw = (dy, -dx). Traversal = (-dy, dx). + return (-dy, dx) if entity.clockwise else (dy, -dx) + + elif isinstance(entity, Bezier): + start = self.registry.get_point(entity.start_idx) + end = self.registry.get_point(entity.end_idx) + cp1_x, cp1_y, cp2_x, cp2_y = ( + entity.get_control_points_or_endpoints(self.registry) + ) + if start_pid == start.id: + return (cp1_x - start.x, cp1_y - start.y) + else: + return (end.x - cp2_x, end.y - cp2_y) + return (1.0, 0.0) + + def _build_adjacency_list(self) -> dict[EntityID, list[dict[str, Any]]]: + """ + Builds a map of point_id -> list of outgoing edges. + Each edge dict contains: {'to': point_id, 'id': entity_id, 'fwd': bool} + + Coincident points are treated as the same node in the graph, so edges + are added for all points in a coincident group. + """ + adj = defaultdict(list) + + # Build a mapping from each point to its coincident group + point_to_group: dict[EntityID, set[EntityID]] = {} + for p in self.registry.points: + if p.id not in point_to_group: + coincident_group = self.get_coincident_points(p.id) + for pid in coincident_group: + point_to_group[pid] = coincident_group + + for e in self.registry.entities: + # Skip circles in graph traversal (handled separately) + if isinstance(e, Circle): + continue + if isinstance(e, (Line, Arc, Bezier)): + p_ids = e.get_endpoint_ids() + p1_id, p2_id = p_ids[0], p_ids[1] + + # Get the coincident groups for both endpoints + group1 = point_to_group.get(p1_id, {p1_id}) + group2 = point_to_group.get(p2_id, {p2_id}) + + # Add edges from all points in group1 to all points in group2 + for src in group1: + for dst in group2: + if src != dst: + adj[src].append( + {"to": dst, "id": e.id, "fwd": True} + ) + + # Add edges from all points in group2 to all points in group1 + for src in group2: + for dst in group1: + if src != dst: + adj[src].append( + {"to": dst, "id": e.id, "fwd": False} + ) + return adj + + def _sort_edges_by_angle( + self, adj: dict[EntityID, list[dict[str, Any]]] + ) -> dict[EntityID, list[dict[str, Any]]]: + """ + Sorts the outgoing edges at each node by angle (CCW). + """ + sorted_adj = {} + for p_id, edges in adj.items(): + edges_with_angle = [] + for edge in edges: + entity = self.registry.get_entity(edge["id"]) + if not entity: + continue + tangent_vec = self._get_edge_tangent_at_start(entity, p_id) + angle = math.atan2(tangent_vec[1], tangent_vec[0]) + edges_with_angle.append({"angle": angle, **edge}) + + # Sort by angle [-pi, pi] + edges_with_angle.sort(key=lambda x: x["angle"]) + sorted_adj[p_id] = edges_with_angle + return sorted_adj + + def _get_next_edge_ccw( + self, + current_p_id: EntityID, + incoming_entity_id: EntityID, + incoming_fwd: bool, + sorted_adj: dict[EntityID, list[dict[str, Any]]], + ) -> dict[str, Any] | None: + """ + Given an incoming edge to a node, picks the next edge in CCW order + (left-most turn) to traverse faces. + """ + outgoing_edges = sorted_adj.get(current_p_id, []) + if not outgoing_edges: + return None + + # If we arrived via `incoming_entity_id` traveling `incoming_fwd`, + # then looking back from the current node, that edge is the reverse. + rev_fwd = not incoming_fwd + + try: + # Find the edge entry in the current node's list that corresponds + # to where we came from. + idx = next( + i + for i, e in enumerate(outgoing_edges) + if e["id"] == incoming_entity_id and e["fwd"] == rev_fwd + ) + # Pick the previous edge in the sorted list (CCW rotation) + next_idx = (idx - 1) % len(outgoing_edges) + return outgoing_edges[next_idx] + except StopIteration: + return None + + def _calculate_loop_signed_area( + self, loop: list[tuple[EntityID, bool]] + ) -> float: + """Calculates signed area of the loop using Shoelace formula.""" + if not loop: + return 0.0 + + # Special case for circles + if len(loop) == 1: + entity = self.registry.get_entity(loop[0][0]) + if isinstance(entity, Circle): + center = self.registry.get_point(entity.center_idx) + radius_pt = self.registry.get_point(entity.radius_pt_idx) + radius = math.hypot( + radius_pt.x - center.x, radius_pt.y - center.y + ) + # By convention, a single circle loop is CCW -> positive area + return math.pi * radius**2 + if isinstance(entity, Ellipse): + center = self.registry.get_point(entity.center_idx) + radius_x_pt = self.registry.get_point(entity.radius_x_pt_idx) + radius_y_pt = self.registry.get_point(entity.radius_y_pt_idx) + rx = math.hypot( + radius_x_pt.x - center.x, radius_x_pt.y - center.y + ) + ry = math.hypot( + radius_y_pt.x - center.x, radius_y_pt.y - center.y + ) + return math.pi * rx * ry + + points = [] + first_ent = self.registry.get_entity(loop[0][0]) + if not first_ent: + return 0.0 + first_fwd = loop[0][1] + p_ids = first_ent.get_endpoint_ids() + curr_p_id = p_ids[0] if first_fwd else p_ids[1] + + for eid, fwd in loop: + try: + pt = self.registry.get_point(curr_p_id) + points.append((pt.x, pt.y)) + ent = self.registry.get_entity(eid) + if not ent: + return 0.0 + p_ids = ent.get_endpoint_ids() + curr_p_id = p_ids[1] if curr_p_id == p_ids[0] else p_ids[0] + except IndexError: + return 0.0 + + area = 0.0 + for i in range(len(points)): + p1 = points[i] + p2 = points[(i + 1) % len(points)] + area += p1[0] * p2[1] - p2[0] * p1[1] + area *= 0.5 + + # Add contributions from Arcs (area between chord and arc) + for eid, fwd in loop: + ent = self.registry.get_entity(eid) + if isinstance(ent, Arc): + # Calculate area of the circular segment + start = self.registry.get_point(ent.start_idx) + end = self.registry.get_point(ent.end_idx) + center = self.registry.get_point(ent.center_idx) + + # Vectors from center + r_vec_start = (start.x - center.x, start.y - center.y) + r_vec_end = (end.x - center.x, end.y - center.y) + radius_sq = r_vec_start[0] ** 2 + r_vec_start[1] ** 2 + + # Calculate sweep angle of the arc definition + ang_start = math.atan2(r_vec_start[1], r_vec_start[0]) + ang_end = math.atan2(r_vec_end[1], r_vec_end[0]) + + if ent.clockwise: + # CW: Start -> End decreases angle + diff = ang_start - ang_end + else: + # CCW: Start -> End increases angle + diff = ang_end - ang_start + + # Normalize to [0, 2pi) + while diff < 0: + diff += 2 * math.pi + while diff >= 2 * math.pi: + diff -= 2 * math.pi + + # Area of segment = 0.5 * r^2 * (theta - sin(theta)) + # This area is always positive. + seg_area = 0.5 * radius_sq * (diff - math.sin(diff)) + + # Determine sign contribution to the loop area (assumed + # CCW positive). + # If Arc is CCW and we traverse Fwd: Left turn. Add. + # If Arc is CW and we traverse Fwd: Right turn. Subtract. + # If Arc is CCW and we traverse Rev: Right turn. Subtract. + # If Arc is CW and we traverse Rev: Left turn. Add. + + is_ccw_arc = not ent.clockwise + is_left_turn = is_ccw_arc == fwd + + if is_left_turn: + area += seg_area + else: + area -= seg_area + + return area + + def _find_all_closed_loops(self) -> list[list[tuple[EntityID, bool]]]: + """ + Finds all closed loops (faces) in the sketch graph. + """ + adj = self._build_adjacency_list() + sorted_adj = self._sort_edges_by_angle(adj) + + loops = [] + visited_half_edges: set[tuple[EntityID, EntityID, bool]] = set() + + for p_start, edges in sorted_adj.items(): + for start_edge in edges: + half_edge_key = (p_start, start_edge["id"], start_edge["fwd"]) + if half_edge_key in visited_half_edges: + continue + + loop: list[tuple[EntityID, bool]] = [] + loop_half_edges: list[tuple[EntityID, EntityID, bool]] = [] + curr_p = p_start + curr_edge = start_edge + + for __ in range(len(self.registry.entities) + 1): + current_half_edge = ( + curr_p, + curr_edge["id"], + curr_edge["fwd"], + ) + if current_half_edge in visited_half_edges: + loop = [] + break + + loop.append((curr_edge["id"], curr_edge["fwd"])) + loop_half_edges.append(current_half_edge) + + next_p = curr_edge["to"] + + next_edge_info = self._get_next_edge_ccw( + next_p, curr_edge["id"], curr_edge["fwd"], sorted_adj + ) + + if not next_edge_info: + loop = [] + break + + next_key = ( + next_p, + next_edge_info["id"], + next_edge_info["fwd"], + ) + if next_key == half_edge_key: + break # Loop closed + + curr_p = next_p + curr_edge = next_edge_info + else: + loop = [] # Loop did not close + + if loop and self._calculate_loop_signed_area(loop) > 1e-6: + loops.append(loop) + # Mark all half-edges from the valid loop as visited + visited_half_edges.update(loop_half_edges) + + # Add circles as single-entity loops + for e in self.registry.entities: + if isinstance(e, Circle): + loops.append([(e.id, True)]) + + # Add ellipses as single-entity loops + for e in self.registry.entities: + if isinstance(e, Ellipse): + loops.append([(e.id, True)]) + + return loops + + def _loop_to_polygon( + self, loop: list[tuple[EntityID, bool]] + ) -> list[tuple[float, float]]: + """ + Converts a loop of entities into a list of 2D polygon vertices, + sampling beziers and linearizing arcs. + """ + polygon: list[tuple[float, float]] = [] + + for eid, fwd in loop: + entity = self.registry.get_entity(eid) + if not entity: + return [] + + vertices = entity.to_polygon_vertices(self.registry, fwd) + if not vertices: + return [] + polygon.extend(vertices) + + return polygon + + def get_loop_at_point( + self, mx: float, my: float + ) -> list[tuple[EntityID, bool]] | None: + """ + Finds the smallest closed loop containing the given point. + Returns None if no loop contains the point. + """ + all_loops = self._find_all_closed_loops() + hit_loops = [] + + for loop in all_loops: + is_hit = False + + if len(loop) == 1: + entity = self.registry.get_entity(loop[0][0]) + if isinstance(entity, Circle): + center = self.registry.get_point(entity.center_idx) + radius_pt = self.registry.get_point(entity.radius_pt_idx) + if center and radius_pt: + radius = math.hypot( + radius_pt.x - center.x, radius_pt.y - center.y + ) + dist_sq = (mx - center.x) ** 2 + (my - center.y) ** 2 + if dist_sq <= radius**2: + is_hit = True + elif isinstance(entity, Ellipse): + center = self.registry.get_point(entity.center_idx) + radius_x_pt = self.registry.get_point( + entity.radius_x_pt_idx + ) + radius_y_pt = self.registry.get_point( + entity.radius_y_pt_idx + ) + if center and radius_x_pt and radius_y_pt: + rx = math.hypot( + radius_x_pt.x - center.x, radius_x_pt.y - center.y + ) + ry = math.hypot( + radius_y_pt.x - center.x, radius_y_pt.y - center.y + ) + if rx > 1e-9 and ry > 1e-9: + rotation = math.atan2( + radius_x_pt.y - center.y, + radius_x_pt.x - center.x, + ) + cos_a = math.cos(-rotation) + sin_a = math.sin(-rotation) + dx = mx - center.x + dy = my - center.y + local_x = dx * cos_a - dy * sin_a + local_y = dx * sin_a + dy * cos_a + ellipse_dist = (local_x / rx) ** 2 + ( + local_y / ry + ) ** 2 + if ellipse_dist <= 1.0: + is_hit = True + else: + polygon = self._loop_to_polygon(loop) + if polygon and is_point_inside_polygon((mx, my), polygon): + is_hit = True + + if is_hit: + area = abs(self._calculate_loop_signed_area(loop)) + hit_loops.append((area, loop)) + + if not hit_loops: + return None + + hit_loops.sort(key=lambda x: x[0]) + return hit_loops[0][1] + + # --- Constraint Shortcuts --- + + def get_coincident_points(self, start_pid: EntityID) -> set[EntityID]: + """ + Finds all points transitively connected to start_pid via + CoincidentConstraints. + Returns a set including the starting point itself. + """ + coincident_group = {start_pid} + + # Use a list as a queue for a breadth-first search + queue = [start_pid] + visited = {start_pid} + + head = 0 + while head < len(queue): + current_pid = queue[head] + head += 1 + + for constr in self.constraints: + if not isinstance(constr, CoincidentConstraint): + continue + + # Find the other point in the constraint + other_pid = -1 + if constr.p1 == current_pid: + other_pid = constr.p2 + elif constr.p2 == current_pid: + other_pid = constr.p1 + + if other_pid != -1 and other_pid not in visited: + visited.add(other_pid) + coincident_group.add(other_pid) + queue.append(other_pid) + + return coincident_group + + def constrain_distance( + self, p1: EntityID, p2: EntityID, dist: str | float + ) -> DistanceConstraint: + constr = DistanceConstraint(p1, p2, dist) + self.constraints.append(constr) + return constr + + def constrain_equal_distance( + self, p1: EntityID, p2: EntityID, p3: EntityID, p4: EntityID + ) -> None: + """Enforces dist(p1, p2) == dist(p3, p4).""" + self.constraints.append(EqualDistanceConstraint(p1, p2, p3, p4)) + + def constrain_horizontal(self, p1: EntityID, p2: EntityID) -> None: + self.constraints.append(HorizontalConstraint(p1, p2)) + + def constrain_vertical(self, p1: EntityID, p2: EntityID) -> None: + self.constraints.append(VerticalConstraint(p1, p2)) + + def constrain_coincident(self, p1: EntityID, p2: EntityID) -> None: + self.constraints.append(CoincidentConstraint(p1, p2)) + + def constrain_point_on_line( + self, point_id: EntityID, shape_id: EntityID + ) -> None: + self.constraints.append(PointOnLineConstraint(point_id, shape_id)) + + def constrain_radius( + self, entity_id: EntityID, radius: str | float + ) -> RadiusConstraint: + constr = RadiusConstraint(entity_id, radius) + self.constraints.append(constr) + return constr + + def constrain_diameter( + self, circle_id: EntityID, diameter: str | float + ) -> DiameterConstraint: + constr = DiameterConstraint(circle_id, diameter) + self.constraints.append(constr) + return constr + + def constrain_perpendicular(self, l1: EntityID, l2: EntityID) -> None: + self.constraints.append(PerpendicularConstraint(l1, l2)) + + def constrain_tangent(self, line: EntityID, shape: EntityID) -> None: + self.constraints.append(TangentConstraint(line, shape)) + + def constrain_equal_length(self, entity_ids: list[EntityID]) -> None: + """Enforces equal length/radius between two or more entities.""" + if len(entity_ids) < 2: + return + self.constraints.append(EqualLengthConstraint(entity_ids)) + + def constrain_symmetry( + self, point_ids: list[EntityID], entity_ids: list[EntityID] + ) -> None: + """ + Enforces symmetry. + - If 3 points: The first in point_ids is treated as the center. + - If 2 points + 1 Line: The line is the axis. + """ + if len(point_ids) == 3 and not entity_ids: + # 3 Points: First is Center, other two are symmetric + center = point_ids[0] + p1 = point_ids[1] + p2 = point_ids[2] + self.constraints.append(SymmetryConstraint(p1, p2, center=center)) + + elif len(point_ids) == 2 and len(entity_ids) == 1: + # 2 Points + 1 Line: Line is Axis + p1 = point_ids[0] + p2 = point_ids[1] + axis = entity_ids[0] + self.constraints.append(SymmetryConstraint(p1, p2, axis=axis)) + + # --- Manipulation & Processing --- + + def move_point(self, pid: EntityID, x: float, y: float) -> bool: + """ + Attempts to move a point to a new location and resolve constraints. + Returns True if the point was moved, False if it is locked/constrained. + """ + try: + p = self.registry.get_point(pid) + except IndexError: + return False + + if p.fixed: + return False + + # Backend Logic: If the solver has determined this point has 0 degrees + # of freedom (fully constrained), we reject kinematic movement. + if p.constrained: + return False + + # Perturbation Strategy: Update initial guess, then solve. + p.x = x + p.y = y + + return self.solve() + + def solve( + self, + extra_constraints: list[Constraint] | None = None, + update_constraint_status: bool = True, + variable_overrides: dict[str, Any] | None = None, + ) -> bool: + """ + Resolves all constraints. + + Args: + extra_constraints: A list of temporary constraints to add for this + solve, e.g., for dragging. + update_constraint_status: If True, re-calculates the degrees of + freedom for all points and entities after a successful solve. + variable_overrides: A dictionary of parameter values to use for + this solve only, without permanently changing the sketch's + parameters. e.g., `{'width': 150.0}`. + + Returns: + True if the solver converged successfully. + """ + success = False + try: + # Step 1: Create a disposable ParameterContext clone for this + # solve. + solve_params = ParameterContext.from_dict(self.params.to_dict()) + + # Step 2: Build the seed dictionary, starting with defaults from + # the VarSet, then applying instance-specific overrides. + initial_values = {} + if self.input_parameters: + initial_values.update(self.input_parameters.get_values()) + if variable_overrides: + initial_values.update(variable_overrides) + + self._last_solve_values = dict(initial_values) + self._resolved_text_cache = {} + + # Step 3: Evaluate all expressions from scratch using the temporary + # context, seeded with the combined values. + solve_params.evaluate_all(initial_values=initial_values) + ctx = solve_params.get_all_values() + + # --- Solver Stabilization --- + # Add weak, temporary constraints to every non-fixed point, + # pulling it towards its current location. This acts as an + # "inertia" term, encouraging the solver to find the solution + # closest to the current state and preventing large, unexpected + # geometric jumps. + stabilizer_constraints = [] + hold_weight = 1e-4 + for p in self.registry.points: + if not p.fixed: + stabilizer_constraints.append( + DragConstraint( + p.id, + p.x, + p.y, + weight=hold_weight, + user_visible=False, + ) + ) + + # Step 4: Update constraints with the final, resolved values. + all_constraints = self.constraints + if extra_constraints: + all_constraints = self.constraints + extra_constraints + for c in all_constraints: + if hasattr(c, "update_from_context"): + c.update_from_context(ctx) + + # Step 5: Run the solver with the disposable, correctly + # evaluated context. + solver = Solver( + self.registry, + solve_params, + all_constraints, + auxiliary_constraints=stabilizer_constraints, + ) + success = solver.solve(update_dof=update_constraint_status) + + # Step 6: Update constraint conflict status + if update_constraint_status: + self._update_conflict_status(solver) + + except Exception: + import logging + + logging.getLogger(__name__).exception("Sketch solve failed") + success = False + + return success + + def _update_conflict_status(self, solver: Solver) -> None: + """ + Updates the conflict status of all constraints based on solver results. + Constraints with significant residual error are marked as CONFLICTING. + """ + conflicting_indices = solver.get_conflicting_constraints() + + for idx, constraint in enumerate(self.constraints): + if idx in conflicting_indices: + if constraint.status != ConstraintStatus.ERROR: + constraint.status = ConstraintStatus.CONFLICTING + elif constraint.status == ConstraintStatus.CONFLICTING: + constraint.status = ConstraintStatus.VALID + + def _resolve_text_content(self, entity: TextBoxEntity) -> str | None: + """ + Resolves template expressions in a text box's content using + the sketch's current parameter values. Returns None if the + content has no templates or resolution fails. + + Results are cached per entity so that volatile expressions + (e.g. uuid4()) produce the same value across multiple calls + within a single solve cycle. + """ + if entity.id in self._resolved_text_cache: + return self._resolved_text_cache[entity.id] + + if not entity.content: + self._resolved_text_cache[entity.id] = None + return None + + try: + solve_params = ParameterContext.from_dict(self.params.to_dict()) + initial_values = dict(self._last_solve_values) + if not initial_values and self.input_parameters: + initial_values.update(self.input_parameters.get_values()) + solve_params.evaluate_all(initial_values=initial_values) + ctx = solve_params.get_all_values() + ctx["today"] = lambda: datetime.now(tz=timezone.utc).date() + ctx["now"] = lambda: datetime.now(tz=timezone.utc) + ctx["uuid4"] = lambda: str(uuid.uuid4())[:8] + expr_map = ExpressionMap(ctx) + resolved = expr_map.format(entity.content) + logger.debug( + f"_resolve_text_content: content=" + f"{entity.content!r} -> {resolved!r} " + f"values={list(ctx.keys())[:5]}" + ) + self._resolved_text_cache[entity.id] = resolved + return resolved + except (KeyError, IndexError, ValueError) as e: + logger.debug( + f"Template resolution failed for '{entity.content}': {e}" + ) + self._resolved_text_cache[entity.id] = None + return None + + def to_geometry(self) -> Geometry: + """ + Converts the solved sketch into a Geometry object. + Links separate entities into continuous paths where possible. + """ + geo = Geometry() + + # 1. Identify chainable vs standalone + chainable = [] + standalone = [] + + for e in self.registry.entities: + if e.construction: + continue + if e.invisible: + continue + if isinstance(e, (Line, Arc, Bezier)): + chainable.append(e) + else: + standalone.append(e) + + # 2. Add standalone geometry (Circles, Text) + for e in standalone: + if isinstance(e, TextBoxEntity): + resolved = self._resolve_text_content(e) + geo.extend( + e.to_geometry(self.registry, resolved_content=resolved) + ) + else: + geo.extend(e.to_geometry(self.registry)) + + if not chainable: + return geo + + # 3. Build Connectivity Graph for Lines/Arcs + # Use simple Union-Find to group coincident points + parent = {p.id: p.id for p in self.registry.points} + + def find(i): + path = [] + while parent[i] != i: + path.append(i) + i = parent[i] + for node in path: + parent[node] = i + return i + + def union(i, j): + root_i = find(i) + root_j = find(j) + if root_i != root_j: + parent[root_i] = root_j + + # Apply Coincident Constraints + for c in self.constraints: + if ( + isinstance(c, CoincidentConstraint) + and c.p1 in parent + and c.p2 in parent + ): + # Points exist (sanity check) + union(c.p1, c.p2) + + adj = defaultdict(list) + for e in chainable: + if isinstance(e, Line): + u, v = e.p1_idx, e.p2_idx + elif isinstance(e, (Arc, Bezier)): + u, v = e.start_idx, e.end_idx + else: + continue + + root_u = find(u) + root_v = find(v) + adj[root_u].append((e, root_v)) + adj[root_v].append((e, root_u)) + + # 4. Traverse Graph to build continuous paths + visited = set() + + # Helper to get start/end group IDs + def get_endpoints(ent): + if isinstance(ent, Line): + return find(ent.p1_idx), find(ent.p2_idx) + if isinstance(ent, Arc): + return find(ent.start_idx), find(ent.end_idx) + if isinstance(ent, Bezier): + return find(ent.start_idx), find(ent.end_idx) + return -1, -1 + + for start_e in chainable: + if start_e.id in visited: + continue + + # Start a new chain + visited.add(start_e.id) + + # Seed direction + u, v = get_endpoints(start_e) + + # Grow Right (from v) + right_list = [] + curr = v + while True: + found = None + for cand, neighbor in adj[curr]: + if cand.id not in visited: + found = (cand, neighbor) + break + if found: + cand, next_node = found + visited.add(cand.id) + # Direction check: if c_u == curr, then Forward (u->v) + c_u, _ = get_endpoints(cand) + is_fwd = c_u == curr + right_list.append((cand, is_fwd)) + curr = next_node + else: + break + + # Grow Left (from u) + left_list = [] + curr = u + while True: + found = None + for cand, neighbor in adj[curr]: + if cand.id not in visited: + found = (cand, neighbor) + break + if found: + cand, next_node = found + visited.add(cand.id) + # We are growing backwards from u. + # cand connects next_node <-> curr(u). + # We want flow: next_node -> curr. + # If cand is Forward (c_u -> c_v), then c_u must be + # next_node. + c_u, _ = get_endpoints(cand) + is_fwd = c_u == next_node + left_list.append((cand, is_fwd)) + curr = next_node + else: + break + + # Assemble: Reversed(Left) -> Seed -> Right + final_chain = ( + list(reversed(left_list)) + [(start_e, True)] + right_list + ) + + # Generate Geometry + first_e, first_fwd = final_chain[0] + if isinstance(first_e, Line): + s_id = first_e.p1_idx if first_fwd else first_e.p2_idx + elif isinstance(first_e, Arc): + s_id = first_e.start_idx if first_fwd else first_e.end_idx + else: # Bezier + s_id = first_e.start_idx if first_fwd else first_e.end_idx + + start_pt = self.registry.get_point(s_id) + geo.move_to(start_pt.x, start_pt.y) + + for ent, fwd in final_chain: + ent.append_to_geometry(geo, self.registry, fwd) + + return geo + + def get_fill_render_data( + self, exclude_ids: set[EntityID] | None = None + ) -> list[FillRenderData]: + """ + Generates FillRenderData objects for all defined fills. + + Each FillRenderData contains the geometry and styling information + needed to render a fill region. + + Args: + exclude_ids: Optional set of entity IDs to exclude from fill + generation (e.g., text boxes being edited). + """ + if exclude_ids is None: + exclude_ids = set() + + render_data = [] + for fill in self.fills: + if not fill.boundary: + continue + + geo = self._create_fill_geometry(fill, exclude_ids) + if geo is not None: + render_data.append( + FillRenderData( + geometry=geo, + style=fill.style, + color=fill.color, + gradient_stops=fill.gradient_stops, + gradient_angle=fill.gradient_angle, + ) + ) + + for entity in self.registry.entities: + if entity.id in exclude_ids: + continue + if not entity.construction and isinstance(entity, TextBoxEntity): + resolved = self._resolve_text_content(entity) + text_geo = entity.create_text_fill_geometry( + self.registry, resolved_content=resolved + ) + if text_geo: + color = ( + entity.fill_color + if isinstance(entity, TextBoxEntity) + and entity.fill_color is not None + else DEFAULT_FILL_COLOR + ) + render_data.append( + FillRenderData( + geometry=text_geo, + style=FillStyle.SOLID, + color=color, + ) + ) + + return render_data + + def _create_fill_geometry( + self, fill: "Fill", exclude_ids: set[EntityID] + ) -> Geometry | None: + """Create geometry for a single fill.""" + if len(fill.boundary) == 1: + eid, _ = fill.boundary[0] + if eid in exclude_ids: + return None + entity = self.registry.get_entity(eid) + if entity: + return entity.create_fill_geometry(self.registry) + return None + + try: + first_eid, first_fwd = fill.boundary[0] + if first_eid in exclude_ids: + return None + first_ent = self.registry.get_entity(first_eid) + if not first_ent: + return None + + p_ids = first_ent.get_endpoint_ids() + start_pid = p_ids[0] if first_fwd else p_ids[1] + start_pt = self.registry.get_point(start_pid) + + geo = Geometry() + geo.move_to(start_pt.x, start_pt.y) + + for eid, fwd in fill.boundary: + if eid in exclude_ids: + return None + entity = self.registry.get_entity(eid) + if not entity: + return None + entity.append_to_geometry(geo, self.registry, fwd) + + return geo + + except (IndexError, AttributeError): + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/__init__.py new file mode 100644 index 000000000..23211ad8f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/__init__.py @@ -0,0 +1,24 @@ +from .engine import SnapEngine, SnapLineProducer +from .spatial import SnapLineIndex +from .types import ( + SNAP_LINE_STYLES, + DragContext, + SnapLine, + SnapLineStyle, + SnapLineType, + SnapPoint, + SnapResult, +) + +__all__ = [ + "SNAP_LINE_STYLES", + "DragContext", + "SnapEngine", + "SnapLine", + "SnapLineIndex", + "SnapLineProducer", + "SnapLineStyle", + "SnapLineType", + "SnapPoint", + "SnapResult", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/engine.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/engine.py new file mode 100644 index 000000000..6eb449e99 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/engine.py @@ -0,0 +1,238 @@ +import logging +from collections.abc import Iterator + +from raygeo.geo.types import Point as GeoPoint + +from ..registry import EntityRegistry +from .spatial import SnapLineIndex +from .types import ( + DragContext, + SnapLine, + SnapPoint, + SnapResult, +) + +logger = logging.getLogger(__name__) + + +class SnapLineProducer: + def produce( + self, + registry: EntityRegistry, + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + raise NotImplementedError + + def produce_points( + self, + registry: EntityRegistry, + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + return iter(()) + + +class SnapEngine: + DEFAULT_THRESHOLD = 5.0 + + def __init__(self, threshold: float = DEFAULT_THRESHOLD) -> None: + self._producers: list[SnapLineProducer] = [] + self._threshold: float = threshold + self._index: SnapLineIndex = SnapLineIndex() + self._cached_points: list[SnapPoint] = [] + self._last_query_pos: GeoPoint | None = None + self._enabled: bool = True + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + self._enabled = value + + @property + def threshold(self) -> float: + return self._threshold + + @threshold.setter + def threshold(self, value: float) -> None: + self._threshold = value + + def register_producer(self, producer: SnapLineProducer) -> None: + self._producers.append(producer) + + def unregister_producer(self, producer: SnapLineProducer) -> None: + if producer in self._producers: + self._producers.remove(producer) + + def clear_producers(self) -> None: + self._producers.clear() + + def rebuild_index( + self, + registry: EntityRegistry, + drag_position: GeoPoint, + drag_context: DragContext, + ) -> None: + self._index.clear() + self._cached_points.clear() + + for producer in self._producers: + try: + self._index.add_all( + producer.produce( + registry, drag_position, drag_context, self._threshold + ) + ) + for snap_point in producer.produce_points( + registry, drag_position, drag_context, self._threshold + ): + self._cached_points.append(snap_point) + except Exception as e: # noqa: BLE001 - addon producer boundary + logger.warning(f"SnapLineProducer error: {e}") + + self._last_query_pos = drag_position + + def query( + self, + registry: EntityRegistry, + position: GeoPoint, + drag_context: DragContext | None = None, + ) -> SnapResult: + if not self._enabled: + return SnapResult.no_snap(position) + + if drag_context is None: + drag_context = DragContext() + + self.rebuild_index(registry, position, drag_context) + + x, y = position + + point_result = self._find_nearest_snap_point(x, y) + if point_result is not None: + snap_point, dist = point_result + crossing_lines = self._find_crossing_lines(x, y, snap_point) + return SnapResult.from_snap_point(snap_point, dist, crossing_lines) + + best_h, best_v = self._find_best_lines_for_both_axes(x, y) + if best_h is not None or best_v is not None: + snap_x = best_v.coordinate if best_v else x + snap_y = best_h.coordinate if best_h else y + snap_lines: list[SnapLine] = [] + if best_h: + snap_lines.append(best_h) + if best_v: + snap_lines.append(best_v) + dist = max( + best_h.distance_to(x, y) if best_h else 0, + best_v.distance_to(x, y) if best_v else 0, + ) + return SnapResult( + snapped=True, + position=(snap_x, snap_y), + snap_lines=snap_lines, + distance=dist, + ) + + return SnapResult.no_snap(position) + + def _find_best_lines_for_both_axes( + self, x: float, y: float + ) -> tuple[SnapLine | None, SnapLine | None]: + best_h: SnapLine | None = None + best_h_dist: float = self._threshold + best_v: SnapLine | None = None + best_v_dist: float = self._threshold + + for indexed in self._index._horizontal: + if indexed.snap_line is None: + continue + dist = abs(y - indexed.coordinate) + if dist < best_h_dist: + best_h_dist = dist + best_h = indexed.snap_line + + for indexed in self._index._vertical: + if indexed.snap_line is None: + continue + dist = abs(x - indexed.coordinate) + if dist < best_v_dist: + best_v_dist = dist + best_v = indexed.snap_line + + return (best_h, best_v) + + def _find_nearest_snap_point( + self, x: float, y: float + ) -> tuple[SnapPoint, float] | None: + best_point: SnapPoint | None = None + best_dist: float = self._threshold + best_priority: int = -1 + + for sp in self._cached_points: + dx = x - sp.x + dy = y - sp.y + dist = (dx * dx + dy * dy) ** 0.5 + priority = sp.line_type.priority + + if dist > self._threshold: + continue + + if priority > best_priority or ( + priority == best_priority and dist < best_dist + ): + best_dist = dist + best_point = sp + best_priority = priority + + if best_point is not None: + return (best_point, best_dist) + return None + + def _find_crossing_lines( + self, x: float, y: float, snap_point: SnapPoint + ) -> list[SnapLine]: + crossing: list[SnapLine] = [] + for sl in self._get_all_lines(): + if ( + sl.is_horizontal + and abs(sl.coordinate - snap_point.y) < 1e-6 + or ( + not sl.is_horizontal + and abs(sl.coordinate - snap_point.x) < 1e-6 + ) + ): + crossing.append(sl) + return crossing + + def _get_all_lines(self) -> list[SnapLine]: + lines: list[SnapLine] = [] + for indexed in self._index._horizontal: + if indexed.snap_line is not None: + lines.append(indexed.snap_line) + for indexed in self._index._vertical: + if indexed.snap_line is not None: + lines.append(indexed.snap_line) + return lines + + def get_visible_snap_lines( + self, + registry: EntityRegistry, + position: GeoPoint, + drag_context: DragContext | None = None, + ) -> list[SnapLine]: + if not self._enabled: + return [] + + if drag_context is None: + drag_context = DragContext() + + if self._last_query_pos != position: + self.rebuild_index(registry, position, drag_context) + + return self._get_all_lines() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/__init__.py new file mode 100644 index 000000000..e1bc98f47 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/__init__.py @@ -0,0 +1,20 @@ +from ..engine import SnapLineProducer +from ..types import SnapLine, SnapPoint +from .centers import CentersProducer +from .entity_points import EntityPointsProducer +from .equidistant import EquidistantLinesProducer +from .intersections import IntersectionsProducer +from .midpoints import MidpointsProducer +from .on_entity import OnEntityProducer + +__all__ = [ + "CentersProducer", + "EntityPointsProducer", + "EquidistantLinesProducer", + "IntersectionsProducer", + "MidpointsProducer", + "OnEntityProducer", + "SnapLine", + "SnapLineProducer", + "SnapPoint", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/centers.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/centers.py new file mode 100644 index 000000000..0b252d306 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/centers.py @@ -0,0 +1,99 @@ +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ...entities import Arc, Circle, Ellipse +from ..engine import SnapLineProducer +from ..types import DragContext, SnapLine, SnapLineType, SnapPoint + +if TYPE_CHECKING: + from ...registry import EntityRegistry + + +class CentersProducer(SnapLineProducer): + def __init__(self, include_construction: bool = True) -> None: + self._include_construction: bool = include_construction + + def produce( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + x, y = drag_position + for entity in registry.entities: + if drag_context.is_entity_dragged(entity.id): + continue + if not self._include_construction and entity.construction: + continue + + if not isinstance(entity, (Arc, Circle, Ellipse)): + continue + + if drag_context.is_point_dragged(entity.center_idx): + continue + + center = self._get_center(entity, registry) + if center is None: + continue + + cx, cy = center + if abs(cx - x) <= threshold: + yield SnapLine( + is_horizontal=False, + coordinate=cx, + line_type=SnapLineType.CENTER, + source=entity, + ) + if abs(cy - y) <= threshold: + yield SnapLine( + is_horizontal=True, + coordinate=cy, + line_type=SnapLineType.CENTER, + source=entity, + ) + + def produce_points( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + x, y = drag_position + for entity in registry.entities: + if drag_context.is_entity_dragged(entity.id): + continue + if not self._include_construction and entity.construction: + continue + + if not isinstance(entity, (Arc, Circle, Ellipse)): + continue + + if drag_context.is_point_dragged(entity.center_idx): + continue + + center = self._get_center(entity, registry) + if center is None: + continue + + cx, cy = center + dist = ((cx - x) ** 2 + (cy - y) ** 2) ** 0.5 + if dist <= threshold: + yield SnapPoint( + x=cx, + y=cy, + line_type=SnapLineType.CENTER, + source=entity, + ) + + def _get_center( + self, entity: object, registry: "EntityRegistry" + ) -> GeoPoint | None: + if isinstance(entity, (Arc, Circle, Ellipse)): + center = registry.get_point(entity.center_idx) + if center: + return (center.x, center.y) + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/entity_points.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/entity_points.py new file mode 100644 index 000000000..78d980cd1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/entity_points.py @@ -0,0 +1,62 @@ +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..engine import SnapLineProducer +from ..types import DragContext, SnapLine, SnapLineType, SnapPoint + +if TYPE_CHECKING: + from ...registry import EntityRegistry + + +class EntityPointsProducer(SnapLineProducer): + def produce( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + x, y = drag_position + for point in registry.points: + if drag_context.is_point_dragged(point.id): + continue + + px, py = point.x, point.y + if abs(px - x) <= threshold: + yield SnapLine( + is_horizontal=False, + coordinate=px, + line_type=SnapLineType.ENTITY_POINT, + source=point, + ) + if abs(py - y) <= threshold: + yield SnapLine( + is_horizontal=True, + coordinate=py, + line_type=SnapLineType.ENTITY_POINT, + source=point, + ) + + def produce_points( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + x, y = drag_position + for point in registry.points: + if drag_context.is_point_dragged(point.id): + continue + + px, py = point.x, point.y + dist = ((px - x) ** 2 + (py - y) ** 2) ** 0.5 + if dist <= threshold: + yield SnapPoint( + x=px, + y=py, + line_type=SnapLineType.ENTITY_POINT, + source=point, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/equidistant.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/equidistant.py new file mode 100644 index 000000000..c2b68e2de --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/equidistant.py @@ -0,0 +1,170 @@ +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..engine import SnapLineProducer +from ..types import DragContext, SnapLine, SnapLineType, SnapPoint + +if TYPE_CHECKING: + from ...registry import EntityRegistry + + +class EquidistantLinesProducer(SnapLineProducer): + def __init__( + self, + spacing_tolerance: float = 0.5, + max_spacing: float = 100.0, + include_construction: bool = True, + ) -> None: + self._spacing_tolerance: float = spacing_tolerance + self._max_spacing: float = max_spacing + self._include_construction: bool = include_construction + + def produce( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + return iter(()) + + def produce_points( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + x, y = drag_position + + v_coords, axis_x = self._collect_aligned_coords_with_axis( + registry, drag_context, fixed_x=x, threshold=threshold + ) + h_coords, axis_y = self._collect_aligned_coords_with_axis( + registry, drag_context, fixed_y=y, threshold=threshold + ) + + for snap_coord, spacing, pattern in self._find_equidistant_snaps( + v_coords, y, threshold + ): + yield SnapPoint( + x=axis_x, + y=snap_coord, + line_type=SnapLineType.EQUIDISTANT, + spacing=spacing, + is_horizontal=True, + pattern_coords=pattern, + axis_coord=axis_x, + ) + + for snap_coord, spacing, pattern in self._find_equidistant_snaps( + h_coords, x, threshold + ): + yield SnapPoint( + x=snap_coord, + y=axis_y, + line_type=SnapLineType.EQUIDISTANT, + spacing=spacing, + is_horizontal=False, + pattern_coords=pattern, + axis_coord=axis_y, + ) + + def _collect_aligned_coords_with_axis( + self, + registry: "EntityRegistry", + drag_context: DragContext, + fixed_x: float | None = None, + fixed_y: float | None = None, + threshold: float = 0.0, + ) -> tuple[list[float], float]: + coords: list[float] = [] + axis_values: list[float] = [] + + for point in registry.points: + if drag_context.is_point_dragged(point.id): + continue + + if fixed_x is not None: + if abs(point.x - fixed_x) > threshold: + continue + coords.append(point.y) + axis_values.append(point.x) + elif fixed_y is not None: + if abs(point.y - fixed_y) > threshold: + continue + coords.append(point.x) + axis_values.append(point.y) + + axis_coord = ( + sum(axis_values) / len(axis_values) if axis_values else 0.0 + ) + return sorted(set(coords)), axis_coord + + def _find_equidistant_snaps( + self, + aligned_coords: list[float], + drag_coord: float, + threshold: float, + ) -> Iterator[tuple[float, float, tuple[float, ...]]]: + if len(aligned_coords) < 2: + return + + seen_snaps: set[float] = set() + + spacings: set[float] = set() + for i in range(len(aligned_coords) - 1): + spacing = aligned_coords[i + 1] - aligned_coords[i] + if spacing > 1e-6 and spacing <= self._max_spacing: + spacings.add(round(spacing, 6)) + + for spacing in spacings: + for base_coord in aligned_coords: + n = round((drag_coord - base_coord) / spacing) + snap_coord = base_coord + n * spacing + + if snap_coord in seen_snaps: + continue + if abs(snap_coord - drag_coord) > threshold: + continue + + pattern_coords = self._build_pattern( + aligned_coords, snap_coord, spacing + ) + + if len(pattern_coords) >= 3: + seen_snaps.add(snap_coord) + yield (snap_coord, spacing, pattern_coords) + + def _build_pattern( + self, + aligned_coords: list[float], + snap_coord: float, + spacing: float, + ) -> tuple[float, ...]: + all_coords: set[float] = set(aligned_coords) + all_coords.add(snap_coord) + + pattern: list[float] = [] + + coord = snap_coord + while any( + abs(coord - c) < self._spacing_tolerance for c in all_coords + ): + pattern.append(coord) + coord -= spacing + if coord < min(all_coords) - spacing: + break + + coord = snap_coord + spacing + while any( + abs(coord - c) < self._spacing_tolerance for c in all_coords + ): + pattern.append(coord) + coord += spacing + if coord > max(all_coords) + spacing: + break + + return tuple(sorted(pattern)) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/intersections.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/intersections.py new file mode 100644 index 000000000..23a280b0e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/intersections.py @@ -0,0 +1,220 @@ +import math +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from raygeo.geo.shape.circle import ( + get_circle_circle_intersections, + get_line_circle_intersections, +) +from raygeo.geo.shape.line import get_line_segment_intersection +from raygeo.geo.types import Point as GeoPoint + +from ...entities import Arc, Circle, Line +from ..engine import SnapLineProducer +from ..types import DragContext, SnapLine, SnapLineType, SnapPoint + +if TYPE_CHECKING: + from ...registry import EntityRegistry + + +class IntersectionsProducer(SnapLineProducer): + def __init__(self, include_construction: bool = True) -> None: + self._include_construction: bool = include_construction + + def produce( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + x, y = drag_position + for ix, iy in self._get_all_intersections(registry, drag_context): + if abs(ix - x) <= threshold: + yield SnapLine( + is_horizontal=False, + coordinate=ix, + line_type=SnapLineType.INTERSECTION, + ) + if abs(iy - y) <= threshold: + yield SnapLine( + is_horizontal=True, + coordinate=iy, + line_type=SnapLineType.INTERSECTION, + ) + + def produce_points( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + x, y = drag_position + for ix, iy in self._get_all_intersections(registry, drag_context): + dist = ((ix - x) ** 2 + (iy - y) ** 2) ** 0.5 + if dist <= threshold: + yield SnapPoint( + x=ix, + y=iy, + line_type=SnapLineType.INTERSECTION, + ) + + def _get_all_intersections( + self, registry: "EntityRegistry", drag_context: DragContext + ) -> Iterator[GeoPoint]: + entities = [ + e + for e in registry.entities + if not drag_context.is_entity_dragged(e.id) + and not any( + drag_context.is_point_dragged(pid) for pid in e.get_point_ids() + ) + and (self._include_construction or not e.construction) + ] + + for i, e1 in enumerate(entities): + for e2 in entities[i + 1 :]: + yield from self._get_intersections(e1, e2, registry) + + def _get_intersections( + self, e1: object, e2: object, registry: "EntityRegistry" + ) -> Iterator[GeoPoint]: + if isinstance(e1, Line) and isinstance(e2, Line): + yield from self._line_line_intersections(e1, e2, registry) + elif isinstance(e1, Line) and isinstance(e2, Arc): + yield from self._line_arc_intersections(e1, e2, registry) + elif isinstance(e1, Arc) and isinstance(e2, Line): + yield from self._line_arc_intersections(e2, e1, registry) + elif isinstance(e1, Line) and isinstance(e2, Circle): + yield from self._line_circle_intersections(e1, e2, registry) + elif isinstance(e1, Circle) and isinstance(e2, Line): + yield from self._line_circle_intersections(e2, e1, registry) + elif isinstance(e1, Arc) and isinstance(e2, Arc): + yield from self._arc_arc_intersections(e1, e2, registry) + elif isinstance(e1, Circle) and isinstance(e2, Circle): + yield from self._circle_circle_intersections(e1, e2, registry) + + def _line_line_intersections( + self, + line1: Line, + line2: Line, + registry: "EntityRegistry", + ) -> Iterator[GeoPoint]: + p1 = registry.get_point(line1.p1_idx) + p2 = registry.get_point(line1.p2_idx) + p3 = registry.get_point(line2.p1_idx) + p4 = registry.get_point(line2.p2_idx) + + if not all([p1, p2, p3, p4]): + return + + result = get_line_segment_intersection( + (p1.x, p1.y), + (p2.x, p2.y), + (p3.x, p3.y), + (p4.x, p4.y), + ) + if result is not None: + yield result + + def _line_arc_intersections( + self, + line: Line, + arc: Arc, + registry: "EntityRegistry", + ) -> Iterator[GeoPoint]: + p1 = registry.get_point(line.p1_idx) + p2 = registry.get_point(line.p2_idx) + center = registry.get_point(arc.center_idx) + start = registry.get_point(arc.start_idx) + + if not all([p1, p2, center, start]): + return + + radius = math.hypot(start.x - center.x, start.y - center.y) + if radius < 1e-10: + return + + for ix, iy in get_line_circle_intersections( + (p1.x, p1.y), + (p2.x, p2.y), + (center.x, center.y), + radius, + ): + angle = math.atan2(iy - center.y, ix - center.x) + if arc.is_angle_within_sweep(angle, registry): + yield (ix, iy) + + def _line_circle_intersections( + self, + line: Line, + circle: Circle, + registry: "EntityRegistry", + ) -> Iterator[GeoPoint]: + p1 = registry.get_point(line.p1_idx) + p2 = registry.get_point(line.p2_idx) + center = registry.get_point(circle.center_idx) + radius_pt = registry.get_point(circle.radius_pt_idx) + + if not all([p1, p2, center, radius_pt]): + return + + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + if radius < 1e-10: + return + + yield from get_line_circle_intersections( + (p1.x, p1.y), + (p2.x, p2.y), + (center.x, center.y), + radius, + ) + + def _arc_arc_intersections( + self, + arc1: Arc, + arc2: Arc, + registry: "EntityRegistry", + ) -> Iterator[GeoPoint]: + c1 = registry.get_point(arc1.center_idx) + s1 = registry.get_point(arc1.start_idx) + c2 = registry.get_point(arc2.center_idx) + s2 = registry.get_point(arc2.start_idx) + + if not all([c1, s1, c2, s2]): + return + + r1 = math.hypot(s1.x - c1.x, s1.y - c1.y) + r2 = math.hypot(s2.x - c2.x, s2.y - c2.y) + + for ix, iy in get_circle_circle_intersections( + (c1.x, c1.y), r1, (c2.x, c2.y), r2 + ): + angle1 = math.atan2(iy - c1.y, ix - c1.x) + angle2 = math.atan2(iy - c2.y, ix - c2.x) + if arc1.is_angle_within_sweep( + angle1, registry + ) and arc2.is_angle_within_sweep(angle2, registry): + yield (ix, iy) + + def _circle_circle_intersections( + self, + circle1: Circle, + circle2: Circle, + registry: "EntityRegistry", + ) -> Iterator[GeoPoint]: + c1 = registry.get_point(circle1.center_idx) + r1_pt = registry.get_point(circle1.radius_pt_idx) + c2 = registry.get_point(circle2.center_idx) + r2_pt = registry.get_point(circle2.radius_pt_idx) + + if not all([c1, r1_pt, c2, r2_pt]): + return + + r1 = math.hypot(r1_pt.x - c1.x, r1_pt.y - c1.y) + r2 = math.hypot(r2_pt.x - c2.x, r2_pt.y - c2.y) + + yield from get_circle_circle_intersections( + (c1.x, c1.y), r1, (c2.x, c2.y), r2 + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/midpoints.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/midpoints.py new file mode 100644 index 000000000..13b918aaf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/midpoints.py @@ -0,0 +1,79 @@ +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ...entities import Arc, Line +from ..engine import SnapLineProducer +from ..types import DragContext, SnapLine, SnapLineType, SnapPoint + +if TYPE_CHECKING: + from ...registry import EntityRegistry + + +class MidpointsProducer(SnapLineProducer): + def produce( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + return iter(()) + + def produce_points( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + x, y = drag_position + for entity in registry.entities: + if drag_context.is_entity_dragged(entity.id): + continue + entity_points = entity.get_point_ids() + if any( + drag_context.is_point_dragged(pid) for pid in entity_points + ): + continue + + mid = self._get_midpoint(entity, registry) + if mid is None: + continue + + mx, my = mid + dist = ((mx - x) ** 2 + (my - y) ** 2) ** 0.5 + if dist <= threshold: + yield SnapPoint( + x=mx, + y=my, + line_type=SnapLineType.MIDPOINT, + source=entity, + ) + + def _get_midpoint( + self, entity: object, registry: "EntityRegistry" + ) -> GeoPoint | None: + if isinstance(entity, Line): + return self._line_midpoint(entity, registry) + elif isinstance(entity, Arc): + return self._arc_midpoint(entity, registry) + return None + + def _line_midpoint( + self, line: Line, registry: "EntityRegistry" + ) -> GeoPoint | None: + p1 = registry.get_point(line.p1_idx) + p2 = registry.get_point(line.p2_idx) + if p1 and p2: + return ((p1.x + p2.x) / 2, (p1.y + p2.y) / 2) + return None + + def _arc_midpoint( + self, arc: Arc, registry: "EntityRegistry" + ) -> GeoPoint | None: + midpoint = arc.get_midpoint(registry) + if midpoint: + return (midpoint[0], midpoint[1]) + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/on_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/on_entity.py new file mode 100644 index 000000000..b0a1ba234 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/producers/on_entity.py @@ -0,0 +1,163 @@ +import math +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ...entities import Arc, Circle, Line +from ..engine import SnapLineProducer +from ..types import DragContext, SnapLine, SnapLineType, SnapPoint + +if TYPE_CHECKING: + from ...registry import EntityRegistry + + +class OnEntityProducer(SnapLineProducer): + def produce( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapLine]: + return iter(()) + + def produce_points( + self, + registry: "EntityRegistry", + drag_position: GeoPoint, + drag_context: DragContext, + threshold: float, + ) -> Iterator[SnapPoint]: + x, y = drag_position + + for entity in registry.entities: + if drag_context.is_entity_dragged(entity.id): + continue + entity_points = entity.get_point_ids() + if any( + drag_context.is_point_dragged(pid) for pid in entity_points + ): + continue + + if isinstance(entity, Line): + snap_point = self._nearest_point_on_line( + entity, registry, x, y, threshold + ) + elif isinstance(entity, Arc): + snap_point = self._nearest_point_on_arc( + entity, registry, x, y, threshold + ) + elif isinstance(entity, Circle): + snap_point = self._nearest_point_on_circle( + entity, registry, x, y, threshold + ) + else: + continue + + if snap_point is not None: + yield SnapPoint( + x=snap_point[0], + y=snap_point[1], + line_type=SnapLineType.ON_ENTITY, + source=entity, + ) + + def _nearest_point_on_line( + self, + line: Line, + registry: "EntityRegistry", + x: float, + y: float, + threshold: float, + ) -> GeoPoint | None: + p1 = registry.get_point(line.p1_idx) + p2 = registry.get_point(line.p2_idx) + if not p1 or not p2: + return None + + dx = p2.x - p1.x + dy = p2.y - p1.y + len_sq = dx * dx + dy * dy + + if len_sq < 1e-10: + dist = math.hypot(x - p1.x, y - p1.y) + if dist <= threshold: + return (p1.x, p1.y) + return None + + t = ((x - p1.x) * dx + (y - p1.y) * dy) / len_sq + t = max(0.0, min(1.0, t)) + + nearest_x = p1.x + t * dx + nearest_y = p1.y + t * dy + + dist = math.hypot(x - nearest_x, y - nearest_y) + if dist <= threshold: + return (nearest_x, nearest_y) + return None + + def _nearest_point_on_arc( + self, + arc: Arc, + registry: "EntityRegistry", + x: float, + y: float, + threshold: float, + ) -> GeoPoint | None: + center = registry.get_point(arc.center_idx) + start = registry.get_point(arc.start_idx) + if not center or not start: + return None + + radius = math.hypot(start.x - center.x, start.y - center.y) + if radius < 1e-10: + return None + + dist_to_center = math.hypot(x - center.x, y - center.y) + if dist_to_center < 1e-10: + angle = 0.0 + else: + angle = math.atan2(y - center.y, x - center.x) + + if not arc.is_angle_within_sweep(angle, registry): + return None + + nearest_x = center.x + radius * math.cos(angle) + nearest_y = center.y + radius * math.sin(angle) + + dist = math.hypot(x - nearest_x, y - nearest_y) + if dist <= threshold: + return (nearest_x, nearest_y) + return None + + def _nearest_point_on_circle( + self, + circle: Circle, + registry: "EntityRegistry", + x: float, + y: float, + threshold: float, + ) -> GeoPoint | None: + center = registry.get_point(circle.center_idx) + radius_pt = registry.get_point(circle.radius_pt_idx) + if not center or not radius_pt: + return None + + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + if radius < 1e-10: + return None + + dist_to_center = math.hypot(x - center.x, y - center.y) + if dist_to_center < 1e-10: + angle = 0.0 + else: + angle = math.atan2(y - center.y, x - center.x) + + nearest_x = center.x + radius * math.cos(angle) + nearest_y = center.y + radius * math.sin(angle) + + dist = math.hypot(x - nearest_x, y - nearest_y) + if dist <= threshold: + return (nearest_x, nearest_y) + return None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/spatial.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/spatial.py new file mode 100644 index 000000000..64fbecfae --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/spatial.py @@ -0,0 +1,90 @@ +import bisect +from collections.abc import Iterator +from dataclasses import dataclass + +from .types import SnapLine + + +@dataclass +class IndexedLine: + snap_line: SnapLine | None + coordinate: float + + def __lt__(self, other: "IndexedLine") -> bool: + return self.coordinate < other.coordinate + + +class SnapLineIndex: + def __init__(self) -> None: + self._horizontal: list[IndexedLine] = [] + self._vertical: list[IndexedLine] = [] + self._dirty: bool = False + + def clear(self) -> None: + self._horizontal.clear() + self._vertical.clear() + self._dirty = False + + def add(self, snap_line: SnapLine) -> None: + indexed = IndexedLine(snap_line, snap_line.coordinate) + if snap_line.is_horizontal: + bisect.insort(self._horizontal, indexed) + else: + bisect.insort(self._vertical, indexed) + self._dirty = True + + def add_all(self, snap_lines: Iterator[SnapLine]) -> None: + for sl in snap_lines: + self.add(sl) + + def query_horizontal( + self, y: float, threshold: float + ) -> list[tuple[SnapLine, float]]: + results: list[tuple[SnapLine, float]] = [] + low = y - threshold + high = y + threshold + + left = bisect.bisect_left(self._horizontal, IndexedLine(None, low)) + right = bisect.bisect_right(self._horizontal, IndexedLine(None, high)) + + for i in range(left, right): + indexed = self._horizontal[i] + if indexed.snap_line is None: + continue + dist = abs(y - indexed.coordinate) + if dist <= threshold: + results.append((indexed.snap_line, dist)) + + return results + + def query_vertical( + self, x: float, threshold: float + ) -> list[tuple[SnapLine, float]]: + results: list[tuple[SnapLine, float]] = [] + low = x - threshold + high = x + threshold + + left = bisect.bisect_left(self._vertical, IndexedLine(None, low)) + right = bisect.bisect_right(self._vertical, IndexedLine(None, high)) + + for i in range(left, right): + indexed = self._vertical[i] + if indexed.snap_line is None: + continue + dist = abs(x - indexed.coordinate) + if dist <= threshold: + results.append((indexed.snap_line, dist)) + + return results + + def query( + self, x: float, y: float, threshold: float + ) -> list[tuple[SnapLine, float]]: + results: list[tuple[SnapLine, float]] = [] + results.extend(self.query_horizontal(y, threshold)) + results.extend(self.query_vertical(x, threshold)) + results.sort(key=lambda t: (t[1], -t[0].line_type.priority)) + return results + + def __len__(self) -> int: + return len(self._horizontal) + len(self._vertical) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/types.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/types.py new file mode 100644 index 000000000..4f58c31c7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/snap/types.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Any + +from raygeo.geo.types import Point as GeoPoint + +from rayforge.core.color import ColorRGBA + +from ..types import EntityID + + +class SnapLineType(Enum): + ENTITY_POINT = auto() + ON_ENTITY = auto() + INTERSECTION = auto() + MIDPOINT = auto() + EQUIDISTANT = auto() + TANGENT = auto() + CENTER = auto() + + @property + def priority(self) -> int: + priorities: dict[SnapLineType, int] = { + SnapLineType.ENTITY_POINT: 100, + SnapLineType.MIDPOINT: 90, + SnapLineType.ON_ENTITY: 80, + SnapLineType.INTERSECTION: 70, + SnapLineType.EQUIDISTANT: 60, + SnapLineType.TANGENT: 40, + SnapLineType.CENTER: 30, + } + return priorities.get(self, 0) + + +@dataclass(frozen=True) +class SnapLineStyle: + color: ColorRGBA = (0.0, 0.6, 1.0, 0.8) + dash: tuple[float, ...] | None = None + line_width: float = 1.0 + + +SNAP_LINE_STYLES: dict[SnapLineType, SnapLineStyle] = { + SnapLineType.ENTITY_POINT: SnapLineStyle( + color=(0.2, 0.6, 1.0, 0.9), + dash=(8, 4), + line_width=2.0, + ), + SnapLineType.ON_ENTITY: SnapLineStyle( + color=(1.0, 0.4, 0.8, 0.9), + dash=(8, 4), + line_width=2.0, + ), + SnapLineType.INTERSECTION: SnapLineStyle( + color=(1.0, 0.2, 0.8, 0.9), + dash=(8, 4), + line_width=2.0, + ), + SnapLineType.MIDPOINT: SnapLineStyle( + color=(0.2, 0.9, 0.3, 0.9), + dash=(8, 4), + line_width=2.0, + ), + SnapLineType.EQUIDISTANT: SnapLineStyle( + color=(1.0, 0.6, 0.2, 0.9), + dash=(8, 4), + line_width=2.0, + ), + SnapLineType.TANGENT: SnapLineStyle( + color=(0.7, 0.3, 0.9, 0.9), + dash=(8, 4), + line_width=2.0, + ), + SnapLineType.CENTER: SnapLineStyle( + color=(1.0, 0.3, 0.3, 0.9), + dash=(8, 4), + line_width=2.0, + ), +} + + +@dataclass(frozen=True) +class SnapPoint: + x: float + y: float + line_type: SnapLineType + source: Any | None = None + spacing: float | None = None + is_horizontal: bool = False + pattern_coords: tuple[float, ...] | None = None + axis_coord: float | None = None + + @property + def pos(self) -> GeoPoint: + return (self.x, self.y) + + +@dataclass(frozen=True) +class SnapLine: + is_horizontal: bool + coordinate: float + line_type: SnapLineType + source: Any | None = None + + @property + def style(self) -> SnapLineStyle: + return SNAP_LINE_STYLES.get(self.line_type, SnapLineStyle()) + + def distance_to(self, x: float, y: float) -> float: + if self.is_horizontal: + return abs(y - self.coordinate) + else: + return abs(x - self.coordinate) + + def get_snap_position(self, x: float, y: float) -> GeoPoint: + if self.is_horizontal: + return (x, self.coordinate) + else: + return (self.coordinate, y) + + +@dataclass +class SnapResult: + snapped: bool = False + position: GeoPoint = (0.0, 0.0) + snap_lines: list[SnapLine] = field(default_factory=list) + snap_points: list[SnapPoint] = field(default_factory=list) + primary_snap_line: SnapLine | None = None + primary_snap_point: SnapPoint | None = None + distance: float = float("inf") + + @classmethod + def no_snap(cls, position: GeoPoint) -> "SnapResult": + return cls(snapped=False, position=position) + + @classmethod + def from_snap_line( + cls, + snap_line: SnapLine, + original_pos: GeoPoint, + distance: float, + ) -> "SnapResult": + snapped_pos = snap_line.get_snap_position(*original_pos) + return cls( + snapped=True, + position=snapped_pos, + snap_lines=[snap_line], + primary_snap_line=snap_line, + distance=distance, + ) + + @classmethod + def from_snap_point( + cls, + snap_point: SnapPoint, + distance: float, + snap_lines: list[SnapLine] | None = None, + ) -> "SnapResult": + return cls( + snapped=True, + position=snap_point.pos, + snap_points=[snap_point], + snap_lines=snap_lines or [], + primary_snap_point=snap_point, + distance=distance, + ) + + +class DragContext: + def __init__( + self, + dragged_point_ids: set[EntityID] | None = None, + dragged_entity_ids: set[EntityID] | None = None, + initial_positions: dict[EntityID, GeoPoint] | None = None, + ): + self.dragged_point_ids: set[EntityID] = dragged_point_ids or set() + self.dragged_entity_ids: set[EntityID] = dragged_entity_ids or set() + self.initial_positions: dict[EntityID, GeoPoint] = ( + initial_positions or {} + ) + + def is_point_dragged(self, point_id: EntityID) -> bool: + return point_id in self.dragged_point_ids + + def is_entity_dragged(self, entity_id: EntityID) -> bool: + return entity_id in self.dragged_entity_ids diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/solver.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/solver.py new file mode 100644 index 000000000..13515ea48 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/solver.py @@ -0,0 +1,250 @@ +from collections.abc import Sequence + +import numpy as np +import scipy.linalg +from scipy.optimize import least_squares + +from .constraints import Constraint +from .entities import Point +from .params import ParameterContext +from .registry import EntityRegistry + +CONFLICT_ERROR_THRESHOLD = 1e-3 + + +class Solver: + def __init__( + self, + registry: EntityRegistry, + params: ParameterContext, + constraints: Sequence[Constraint], + auxiliary_constraints: Sequence[Constraint] = (), + ): + self.registry = registry + self.params = params + self.constraints = constraints + self.auxiliary_constraints = auxiliary_constraints + + def solve(self, tolerance: float = 1e-5, update_dof: bool = True) -> bool: + """ + Runs the least_squares optimizer to satisfy constraints. + Returns True if successful. + If update_dof is False, it will skip re-calculating the constrained + status of points and entities, which is useful for interactive updates. + """ + # 1. Identify mutable points (degrees of freedom) + mutable_points: list[Point] = [ + p for p in self.registry.points if not p.fixed + ] + + # Map point_id -> index in state vector (0, 2, 4...) + point_indices = {p.id: i * 2 for i, p in enumerate(mutable_points)} + + # Only reset if we are doing a full DOF update + if update_dof: + for p in self.registry.points: + p.constrained = p.fixed + + if not mutable_points: + if update_dof: + self._update_entity_constraints() + return True # Nothing to solve + + all_constraints = list(self.constraints) + list( + self.auxiliary_constraints + ) + + # 2. Extract initial state vector [x0, y0, x1, y1, ...] + x0_list = [] + for p in mutable_points: + x0_list.extend([p.x, p.y]) + + x0 = np.array(x0_list) + + def update_registry(x_state): + """Updates registry points directly from vector.""" + ptr = 0 + for p in mutable_points: + p.x = x_state[ptr] + p.y = x_state[ptr + 1] + ptr += 2 + + # 3. Define the objective function (residuals) + def objective(x_state): + update_registry(x_state) + + # Calculate errors + residuals = [] + for const in all_constraints: + err = const.error(self.registry, self.params) + # Flatten the error result into the residuals list + if isinstance(err, (tuple, list)): + residuals.extend(err) + else: + residuals.append(err) + + # If there are no constraints but we have mutable points, + # we need at least one residual for least_squares. + if not residuals: + return np.array([0.0]) + + return np.array(residuals) + + # Helper to build Jacobian rows for a specific set of constraints + def build_jacobian_rows(constraint_list, n_vars): + rows = [] + for const in constraint_list: + grad_map = const.gradient(self.registry, self.params) + + # Determine how many residuals this constraint produces + # We can infer this from the gradient map lists + num_residuals = 0 + if grad_map: + first_val = next(iter(grad_map.values())) + num_residuals = len(first_val) + else: + # Fallback check if gradient not implemented but error + # exists + err = const.error(self.registry, self.params) + if isinstance(err, (tuple, list)): + num_residuals = len(err) + else: + num_residuals = 1 + + # Create zero rows for these residuals + for _ in range(num_residuals): + rows.append(np.zeros(n_vars)) + + start_row = len(rows) - num_residuals + + # Fill in the gradients + for pid, grads in grad_map.items(): + if pid in point_indices: + idx = point_indices[pid] + for i, (dx, dy) in enumerate(grads): + current_row = rows[start_row + i] + current_row[idx] = dx + current_row[idx + 1] = dy + + return rows + + # 4. Define the Jacobian function + def jacobian(x_state): + # Ensure registry is up-to-date + update_registry(x_state) + n_vars = len(x0) + + rows = build_jacobian_rows(all_constraints, n_vars) + + if not rows: + return np.zeros((1, n_vars)) + + return np.vstack(rows) + + # 5. Solve + # 'trf' is robust for under-constrained problems (m < n) + # We pass the analytical jacobian + result = least_squares( + objective, + x0, + jac=jacobian, # type: ignore + method="trf", + ftol=tolerance, + xtol=1e-8, + ) + + # 6. Final Update to ensure registry matches result + update_registry(result.x) + + success = bool(result.success and result.cost <= tolerance) + + # 7. Analyze Degrees of Freedom (DOF) - CONDITIONALLY + if success and update_dof: + # Re-compute Jacobian using ONLY hard constraints for DOF + # analysis. Stabilizer constraints (auxiliary) should not count + # towards DOF. + hard_rows = build_jacobian_rows(self.constraints, len(x0)) + if hard_rows: + hard_jac = np.vstack(hard_rows) + else: + hard_jac = np.zeros((1, len(x0))) + + self._analyze_dof(hard_jac, mutable_points) + self._update_entity_constraints() + + return success + + def _analyze_dof(self, jacobian: np.ndarray, mutable_points: list[Point]): + """ + Determines which points are fully constrained by analyzing the + Null Space of the Jacobian matrix. + """ + # If Jacobian is (n_constraints, n_vars), the Null Space represents + # directions in which variables can move without changing residuals. + # If the Null Space is empty, the system is fully constrained. + + # Get the null space basis ( orthonormal columns ) + # Using a tighter tolerance (1e-9) prevents false positives for DOF + # when the system is actually rigid but has scaling differences. + null_space = scipy.linalg.null_space(jacobian, rcond=1e-9) + + # null_space shape is (n_vars, n_dof) + # If n_dof == 0, everything is constrained. + + if null_space.size == 0: + for p in mutable_points: + p.constrained = True + return + + # If we have DOFs, we need to see which variables participate in them. + # Rows of null_space correspond to [x0, y0, x1, y1, ...] + n_vars = null_space.shape[0] + + for i, p in enumerate(mutable_points): + idx_x = i * 2 + idx_y = i * 2 + 1 + + if idx_x >= n_vars: + break + + # Check magnitude of the point's contribution to the null space. + # If row vectors in null space are zero (or near zero), + # this variable cannot move effectively. + x_mobility = np.sum(np.abs(null_space[idx_x, :])) + y_mobility = np.sum(np.abs(null_space[idx_y, :])) + + # If mobility is negligible, the point is constrained. + p.constrained = (x_mobility < 1e-4) and (y_mobility < 1e-4) + + def _update_entity_constraints(self): + """ + Updates the constrained status of Entities based on their points. + An entity is constrained only if all its defining points are + constrained. + """ + for entity in self.registry.entities: + entity.update_constrained_status(self.registry, self.constraints) + + def get_conflicting_constraints( + self, threshold: float = CONFLICT_ERROR_THRESHOLD + ) -> set[int]: + """ + Identifies constraints that have significant residual error, + indicating they conflict with other constraints or cannot be satisfied. + + Returns: + Set of constraint indices in self.constraints that are conflicting. + """ + conflicting: set[int] = set() + + for idx, const in enumerate(self.constraints): + err = const.error(self.registry, self.params) + if isinstance(err, (tuple, list)): + max_err = max(abs(e) for e in err) + else: + max_err = abs(err) + + if max_err > threshold: + conflicting.add(idx) + + return conflicting diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/types.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/types.py new file mode 100644 index 000000000..8bb96d9ef --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/types.py @@ -0,0 +1,3 @@ +from typing import TypeAlias + +EntityID: TypeAlias = int diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/frontend.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/frontend.py new file mode 100644 index 000000000..f1d7a5494 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/frontend.py @@ -0,0 +1,38 @@ +""" +Frontend entry point for sketcher addon. + +Registers UI widgets with the main application. +""" + +import logging + +from rayforge.core.hooks import hookimpl + +logger = logging.getLogger(__name__) + +ADDON_NAME = "sketcher" + + +@hookimpl +def register_commands(command_registry): + """Register SketchCmd with the command registry.""" + from .ui_gtk.sketch_cmd import SketchCmd + + command_registry.register("sketch", SketchCmd, ADDON_NAME) + + +@hookimpl +def main_window_ready(main_window): + """Set up sketch studio and mode command when main window is ready.""" + from .ui_gtk import setup_sketch_page + + setup_sketch_page(main_window) + + +@hookimpl +def on_unload(): + """Clean up sketch studio when addon is disabled.""" + logger.info("on_unload hook called, tearing down sketch page") + from .ui_gtk import teardown_sketch_page + + teardown_sketch_page() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/__init__.py new file mode 100644 index 000000000..108bdb559 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/__init__.py @@ -0,0 +1,4 @@ +from .exporter import SketchExporter +from .importer import SketchImporter + +__all__ = ["SketchExporter", "SketchImporter"] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/exporter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/exporter.py new file mode 100644 index 000000000..13899ec48 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/exporter.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from rayforge import const +from rayforge.image.base_exporter import Exporter + +if TYPE_CHECKING: + from rayforge.core.workpiece import WorkPiece + + from ..core import Sketch + + +class SketchExporter(Exporter): + """ + Exports the parametric source data of a sketch-based WorkPiece. + """ + + label = _("{app_name} Sketch").format(app_name=const.APP_NAME) + extensions = (".rfs",) + mime_types = (const.MIME_TYPE_SKETCH,) + + def __init__(self, doc_item: WorkPiece): + """ + Initializes the exporter for a specific sketch-based WorkPiece. + + Args: + doc_item: The WorkPiece whose sketch source should be exported. + """ + super().__init__(doc_item) + from rayforge.core.workpiece import WorkPiece + + if not isinstance(doc_item, WorkPiece): + raise TypeError("SketchExporter can only export WorkPiece items.") + self.workpiece = doc_item + + def export(self) -> bytes: + """ + Retrieves the serialized Sketch definition from the document's + sketch registry. + + Returns: + The raw JSON bytes representing the sketch. + + Raises: + ValueError: If the WorkPiece is not derived from a sketch or if + the sketch definition is missing. + """ + sketch = cast("Sketch", self.workpiece.get_geometry_provider()) + if not sketch: + raise ValueError( + "Cannot export: The selected item is not based on a sketch " + "or its definition is missing." + ) + + sketch_dict = sketch.to_dict() + return json.dumps(sketch_dict).encode("utf-8") diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/importer.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/importer.py new file mode 100644 index 000000000..23ab1642e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/importer.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Iterator +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +from raygeo.geo import Matrix + +from rayforge import const +from rayforge.core.item import DocItem +from rayforge.core.source_asset import SourceAsset +from rayforge.core.workpiece import WorkPiece +from rayforge.image.base_importer import ( + Importer, + ImporterFeature, +) +from rayforge.image.engine import NormalizationEngine +from rayforge.image.structures import ( + ImportManifest, + ImportPayload, + LayerGeometry, + ParsingResult, + VectorizationResult, +) + +from ..core import Sketch + +if TYPE_CHECKING: + from rayforge.core.vectorization_spec import VectorizationSpec + +logger = logging.getLogger(__name__) + + +class SketchImporter(Importer): + """ + Parses a .rfs file (serialized Sketch data) and prepares it for + integration into a document. + """ + + label = _("{app_name} Sketch").format(app_name=const.APP_NAME) + extensions = (".rfs",) + mime_types = (const.MIME_TYPE_SKETCH,) + features: ClassVar[set[ImporterFeature]] = {ImporterFeature.DIRECT_VECTOR} + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self.parsed_sketch: Sketch | None = None + + def scan(self) -> ImportManifest: + """ + Scans the sketch JSON to extract its name. + """ + try: + sketch_dict = json.loads(self.raw_data.decode("utf-8")) + name = sketch_dict.get("name") or self.source_file.stem + return ImportManifest( + title=name, warnings=self._warnings, errors=self._errors + ) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.warning( + f"Sketch scan failed for {self.source_file.name}: {e}" + ) + self.add_error(_("Sketch file is invalid JSON: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + def _post_process_payload(self, payload: ImportPayload) -> ImportPayload: + """ + Overrides the base importer hook to add sketch-specific data. + This links the generated WorkPieces back to the Sketch definition + and includes the Sketch itself in the payload for the document to + register. + """ + if not self.parsed_sketch: + return payload + + def find_workpieces(items: list[DocItem]) -> Iterator[WorkPiece]: + """Recursively find all WorkPiece objects in a list of items.""" + for item in items: + if isinstance(item, WorkPiece): + yield item + elif item.children: + yield from find_workpieces(item.children) + + for wp in find_workpieces(payload.items): + wp.geometry_provider_uid = self.parsed_sketch.uid + wp.name = self.parsed_sketch.name + + payload.assets = [self.parsed_sketch] + return payload + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset for Sketch import. + """ + from .renderer import SKETCH_RENDERER + + _, _, width, height = parse_result.document_bounds + + return SourceAsset( + source_file=self.source_file + if self.source_file + else Path("sketch.rfs"), + original_data=self.raw_data, + renderer=SKETCH_RENDERER, + metadata={"is_vector": True}, + width_mm=width, + height_mm=height, + ) + + def parse(self) -> ParsingResult | None: + """Phase 2: Parse JSON into Sketch model and solve it for bounds.""" + try: + sketch_dict = json.loads(self.raw_data.decode("utf-8")) + self.parsed_sketch = Sketch.from_dict(sketch_dict) + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.error(f"Failed to parse sketch data: {e}") + self.add_error(_("Failed to load sketch structure: {}").format(e)) + return None + + final_name = self.parsed_sketch.name + if not final_name and self.source_file: + final_name = self.source_file.stem + if not final_name: + final_name = "Untitled" + self.parsed_sketch.name = final_name + + self.parsed_sketch.solve() + geometry = self.parsed_sketch.to_geometry() + + if geometry.is_empty(): + min_x, min_y, width, height = 0.0, 0.0, 1.0, 1.0 + else: + min_x, min_y, max_x, max_y = geometry.rect() + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + + document_bounds = (min_x, min_y, width, height) + + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[], + world_frame_of_reference=document_bounds, + background_world_transform=Matrix(), + ) + + bg_item = NormalizationEngine.calculate_layout_item( + document_bounds, temp_result + ) + + layer_id = "__default__" + + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[ + LayerGeometry( + layer_id=layer_id, + name=layer_id, + content_bounds=document_bounds, + ) + ], + world_frame_of_reference=document_bounds, + background_world_transform=bg_item.world_matrix, + ) + + def vectorize( + self, parse_result: ParsingResult, spec: VectorizationSpec + ) -> VectorizationResult: + """Phase 3: Extract geometry from the solved sketch.""" + if not self.parsed_sketch: + return VectorizationResult({}, parse_result) + + geometry = self.parsed_sketch.to_geometry() + geometry.close_gaps() + geometry.upgrade_to_scalable() + + fill_render_data = self.parsed_sketch.get_fill_render_data() + for fd in fill_render_data: + fd.geometry.upgrade_to_scalable() + + layer_id = parse_result.layers[0].layer_id + return VectorizationResult( + geometries_by_layer={layer_id: geometry}, + fills_by_layer={layer_id: fill_render_data}, + source_parse_result=parse_result, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/renderer.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/renderer.py new file mode 100644 index 000000000..e94d2c94c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/image/renderer.py @@ -0,0 +1,283 @@ +import logging +import math +import warnings +from typing import ( + TYPE_CHECKING, + Optional, +) + +from raygeo.geo import Geometry +from raygeo.svg import geometry_to_svg_path + +from rayforge.core.color import ColorRGBA +from rayforge.image.base_renderer import Renderer, RenderSpecification +from rayforge.image.structures import FillRenderData, FillStyle +from rayforge.image.svg.svg_fallback import ( + SVG_LOAD_AVAILABLE, + cairo_surface_to_vips, + render_svg_to_cairo, +) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + from rayforge.core.source_asset_segment import SourceAssetSegment + from rayforge.core.workpiece import RenderContext + from rayforge.image.structures import ImportResult + + +logger = logging.getLogger(__name__) + + +class SketchRenderer(Renderer): + """ + Renders a sketch's "design view" by generating an in-memory SVG + and rasterizing it with Vips. It handles both fills and strokes. + """ + + def compute_render_spec( + self, + segment: Optional["SourceAssetSegment"], + target_size: tuple[int, int], + source_context: "RenderContext", + ) -> "RenderSpecification": + """ + Specifies that 'boundaries' and 'fills' geometries are required for + rendering sketches. + """ + kwargs = { + "boundaries": source_context.boundaries, + "fills": source_context.fills, + } + return RenderSpecification( + width=target_size[0], + height=target_size[1], + data=source_context.data, + kwargs=kwargs, + apply_mask=False, + ) + + def render_preview_image( + self, + import_result: "ImportResult", + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """ + Generates a preview by rendering the sketch's vectorized geometry. + """ + from raygeo.geo import Matrix + + vec_result = import_result.vectorization_result + if not vec_result: + return None + + merged_boundaries = Geometry() + for geo in vec_result.geometries_by_layer.values(): + if geo: + merged_boundaries.extend(geo) + + if merged_boundaries.is_empty(): + return None + + min_x, min_y, max_x, max_y = merged_boundaries.rect() + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + norm_matrix = Matrix.scale( + 1.0 / width, 1.0 / height + ) @ Matrix.translation(-min_x, -min_y) + normalized_boundaries = merged_boundaries.copy() + normalized_boundaries.transform(norm_matrix) + + normalized_fills = [] + for fill_list in vec_result.fills_by_layer.values(): + for fill_data in fill_list: + norm_fill = fill_data.geometry.copy() + norm_fill.transform(norm_matrix) + normalized_fills.append( + FillRenderData( + geometry=norm_fill, + style=fill_data.style, + color=fill_data.color, + gradient_stops=fill_data.gradient_stops, + gradient_angle=fill_data.gradient_angle, + ) + ) + + return self.render_base_image( + data=b"", + width=target_width, + height=target_height, + boundaries=normalized_boundaries, + fills=normalized_fills, + ) + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + """ + Renders the sketch's vector data to a pyvips Image. + It expects 'boundaries' (strokes) and optionally 'fills' + (FillRenderData or Geometry objects) in kwargs. + """ + logger.debug( + f"SketchRenderer.render_base_image called. " + f"width={width}, height={height}" + ) + + boundaries: Geometry | None = kwargs.get("boundaries") + fills = kwargs.get("fills") + + if not boundaries and not fills: + return None + + svg_parts = [ + ( + f'' + ) + ] + + if fills: + for fill_data in fills: + path_d = geometry_to_svg_path( + fill_data.geometry, width, height + ) + if path_d: + fill_svg = self._fill_to_svg(fill_data, path_d) + svg_parts.append(fill_svg) + + if boundaries: + stroke_width = 1.0 + path_d = geometry_to_svg_path(boundaries, width, height) + if path_d: + svg_parts.append( + f'' + ) + + svg_parts.append("") + svg_string = "".join(svg_parts) + svg_bytes = svg_string.encode("utf-8") + + try: + if SVG_LOAD_AVAILABLE: + image = pyvips.Image.svgload_buffer(svg_bytes) + else: + surface = render_svg_to_cairo(svg_bytes, width, height) + if not surface: + logger.error("Failed to render sketch SVG with Cairo.") + logger.debug(f"Failed SVG content:\n{svg_string}") + return None + image = cairo_surface_to_vips(surface) + if not image: + logger.error("Failed to convert Cairo surface to pyvips.") + return None + return image + except pyvips.Error as e: + logger.error(f"Failed to render sketch SVG with Vips: {e}") + logger.debug(f"Failed SVG content:\n{svg_string}") + return None + + def _fill_to_svg(self, fill_data: FillRenderData, path_d: str) -> str: + """Convert FillRenderData to an SVG path element.""" + if fill_data.style == FillStyle.SOLID: + color = self._rgba_to_svg_color(fill_data.color) + return f'' + + if fill_data.style == FillStyle.LINEAR_GRADIENT: + return self._linear_gradient_to_svg(fill_data, path_d) + + if fill_data.style == FillStyle.RADIAL_GRADIENT: + return self._radial_gradient_to_svg(fill_data, path_d) + + color = self._rgba_to_svg_color(fill_data.color) + return f'' + + def _rgba_to_svg_color(self, color: ColorRGBA) -> str: + """Convert RGBA tuple to SVG color string.""" + r = int(color[0] * 255) + g = int(color[1] * 255) + b = int(color[2] * 255) + return f"rgb({r},{g},{b})" + + def _linear_gradient_to_svg( + self, fill_data: FillRenderData, path_d: str + ) -> str: + """Create SVG with linear gradient.""" + import uuid + + grad_id = f"grad_{uuid.uuid4().hex[:8]}" + + angle_rad = math.radians(fill_data.gradient_angle) + x1 = 50 - 50 * math.cos(angle_rad) + y1 = 50 - 50 * math.sin(angle_rad) + x2 = 50 + 50 * math.cos(angle_rad) + y2 = 50 + 50 * math.sin(angle_rad) + + stops = self._get_gradient_stops(fill_data) + gradient_svg = ( + f'' + f"{stops}" + f"" + ) + + return ( + f"{gradient_svg}" + f'' + ) + + def _radial_gradient_to_svg( + self, fill_data: FillRenderData, path_d: str + ) -> str: + """Create SVG with radial gradient.""" + import uuid + + grad_id = f"grad_{uuid.uuid4().hex[:8]}" + + stops = self._get_gradient_stops(fill_data) + gradient_svg = ( + f'' + f"{stops}" + f"" + ) + + return ( + f"{gradient_svg}" + f'' + ) + + def _get_gradient_stops(self, fill_data: FillRenderData) -> str: + """Generate SVG gradient stop elements.""" + if not fill_data.gradient_stops: + color = self._rgba_to_svg_color(fill_data.color) + opacity = fill_data.color[3] + return ( + f'' + f'' + ) + + stops = [] + for pos, color in fill_data.gradient_stops: + svg_color = self._rgba_to_svg_color(color) + opacity = color[3] + stops.append( + f'' + ) + return "".join(stops) + + +SKETCH_RENDERER = SketchRenderer() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py new file mode 100644 index 000000000..c3b519129 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py @@ -0,0 +1,271 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Optional + +from gi.repository import Gio, GLib + +from rayforge.context import get_context +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.action_registry import ( + MenuPlacement, + action_registry, +) +from rayforge.ui_gtk.actions import action_extension_registry +from rayforge.ui_gtk.canvas2d.context_menu import ( + context_menu_extension_registry, +) +from rayforge.ui_gtk.doceditor.property_providers import ( + property_provider_registry, +) +from rayforge.ui_gtk.shared.keyboard import PRIMARY_ACCEL + +from .property_provider import SketchPropertyProvider +from .sketch_mode_cmd import SketchModeCmd +from .sketchelement import SketchElement + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from gi.repository import Gtk + + from rayforge.core.item import DocItem + from rayforge.ui_gtk.actions import ActionManager + from rayforge.ui_gtk.canvas2d.surface import WorkSurface + from rayforge.ui_gtk.mainwindow import MainWindow + + from .studio import SketchStudio + + +_sketch_studio: Optional["SketchStudio"] = None +_sketch_mode_cmd: Optional["SketchModeCmd"] = None +_main_window: Optional["MainWindow"] = None + + +def _build_sketch_context_menu_items( + surface: "WorkSurface", + item: Optional["DocItem"], + gesture: "Gtk.Gesture", + menu: Gio.Menu, +): + """ + Context menu extension handler for sketch workpieces. + + Adds "Edit Sketch" and "Export Object" items when the context menu + is shown for a sketch-based workpiece. + """ + if not isinstance(item, WorkPiece): + return + + if not item.geometry_provider_uid: + return + + # Create a section for sketch-specific items at the top of the menu + sketch_section = Gio.Menu.new() + sketch_section.append_item( + Gio.MenuItem.new(_("Edit Sketch"), "win.edit_sketch") + ) + sketch_section.append_item( + Gio.MenuItem.new(_("Export Object..."), "win.export_object") + ) + + # Prepend the sketch section to the menu + menu.prepend_section(None, sketch_section) + + +def _on_config_changed(sender, **kwargs): + """Handle config changes to update sketch studio dimensions.""" + if _sketch_studio is None: + return + + config = get_context().config + if config.machine: + width_mm, height_mm = config.machine.axis_extents + _sketch_studio.set_world_size(width_mm, height_mm) + + +def setup_sketch_page(main_window: "MainWindow") -> "SketchStudio": + """ + Create and register the sketch studio page with the main window. + + This function is called by the sketcher addon's main_window_ready hook + to set up the sketch editing page. All sketch-specific logic lives here. + + Args: + main_window: The MainWindow instance + + Returns: + The created SketchStudio instance + """ + global _sketch_studio, _sketch_mode_cmd, _main_window + + if _sketch_studio is not None: + return _sketch_studio + + from .studio import SketchStudio + + config = get_context().config + if config.machine: + width_mm, height_mm = config.machine.axis_extents + else: + width_mm, height_mm = 100.0, 100.0 + + sketch_studio = SketchStudio( + main_window, width_mm=width_mm, height_mm=height_mm + ) + + _sketch_mode_cmd = SketchModeCmd(main_window, main_window.doc_editor) + _main_window = main_window + + sketch_studio.finished.connect(_sketch_mode_cmd.on_sketch_finished) + sketch_studio.cancelled.connect(_sketch_mode_cmd.on_sketch_cancelled) + + main_window.add_stack_page("sketch", sketch_studio) + + config.changed.connect(_on_config_changed) + + _sketch_studio = sketch_studio + + return sketch_studio + + +def teardown_sketch_page(): + """ + Clean up the sketch studio page when the addon is disabled. + + This disconnects signals and removes the studio from the main window. + """ + global _sketch_studio, _sketch_mode_cmd, _main_window + + logger.info( + f"teardown_sketch_page called, studio={_sketch_studio}, " + f"main_window={_main_window}" + ) + + if _sketch_studio is None: + logger.info("teardown_sketch_page: _sketch_studio is None, returning") + return + + get_context().config.changed.disconnect(_on_config_changed) + + if _sketch_mode_cmd is not None: + _sketch_studio.finished.disconnect(_sketch_mode_cmd.on_sketch_finished) + _sketch_studio.cancelled.disconnect( + _sketch_mode_cmd.on_sketch_cancelled + ) + + if _main_window is not None: + _main_window.remove_stack_page("sketch") + + _sketch_studio = None + _sketch_mode_cmd = None + _main_window = None + logger.info("teardown_sketch_page completed") + + +def get_sketch_studio() -> Optional["SketchStudio"]: + """Get the global SketchStudio instance.""" + return _sketch_studio + + +def get_sketch_mode_cmd() -> Optional["SketchModeCmd"]: + """Get the global SketchModeCmd instance.""" + return _sketch_mode_cmd + + +def _register_actions(action_manager: "ActionManager"): + """Register sketch actions with the ActionManager.""" + if _sketch_mode_cmd is None: + setup_sketch_page(action_manager.win) + cmd = _sketch_mode_cmd + assert cmd is not None + + action = Gio.SimpleAction.new("new_sketch", None) + action.connect("activate", cmd.on_new_sketch) + action_manager.win.add_action(action) + action_manager.actions["new_sketch"] = action + action_registry.register( + action_name="new_sketch", + action=action, + addon_name="sketcher", + label=_("New Sketch"), + shortcut=f"{PRIMARY_ACCEL}n", + menu=MenuPlacement(menu_id="object", priority=50), + ) + + action = Gio.SimpleAction.new("edit_sketch", None) + action.connect("activate", cmd.on_edit_sketch) + action_manager.win.add_action(action) + action_manager.actions["edit_sketch"] = action + + action = Gio.SimpleAction.new("export_object", None) + action.connect("activate", cmd.on_export_object) + action_manager.win.add_action(action) + action_manager.actions["export_object"] = action + + action = Gio.SimpleAction.new("add-sketch", None) + action.connect("activate", cmd.on_new_sketch) + action_manager.win.add_action(action) + action_manager.actions["add-sketch"] = action + + action = Gio.SimpleAction.new("activate-sketch", GLib.VariantType.new("s")) + action.connect("activate", cmd.on_activate_sketch) + action_manager.win.add_action(action) + action_manager.actions["activate-sketch"] = action + + action = Gio.SimpleAction.new( + "edit-sketch-item", GLib.VariantType.new("s") + ) + action.connect("activate", cmd.on_edit_sketch_item) + action_manager.win.add_action(action) + action_manager.actions["edit-sketch-item"] = action + + +def _update_action_states(action_manager: "ActionManager"): + """Update sketch action states based on selection.""" + action_manager.actions["add-sketch"].set_enabled(True) + + selected_wps = action_manager.win.surface.get_selected_workpieces() + + can_edit_sketch = False + can_export_object = False + if len(selected_wps) == 1: + wp = selected_wps[0] + if wp.geometry_provider_uid: + can_edit_sketch = True + if wp.boundaries is not None and not wp.boundaries.is_empty(): + can_export_object = True + + if "export_object" in action_manager.actions: + action_manager.actions["export_object"].set_enabled(can_export_object) + if "edit_sketch" in action_manager.actions: + action_manager.actions["edit_sketch"].set_enabled(can_edit_sketch) + + +def register(): + """Register sketch module components with the application. + + This function is called during application initialization to + register sketch-specific components with their respective registries. + """ + property_provider_registry.register(SketchPropertyProvider, "sketcher") + context_menu_extension_registry.register( + _build_sketch_context_menu_items, "sketcher" + ) + action_extension_registry.register_setup(_register_actions, "sketcher") + action_extension_registry.register_state_update( + _update_action_states, "sketcher" + ) + + +# Auto-register when module is imported +register() + +__all__ = [ + "SketchElement", + "SketchModeCmd", + "SketchPropertyProvider", + "get_sketch_mode_cmd", + "get_sketch_studio", + "setup_sketch_page", + "teardown_sketch_page", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/conflicts_widget.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/conflicts_widget.py new file mode 100644 index 000000000..facbb2d78 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/conflicts_widget.py @@ -0,0 +1,134 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Optional + +from gi.repository import Adw, Gtk + +from rayforge.ui_gtk.icons import get_icon + +from ..core.commands.items import RemoveItemsCommand + +if TYPE_CHECKING: + from .sketchelement import SketchElement + +logger = logging.getLogger(__name__) + + +class ConflictingConstraintsWidget(Adw.PreferencesGroup): + """ + A widget that displays conflicting constraints in the sketch studio. + Shows a list of constraints that have CONFLICTING status after solving. + """ + + def __init__(self): + super().__init__() + self._sketch_element: SketchElement | None = None + + self.set_title(_("Conflicting Constraints")) + self.set_description( + _("These constraints cannot be satisfied simultaneously") + ) + self.set_visible(False) + + self._rows: list[Adw.ActionRow] = [] + self._constraint_indices: list[int] = [] + + def set_sketch_element(self, element: Optional["SketchElement"]): + """ + Sets the sketch element to monitor for conflicts. + """ + self._sketch_element = element + self._update_conflicts() + + def _update_conflicts(self): + """ + Updates the list of conflicting constraints. + """ + for row in self._rows: + self.remove(row) + self._rows.clear() + self._constraint_indices.clear() + + if self._sketch_element is None: + self.set_visible(False) + return + + sketch = self._sketch_element.sketch + conflicts = sketch.conflicting_constraints + + if not conflicts: + self.set_visible(False) + return + + self.set_visible(True) + + for constraint in conflicts: + idx = sketch.constraints.index(constraint) + self._constraint_indices.append(idx) + + row = Adw.ActionRow() + row.set_title(constraint.get_title()) + row.set_activatable(True) + + subtitle = constraint.get_subtitle(sketch.registry) + if subtitle: + row.set_subtitle(subtitle) + + delete_btn = Gtk.Button(child=get_icon("delete-symbolic")) + delete_btn.add_css_class("flat") + delete_btn.add_css_class("circular") + delete_btn.set_valign(Gtk.Align.CENTER) + delete_btn.set_tooltip_text(_("Delete constraint")) + delete_btn.connect("clicked", self._on_delete_clicked, idx) + row.add_suffix(delete_btn) + + icon = get_icon("warning-symbolic") + icon.add_css_class("error") + icon.set_valign(Gtk.Align.CENTER) + row.add_suffix(icon) + + row.connect("activated", self._on_row_activated, idx) + + hover_ctrl = Gtk.EventControllerMotion() + hover_ctrl.connect("enter", self._on_row_enter, idx) + hover_ctrl.connect("leave", self._on_row_leave) + row.add_controller(hover_ctrl) + + self.add(row) + self._rows.append(row) + + def _on_row_enter(self, ctrl, x, y, idx: int): + """Highlights the constraint on the canvas when hovering.""" + if self._sketch_element: + self._sketch_element.external_hovered_constraint_idx = idx + self._sketch_element.mark_dirty() + + def _on_row_leave(self, ctrl): + """Removes highlight when leaving the row.""" + if self._sketch_element: + self._sketch_element.external_hovered_constraint_idx = None + self._sketch_element.mark_dirty() + + def _on_row_activated(self, row, idx: int): + """Selects the constraint when the row is clicked.""" + if self._sketch_element: + self._sketch_element.selection.select_constraint( + idx, is_multi=False + ) + + def _on_delete_clicked(self, btn, idx: int): + """Deletes the constraint.""" + if self._sketch_element is None: + return + + sketch = self._sketch_element.sketch + if idx < 0 or idx >= len(sketch.constraints): + return + + constraint = sketch.constraints[idx] + cmd = RemoveItemsCommand( + sketch=sketch, + name=_("Delete Constraint"), + constraints=[constraint], + ) + self._sketch_element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/editor.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/editor.py new file mode 100644 index 000000000..3ec936aab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/editor.py @@ -0,0 +1,450 @@ +import logging +from typing import TYPE_CHECKING + +from gi.repository import Gdk, GLib, Gtk + +from rayforge.core.undo import HistoryManager +from rayforge.ui_gtk.canvas.cursor import get_tool_cursor +from rayforge.ui_gtk.shared.keyboard import is_primary_modifier + +from ..core.constraints import Constraint +from ..core.entities import Entity, Point +from .piemenu import SketchPieMenu +from .tools import KEY_TO_TOOL, SelectTool, TextBoxTool +from .tools.base import SketcherKey +from .tools.text_box_tool import TextBoxState + +if TYPE_CHECKING: + from .sketchelement import SketchElement + + +logger = logging.getLogger(__name__) + + +class SketchEditor: + """ + The SketchEditor provides a controller for an interactive sketch editing + session. It is not a widget, but rather a host that manages the UI + (PieMenu), state (HistoryManager), and input delegation for a given + SketchElement. It can be used by any canvas-like widget. + """ + + KEY_SEQUENCE_TIMEOUT_MS = 1500 # 1.5 seconds + + def __init__(self, parent_window: Gtk.Window): + self.parent_window = parent_window + self.sketch_element: SketchElement | None = None + + # The SketchEditor manages its own undo/redo history, separate from + # the main document editor. + self.history_manager = HistoryManager() + + # 1. Key Press Handling State + self.key_sequence = [] + self.key_sequence_timer_id: int | None = None + self.text_edit_cursor_timer_id: int | None = None + self._init_shortcuts() + + # 2. Pie Menu Setup + # The pie menu is initially parented to the window, but will be + # re-parented to the canvas when a sketch is activated for more + # reliable positioning (especially on Windows). + self.pie_menu = SketchPieMenu(self.parent_window) + + # Connect signals + self.pie_menu.tool_selected.connect(self.on_tool_selected) + self.pie_menu.right_clicked.connect(self.on_pie_menu_right_click) + + def _init_shortcuts(self): + """Build prefix set for multi-key sequences.""" + self.shortcut_prefixes = { + k[:i] for k in KEY_TO_TOOL for i in range(1, len(k)) + } + + def activate(self, sketch_element: "SketchElement"): + """Begins an editing session on the given SketchElement.""" + logger.debug(f"Activating SketchEditor for element {sketch_element}") + self.sketch_element = sketch_element + self.sketch_element.editor = self + + # Re-parent the pie menu to the canvas for more reliable positioning + # (translate_coordinates doesn't work reliably on Windows when the + # popover is parented to a different widget in the hierarchy) + if sketch_element.canvas: + self.pie_menu.unparent() + self.pie_menu.set_parent(sketch_element.canvas) + + self.history_manager.changed.connect(self._on_history_changed) + + # Connect to TextBoxTool signals for UI management + text_tool = self.sketch_element.tools.get("text_box") + if isinstance(text_tool, TextBoxTool): + text_tool.editing_started.connect(self._on_text_editing_started) + text_tool.editing_finished.connect(self._on_text_editing_finished) + text_tool.cursor_moved.connect(self._on_text_cursor_moved) + + def _on_history_changed(self, sender, command): + """Called when the undo/redo history changes.""" + if self.sketch_element: + self.sketch_element.mark_dirty() + + def deactivate(self): + """Ends the current editing session.""" + logger.debug("Deactivating SketchEditor") + self._reset_key_sequence() + self._stop_text_cursor_timer() + if self.sketch_element: + # Disconnect signals + self.history_manager.changed.disconnect(self._on_history_changed) + + text_tool = self.sketch_element.tools.get("text_box") + if isinstance(text_tool, TextBoxTool): + text_tool.editing_started.disconnect( + self._on_text_editing_started + ) + text_tool.editing_finished.disconnect( + self._on_text_editing_finished + ) + text_tool.cursor_moved.disconnect(self._on_text_cursor_moved) + + # Clean up any in-progress tool state + self.sketch_element.current_tool.on_deactivate() + if self.sketch_element.canvas: + # Reset cursor to default + self.sketch_element.canvas.set_cursor(None) + self.sketch_element.editor = None + self.sketch_element = None + if self.pie_menu.is_visible(): + self.pie_menu.popdown() + + # Re-parent the pie menu back to the window + self.pie_menu.unparent() + self.pie_menu.set_parent(self.parent_window) + + def get_current_cursor(self) -> Gdk.Cursor | None: + """ + Determines the appropriate cursor based on the current tool and + context (e.g., hovering over a point). + """ + if not self.sketch_element: + return None + + # Priority 1: Check for specific hover states in the 'select' tool. + select_tool = self.sketch_element.tools.get("select") + if ( + self.sketch_element.active_tool_name == "select" + and isinstance(select_tool, SelectTool) + and select_tool.hovered_point_id is not None + ): + return Gdk.Cursor.new_from_name("move") + + # Priority 1.5: Text Editing Cursor + current_tool = self.sketch_element.current_tool + if isinstance(current_tool, TextBoxTool) and current_tool.is_hovering: + return Gdk.Cursor.new_from_name("text") + + if current_tool.CURSOR_ICON: + canvas = self.sketch_element.canvas + if canvas: + fg_color = canvas.get_color() + color = (fg_color.red, fg_color.green, fg_color.blue, 1.0) + else: + color = None + return get_tool_cursor(current_tool.CURSOR_ICON, color) + + return None + + def on_pie_menu_right_click(self, sender, gesture, n_press, x, y): + """ + Handles a right-click on the PieMenu. Just closes it - the user + can right-click again to reposition. + """ + self.pie_menu.popdown() + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + def handle_right_click( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ): + """ + Opens the pie menu at the cursor location with resolved context. + This is the primary entry point for right-click handling. + """ + sketch_element = self.sketch_element + if not sketch_element or not sketch_element.canvas: + return + + if self.pie_menu.is_visible(): + self.pie_menu.popdown() + + # Use the element's canvas to convert from widget to world coordinates + world_x, world_y = sketch_element.canvas._get_world_coords(x, y) + + target: Point | Entity | Constraint | None = None + target_type: str | None = None + + # Before showing the menu, we deactivate the current tool to clean + # up any in-progress state. + sketch_element.current_tool.on_deactivate() + + # 1. Hit Test + hit_type, hit_obj = sketch_element.hittester.get_hit_data( + world_x, world_y, sketch_element + ) + target_type = hit_type + + # 2. Resolve Hit Object to Concrete Type AND Update Selection + # Only change selection if the clicked item is not already selected. + sel = sketch_element.selection + + if hit_type == "point": + assert isinstance(hit_obj, int) + pid = hit_obj + target = sketch_element.sketch.registry.get_point(pid) + + # Check if this point is a valid chamfer corner (2 lines). If so, + # select it as a junction instead of a point. + if len(sketch_element.get_lines_at_point(pid)) == 2: + if sel.junction_pid != pid: + sketch_element.selection.select_junction( + pid, is_multi=False + ) + target_type = "junction" + elif pid not in sel.point_ids: + sketch_element.selection.select_point(pid, is_multi=False) + + elif hit_type == "junction": + assert isinstance(hit_obj, int) + pid = hit_obj + target = sketch_element.sketch.registry.get_point(pid) + if sel.junction_pid != pid: + sketch_element.selection.select_junction(pid, is_multi=False) + + elif hit_type == "entity": + assert isinstance(hit_obj, Entity) + target = hit_obj + if target.id not in sel.entity_ids: + sketch_element.selection.select_entity(target, is_multi=False) + + elif hit_type == "constraint": + assert isinstance(hit_obj, int) + idx = hit_obj + if 0 <= idx < len(sketch_element.sketch.constraints): + target = sketch_element.sketch.constraints[idx] + if sel.constraint_idx != idx: + sketch_element.selection.select_constraint( + idx, is_multi=False + ) + + self.pie_menu.set_context(sketch_element, target, target_type) + + if not self.pie_menu.has_items(): + logger.debug("No tools available for this context") + return + + logger.info(f"Opening Pie Menu at {x}, {y} (Type: {target_type})") + self.pie_menu.popup_at_location(x, y) + + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + def on_tool_selected(self, sender, tool: str): + logger.info(f"Tool activated: {tool}") + if self.sketch_element: + self.sketch_element.set_tool(tool) + if self.sketch_element.canvas: + self.sketch_element.canvas.grab_focus() + + # --- Text Box UI Management --- + + def _on_text_editing_started(self, sender: TextBoxTool): + """Starts the cursor blinking timer when text editing begins.""" + self._stop_text_cursor_timer() # Ensure no old timer is running + + def toggle_cursor_callback(): + # This callback continues as long as the tool that started it + # is still in the editing state. + if sender.state == TextBoxState.EDITING: + sender.toggle_cursor_visibility() + return GLib.SOURCE_CONTINUE # Keep timer running + + # If state is no longer editing, the timer should stop. + # This is a safety net; the timer is usually stopped explicitly. + self.text_edit_cursor_timer_id = None + return GLib.SOURCE_REMOVE + + self.text_edit_cursor_timer_id = GLib.timeout_add( + 500, toggle_cursor_callback + ) + + def _on_text_editing_finished(self, sender: TextBoxTool): + """Stops the cursor blinking timer.""" + self._stop_text_cursor_timer() + + def _on_text_cursor_moved(self, sender: TextBoxTool): + """ + Resets the cursor blink timer to ensure visibility immediately + after moving. + """ + self._on_text_editing_started(sender) + + def _stop_text_cursor_timer(self): + """Safely removes the GLib timer source.""" + if self.text_edit_cursor_timer_id is not None: + GLib.source_remove(self.text_edit_cursor_timer_id) + self.text_edit_cursor_timer_id = None + + # --- Key Handling --- + + def _on_key_sequence_timeout(self) -> bool: + """Callback to reset the key sequence after a delay.""" + logger.debug("Key sequence timed out.") + self.key_sequence_timer_id = None + self._reset_key_sequence() + return GLib.SOURCE_REMOVE + + def _reset_key_sequence(self): + """Clears the key sequence and cancels any pending timeout.""" + self.key_sequence.clear() + if self.key_sequence_timer_id: + GLib.source_remove(self.key_sequence_timer_id) + self.key_sequence_timer_id = None + + def handle_key_press( + self, keyval: int, keycode: int, state: Gdk.ModifierType + ) -> bool: + """Handles key press events for the sketcher session.""" + if not self.sketch_element: + return False + + is_primary = is_primary_modifier(state) + is_shift = bool(state & Gdk.ModifierType.SHIFT_MASK) + + # Priority 0: Active text editing + tool = self.sketch_element.current_tool + if ( + isinstance(tool, TextBoxTool) + and tool.state == TextBoxState.EDITING + ): + key_map = { + Gdk.KEY_BackSpace: SketcherKey.BACKSPACE, + Gdk.KEY_Delete: SketcherKey.DELETE, + Gdk.KEY_Left: SketcherKey.ARROW_LEFT, + Gdk.KEY_Right: SketcherKey.ARROW_RIGHT, + Gdk.KEY_Return: SketcherKey.RETURN, + Gdk.KEY_Escape: SketcherKey.ESCAPE, + Gdk.KEY_Home: SketcherKey.HOME, + Gdk.KEY_End: SketcherKey.END, + Gdk.KEY_KP_Home: SketcherKey.HOME, + Gdk.KEY_KP_End: SketcherKey.END, + } + if is_primary: + key_map[Gdk.KEY_z] = SketcherKey.UNDO + key_map[Gdk.KEY_y] = SketcherKey.REDO + key_map[Gdk.KEY_c] = SketcherKey.COPY + key_map[Gdk.KEY_x] = SketcherKey.CUT + key_map[Gdk.KEY_v] = SketcherKey.PASTE + key_map[Gdk.KEY_a] = SketcherKey.SELECT_ALL + if keyval in key_map: + return tool.handle_key_event( + key_map[keyval], shift=is_shift, ctrl=is_primary + ) + + if is_primary: + return False + + key_unicode = Gdk.keyval_to_unicode(keyval) + if key_unicode != 0: + return tool.handle_text_input(chr(key_unicode)) + return False # Unhandled key during text edit + + is_primary = is_primary_modifier(state) + + # Priority 0.5: Tool dimension input during preview + preview_state = tool.get_preview_state() if tool else None + if preview_state is not None and not is_primary: + dim_key_map = { + Gdk.KEY_BackSpace: SketcherKey.BACKSPACE, + Gdk.KEY_Delete: SketcherKey.DELETE, + Gdk.KEY_Return: SketcherKey.RETURN, + Gdk.KEY_KP_Enter: SketcherKey.RETURN, + Gdk.KEY_Escape: SketcherKey.ESCAPE, + Gdk.KEY_Tab: SketcherKey.TAB, + Gdk.KEY_ISO_Left_Tab: SketcherKey.TAB, + } + if keyval in dim_key_map: + handled = tool.handle_key_event( + dim_key_map[keyval], shift=is_shift + ) + if handled: + return True + + key_unicode = Gdk.keyval_to_unicode(keyval) + if key_unicode != 0: + char = chr(key_unicode) + if char.isdigit() or char in ".," or char == " ": + handled = tool.handle_text_input(char) + if handled: + return True + + # Priority 1: Immediate actions (Undo/Redo, Delete) + if is_primary: + if keyval == Gdk.KEY_z: + self.history_manager.undo() + self._reset_key_sequence() + return True + if keyval == Gdk.KEY_y: + self.history_manager.redo() + self._reset_key_sequence() + return True + + if keyval == Gdk.KEY_Delete: + self.sketch_element.delete_selection() + self._reset_key_sequence() + return True + + # Priority 2: Escape key logic + if keyval == Gdk.KEY_Escape: + self._reset_key_sequence() + # If a tool is active, switch to select tool + if self.sketch_element.active_tool_name != "select": + self.sketch_element.set_tool("select") + return True + # If elements are selected, unselect them + if self.sketch_element.get_selected_elements(): + self.sketch_element.unselect_all() + return True + return False # Propagate up if nothing else to do + + # Priority 3: Shortcut sequence handling for normal keys + key_unicode = Gdk.keyval_to_unicode(keyval) + if key_unicode == 0: + # Not a printable character, ignore for sequences. + return False + + char = chr(key_unicode).lower() + self.key_sequence.append(char) + current_sequence = "".join(self.key_sequence) + + logger.debug(f"Key sequence: {current_sequence}") + + # Check for a complete shortcut match + if current_sequence in KEY_TO_TOOL: + tool_name = KEY_TO_TOOL[current_sequence] + logger.info(f"Shortcut '{current_sequence}' -> tool '{tool_name}'") + self.sketch_element.set_tool(tool_name) + self._reset_key_sequence() + return True + + # If it's not a full match, check if it's a prefix of another shortcut + if current_sequence in self.shortcut_prefixes: + # It's a valid start, so reset the timeout timer and wait for the + # next key. + if self.key_sequence_timer_id: + GLib.source_remove(self.key_sequence_timer_id) + self.key_sequence_timer_id = GLib.timeout_add( + self.KEY_SEQUENCE_TIMEOUT_MS, self._on_key_sequence_timeout + ) + return True + + # If the sequence is not a match and not a prefix, it's invalid. + self._reset_key_sequence() + return False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/font_properties.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/font_properties.py new file mode 100644 index 000000000..b6f5cb660 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/font_properties.py @@ -0,0 +1,253 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk, Pango +from raygeo.geo.shape.text import FontConfig + +from rayforge.ui_gtk.icons import get_icon +from rayforge.ui_gtk.shared.pref_rows import SpinRow + +from ..core.commands.text_property import ( + ModifyTextPropertyCommand, +) +from ..core.entities.text_box import TextBoxEntity + +if TYPE_CHECKING: + from .editor import SketchEditor + +logger = logging.getLogger(__name__) + + +class FontPropertiesWidget(Adw.PreferencesGroup): + """ + A widget that displays font properties for a selected TextBoxEntity. + Shows font family, size, bold, and italic options using Adw widgets. + """ + + def __init__(self, editor: "SketchEditor"): + super().__init__() + self.editor = editor + self._text_entity_id: int | None = None + self._in_update = False + self._current_font_family = "sans-serif" + + self.set_title(_("Font Properties")) + self.set_description( + _("Configure font family, size, and style for text boxes") + ) + self.set_visible(False) + + self._build_ui() + + def _build_ui(self): + """Builds the UI for font properties.""" + self.font_family_row = Adw.ActionRow() + self.font_family_row.set_title(_("Font Family")) + self.font_family_row.set_subtitle(self._current_font_family) + self.font_family_row.set_activatable(True) + self.font_family_row.connect( + "activated", self._on_font_family_row_activated + ) + self.font_family_row.add_suffix(get_icon("go-next-symbolic")) + self.add(self.font_family_row) + + self.font_size_row = SpinRow( + _("Font Size"), + lower=1.0, + upper=500.0, + step_increment=0.1, + digits=1, + value=10.0, + ) + self.font_size_row.value_changed.connect(self._on_font_size_changed) + self.add(self.font_size_row) + + self.bold_row = Adw.ActionRow() + self.bold_row.set_title(_("Bold")) + + bold_switch = Gtk.Switch() + bold_switch.set_valign(Gtk.Align.CENTER) + bold_switch.connect("state-set", self._on_bold_changed) + self.bold_row.add_suffix(bold_switch) + self.bold_row.set_activatable_widget(bold_switch) + self.bold_switch = bold_switch + self.add(self.bold_row) + + self.italic_row = Adw.ActionRow() + self.italic_row.set_title(_("Italic")) + + italic_switch = Gtk.Switch() + italic_switch.set_valign(Gtk.Align.CENTER) + italic_switch.connect("state-set", self._on_italic_changed) + self.italic_row.add_suffix(italic_switch) + self.italic_row.set_activatable_widget(italic_switch) + self.italic_switch = italic_switch + self.add(self.italic_row) + + def set_text_entity(self, entity_id: int | None): + """ + Sets the text entity to display font properties for. + Hides the widget if entity_id is None. + """ + self._text_entity_id = entity_id + + if entity_id is None: + self.set_visible(False) + return + + sketch_element = self.editor.sketch_element + if not sketch_element: + self.set_visible(False) + return + + entity = sketch_element.sketch.registry.get_entity(entity_id) + if not isinstance(entity, TextBoxEntity): + self.set_visible(False) + return + + self.set_visible(True) + self._update_ui_from_model(entity.font_config) + + def _update_ui_from_model(self, font_config: FontConfig): + """Updates the UI widgets from the font configuration.""" + self._in_update = True + try: + self.font_size_row.set_value(font_config.size) + self.bold_switch.set_active(font_config.bold) + self.italic_switch.set_active(font_config.italic) + self._current_font_family = font_config.family + self.font_family_row.set_subtitle(self._current_font_family) + finally: + self._in_update = False + + def _get_font_config_from_ui(self) -> FontConfig: + """Creates a FontConfig from the current UI values.""" + return FontConfig( + family=self._current_font_family, + size=self.font_size_row.get_value(), + bold=self.bold_switch.get_active(), + italic=self.italic_switch.get_active(), + ) + + def _on_font_family_row_activated(self, row, *args): + """Handles font family row activation to open font chooser.""" + if self._in_update or self._text_entity_id is None: + return + self._open_font_chooser_dialog() + + def _on_font_size_changed(self, row): + """Handles font size change.""" + if self._in_update or self._text_entity_id is None: + return + self._apply_font_config() + + def _on_bold_changed(self, switch, state): + """Handles bold toggle change.""" + if self._in_update or self._text_entity_id is None: + return + self._apply_font_config() + + def _on_italic_changed(self, switch, state): + """Handles italic toggle change.""" + if self._in_update or self._text_entity_id is None: + return + self._apply_font_config() + + def _open_font_chooser_dialog(self): + """Opens a Gtk font chooser dialog for font selection.""" + dialog = Gtk.FontChooserDialog( + title=_("Select Font"), transient_for=self.editor.parent_window + ) + font_desc = self._get_font_description_from_ui() + dialog.set_font_desc(font_desc) + + def on_response(dialog, response): + logger.debug(f"Font chooser dialog response: {response}") + if response == Gtk.ResponseType.OK: + font_desc = dialog.get_font_desc() + logger.debug(f"Selected font description: {font_desc}") + if font_desc is not None: + self._update_ui_from_font_description(font_desc) + self._apply_font_config() + dialog.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _get_font_description_from_ui(self) -> Pango.FontDescription: + """Creates a Pango.FontDescription from current UI values.""" + font_desc = Pango.FontDescription() + font_desc.set_family(self._current_font_family) + font_size_pt = self.font_size_row.get_value() + font_desc.set_size(int(font_size_pt * Pango.SCALE)) + style = ( + Pango.Style.ITALIC + if self.italic_switch.get_active() + else Pango.Style.NORMAL + ) + font_desc.set_style(style) + weight = ( + Pango.Weight.BOLD + if self.bold_switch.get_active() + else Pango.Weight.NORMAL + ) + font_desc.set_weight(weight) + return font_desc + + def _update_ui_from_font_description( + self, font_desc: Pango.FontDescription + ): + """Updates UI widgets from a Pango.FontDescription.""" + self._in_update = True + try: + family = font_desc.get_family() + if family: + self._current_font_family = family + self.font_family_row.set_subtitle(self._current_font_family) + + size = font_desc.get_size() + if size > 0: + font_size_pt = size / Pango.SCALE + self.font_size_row.set_value(font_size_pt) + + style = font_desc.get_style() + is_italic = style == Pango.Style.ITALIC + self.italic_switch.set_active(is_italic) + + weight = font_desc.get_weight() + is_bold = weight >= Pango.Weight.BOLD + self.bold_switch.set_active(is_bold) + finally: + self._in_update = False + + def _apply_font_config(self): + """Applies the current font configuration to the text entity.""" + if self._text_entity_id is None: + return + + sketch_element = self.editor.sketch_element + if not sketch_element: + return + + entity = sketch_element.sketch.registry.get_entity( + self._text_entity_id + ) + if not isinstance(entity, TextBoxEntity): + return + + new_font_config = self._get_font_config_from_ui() + + # Check if the text box is being edited and use the live buffer + text_tool = sketch_element.tools.get("text_box") + content = entity.content + if text_tool and text_tool.editing_entity_id == self._text_entity_id: + content = text_tool.text_buffer + + cmd = ModifyTextPropertyCommand( + sketch=sketch_element.sketch, + text_entity_id=self._text_entity_id, + new_content=content, + new_font_config=new_font_config, + ) + self.editor.history_manager.execute(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/hittest.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/hittest.py new file mode 100644 index 000000000..55bad6a36 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/hittest.py @@ -0,0 +1,256 @@ +import logging +from typing import Any + +import cairo + +from rayforge.ui_gtk.canvas.worldsurface import WorldSurface + +from ..core.constraints import ( + CoincidentConstraint, + PointOnLineConstraint, +) +from ..core.entities import ( + Bezier, + Entity, + TextBoxEntity, +) + +logger = logging.getLogger(__name__) + + +class SketchHitTester: + """Handles geometric hit testing for sketch elements.""" + + def __init__(self, snap_distance: float = 12.0): + self.snap_distance = snap_distance + + def screen_to_model( + self, wx: float, wy: float, element: Any + ) -> tuple[float, float]: + """ + Converts world coordinates to Model coordinates + (accounting for content_transform). + World -> Local -> Model + """ + try: + # 1. World -> Local + inv_world = element.get_world_transform().invert() + lx, ly = inv_world.transform_point((wx, wy)) + + # 2. Local -> Model (Inverse of content_transform) + inv_content = element.content_transform.invert() + mx, my = inv_content.transform_point((lx, ly)) + + return mx, my + except ValueError: + return 0.0, 0.0 + + def get_model_to_screen_transform(self, element: Any) -> Any: + """ + Returns Matrix: Model -> Screen. + """ + if not element.canvas: + return cairo.Matrix() + + local_to_screen = ( + element.canvas.view_transform @ element.get_world_transform() + ) + model_to_local = element.content_transform + + return local_to_screen @ model_to_local + + def get_hit_data( + self, wx: float, wy: float, element: Any + ) -> tuple[str | None, Any]: + """ + Determines what was clicked using Model coordinates. + Returns (type_string, object_id_or_index). + Priorities: Points > Overlays (Constraints/Junctions) > Entities. + """ + if not element.canvas: + return None, None + + # 1. Points (most specific target) + hit_pid = self._hit_test_points(wx, wy, element) + if hit_pid is not None: + return "point", hit_pid + + # 2. Overlays (Constraints and Junctions) + hit_type, hit_obj = self._hit_test_overlays(wx, wy, element) + if hit_type is not None: + return hit_type, hit_obj + + # 3. Entities (Lines/Arcs/Circles) + hit_entity = self._hit_test_entities(wx, wy, element) + if hit_entity is not None: + return "entity", hit_entity + + return None, None + + def get_objects_in_rect( + self, + min_x: float, + min_y: float, + max_x: float, + max_y: float, + element: Any, + strict_containment: bool = False, + ) -> tuple[list[int], list[int]]: + """ + Finds all points and entities within a Model Space rectangle. + + Args: + min_x, min_y, max_x, max_y: The rectangle in Model Space. + element: The SketchElement. + strict_containment: + If True (Window Selection): Objects must be fully inside. + If False (Crossing Selection): Objects can overlap or be + inside. + + Returns: + A tuple of (list_of_point_ids, list_of_entity_ids). + """ + registry = element.sketch.registry + points_inside = [] + entities_inside = [] + rect = (min_x, min_y, max_x, max_y) + + # 1. Check Points + for p in registry.points: + if p.is_in_rect(rect): + points_inside.append(p.id) + + # 2. Check Entities + for e in registry.entities: + if e.invisible: + continue + is_match = False + if strict_containment: + if e.is_contained_by(rect, registry): + is_match = True + else: + if e.intersects_rect(rect, registry): + is_match = True + + if is_match: + entities_inside.append(e.id) + + return points_inside, entities_inside + + def _hit_test_points(self, wx, wy, element) -> int | None: + """Precise point hit-testing in SCREEN coordinates.""" + if not element.canvas: + return None + + to_screen = self.get_model_to_screen_transform(element) + cursor_sx, cursor_sy = element.canvas.view_transform.transform_point( + (wx, wy) + ) + threshold = element.point_radius + 2.0 + best_pid = None + min_dist_sq = float("inf") + + points = element.sketch.registry.points or [] + for p in points: + pt_sx, pt_sy = to_screen.transform_point((p.x, p.y)) + dist_sq = (cursor_sx - pt_sx) ** 2 + (cursor_sy - pt_sy) ** 2 + if dist_sq < threshold**2 and dist_sq < min_dist_sq: + min_dist_sq = dist_sq + best_pid = p.id + return best_pid + + def _hit_test_overlays(self, wx, wy, element) -> tuple[str | None, Any]: + if not element.canvas: + return None, None + to_screen = self.get_model_to_screen_transform(element) + cursor_sx, cursor_sy = element.canvas.view_transform.transform_point( + (wx, wy) + ) + constraints = element.sketch.constraints or [] + for constr in constraints: + if not constr.user_visible: + continue + threshold = 13.0 + text_box_point_ids = set() + for entity in element.sketch.registry.entities: + if isinstance(entity, TextBoxEntity): + text_box_point_ids.update( + entity.get_all_frame_point_ids(element.sketch.registry) + ) + for entity in element.sketch.registry.entities: + if isinstance(entity, Bezier): + start_pt = element.sketch.registry.get_point(entity.start_idx) + end_pt = element.sketch.registry.get_point(entity.end_idx) + if start_pt is None or end_pt is None: + continue + if entity.cp1 is not None: + cp1_abs = ( + start_pt.x + entity.cp1[0], + start_pt.y + entity.cp1[1], + ) + cp1_sx, cp1_sy = to_screen.transform_point(cp1_abs) + dx = cursor_sx - cp1_sx + dy = cursor_sy - cp1_sy + dist_sq = dx * dx + dy * dy + if dist_sq < threshold**2: + return "control_point_out", ( + start_pt.id, + entity.id, + 1, + ) + if entity.cp2 is not None: + cp2_abs = ( + end_pt.x + entity.cp2[0], + end_pt.y + entity.cp2[1], + ) + cp2_sx, cp2_sy = to_screen.transform_point(cp2_abs) + dx = cursor_sx - cp2_sx + dy = cursor_sy - cp2_sy + dist_sq = dx * dx + dy * dy + if dist_sq < threshold**2: + return "control_point_in", ( + end_pt.id, + entity.id, + 2, + ) + for idx, constr in enumerate(constraints): + if not constr.user_visible: + continue + if isinstance(constr, CoincidentConstraint): + if ( + constr.p1 in text_box_point_ids + or constr.p2 in text_box_point_ids + ): + continue + elif ( + isinstance(constr, PointOnLineConstraint) + and constr.point_id in text_box_point_ids + ): + continue + if constr.is_hit( + cursor_sx, + cursor_sy, + element.sketch.registry, + to_screen.transform_point, + element, + element.point_radius, + ): + return "constraint", idx + return None, None + + def _hit_test_entities(self, wx, wy, element) -> Entity | None: + mx, my = self.screen_to_model(wx, wy, element) + scale = 1.0 + if isinstance(element.canvas, WorldSurface): + scale_x, _ = element.canvas.get_view_scale() + scale = scale_x if scale_x > 1e-9 else 1.0 + threshold = self.snap_distance / scale + + registry = element.sketch.registry + entities = registry.entities or [] + for entity in entities: + if entity.invisible: + continue + if entity.hit_test(mx, my, threshold, registry): + return entity + return None 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 new file mode 100644 index 000000000..8ac9eede6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/menu.py @@ -0,0 +1,57 @@ +from gettext import gettext as _ + +from gi.repository import Gio + + +class SketchMenu(Gio.Menu): + """ + The menu model for the Sketch Studio mode. + """ + + def __init__(self): + super().__init__() + + # File + file_menu = Gio.Menu() + file_menu.append(_("Finish Sketch"), "sketch.finish") + file_menu.append(_("Cancel Sketch"), "sketch.cancel") + self.append_submenu(_("_File"), file_menu) + + # Edit + edit_menu = Gio.Menu() + history_group = Gio.Menu() + history_group.append(_("Undo"), "sketch.undo") + history_group.append(_("Redo"), "sketch.redo") + edit_menu.append_section(None, history_group) + + edit_ops = Gio.Menu() + edit_ops.append(_("Delete"), "sketch.delete") + edit_menu.append_section(None, edit_ops) + self.append_submenu(_("_Edit"), edit_menu) + + # Tools + tools_menu = Gio.Menu() + + tools_group = Gio.Menu() + tools_group.append(_("Select"), "sketch.tool_select") + tools_group.append(_("Circle"), "sketch.tool_circle") + tools_group.append(_("Arc"), "sketch.tool_arc") + 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(_("Fill Area"), "sketch.tool_fill") + tools_menu.append_section(_("Tools"), tools_group) + + constr_group = Gio.Menu() + constr_group.append( + _("Toggle Construction"), "sketch.toggle_construction" + ) + constr_group.append(_("Chamfer Corner"), "sketch.chamfer_corner") + tools_menu.append_section(_("Modify"), constr_group) + + self.append_submenu(_("_Sketch"), tools_menu) + + # View + view_menu = Gio.Menu() + view_menu.append(_("Fit View"), "sketch.view_fit") + self.append_submenu(_("_View"), view_menu) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/piemenu.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/piemenu.py new file mode 100644 index 000000000..1180d5e3e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/piemenu.py @@ -0,0 +1,104 @@ +import logging +from typing import TYPE_CHECKING, Union + +from blinker import Signal +from gi.repository import Gtk + +from rayforge.ui_gtk.shared.piemenu import PieMenu, PieMenuItem + +from .tools import TOOL_REGISTRY + +if TYPE_CHECKING: + from ..core.constraints import Constraint + from ..core.entities import Entity, Point + from .sketchelement import SketchElement + +logger = logging.getLogger(__name__) + + +class SketchPieMenu(PieMenu): + """ + Subclass of PieMenu specifically for the SketcherCanvas. + Builds menu dynamically from registered tools. + """ + + def __init__(self, parent_widget: Gtk.Widget): + super().__init__(parent_widget) + + self.sketch_element: SketchElement | None = None + self.target: Point | Entity | Constraint | None = None + self.target_type: str | None = None + + self.tool_selected = Signal() + + self._tool_to_display_key: dict[str, str] = {} + for tool_name, tool_cls in TOOL_REGISTRY.items(): + if tool_cls.SHORTCUTS: + key = tool_cls.SHORTCUTS[0] + display_key = "Space" if key == " " else key.upper() + self._tool_to_display_key[tool_name] = display_key + + def set_context( + self, + sketch_element: "SketchElement", + target: Union["Point", "Entity", "Constraint"] | None, + target_type: str | None, + ): + self.sketch_element = sketch_element + self.target = target + self.target_type = target_type + + sel_count = 0 + if self.sketch_element and self.sketch_element.selection: + sel = self.sketch_element.selection + sel_count = len(sel.point_ids) + len(sel.entity_ids) + + logger.debug( + f"PieMenu Context: Type={target_type}, " + f"Target={target}, SelectionCount={sel_count}" + ) + + self._rebuild_menu() + + def _rebuild_menu(self): + """Rebuild menu items based on context and tool availability.""" + self.items.clear() + self._active_index = -1 + + if not self.sketch_element: + return + + for tool_name, tool in self.sketch_element.tools.items(): + if tool.ICON is None or tool.LABEL is None: + continue + + if not tool.is_available(self.target, self.target_type): + continue + + label = self._get_tool_label(tool_name, tool.LABEL) + item = PieMenuItem(tool.ICON, label, data=tool_name) + item.on_click.connect(self._on_tool_clicked, weak=False) + self.add_item(item) + + def has_items(self) -> bool: + """Returns True if the menu has any items.""" + return len(self.items) > 0 + + def _get_tool_label(self, tool_name: str, base_label: str) -> str: + """Get label with shortcut hint if available.""" + key = self._tool_to_display_key.get(tool_name) + if not key: + return base_label + + if len(key) > 1 and key != "Space": + formatted_key = "-".join(key) + else: + formatted_key = key + + return f"{base_label} ({formatted_key})" + + def _on_tool_clicked(self, sender): + """Handle tool selection signals.""" + if sender.data: + logger.info(f"Tool activated: {sender.data}") + self.tool_selected.send(self, tool=sender.data) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py new file mode 100644 index 000000000..85be1fa4e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py @@ -0,0 +1,211 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, cast + +from gi.repository import Adw, GLib, Gtk + +from rayforge.core.item import DocItem +from rayforge.core.varset import VarSet +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.doceditor.property_providers.base import ( + PropertyProvider, +) +from rayforge.ui_gtk.shared.pref_rows import SpinRow +from rayforge.ui_gtk.varset.varsetwidget import VarSetRowList + +from .sketch_cmd import SketchCmd + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + from ..core.sketch import Sketch + +logger = logging.getLogger(__name__) + +DEBOUNCE_DELAY_MS = 300 + + +class SketchPropertyProvider(PropertyProvider): + """Provides a VarSetWidget to configure a Sketch's input parameters.""" + + priority = 100 + separate_group = True + group_title = _("Sketch Parameters") + + def can_handle(self, items: list[DocItem]) -> bool: + """ + Handles the selection if all items are WorkPieces derived from the + *same* sketch definition. + """ + if not items: + return False + + first_sketch_uid = None + for item in items: + if ( + not isinstance(item, WorkPiece) + or not item.geometry_provider_uid + ): + return False + + if first_sketch_uid is None: + first_sketch_uid = item.geometry_provider_uid + elif item.geometry_provider_uid != first_sketch_uid: + return False + + return first_sketch_uid is not None + + def create_widgets(self) -> list[Gtk.Widget]: + """Creates the VarSetRowList for sketch parameters.""" + logger.debug("Creating sketch property widgets.") + self.varset_widget = VarSetRowList(show_reset=True) + self.varset_widget.data_changed.connect(self._on_params_changed) + + self._empty_row = Adw.ActionRow( + title=_("No parameters"), + sensitive=False, + ) + + self._debounce_timer_id = 0 + self._pending_changes: dict[str, Any] = {} + + return [self.varset_widget, self._empty_row] + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + """Populates and updates the VarSetRowList based on the selection.""" + logger.debug( + f"Updating sketch property widgets for {len(items)} items." + ) + self._in_update = True + try: + self.editor = editor + self.items = items + workpieces = cast(list[WorkPiece], self.items) + first_wp = workpieces[0] + + sketch = cast("Sketch", first_wp.get_geometry_provider()) + if not sketch: + self.varset_widget.populate(VarSet()) + self._empty_row.set_visible(True) + self.group_subtitle = "" + return + + self.group_subtitle = sketch.name + + if not sketch.input_parameters: + self.varset_widget.populate(VarSet()) + self._empty_row.set_visible(True) + return + + logger.debug( + "Populating VarSetRowList from a clean sketch definition copy." + ) + clean_varset_def = sketch.input_parameters.to_dict( + include_value=False + ) + clean_varset = VarSet.from_dict(clean_varset_def) + self.varset_widget.populate(clean_varset) + self._empty_row.set_visible(False) + + self._update_widget_for_mixed_state(clean_varset, workpieces) + finally: + self._in_update = False + logger.debug("Finished updating sketch property widgets.") + + def _update_widget_for_mixed_state( + self, base_varset: VarSet, workpieces: list[WorkPiece] + ): + """ + Adjusts the UI controls to show common values or indicate a + mixed state when multiple items are selected. + """ + if len(workpieces) == 1: + wp = workpieces[0] + final_values = base_varset.get_values() + final_values.update(wp.geometry_provider_params) + logger.debug( + f"Single item selected, setting final values: {final_values}" + ) + self.varset_widget.set_values(final_values) + return + + logger.debug("Multiple items selected, checking for mixed values.") + for key, (row, var) in self.varset_widget.widget_map.items(): + all_values: set[Any] = set() + for wp in workpieces: + value = wp.geometry_provider_params.get(key, var.default) + all_values.add(value) + + if len(all_values) == 1: + common_value = all_values.pop() + self.varset_widget.set_values({key: common_value}) + else: + logger.debug(f"Parameter '{key}' has mixed values.") + if isinstance(row, Adw.EntryRow): + row.set_text("") + elif isinstance(row, SpinRow): + row.set_subtitle(_("Mixed Values")) + elif isinstance(row, Adw.ComboRow): + row.set_selected(0) + row.set_subtitle(_("Mixed Values")) + elif isinstance( + getattr(row, "get_activatable_widget", lambda: None)(), + Gtk.Switch, + ): + row.set_sensitive(False) + if isinstance(row, Adw.ActionRow): + row.set_subtitle(_("Mixed Values")) + + def _on_params_changed(self, sender: VarSetRowList, key: str): + """ + Handles the raw signal from the VarSetRowList. Instead of applying + changes immediately, it schedules a debounced update. + """ + logger.debug( + f"_on_params_changed called for key '{key}'. " + f"_in_update={self._in_update}." + ) + if self._in_update or not self.items: + return + + if self._debounce_timer_id > 0: + logger.debug("Cancelling previous debounce timer.") + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = 0 + + all_values = sender.get_values() + self._pending_changes[key] = all_values.get(key) + logger.debug(f"Pending changes: {self._pending_changes}") + + logger.debug("Scheduling debounced update.") + self._debounce_timer_id = GLib.timeout_add( + DEBOUNCE_DELAY_MS, self._apply_debounced_changes + ) + + def _apply_debounced_changes(self) -> bool: + """ + Applies the collected changes to the model. This is called by the + GLib timer after the user has paused input. + """ + logger.debug("Debounced timer fired.") + if not self._pending_changes or not self.items: + self._debounce_timer_id = 0 + self._pending_changes.clear() + logger.debug("No pending changes to apply, stopping timer.") + return GLib.SOURCE_REMOVE + + changes_to_apply = self._pending_changes.copy() + self._pending_changes.clear() + self._debounce_timer_id = 0 + + logger.debug( + f"Applying debounced sketch params to {len(self.items)} " + f"items: {changes_to_apply}" + ) + + workpieces = cast(list[WorkPiece], self.items) + SketchCmd(self.editor).set_workpiece_parameters( + workpieces, changes_to_apply + ) + + return GLib.SOURCE_REMOVE diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/renderer.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/renderer.py new file mode 100644 index 000000000..481999646 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/renderer.py @@ -0,0 +1,1120 @@ +import logging +import math +from collections import defaultdict +from collections.abc import Callable +from typing import TYPE_CHECKING + +import cairo +from raygeo.geo import Geometry, Matrix +from raygeo.geo.shape.text import text_to_geometry +from raygeo.geo.types import Point as GeoPoint + +from rayforge.image.geo_renderer import geometry_to_cairo +from rayforge.ui_gtk.canvas import WorldSurface + +from ..core.commands import BezierPreviewState +from ..core.commands.dimension import DimensionData +from ..core.constraints import ( + CoincidentConstraint, + PointOnLineConstraint, +) +from ..core.entities import ( + Arc, + Bezier, + Circle, + Ellipse, + Entity, + Line, + Point, + TextBoxEntity, +) +from ..core.sketch import FillStyle +from ..core.types import EntityID +from .tools import PathTool, TextBoxTool + +if TYPE_CHECKING: + from .sketchelement import SketchElement + +logger = logging.getLogger(__name__) + + +class SketchRenderer: + """Handles rendering of the sketch to a Cairo context.""" + + def __init__(self, element: "SketchElement") -> None: + self.element = element + + def draw(self, ctx: cairo.Context): + """Main draw entry point for sketch entities.""" + ctx.save() + + # Apply the Content Transform (Model -> Local) + content_matrix = cairo.Matrix( + *self.element.content_transform.for_cairo() + ) + ctx.transform(content_matrix) + + # Calculate the inverse scale to maintain constant line width on + # screen. + scale = 1.0 + if isinstance(self.element.canvas, WorldSurface): + scale_x, _ = self.element.canvas.get_view_scale() + scale = scale_x if scale_x > 1e-9 else 1.0 + + scaled_line_width = self.element.line_width / scale + + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + ctx.set_line_join(cairo.LINE_JOIN_ROUND) + ctx.set_line_width(scaled_line_width) + + # Check if the element is the active edit context on the canvas. + is_editing = bool( + self.element.canvas + and self.element.canvas.edit_context is self.element + ) + + # Draw the Origin Icon (Underneath geometry) only when in edit mode. + if is_editing: + self._draw_origin(ctx) + + self._draw_fills(ctx) + self._draw_entities(ctx, is_editing, scaled_line_width) + ctx.restore() + + def draw_edit_overlay(self, ctx: cairo.Context): + """Draws constraints, points, and handles on top of the canvas.""" + if not self.element.canvas: + return + + ctx.set_font_size(12) + + to_screen = self.element.hittester.get_model_to_screen_transform( + self.element + ) + self._draw_points(ctx, to_screen) + self._draw_overlays(ctx) + self._draw_preview_dimensions(ctx) + self._draw_bezier_control_handles(ctx) + + def draw_entity_highlight( + self, + ctx: cairo.Context, + entity: Entity, + color: tuple, + line_width: float = 3.0, + ) -> bool: + """ + Draws an entity with a highlight color in model coordinates. + The caller should set up the appropriate coordinate transform. + Returns True if the entity was drawn successfully. + """ + has_path = False + if isinstance(entity, Line): + has_path = self._define_line_path(ctx, entity) + elif isinstance(entity, Arc): + has_path = self._define_arc_path(ctx, entity) + elif isinstance(entity, Circle): + has_path = self._define_circle_path(ctx, entity) + elif isinstance(entity, Bezier): + has_path = self._define_bezier_path(ctx, entity) + elif isinstance(entity, Ellipse): + has_path = self._define_ellipse_path(ctx, entity) + + if has_path: + ctx.set_source_rgba(*color) + ctx.set_dash([]) + ctx.set_line_width(line_width) + ctx.stroke() + + return has_path + + def _draw_origin(self, ctx: cairo.Context): + """Draws a fixed symbol at (0,0).""" + # The Origin is physically at 0,0 in Model Space + scale = 1.0 + # Check if the host canvas supports get_view_scale + if self.element.canvas: + get_view_scale = getattr( + self.element.canvas, "get_view_scale", None + ) + if get_view_scale: + scale_x, _ = get_view_scale() + scale = scale_x if scale_x > 1e-9 else 1.0 + + ctx.save() + ctx.set_source_rgb(0.8, 0.2, 0.2) # Reddish + # Scale line width so it stays constant on screen + ctx.set_line_width(2.0 / scale) + + len_ = 10.0 / scale + ctx.move_to(-len_, 0) + ctx.line_to(len_, 0) + ctx.move_to(0, -len_) + ctx.line_to(0, len_) + ctx.stroke() + + # Circle + ctx.arc(0, 0, 4.0 / scale, 0, 2 * math.pi) + ctx.stroke() + ctx.restore() + + def _draw_fills(self, ctx: cairo.Context): + """Draws the filled regions of the sketch.""" + exclude_ids = set() + text_tool = self.element.tools.get("text_box") + if ( + self.element.active_tool_name == "text_box" + and isinstance(text_tool, TextBoxTool) + and text_tool.editing_entity_id is not None + ): + exclude_ids.add(text_tool.editing_entity_id) + + sketch = self.element.sketch + + for fill in sketch.fills: + if not fill.boundary: + continue + + geo = self._get_fill_geometry(fill, exclude_ids) + if geo is None: + continue + + ctx.new_path() + geometry_to_cairo(geo, ctx) + ctx.close_path() + + ctx.save() + self._apply_fill_style(ctx, fill) + ctx.fill() + ctx.restore() + + self._draw_text_box_fills(ctx, exclude_ids) + + def _get_fill_geometry(self, fill, exclude_ids): + """Generate geometry for a single fill.""" + if len(fill.boundary) == 1: + eid, _ = fill.boundary[0] + if eid in exclude_ids: + return None + entity = self.element.sketch.registry.get_entity(eid) + if entity: + return entity.create_fill_geometry( + self.element.sketch.registry + ) + return None + + try: + first_eid, first_fwd = fill.boundary[0] + if first_eid in exclude_ids: + return None + first_ent = self.element.sketch.registry.get_entity(first_eid) + if not first_ent: + return None + + p_ids = first_ent.get_endpoint_ids() + start_pid = p_ids[0] if first_fwd else p_ids[1] + start_pt = self.element.sketch.registry.get_point(start_pid) + + geo = Geometry() + geo.move_to(start_pt.x, start_pt.y) + + valid_loop = True + for eid, fwd in fill.boundary: + if eid in exclude_ids: + valid_loop = False + break + entity = self.element.sketch.registry.get_entity(eid) + if not entity: + valid_loop = False + break + entity.append_to_geometry( + geo, self.element.sketch.registry, fwd + ) + + if valid_loop: + return geo + except (IndexError, AttributeError): + pass + return None + + def _apply_fill_style(self, ctx: cairo.Context, fill): + """Apply fill color or gradient to the cairo context.""" + if fill.style == FillStyle.SOLID: + ctx.set_source_rgba(*fill.color) + elif fill.style == FillStyle.LINEAR_GRADIENT: + self._apply_linear_gradient(ctx, fill) + elif fill.style == FillStyle.RADIAL_GRADIENT: + self._apply_radial_gradient(ctx, fill) + else: + ctx.set_source_rgba(*fill.color) + + def _apply_linear_gradient(self, ctx: cairo.Context, fill): + """Apply a linear gradient fill.""" + ext = ctx.path_extents() + x1, y1, x2, y2 = ext + + angle_rad = math.radians(fill.gradient_angle) + cx = (x1 + x2) / 2 + cy = (y1 + y2) / 2 + half_w = (x2 - x1) / 2 + half_h = (y2 - y1) / 2 + + dx = math.cos(angle_rad) * max(half_w, half_h) + dy = math.sin(angle_rad) * max(half_w, half_h) + + grad = cairo.LinearGradient(cx - dx, cy - dy, cx + dx, cy + dy) + + if fill.gradient_stops: + for pos, color in fill.gradient_stops: + grad.add_color_stop_rgba(pos, *color) + else: + grad.add_color_stop_rgba(0.0, *fill.color) + grad.add_color_stop_rgba(1.0, *fill.color) + + ctx.set_source(grad) + + def _apply_radial_gradient(self, ctx: cairo.Context, fill): + """Apply a radial gradient fill.""" + ext = ctx.path_extents() + x1, y1, x2, y2 = ext + + cx = (x1 + x2) / 2 + cy = (y1 + y2) / 2 + radius = max((x2 - x1) / 2, (y2 - y1) / 2) + + grad = cairo.RadialGradient(cx, cy, 0, cx, cy, radius) + + if fill.gradient_stops: + for pos, color in fill.gradient_stops: + grad.add_color_stop_rgba(pos, *color) + else: + grad.add_color_stop_rgba(0.0, *fill.color) + grad.add_color_stop_rgba(1.0, *fill.color) + + ctx.set_source(grad) + + def _draw_text_box_fills(self, ctx: cairo.Context, exclude_ids: set): + """Draw fills for text box entities.""" + for entity in self.element.sketch.registry.entities: + if entity.id in exclude_ids: + continue + if ( + not entity.construction + and isinstance(entity, TextBoxEntity) + and entity.fill_color is not None + ): + text_geo = entity.create_text_fill_geometry( + self.element.sketch.registry + ) + if text_geo: + ctx.new_path() + geometry_to_cairo(text_geo, ctx) + ctx.close_path() + ctx.save() + ctx.set_source_rgba(*entity.fill_color) + ctx.fill() + ctx.restore() + + # --- Entities --- + + def _draw_entities( + self, ctx: cairo.Context, is_editing: bool, base_line_width: float + ): + is_sketch_fully_constrained = self.element.sketch.is_fully_constrained + entities = self.element.sketch.registry.entities or [] + text_tool = self.element.tools.get("text_box") + select_tool = self.element.tools.get("select") + hovered_entity_id = ( + select_tool.hovered_entity_id if select_tool else None + ) + + for entity in entities: + # If a text box is being actively edited, its tool overlay will + # draw it, so we skip the main render pass to avoid flicker. + is_being_edited = ( + isinstance(entity, TextBoxEntity) + and self.element.active_tool_name == "text_box" + and isinstance(text_tool, TextBoxTool) + and text_tool.editing_entity_id == entity.id + ) + if is_being_edited: + continue + + # Skip construction geometry if not in edit mode or if hidden + if entity.construction and ( + not is_editing or not self.element.show_construction_geometry + ): + continue + + if entity.invisible: + continue + + is_sel = entity.id in self.element.selection.entity_ids + is_hovered = entity.id == hovered_entity_id + ctx.save() + + # 1. Define the Path + has_path = False + if isinstance(entity, Line): + has_path = self._define_line_path(ctx, entity) + elif isinstance(entity, Arc): + has_path = self._define_arc_path(ctx, entity) + elif isinstance(entity, Bezier): + has_path = self._define_bezier_path(ctx, entity) + elif isinstance(entity, Circle): + has_path = self._define_circle_path(ctx, entity) + elif isinstance(entity, Ellipse): + has_path = self._define_ellipse_path(ctx, entity) + elif isinstance(entity, TextBoxEntity): + has_path = self._define_text_box_path(ctx, entity) + + if not has_path: + ctx.restore() + continue + + # 2. Draw Selection Underlay (Blurry Glow) + if is_sel: + ctx.save() + ctx.set_dash([]) + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + if isinstance(entity, TextBoxEntity): + ctx.set_line_width(base_line_width * 2.0) + else: + ctx.set_line_width(base_line_width * 3.0) + ctx.stroke_preserve() + ctx.restore() + elif is_hovered: + ctx.save() + ctx.set_dash([]) + ctx.set_source_rgba(1.0, 0.4, 0.2, 0.4) + if isinstance(entity, TextBoxEntity): + ctx.set_line_width(base_line_width * 2.0) + else: + ctx.set_line_width(base_line_width * 3.0) + ctx.stroke_preserve() + ctx.restore() + + # 3. Draw Actual Entity + if isinstance(entity, TextBoxEntity): + if entity.fill_color is not None: + ctx.set_source_rgba(*entity.fill_color) + else: + self._set_standard_color( + ctx, + False, + entity.constrained, + is_sketch_fully_constrained, + ) + ctx.fill() + elif entity.construction: + scale = self.element.line_width / base_line_width + ctx.set_dash([5.0 / scale, 5.0 / scale]) + ctx.set_line_width(base_line_width * 0.8) + if is_hovered: + ctx.set_source_rgb(1.0, 0.4, 0.2) + elif entity.constrained: + ctx.set_source_rgb(0.2, 0.3, 0.6) + else: + ctx.set_source_rgb(0.3, 0.5, 0.8) + ctx.stroke() + else: + self._set_standard_color( + ctx, + is_sel or is_hovered, + entity.constrained, + is_sketch_fully_constrained, + ) + ctx.stroke() + + ctx.restore() + + def _set_standard_color( + self, + ctx: cairo.Context, + is_selected: bool, + is_constrained: bool, + is_sketch_fully_constrained: bool, + ): + if is_selected: + ctx.set_source_rgb(0.2, 0.6, 1.0) # Blue + elif is_constrained: + if is_sketch_fully_constrained: + ctx.set_source_rgb(0.0, 0.6, 0.0) # Darker Green + else: + ctx.set_source_rgb(0.2, 0.8, 0.2) # Light Green + else: + if self.element.canvas: + fg_rgba = self.element.canvas.get_color() + ctx.set_source_rgb(fg_rgba.red, fg_rgba.green, fg_rgba.blue) + else: + ctx.set_source_rgb(0.0, 0.0, 0.0) + + def _safe_get_point(self, pid: EntityID) -> Point | None: + try: + return self.element.sketch.registry.get_point(pid) + except IndexError: + return None + + def _define_line_path(self, ctx: cairo.Context, line: Line) -> bool: + """Defines the path for a line without stroking.""" + p1 = self._safe_get_point(line.p1_idx) + p2 = self._safe_get_point(line.p2_idx) + if p1 and p2: + ctx.move_to(p1.x, p1.y) + ctx.line_to(p2.x, p2.y) + return True + return False + + def _define_arc_path(self, ctx: cairo.Context, arc: Arc) -> bool: + """Defines the path for an arc without stroking.""" + start = self._safe_get_point(arc.start_idx) + end = self._safe_get_point(arc.end_idx) + center = self._safe_get_point(arc.center_idx) + if not (start and end and center): + return False + + radius = math.hypot(start.x - center.x, start.y - center.y) + start_a = math.atan2(start.y - center.y, start.x - center.x) + end_a = math.atan2(end.y - center.y, end.x - center.x) + + ctx.new_sub_path() + if arc.clockwise: + ctx.arc_negative(center.x, center.y, radius, start_a, end_a) + else: + ctx.arc(center.x, center.y, radius, start_a, end_a) + return True + + def _define_circle_path(self, ctx: cairo.Context, circle: Circle) -> bool: + """Defines the path for a circle without stroking.""" + center = self._safe_get_point(circle.center_idx) + radius_pt = self._safe_get_point(circle.radius_pt_idx) + if not (center and radius_pt): + return False + + radius = math.hypot(radius_pt.x - center.x, radius_pt.y - center.y) + ctx.new_sub_path() + ctx.arc(center.x, center.y, radius, 0, 2 * math.pi) + return True + + def _define_ellipse_path( + self, ctx: cairo.Context, ellipse: Ellipse + ) -> bool: + """Defines the path for an ellipse without stroking.""" + center = self._safe_get_point(ellipse.center_idx) + radius_x_pt = self._safe_get_point(ellipse.radius_x_pt_idx) + radius_y_pt = self._safe_get_point(ellipse.radius_y_pt_idx) + if not (center and radius_x_pt and radius_y_pt): + return False + + rx = math.hypot(radius_x_pt.x - center.x, radius_x_pt.y - center.y) + ry = math.hypot(radius_y_pt.x - center.x, radius_y_pt.y - center.y) + if rx < 1e-9 or ry < 1e-9: + return False + + rotation = math.atan2( + radius_x_pt.y - center.y, radius_x_pt.x - center.x + ) + + ctx.save() + ctx.translate(center.x, center.y) + ctx.rotate(rotation) + ctx.scale(rx, ry) + ctx.new_sub_path() + ctx.arc(0, 0, 1, 0, 2 * math.pi) + ctx.restore() + return True + + def _define_bezier_path(self, ctx: cairo.Context, bezier: Bezier) -> bool: + """Defines the path for a bezier curve without stroking.""" + start = self._safe_get_point(bezier.start_idx) + end = self._safe_get_point(bezier.end_idx) + if not (start and end): + return False + + if bezier.is_line(self.element.sketch.registry): + ctx.move_to(start.x, start.y) + ctx.line_to(end.x, end.y) + return True + + cp1_x, cp1_y, cp2_x, cp2_y = bezier.get_control_points_or_endpoints( + self.element.sketch.registry + ) + ctx.move_to(start.x, start.y) + ctx.curve_to(cp1_x, cp1_y, cp2_x, cp2_y, end.x, end.y) + return True + + def _define_text_box_path( + self, ctx: cairo.Context, entity: TextBoxEntity + ) -> bool: + if not entity.content: + return False + + p_origin = self._safe_get_point(entity.origin_id) + p_width = self._safe_get_point(entity.width_id) + p_height = self._safe_get_point(entity.height_id) + + if not (p_origin and p_width and p_height): + return False + + natural_geo = text_to_geometry( + entity.content, font_config=entity.font_config + ) + _, geo_min_y, _, geo_max_y = natural_geo.rect() + text_width = entity.font_config.get_text_width(entity.content) + advance_width = text_width or 1.0 + + transformed_geo = natural_geo.map_to_frame( + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p_height.x, p_height.y), + anchor_x=0.0, + stable_src_width=advance_width, + anchor_y=geo_min_y, + stable_src_height=geo_max_y - geo_min_y, + ) + + geometry_to_cairo(transformed_geo, ctx) + return True + + # --- Overlays (Constraints & Junctions) --- + + def _draw_overlays(self, ctx: cairo.Context): + if not self.element.show_constraints: + return + + # --- Stage 0: Get Hover State --- + select_tool = self.element.tools.get("select") + hovered_constraint_idx = ( + select_tool.hovered_constraint_idx if select_tool else None + ) + if self.element.external_hovered_constraint_idx is not None: + hovered_constraint_idx = ( + self.element.external_hovered_constraint_idx + ) + + # Collect all points associated with text boxes to hide their overlays + text_box_point_ids = set() + for entity in self.element.sketch.registry.entities: + if isinstance(entity, TextBoxEntity): + text_box_point_ids.update( + entity.get_all_frame_point_ids( + self.element.sketch.registry + ) + ) + + # Collect construction entity IDs and their point IDs + construction_entity_ids = set() + construction_point_ids = set() + if not self.element.show_construction_geometry: + for entity in self.element.sketch.registry.entities: + if entity.construction: + construction_entity_ids.add(entity.id) + construction_point_ids.update(entity.get_point_ids()) + + to_screen_transform = ( + self.element.hittester.get_model_to_screen_transform(self.element) + ) + to_screen_func = to_screen_transform.transform_point + + # --- Stage 1: Draw Individual Constraints --- + constraints = self.element.sketch.constraints or [] + for idx, constr in enumerate(constraints): + if not constr.user_visible: + continue + + # Filter specific constraints on text box points to reduce clutter + if isinstance( + constr, (CoincidentConstraint, PointOnLineConstraint) + ) and constr.depends_on_points(text_box_point_ids): + continue + + # Hide constraints referencing construction geometry when hidden + if (construction_entity_ids or construction_point_ids) and ( + constr.depends_on_entities(construction_entity_ids) + or constr.depends_on_points(construction_point_ids) + ): + continue + + is_sel = idx == self.element.selection.constraint_idx + is_hovered = idx == hovered_constraint_idx + + constr.draw( + ctx, + self.element.sketch.registry, + to_screen_func, + is_sel, + is_hovered, + point_radius=self.element.point_radius, + ) + + # Draw implicit junction constraints + self._draw_junctions( + ctx, to_screen_func, text_box_point_ids, construction_point_ids + ) + + def _draw_junctions( + self, + ctx: cairo.Context, + to_screen: Callable[[GeoPoint], GeoPoint], + text_box_point_ids: set[int], + construction_point_ids: set[int] | None = None, + ) -> None: + if construction_point_ids is None: + construction_point_ids = set() + registry = self.element.sketch.registry + select_tool = self.element.tools.get("select") + hovered_junction_pid = ( + select_tool.hovered_junction_pid if select_tool else None + ) + + point_counts = defaultdict(int) + for entity in registry.entities: + if isinstance(entity, Line): + point_counts[entity.p1_idx] += 1 + point_counts[entity.p2_idx] += 1 + elif isinstance(entity, Arc): + point_counts[entity.start_idx] += 1 + point_counts[entity.end_idx] += 1 + point_counts[entity.center_idx] += 1 + elif isinstance(entity, Bezier): + point_counts[entity.start_idx] += 1 + point_counts[entity.end_idx] += 1 + elif isinstance(entity, Circle): + point_counts[entity.center_idx] += 1 + point_counts[entity.radius_pt_idx] += 1 + elif isinstance(entity, Ellipse): + point_counts[entity.center_idx] += 1 + point_counts[entity.radius_x_pt_idx] += 1 + point_counts[entity.radius_y_pt_idx] += 1 + + for pid, count in point_counts.items(): + if count > 1: + # Hide junction visuals for text box points + if pid in text_box_point_ids: + continue + + # Hide junction visuals for construction geometry when hidden + if pid in construction_point_ids: + continue + + is_sel = pid == self.element.selection.junction_pid + is_hovered = pid == hovered_junction_pid + p = self._safe_get_point(pid) + if p: + sx, sy = to_screen((p.x, p.y)) + ctx.save() + ctx.set_line_width(1.5) + + radius = self.element.point_radius + 4 + ctx.new_sub_path() + ctx.arc(sx, sy, radius, 0, 2 * math.pi) + + if is_sel: + self._draw_selection_underlay(ctx) + + # Junctions are always implicit, so we use slightly + # different colors + if is_hovered: + ctx.set_source_rgba(1.0, 0.6, 0.0, 0.9) + else: + ctx.set_source_rgba(0.0, 0.6, 0.0, 0.8) + + ctx.stroke() + ctx.restore() + + def _draw_selection_underlay( + self, ctx: cairo.Context, width_scale: float = 3.0 + ) -> None: + """Draws a semi-transparent blue underlay for the current path.""" + ctx.save() + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.4) + ctx.set_line_width(ctx.get_line_width() * width_scale) + ctx.stroke_preserve() + ctx.restore() + + def _get_entity_by_id(self, eid: EntityID) -> Entity | None: + return self.element.sketch.registry.get_entity(eid) + + # --- Points --- + + def _draw_points( + self, ctx: cairo.Context, to_screen_matrix: Matrix + ) -> None: + """Draws all sketch points, including selection highlights.""" + is_sketch_fully_constrained = self.element.sketch.is_fully_constrained + points = self.element.sketch.registry.points or [] + origin_id = getattr(self.element.sketch, "origin_id", -1) + hover_pid = self.element.tools["select"].hovered_point_id + + if self.element.active_tool_name == "path": + path_tool = self.element.tools.get("path") + if path_tool is not None: + path_hover_pid = path_tool.hovered_point_id + if path_hover_pid is not None: + hover_pid = path_hover_pid + + entity_points = set() + + for eid in self.element.selection.entity_ids: + ent = self._get_entity_by_id(eid) + if isinstance(ent, TextBoxEntity): + entity_points.update( + ent.get_all_frame_point_ids(self.element.sketch.registry) + ) + elif ent: + entity_points.update(ent.get_point_ids()) + + # Collect construction point IDs to hide when construction is hidden + construction_point_ids = set() + if not self.element.show_construction_geometry: + for entity in self.element.sketch.registry.entities: + if entity.construction: + construction_point_ids.update(entity.get_point_ids()) + + # Collect hidden point IDs from active tool preview + hidden_point_ids = set() + preview_state = self.element.current_tool.get_preview_state() + if preview_state is not None: + hidden_point_ids = preview_state.get_hidden_point_ids() + + to_screen = to_screen_matrix.transform_point + + for p in points: + # Hide points belonging to construction geometry when hidden + if p.id in construction_point_ids: + continue + # Hide points marked as hidden by active tool preview + if p.id in hidden_point_ids: + continue + sx, sy = to_screen((p.x, p.y)) + + is_hovered = p.id == hover_pid + is_explicit_sel = p.id in self.element.selection.point_ids + is_implicit_sel = p.id in entity_points + + if p.id == origin_id: + if is_hovered or is_explicit_sel: + ctx.save() + if is_hovered: + ctx.set_source_rgba(1.0, 0.2, 0.2, 1.0) + else: # Selected + ctx.set_source_rgba(0.2, 0.6, 1.0, 1.0) # Blue + ctx.set_line_width(2.0) + ctx.arc( + sx, sy, self.element.point_radius * 1.5, 0, 2 * math.pi + ) + ctx.stroke() + ctx.restore() + continue # Always skip drawing solid dot for origin + + r = self.element.point_radius + + if is_hovered: + ctx.save() + ctx.set_source_rgba(1.0, 0.4, 0.2, 0.4) + ctx.arc(sx, sy, r + 5, 0, 2 * math.pi) + ctx.fill() + ctx.restore() + + # 1. Selection Glow Underlay + if is_explicit_sel or is_implicit_sel: + ctx.save() + ctx.set_source_rgba( + 0.2, 0.6, 1.0, 0.4 + ) # Semi-transparent blue + ctx.arc(sx, sy, r + 4, 0, 2 * math.pi) + ctx.fill() + ctx.restore() + + # 2. Main Point (Hover or Standard Color) + if is_hovered: + ctx.set_source_rgba(1.0, 0.2, 0.2, 1.0) + elif p.constrained: + if is_sketch_fully_constrained: + ctx.set_source_rgba(0.0, 0.6, 0.0, 1.0) # Darker Green + else: + ctx.set_source_rgba(0.2, 0.8, 0.2, 1.0) # Light Green + else: + ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0) # Black + + ctx.arc(sx, sy, r, 0, 2 * math.pi) + ctx.fill() + + def _draw_preview_dimensions(self, ctx: cairo.Context): + tool = self.element.current_tool + preview_state = tool.get_preview_state() + if preview_state is None: + return + + dimensions = preview_state.get_dimensions(self.element.sketch.registry) + if not dimensions: + return + + to_screen_transform = ( + self.element.hittester.get_model_to_screen_transform(self.element) + ) + to_screen = to_screen_transform.transform_point + + ctx.save() + ctx.set_font_size(11) + ctx.set_line_width(1.0) + + dim_input_buffer = getattr(tool, "_dim_input", None) + dim_input_active = ( + dim_input_buffer is not None and dim_input_buffer.is_active() + ) + + for dim_idx, dim in enumerate(dimensions): + if not isinstance(dim, DimensionData): + continue + + sx, sy = to_screen(dim.position) + + has_leader = dim.leader_end is not None + + label = dim.label + is_editing = False + if dim_input_active and dim_input_buffer is not None: + field_text = dim_input_buffer.get_display_text(dim_idx) + if field_text is not None: + label = field_text + is_editing = True + elif dim_input_buffer.field_count == 1: + label = dim_input_buffer.get_display_text() or label + is_editing = True + + extents = ctx.text_extents(label) + text_w = extents.width + text_h = extents.height + x_bearing = extents.x_bearing + + label_offset_x = 15 + label_offset_y = -15 + label_sx = sx + label_offset_x + label_sy = sy + label_offset_y + + if has_leader: + lx, ly = to_screen(dim.leader_end) + ctx.set_source_rgba(0.2, 0.4, 0.8, 0.9) + ctx.move_to(lx, ly) + ctx.line_to(label_sx, label_sy) + ctx.stroke() + + padding = 3.0 + bg_x = label_sx + x_bearing - padding + bg_y = label_sy - text_h - padding + bg_w = text_w + 2 * padding + bg_h = text_h + 2 * padding + + if is_editing: + ctx.set_source_rgba(0.9, 0.95, 1.0, 0.95) + else: + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.85) + ctx.rectangle(bg_x, bg_y, bg_w, bg_h) + ctx.fill() + + if is_editing: + ctx.set_source_rgba(0.0, 0.2, 0.8, 1.0) + else: + ctx.set_source_rgba(0.1, 0.1, 0.1, 1.0) + ctx.move_to(label_sx, label_sy) + ctx.show_text(label) + + ctx.restore() + + def _draw_bezier_control_handles(self, ctx: cairo.Context): + """Draws control handles for bezier preview and selected beziers.""" + to_screen_transform = ( + self.element.hittester.get_model_to_screen_transform(self.element) + ) + to_screen = to_screen_transform.transform_point + + tool = self.element.current_tool + preview_state = None + if isinstance(tool, PathTool): + preview_state = tool.get_preview_state() + + if ( + isinstance(preview_state, BezierPreviewState) + and not preview_state.is_line_preview + ): + self._draw_bezier_preview_handles(ctx, to_screen, preview_state) + + selected_bezier_ids: set[int] = set() + selected_point_ids: set[int] = set() + for eid in self.element.selection.entity_ids: + entity = self._get_entity_by_id(eid) + if isinstance(entity, Bezier): + selected_bezier_ids.add(eid) + selected_point_ids.add(entity.start_idx) + selected_point_ids.add(entity.end_idx) + + for pid in selected_point_ids: + waypoint = self._safe_get_point(pid) + if waypoint is None: + continue + self._draw_waypoint_handles(ctx, to_screen, waypoint) + + selected_waypoint_ids: set[int] = set() + for pid in self.element.selection.point_ids: + selected_waypoint_ids.add(pid) + if self.element.selection.junction_pid is not None: + selected_waypoint_ids.add(self.element.selection.junction_pid) + + for pid in selected_waypoint_ids: + waypoint = self._safe_get_point(pid) + if waypoint is None: + continue + connected = waypoint.get_connected_beziers( + self.element.sketch.registry + ) + if connected: + self._draw_waypoint_handles(ctx, to_screen, waypoint) + + def _draw_bezier_preview_handles( + self, ctx: cairo.Context, to_screen, preview_state: BezierPreviewState + ): + """Draw control handles for the active bezier preview.""" + if preview_state.end_id is None: + return + + try: + start_pt = self.element.sketch.registry.get_point( + preview_state.start_id + ) + end_pt = self.element.sketch.registry.get_point( + preview_state.end_id + ) + except IndexError: + return + + if not (start_pt and end_pt): + return + + start_sx, start_sy = to_screen((start_pt.x, start_pt.y)) + end_sx, end_sy = to_screen((end_pt.x, end_pt.y)) + + temp_bezier = None + if preview_state.temp_entity_id is not None: + temp_entity = self.element.sketch.registry.get_entity( + preview_state.temp_entity_id + ) + if isinstance(temp_entity, Bezier): + temp_bezier = temp_entity + + ctx.save() + ctx.set_line_width(1.0) + ctx.set_source_rgba(0.6, 0.4, 0.8, 0.8) + + if temp_bezier is not None: + if temp_bezier.cp1 is not None: + cp1_abs = ( + start_pt.x + temp_bezier.cp1[0], + start_pt.y + temp_bezier.cp1[1], + ) + cp1_sx, cp1_sy = to_screen(cp1_abs) + ctx.move_to(start_sx, start_sy) + ctx.line_to(cp1_sx, cp1_sy) + ctx.stroke() + + if temp_bezier.cp2 is not None: + cp2_abs = ( + end_pt.x + temp_bezier.cp2[0], + end_pt.y + temp_bezier.cp2[1], + ) + cp2_sx, cp2_sy = to_screen(cp2_abs) + ctx.move_to(end_sx, end_sy) + ctx.line_to(cp2_sx, cp2_sy) + ctx.stroke() + + virtual_cp_abs = preview_state.get_virtual_cp_absolute( + self.element.sketch.registry + ) + if virtual_cp_abs is not None: + cp_sx, cp_sy = to_screen(virtual_cp_abs) + ctx.move_to(end_sx, end_sy) + ctx.line_to(cp_sx, cp_sy) + ctx.stroke() + + ctx.set_source_rgba(0.6, 0.4, 0.8, 1.0) + + if temp_bezier is not None: + if temp_bezier.cp1 is not None: + cp1_abs = ( + start_pt.x + temp_bezier.cp1[0], + start_pt.y + temp_bezier.cp1[1], + ) + cp1_sx, cp1_sy = to_screen(cp1_abs) + ctx.rectangle(cp1_sx - 4, cp1_sy - 4, 8, 8) + ctx.fill() + + if temp_bezier.cp2 is not None: + cp2_abs = ( + end_pt.x + temp_bezier.cp2[0], + end_pt.y + temp_bezier.cp2[1], + ) + cp2_sx, cp2_sy = to_screen(cp2_abs) + ctx.rectangle(cp2_sx - 4, cp2_sy - 4, 8, 8) + ctx.fill() + + if virtual_cp_abs is not None: + cp_sx, cp_sy = to_screen(virtual_cp_abs) + ctx.rectangle(cp_sx - 4, cp_sy - 4, 8, 8) + ctx.fill() + + ctx.restore() + + def _draw_waypoint_handles( + self, + ctx: cairo.Context, + to_screen, + waypoint: Point, + ): + """Draw control handles for all beziers connected to a waypoint.""" + sketch = self.element.sketch + connected_beziers = waypoint.get_connected_beziers( + sketch.registry, sketch + ) + + if not connected_beziers: + return + + wp_sx, wp_sy = to_screen((waypoint.x, waypoint.y)) + + point_ids = {waypoint.id} + point_ids.update(sketch.get_coincident_points(waypoint.id)) + + ctx.save() + ctx.set_line_width(1.0) + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.8) + + for bezier in connected_beziers: + if bezier.start_idx in point_ids and bezier.cp1 is not None: + cp_abs = ( + waypoint.x + bezier.cp1[0], + waypoint.y + bezier.cp1[1], + ) + cp_sx, cp_sy = to_screen(cp_abs) + ctx.move_to(wp_sx, wp_sy) + ctx.line_to(cp_sx, cp_sy) + ctx.stroke() + ctx.set_source_rgba(0.2, 0.6, 1.0, 1.0) + ctx.rectangle(cp_sx - 4, cp_sy - 4, 8, 8) + ctx.fill() + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.8) + + if bezier.end_idx in point_ids and bezier.cp2 is not None: + cp_abs = ( + waypoint.x + bezier.cp2[0], + waypoint.y + bezier.cp2[1], + ) + cp_sx, cp_sy = to_screen(cp_abs) + ctx.move_to(wp_sx, wp_sy) + ctx.line_to(cp_sx, cp_sy) + ctx.stroke() + ctx.set_source_rgba(0.2, 0.6, 1.0, 1.0) + ctx.rectangle(cp_sx - 4, cp_sy - 4, 8, 8) + ctx.fill() + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.8) + + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_cmd.py new file mode 100644 index 000000000..b1d415c37 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_cmd.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.core.undo import ChangePropertyCommand +from rayforge.core.workpiece import WorkPiece + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class SketchCmd: + """Handles commands related to sketch-based workpieces.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def set_workpiece_parameters( + self, workpieces: list[WorkPiece], new_params: dict[str, Any] + ): + """ + Updates the sketch parameters for one or more workpiece instances + in a single undoable transaction. + """ + if not workpieces or not new_params: + return + + history = self._editor.history_manager + + with history.transaction(_("Change Sketch Parameters")) as t: + for wp in workpieces: + if not wp.geometry_provider_uid: + continue + + # Create the full new dictionary of parameters by merging + # the old with the new. + old_params = wp.geometry_provider_params.copy() + updated_params = old_params.copy() + updated_params.update(new_params) + + if old_params == updated_params: + continue + + # Use a property change command, which will trigger the + # setter on the WorkPiece, causing it to regenerate. + cmd = ChangePropertyCommand( + target=wp, + property_name="geometry_provider_params", + new_value=updated_params, + old_value=old_params, + ) + t.execute(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py new file mode 100644 index 000000000..8cbef67e4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py @@ -0,0 +1,222 @@ +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING, Optional, cast + +from gi.repository import GLib + +from rayforge.core.undo import ListItemCommand +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.asset_cmd import UpdateAssetCommand +from rayforge.ui_gtk.doceditor import file_dialogs +from rayforge.usage import get_usage_tracker + +from ..core.sketch import Sketch + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + from rayforge.ui_gtk.mainwindow import MainWindow + + from .studio import SketchStudio + +logger = logging.getLogger(__name__) + + +def _get_sketch_studio() -> Optional["SketchStudio"]: + """Get the SketchStudio instance, avoiding circular imports.""" + from . import get_sketch_studio + + return get_sketch_studio() + + +class SketchModeCmd: + """Handles commands for entering, exiting, and managing sketch mode.""" + + def __init__(self, win: "MainWindow", editor: "DocEditor"): + self._win = win + self._editor = editor + self.active_sketch_workpiece: WorkPiece | None = None + self._is_editing_new_sketch = False + + def enter_sketch_mode( + self, workpiece: WorkPiece, is_new_sketch: bool = False + ): + """Switches the view to the SketchStudio to edit a workpiece.""" + sketch = None + if workpiece.geometry_provider_uid: + sketch = cast( + Sketch | None, + self._editor.doc.get_asset_by_uid( + workpiece.geometry_provider_uid + ), + ) + + if not sketch: + logger.warning("Attempted to edit a non-sketch workpiece.") + return + + try: + sketch_studio = _get_sketch_studio() + if not sketch_studio: + logger.error("SketchStudio not initialized") + return + + self.active_sketch_workpiece = workpiece + self._is_editing_new_sketch = is_new_sketch + sketch_studio.set_sketch(sketch) + self._win.open_modal_page("sketch") + get_usage_tracker().track_page_view("/sketcher", "Sketch Editor") + + self._win.menubar.set_menu_model(sketch_studio.menu_model) + self._win.insert_action_group("sketch", sketch_studio.action_group) + self._win.add_controller(sketch_studio.shortcut_controller) + except Exception: + logger.exception("Failed to load sketch for editing") + + def exit_sketch_mode(self): + """Returns to the main 2D/3D view from the SketchStudio.""" + sketch_studio = _get_sketch_studio() + self._win.menubar.set_menu_model(self._win.menu_model) + self._win.insert_action_group("sketch", None) + if sketch_studio: + self._win.remove_controller(sketch_studio.shortcut_controller) + + self._win.close_modal_page() + self.active_sketch_workpiece = None + self._is_editing_new_sketch = False + + def enter_sketch_definition_mode(self, sketch: Sketch): + """Switches to SketchStudio to edit a sketch definition directly.""" + try: + sketch_studio = _get_sketch_studio() + if not sketch_studio: + logger.error("SketchStudio not initialized") + return + + self.active_sketch_workpiece = None + self._is_editing_new_sketch = False + sketch_studio.set_sketch(sketch) + self._win.open_modal_page("sketch") + get_usage_tracker().track_page_view("/sketcher", "Sketch Editor") + + self._win.menubar.set_menu_model(sketch_studio.menu_model) + self._win.insert_action_group("sketch", sketch_studio.action_group) + self._win.add_controller(sketch_studio.shortcut_controller) + except Exception: + logger.exception("Failed to load sketch definition for editing") + + def on_sketch_definition_activated(self, sender, *, sketch: Sketch): + """Handles activation of a sketch definition from the sketch list.""" + self.enter_sketch_definition_mode(sketch) + + def on_sketch_finished(self, sender, *, sketch: Sketch): + """Handles the 'finished' signal from the SketchStudio.""" + cmd = UpdateAssetCommand( + doc=self._editor.doc, + asset_uid=sketch.uid, + new_data=sketch.to_dict(), + ) + self._editor.history_manager.execute(cmd) + + if self._is_editing_new_sketch: + sketch_studio = _get_sketch_studio() + if sketch_studio: + center_x = sketch_studio.width_mm / 2 + center_y = sketch_studio.height_mm / 2 + self._editor.edit.add_geometry_provider_instance( + sketch.uid, (center_x, center_y) + ) + + self.exit_sketch_mode() + + def on_sketch_cancelled(self, sender): + """Handles the 'cancelled' signal from the SketchStudio.""" + was_new = self._is_editing_new_sketch + self.exit_sketch_mode() + + if was_new: + self._editor.history_manager.undo() + + def on_new_sketch(self, action=None, param=None): + """Action handler for creating a new sketch definition.""" + new_sketch = Sketch(name=_("New Sketch")) + + command = ListItemCommand( + owner_obj=self._editor.doc, + item=new_sketch, + undo_command="remove_asset", + redo_command="add_asset", + name=_("Create Sketch Definition"), + ) + self._editor.history_manager.execute(command) + + self.enter_sketch_definition_mode(new_sketch) + self._is_editing_new_sketch = True + + def on_edit_sketch(self, action, param): + """Action handler for editing the selected sketch.""" + selected_items = self._win.surface.get_selected_workpieces() + if len(selected_items) == 1 and isinstance( + selected_items[0], WorkPiece + ): + wp = selected_items[0] + if wp.geometry_provider_uid: + self.enter_sketch_mode(wp) + else: + self._win._on_editor_notification( + self._win, _("Selected item is not an editable sketch.") + ) + else: + self._win._on_editor_notification( + self._win, _("Please select a single sketch to edit.") + ) + + def on_edit_sketch_requested(self, sender, *, workpiece: WorkPiece): + """Signal handler for edit sketch requests from the surface.""" + logger.debug(f"Sketch edit requested for workpiece {workpiece.name}") + self.enter_sketch_mode(workpiece) + + def on_activate_sketch(self, action, param): + """Action handler for activating a sketch definition.""" + asset_uid = param.get_string() + sketch = self._editor.doc.get_asset_by_uid(asset_uid) + if isinstance(sketch, Sketch): + self.enter_sketch_definition_mode(sketch) + + def on_edit_sketch_item(self, action, param): + """Action handler for editing a sketch-based workpiece.""" + item_uid = param.get_string() + item = self._editor.doc.find_descendant_by_uid(item_uid) + if isinstance(item, WorkPiece) and item.geometry_provider_uid: + self.enter_sketch_mode(item) + + def on_export_object(self, action, param): + """Action handler for exporting the selected object.""" + selected_items = self._win.surface.get_selected_workpieces() + if len(selected_items) == 1: + file_dialogs.show_export_object_dialog( + self._win, + self._on_export_object_save_response, + selected_items[0], + ) + else: + self._win._on_editor_notification( + self._win, _("Please select a single object to export.") + ) + + def _on_export_object_save_response(self, dialog, result, user_data): + """Callback for the export object dialog.""" + try: + file = dialog.save_finish(result) + if not file: + return + file_path = Path(file.get_path()) + + selected = self._win.surface.get_selected_workpieces() + if len(selected) != 1: + return + + self._editor.file.export_object_to_path(file_path, selected[0]) + + except GLib.Error as e: + logger.error(f"Error saving file: {e.message}") diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketchcanvas.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketchcanvas.py new file mode 100644 index 000000000..21f834a7a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketchcanvas.py @@ -0,0 +1,554 @@ +import logging +import math +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gdk, Gtk +from raygeo.geo import Matrix + +from rayforge.camera.controller import CameraController +from rayforge.context import get_context +from rayforge.core.expression import ExpressionContext, safe_evaluate +from rayforge.shared.units.formatter import get_preferred_unit_factor +from rayforge.ui_gtk.canvas import WorldSurface +from rayforge.ui_gtk.canvas2d.elements.camera_image import CameraImageElement +from rayforge.ui_gtk.shared.expression_entry import ExpressionEntry + +from ..core.commands import ModifyConstraintCommand +from ..core.constraints import ( + AngleConstraint, + Constraint, + DiameterConstraint, + DistanceConstraint, + HorizontalConstraint, + RadiusConstraint, + VerticalConstraint, +) +from ..core.sketch import Sketch +from .editor import SketchEditor +from .sketchelement import SketchElement + +logger = logging.getLogger(__name__) + + +class SketchCanvas(WorldSurface): + def __init__( + self, + parent_window: Gtk.Window, + single_mode: bool = False, + width_mm: float = 2000.0, + height_mm: float = 2000.0, + **kwargs, + ): + # In single_mode, we hide the axes and labels for a cleaner look + show_axis = not single_mode + # A Sketcher doesn't have a fixed machine size. We initialize the + # WorldSurface with a large default area to provide an "infinite" feel. + super().__init__( + width_mm=width_mm, + height_mm=height_mm, + show_axis=show_axis, + **kwargs, + ) + # Clear the gray background for a clean sketching surface + self.root.background = (0, 0, 0, 0) + self.parent_window = parent_window + self.single_mode = single_mode + self.set_has_tooltip(True) + + # Keep the grid unit labels in sync with the user's unit preference. + self._axis_renderer.set_grid_unit_factor( + get_preferred_unit_factor("length") + ) + get_context().config.changed.connect(self._on_config_changed) + + # This will hold a reference to the active dialog to prevent it from + # being garbage-collected prematurely. + self._active_dialog: Adw.MessageDialog | None = None + + # The SketchCanvas owns a SketchEditor to manage the session. + self.sketch_editor = SketchEditor(self.parent_window) + + # It creates a single, primary sketch element that is always active. + self.sketch_element = SketchElement() + self.sketch_element.constraint_edit_requested.connect( + self._on_constraint_edit_requested + ) + self.root.add(self.sketch_element) + + # Permanently enter edit mode on the primary sketch element. + self.edit_context = self.sketch_element + self.sketch_editor.activate(self.sketch_element) + + self._camera_elements: dict[CameraController, CameraImageElement] = {} + self._cam_visible: bool = False + + def _on_config_changed(self, sender, **kwargs): + """Updates the grid unit when the user's unit preference changes.""" + self._axis_renderer.set_grid_unit_factor( + get_preferred_unit_factor("length") + ) + self.queue_draw() + + def sync_camera_elements(self): + """Synchronizes camera elements with the current machine's cameras.""" + context = get_context() + machine = context.config.machine + camera_mgr = context.camera_mgr + + if not machine: + self.set_camera_controllers([]) + return + + current_elements = self._camera_elements + current_controllers = set(current_elements.keys()) + + machine_controllers: list[CameraController] = [] + for camera_model in machine.cameras: + controller = camera_mgr.get_controller(camera_model.device_id) + if controller: + machine_controllers.append(controller) + + new_controllers = set(machine_controllers) + + for controller in current_controllers - new_controllers: + element = current_elements[controller] + element.remove() + controller.unsubscribe() + del self._camera_elements[controller] + + for controller in new_controllers - current_controllers: + element = CameraImageElement(controller) + element.set_visible( + self._cam_visible and controller.config.enabled + ) + self.root.insert(0, element) + controller.subscribe() + self._camera_elements[controller] = element + + self.queue_draw() + + def set_camera_controllers(self, controllers: list[CameraController]): + """Sets the camera controllers and creates/removes camera elements.""" + current_elements = self._camera_elements + current_controllers = set(current_elements.keys()) + new_controllers = set(controllers) + + for controller in current_controllers - new_controllers: + element = current_elements[controller] + element.remove() + controller.unsubscribe() + del self._camera_elements[controller] + + for controller in new_controllers - current_controllers: + element = CameraImageElement(controller) + element.set_visible( + self._cam_visible and controller.config.enabled + ) + self.root.insert(0, element) + controller.subscribe() + self._camera_elements[controller] = element + + self.queue_draw() + + def set_camera_image_visibility(self, visible: bool): + """Sets the visibility of camera image overlays.""" + self._cam_visible = visible + for elem in self.find_by_type(CameraImageElement): + camera_elem = cast(CameraImageElement, elem) + camera_elem.set_visible(visible and camera_elem.camera.enabled) + self.queue_draw() + + def set_sketch(self, sketch: "Sketch"): + """ + Replaces the current sketch element's model with the provided Sketch. + This preserves the existing editor/canvas setup but switches the + data being edited, ensuring signal connections are updated. + """ + self.sketch_editor.deactivate() + + # Force a solve immediately. The sketch data has just been loaded from + # disk, so the 'constrained' flags on points/entities are all False. + # Running solve() calculates the Degrees of Freedom (DOF) and updates + # these flags, ensuring fully constrained entities appear green. + sketch.solve() + + # Replace the model on the existing element. The element's property + # setter will handle reconnecting signals to the new VarSet. + self.sketch_element.sketch = sketch + + # Reset content_transform to identity so that update_bounds_from_sketch + # calculates the correct offset from a clean state. Without this, the + # stale content_transform from a previous sketch causes the bounds + # calculation to produce incorrect positioning on subsequent opens. + self.sketch_element.content_transform = Matrix.identity() + + # Position the sketch element at the center of the canvas world. + # The subsequent call to update_bounds_from_sketch (in SketchStudio) + # will adjust the element's bounds and transform to tightly fit the + # geometry while keeping the sketch's origin at this center point. + canvas_w, canvas_h = self.get_size_mm() + cx, cy = canvas_w / 2.0, canvas_h / 2.0 + self.sketch_element.set_transform(Matrix.translation(cx, cy)) + + self.sketch_editor.activate(self.sketch_element) + + # Reset view to center on new sketch content + self.reset_view() + + def leave_edit_mode(self): + """ + Overrides the base Canvas method. + If in single_mode, prevents leaving edit mode via Escape or background + clicks. + """ + if self.single_mode: + logger.debug( + "SketchCanvas in single_mode: preventing exit from edit mode." + ) + return + super().leave_edit_mode() + + def reset_sketch(self) -> SketchElement: + """ + Replaces the current sketch with a new, empty one, ensuring all + internal references are updated correctly. + + :return: The SketchElement instance. + """ + new_sketch = Sketch() + self.set_sketch(new_sketch) + return self.sketch_element + + def reset_view(self) -> None: + """ + Overrides the base implementation to center the view on the geometric + center of the sketch's contents. + """ + logger.debug("Resetting SketchCanvas view to center sketch geometry.") + if not self.sketch_element: + super().reset_view() + return + + sketch = self.sketch_element.sketch + min_x, max_x, min_y, max_y = 0.0, 0.0, 0.0, 0.0 + has_bounds = False + + # 1. Calculate bounding box of all sketch geometry in Model + # coordinates. + geometry = sketch.to_geometry() + if not geometry.is_empty(): + min_x, min_y, max_x, max_y = geometry.rect() + has_bounds = True + elif sketch.registry.points: + # This case handles sketches with only points. + xs = [p.x for p in sketch.registry.points] + ys = [p.y for p in sketch.registry.points] + if xs and ys: + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + has_bounds = True + + # If there is a bounding box, calculate its center. + # Otherwise, the target is the model origin (0,0). + if has_bounds: + model_center_x = (min_x + max_x) / 2.0 + model_center_y = (min_y + max_y) / 2.0 + else: + model_center_x = 0.0 + model_center_y = 0.0 + + # 2. Transform the model center to world coordinates. + model_to_world = ( + self.sketch_element.get_world_transform() + @ self.sketch_element.content_transform + ) + target_center_x, target_center_y = model_to_world.transform_point( + (model_center_x, model_center_y) + ) + + # 3. Calculate pan to move this point to the view center. + # The pan value is the coordinate of the world's top-left corner that + # will be displayed in the view's top-left. + pan_x = target_center_x - (self.width_mm / 2.0) + pan_y = target_center_y - (self.height_mm / 2.0) + + self.set_pan(pan_x, pan_y) + self.set_zoom(1.0) + + def _on_constraint_edit_requested(self, sender, constraint: Constraint): + """ + Opens a dialog to edit the value of a constraint. This is triggered + by a double-click on a constraint label. + """ + if self._active_dialog: + return + + # Ensure the constraint has a 'value' attribute we can edit. + if not hasattr(constraint, "value"): + logger.warning( + "Constraint edit requested for a constraint with no " + f"'value' attribute: {type(constraint).__name__}" + ) + return + + # Get initial text: expression if present, else value + if constraint.expression is not None: + initial_text = constraint.expression + else: + initial_text = f"{float(getattr(constraint, 'value', 0)):g}" + + # Determine the user-friendly label and description based on type + if isinstance(constraint, RadiusConstraint): + row_subtitle = _("Enter radius or expression (e.g. 'width/2').") + elif isinstance(constraint, DiameterConstraint): + row_subtitle = _("Enter diameter or expression.") + elif isinstance(constraint, AngleConstraint): + row_subtitle = _("Enter angle in degrees or expression.") + elif isinstance( + constraint, + (DistanceConstraint, HorizontalConstraint, VerticalConstraint), + ): + row_subtitle = _("Enter length or expression.") + else: + row_subtitle = _("Enter value or expression.") + + # Create ExpressionContext + var_set = self.sketch_element.sketch.input_parameters + variables = ( + {var.key: var.var_type for var in var_set} if var_set else {} + ) + math_functions = { + k: v for k, v in math.__dict__.items() if not k.startswith("__") + } + context = ExpressionContext( + variables=variables, functions=math_functions + ) + + expression_entry = ExpressionEntry() + expression_entry.set_tooltip_text(row_subtitle) + expression_entry.set_vexpand(True) + expression_entry.set_valign(Gtk.Align.START) + + dialog = Adw.MessageDialog( + transient_for=self.parent_window, + modal=True, + destroy_with_parent=True, + heading=_("Edit Constraint"), + ) + dialog.set_extra_child(expression_entry) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("ok", _("OK")) + dialog.set_default_response("ok") + dialog.set_close_response("cancel") + dialog.set_size_request(500, 220) + + # Handle "OK" button sensitivity and "Enter" key + def on_validated(sender, *, is_valid): + dialog.set_response_enabled("ok", is_valid) + + expression_entry.validated.connect(on_validated, weak=False) + expression_entry.activated.connect( + lambda sender: dialog.response("ok"), weak=False + ) + + # Set context and text *after* connecting signals to set initial state + expression_entry.set_context(context) + expression_entry.set_text(initial_text) + + # Request focus for the text view and select all its content. This + # allows the user to start typing immediately to replace the value. + expression_entry.textview.grab_focus() + buffer = expression_entry.textview.get_buffer() + start, end = buffer.get_bounds() + buffer.select_range(start, end) + + def on_response(source, response_id): + if response_id == "ok": + text_val = expression_entry.get_text().strip() + # Initialize with current value to prevent collapse on eval + # failure + new_value = float(getattr(constraint, "value", 0.0)) + new_expr = None + + # Try simple float conversion first + try: + new_value = float(text_val) + # It's a number, so no expression + new_expr = None + except ValueError: + # It's a string, likely an expression or param name. + # We store it as an expression. + new_expr = text_val + # Evaluate immediately to get current value for solving + try: + eval_context = {} + params = self.sketch_element.sketch.input_parameters + if params: + eval_context = params.get_values() + new_value = safe_evaluate(text_val, eval_context) + except ValueError: + # Fallback if invalid immediately + pass + + cmd = ModifyConstraintCommand( + sketch=self.sketch_element.sketch, + constraint=constraint, + new_value=new_value, + new_expression=new_expr, + ) + self.sketch_editor.history_manager.execute(cmd) + + # Explicitly close the dialog + dialog.close() + # Clear the reference to allow the dialog to be destroyed + self._active_dialog = None + + # Store a reference to the dialog to prevent garbage collection + self._active_dialog = dialog + self._active_dialog.connect("response", on_response) + self._active_dialog.present() + + def on_right_click_pressed( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ): + """ + Overrides the base class to unconditionally delegate to the editor. + """ + self.sketch_editor.handle_right_click(gesture, n_press, x, y) + + def on_key_pressed( + self, + controller: Gtk.EventControllerKey, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> bool: + """ + Overrides base to delegate sketcher-specific key presses to the + editor before falling back to the WorldSurface's handlers. + """ + # First, let the sketch editor handle its keys (Undo/Redo, Delete) + if self.sketch_editor.handle_key_press(keyval, keycode, state): + return True + + # Then, let the base class handle its keys (e.g., '1' for reset view) + return bool(super().on_key_pressed(controller, keyval, keycode, state)) + + def update_sketch_cursor(self): + """Forces an update of the cursor based on the editor's state.""" + if self.sketch_editor: + cursor = self.sketch_editor.get_current_cursor() + self.set_cursor(cursor) + + def on_motion(self, gesture: Gtk.Gesture, x: float, y: float): + """ + Overrides the base canvas motion handler to implement sketcher- + specific cursor logic, bypassing the default handle-based system. + """ + # Store raw pixel coordinates for other uses (like scroll-to-zoom) + self._mouse_pos = (x, y) + + world_x, world_y = self._get_world_coords(x, y) + + state = gesture.get_current_event_state() + shift = bool(state & Gdk.ModifierType.SHIFT_MASK) + ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + + # Let the active tool update its hover state (e.g., for snapping) + if self.sketch_element: + self.sketch_element.on_hover_motion( + world_x, world_y, shift=shift, ctrl=ctrl + ) + + # Update tooltip based on constraint hover state + self._update_constraint_tooltip() + + # Set the cursor based on the complete state from the editor + self.update_sketch_cursor() + + def on_motion_leave(self, controller: Gtk.EventControllerMotion): + """Resets hover state and cursor when the mouse leaves the canvas.""" + super().on_motion_leave(controller) + self.set_cursor(None) # Reset to default cursor + self.set_tooltip_text("") # Clear tooltip + + def _update_constraint_tooltip(self): + """Updates the tooltip based on the hovered constraint.""" + if not self.sketch_element: + return + + select_tool = self.sketch_element.tools.get("select") + if not select_tool: + return + + hovered_idx = select_tool.hovered_constraint_idx + if hovered_idx is not None and 0 <= hovered_idx < len( + self.sketch_element.sketch.constraints + ): + constraint = self.sketch_element.sketch.constraints[hovered_idx] + tooltip_text = constraint.get_type_name() + self.set_tooltip_text(tooltip_text) + else: + self.set_tooltip_text("") + + def on_button_press( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ): + """ + Overrides the base Canvas handler to manage pie menu visibility, + delegate to the active tool, and correctly handle gesture state for + double-clicks. + """ + # If the pie menu is visible, a left click should dismiss it. + if ( + self.sketch_editor.pie_menu.is_visible() + and gesture.get_current_button() != 3 + ): + self.sketch_editor.pie_menu.popdown() + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + return + + if gesture.get_current_button() == 3: + return # Already handled by the right-click gesture + + # Replicate the logic from the base Canvas.on_button_press but with + # conditional gesture claiming. + self.grab_focus() + + handled = False + if self.edit_context: + world_x, world_y = self._get_world_coords(x, y) + # Delegate to the tool. The tool's return value determines if + # the gesture sequence should be terminated. + sketch_element = cast(SketchElement, self.edit_context) + handled = sketch_element.handle_edit_press( + world_x, world_y, n_press + ) + + # Only claim the gesture if the tool has fully handled the event + # (e.g., a completed double-click). A single click should not + # claim the gesture, allowing the second click to be detected. + if handled: + logger.debug( + "Tool handled the press event, claiming gesture state." + ) + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + else: + logger.debug("Tool did not handle press event, gesture continues.") + + def on_click_released( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ): + """ + Overrides base to handle click release for edit context tools. + The base class returns early for edit_context, but sketcher tools + need to receive release events for click-without-drag operations. + """ + if self.edit_context: + world_x, world_y = self._get_world_coords(x, y) + sketch_element = cast(SketchElement, self.edit_context) + sketch_element.handle_edit_release(world_x, world_y) + return + + super().on_click_released(gesture, n_press, x, y) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketchelement.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketchelement.py new file mode 100644 index 000000000..0cf023970 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketchelement.py @@ -0,0 +1,397 @@ +import logging +from typing import TYPE_CHECKING, cast + +import cairo +from blinker import Signal +from raygeo.geo import Matrix + +from rayforge.ui_gtk.canvas import CanvasElement + +from ..core.entities import Line +from ..core.selection import SketchSelection +from ..core.sketch import Sketch +from ..core.snap import SnapEngine +from ..core.snap.producers import ( + CentersProducer, + EntityPointsProducer, + EquidistantLinesProducer, + IntersectionsProducer, + MidpointsProducer, + OnEntityProducer, +) +from ..core.types import EntityID +from .hittest import SketchHitTester +from .renderer import SketchRenderer +from .tools import TOOL_REGISTRY + +if TYPE_CHECKING: + from rayforge.core.undo.command import Command + + from .editor import SketchEditor + from .sketchcanvas import SketchCanvas + +logger = logging.getLogger(__name__) + + +class SketchElement(CanvasElement): + def __init__( + self, + x: float = 0, + y: float = 0, + width: float = 1.0, + height: float = 1.0, + sketch: Sketch | None = None, + **kwargs, + ): + # Pass the required positional arguments to the parent class. + super().__init__( + x=x, + y=y, + width=width, + height=height, + is_editable=True, + clip=False, + **kwargs, + ) + + # Signals + self.constraint_edit_requested = Signal() + self.tool_changed = Signal() + self.solved = Signal() + self.preview_changed = Signal() + + # Model + self._sketch: Sketch + self.external_hovered_constraint_idx: int | None = None + + # State Managers + self.selection = SketchSelection() + self.hittester = SketchHitTester() + self.renderer = SketchRenderer(self) + self.editor: SketchEditor | None + self.snap_engine = self._create_snap_engine() + + # This must be set after self.selection is initialized + self.sketch = sketch if sketch is not None else Sketch() + + # Tools + self.tools = { + name: tool_cls(self) for name, tool_cls in TOOL_REGISTRY.items() + } + self.active_tool_name = "select" + + # Config + self.point_radius = 5.0 + self.line_width = 2.0 + + # Visibility toggles + self.show_constraints = True + self.show_construction_geometry = True + + @property + def sketch(self) -> Sketch: + return self._sketch + + @sketch.setter + def sketch(self, new_sketch: Sketch): + logger.debug(f"Called for sketch '{new_sketch.name}'") + # Disconnect from old sketch's signals if it exists + if hasattr(self, "_sketch"): + self._disconnect_signals() + + self._sketch = new_sketch + + # Connect to new sketch's signals + self._connect_signals() + + def _connect_signals(self): + """Connects to signals that indicate the model has changed.""" + self.sketch.updated.connect(self._on_model_changed) + if self.sketch and self.sketch.input_parameters is not None: + logger.debug( + f"Connecting to VarSet signals on " + f"{type(self.sketch.input_parameters).__name__} " + f"(id: {id(self.sketch.input_parameters)})" + ) + self.sketch.input_parameters.var_added.connect( + self._on_model_changed + ) + self.sketch.input_parameters.var_removed.connect( + self._on_model_changed + ) + self.sketch.input_parameters.var_value_changed.connect( + self._on_model_changed + ) + self.sketch.input_parameters.var_definition_changed.connect( + self._on_model_changed + ) + self.sketch.input_parameters.cleared.connect( + self._on_model_changed + ) + + def _disconnect_signals(self): + """Disconnects signals to prevent leaks.""" + self.sketch.updated.disconnect(self._on_model_changed) + self.sketch.input_parameters.var_added.disconnect( + self._on_model_changed + ) + self.sketch.input_parameters.var_removed.disconnect( + self._on_model_changed + ) + self.sketch.input_parameters.var_value_changed.disconnect( + self._on_model_changed + ) + self.sketch.input_parameters.var_definition_changed.disconnect( + self._on_model_changed + ) + self.sketch.input_parameters.cleared.disconnect(self._on_model_changed) + + def _create_snap_engine(self) -> SnapEngine: + engine = SnapEngine() + engine.register_producer(EntityPointsProducer()) + engine.register_producer(OnEntityProducer()) + engine.register_producer(MidpointsProducer()) + engine.register_producer(IntersectionsProducer()) + engine.register_producer(EquidistantLinesProducer()) + engine.register_producer(CentersProducer()) + return engine + + def _on_model_changed(self, sender, **kwargs): + """ + Central handler for all model changes. Triggers a solve and redraw. + """ + logger.debug( + f"Triggered by {type(sender).__name__} (id: {id(sender)}) " + f"with kwargs: {kwargs}. Solving and redrawing." + ) + self.sketch.solve() + self.update_bounds_from_sketch() + self.mark_dirty() + self.solved.send(self) + + def remove(self): + """Overrides remove to cleanup signal connections.""" + self._disconnect_signals() + super().remove() + + @property + def current_tool(self): + return self.tools.get(self.active_tool_name, self.tools["select"]) + + def execute_command(self, command: "Command"): + """Executes a command via the history manager if available.""" + if self.editor: + self.editor.history_manager.execute(command) + + def get_selected_elements(self) -> bool: + """ + Helper method to check if any internal items (points, entities, etc.) + are selected. Returns a boolean, not a list of elements. + """ + sel = self.selection + return bool( + sel.point_ids + or sel.entity_ids + or sel.constraint_idx is not None + or sel.junction_pid is not None + ) + + def unselect_all(self): + """Clears the internal sketch selection.""" + self.selection.clear() + self.mark_dirty() + + def update_bounds_from_sketch(self): + """ + Calculates the bounding box of the sketch geometry and updates the + element's size and transform. For empty sketches, it creates a + minimum-sized box and centers the origin. For non-empty sketches, + it shrinks to fit the geometry exactly. + """ + # A sketch is considered "empty" for bounding purposes if it has no + # entities and at most one point (which would be the origin). + is_truly_empty = ( + len(self.sketch.registry.entities) == 0 + and len(self.sketch.registry.points) <= 1 + ) + + new_width: float + new_height: float + new_offset_x: float + new_offset_y: float + + if is_truly_empty: + # Apply a minimum dimension for selectability and center the + # origin. + min_dim = 50.0 + new_width = min_dim + new_height = min_dim + new_offset_x = min_dim / 2.0 + new_offset_y = min_dim / 2.0 + else: + # Calculate the precise bounding box of all geometry. + geometry = self.sketch.to_geometry() + if geometry.is_empty(): + # This case handles sketches with only points. + if not self.sketch.registry.points: + min_x, max_x, min_y, max_y = 0, 0, 0, 0 + else: + xs = [p.x for p in self.sketch.registry.points] + ys = [p.y for p in self.sketch.registry.points] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + else: + min_x, min_y, max_x, max_y = geometry.rect() + + # The element size is exactly the geometry size. No padding. + new_width = max_x - min_x + new_height = max_y - min_y + # The offset moves the geometry's top-left to the element's origin. + new_offset_x = -min_x + new_offset_y = -min_y + + # Calculate the change in offset needed to keep the content visually + # stationary on the canvas during the bounds update. + current_offset_x, current_offset_y = ( + self.content_transform.get_translation() + ) + delta_x = new_offset_x - current_offset_x + delta_y = new_offset_y - current_offset_y + + # Apply all the calculated updates. + self.content_transform = Matrix.translation(new_offset_x, new_offset_y) + self.width = new_width + self.height = new_height + + # Update the element's main transform to counteract the content shift. + self.set_transform( + self.transform @ Matrix.translation(-delta_x, -delta_y) + ) + + self.mark_dirty() + + def on_edit_mode_leave(self): + """Called when this element is no longer the Canvas's edit_context.""" + self.update_bounds_from_sketch() + + # ========================================================================= + # Rendering + # ========================================================================= + + def draw(self, ctx: cairo.Context): + """Main draw entry point.""" + self.renderer.draw(ctx) + + def draw_edit_overlay(self, ctx: cairo.Context): + """Draws constraints, points, and handles on top of the canvas.""" + self.renderer.draw_edit_overlay(ctx) + # Allow the active tool to draw its own overlay (e.g. selection box) + self.current_tool.draw_overlay(ctx) + + # ========================================================================= + # Input Handling (Routed to Tools) + # ========================================================================= + + def handle_edit_press( + self, world_x: float, world_y: float, n_press: int = 1 + ) -> bool: + return self.current_tool.on_press(world_x, world_y, n_press) + + def handle_edit_drag(self, world_dx: float, world_dy: float): + self.current_tool.on_drag(world_dx, world_dy) + + def handle_edit_release(self, world_x: float, world_y: float): + self.current_tool.on_release(world_x, world_y) + + def on_hover_motion( + self, + world_x: float, + world_y: float, + shift: bool = False, + ctrl: bool = False, + ): + """Dispatches hover events to the currently active tool.""" + tool = self.current_tool + tool.on_modifier_change(shift=shift, ctrl=ctrl) + tool.on_hover_motion(world_x, world_y) + + def get_lines_at_point(self, pid: EntityID) -> list[Line]: + return [ + e + for e in self.sketch.registry.entities + if isinstance(e, Line) and pid in (e.p1_idx, e.p2_idx) + ] + + def set_tool(self, tool_name: str): + if tool_name in self.tools and self.active_tool_name != tool_name: + # Deactivate the old tool before switching to the new one. + self.current_tool.on_deactivate() + self.active_tool_name = tool_name + self.mark_dirty() + self.tool_changed.send(self, tool_name=tool_name) + if self.canvas: + canvas = cast("SketchCanvas", self.canvas) + canvas.update_sketch_cursor() + self.current_tool.on_activate() + + def delete_selection(self) -> bool: + return self.tools["delete"]._delete_selection() + + def toggle_construction_on_selection(self): + self.tools["construction"]._toggle_construction() + + def add_chamfer_action(self): + self.tools["chamfer"]._add_chamfer() + + def add_fillet_action(self): + self.tools["fillet"]._add_fillet() + + def is_action_supported(self, action: str) -> bool: + tool = self.tools.get(action) + if not tool: + return False + + sel = self.selection + target = None + target_type = None + + if sel.junction_pid is not None: + target = self.sketch.registry.get_point(sel.junction_pid) + target_type = "junction" + elif sel.point_ids: + target = self.sketch.registry.get_point(sel.point_ids[0]) + target_type = "point" + elif sel.entity_ids: + target = self.sketch.registry.get_entity(sel.entity_ids[0]) + target_type = "entity" + elif sel.constraint_idx is not None: + if 0 <= sel.constraint_idx < len(self.sketch.constraints): + target = self.sketch.constraints[sel.constraint_idx] + target_type = "constraint" + + return tool.is_available(target, target_type) + + def add_alignment_constraint(self): + self.tools["coincident"]._add_constraint() + + def remove_point_if_unused(self, pid: int | None) -> bool: + """ + Removes a point from the registry if it's not part of any entity. + + Args: + pid: The point ID to remove. + + Returns: + True if the point was removed, False otherwise. + """ + if pid is None: + return False + removed = self.sketch.remove_point_if_unused(pid) + if removed: + self.mark_dirty() + return removed + + def mark_dirty(self, ancestors=False, recursive=False): + super().mark_dirty(ancestors=ancestors, recursive=recursive) + if self.canvas: + self.canvas.queue_draw() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/studio.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/studio.py new file mode 100644 index 000000000..3a35079a5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/studio.py @@ -0,0 +1,579 @@ +import logging +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Adw, Gdk, Gio, GLib, Gtk + +from rayforge.core.undo.property_cmd import ChangePropertyCommand +from rayforge.core.varset import FloatVar, IntVar, SliderFloatVar, Var +from rayforge.ui_gtk.icons import get_icon +from rayforge.ui_gtk.shared.keyboard import PRIMARY_ACCEL +from rayforge.ui_gtk.shared.status_bar import StatusBar +from rayforge.ui_gtk.varset.varset_editor import VarSetEditorWidget + +from ..core.entities.text_box import TextBoxEntity +from ..core.sketch import DEFAULT_FILL_COLOR, Sketch +from .conflicts_widget import ConflictingConstraintsWidget +from .font_properties import FontPropertiesWidget +from .menu import SketchMenu +from .sketchcanvas import SketchCanvas +from .tools import ACTION_TOOL_MAP +from .tools.fill_tool import FillTool + +logger = logging.getLogger(__name__) + + +class SketchStudio(Gtk.Box): + """ + The top-level container for the sketching environment. + Manages the layout of the canvas and side panels and orchestrates the + save/cancel lifecycle. + """ + + def __init__( + self, + parent_window: Gtk.Window, + width_mm: float = 1000.0, + height_mm: float = 1000.0, + **kwargs, + ): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + self.parent_window = parent_window + self.width_mm = width_mm + self.height_mm = height_mm + + # Signals + self.finished = Signal() + self.cancelled = Signal() + + self._build_ui() + + def set_world_size(self, width_mm: float, height_mm: float): + """ + Updates the world dimensions of the sketch canvas. This is called + by the main window when the machine configuration changes. + """ + self.width_mm = width_mm + self.height_mm = height_mm + if self.canvas: + self.canvas.set_size(width_mm, height_mm) + + def _build_toolbar(self): + toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + toolbar.set_margin_bottom(2) + toolbar.set_margin_top(2) + toolbar.set_margin_start(12) + toolbar.set_margin_end(12) + self.append(toolbar) + + self.constraints_button = Gtk.ToggleButton() + self.constraints_button.set_child( + get_icon("sketch-constrain-point-symbolic") + ) + self.constraints_button.set_active(True) + self.constraints_button.set_tooltip_text(_("Toggle constraints")) + self.constraints_button.connect("toggled", self._on_toggle_constraints) + toolbar.append(self.constraints_button) + + self.construction_button = Gtk.ToggleButton() + self.construction_button.set_child( + get_icon("sketch-construction-symbolic") + ) + self.construction_button.set_active(True) + self.construction_button.set_tooltip_text( + _("Toggle construction geometry") + ) + self.construction_button.connect( + "toggled", self._on_toggle_construction_visibility + ) + toolbar.append(self.construction_button) + + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + toolbar.append(sep) + + color = FillTool.get_current_color() + logger.debug(f"FillTool current color: {color}") + if color[3] == 0: + color = DEFAULT_FILL_COLOR + FillTool.set_current_color(color) + logger.debug(f"Using default color: {color}") + + self._fill_color = color + self.fill_color_swatch = Gtk.DrawingArea() + self.fill_color_swatch.set_size_request(24, 24) + self.fill_color_swatch.set_draw_func(self._on_draw_color_swatch) + + self.fill_color_button = Gtk.Button() + self.fill_color_button.set_child(self.fill_color_swatch) + self.fill_color_button.connect( + "clicked", self._on_color_button_clicked + ) + + color_label = Gtk.Label(label=_("Fill color:")) + color_label.set_margin_start(6) + + color_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + color_box.append(color_label) + color_box.append(self.fill_color_button) + toolbar.append(color_box) + + spacer = Gtk.Box() + spacer.set_hexpand(True) + toolbar.append(spacer) + + cancel_button = Gtk.Button(label=_("Cancel")) + cancel_button.connect("clicked", self._on_cancel_clicked) + toolbar.append(cancel_button) + + finish_button = Gtk.Button(label=_("Finish")) + finish_button.add_css_class("suggested-action") + finish_button.connect("clicked", self._on_finish_clicked) + toolbar.append(finish_button) + + def _on_draw_color_swatch(self, area, cr, width, height): + """Draw the color swatch.""" + r, g, b, a = self._fill_color + cr.set_source_rgba(r, g, b, a) + cr.rectangle(0, 0, width, height) + cr.fill() + + def _on_color_button_clicked(self, button): + """Show color chooser dialog.""" + dialog = Gtk.ColorChooserDialog( + transient_for=self.parent_window, + modal=True, + ) + dialog.set_use_alpha(True) + rgba = Gdk.RGBA() + rgba.red, rgba.green, rgba.blue, rgba.alpha = self._fill_color + dialog.set_rgba(rgba) + + def on_response(dialog, response): + if response == Gtk.ResponseType.OK: + rgba = dialog.get_rgba() + self._fill_color = ( + rgba.red, + rgba.green, + rgba.blue, + rgba.alpha, + ) + FillTool.set_current_color(self._fill_color) + self.fill_color_swatch.queue_draw() + dialog.destroy() + + dialog.connect("response", on_response) + dialog.show() + + def _on_fill_color_changed(self, button): + """Handle fill color change from the color button.""" + + def _build_ui(self): + self._build_toolbar() + + # Main Content Area (Side Panel + Canvas) + # Use a Paned widget to allow resizing between the panel and canvas + main_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) + main_paned.set_vexpand(True) + main_paned.set_position(450) + self.append(main_paned) + + # 2a. Side Panel + side_panel_scroller = Gtk.ScrolledWindow() + side_panel_scroller.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + # The paned widget handles sizing, no size request is needed here. + main_paned.set_start_child(side_panel_scroller) + + side_panel_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=24 + ) + side_panel_box.set_margin_top(12) + side_panel_box.set_margin_bottom(12) + side_panel_box.set_margin_start(12) + side_panel_box.set_margin_end(12) + side_panel_scroller.set_child(side_panel_box) + + # Properties Group (Name) + properties_group = Adw.PreferencesGroup() + properties_group.set_title(_("Properties")) + properties_group.set_description( + _("Configure the sketch name and basic properties") + ) + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.connect("changed", self._on_name_changed) + properties_group.add(self.name_row) + side_panel_box.append(properties_group) + + # VarSet Editor Group + # Limit the types of variables that user can add to the sketch + self.varset_editor = VarSetEditorWidget( + vartypes={IntVar, FloatVar, SliderFloatVar, Var} + ) + side_panel_box.append(self.varset_editor) + + # 2b. Canvas wrapped in overlay for visibility controls + self.canvas = SketchCanvas( + parent_window=self.parent_window, + single_mode=True, + width_mm=self.width_mm, + height_mm=self.height_mm, + ) + self.canvas_overlay = Gtk.Overlay() + self.canvas_overlay.set_child(self.canvas) + + self._cam_icon_on = get_icon("camera-on-symbolic") + self._cam_icon_off = get_icon("camera-off-symbolic") + self._camera_button = Gtk.ToggleButton() + self._camera_button.set_child(self._cam_icon_off) + self._camera_button.set_tooltip_text(_("Toggle camera view")) + self._camera_button.connect("toggled", self._on_camera_toggled) + self._camera_overlay_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=2 + ) + self._camera_overlay_box.add_css_class("visibility-overlay") + self._camera_overlay_box.append(self._camera_button) + self._camera_overlay_box.set_halign(Gtk.Align.END) + self._camera_overlay_box.set_valign(Gtk.Align.START) + self._camera_overlay_box.set_margin_top(6) + self._camera_overlay_box.set_margin_end(6) + self.canvas_overlay.add_overlay(self._camera_overlay_box) + + # The paned widget will handle expansion. + main_paned.set_end_child(self.canvas_overlay) + + # 3. Status Bar + self.status_bar = StatusBar() + self.append(self.status_bar) + + # Connect history manager to varset editor for undo support + if self.canvas.sketch_editor: + self.varset_editor.undo_manager = ( + self.canvas.sketch_editor.history_manager + ) + + # Font Properties Group (shows when a text box is selected) + self.font_properties = FontPropertiesWidget(self.canvas.sketch_editor) + side_panel_box.append(self.font_properties) + + # Conflicting Constraints Group (shows when there are conflicts) + self.conflicts_widget = ConflictingConstraintsWidget() + side_panel_box.append(self.conflicts_widget) + + # Initialize the VarSetEditor with the default sketch's parameters + # This ensures variables added before 'set_sketch' are attached to + # the current sketch + if self.canvas.sketch_element: + self.varset_editor.populate( + self.canvas.sketch_element.sketch.input_parameters + ) + + # 3. Initialize Actions and Menus + self._init_menu() + self._init_actions() + + def _init_menu(self): + """Initializes the menu model.""" + self.menu_model = SketchMenu() + + def _init_actions(self): + """Initializes the action group and shortcut controller.""" + self.action_group = Gio.SimpleActionGroup() + + actions = [ + ("finish", self._on_finish_clicked), + ("cancel", self._on_cancel_clicked), + ("undo", self._on_undo), + ("redo", self._on_redo), + ("delete", self._on_delete), + ("view_fit", self._on_view_fit), + ("toggle_construction", self._on_toggle_construction), + ("chamfer_corner", self._on_chamfer), + ] + + for name, cb in actions: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", cb) + self.action_group.add_action(action) + + for action_name, tool_id in ACTION_TOOL_MAP.items(): + action = Gio.SimpleAction.new(action_name, None) + action.connect( + "activate", + lambda a, p, t=tool_id: self.canvas.sketch_element.set_tool(t), + ) + self.action_group.add_action(action) + + self.shortcut_controller = Gtk.ShortcutController() + self.shortcut_controller.set_scope(Gtk.ShortcutScope.MANAGED) + self.shortcut_controller.set_propagation_phase( + Gtk.PropagationPhase.BUBBLE + ) + + shortcuts = { + "sketch.undo": [f"{PRIMARY_ACCEL}z"], + "sketch.redo": [f"{PRIMARY_ACCEL}y", f"{PRIMARY_ACCEL}z"], + "sketch.delete": ["Delete"], + "sketch.view_fit": ["1"], + "sketch.finish": [f"{PRIMARY_ACCEL}Return"], + } + + for action_name, accels in shortcuts.items(): + for accel in accels: + shortcut = Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string(accel), + Gtk.NamedAction.new(action_name), + ) + self.shortcut_controller.add_shortcut(shortcut) + + def set_sketch(self, sketch: Sketch): + """Loads a sketch model into the studio.""" + logger.debug( + f"Called with sketch '{sketch.name}' (id: {id(sketch)}) and " + f"VarSet (id: {id(sketch.input_parameters)})" + ) + r, g, b, a = self._fill_color + logger.debug( + f"set_sketch: fill color = red={r}, green={g}, blue={b}, alpha={a}" + ) + # Load sketch into the canvas element + self.canvas.set_sketch(sketch) + + # Populate side panel with sketch data + self.name_row.set_text(sketch.name) + self.varset_editor.populate(sketch.input_parameters) + + # Connect to selection changes to show/hide font properties + if self.canvas.sketch_element: + self.canvas.sketch_element.selection.changed.connect( + self._on_selection_changed + ) + self.canvas.sketch_element.tool_changed.connect( + self._on_tool_changed + ) + self.canvas.sketch_element.solved.connect(self._on_sketch_solved) + self.canvas.sketch_element.preview_changed.connect( + self._on_preview_changed + ) + self.canvas.edit_drag_begin.connect(self._on_edit_drag_begin) + self.canvas.edit_drag_end.connect(self._on_edit_drag_end) + self._on_selection_changed(self.canvas.sketch_element.selection) + + # Sync toolbar button states with sketch element + self.constraints_button.set_active( + self.canvas.sketch_element.show_constraints + ) + self.construction_button.set_active( + self.canvas.sketch_element.show_construction_geometry + ) + + # Connect conflicts widget to sketch element + self.conflicts_widget.set_sketch_element( + self.canvas.sketch_element + ) + + # Connect to text editing signals to show font properties + text_tool = self.canvas.sketch_element.tools.get("text_box") + if text_tool: + text_tool.editing_started.connect( + self._on_text_editing_started + ) + text_tool.editing_finished.connect( + self._on_text_editing_finished + ) + + # Ensure the element bounds are updated for the new content + self.canvas.sketch_element.update_bounds_from_sketch() + # Reset view to center content + self.canvas.reset_view() + + # Sync camera elements (visibility stays hidden until user toggles) + self.canvas.sync_camera_elements() + + # Grab focus for the canvas so keyboard shortcuts work + # Use a tick callback to ensure focus is grabbed after the widget + # is visible and the main loop has processed the visibility change + + def grab_focus_callback(widget, clock): + self.canvas.grab_focus() + return GLib.SOURCE_REMOVE + + self.add_tick_callback(grab_focus_callback) + + # --- Action Handlers --- + + def _on_name_changed(self, entry_row: Adw.EntryRow): + """Updates the sketch's name with undo support.""" + if not self.canvas or not self.canvas.sketch_element: + return + sketch = self.canvas.sketch_element.sketch + new_name = entry_row.get_text() + + if sketch.name == new_name: + return + + if self.canvas.sketch_editor: + cmd = ChangePropertyCommand( + target=sketch, + property_name="name", + new_value=new_name, + on_change_callback=self._sync_name_ui, + name=_("Rename Sketch"), + ) + self.canvas.sketch_editor.history_manager.execute(cmd) + else: + sketch.name = new_name + + def _sync_name_ui(self): + """Updates to UI to match the underlying sketch object (for Undo).""" + if not self.canvas or not self.canvas.sketch_element: + return + sketch = self.canvas.sketch_element.sketch + if self.name_row.get_text() != sketch.name: + self.name_row.set_text(sketch.name) + + def _on_selection_changed(self, selection): + """Handles selection changes to show/hide font properties.""" + if not self.canvas or not self.canvas.sketch_element: + self.font_properties.set_text_entity(None) + self._update_status_bar() + return + + text_entity_id = None + if len(selection.entity_ids) == 1: + entity_id = selection.entity_ids[0] + entity = self.canvas.sketch_element.sketch.registry.get_entity( + entity_id + ) + if isinstance(entity, TextBoxEntity): + text_entity_id = entity_id + + self.font_properties.set_text_entity(text_entity_id) + self._update_status_bar() + + def _on_tool_changed(self, sender, tool_name: str): + """Handles tool changes to update status bar.""" + self._update_status_bar() + + def _on_edit_drag_begin(self, sender): + """Handles start of edit mode drag to show context shortcuts.""" + self._update_status_bar() + + def _on_edit_drag_end(self, sender): + """Handles end of edit mode drag to restore default shortcuts.""" + self._update_status_bar() + + def _on_preview_changed(self, sender): + """Handles preview state changes to update status bar shortcuts.""" + self._update_status_bar() + + def _on_sketch_solved(self, sender): + """Handles sketch solve completion to update conflicts widget.""" + self.conflicts_widget._update_conflicts() + + def _update_status_bar(self): + """Updates the status bar with current shortcuts.""" + self.status_bar.clear() + + if not self.canvas or not self.canvas.sketch_element: + return + + element = self.canvas.sketch_element + tool = element.current_tool + + def is_not_dragging(): + return not (hasattr(tool, "_is_dragging") and tool._is_dragging()) + + # Tool shortcuts (key can be string or list of strings) + for key, label, condition in tool.get_active_shortcuts(): + if condition is None or condition(): + if isinstance(key, list): + display_keys = key + else: + display_keys = [key] + self.status_bar.add_shortcut_entry( + display_keys, label, separator="" + ) + + # Global shortcuts from all tools + for tool_instance in element.tools.values(): + if tool_instance.SHORTCUTS and tool_instance.shortcut_is_active(): + key = tool_instance.SHORTCUTS[0] + label = tool_instance.LABEL or "" + if is_not_dragging(): + if key == " ": + display_keys = ["Space"] + else: + display_keys = [k.upper() for k in key] + self.status_bar.add_shortcut_entry( + display_keys, label, separator="" + ) + + def _on_text_editing_started(self, sender): + """Shows font properties when text editing begins.""" + if sender.editing_entity_id is not None: + self.font_properties.set_text_entity(sender.editing_entity_id) + + def _on_text_editing_finished(self, sender): + """Hides font properties when text editing ends.""" + self.font_properties.set_text_entity(None) + + def _on_undo(self, action, param): + if self.canvas.sketch_editor: + self.canvas.sketch_editor.history_manager.undo() + + def _on_redo(self, action, param): + if self.canvas.sketch_editor: + self.canvas.sketch_editor.history_manager.redo() + + def _on_delete(self, action, param): + if self.canvas.sketch_element: + self.canvas.sketch_element.delete_selection() + + def _on_view_fit(self, action, param): + self.canvas.reset_view() + + def _on_toggle_construction(self, action, param): + if self.canvas.sketch_element: + self.canvas.sketch_element.toggle_construction_on_selection() + + def _on_chamfer(self, action, param): + if self.canvas.sketch_element: + self.canvas.sketch_element.add_chamfer_action() + + def _on_toggle_constraints(self, btn): + if self.canvas.sketch_element: + self.canvas.sketch_element.show_constraints = btn.get_active() + self.canvas.sketch_element.mark_dirty() + + def _on_toggle_construction_visibility(self, btn): + if self.canvas.sketch_element: + self.canvas.sketch_element.show_construction_geometry = ( + btn.get_active() + ) + self.canvas.sketch_element.mark_dirty() + + def _on_camera_toggled(self, btn): + """Handles camera toggle from the visibility overlay button.""" + is_visible = btn.get_active() + self.canvas.set_camera_image_visibility(is_visible) + self.canvas.sync_camera_elements() + btn.set_child(self._cam_icon_on if is_visible else self._cam_icon_off) + + def _on_cancel_clicked(self, btn, *args): + logger.info("SketchStudio: Cancel clicked") + self.cancelled.send(self) + + def _on_finish_clicked(self, btn, *args): + logger.info("SketchStudio: Finish clicked") + + # Ensure any active tool commits its pending state (e.g. text editing) + if self.canvas and self.canvas.sketch_element: + # Switching to 'select' ensures the current tool's on_deactivate() + # is called, which finalizes edits. + self.canvas.sketch_element.set_tool("select") + + # Retrieve the modified sketch from the canvas element + if self.canvas.sketch_element: + sketch = self.canvas.sketch_element.sketch + self.finished.send(self, sketch=sketch) 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 new file mode 100644 index 000000000..c78c1dbbd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/__init__.py @@ -0,0 +1,123 @@ +from .angle_constraint_tool import AngleConstraintTool +from .arc_tool import ArcTool +from .aspect_ratio_constraint_tool import AspectRatioConstraintTool +from .base import SketcherKey, SketchTool +from .chamfer_tool import ChamferTool +from .circle_tool import CircleTool +from .coincident_constraint_tool import CoincidentConstraintTool +from .construction_tool import ConstructionTool +from .delete_tool import DeleteTool +from .diameter_constraint_tool import DiameterConstraintTool +from .distance_constraint_tool import DistanceConstraintTool +from .equal_constraint_tool import EqualConstraintTool +from .fill_tool import FillTool +from .fillet_tool import FilletTool +from .grid_tool import GridTool +from .horizontal_constraint_tool import HorizontalConstraintTool +from .path_tool import PathTool +from .perpendicular_constraint_tool import PerpendicularConstraintTool +from .radius_constraint_tool import RadiusConstraintTool +from .rectangle_tool import RectangleTool +from .rounded_rect_tool import RoundedRectTool +from .select_tool import SelectTool +from .snap_mixin import SnapMixin +from .straighten_tool import StraightenTool +from .symmetry_constraint_tool import SymmetryConstraintTool +from .tangent_constraint_tool import TangentConstraintTool +from .text_box_tool import TextBoxTool +from .vertical_constraint_tool import VerticalConstraintTool +from .waypoint_sharp_tool import WaypointSharpTool +from .waypoint_smooth_tool import WaypointSmoothTool +from .waypoint_symmetric_tool import WaypointSymmetricTool + +TOOL_REGISTRY = { + "angle": AngleConstraintTool, + "arc": ArcTool, + "aspect_ratio": AspectRatioConstraintTool, + "chamfer": ChamferTool, + "circle": CircleTool, + "coincident": CoincidentConstraintTool, + "construction": ConstructionTool, + "delete": DeleteTool, + "diameter": DiameterConstraintTool, + "distance": DistanceConstraintTool, + "equal": EqualConstraintTool, + "fill": FillTool, + "fillet": FilletTool, + "grid": GridTool, + "horizontal": HorizontalConstraintTool, + "path": PathTool, + "perpendicular": PerpendicularConstraintTool, + "radius": RadiusConstraintTool, + "rectangle": RectangleTool, + "rounded_rect": RoundedRectTool, + "select": SelectTool, + "straighten": StraightenTool, + "symmetry": SymmetryConstraintTool, + "tangent": TangentConstraintTool, + "text_box": TextBoxTool, + "vertical": VerticalConstraintTool, + "waypoint_sharp": WaypointSharpTool, + "waypoint_smooth": WaypointSmoothTool, + "waypoint_symmetric": WaypointSymmetricTool, +} + + +def build_key_to_tool_map() -> dict[str, str]: + """Build reverse lookup: key sequence -> tool name.""" + key_map = {} + for tool_name, tool_cls in TOOL_REGISTRY.items(): + for key in tool_cls.SHORTCUTS: + key_map[key] = tool_name + return key_map + + +def build_action_tool_map() -> dict[str, str]: + """Build mapping: action name -> tool name for all tools.""" + action_map = {} + for tool_name in TOOL_REGISTRY: + action_name = f"tool_{tool_name}" + action_map[action_name] = tool_name + return action_map + + +KEY_TO_TOOL = build_key_to_tool_map() +ACTION_TOOL_MAP = build_action_tool_map() + +__all__ = [ + "ACTION_TOOL_MAP", + "KEY_TO_TOOL", + "TOOL_REGISTRY", + "AngleConstraintTool", + "ArcTool", + "AspectRatioConstraintTool", + "ChamferTool", + "CircleTool", + "CoincidentConstraintTool", + "ConstructionTool", + "DeleteTool", + "DiameterConstraintTool", + "DistanceConstraintTool", + "EqualConstraintTool", + "FillTool", + "FilletTool", + "GridTool", + "HorizontalConstraintTool", + "PathTool", + "PerpendicularConstraintTool", + "RadiusConstraintTool", + "RectangleTool", + "RoundedRectTool", + "SelectTool", + "SketchTool", + "SketcherKey", + "SnapMixin", + "StraightenTool", + "SymmetryConstraintTool", + "TangentConstraintTool", + "TextBoxTool", + "VerticalConstraintTool", + "WaypointSharpTool", + "WaypointSmoothTool", + "WaypointSymmetricTool", +] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/angle_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/angle_constraint_tool.py new file mode 100644 index 000000000..4a2b6fa62 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/angle_constraint_tool.py @@ -0,0 +1,80 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + AngleConstraintCommand, +) +from ...core.constraints import AngleConstraint +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class AngleConstraintTool(SketchTool): + ICON = "sketch-constrain-angle-symbolic" + LABEL = _("Angle") + SHORTCUTS: ClassVar[list[str]] = ["ka"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return AngleConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + if len(sel.entity_ids) < 2: + logger.warning("Angle constraint requires exactly 2 lines.") + return + + e1_id = sel.entity_ids[0] + e2_id = sel.entity_ids[1] + + params = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, e1_id, e2_id + ) + + if params is None: + return + + constr = AngleConstraint( + params.anchor_id, + params.other_id, + params.value_deg, + e1_far_idx=params.anchor_far_idx, + e2_far_idx=params.other_far_idx, + ) + cmd = AddItemsCommand( + sketch, + _("Add Angle Constraint"), + constraints=[constr], + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/arc_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/arc_tool.py new file mode 100644 index 000000000..d1ae60a23 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/arc_tool.py @@ -0,0 +1,338 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +import cairo + +from ...core.commands import ArcCommand, ArcPreviewState +from .base import SketcherKey, SketchTool +from .dimension_input import DimensionInputHandler +from .snap_mixin import SnapMixin + + +class ArcTool(SnapMixin, SketchTool): + """Handles creating arcs (Center -> Start -> End). + + - Tab: toggle magnetic snap + """ + + ICON = "sketch-arc-symbolic" + LABEL = _("Arc") + SHORTCUTS: ClassVar[list[str]] = ["ga"] + CURSOR_ICON = "sketch-arc-symbolic" + + def __init__(self, element): + super().__init__(element) + self._preview_state: ArcPreviewState | None = None + self._dim_input = DimensionInputHandler() + + 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) -> ArcPreviewState | None: + return self._preview_state + + def on_deactivate(self): + """Clean up any intermediate points if the arc was not finished.""" + self._dim_input.cancel() + if self._preview_state is not None: + if self._preview_state.has_start_point: + ArcCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + + if self._preview_state.center_temp: + self.element.remove_point_if_unused( + self._preview_state.center_id + ) + if self._preview_state.start_temp: + self.element.remove_point_if_unused( + self._preview_state.start_id + ) + + self._preview_state = None + self.element.preview_changed.send(self.element) + + self.clear_snap_result() + self.element.mark_dirty() + + 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 + ) + pid_hit = self.get_snapped_point_id() + + return self._handle_click(pid_hit, mx, my) + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_hover_motion(self, world_x: float, world_y: float): + """Updates the live preview of the arc.""" + if self._preview_state is None: + self.clear_snap_result() + return + + if not self._preview_state.has_start_point: + return + + if self._dim_input.is_active(): + return + + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + + preview_ids = self._preview_state.get_preview_point_ids() + mx, my = self.query_snap_for_creation( + self.element, mx, my, preview_ids + ) + + try: + ArcCommand.update_preview( + self.element.sketch.registry, self._preview_state, mx, my + ) + self.element.mark_dirty() + except IndexError: + self.on_deactivate() + + 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 _handle_click(self, pid_hit, mx, my) -> bool: + # State machine: Center -> Start -> End + + if self._preview_state is not None: + try: + self.element.sketch.registry.get_point( + self._preview_state.center_id + ) + except IndexError: + self.on_deactivate() + return True + + if self._preview_state is None: + # Step 1: Center Point + self._preview_state = ArcCommand.start_center_preview( + self.element.sketch.registry, mx, my, snapped_pid=pid_hit + ) + self.element.preview_changed.send(self.element) + self.element.update_bounds_from_sketch() + + elif not self._preview_state.has_start_point: + # Step 2: Start Point + if pid_hit == self._preview_state.center_id: + self.element.mark_dirty() + return True + + ArcCommand.set_start_point( + self.element.sketch.registry, + self._preview_state, + mx, + my, + snapped_pid=pid_hit, + ) + self.element.update_bounds_from_sketch() + + else: + # Step 3: End Point (Finalize) + if self._preview_state is None: + return False + if self._preview_state.start_id is None: + return False + + preview_ids = self._preview_state.get_preview_point_ids() + clockwise = self._preview_state.clockwise + center_id = self._preview_state.center_id + center_temp = self._preview_state.center_temp + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + ArcCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + + self._preview_state = None + self.element.preview_changed.send(self.element) + self._dim_input.cancel() + + final_pid = None if pid_hit in preview_ids else pid_hit + + cmd = ArcCommand( + self.element.sketch, + center_id, + start_id, + (mx, my), + end_pid=final_pid, + is_center_temp=center_temp, + is_start_temp=start_temp, + clockwise=clockwise, + ) + self.element.execute_command(cmd) + + self.clear_snap_result() + + self.element.mark_dirty() + return True + + def handle_text_input(self, text: str) -> bool: + """Handle numeric input for setting arc radius.""" + if self._preview_state is None: + return False + + if not self._preview_state.has_start_point: + 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 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: + if self._dim_input.is_active(): + self._apply_dimension_input() + return True + else: + self.toggle_magnetic_snap() + return True + + if not self._preview_state.has_start_point: + return False + + if key == SketcherKey.BACKSPACE: + if self._dim_input.is_active(): + self._dim_input.handle_backspace() + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.DELETE: + if self._dim_input.is_active(): + self._dim_input.handle_delete() + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.RETURN: + if self._dim_input.is_active(): + self._apply_dimension_input() + return True + return False + + if key == SketcherKey.ESCAPE: + if self._dim_input.is_active(): + self._dim_input.cancel() + self.element.mark_dirty() + return True + return False + + return False + + def _apply_dimension_input(self): + """Apply the dimension input to the preview arc.""" + if self._preview_state is None: + self._dim_input.cancel() + return + + values = self._dim_input.commit() + if values is None or len(values) == 0: + return + + radius = values[0] + if radius is None: + return + + self._preview_state.set_radius(self.element.sketch.registry, radius) + self._finalize_shape(fixed_radius=radius) + self.element.mark_dirty() + + def _finalize_shape(self, fixed_radius: float | None = None): + if self._preview_state is None: + return + if self._preview_state.start_id is None: + return + if self._preview_state.temp_end_id is None: + return + + clockwise = self._preview_state.clockwise + center_id = self._preview_state.center_id + center_temp = self._preview_state.center_temp + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + try: + end_pt = self.element.sketch.registry.get_point( + self._preview_state.temp_end_id + ) + except IndexError: + self._preview_state = None + self.element.preview_changed.send(self.element) + self._dim_input.cancel() + self.element.mark_dirty() + return + + mx = end_pt.x + my = end_pt.y + ArcCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + self._dim_input.cancel() + cmd = ArcCommand( + self.element.sketch, + center_id, + start_id, + (mx, my), + end_pid=None, + is_center_temp=center_temp, + is_start_temp=start_temp, + clockwise=clockwise, + fixed_radius=fixed_radius, + ) + self.element.execute_command(cmd) + self.clear_snap_result() + self.element.mark_dirty() + + 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 not None: + if self._preview_state.has_start_point: + if self._dim_input.is_active(): + return self._dim_input.get_active_shortcuts() + return [ + ("0-9", _("Type radius"), None), + ("Tab", _("Toggle Magnetic Snap"), None), + ] + return [ + ("Tab", _("Toggle Magnetic Snap"), None), + ] + return [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py new file mode 100644 index 000000000..2e8c87688 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/aspect_ratio_constraint_tool.py @@ -0,0 +1,86 @@ +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import AddItemsCommand +from ...core.constraints import AspectRatioConstraint +from ...core.entities import Entity, Line, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class AspectRatioConstraintTool(SketchTool): + ICON = "sketch-constrain-aspect-symbolic" + LABEL = _("Aspect Ratio") + SHORTCUTS: ClassVar[list[str]] = ["kx"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return AspectRatioConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + if len(sel.entity_ids) != 2: + return + + e1_id = sel.entity_ids[0] + e2_id = sel.entity_ids[1] + + e1 = sketch.registry.get_entity(e1_id) + e2 = sketch.registry.get_entity(e2_id) + + if not isinstance(e1, Line) or not isinstance(e2, Line): + logger.warning("Aspect ratio constraint requires 2 lines.") + return + + p1 = sketch.registry.get_point(e1.p1_idx) + p2 = sketch.registry.get_point(e1.p2_idx) + p3 = sketch.registry.get_point(e2.p1_idx) + p4 = sketch.registry.get_point(e2.p2_idx) + + if not all([p1, p2, p3, p4]): + logger.warning("Could not resolve all points for aspect ratio.") + return + + dist1 = math.hypot(p2.x - p1.x, p2.y - p1.y) + dist2 = math.hypot(p4.x - p3.x, p4.y - p3.y) + + if dist2 < 1e-9: + logger.warning("Second line has zero length.") + return + + ratio = dist1 / dist2 + constr = AspectRatioConstraint(p1.id, p2.id, p3.id, p4.id, ratio) + cmd = AddItemsCommand( + sketch, _("Add Aspect Ratio Constraint"), constraints=[constr] + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/base.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/base.py new file mode 100644 index 000000000..c402f4011 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/base.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from enum import Enum, auto +from typing import TYPE_CHECKING, ClassVar + +import cairo + +if TYPE_CHECKING: + from ...core.commands.base import PreviewState + from ...core.constraints import Constraint + from ...core.entities import Entity, Point + from ..sketchelement import SketchElement + + +class SketcherKey(Enum): + """UI-agnostic special key identifiers.""" + + BACKSPACE = auto() + DELETE = auto() + ARROW_LEFT = auto() + ARROW_RIGHT = auto() + RETURN = auto() + ESCAPE = auto() + HOME = auto() + END = auto() + TAB = auto() + UNDO = auto() + REDO = auto() + COPY = auto() + PASTE = auto() + CUT = auto() + SELECT_ALL = auto() + + +class SketchTool(ABC): + """Abstract base class for sketcher tools.""" + + ICON: str | None = None + LABEL: str | None = None + SHORTCUTS: ClassVar[list[str]] = [] + CURSOR_ICON: str | None = None + + def __init__(self, element: SketchElement): + self.element = element + + @abstractmethod + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + pass + + @abstractmethod + def on_drag(self, world_dx: float, world_dy: float): + pass + + @abstractmethod + def on_release(self, world_x: float, world_y: float): + pass + + def on_hover_motion(self, world_x: float, world_y: float): + """Optional hook for hover effects.""" + + def on_modifier_change(self, shift: bool = False, ctrl: bool = False): + """ + Called when modifier keys (Shift/Ctrl) change during tool operation. + + Override in subclasses that need to respond to modifier key changes + during drag operations. + """ + + def on_deactivate(self): + """ + Called when the tool is about to be switched or deactivated. + Subclasses can implement this to clean up their state. + """ + + def on_activate(self): + """ + Called when the tool becomes active. + Action tools override this to execute immediately. + """ + + def draw_overlay(self, ctx: cairo.Context): + """ + Called by the SketchElement to allow the active tool to draw + transient UI (like selection boxes) in screen space. + """ + + def get_preview_state(self) -> PreviewState | None: + """ + Returns the current preview state for tools that support live preview. + Override in subclasses that have a _preview_state attribute. + """ + return None + + def handle_text_input(self, text: str) -> bool: + """Optional hook for handling printable character input.""" + return False + + def handle_key_event( + self, key: SketcherKey, shift: bool = False, ctrl: bool = False + ) -> bool: + """Optional hook for handling special (non-character) key events.""" + return False + + def get_active_shortcuts( + self, + ) -> list[tuple[str | list[str], str, Callable[[], bool] | None]]: + """ + Returns shortcuts currently available based on tool state. + + Returns a list of (key, label, condition) tuples. + - key: Either a string (single key) or list of strings (multiple keys) + - label: Human-readable description + - condition: Optional callable returning True if shortcut should be + shown. If None, shortcut is always shown. + + Override in subclasses to provide context-sensitive shortcuts. + """ + return [] + + def is_available( + self, + target: Point | Entity | Constraint | None, + target_type: str | None, + ) -> bool: + """ + Determines if this tool should be visible in the pie menu. + + Args: + target: The object under the cursor (Point, Entity, Constraint) + target_type: Type identifier ('point', 'entity', 'constraint', + 'junction', None for empty space) + + Returns: + True if the tool should be shown, False otherwise. + + Default implementation returns True for tools with ICON and LABEL. + Override in subclasses for context-sensitive visibility. + """ + return self.ICON is not None and self.LABEL is not None + + def shortcut_is_active(self) -> bool: + """ + Determines if this tool's shortcut should be shown in the status bar. + + Returns: + True if the shortcut should be shown, False otherwise. + + Default implementation delegates to is_available() with no target. + Global tools (line, arc, etc.) should override to always return True. + Constraint/action tools should use the default to show only when + applicable. + """ + return self.is_available(None, None) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/chamfer_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/chamfer_tool.py new file mode 100644 index 000000000..228f0dacf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/chamfer_tool.py @@ -0,0 +1,128 @@ +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ChamferCommand +from ...core.entities import Entity, Line, Point +from ...core.types import EntityID +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class ChamferTool(SketchTool): + ICON = "sketch-chamfer-symbolic" + LABEL = _("Chamfer") + SHORTCUTS: ClassVar[list[str]] = ["ch"] + DEFAULT_DISTANCE_RATIO = 0.15 + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + if target_type != "junction": + return False + if target is None or not isinstance(target, Point): + return False + lines = self._get_lines_at_point(target.id) + return len(lines) == 2 + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_chamfer() + self.element.set_tool("select") + + def _get_lines_at_point(self, pid: EntityID) -> list[Line]: + sketch = self.element.sketch + return [ + e + for e in sketch.registry.entities + if isinstance(e, Line) and pid in (e.p1_idx, e.p2_idx) + ] + + def _get_default_distance(self, corner_pid: EntityID) -> float: + sketch = self.element.sketch + corner_point = sketch.registry.get_point(corner_pid) + if not corner_point: + return 10.0 + + lines = self._get_lines_at_point(corner_pid) + if len(lines) != 2: + return 10.0 + + line1, line2 = lines + other1_pid = ( + line1.p2_idx if line1.p1_idx == corner_pid else line1.p1_idx + ) + other2_pid = ( + line2.p2_idx if line2.p1_idx == corner_pid else line2.p1_idx + ) + + other1_pt = sketch.registry.get_point(other1_pid) + other2_pt = sketch.registry.get_point(other2_pid) + + if not other1_pt or not other2_pt: + return 10.0 + + v1 = (other1_pt.x - corner_point.x, other1_pt.y - corner_point.y) + v2 = (other2_pt.x - corner_point.x, other2_pt.y - corner_point.y) + len1 = math.hypot(v1[0], v1[1]) + len2 = math.hypot(v2[0], v2[1]) + + min_len = min(len1, len2) + return min_len * self.DEFAULT_DISTANCE_RATIO + + def _add_chamfer(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + corner_pid = sel.junction_pid + if corner_pid is None and sel.point_ids: + corner_pid = sel.point_ids[0] + + if corner_pid is None: + return + + lines_at_junction = self._get_lines_at_point(corner_pid) + if len(lines_at_junction) != 2: + return + line1, line2 = lines_at_junction + + default_distance = self._get_default_distance(corner_pid) + geom = ChamferCommand.calculate_geometry( + sketch.registry, + corner_pid, + line1.id, + line2.id, + default_distance, + ) + + if not geom: + logger.warning("Lines are too short to create a chamfer.") + return + + cmd = ChamferCommand( + sketch, + corner_pid, + line1.id, + line2.id, + sketch.params.evaluate(default_distance), + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/circle_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/circle_tool.py new file mode 100644 index 000000000..d5ab474df --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/circle_tool.py @@ -0,0 +1,198 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +import cairo + +from ...core.commands import EllipseCommand, EllipsePreviewState +from .base import SketcherKey, SketchTool +from .snap_mixin import SnapMixin + + +class CircleTool(SnapMixin, SketchTool): + """Handles creating ellipses/circles via drag-to-create. + + - Default: drag creates an ellipse fitting the bounding box + - Ctrl: constrain to circle (equal radii) + - Shift: center the ellipse on the starting point + - Tab: toggle magnetic snap + """ + + ICON = "sketch-circle-symbolic" + LABEL = _("Ellipse") + SHORTCUTS: ClassVar[list[str]] = ["gc"] + CURSOR_ICON = "sketch-circle-symbolic" + + def __init__(self, element): + super().__init__(element) + self._preview_state: EllipsePreviewState | None = None + self._ctrl_held = False + self._shift_held = 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) -> EllipsePreviewState | None: + return self._preview_state + + def on_deactivate(self): + """Clean up if the tool is deactivated mid-creation.""" + if self._preview_state is not None: + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + EllipseCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + + if start_temp: + self.element.remove_point_if_unused(start_id) + + 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 + ) + + pid_hit = self.get_snapped_point_id() + + if self._preview_state is None: + self._preview_state = EllipseCommand.start_preview( + self.element.sketch.registry, mx, my, snapped_pid=pid_hit + ) + self.element.preview_changed.send(self.element) + self.element.mark_dirty() + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + if self._preview_state is None: + return + + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + + preview_ids = self._preview_state.get_preview_point_ids() + mx, my = self.query_snap_for_creation( + self.element, mx, my, preview_ids + ) + + pid_hit = self.get_snapped_point_id() + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + EllipseCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + + final_pid = None if pid_hit in preview_ids else pid_hit + + cmd = EllipseCommand( + self.element.sketch, + start_id, + (mx, my), + end_pid=final_pid, + is_start_temp=start_temp, + center_on_start=self._shift_held, + constrain_circle=self._ctrl_held, + ) + self.element.execute_command(cmd) + self.element.mark_dirty() + + self.clear_snap_result() + + def on_hover_motion(self, world_x: float, world_y: float): + """Updates the live preview of the ellipse.""" + if self._preview_state is None: + self.clear_snap_result() + return + + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + + preview_ids = self._preview_state.get_preview_point_ids() + mx, my = self.query_snap_for_creation( + self.element, mx, my, preview_ids + ) + + try: + EllipseCommand.update_preview( + self.element.sketch.registry, + self._preview_state, + mx, + my, + center_on_start=self._shift_held, + constrain_circle=self._ctrl_held, + ) + self.element.mark_dirty() + except (IndexError, KeyError): + self.on_deactivate() + + 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 handle_key_event( + self, key: SketcherKey, shift: bool = False, ctrl: bool = False + ) -> bool: + """Handle modifier keys for ellipse creation.""" + if self._preview_state is None: + return False + + if key == SketcherKey.ESCAPE: + self.on_deactivate() + return True + + if key == SketcherKey.TAB: + self.toggle_magnetic_snap() + return True + + return False + + def on_modifier_change(self, shift: bool = False, ctrl: bool = False): + """Called when modifier keys change during drag.""" + if self._preview_state is None: + return + + changed = self._ctrl_held != ctrl or self._shift_held != shift + self._ctrl_held = ctrl + self._shift_held = shift + + if changed: + self.element.mark_dirty() + + 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 not None: + return [ + ("Shift", _("Center on start point"), None), + ("Ctrl", _("Constrain to circle"), None), + ("Tab", _("Toggle Magnetic Snap"), None), + ("Esc", _("Cancel"), None), + ] + return [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/coincident_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/coincident_constraint_tool.py new file mode 100644 index 000000000..5e7acd49e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/coincident_constraint_tool.py @@ -0,0 +1,100 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import AddItemsCommand +from ...core.constraints import ( + CoincidentConstraint, + PointOnLineConstraint, +) +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class CoincidentConstraintTool(SketchTool): + ICON = "sketch-constrain-point-symbolic" + LABEL = _("Coincident") + SHORTCUTS: ClassVar[list[str]] = ["o", "c"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + sel = self.element.selection + sketch = self.element.sketch + if CoincidentConstraint.can_apply_to(sel, sketch): + p1_id, p2_id = sel.point_ids + return not self._has_coincident_constraint(sketch, p1_id, p2_id) + if PointOnLineConstraint.can_apply_to(sel, sketch): + point_id = sel.point_ids[0] + entity_id = sel.entity_ids[0] + return not self._has_point_on_line_constraint( + sketch, point_id, entity_id + ) + return False + + def _has_coincident_constraint( + self, sketch, p1_id: int, p2_id: int + ) -> bool: + for c in sketch.constraints: + if isinstance(c, CoincidentConstraint) and ( + (c.p1 == p1_id and c.p2 == p2_id) + or (c.p1 == p2_id and c.p2 == p1_id) + ): + return True + return False + + def _has_point_on_line_constraint( + self, sketch, point_id: int, entity_id: int + ) -> bool: + for c in sketch.constraints: + if ( + isinstance(c, PointOnLineConstraint) + and c.point_id == point_id + and c.shape_id == entity_id + ): + return True + return False + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + + if CoincidentConstraint.can_apply_to(sel, sketch): + p1_id, p2_id = sel.point_ids + constr = CoincidentConstraint(p1_id, p2_id) + cmd = AddItemsCommand( + sketch, + _("Add Coincident Constraint"), + constraints=[constr], + ) + self.element.execute_command(cmd) + sel.point_ids = [p1_id] + sel.changed.send(sel) + elif PointOnLineConstraint.can_apply_to(sel, sketch): + sel_entity_id = sel.entity_ids[0] + target_pid = sel.point_ids[0] + constr = PointOnLineConstraint(target_pid, sel_entity_id) + cmd = AddItemsCommand( + sketch, _("Add Point On Shape"), constraints=[constr] + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/construction_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/construction_tool.py new file mode 100644 index 000000000..2f81154d6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/construction_tool.py @@ -0,0 +1,51 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ToggleConstructionCommand +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class ConstructionTool(SketchTool): + ICON = "sketch-construction-symbolic" + LABEL = _("Construction") + SHORTCUTS: ClassVar[list[str]] = ["gn"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return len(self.element.selection.entity_ids) > 0 + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._toggle_construction() + self.element.set_tool("select") + + def _toggle_construction(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not sel.entity_ids or not editor: + return + + cmd = ToggleConstructionCommand( + sketch, _("Toggle Construction"), sel.entity_ids + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/delete_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/delete_tool.py new file mode 100644 index 000000000..655209d44 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/delete_tool.py @@ -0,0 +1,78 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Union + +from ...core.commands import UnstickJunctionCommand +from ...core.commands.items import RemoveItemsCommand +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class DeleteTool(SketchTool): + ICON = "delete-symbolic" + LABEL = _("Delete") + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + sel = self.element.selection + return bool( + sel.point_ids + or sel.entity_ids + or sel.constraint_idx is not None + or sel.junction_pid is not None + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._delete_selection() + self.element.set_tool("select") + + def _delete_selection(self) -> bool: + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return False + + if sel.junction_pid is not None: + cmd = UnstickJunctionCommand(sketch, sel.junction_pid) + self.element.execute_command(cmd) + sel.clear() + return True + + ( + points_to_del, + entities_to_del, + constraints_to_del, + ) = RemoveItemsCommand.calculate_dependencies(sketch, sel) + + did_work = bool(points_to_del or entities_to_del or constraints_to_del) + if did_work: + cmd = RemoveItemsCommand( + sketch, + _("Delete Selection"), + points=points_to_del, + entities=entities_to_del, + constraints=constraints_to_del, + ) + self.element.execute_command(cmd) + sel.clear() + + return did_work diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/diameter_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/diameter_constraint_tool.py new file mode 100644 index 000000000..40059e7df --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/diameter_constraint_tool.py @@ -0,0 +1,71 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + CreateOrEditConstraintCommand, +) +from ...core.constraints import DiameterConstraint +from ...core.entities import Circle, Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class DiameterConstraintTool(SketchTool): + ICON = "sketch-diameter-symbolic" + LABEL = _("Diameter") + SHORTCUTS: ClassVar[list[str]] = ["ko"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return DiameterConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + eid = sel.entity_ids[0] + e = sketch.registry.get_entity(eid) + + if not isinstance(e, Circle): + logger.warning("Selected entity is not a Circle.") + return + + constr = CreateOrEditConstraintCommand.create_constraint_for_entity( + sketch, e + ) + + if constr: + cmd = AddItemsCommand( + sketch, + _("Add Diameter Constraint"), + constraints=[constr], + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/dimension_input.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/dimension_input.py new file mode 100644 index 000000000..29ae74983 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/dimension_input.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import locale +from collections.abc import Callable +from dataclasses import dataclass, field +from gettext import gettext as _ +from typing import cast + + +@dataclass +class DimensionInputHandler: + """ + Handles locale-aware numeric input for sketch tool dimension editing. + + Supports multiple fields with Tab navigation between them. + Respects the system locale for decimal separator (comma vs dot). + """ + + field_count: int = 1 + field_labels: list[str] | None = None + decimal_sep: str = field(default=".", init=False) + buffers: list[str] = field(default_factory=list, init=False) + current_field: int = field(default=0, init=False) + _is_active: bool = field(default=False, init=False) + + def __post_init__(self): + self._detect_decimal_separator() + self._init_buffers() + + def _detect_decimal_separator(self): + try: + self.decimal_sep = locale.localeconv().get("decimal_point", ".") + except (locale.Error, AttributeError): + self.decimal_sep = "." + + def _init_buffers(self): + self.buffers = [""] * self.field_count + self.current_field = 0 + + def is_active(self) -> bool: + return self._is_active + + def start(self) -> None: + self._init_buffers() + self._is_active = True + + def cancel(self) -> None: + self._init_buffers() + self._is_active = False + + def get_display_text(self, field_index: int | None = None) -> str | None: + """ + Returns the current buffer text for display. + + Args: + field_index: If provided, returns text for that specific field. + Returns buffer content if any, or None if empty. + + Returns: + The buffer text (or None if empty), or None if field_index is + out of range. + """ + if field_index is not None: + if field_index < 0 or field_index >= len(self.buffers): + return None + buf = self.buffers[field_index] + return buf if buf else None + + if self.field_count == 1: + return self.buffers[0] + + parts = [] + for i, buf in enumerate(self.buffers): + label = ( + self.field_labels[i] + if self.field_labels and i < len(self.field_labels) + else str(i + 1) + ) + if i == self.current_field: + parts.append(f"[{label}: {buf or '_'}]") + else: + parts.append(f"{label}: {buf or '_'}") + return " | ".join(parts) + + def handle_text_input(self, text: str) -> bool: + if not self._is_active: + return False + + for char in text: + if self._is_valid_char(char): + self.buffers[self.current_field] += char + + return True + + def _is_valid_char(self, char: str) -> bool: + if char.isdigit(): + return True + if char == self.decimal_sep or char == "." or char == ",": + buf = self.buffers[self.current_field] + if self.decimal_sep not in buf: + dot_count = buf.count(".") + comma_count = buf.count(",") + if dot_count + comma_count == 0: + return True + return False + if char == " " and ( + self.field_count > 1 and self.current_field < self.field_count - 1 + ): + self.current_field += 1 + return True + return False + + def handle_backspace(self) -> bool: + if not self._is_active: + return False + + if self.buffers[self.current_field]: + self.buffers[self.current_field] = self.buffers[ + self.current_field + ][:-1] + return True + + if self.field_count > 1 and self.current_field > 0: + self.current_field -= 1 + return True + + return False + + def handle_delete(self) -> bool: + return self.handle_backspace() + + def handle_tab(self, shift: bool = False) -> tuple[bool, bool, int | None]: + """ + Handle Tab key press. + + Returns: + Tuple of (handled, should_apply, committed_field): + - handled: True if the key was consumed + - should_apply: True if the input should be committed + (Tab on last field or single-field mode) + - committed_field: The field index that was tabbed out of + (for applying immediate constraints), or None + """ + if not self._is_active: + return (False, False, None) + + if self.field_count <= 1: + return (True, True, 0) + + if shift: + if self.current_field > 0: + committed_field = self.current_field + self.current_field -= 1 + return (True, False, committed_field) + else: + if self.current_field < self.field_count - 1: + committed_field = self.current_field + self.current_field += 1 + return (True, False, committed_field) + else: + return (True, True, self.current_field) + + return (False, False, None) + + def get_field_value(self, field_index: int) -> float | None: + """ + Parse and return the value for a specific field. + + Returns None if the field is empty or invalid. + """ + if field_index < 0 or field_index >= len(self.buffers): + return None + buf = self.buffers[field_index] + if not buf.strip(): + return None + try: + normalized = buf.strip().replace(",", ".") + value = float(normalized) + if value < 0: + return None + return value + except ValueError: + return None + + def parse_values(self) -> list[float] | None: + values = [] + + for buf in self.buffers: + if not buf.strip(): + values.append(None) + continue + try: + normalized = buf.strip().replace(",", ".") + value = float(normalized) + if value < 0: + return None + values.append(value) + except ValueError: + return None + + if all(v is None for v in values): + return None + + return values + + def commit(self) -> tuple[float | None, ...] | None: + values = self.parse_values() + self._is_active = False + self._init_buffers() + if values is None: + return None + return tuple(values) + + def get_active_shortcuts( + self, + ) -> list[tuple[str | list[str], str, Callable[[], bool] | None]]: + if not self._is_active: + return [] + + shortcuts = [ + ("Enter", _("Apply"), None), + ("Esc", _("Cancel"), None), + ("Del", _("Delete"), None), + ] + + if self.field_count > 1: + shortcuts.append(("Tab", _("Next field"), None)) + shortcuts.append(("⇧Tab", _("Prev field"), None)) + + return cast( + list[tuple[str | list[str], str, Callable[[], bool] | None]], + shortcuts, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/distance_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/distance_constraint_tool.py new file mode 100644 index 000000000..3aa6bf417 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/distance_constraint_tool.py @@ -0,0 +1,68 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + DistanceConstraintCommand, +) +from ...core.constraints import DistanceConstraint +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class DistanceConstraintTool(SketchTool): + ICON = "sketch-distance-symbolic" + LABEL = _("Distance") + SHORTCUTS: ClassVar[list[str]] = ["kd"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return DistanceConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + editor = self.element.editor + sketch = self.element.sketch + sel = self.element.selection + + if not editor: + return + + params = DistanceConstraintCommand.calculate_distance( + sketch.registry, sel.point_ids, sel.entity_ids + ) + + if params is None: + logger.warning("Select 2 Points or 1 Line for Distance.") + return + + constr = DistanceConstraint( + params.p1_id, params.p2_id, params.distance + ) + cmd = AddItemsCommand( + sketch, _("Add Distance Constraint"), constraints=[constr] + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/equal_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/equal_constraint_tool.py new file mode 100644 index 000000000..077b983e2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/equal_constraint_tool.py @@ -0,0 +1,79 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + EqualConstraintCommand, +) +from ...core.commands.items import RemoveItemsCommand +from ...core.constraints import EqualLengthConstraint +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class EqualConstraintTool(SketchTool): + ICON = "sketch-constrain-equal-symbolic" + LABEL = _("Equal") + SHORTCUTS: ClassVar[list[str]] = ["e"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return EqualLengthConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, sel.entity_ids + ) + + if result is None: + return + + remove_cmd = RemoveItemsCommand( + sketch, "", constraints=result.constraints_to_remove + ) + new_constr = EqualLengthConstraint(result.final_entity_ids) + add_cmd = AddItemsCommand( + sketch, _("Add Equal Constraint"), constraints=[new_constr] + ) + + remove_cmd._do_execute() + + original_add_undo = add_cmd._do_undo + + def composite_undo(): + original_add_undo() + remove_cmd._do_undo() + + add_cmd._do_undo = composite_undo + self.element.execute_command(add_cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/fill_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/fill_tool.py new file mode 100644 index 000000000..51ed79b41 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/fill_tool.py @@ -0,0 +1,132 @@ +from gettext import gettext as _ +from typing import ClassVar + +import cairo + +from rayforge.core.color import ColorRGBA +from rayforge.image.geo_renderer import geometry_to_cairo + +from ...core.commands import AddFillCommand, RemoveFillCommand +from ...core.commands.fill import SetTextFillCommand +from ...core.entities.text_box import TextBoxEntity +from ...core.sketch import DEFAULT_FILL_COLOR, FillStyle +from .base import SketchTool + + +class FillTool(SketchTool): + """Handles creating and removing fills from closed regions.""" + + ICON = "sketch-fill-symbolic" + LABEL = _("Fill") + SHORTCUTS: ClassVar[list[str]] = ["gf"] + CURSOR_ICON = "sketch-fill-symbolic" + + _current_color: ColorRGBA = DEFAULT_FILL_COLOR + _current_style: FillStyle = FillStyle.SOLID + + def __init__(self, element): + super().__init__(element) + + @classmethod + def get_current_color(cls) -> ColorRGBA: + """Get the current fill color for new fills.""" + return cls._current_color + + @classmethod + def set_current_color(cls, color: ColorRGBA): + """Set the current fill color for new fills.""" + cls._current_color = color + + @classmethod + def get_current_style(cls) -> FillStyle: + """Get the current fill style for new fills.""" + return cls._current_style + + @classmethod + def set_current_style(cls, style: FillStyle): + """Set the current fill style for new fills.""" + cls._current_style = style + + def is_available(self, target, target_type) -> bool: + return target is None + + def shortcut_is_active(self) -> bool: + return True + + def _find_text_entity_at_point( + self, mx: float, my: float + ) -> TextBoxEntity | None: + """Check if a model-space point falls inside any text glyph.""" + registry = self.element.sketch.registry + surface = cairo.RecordingSurface(cairo.CONTENT_COLOR_ALPHA, None) + ctx = cairo.Context(surface) + for entity in registry.entities: + if not isinstance(entity, TextBoxEntity): + continue + if entity.construction or not entity.content: + continue + text_geo = entity.create_text_fill_geometry(registry) + if text_geo is None or text_geo.is_empty(): + continue + ctx.new_path() + geometry_to_cairo(text_geo, ctx) + if ctx.in_fill(mx, my): + return entity + return None + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + if n_press != 1: + return False + + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + + text_entity = self._find_text_entity_at_point(mx, my) + if text_entity is not None: + if text_entity.fill_color is not None: + cmd = SetTextFillCommand( + self.element.sketch, text_entity.id, None + ) + else: + cmd = SetTextFillCommand( + self.element.sketch, + text_entity.id, + self._current_color, + ) + self.element.execute_command(cmd) + self.element.mark_dirty() + return True + + target_loop = self.element.sketch.get_loop_at_point(mx, my) + if not target_loop: + return False + + sketch = self.element.sketch + target_loop_set = frozenset(target_loop) + + existing_fill = None + for fill in sketch.fills: + if frozenset(fill.boundary) == target_loop_set: + existing_fill = fill + break + + if existing_fill: + cmd = RemoveFillCommand(sketch, existing_fill) + else: + cmd = AddFillCommand( + sketch, + target_loop, + style=self._current_style, + color=self._current_color, + ) + + self.element.execute_command(cmd) + self.element.mark_dirty() + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/fillet_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/fillet_tool.py new file mode 100644 index 000000000..01b1046e0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/fillet_tool.py @@ -0,0 +1,129 @@ +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import FilletCommand +from ...core.entities import Entity, Line, Point +from ...core.types import EntityID +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class FilletTool(SketchTool): + ICON = "sketch-fillet-symbolic" + LABEL = _("Fillet") + SHORTCUTS: ClassVar[list[str]] = ["cf"] + DEFAULT_RADIUS_RATIO = 0.15 + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + if target_type != "junction": + return False + if target is None or not isinstance(target, Point): + return False + lines = self._get_lines_at_point(target.id) + return len(lines) == 2 + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_fillet() + self.element.set_tool("select") + + def _get_lines_at_point(self, pid: EntityID) -> list[Line]: + sketch = self.element.sketch + return [ + e + for e in sketch.registry.entities + if isinstance(e, Line) and pid in (e.p1_idx, e.p2_idx) + ] + + def _get_default_radius(self, corner_pid: EntityID) -> float: + sketch = self.element.sketch + corner_point = sketch.registry.get_point(corner_pid) + if not corner_point: + return 10.0 + + lines = self._get_lines_at_point(corner_pid) + if len(lines) != 2: + return 10.0 + + line1, line2 = lines + other1_pid = ( + line1.p2_idx if line1.p1_idx == corner_pid else line1.p1_idx + ) + other2_pid = ( + line2.p2_idx if line2.p1_idx == corner_pid else line2.p1_idx + ) + + other1_pt = sketch.registry.get_point(other1_pid) + other2_pt = sketch.registry.get_point(other2_pid) + + if not other1_pt or not other2_pt: + return 10.0 + + v1 = (other1_pt.x - corner_point.x, other1_pt.y - corner_point.y) + v2 = (other2_pt.x - corner_point.x, other2_pt.y - corner_point.y) + len1 = math.hypot(v1[0], v1[1]) + len2 = math.hypot(v2[0], v2[1]) + + min_len = min(len1, len2) + return min_len * self.DEFAULT_RADIUS_RATIO + + def _add_fillet(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + corner_pid = sel.junction_pid + if corner_pid is None and sel.point_ids: + corner_pid = sel.point_ids[0] + + if corner_pid is None: + return + + lines_at_junction = self._get_lines_at_point(corner_pid) + if len(lines_at_junction) != 2: + return + line1, line2 = lines_at_junction + + default_radius = self._get_default_radius(corner_pid) + geom = FilletCommand.calculate_geometry( + sketch.registry, + corner_pid, + line1.id, + line2.id, + default_radius, + ) + if not geom: + logger.warning( + "Lines are too short or angle too acute for fillet." + ) + return + + cmd = FilletCommand( + sketch, + corner_pid, + line1.id, + line2.id, + sketch.params.evaluate(default_radius), + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/grid_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/grid_tool.py new file mode 100644 index 000000000..f592b5557 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/grid_tool.py @@ -0,0 +1,102 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union, cast + +from gi.repository import Adw, Gtk + +from rayforge.ui_gtk.shared.pref_rows import SpinRow + +from ...core.commands import GridCommand +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + + +class GridTool(SketchTool): + ICON = "sketch-grid-symbolic" + LABEL = _("Grid") + SHORTCUTS: ClassVar[list[str]] = ["gg"] + + def is_available( + self, + target: Union["Point", "Entity", "Constraint"] | None, + target_type: str | None, + ) -> bool: + return target is None + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._show_dialog() + self.element.set_tool("select") + + def _show_dialog(self): + editor = self.element.editor + if not editor or not editor.parent_window: + return + + parent_window = cast(Gtk.Window, editor.parent_window) + + dialog = Adw.MessageDialog( + transient_for=parent_window, + modal=True, + destroy_with_parent=True, + heading=_("Create Grid"), + ) + + rows_row = SpinRow( + _("Rows"), + lower=2, + upper=100, + digits=0, + value=3, + ) + + cols_row = SpinRow( + _("Columns"), + lower=2, + upper=100, + digits=0, + value=3, + ) + + list_box = Gtk.ListBox( + selection_mode=Gtk.SelectionMode.NONE, + css_classes=["boxed-list"], + ) + list_box.append(rows_row) + list_box.append(cols_row) + dialog.set_extra_child(list_box) + + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("create", _("Create")) + dialog.set_response_appearance( + "create", Adw.ResponseAppearance.SUGGESTED + ) + dialog.set_default_response("create") + dialog.set_close_response("cancel") + + def on_response(source, response_id): + if response_id == "create": + rows = rows_row.get_int_value() + cols = cols_row.get_int_value() + + cmd = GridCommand( + self.element.sketch, + rows=rows, + cols=cols, + ) + self.element.execute_command(cmd) + + dialog.close() + + dialog.connect("response", on_response) + dialog.present() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/horizontal_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/horizontal_constraint_tool.py new file mode 100644 index 000000000..583d75398 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/horizontal_constraint_tool.py @@ -0,0 +1,70 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import AddItemsCommand +from ...core.constraints import HorizontalConstraint +from ...core.entities import Entity, Line, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class HorizontalConstraintTool(SketchTool): + ICON = "sketch-constrain-horizontal-symbolic" + LABEL = _("Horizontal") + SHORTCUTS: ClassVar[list[str]] = ["h"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return HorizontalConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + constraints_to_add = [] + + if len(sel.point_ids) == 2 and not sel.entity_ids: + p1_id, p2_id = sel.point_ids + constraints_to_add.append(HorizontalConstraint(p1_id, p2_id)) + elif len(sel.entity_ids) > 0 and not sel.point_ids: + for eid in sel.entity_ids: + e = sketch.registry.get_entity(eid) + if isinstance(e, Line): + constraints_to_add.append( + HorizontalConstraint(e.p1_idx, e.p2_idx) + ) + + if constraints_to_add: + cmd = AddItemsCommand( + sketch, + _("Add Horizontal Constraint"), + constraints=constraints_to_add, + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/path_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/path_tool.py new file mode 100644 index 000000000..e94f699a9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/path_tool.py @@ -0,0 +1,641 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +import cairo + +from rayforge.ui_gtk.shared.keyboard import PRIMARY_KEY_NAME + +from ...core.commands import BezierCommand, BezierPreviewState +from ...core.entities import Bezier +from .base import SketcherKey, SketchTool +from .snap_mixin import SnapMixin + +logger = logging.getLogger(__name__) + + +DRAG_THRESHOLD = 2.0 + + +class PathTool(SnapMixin, SketchTool): + """ + Handles creating lines and bezier curves with a unified workflow. + + Workflow: + - Click once: starts line preview from start point + - Hover: live preview of line segment + - Second click without drag: creates line segment, starts next preview + - Second click with drag: creates bezier where drag controls the "bow" + - Tab: toggle magnetic snap + """ + + ICON = "sketch-bezier-symbolic" + LABEL = _("Path") + SHORTCUTS: ClassVar[list[str]] = ["gp", "gl"] + CURSOR_ICON = "sketch-line-symbolic" + + def __init__(self, element): + super().__init__(element) + self._preview_state: BezierPreviewState | None = None + self._press_pos: tuple[float, float] | None = None + self._waypoint_model_pos: tuple[float, float] | None = None + self._snapped_pid: int | None = None + self._dragging: bool = False + self._in_press: bool = False + self._mirror_cp_offset: tuple[float, float] | None = None + self._release_handled: bool = False + self.hovered_point_id: int | None = None + + def is_available(self, target, target_type) -> bool: + return target is None + + def shortcut_is_active(self) -> bool: + return True + + def get_active_shortcuts( + self, + ) -> list[tuple[str | list[str], str, Callable[[], bool] | None]]: + shortcuts = [] + if self._preview_state is not None: + shortcuts.extend( + [ + ("Tab", _("Toggle Magnetic Snap"), None), + ( + "Shift", + _("Constrain to Axis"), + None, + ), + ] + ) + shortcuts.append( + (PRIMARY_KEY_NAME, _("Snap to Grid"), lambda: self._dragging), + ) + return shortcuts + + def get_preview_state(self) -> BezierPreviewState | None: + return self._preview_state + + def on_deactivate(self): + """Clean up if the tool is deactivated mid-creation.""" + logger.debug( + f"on_deactivate: preview_state={self._preview_state is not None}" + ) + if self._preview_state is not None: + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + BezierCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + + if start_temp: + self.element.remove_point_if_unused(start_id) + + self.element.mark_dirty() + + self._press_pos = None + self._waypoint_model_pos = None + self._snapped_pid = None + self._dragging = False + self._in_press = False + self._mirror_cp_offset = None + self._release_handled = False + self.hovered_point_id = None + 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 + ) + pid_hit = self.get_snapped_point_id() + + logger.debug( + f"on_press: preview_state={self._preview_state is not None}, " + f"snapped={pid_hit}" + ) + + if ( + self._preview_state is not None + and pid_hit is not None + and pid_hit not in self._preview_state.get_preview_point_ids() + ): + self._snapped_pid = pid_hit + try: + snapped_pt = self.element.sketch.registry.get_point(pid_hit) + BezierCommand.update_preview( + self.element.sketch.registry, + self._preview_state, + snapped_pt.x, + snapped_pt.y, + ) + except (IndexError, KeyError): + pass + + is_line = self._preview_state.is_line_preview + if is_line: + self._finalize_line_segment() + else: + self._finalize_bezier_segment() + + if self._preview_state is not None: + BezierCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + + self._press_pos = None + self._waypoint_model_pos = None + self._snapped_pid = None + self._dragging = False + self._mirror_cp_offset = None + self.hovered_point_id = None + self.element.mark_dirty() + return False + + self._press_pos = (world_x, world_y) + self._waypoint_model_pos = (mx, my) + self._snapped_pid = pid_hit + self._dragging = False + self._in_press = True + self._release_handled = False + + if self._preview_state is None: + self._preview_state = BezierCommand.start_preview( + self.element.sketch.registry, mx, my, snapped_pid=pid_hit + ) + logger.debug( + f"start_preview created: entity_id=" + f"{self._preview_state.temp_entity_id}" + ) + self.element.preview_changed.send(self.element) + + self.element.mark_dirty() + return False + + def _constrain_to_axis(self, mx: float, my: float) -> tuple[float, float]: + """Constrain model position to horizontal or vertical from start.""" + if self._preview_state is None: + return mx, my + try: + start_pt = self.element.sketch.registry.get_point( + self._preview_state.start_id + ) + if start_pt: + dx = mx - start_pt.x + dy = my - start_pt.y + if abs(dx) > abs(dy): + return mx, start_pt.y + else: + return start_pt.x, my + except IndexError: + pass + return mx, my + + def on_drag(self, world_dx: float, world_dy: float): + ps = self._preview_state + is_line = ps.is_line_preview if ps else None + logger.debug( + f"on_drag: press_pos={self._press_pos}, " + f"preview_state={self._preview_state is not None}, " + f"dragging={self._dragging}, is_line_preview={is_line}" + ) + if self._press_pos is None or self._preview_state is None: + return + + if self._waypoint_model_pos is None: + return + + current_x = self._press_pos[0] + world_dx + current_y = self._press_pos[1] + world_dy + + mx, my = self.element.hittester.screen_to_model( + current_x, current_y, self.element + ) + + if self.element.canvas and self.element.canvas._shift_pressed: + mx, my = self._constrain_to_axis(mx, my) + + if not self._preview_state.is_line_preview: + self._dragging = True + logger.debug( + f"on_drag (bezier): calling update_control_point " + f"at ({mx:.1f}, {my:.1f})" + ) + BezierCommand.update_control_point( + self.element.sketch.registry, + self._preview_state, + mx, + my, + ) + self.element.mark_dirty() + return + + if self._preview_state.end_id is None: + return + + start_pt = self.element.sketch.registry.get_point( + self._preview_state.start_id + ) + end_pt = self.element.sketch.registry.get_point( + self._preview_state.end_id + ) + if start_pt and end_pt: + dist_sq = (start_pt.x - end_pt.x) ** 2 + ( + start_pt.y - end_pt.y + ) ** 2 + has_virtual_cp = self._mirror_cp_offset is not None + if dist_sq < 1.0 and not has_virtual_cp: + return + + dx = current_x - self._press_pos[0] + dy = current_y - self._press_pos[1] + dist_sq = dx * dx + dy * dy + + if dist_sq < DRAG_THRESHOLD * DRAG_THRESHOLD: + return + + if not self._dragging: + self._dragging = True + + logger.debug( + f"convert_to_bezier: waypoint=" + f"({self._waypoint_model_pos[0]:.1f}, " + f"{self._waypoint_model_pos[1]:.1f}), " + f"drag=({mx:.1f}, {my:.1f}), " + f"mirror={self._mirror_cp_offset}" + ) + BezierCommand.convert_to_bezier( + self.element.sketch.registry, + self._preview_state, + self._waypoint_model_pos[0], + self._waypoint_model_pos[1], + mx, + my, + mirror_cp_offset=self._mirror_cp_offset, + ) + else: + logger.debug( + f"on_drag: calling update_control_point " + f"at ({mx:.1f}, {my:.1f})" + ) + BezierCommand.update_control_point( + self.element.sketch.registry, + self._preview_state, + mx, + my, + ) + + self.element.mark_dirty() + + def on_release(self, world_x: float, world_y: float): + if self._release_handled: + return + self._release_handled = True + + self._in_press = False + logger.debug( + f"on_release: preview_state={self._preview_state is not None}, " + f"dragging={self._dragging}" + ) + + if self._preview_state is None: + self._press_pos = None + self._waypoint_model_pos = None + self._snapped_pid = None + self._dragging = False + return + + endpoint_moved = self._end_point_moved() + is_line = self._preview_state.is_line_preview + logger.debug( + f"on_release: endpoint_moved={endpoint_moved}, " + f"is_line_preview={is_line}" + ) + + if not is_line: + if endpoint_moved: + logger.debug("on_release: calling _finalize_bezier_segment") + self._finalize_bezier_segment() + else: + logger.debug( + "on_release: bezier preview, endpoint not moved, " + "keeping preview" + ) + elif self._dragging: + logger.debug( + "on_release: dragging, calling _finalize_bezier_segment" + ) + self._finalize_bezier_segment() + else: + if endpoint_moved: + logger.debug("on_release: calling _finalize_line_segment") + self._finalize_line_segment() + + self._press_pos = None + self._waypoint_model_pos = None + self._snapped_pid = None + self._dragging = False + self.element.mark_dirty() + + def _end_point_moved(self) -> bool: + """Check if the preview end point has moved from the start position.""" + if self._preview_state is None: + return False + if self._preview_state.end_id is None: + return False + + try: + start_pt = self.element.sketch.registry.get_point( + self._preview_state.start_id + ) + end_pt = self.element.sketch.registry.get_point( + self._preview_state.end_id + ) + dx = end_pt.x - start_pt.x + dy = end_pt.y - start_pt.y + dist_sq = dx * dx + dy * dy + return dist_sq > 0.01 + except IndexError: + return False + + def _finalize_line_segment(self): + """Finalize the current segment and start a new preview.""" + if self._preview_state is None: + return + + preview_ids = self._preview_state.get_preview_point_ids() + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + end_id = self._preview_state.end_id + + if end_id is None: + return + + try: + end_pt = self.element.sketch.registry.get_point(end_id) + end_x, end_y = end_pt.x, end_pt.y + except (IndexError, AttributeError): + return + + final_pid = None + if ( + self._snapped_pid is not None + and self._snapped_pid not in preview_ids + ): + final_pid = self._snapped_pid + try: + snapped_pt = self.element.sketch.registry.get_point(final_pid) + end_x, end_y = snapped_pt.x, snapped_pt.y + except IndexError: + final_pid = None + + if final_pid == start_id: + self._press_pos = None + self._waypoint_model_pos = None + self._snapped_pid = None + self._dragging = False + self.element.mark_dirty() + return + + has_virtual_cp = self._mirror_cp_offset is not None + + BezierCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self._mirror_cp_offset = None + + end_pid = final_pid + if end_pid is None: + end_pid = self.element.sketch.registry._id_counter + + constraints = self.build_snap_constraints( + end_id, + end_pid=end_pid, + snapped_to_existing=(final_pid is not None), + existing_constraints=self.element.sketch.constraints, + ) + + cmd = BezierCommand( + self.element.sketch, + start_id, + (end_x, end_y), + end_pid=final_pid, + is_start_temp=start_temp, + is_line=not has_virtual_cp, + constraints=constraints, + ) + self.element.execute_command(cmd) + + if cmd.committed_end_id is not None: + try: + new_start_pt = self.element.sketch.registry.get_point( + cmd.committed_end_id + ) + self._preview_state = BezierCommand.start_preview( + self.element.sketch.registry, + new_start_pt.x, + new_start_pt.y, + snapped_pid=cmd.committed_end_id, + ) + except IndexError: + pass + + self.clear_snap_result() + + def _finalize_bezier_segment(self): + """Finalize the current segment as a bezier and start a new preview.""" + if self._preview_state is None: + return + + if self._preview_state.is_line_preview: + return + + if not self._end_point_moved(): + return + + preview_ids = self._preview_state.get_preview_point_ids() + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + end_id = self._preview_state.end_id + + if end_id is None: + return + + try: + end_pt = self.element.sketch.registry.get_point(end_id) + end_x, end_y = end_pt.x, end_pt.y + except (IndexError, AttributeError): + return + + final_pid = None + if ( + self._snapped_pid is not None + and self._snapped_pid not in preview_ids + ): + final_pid = self._snapped_pid + try: + snapped_pt = self.element.sketch.registry.get_point(final_pid) + end_x, end_y = snapped_pt.x, snapped_pt.y + except IndexError: + final_pid = None + + if final_pid == start_id: + BezierCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self._press_pos = None + self._waypoint_model_pos = None + self._snapped_pid = None + self._dragging = False + self.element.mark_dirty() + return + + temp_entity_id = self._preview_state.temp_entity_id + temp_entity = None + cp1 = None + cp2 = None + if temp_entity_id is not None: + temp_entity = self.element.sketch.registry.get_entity( + temp_entity_id + ) + if isinstance(temp_entity, Bezier): + cp1 = temp_entity.cp1 + cp2 = temp_entity.cp2 + + self._mirror_cp_offset = self._preview_state.virtual_cp + logger.debug( + f"_finalize_bezier_segment: cp1={cp1}, cp2={cp2}, " + f"virtual_cp={self._preview_state.virtual_cp}, " + f"mirror={self._mirror_cp_offset}" + ) + + BezierCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + + bz_end_pid = final_pid + if bz_end_pid is None: + bz_end_pid = self.element.sketch.registry._id_counter + + snap_constraints = self.build_snap_constraints( + end_id, + end_pid=bz_end_pid, + snapped_to_existing=(final_pid is not None), + existing_constraints=self.element.sketch.constraints, + ) + + cmd = BezierCommand( + self.element.sketch, + start_id, + (end_x, end_y), + end_pid=final_pid, + is_start_temp=start_temp, + is_line=False, + cp1=cp1, + cp2=cp2, + constraints=snap_constraints, + ) + self.element.execute_command(cmd) + + if cmd.committed_end_id is not None: + try: + new_start_pt = self.element.sketch.registry.get_point( + cmd.committed_end_id + ) + self._preview_state = BezierCommand.start_preview( + self.element.sketch.registry, + new_start_pt.x, + new_start_pt.y, + snapped_pid=cmd.committed_end_id, + virtual_cp=self._mirror_cp_offset, + ) + except IndexError: + pass + + self.clear_snap_result() + + def on_hover_motion(self, world_x: float, world_y: float): + """Updates the live preview of the line/bezier.""" + if self._preview_state is None: + self.clear_snap_result() + return + + if self._in_press: + return + + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + + if self.element.canvas and self.element.canvas._shift_pressed: + mx, my = self._constrain_to_axis(mx, my) + + preview_ids = self._preview_state.get_preview_point_ids() + mx, my = self.query_snap_for_creation( + self.element, mx, my, preview_ids + ) + + pid_hit = self.get_snapped_point_id() + + new_hovered_pid = None + if pid_hit is not None and pid_hit not in preview_ids: + new_hovered_pid = pid_hit + + if self.hovered_point_id != new_hovered_pid: + self.hovered_point_id = new_hovered_pid + self.element.mark_dirty() + + try: + if new_hovered_pid is not None: + snapped_pt = self.element.sketch.registry.get_point( + new_hovered_pid + ) + if snapped_pt: + BezierCommand.update_preview( + self.element.sketch.registry, + self._preview_state, + snapped_pt.x, + snapped_pt.y, + ) + else: + BezierCommand.update_preview( + self.element.sketch.registry, self._preview_state, mx, my + ) + self.element.mark_dirty() + except (IndexError, KeyError): + self.on_deactivate() + + 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 handle_key_event( + self, key: SketcherKey, shift: bool = False, ctrl: bool = False + ) -> bool: + """Handle key events.""" + if self._preview_state is not None: + if key == SketcherKey.ESCAPE: + self.on_deactivate() + return True + + if key == SketcherKey.TAB: + self.toggle_magnetic_snap() + return True + + return False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/perpendicular_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/perpendicular_constraint_tool.py new file mode 100644 index 000000000..6db261b3e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/perpendicular_constraint_tool.py @@ -0,0 +1,60 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import AddItemsCommand +from ...core.constraints import PerpendicularConstraint +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class PerpendicularConstraintTool(SketchTool): + ICON = "sketch-constrain-perpendicular-symbolic" + LABEL = _("Perpendicular") + SHORTCUTS: ClassVar[list[str]] = ["n"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return PerpendicularConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + e1_id = sel.entity_ids[0] + e2_id = sel.entity_ids[1] + + constr = PerpendicularConstraint(e1_id, e2_id) + cmd = AddItemsCommand( + sketch, + _("Add Perpendicular Constraint"), + constraints=[constr], + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/radius_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/radius_constraint_tool.py new file mode 100644 index 000000000..eab70c0dc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/radius_constraint_tool.py @@ -0,0 +1,71 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + CreateOrEditConstraintCommand, +) +from ...core.constraints import RadiusConstraint +from ...core.entities import Arc, Circle, Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class RadiusConstraintTool(SketchTool): + ICON = "sketch-radius-symbolic" + LABEL = _("Radius") + SHORTCUTS: ClassVar[list[str]] = ["kr"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return RadiusConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + eid = sel.entity_ids[0] + e = sketch.registry.get_entity(eid) + + if not isinstance(e, (Arc, Circle)): + logger.warning("Could not add radius constraint.") + return + + constr = CreateOrEditConstraintCommand.create_constraint_for_entity( + sketch, e + ) + + if constr and isinstance(constr, RadiusConstraint): + cmd = AddItemsCommand( + sketch, _("Add Radius Constraint"), constraints=[constr] + ) + self.element.execute_command(cmd) + else: + logger.warning("Could not add radius constraint.") diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/rectangle_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/rectangle_tool.py new file mode 100644 index 000000000..d89690bc9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/rectangle_tool.py @@ -0,0 +1,303 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +import cairo + +from ...core.commands import ( + RectangleCommand, + RectanglePreviewState, +) +from .base import SketcherKey, SketchTool +from .dimension_input import DimensionInputHandler +from .snap_mixin import SnapMixin + + +class RectangleTool(SnapMixin, SketchTool): + """Handles creating rectangles. + + - Tab: toggle magnetic snap + """ + + ICON = "sketch-rect-symbolic" + LABEL = _("Rectangle") + SHORTCUTS: ClassVar[list[str]] = ["gr"] + CURSOR_ICON = "sketch-rect-symbolic" + + def __init__(self, element): + super().__init__(element) + self._preview_state: RectanglePreviewState | None = None + self._dim_input = DimensionInputHandler( + field_count=2, field_labels=[_("W"), _("H")] + ) + + 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) -> RectanglePreviewState | None: + return self._preview_state + + def on_deactivate(self): + """Clean up if the tool is deactivated mid-creation.""" + self._dim_input.cancel() + if self._preview_state is not None: + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + RectangleCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + + if start_temp: + self.element.remove_point_if_unused(start_id) + + 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 + ) + pid_hit = self.get_snapped_point_id() + + return self._handle_click(pid_hit, mx, my) + + def on_hover_motion(self, world_x: float, world_y: float): + """Updates the live preview of the rectangle.""" + 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 + ) + + preview_ids = self._preview_state.get_preview_point_ids() + mx, my = self.query_snap_for_creation( + self.element, mx, my, preview_ids + ) + + try: + RectangleCommand.update_preview( + self.element.sketch.registry, self._preview_state, mx, my + ) + self.element.mark_dirty() + except (IndexError, KeyError): + self.on_deactivate() + + 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 _handle_click(self, pid_hit: int | None, mx: float, my: float) -> bool: + if self._preview_state is None: + self._preview_state = RectangleCommand.start_preview( + self.element.sketch.registry, mx, my, snapped_pid=pid_hit + ) + self.element.preview_changed.send(self.element) + else: + preview_ids = self._preview_state.get_preview_point_ids() + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + RectangleCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + self._dim_input.cancel() + + final_pid = None if pid_hit in preview_ids else pid_hit + + cmd = RectangleCommand( + self.element.sketch, + start_id, + (mx, my), + end_pid=final_pid, + is_start_temp=start_temp, + ) + self.element.execute_command(cmd) + + self.clear_snap_result() + + self.element.mark_dirty() + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def handle_text_input(self, text: str) -> bool: + """Handle numeric input for setting rectangle dimensions.""" + 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 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: + if self._dim_input.is_active(): + handled, should_apply, committed_field = ( + self._dim_input.handle_tab(shift=shift) + ) + if should_apply: + self._apply_dimension_input() + elif handled and committed_field is not None: + self._apply_field_constraint(committed_field) + self.element.mark_dirty() + elif handled: + self.element.mark_dirty() + return True + else: + self.toggle_magnetic_snap() + return True + + if key == SketcherKey.BACKSPACE: + if self._dim_input.is_active(): + self._dim_input.handle_backspace() + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.DELETE: + if self._dim_input.is_active(): + self._dim_input.handle_delete() + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.RETURN: + if self._dim_input.is_active(): + self._apply_dimension_input() + return True + return False + + if key == SketcherKey.ESCAPE: + if self._dim_input.is_active(): + self._dim_input.cancel() + self.element.mark_dirty() + return True + return False + + return False + + def _apply_field_constraint(self, field_index: int) -> None: + """Apply constraint for a specific field when Tabbing out of it.""" + if self._preview_state is None: + return + + value = self._dim_input.get_field_value(field_index) + if value is None: + return + + registry = self.element.sketch.registry + if field_index == 0: + self._preview_state.set_dimensions(registry, width=value) + elif field_index == 1: + self._preview_state.set_dimensions(registry, height=value) + + def _apply_dimension_input(self): + """Apply the dimension input to the preview rectangle.""" + if self._preview_state is None: + self._dim_input.cancel() + return + + values = self._dim_input.commit() + if values is None: + return + + registry = self.element.sketch.registry + width = values[0] if len(values) > 0 else None + height = values[1] if len(values) > 1 else None + + if width is not None or height is not None: + self._preview_state.set_dimensions( + registry, width=width, height=height + ) + self._finalize_shape(fixed_width=width, fixed_height=height) + self.element.mark_dirty() + + def _finalize_shape( + self, + fixed_width: float | None = None, + fixed_height: float | None = None, + ): + if self._preview_state is None: + return + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + try: + end_pt = self.element.sketch.registry.get_point( + self._preview_state.p_end_id + ) + except IndexError: + self._preview_state = None + self.element.preview_changed.send(self.element) + self._dim_input.cancel() + self.element.mark_dirty() + return + mx = end_pt.x + my = end_pt.y + RectangleCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + self._dim_input.cancel() + cmd = RectangleCommand( + self.element.sketch, + start_id, + (mx, my), + end_pid=None, + is_start_temp=start_temp, + fixed_width=fixed_width, + fixed_height=fixed_height, + ) + self.element.execute_command(cmd) + self.clear_snap_result() + self.element.mark_dirty() + + 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 not None: + if self._dim_input.is_active(): + return self._dim_input.get_active_shortcuts() + return [ + ("0-9", _("Type dimensions (W H)"), None), + ("Tab", _("Toggle Magnetic Snap"), None), + ] + return [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/rounded_rect_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/rounded_rect_tool.py new file mode 100644 index 000000000..53ea8a424 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/rounded_rect_tool.py @@ -0,0 +1,285 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +from ...core.commands import ( + RoundedRectCommand, + RoundedRectPreviewState, +) +from .base import SketcherKey, SketchTool +from .dimension_input import DimensionInputHandler + + +class RoundedRectTool(SketchTool): + """Handles creating rounded rectangles.""" + + ICON = "sketch-rounded-rect-symbolic" + LABEL = _("Rounded Rectangle") + SHORTCUTS: ClassVar[list[str]] = ["go"] + CURSOR_ICON = "sketch-rounded-rect-symbolic" + DEFAULT_RADIUS = 10.0 + + def __init__(self, element): + super().__init__(element) + self._preview_state: RoundedRectPreviewState | None = None + self._dim_input = DimensionInputHandler( + field_count=3, field_labels=[_("W"), _("H"), _("R")] + ) + + 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) -> RoundedRectPreviewState | None: + return self._preview_state + + def on_deactivate(self): + """Clean up if the tool is deactivated mid-creation.""" + self._dim_input.cancel() + if self._preview_state is None: + return + + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + RoundedRectCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + + if start_temp: + self.element.remove_point_if_unused(start_id) + + self.element.mark_dirty() + + 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 + ) + hit_type, hit_obj = self.element.hittester.get_hit_data( + world_x, world_y, self.element + ) + pid_hit = hit_obj if hit_type == "point" else None + return self._handle_click(pid_hit, mx, my) + + def on_hover_motion(self, world_x: float, world_y: float): + """Updates the live preview of the rounded rectangle.""" + if self._preview_state is None: + return + + if self._dim_input.is_active(): + return + + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + + try: + RoundedRectCommand.update_preview( + self.element.sketch.registry, self._preview_state, mx, my + ) + self.element.mark_dirty() + except (IndexError, KeyError): + self.on_deactivate() + + def _handle_click(self, pid_hit: int | None, mx: float, my: float) -> bool: + if self._preview_state is None: + # --- First Click: Start preview --- + self._preview_state = RoundedRectCommand.start_preview( + self.element.sketch.registry, + mx, + my, + snapped_pid=pid_hit, + radius=self.DEFAULT_RADIUS, + ) + else: + # --- Second Click: Finalize the rounded rectangle --- + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + + RoundedRectCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self._dim_input.cancel() + + cmd = RoundedRectCommand( + self.element.sketch, + start_id, + (mx, my), + self.DEFAULT_RADIUS, + is_start_temp=start_temp, + ) + self.element.execute_command(cmd) + + self.element.mark_dirty() + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def handle_text_input(self, text: str) -> bool: + """Handle numeric input for setting rounded rectangle dimensions.""" + 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 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.BACKSPACE: + if self._dim_input.is_active(): + self._dim_input.handle_backspace() + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.DELETE: + if self._dim_input.is_active(): + self._dim_input.handle_delete() + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.TAB: + if self._dim_input.is_active(): + handled, should_apply, committed_field = ( + self._dim_input.handle_tab(shift=shift) + ) + if should_apply: + self._apply_dimension_input() + elif handled and committed_field is not None: + self._apply_field_constraint(committed_field) + self.element.mark_dirty() + elif handled: + self.element.mark_dirty() + return True + return False + + if key == SketcherKey.RETURN: + if self._dim_input.is_active(): + self._apply_dimension_input() + return True + return False + + if key == SketcherKey.ESCAPE: + if self._dim_input.is_active(): + self._dim_input.cancel() + self.element.mark_dirty() + return True + return False + + return False + + def _apply_field_constraint(self, field_index: int) -> None: + """Apply constraint for a specific field when Tabbing out of it.""" + if self._preview_state is None: + return + + value = self._dim_input.get_field_value(field_index) + if value is None: + return + + registry = self.element.sketch.registry + if field_index == 0: + self._preview_state.set_dimensions(registry, width=value) + elif field_index == 1: + self._preview_state.set_dimensions(registry, height=value) + elif field_index == 2: + self._preview_state.set_dimensions(registry, radius=value) + + def _apply_dimension_input(self): + """Apply the dimension input to the preview rounded rectangle.""" + if self._preview_state is None: + self._dim_input.cancel() + return + + values = self._dim_input.commit() + if values is None: + return + + registry = self.element.sketch.registry + width = values[0] if len(values) > 0 else None + height = values[1] if len(values) > 1 else None + radius = values[2] if len(values) > 2 else None + + if width is not None or height is not None or radius is not None: + self._preview_state.set_dimensions( + registry, width=width, height=height, radius=radius + ) + self._finalize_shape( + fixed_width=width, fixed_height=height, fixed_radius=radius + ) + self.element.mark_dirty() + + def _finalize_shape( + self, + fixed_width: float | None = None, + fixed_height: float | None = None, + fixed_radius: float | None = None, + ): + if self._preview_state is None: + return + start_id = self._preview_state.start_id + start_temp = self._preview_state.start_temp + radius = ( + fixed_radius + if fixed_radius is not None + else self._preview_state.radius + ) + try: + end_pt = self.element.sketch.registry.get_point( + self._preview_state.p_end_id + ) + except IndexError: + self._preview_state = None + self._dim_input.cancel() + self.element.mark_dirty() + return + mx = end_pt.x + my = end_pt.y + RoundedRectCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self._dim_input.cancel() + cmd = RoundedRectCommand( + self.element.sketch, + start_id, + (mx, my), + radius, + is_start_temp=start_temp, + fixed_width=fixed_width, + fixed_height=fixed_height, + fixed_radius=fixed_radius, + ) + self.element.execute_command(cmd) + self.element.mark_dirty() + + 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 not None: + if self._dim_input.is_active(): + return self._dim_input.get_active_shortcuts() + return [ + ("0-9", _("Type dimensions (W H R)"), None), + ] + return [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/select_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/select_tool.py new file mode 100644 index 000000000..90e122d5d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/select_tool.py @@ -0,0 +1,885 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + cast, +) + +import cairo +from raygeo.geo import Matrix +from raygeo.geo.types import Point as GeoPoint + +from ...core.commands import ( + CreateOrEditConstraintCommand, + MoveControlPointCommand, + MovePointCommand, +) +from ...core.constraints import ( + AngleConstraint, + DiameterConstraint, + DistanceConstraint, + DragConstraint, + RadiusConstraint, +) +from ...core.entities import ( + Arc, + Bezier, + Circle, + Entity, + Line, + TextBoxEntity, +) +from ...core.types import EntityID +from .base import SketcherKey, SketchTool +from .snap_mixin import SnapMixin +from .text_box_tool import TextBoxTool + +if TYPE_CHECKING: + from ...core.selection import SketchSelection + + +logger = logging.getLogger(__name__) + + +class SelectTool(SnapMixin, SketchTool): + """Handles selection and point dragging.""" + + ICON = "sketch-select-symbolic" + LABEL = _("Select") + SHORTCUTS: ClassVar[list[str]] = [" "] + + def __init__(self, element): + super().__init__(element) + self.hovered_point_id: EntityID | None = None + self.hovered_constraint_idx: int | None = None + self.hovered_junction_pid: EntityID | None = None + self.hovered_entity_id: EntityID | None = None + + # --- Box Selection State --- + self.is_box_selecting: bool = False + self.drag_start_world_pos: GeoPoint | None = None + self.drag_current_world_pos: GeoPoint | None = None + self.drag_initial_selection: SketchSelection | None = None + + # --- Drag State --- + # For dragging a single point + self.dragged_point_id: EntityID | None = None + self.drag_point_start_pos: GeoPoint | None = None + + # For dragging entities (lines/arcs) + self.dragged_entity: Entity | None = None + self.drag_start_model_pos: GeoPoint | None = None + + # For dragging control points + self.dragged_cp_bezier_id: EntityID | None = None + self.dragged_cp_index: int | None = None + self.drag_cp_start_offset: tuple[float, float] | None = None + + # State for stabilizing drag calculations and undo snapshots + self.drag_start_wt_inv: Matrix | None = None + self.drag_start_ct_inv: Matrix | None = None + + # Snapshots taken at start of drag + self.drag_initial_positions: dict[EntityID, GeoPoint] = {} + self.drag_initial_entity_states: dict[EntityID, Any] = {} + + self.drag_point_distances: dict[EntityID, int] = {} + + def is_available(self, target, target_type) -> bool: + return target is None + + def shortcut_is_active(self) -> bool: + return True + + def _is_dragging(self) -> bool: + """Returns True if currently dragging a point, entity, or CP.""" + return ( + self.dragged_point_id is not None + or self.dragged_entity is not None + or self.dragged_cp_bezier_id is not None + ) + + def _get_drag_start_world_pos(self) -> tuple[float, float]: + """Returns the world-space start position of the current drag.""" + if self.drag_point_start_pos is not None: + return self.drag_point_start_pos + if self.drag_start_model_pos is not None: + return self.drag_start_model_pos + return 0.0, 0.0 + + def get_active_shortcuts( + self, + ) -> list[tuple[str | list[str], str, Callable[[], bool] | None]]: + """Returns shortcuts available based on current tool state.""" + return [ + ("Shift", _("Constrain to Axis"), lambda: self._is_dragging()), + ("Tab", _("Toggle Magnetic Snap"), lambda: self._is_dragging()), + ( + ["Shift", "Doubleclick"], + _("Select Connected"), + lambda: not self._is_dragging(), + ), + ] + + def handle_key_event( + self, key: SketcherKey, shift: bool = False, ctrl: bool = False + ) -> bool: + """Handle key events for toggling magnetic snap.""" + return self.handle_snap_key_event(key, is_active=self._is_dragging()) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + hit_type, hit_obj = self.element.hittester.get_hit_data( + world_x, world_y, self.element + ) + logger.debug( + f"SelectTool.on_press: n_press={n_press}, hit_type='{hit_type}'" + ) + + is_shift_pressed = False + if self.element.canvas: + is_shift_pressed = self.element.canvas._shift_pressed + + # Shift+double click on entity to select all connected geometry + if n_press == 2 and hit_type == "entity" and is_shift_pressed: + logger.debug("Shift+double-click on entity detected.") + entity = cast(Entity, hit_obj) + self.element.selection.select_connected_entities( + entity.id, self.element.sketch.registry + ) + self.element.mark_dirty() + return True + + # Shift+double click on point to select all connected geometry + if n_press == 2 and hit_type == "point" and is_shift_pressed: + logger.debug("Shift+double-click on point detected.") + pid = cast(EntityID, hit_obj) + registry = self.element.sketch.registry + entity_ids = self.element.selection.entity_ids + entity_ids.clear() + for entity in registry.entities: + if pid in entity.get_point_ids(): + connected = registry.get_connected_entity_ids(entity.id) + entity_ids.extend(connected) + entity_ids[:] = list(set(entity_ids)) + self.element.selection.point_ids.clear() + self.element.selection.constraint_idx = None + self.element.selection.junction_pid = None + self.element.selection.changed.send(self.element.selection) + self.element.mark_dirty() + return True + + # Double click on entity to add/edit constraints. This is a terminal + # action, so returning True is correct. + if n_press == 2 and hit_type == "entity": + logger.debug("Double-click on entity detected.") + entity = cast(Entity, hit_obj) + if isinstance(entity, (Arc, Line, Circle)): + cmd = CreateOrEditConstraintCommand( + self.element.sketch, entity + ) + self.element.execute_command(cmd) + if cmd.constraint is not None: + logger.debug(f"Constraint for editing: {cmd.constraint}") + self.element.constraint_edit_requested.send( + self.element, constraint=cmd.constraint + ) + return True + elif isinstance(entity, TextBoxEntity): + text_tool = self.element.tools.get("text_box") + if isinstance(text_tool, TextBoxTool): + self.element.set_tool("text_box") + text_tool.start_editing(entity.id) + # Delegate the press to the new tool so it can + # initialize its own drag state, then return False to + # allow the gesture to continue into a drag. + text_tool.on_press(world_x, world_y, 1) + return False + return True + + # Double click edits constraint value + if n_press == 2 and hit_type == "constraint": + logger.debug("Double-click on constraint detected.") + idx = cast(int, hit_obj) + constraints = self.element.sketch.constraints + if constraints and idx < len(constraints): + constr = constraints[idx] + if isinstance( + constr, + ( + AngleConstraint, + DiameterConstraint, + DistanceConstraint, + RadiusConstraint, + ), + ): + logger.debug( + f"Emitting signal for constraint edit: {constr}" + ) + self.element.constraint_edit_requested.send( + self.element, constraint=constr + ) + return True + + # --- SINGLE CLICK LOGIC --- + # For single-clicks (n_press == 1), we must return False to allow the + # GTK gesture to continue listening for a potential second click. + # Returning True here would terminate the gesture recognition. + + is_multi = False + if self.element.canvas: + is_multi = ( + self.element.canvas._shift_pressed + or self.element.canvas._ctrl_pressed + ) + + if hit_type == "constraint": + idx = cast(int, hit_obj) + self.element.selection.select_constraint(idx, is_multi) + + # Also prepare for a drag if the constraint is point-like. + constraints = self.element.sketch.constraints + if constraints and idx < len(constraints): + constr = constraints[idx] + pid_to_drag = constr.get_draggable_point() + + if pid_to_drag is not None: + self._prepare_point_drag(pid_to_drag) + + self.element.mark_dirty() + return False + + if hit_type == "junction": + pid = cast(EntityID, hit_obj) + self.element.selection.select_junction(pid, is_multi) + self._prepare_point_drag(pid) + self.element.mark_dirty() + return False + + if hit_type in ("control_point_in", "control_point_out"): + _point_id, bezier_id, cp_index = hit_obj + self._prepare_control_point_drag(bezier_id, cp_index) + self.element.mark_dirty() + return False + + if hit_type == "point": + pid = cast(EntityID, hit_obj) + self.element.selection.select_point(pid, is_multi) + self._prepare_point_drag(pid) + self.element.mark_dirty() + return False + + elif hit_type == "entity": + entity = cast(Entity, hit_obj) + self.element.selection.select_entity(entity, is_multi) + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + self._prepare_entity_drag(entity, mx, my) + self.element.mark_dirty() + return False + + else: + # Click on empty space: Prepare for Box Selection + if not is_multi: + self.element.selection.clear() + self.drag_initial_selection = None + else: + # Store the selection state BEFORE the drag + self.drag_initial_selection = self.element.selection.copy() + + self.is_box_selecting = True + self.drag_start_world_pos = (world_x, world_y) + self.drag_current_world_pos = (world_x, world_y) + self.element.mark_dirty() + return False + + def on_drag(self, world_dx: float, world_dy: float): + if self._is_dragging() and self.element.canvas: + canvas = self.element.canvas + if canvas._shift_pressed: + if abs(world_dx) > abs(world_dy): + world_dy = 0.0 + else: + world_dx = 0.0 + + if self.dragged_cp_bezier_id is not None: + self._handle_control_point_drag(world_dx, world_dy) + elif self.dragged_point_id is not None: + self._handle_point_drag(world_dx, world_dy) + elif self.dragged_entity is not None: + self._handle_entity_drag(world_dx, world_dy) + elif self.is_box_selecting and self.drag_start_world_pos: + # Update current drag position and perform live selection + start_x, start_y = self.drag_start_world_pos + self.drag_current_world_pos = ( + start_x + world_dx, + start_y + world_dy, + ) + self._update_live_box_selection() + self.element.mark_dirty() + + def on_release(self, world_x: float, world_y: float): + # Handle the end of a Box Selection + if self.is_box_selecting: + # Selection is already live. Just clean up the drag state. + self.is_box_selecting = False + self.drag_start_world_pos = None + self.drag_current_world_pos = None + self.drag_initial_selection = None + self.element.mark_dirty() + return + + # Handle the end of a Control Point drag + if ( + self.dragged_cp_bezier_id is not None + and self.dragged_cp_index is not None + and self.drag_cp_start_offset is not None + ): + bezier_id = self.dragged_cp_bezier_id + cp_index = self.dragged_cp_index + bezier = self._safe_get_entity(bezier_id) + if isinstance(bezier, Bezier): + start_offset = self.drag_cp_start_offset + end_offset = bezier.cp1 if cp_index == 1 else bezier.cp2 + if start_offset != end_offset: + cmd = MoveControlPointCommand( + self.element.sketch, + bezier_id, + cp_index, + start_offset, + end_offset, + ) + self.element.execute_command(cmd) + + self.dragged_cp_bezier_id = None + self.dragged_cp_index = None + self.drag_cp_start_offset = None + self.element.mark_dirty() + return + + # If a point was dragged, create an undoable command + if self.dragged_point_id is not None and self.drag_point_start_pos: + p = self._safe_get_point(self.dragged_point_id) + if p: + start_x, start_y = self.drag_point_start_pos + end_x, end_y = p.x, p.y + + # Only create a command if the point actually moved + if abs(start_x - end_x) > 1e-6 or abs(start_y - end_y) > 1e-6: + # Pass the full snapshot (points + entities) + # We must copy because self.drag_initial_* are cleared + # below + snapshot = ( + self.drag_initial_positions.copy(), + self.drag_initial_entity_states.copy(), + ) + snap_constraints = self.build_snap_constraints( + self.dragged_point_id + ) + cmd = MovePointCommand( + self.element.sketch, + self.dragged_point_id, + (start_x, start_y), + (end_x, end_y), + snapshot=snapshot, + snap_constraints=snap_constraints, + ) + self.element.execute_command(cmd) + + self.current_snap_result = None + + # Clear all drag-related state + self.dragged_point_id = None + self.drag_point_start_pos = None + self.dragged_entity = None + self.drag_start_model_pos = None + self.drag_initial_positions.clear() + self.drag_initial_entity_states.clear() + self.drag_point_distances.clear() + self.drag_start_wt_inv = None + self.drag_start_ct_inv = None + + # Final solve, now allowing constraint status to be updated. + # Note: The command execution will have already triggered a solve. + # This solve is for the final state after releasing the mouse. + self.element.sketch.solve() + # Perform a final, guaranteed update to settle the bounds. + self.element.update_bounds_from_sketch() + self.element.mark_dirty() + + def on_hover_motion(self, world_x: float, world_y: float): + if self.is_box_selecting: + return + + hit_type, hit_obj = self.element.hittester.get_hit_data( + world_x, world_y, self.element + ) + + new_hover_pid = None + new_hover_constraint_idx = None + new_hover_junction_pid = None + new_hover_entity_id = None + + if hit_type == "point": + new_hover_pid = hit_obj + elif hit_type == "constraint": + new_hover_constraint_idx = hit_obj + elif hit_type == "junction": + new_hover_junction_pid = hit_obj + elif hit_type == "entity": + new_hover_entity_id = hit_obj.id + + if ( + self.hovered_point_id != new_hover_pid + or self.hovered_constraint_idx != new_hover_constraint_idx + or self.hovered_junction_pid != new_hover_junction_pid + or self.hovered_entity_id != new_hover_entity_id + ): + self.hovered_point_id = new_hover_pid + self.hovered_constraint_idx = new_hover_constraint_idx + self.hovered_junction_pid = new_hover_junction_pid + self.hovered_entity_id = new_hover_entity_id + self.element.mark_dirty() + + def draw_overlay(self, ctx: cairo.Context): + """Draws the selection box and snap lines.""" + if self.is_box_selecting: + self._draw_selection_box(ctx) + + if self._is_dragging(): + self.draw_snap_feedback(ctx, self.element) + + self.draw_debug_snap_lines(ctx, self.element) + + def _draw_selection_box(self, ctx: cairo.Context): + if not self.drag_start_world_pos or not self.drag_current_world_pos: + return + + if not self.element.canvas: + return + + # Transform World Coordinates to Screen Coordinates + view_transform = self.element.canvas.view_transform + start_px = view_transform.transform_point(self.drag_start_world_pos) + curr_px = view_transform.transform_point(self.drag_current_world_pos) + + x, y = start_px + w = curr_px[0] - x + h = curr_px[1] - y + + ctx.save() + + # Draw a blue selection box. + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.2) # Selection Fill: Blue + ctx.set_dash([4, 2]) + ctx.rectangle(x, y, w, h) + ctx.fill_preserve() + + # Stroke border + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.7) # Selection Border: Blue + ctx.set_line_width(1.0) + ctx.stroke() + + ctx.restore() + + # --- Drag Logic Handlers --- + + def _update_live_box_selection(self): + """ + Calculates and updates the selection based on the current drag box. + """ + if not self.drag_start_world_pos or not self.drag_current_world_pos: + return + + # Calculate box in world coords + start_wx, start_wy = self.drag_start_world_pos + end_wx, end_wy = self.drag_current_world_pos + + # Convert world box to Model Space for query + mx1, my1 = self.element.hittester.screen_to_model( + start_wx, start_wy, self.element + ) + mx2, my2 = self.element.hittester.screen_to_model( + end_wx, end_wy, self.element + ) + model_min_x = min(mx1, mx2) + model_max_x = max(mx1, mx2) + model_min_y = min(my1, my2) + model_max_y = max(my1, my2) + + # Perform a "crossing" selection. + points_hit, entities_hit = self.element.hittester.get_objects_in_rect( + model_min_x, + model_min_y, + model_max_x, + model_max_y, + self.element, + strict_containment=False, + ) + + is_additive = self.drag_initial_selection is not None + + if is_additive: + # Restore the pre-drag state + initial_state = self.drag_initial_selection + if initial_state: + self.element.selection.point_ids = initial_state.point_ids[:] + self.element.selection.entity_ids = initial_state.entity_ids[:] + + # Add newly found items + for pid in points_hit: + if pid not in self.element.selection.point_ids: + self.element.selection.point_ids.append(pid) + + for eid in entities_hit: + if eid not in self.element.selection.entity_ids: + self.element.selection.entity_ids.append(eid) + else: + # Not additive, so the selection is exactly what's in the box + self.element.selection.point_ids = points_hit + self.element.selection.entity_ids = entities_hit + # Clear other selection types + self.element.selection.constraint_idx = None + self.element.selection.junction_pid = None + + def _get_model_delta(self, world_dx: float, world_dy: float) -> GeoPoint: + """Safely converts a world-space delta to a model-space delta.""" + if self.drag_start_wt_inv is None or self.drag_start_ct_inv is None: + return 0.0, 0.0 + + wt_vec = self.drag_start_wt_inv.without_translation() + ct_vec = self.drag_start_ct_inv.without_translation() + + ldx, ldy = wt_vec.transform_vector((world_dx, world_dy)) + mdx, mdy = ct_vec.transform_vector((ldx, ldy)) + return mdx, mdy + + def _handle_point_drag(self, world_dx: float, world_dy: float): + """Logic for dragging a single point.""" + if self.dragged_point_id is None or self.drag_point_start_pos is None: + return + + mdx, mdy = self._get_model_delta(world_dx, world_dy) + start_x, start_y = self.drag_point_start_pos + target_x = start_x + mdx + target_y = start_y + mdy + + if self.magnetic_snap_enabled: + coincident_group = self.element.sketch.get_coincident_points( + self.dragged_point_id + ) + target_x, target_y = self.query_snap_for_drag( + self.element, + target_x, + target_y, + dragged_point_ids=coincident_group, + initial_positions=self.drag_initial_positions, + ) + else: + self.current_snap_result = None + + drag_constraints = [] + + # Ask the sketch model for the group of points that must move together. + coincident_group = self.element.sketch.get_coincident_points( + self.dragged_point_id + ) + + # Check if any point in the coincident group is fixed - if so, + # the dragged point can't move and we shouldn't apply rigid drag. + any_fixed = False + for pid in coincident_group: + p = self._safe_get_point(pid) + if p and p.fixed: + any_fixed = True + break + + # Apply a strong drag constraint to ALL points in the coincident + # group (they all move to the same target position). + for pid in coincident_group: + drag_constraints.append( + DragConstraint(pid, target_x, target_y, weight=0.1) + ) + + # Also include rigidly connected points (e.g., ellipse center drag + # should move all ellipse points together). These move by the same + # delta from their initial positions, not to the same target. + # Skip this if the dragged point is fixed (coincident with fixed). + dragged_group = coincident_group + if not any_fixed: + rigid_points = ( + self.element.sketch.registry.get_rigidly_connected_points( + self.dragged_point_id + ) + ) + for pid in rigid_points: + if pid in coincident_group: + continue + initial_pos = self.drag_initial_positions.get(pid) + if initial_pos: + p = self._safe_get_point(pid) + if p and not p.fixed: + rigid_target_x = initial_pos[0] + mdx + rigid_target_y = initial_pos[1] + mdy + drag_constraints.append( + DragConstraint( + pid, rigid_target_x, rigid_target_y, weight=0.1 + ) + ) + dragged_group = coincident_group | set(rigid_points) + + base_hold_weight = 0.01 + max_hops = max( + (d for d in self.drag_point_distances.values() if d > 0), default=1 + ) + for pid, pos in self.drag_initial_positions.items(): + # Skip any point that is part of the actively dragged group. + if pid in dragged_group: + continue + + p = self._safe_get_point(pid) + if not p or p.fixed: + continue + hops = self.drag_point_distances.get(pid, -1) + weight = 0 + if hops == -1: + weight = base_hold_weight + elif hops > 0: + weight = base_hold_weight * (hops / max_hops) + if weight > 0: + drag_constraints.append( + DragConstraint(pid, pos[0], pos[1], weight=weight) + ) + + self.element.sketch.solve( + extra_constraints=drag_constraints, update_constraint_status=False + ) + self.element.mark_dirty() + + def _handle_entity_drag(self, world_dx: float, world_dy: float): + """Logic for dragging one or more selected entities.""" + mdx, mdy = self._get_model_delta(world_dx, world_dy) + if mdx == 0 and mdy == 0: + return + + # 1. Identify all unique points from all selected entities + points_to_drag = set() + first_entity_point = None + for eid in self.element.selection.entity_ids: + entity = self.element.sketch.registry.get_entity(eid) + if entity: + entity_points = entity.get_point_ids() + points_to_drag.update(entity_points) + if first_entity_point is None and entity_points: + first_entity_point = entity_points[0] + + # 2. Magnetic snap: use first entity point as reference + if self.magnetic_snap_enabled and first_entity_point is not None: + initial_pos = self.drag_initial_positions.get(first_entity_point) + if initial_pos: + ref_target_x = initial_pos[0] + mdx + ref_target_y = initial_pos[1] + mdy + snapped_x, snapped_y = self.query_snap_for_drag( + self.element, + ref_target_x, + ref_target_y, + dragged_point_ids=points_to_drag, + dragged_entity_ids=set(self.element.selection.entity_ids), + initial_positions=self.drag_initial_positions, + ) + if ( + self.current_snap_result + and self.current_snap_result.snapped + ): + mdx = snapped_x - initial_pos[0] + mdy = snapped_y - initial_pos[1] + else: + self.current_snap_result = None + + drag_constraints = [] + strong_drag_weight = 1.0 + for pid in points_to_drag: + p = self._safe_get_point(pid) + if not p or p.fixed: + continue + + initial_pos = self.drag_initial_positions.get(pid) + if initial_pos: + target_x = initial_pos[0] + mdx + target_y = initial_pos[1] + mdy + drag_constraints.append( + DragConstraint( + pid, target_x, target_y, weight=strong_drag_weight + ) + ) + + # 3. Add weak "holding" constraints for all other points + hold_weight = 0.01 + for pid, pos in self.drag_initial_positions.items(): + if pid in points_to_drag: + continue + p = self._safe_get_point(pid) + if not p or p.fixed: + continue + drag_constraints.append( + DragConstraint(pid, pos[0], pos[1], weight=hold_weight) + ) + + # 4. Solve and update + self.element.sketch.solve( + extra_constraints=drag_constraints, update_constraint_status=False + ) + self.element.mark_dirty() + + # --- Drag Preparation --- + + def _prepare_point_drag(self, pid: EntityID): + """Sets up state for dragging a single point.""" + self.dragged_point_id = pid + self.dragged_entity = None # Mutually exclusive + p = self._safe_get_point(pid) + if not p: + return + + self.drag_point_start_pos = (p.x, p.y) + self._cache_drag_start_state() + self._calculate_geometric_hops(pid) + + def _prepare_entity_drag( + self, entity: Entity, model_x: float, model_y: float + ): + """Sets up state for dragging an entity (or group of entities).""" + self.dragged_entity = entity + self.dragged_point_id = None # Mutually exclusive + self.drag_start_model_pos = (model_x, model_y) + self._cache_drag_start_state() + + def _prepare_control_point_drag(self, bezier_id: EntityID, cp_index: int): + """Sets up state for dragging a control point. + + Does not clear selection. + """ + self.dragged_cp_bezier_id = bezier_id + self.dragged_cp_index = cp_index + self.dragged_point_id = None + self.dragged_entity = None + bezier = self._safe_get_entity(bezier_id) + if not isinstance(bezier, Bezier): + return + if cp_index == 1: + self.drag_cp_start_offset = bezier.cp1 + else: + self.drag_cp_start_offset = bezier.cp2 + self._cache_drag_start_state() + + def _handle_control_point_drag(self, world_dx: float, world_dy: float): + """Logic for dragging a control point offset.""" + if self.dragged_cp_bezier_id is None or self.dragged_cp_index is None: + return + bezier = self._safe_get_entity(self.dragged_cp_bezier_id) + if not isinstance(bezier, Bezier): + return + mdx, mdy = self._get_model_delta(world_dx, world_dy) + if self.drag_cp_start_offset is None: + base_x, base_y = 0.0, 0.0 + else: + base_x, base_y = self.drag_cp_start_offset + new_offset = (base_x + mdx, base_y + mdy) + + if self.dragged_cp_index == 1: + bezier.cp1 = new_offset + point_id = bezier.start_idx + else: + bezier.cp2 = new_offset + point_id = bezier.end_idx + + p = self._safe_get_point(point_id) + if p is not None: + registry = self.element.sketch.registry + p.apply_constraint( + registry, bezier, self.dragged_cp_index, self.element.sketch + ) + + self.element.mark_dirty() + + def _cache_drag_start_state(self): + """ + Caches transforms and ALL state (points + entities) at start of drag. + """ + self.drag_start_wt_inv = self.element.get_world_transform().invert() + self.drag_start_ct_inv = self.element.content_transform.invert() + + # Capture Points + self.drag_initial_positions = { + pt.id: (pt.x, pt.y) for pt in self.element.sketch.registry.points + } + + # Capture Entity States + self.drag_initial_entity_states = {} + for e in self.element.sketch.registry.entities: + state = e.get_state() + if state is not None: + self.drag_initial_entity_states[e.id] = state + + def _safe_get_point(self, pid: EntityID): + try: + return self.element.sketch.registry.get_point(pid) + except IndexError: + return None + + def _safe_get_entity(self, eid: EntityID): + try: + return self.element.sketch.registry.get_entity(eid) + except IndexError: + return None + + def _calculate_geometric_hops(self, start_pid: EntityID): + """ + Calculates distance (in entity hops) from a start point to all others + using BFS. Results are stored in self.drag_point_distances. + """ + registry = self.element.sketch.registry + if not registry.points: + self.drag_point_distances = {} + return + + adj: dict[EntityID, list[EntityID]] = { + p.id: [] for p in registry.points + } + for entity in registry.entities: + if isinstance(entity, Line): + p1, p2 = entity.p1_idx, entity.p2_idx + if p1 in adj and p2 in adj: + adj[p1].append(p2) + adj[p2].append(p1) + elif isinstance(entity, Arc): + s, e, c = entity.start_idx, entity.end_idx, entity.center_idx + if s in adj and e in adj and c in adj: + adj[s].extend([e, c]) + adj[e].extend([s, c]) + adj[c].extend([s, e]) + elif isinstance(entity, Circle): + c, r = entity.center_idx, entity.radius_pt_idx + if c in adj and r in adj: + adj[c].append(r) + adj[r].append(c) + + q = [(start_pid, 0)] + distances = {p.id: -1 for p in registry.points} + if start_pid in distances: + distances[start_pid] = 0 + + head = 0 + while head < len(q): + curr_pid, dist = q[head] + head += 1 + + for neighbor_pid in adj.get(curr_pid, []): + if distances.get(neighbor_pid) == -1: # Unvisited + distances[neighbor_pid] = dist + 1 + q.append((neighbor_pid, dist + 1)) + + self.drag_point_distances = distances diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/snap_mixin.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/snap_mixin.py new file mode 100644 index 000000000..16bd8d5f4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/snap_mixin.py @@ -0,0 +1,571 @@ +import os +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo.types import Point as GeoPoint + +from ...core.constraints import ( + CoincidentConstraint, + HorizontalConstraint, + PointOnLineConstraint, + SymmetryConstraint, + VerticalConstraint, +) +from ...core.entities import Arc, Circle, Line, Point +from ...core.snap import ( + SNAP_LINE_STYLES, + DragContext, + SnapLineType, + SnapResult, +) +from ...core.snap.types import SnapLineStyle +from .base import SketcherKey + +if TYPE_CHECKING: + from ..sketchelement import SketchElement + +DEBUG_SNAPPING = os.environ.get("DEBUG_SNAPPING", "").lower() in ( + "1", + "true", + "yes", +) + + +class SnapMixin: + """Mixin providing snap functionality for sketch tools. + + This mixin provides: + - Magnetic snap querying during hover and creation + - Snap visual feedback rendering + - Constraint creation from snap results + - Tab key toggle for magnetic snap + + Usage: + class MyTool(SnapMixin, SketchTool): + def on_hover_motion(self, world_x, world_y): + mx, my = self.element.hittester.screen_to_model(...) + mx, my, snap_result = self.query_snap_for_creation( + self.element, mx, my + ) + self.current_snap_result = snap_result + # Use mx, my for snapped position + + def draw_overlay(self, ctx): + self.draw_snap_feedback(ctx, self.element) + """ + + current_snap_result: SnapResult | None = None + magnetic_snap_enabled: bool = True + + def toggle_magnetic_snap(self) -> None: + """Toggle magnetic snap on/off.""" + self.magnetic_snap_enabled = not self.magnetic_snap_enabled + + def is_snap_active(self) -> bool: + """Check if magnetic snap is currently active and has a result.""" + return ( + self.magnetic_snap_enabled and self.current_snap_result is not None + ) + + def query_snap_for_creation( + self, + element: "SketchElement", + model_x: float, + model_y: float, + exclude_points: set[int] | None = None, + ) -> GeoPoint: + """Query snap engine for geometry creation. + + This is used during hover/press in creation tools to get + snapped positions. + + Args: + element: The SketchElement + model_x: Model X coordinate + model_y: Model Y coordinate + exclude_points: Optional set of point IDs to exclude from snapping + + Returns: + Snapped position as GeoPoint (or original position if no snap) + """ + if not self.magnetic_snap_enabled: + self.current_snap_result = None + return (model_x, model_y) + + context = DragContext( + dragged_point_ids=exclude_points or set(), + dragged_entity_ids=set(), + initial_positions={}, + ) + + snap_result = self._query_snap_engine( + element, (model_x, model_y), context + ) + + if snap_result.snapped: + self.current_snap_result = snap_result + return snap_result.position + else: + self.current_snap_result = None + return (model_x, model_y) + + def query_snap_for_drag( + self, + element: "SketchElement", + model_x: float, + model_y: float, + dragged_point_ids: set[int] | None = None, + dragged_entity_ids: set[int] | None = None, + initial_positions: dict | None = None, + ) -> GeoPoint: + """Query snap engine during drag operations. + + Args: + element: The SketchElement + model_x: Model X coordinate (target position) + model_y: Model Y coordinate (target position) + dragged_point_ids: Points being dragged (excluded from snap) + dragged_entity_ids: Entities being dragged (excluded from snap) + initial_positions: Initial positions for drag context + + Returns: + Snapped position as GeoPoint (or original position if no snap) + """ + if not self.magnetic_snap_enabled: + self.current_snap_result = None + return (model_x, model_y) + + context = DragContext( + dragged_point_ids=dragged_point_ids or set(), + dragged_entity_ids=dragged_entity_ids or set(), + initial_positions=initial_positions or {}, + ) + + snap_result = self._query_snap_engine( + element, (model_x, model_y), context + ) + + if snap_result.snapped: + self.current_snap_result = snap_result + return snap_result.position + else: + self.current_snap_result = None + return (model_x, model_y) + + def _query_snap_engine( + self, + element: "SketchElement", + position: GeoPoint, + context: DragContext, + ) -> SnapResult: + """Internal method to query the snap engine.""" + if element.canvas: + scale_x, _ = element.canvas.view_transform.get_scale() + if scale_x > 0: + element.snap_engine.threshold = 5.0 / scale_x + return element.snap_engine.query( + element.sketch.registry, position, context + ) + + def build_snap_constraints( + self, + point_id: int, + *, + end_pid: int | None = None, + snapped_to_existing: bool = False, + existing_constraints: list[Any] | None = None, + ) -> list[Any]: + """Build constraints from the current snap result. + + Call this when finalizing geometry to create persistent constraints + from the snap operation. + + Args: + point_id: The point ID that was snapped + end_pid: Committed end point ID for axis auto-constraints + snapped_to_existing: True when point was snapped to an + existing point (skips snap-only constraints) + existing_constraints: Sketch constraints to skip if + already present (avoids duplicates) + + Returns: + List of constraints to add to the sketch + """ + constraints: list[Any] = [] + result = self.current_snap_result + if not result: + return constraints + + pid = end_pid if end_pid is not None else point_id + + if not snapped_to_existing and result.primary_snap_point: + sp = result.primary_snap_point + if sp.line_type == SnapLineType.MIDPOINT: + constraints.extend(self._build_midpoint_constraint(sp, pid)) + elif sp.line_type == SnapLineType.ENTITY_POINT: + constraints.extend(self._build_coincident_constraint(sp, pid)) + elif sp.line_type == SnapLineType.ON_ENTITY: + constraints.extend(self._build_on_entity_constraint(sp, pid)) + + if end_pid is not None: + constraints.extend( + self._build_axis_constraints( + result, end_pid, existing_constraints + ) + ) + + return constraints + + def _build_midpoint_constraint(self, sp, pid): + if not isinstance(sp.source, Line): + return [] + line = sp.source + if pid in (line.p1_idx, line.p2_idx): + return [] + return [ + SymmetryConstraint( + p1=line.p1_idx, + p2=line.p2_idx, + center=pid, + ) + ] + + def _build_coincident_constraint(self, sp, pid): + if not isinstance(sp.source, Point): + return [] + if sp.source.id == pid: + return [] + return [CoincidentConstraint(p1=pid, p2=sp.source.id)] + + def _build_on_entity_constraint(self, sp, pid): + if not isinstance(sp.source, (Line, Arc, Circle)): + return [] + return [ + PointOnLineConstraint( + point_id=pid, + shape_id=sp.source.id, + ) + ] + + def _build_axis_constraints(self, result, end_pid, existing): + constraints: list[Any] = [] + seen: set = set() + for ec in existing or []: + if isinstance(ec, (HorizontalConstraint, VerticalConstraint)): + seen.add( + ( + type(ec).__name__, + min(ec.p1, ec.p2), + max(ec.p1, ec.p2), + ) + ) + + for sl in result.snap_lines: + if not isinstance(sl.source, Point): + continue + src_id = sl.source.id + if src_id == end_pid: + continue + key = ( + "HorizontalConstraint" + if sl.is_horizontal + else "VerticalConstraint", + min(src_id, end_pid), + max(src_id, end_pid), + ) + if key in seen: + continue + seen.add(key) + if sl.is_horizontal: + constraints.append(HorizontalConstraint(src_id, end_pid)) + else: + constraints.append(VerticalConstraint(src_id, end_pid)) + + return constraints + + def get_snapped_point_id(self) -> int | None: + """Get the point ID if snapped to an existing point. + + Returns: + Point ID if snapped to ENTITY_POINT, None otherwise + """ + if ( + self.current_snap_result + and self.current_snap_result.primary_snap_point + ): + sp = self.current_snap_result.primary_snap_point + if sp.line_type == SnapLineType.ENTITY_POINT and isinstance( + sp.source, Point + ): + return sp.source.id + return None + + def clear_snap_result(self) -> None: + """Clear the current snap result.""" + self.current_snap_result = None + + def handle_snap_key_event( + self, key: SketcherKey, is_active: bool = True + ) -> bool: + """Handle key events for snap toggle. + + Args: + key: The key event + is_active: Whether the tool is in an active state (e.g., dragging) + + Returns: + True if the key was handled + """ + if key == SketcherKey.TAB and is_active: + self.toggle_magnetic_snap() + return True + return False + + def draw_snap_feedback( + self, + ctx: cairo.Context, + element: "SketchElement", + ) -> None: + """Draw snap lines and snap point indicators. + + Args: + ctx: Cairo context for drawing + element: The SketchElement + """ + if not self.current_snap_result or not element.canvas: + return + + to_screen = element.hittester.get_model_to_screen_transform(element) + canvas_width = element.canvas.get_width() + canvas_height = element.canvas.get_height() + + ctx.save() + + sp = self.current_snap_result.primary_snap_point + skip_snap_lines = sp is not None and sp.line_type in ( + SnapLineType.ENTITY_POINT, + SnapLineType.MIDPOINT, + SnapLineType.ON_ENTITY, + ) + + if not skip_snap_lines: + for snap_line in self.current_snap_result.snap_lines: + style = snap_line.style + ctx.set_source_rgba(*style.color) + if style.dash: + ctx.set_dash(style.dash) + else: + ctx.set_dash([]) + ctx.set_line_width(style.line_width) + + if snap_line.is_horizontal: + _, screen_y = to_screen.transform_point( + (0, snap_line.coordinate) + ) + ctx.move_to(0, screen_y) + ctx.line_to(canvas_width, screen_y) + else: + screen_x, _ = to_screen.transform_point( + (snap_line.coordinate, 0) + ) + ctx.move_to(screen_x, 0) + ctx.line_to(screen_x, canvas_height) + ctx.stroke() + + if self.current_snap_result.primary_snap_point: + self._draw_snap_point_indicator(ctx, element, to_screen) + + ctx.restore() + + def _draw_snap_point_indicator( + self, + ctx: cairo.Context, + element: "SketchElement", + to_screen, + ) -> None: + """Draw the indicator for the primary snap point.""" + from ...core.constraints.symmetry import draw_symmetry_arrows + + if not self.current_snap_result: + return + + sp = self.current_snap_result.primary_snap_point + if not sp: + return + + sx, sy = to_screen.transform_point((sp.x, sp.y)) + + if sp.line_type == SnapLineType.EQUIDISTANT and sp.spacing: + self._draw_equidistant_indicator( + ctx, element, to_screen, sp, sx, sy + ) + elif sp.line_type == SnapLineType.MIDPOINT: + if isinstance(sp.source, Line): + line = sp.source + p1 = element.sketch.registry.get_point(line.p1_idx) + p2 = element.sketch.registry.get_point(line.p2_idx) + if p1 and p2: + s1 = to_screen.transform_point((p1.x, p1.y)) + s2 = to_screen.transform_point((p2.x, p2.y)) + style = SNAP_LINE_STYLES.get(sp.line_type, SnapLineStyle()) + ctx.set_source_rgba(*style.color) + ctx.set_dash([]) + ctx.set_line_width(1.5) + draw_symmetry_arrows(ctx, s1, s2) + ctx.stroke() + elif sp.line_type == SnapLineType.ENTITY_POINT: + style = SNAP_LINE_STYLES.get(sp.line_type, SnapLineStyle()) + ctx.set_source_rgba(*style.color) + ctx.set_dash([]) + ctx.set_line_width(2.0) + ctx.new_path() + ctx.arc(sx, sy, 8, 0, 2 * 3.14159) + ctx.stroke() + elif sp.line_type == SnapLineType.ON_ENTITY: + if sp.source is not None: + style = SNAP_LINE_STYLES.get( + SnapLineType.ON_ENTITY, SnapLineStyle() + ) + ctx.save() + ctx.transform(cairo.Matrix(*to_screen.for_cairo())) + scale_x, _ = to_screen.get_scale() + scale = scale_x if scale_x > 1e-9 else 1.0 + element.renderer.draw_entity_highlight( + ctx, sp.source, style.color, line_width=3.0 / scale + ) + ctx.restore() + else: + ctx.set_source_rgba(1.0, 0.0, 1.0, 0.8) + ctx.new_path() + ctx.arc(sx, sy, 5, 0, 2 * 3.14159) + ctx.fill() + + def _draw_equidistant_indicator( + self, + ctx: cairo.Context, + element: "SketchElement", + to_screen, + sp, + sx: float, + sy: float, + ) -> None: + """Draw equidistant snap indicator with double arrows.""" + style = SNAP_LINE_STYLES.get(sp.line_type, SnapLineStyle()) + ctx.set_source_rgba(*style.color) + ctx.set_dash([]) + ctx.set_line_width(2.0) + scale_x, _ = to_screen.get_scale() + head_len = min(sp.spacing * scale_x * 0.15, 8) if sp.spacing else 8 + head_width = 4 + tick_len = 6 + + coords = ( + sp.pattern_coords + if sp.pattern_coords + else (sp.y if sp.is_horizontal else sp.x,) + ) + + def draw_double_arrow(ctx, x1, y1, x2, y2, head_len, head_width): + dx = x2 - x1 + dy = y2 - y1 + length = (dx * dx + dy * dy) ** 0.5 + if length < 1e-6: + return + ux, uy = dx / length, dy / length + ctx.move_to(x1, y1) + ctx.line_to(x2, y2) + for px, py, direction in [(x1, y1, -1), (x2, y2, 1)]: + hx = px - direction * ux * head_len + hy = py - direction * uy * head_len + ctx.move_to(hx - uy * head_width, hy + ux * head_width) + ctx.line_to(px, py) + ctx.line_to(hx + uy * head_width, hy - ux * head_width) + + if sp.is_horizontal: + axis_x = sp.axis_coord if sp.axis_coord is not None else sp.x + for i in range(len(coords) - 1): + y1, y2 = coords[i], coords[i + 1] + if sp.spacing and abs(y2 - y1 - sp.spacing) > 0.5: + continue + sy1 = to_screen.transform_point((axis_x, y1))[1] + sy2 = to_screen.transform_point((axis_x, y2))[1] + local_sx = to_screen.transform_point((axis_x, sp.y))[0] + draw_double_arrow( + ctx, local_sx, sy1, local_sx, sy2, head_len, head_width + ) + ctx.move_to(local_sx - tick_len, sy1) + ctx.line_to(local_sx + tick_len, sy1) + ctx.move_to(local_sx - tick_len, sy2) + ctx.line_to(local_sx + tick_len, sy2) + else: + axis_y = sp.axis_coord if sp.axis_coord is not None else sp.y + for i in range(len(coords) - 1): + x1, x2 = coords[i], coords[i + 1] + if sp.spacing and abs(x2 - x1 - sp.spacing) > 0.5: + continue + sx1 = to_screen.transform_point((x1, axis_y))[0] + sx2 = to_screen.transform_point((x2, axis_y))[0] + local_sy = to_screen.transform_point((sp.x, axis_y))[1] + draw_double_arrow( + ctx, sx1, local_sy, sx2, local_sy, head_len, head_width + ) + ctx.move_to(sx1, local_sy - tick_len) + ctx.line_to(sx1, local_sy + tick_len) + ctx.move_to(sx2, local_sy - tick_len) + ctx.line_to(sx2, local_sy + tick_len) + ctx.stroke() + + def draw_debug_snap_lines( + self, + ctx: cairo.Context, + element: "SketchElement", + ) -> None: + """Draw all available snap lines for debugging.""" + if not DEBUG_SNAPPING or not element.canvas: + return + + to_screen = element.hittester.get_model_to_screen_transform(element) + canvas_width = element.canvas.get_width() + canvas_height = element.canvas.get_height() + + center_x = canvas_width / 2 + center_y = canvas_height / 2 + view_transform = element.canvas.view_transform + model_center = view_transform.invert().transform_point( + (center_x, center_y) + ) + + old_threshold = element.snap_engine.threshold + element.snap_engine.threshold = 1e9 + snap_lines = element.snap_engine.get_visible_snap_lines( + element.sketch.registry, + model_center, + DragContext(), + ) + element.snap_engine.threshold = old_threshold + + ctx.save() + for snap_line in snap_lines: + style = snap_line.style + ctx.set_source_rgba(*style.color) + if style.dash: + ctx.set_dash(style.dash) + else: + ctx.set_dash([]) + ctx.set_line_width(style.line_width) + + if snap_line.is_horizontal: + _, screen_y = to_screen.transform_point( + (0, snap_line.coordinate) + ) + ctx.move_to(0, screen_y) + ctx.line_to(canvas_width, screen_y) + else: + screen_x, _ = to_screen.transform_point( + (snap_line.coordinate, 0) + ) + ctx.move_to(screen_x, 0) + ctx.line_to(screen_x, canvas_height) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/straighten_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/straighten_tool.py new file mode 100644 index 000000000..82e5b0ce2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/straighten_tool.py @@ -0,0 +1,55 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Union + +from ...core.commands import StraightenBezierCommand +from ...core.entities import Bezier, Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + + +class StraightenTool(SketchTool): + ICON = "sketch-line-symbolic" + LABEL = _("Straighten") + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + if not isinstance(target, Bezier): + return False + return not target.is_line(self.element.sketch.registry) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor or not sel.entity_ids: + return + + bezier_ids = [] + for entity_id in sel.entity_ids: + entity = sketch.registry.get_entity(entity_id) + if isinstance(entity, Bezier): + bezier_ids.append(entity_id) + + if not bezier_ids: + return + + for bezier_id in bezier_ids: + cmd = StraightenBezierCommand(sketch, bezier_id) + self.element.execute_command(cmd) + + self.element.set_tool("select") diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/symmetry_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/symmetry_constraint_tool.py new file mode 100644 index 000000000..45bc45e04 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/symmetry_constraint_tool.py @@ -0,0 +1,73 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + SymmetryConstraintCommand, +) +from ...core.constraints import SymmetryConstraint +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class SymmetryConstraintTool(SketchTool): + ICON = "sketch-constrain-symmetric-symbolic" + LABEL = _("Symmetry") + SHORTCUTS: ClassVar[list[str]] = ["s"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return SymmetryConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + params = SymmetryConstraintCommand.determine_constraint_params( + sel.point_ids, sel.entity_ids + ) + + if params is None: + return + + if params.center_id is not None: + constr = SymmetryConstraint( + params.p1_id, params.p2_id, center=params.center_id + ) + else: + constr = SymmetryConstraint( + params.p1_id, params.p2_id, axis=params.axis_id + ) + + cmd = AddItemsCommand( + sketch, _("Add Symmetry Constraint"), constraints=[constr] + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/tangent_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/tangent_constraint_tool.py new file mode 100644 index 000000000..b855001a7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/tangent_constraint_tool.py @@ -0,0 +1,66 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import ( + AddItemsCommand, + TangentConstraintCommand, +) +from ...core.constraints import TangentConstraint +from ...core.entities import Entity, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class TangentConstraintTool(SketchTool): + ICON = "sketch-constrain-tangential-symbolic" + LABEL = _("Tangent") + SHORTCUTS: ClassVar[list[str]] = ["t"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return TangentConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + params = TangentConstraintCommand.identify_entities( + sketch.registry, sel.entity_ids + ) + + if params is None: + logger.warning("Select 1 Line and 1 Arc/Circle for Tangent.") + return + + constr = TangentConstraint(params.line_id, params.shape_id) + cmd = AddItemsCommand( + sketch, _("Add Tangent Constraint"), constraints=[constr] + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/text_box_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/text_box_tool.py new file mode 100644 index 000000000..ff9008c77 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/text_box_tool.py @@ -0,0 +1,1030 @@ +import logging +import math +from enum import Enum, auto +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, cast + +import cairo +from blinker import Signal +from gi.repository import Gdk, GLib +from raygeo.geo.shape.polygon import is_point_inside_polygon +from raygeo.geo.shape.text import text_to_geometry + +from rayforge.image.geo_renderer import geometry_to_cairo + +from ...core.commands import TextBoxCommand +from ...core.commands.live_text_edit import LiveTextEditCommand +from ...core.commands.text_property import ModifyTextPropertyCommand +from ...core.constraints import ( + AspectRatioConstraint, + HorizontalConstraint, + VerticalConstraint, +) +from ...core.entities import Line, Point, TextBoxEntity +from ...core.types import EntityID +from .base import SketcherKey, SketchTool + +if TYPE_CHECKING: + from ..sketchcanvas import SketchCanvas + +logger = logging.getLogger(__name__) + + +class TextBoxState(Enum): + """Defines the state of the TextBoxTool.""" + + IDLE = auto() + EDITING = auto() + + +class TextBoxTool(SketchTool): + ICON = "sketch-text-symbolic" + LABEL = _("Text Box") + SHORTCUTS: ClassVar[list[str]] = ["gt"] + CURSOR_ICON = "sketch-text-symbolic" + EDITING_SHORTCUTS: ClassVar[list[str]] = [] + + def __init__(self, element): + super().__init__(element) + self.state = TextBoxState.IDLE + self.editing_entity_id: EntityID | None = None + self.text_buffer = "" + self.cursor_pos: int = 0 + self.cursor_visible = True + self.is_hovering = False + self.live_edit_cmd: LiveTextEditCommand | None = None + self._is_new_text_box = False + + # Text selection state + self.selection_start: int | None = None + self.selection_end: int | None = None + self.is_drag_selecting = False + self.drag_start_pos: int = 0 + self._drag_start_world_x: float = 0.0 + self._drag_start_world_y: float = 0.0 + + # Signals for the UI layer to manage timers, etc. + self.editing_started = Signal() + self.editing_finished = Signal() + self.cursor_moved = Signal() + + def is_available(self, target, target_type) -> bool: + return target is None + + def shortcut_is_active(self) -> bool: + return True + + def _get_selection_range(self) -> tuple[int, int]: + """Returns normalized selection range (start, end).""" + if self.selection_start is None or self.selection_end is None: + return 0, 0 + return ( + min(self.selection_start, self.selection_end), + max(self.selection_start, self.selection_end), + ) + + def get_selected_text(self) -> str: + """Returns the currently selected text.""" + start, end = self._get_selection_range() + if start == end: + return "" + return self.text_buffer[start:end] + + def clear_selection(self): + """Clears the current selection.""" + self.selection_start = None + self.selection_end = None + + def set_selection(self, start: int, end: int): + """Sets the selection range.""" + self.selection_start = start + self.selection_end = end + + def extend_selection(self, new_pos: int): + """Extends the selection to a new position.""" + if self.selection_start is None: + self.selection_start = self.cursor_pos + self.selection_end = new_pos + + def start_editing(self, entity_id: EntityID): + """Public method to begin editing an existing text box.""" + entity = self.element.sketch.registry.get_entity(entity_id) + if not isinstance(entity, TextBoxEntity): + return + + self.editing_entity_id = entity_id + self.text_buffer = entity.content + self.cursor_pos = len(self.text_buffer) # Cursor at the end + self.clear_selection() + self.state = TextBoxState.EDITING + self.cursor_visible = True + self.element.mark_dirty() + self.editing_started.send(self) + + self.live_edit_cmd = LiveTextEditCommand( + self.element.sketch, entity_id + ) + self.live_edit_cmd.capture_state(self.text_buffer, self.cursor_pos) + + def on_deactivate(self): + if self.state == TextBoxState.EDITING: + self.editing_finished.send(self) + + if self.live_edit_cmd: + self.element.execute_command(self.live_edit_cmd) + self.live_edit_cmd = None + + self._finalize_edit() + + if self._is_new_text_box: + self._is_new_text_box = False + if self.element.editor: + self.element.editor.history_manager.end_transaction() + + self.state = TextBoxState.IDLE + self.editing_entity_id = None + self.text_buffer = "" + self.cursor_pos = 0 + self.clear_selection() + self.is_drag_selecting = False + self.is_hovering = False + + def toggle_cursor_visibility(self): + """Called by the UI timer to toggle the cursor's visual state.""" + if self.state == TextBoxState.EDITING: + self.cursor_visible = not self.cursor_visible + self.element.mark_dirty() + + 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 + ) + + if self.state == TextBoxState.IDLE: + return self._handle_idle_press(mx, my, world_x, world_y) + elif self.state == TextBoxState.EDITING: + return self._handle_editing_press( + mx, my, world_x, world_y, n_press + ) + return False + + def _handle_idle_press( + self, mx: float, my: float, world_x: float, world_y: float + ) -> bool: + clicked_entity_id = self._find_text_box_at_point(mx, my) + if clicked_entity_id is not None: + self.start_editing(clicked_entity_id) + # Initialize drag selection state to allow immediate selection + # without requiring a second click. + self._update_cursor_from_click(mx, my) + self.is_drag_selecting = True + self.drag_start_pos = self.cursor_pos + self.selection_start = self.cursor_pos + self.selection_end = self.cursor_pos + self._drag_start_world_x = world_x + self._drag_start_world_y = world_y + return False # Don't claim gesture, allow drag events + + editor = self.element.editor + self._is_new_text_box = bool(editor) + if editor: + editor.history_manager.begin_transaction(_("Add Text Box")) + + cmd = TextBoxCommand(self.element.sketch, origin=(mx, my)) + self.element.execute_command(cmd) + + if cmd.text_box_id is not None: + self.start_editing(cmd.text_box_id) + return True + + def _handle_editing_press( + self, + mx: float, + my: float, + world_x: float, + world_y: float, + n_press: int = 1, + ) -> bool: + if self._is_point_inside_box(mx, my): + self._update_cursor_from_click(mx, my) + if n_press == 2: + self._select_word_at_cursor() + self.is_drag_selecting = False + self.element.mark_dirty() + return True + elif n_press == 3: + self._select_line_at_cursor() + self.is_drag_selecting = False + self.element.mark_dirty() + return True + self.is_drag_selecting = True + self.drag_start_pos = self.cursor_pos + self.selection_start = self.cursor_pos + self.selection_end = self.cursor_pos + self._drag_start_world_x = world_x + self._drag_start_world_y = world_y + logger.debug( + f"_handle_editing_press: is_drag_selecting=True, " + f"cursor_pos={self.cursor_pos}, " + f"start=({world_x}, {world_y})" + ) + return False # Don't claim gesture, allow drag events + + clicked_entity_id = self._find_text_box_at_point(mx, my) + if clicked_entity_id is not None: + self.on_deactivate() + self.start_editing(clicked_entity_id) + self._update_cursor_from_click(mx, my) + if n_press == 2: + self._select_word_at_cursor() + self.is_drag_selecting = False + self.element.mark_dirty() + return True + elif n_press == 3: + self._select_line_at_cursor() + self.is_drag_selecting = False + self.element.mark_dirty() + return True + # Initialize drag state and return False to allow drag to start + self.is_drag_selecting = True + self.drag_start_pos = self.cursor_pos + self.selection_start = self.cursor_pos + self.selection_end = self.cursor_pos + self._drag_start_world_x = world_x + self._drag_start_world_y = world_y + return False + + self.on_deactivate() + return self._handle_idle_press(mx, my, world_x, world_y) + + def _is_point_inside_box(self, mx: float, my: float) -> bool: + if self.editing_entity_id is None: + return False + + entity = self.element.sketch.registry.get_entity( + self.editing_entity_id + ) + if not isinstance(entity, TextBoxEntity): + return False + + p_origin = self.element.sketch.registry.get_point(entity.origin_id) + p_width = self.element.sketch.registry.get_point(entity.width_id) + p_height = self.element.sketch.registry.get_point(entity.height_id) + + p4_id = entity.get_fourth_corner_id(self.element.sketch.registry) + if p4_id: + p4 = self.element.sketch.registry.get_point(p4_id) + p4_x, p4_y = p4.x, p4.y + else: + # Calculate fourth corner from origin, width, and height points + p4_x = p_width.x + p_height.x - p_origin.x + p4_y = p_width.y + p_height.y - p_origin.y + + # Define the polygon for the text box + polygon = [ + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p4_x, p4_y), + (p_height.x, p_height.y), + ] + + # Use point-in-polygon check for accurate hit testing (handles + # rotation) + return is_point_inside_polygon((mx, my), polygon) + + def _is_point_inside_any_text_box(self, mx: float, my: float) -> bool: + for entity in self.element.sketch.registry.entities: + if isinstance( + entity, TextBoxEntity + ) and self._is_point_inside_entity_box(entity, mx, my): + return True + return False + + def _find_text_box_at_point(self, mx: float, my: float) -> int | None: + for entity in reversed(self.element.sketch.registry.entities): + if isinstance( + entity, TextBoxEntity + ) and self._is_point_inside_entity_box(entity, mx, my): + return entity.id + return None + + def _is_point_inside_entity_box( + self, entity: TextBoxEntity, mx: float, my: float + ) -> bool: + p_origin = self.element.sketch.registry.get_point(entity.origin_id) + p_width = self.element.sketch.registry.get_point(entity.width_id) + p_height = self.element.sketch.registry.get_point(entity.height_id) + + p4_id = entity.get_fourth_corner_id(self.element.sketch.registry) + if p4_id: + p4 = self.element.sketch.registry.get_point(p4_id) + p4_x, p4_y = p4.x, p4.y + else: + p4_x = p_width.x + p_height.x - p_origin.x + p4_y = p_width.y + p_height.y - p_origin.y + + polygon = [ + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p4_x, p4_y), + (p_height.x, p_height.y), + ] + + return is_point_inside_polygon((mx, my), polygon) + + def _select_word_at_cursor(self): + """Selects the word at the current cursor position.""" + if not self.text_buffer: + return + + pos = self.cursor_pos + text = self.text_buffer + + # Find word start (find first non-alphanumeric char before cursor) + start = pos + while start > 0 and not text[start - 1].isspace(): + start -= 1 + + # Find word end (find first space or end after cursor) + end = pos + while end < len(text) and not text[end].isspace(): + end += 1 + + self.set_selection(start, end) + self.cursor_pos = end + + def _select_line_at_cursor(self): + """Selects the entire line at the current cursor position.""" + self.set_selection(0, len(self.text_buffer)) + self.cursor_pos = len(self.text_buffer) + + def _finalize_edit(self): + if self.editing_entity_id is not None: + entity = self.element.sketch.registry.get_entity( + self.editing_entity_id + ) + if entity: + entity = cast(TextBoxEntity, entity) + cmd = ModifyTextPropertyCommand( + self.element.sketch, + self.editing_entity_id, + self.text_buffer, + entity.font_config, + ) + self.element.execute_command(cmd) + + def on_drag(self, world_dx: float, world_dy: float): + if self.state == TextBoxState.EDITING and self.is_drag_selecting: + logger.debug( + f"on_drag: state=EDITING, is_drag_selecting=True, " + f"dx={world_dx}, dy={world_dy}" + ) + current_world_x = self._drag_start_world_x + world_dx + current_world_y = self._drag_start_world_y + world_dy + mx, my = self.element.hittester.screen_to_model( + current_world_x, current_world_y, self.element + ) + self._update_cursor_from_click(mx, my) + self.selection_end = self.cursor_pos + logger.debug( + f"on_drag: cursor_pos={self.cursor_pos}, " + f"selection_start={self.selection_start}, " + f"selection_end={self.selection_end}" + ) + self.element.mark_dirty() + else: + logger.debug( + f"on_drag: state={self.state}, " + f"is_drag_selecting={self.is_drag_selecting}" + ) + + def on_release(self, world_x: float, world_y: float): + if self.is_drag_selecting: + self.is_drag_selecting = False + start, end = self._get_selection_range() + logger.debug( + f"on_release: start={start}, end={end}, " + f"is_drag_selecting=False" + ) + if start == end: + self.clear_selection() + self.element.mark_dirty() + + def on_hover_motion(self, world_x: float, world_y: float): + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + self.is_hovering = self._is_point_inside_any_text_box(mx, my) + + def handle_text_input(self, text: str) -> bool: + if self.state != TextBoxState.EDITING: + return False + + self.cursor_visible = True + + start, end = self._get_selection_range() + if start != end: + self.text_buffer = ( + self.text_buffer[:start] + text + self.text_buffer[end:] + ) + self.cursor_pos = start + 1 + self.clear_selection() + else: + self.text_buffer = ( + self.text_buffer[: self.cursor_pos] + + text + + self.text_buffer[self.cursor_pos :] + ) + self.cursor_pos += 1 + + self._resize_box_to_fit_text() + self.cursor_moved.send(self) + + if self.live_edit_cmd: + self.live_edit_cmd.capture_state(self.text_buffer, self.cursor_pos) + + return True + + def handle_key_event( + self, key: SketcherKey, shift: bool = False, ctrl: bool = False + ) -> bool: + if self.state != TextBoxState.EDITING: + return False + + self.cursor_visible = True # Make cursor visible on keypress + + if key == SketcherKey.UNDO: + if self.live_edit_cmd: + self.live_edit_cmd.undo() + self.text_buffer = self.live_edit_cmd.get_current_content() + self.cursor_pos = self.live_edit_cmd.get_current_cursor_pos() + self._resize_box_to_fit_text() + self.element.mark_dirty() + self.cursor_moved.send(self) + return True + elif key == SketcherKey.REDO: + if self.live_edit_cmd: + self.live_edit_cmd.redo() + self.text_buffer = self.live_edit_cmd.get_current_content() + self.cursor_pos = self.live_edit_cmd.get_current_cursor_pos() + self._resize_box_to_fit_text() + self.element.mark_dirty() + self.cursor_moved.send(self) + return True + elif key == SketcherKey.BACKSPACE: + start, end = self._get_selection_range() + if start != end: + self.text_buffer = ( + self.text_buffer[:start] + self.text_buffer[end:] + ) + self.cursor_pos = start + self.clear_selection() + elif self.cursor_pos > 0: + self.text_buffer = ( + self.text_buffer[: self.cursor_pos - 1] + + self.text_buffer[self.cursor_pos :] + ) + self.cursor_pos -= 1 + + self._resize_box_to_fit_text() + self.cursor_moved.send(self) + + if self.live_edit_cmd: + self.live_edit_cmd.capture_state( + self.text_buffer, self.cursor_pos + ) + + return True + elif key == SketcherKey.DELETE: + start, end = self._get_selection_range() + if start != end: + self.text_buffer = ( + self.text_buffer[:start] + self.text_buffer[end:] + ) + self.cursor_pos = start + self.clear_selection() + elif self.cursor_pos < len(self.text_buffer): + self.text_buffer = ( + self.text_buffer[: self.cursor_pos] + + self.text_buffer[self.cursor_pos + 1 :] + ) + + self._resize_box_to_fit_text() + self.cursor_moved.send(self) + + if self.live_edit_cmd: + self.live_edit_cmd.capture_state( + self.text_buffer, self.cursor_pos + ) + + return True + elif key == SketcherKey.ARROW_LEFT: + new_pos = max(0, self.cursor_pos - 1) + if shift: + self.extend_selection(new_pos) + else: + self.clear_selection() + self.cursor_pos = new_pos + self.element.mark_dirty() + self.cursor_moved.send(self) + return True + elif key == SketcherKey.ARROW_RIGHT: + new_pos = min(len(self.text_buffer), self.cursor_pos + 1) + if shift: + self.extend_selection(new_pos) + else: + self.clear_selection() + self.cursor_pos = new_pos + self.element.mark_dirty() + self.cursor_moved.send(self) + return True + elif key == SketcherKey.HOME: + new_pos = 0 + if shift: + self.extend_selection(new_pos) + else: + self.clear_selection() + self.cursor_pos = new_pos + self.element.mark_dirty() + self.cursor_moved.send(self) + return True + elif key == SketcherKey.END: + new_pos = len(self.text_buffer) + if shift: + self.extend_selection(new_pos) + else: + self.clear_selection() + self.cursor_pos = new_pos + self.element.mark_dirty() + self.cursor_moved.send(self) + return True + elif key == SketcherKey.SELECT_ALL: + self.set_selection(0, len(self.text_buffer)) + self.cursor_pos = len(self.text_buffer) + self.element.mark_dirty() + return True + elif key == SketcherKey.COPY and ctrl: + display = Gdk.Display.get_default() + if display is None: + return True + clipboard = display.get_clipboard() + clipboard.set(self.get_selected_text()) + return True + elif key == SketcherKey.CUT and ctrl: + display = Gdk.Display.get_default() + if display is None: + return True + clipboard = display.get_clipboard() + clipboard.set(self.get_selected_text()) + + start, end = self._get_selection_range() + if start != end: + self.text_buffer = ( + self.text_buffer[:start] + self.text_buffer[end:] + ) + self.cursor_pos = start + self.clear_selection() + self._resize_box_to_fit_text() + self.element.mark_dirty() + self.cursor_moved.send(self) + + if self.live_edit_cmd: + self.live_edit_cmd.capture_state( + self.text_buffer, self.cursor_pos + ) + return True + elif key == SketcherKey.PASTE and ctrl: + display = Gdk.Display.get_default() + if display is None: + return True + clipboard = display.get_clipboard() + + def on_paste_ready(clipboard, result): + try: + text = clipboard.read_text_finish(result) + if text: + start, end = self._get_selection_range() + if start != end: + self.text_buffer = ( + self.text_buffer[:start] + + text + + self.text_buffer[end:] + ) + self.cursor_pos = start + len(text) + self.clear_selection() + else: + self.text_buffer = ( + self.text_buffer[: self.cursor_pos] + + text + + self.text_buffer[self.cursor_pos :] + ) + self.cursor_pos += len(text) + + self._resize_box_to_fit_text() + self.element.mark_dirty() + self.cursor_moved.send(self) + + if self.live_edit_cmd: + self.live_edit_cmd.capture_state( + self.text_buffer, self.cursor_pos + ) + except GLib.Error: + pass + + clipboard.read_text_async(None, on_paste_ready) + return True + elif key == SketcherKey.RETURN or key == SketcherKey.ESCAPE: + self.on_deactivate() + return True + + return False + + def _find_opposite_corner( + self, text_entity: TextBoxEntity + ) -> Point | None: + """Finds the 4th point of the bounding box parallelogram.""" + p_w = text_entity.width_id + for eid in text_entity.construction_line_ids: + line = self.element.sketch.registry.get_entity(eid) + if isinstance(line, Line): + if line.p1_idx == p_w and line.p2_idx != text_entity.origin_id: + return self.element.sketch.registry.get_point(line.p2_idx) + if line.p2_idx == p_w and line.p1_idx != text_entity.origin_id: + return self.element.sketch.registry.get_point(line.p1_idx) + return None + + def _resize_box_to_fit_text(self): + """Live-updates the box points to match the current text buffer.""" + if self.editing_entity_id is None: + return + + entity = self.element.sketch.registry.get_entity( + self.editing_entity_id + ) + if not isinstance(entity, TextBoxEntity): + return + + natural_width, natural_height = entity.get_natural_size( + self.text_buffer + ) + + p_origin = self.element.sketch.registry.get_point(entity.origin_id) + p_width = self.element.sketch.registry.get_point(entity.width_id) + p_height = self.element.sketch.registry.get_point(entity.height_id) + + # Determine the sign of the width/height directions + dx = p_width.x - p_origin.x + dy = p_width.y - p_origin.y + dx_h = p_height.x - p_origin.x + dy_h = p_height.y - p_origin.y + + sign_w = 1.0 if dx >= 0 else -1.0 + sign_h = 1.0 if dy_h >= 0 else -1.0 + + # Check for horizontal/vertical constraints to determine how to + # position the width and height points + width_is_horizontal = False + height_is_vertical = False + + for constr in self.element.sketch.constraints: + if isinstance(constr, HorizontalConstraint): + if ( + constr.p1 == entity.origin_id + and constr.p2 == entity.width_id + ) or ( + constr.p2 == entity.origin_id + and constr.p1 == entity.width_id + ): + width_is_horizontal = True + elif isinstance(constr, VerticalConstraint) and ( + ( + constr.p1 == entity.origin_id + and constr.p2 == entity.height_id + ) + or ( + constr.p2 == entity.origin_id + and constr.p1 == entity.height_id + ) + ): + height_is_vertical = True + + # Directly set point positions, respecting constraints + if width_is_horizontal: + p_width.x = p_origin.x + natural_width * sign_w + p_width.y = p_origin.y + else: + current_len = math.hypot(dx, dy) + if current_len > 1e-9: + ux, uy = dx / current_len, dy / current_len + else: + ux, uy = sign_w, 0.0 + p_width.x = p_origin.x + natural_width * ux + p_width.y = p_origin.y + natural_width * uy + + if height_is_vertical: + p_height.x = p_origin.x + p_height.y = p_origin.y + natural_height * sign_h + else: + current_h_len = math.hypot(dx_h, dy_h) + if current_h_len > 1e-9: + vx, vy = dx_h / current_h_len, dy_h / current_h_len + else: + vx, vy = 0.0, sign_h + p_height.x = p_origin.x + natural_height * vx + p_height.y = p_origin.y + natural_height * vy + + # Update p4 to maintain parallelogram shape + # p4 = width + height - origin + p4_id = entity.get_fourth_corner_id(self.element.sketch.registry) + if p4_id is not None: + p4 = self.element.sketch.registry.get_point(p4_id) + p4.x = p_width.x + p_height.x - p_origin.x + p4.y = p_width.y + p_height.y - p_origin.y + + # Update aspect ratio constraint value + if natural_height > 1e-9: + new_ratio = natural_width / natural_height + for constr in self.element.sketch.constraints: + if ( + isinstance(constr, AspectRatioConstraint) + and constr.p1 == entity.origin_id + and constr.p2 == entity.width_id + and constr.p3 == entity.origin_id + and constr.p4 == entity.height_id + ): + constr.ratio = new_ratio + break + + self.element.mark_dirty() + + def _update_cursor_from_click(self, mx: float, my: float): + """Finds the best cursor position based on a click in model space.""" + if self.editing_entity_id is None: + return + entity = self.element.sketch.registry.get_entity( + self.editing_entity_id + ) + if not isinstance(entity, TextBoxEntity): + return + + p_origin = self.element.sketch.registry.get_point(entity.origin_id) + p_width = self.element.sketch.registry.get_point(entity.width_id) + p_height = self.element.sketch.registry.get_point(entity.height_id) + + # 1. Project the click point onto the width vector (u) of the box. + # This handles rotated and scaled text boxes correctly. + u_vec = (p_width.x - p_origin.x, p_width.y - p_origin.y) + v_vec = (p_height.x - p_origin.x, p_height.y - p_origin.y) + det = u_vec[0] * v_vec[1] - u_vec[1] * v_vec[0] + if abs(det) < 1e-9: + return + + inv_det = 1.0 / det + click_vec = (mx - p_origin.x, my - p_origin.y) + + # alpha is the normalized coordinate (0..1) along the width axis + alpha = (click_vec[0] * v_vec[1] - click_vec[1] * v_vec[0]) * inv_det + + # 2. Map click position to advance-space x-coordinate + advance_width = entity.font_config.get_text_width(self.text_buffer) + + if not self.text_buffer: + self.cursor_pos = 0 + self.cursor_visible = True + self.element.mark_dirty() + self.cursor_moved.send(self) + return + + if advance_width < 1e-9: + advance_width = 1.0 + + # Map normalized alpha to geometry x-coordinate + target_x_natural = alpha * advance_width + + # 3. Find closest character break + best_i, min_dist = 0, float("inf") + + # Iterate through all possible cursor positions + # (before first char ... after last char) + for i in range(len(self.text_buffer) + 1): + sub_max_x = entity.font_config.get_text_position( + self.text_buffer, i + ) + + dist = abs(sub_max_x - target_x_natural) + if dist < min_dist: + min_dist = dist + best_i = i + + self.cursor_pos = best_i + self.cursor_visible = True + self.element.mark_dirty() + self.cursor_moved.send(self) + + def draw_overlay(self, ctx: cairo.Context): + if ( + self.state != TextBoxState.EDITING + or self.editing_entity_id is None + ): + return + + entity = cast( + TextBoxEntity, + self.element.sketch.registry.get_entity(self.editing_entity_id), + ) + if not entity: + return + + p_origin = self.element.sketch.registry.get_point(entity.origin_id) + p_width = self.element.sketch.registry.get_point(entity.width_id) + p_height = self.element.sketch.registry.get_point(entity.height_id) + + natural_geo = text_to_geometry( + self.text_buffer, font_config=entity.font_config + ) + + _, nat_min_y, _, nat_max_y = natural_geo.rect() + + # Handle empty text case for frame mapping logic + if not self.text_buffer: + nat_min_y = 0.0 + nat_max_y = entity.font_config.size + + advance_width = ( + entity.font_config.get_text_width(self.text_buffer) + if self.text_buffer + else 10.0 + ) + + transformed_geo = natural_geo.map_to_frame( + (p_origin.x, p_origin.y), + (p_width.x, p_width.y), + (p_height.x, p_height.y), + anchor_x=0.0, + stable_src_width=advance_width, + anchor_y=nat_min_y, + stable_src_height=nat_max_y - nat_min_y, + ) + logger.debug(f"Transformed text geometry: {transformed_geo.rect()}") + + ctx.save() + model_to_screen_matrix = ( + self.element.hittester.get_model_to_screen_transform(self.element) + ) + cairo_mat = cairo.Matrix(*model_to_screen_matrix.for_cairo()) + ctx.transform(cairo_mat) + + geometry_to_cairo(transformed_geo, ctx) + self._set_text_color(ctx, entity) + ctx.fill() + + start, end = self._get_selection_range() + if start != end: + self._draw_selection_highlight( + ctx, + nat_min_y, + nat_max_y, + advance_width, + ) + + if self.cursor_visible: + # Calculate view scale for consistent cursor size + scale = 1.0 + if self.element.canvas: + canvas = cast("SketchCanvas", self.element.canvas) + scale_x, _ = canvas.get_view_scale() + scale = scale_x if scale_x > 1e-13 else 1.0 + cursor_width = 3.0 / scale + + # Calculate cursor position (kerning-aware) + sub_max_x = entity.font_config.get_text_position( + self.text_buffer, self.cursor_pos + ) + + cursor_height = nat_max_y - nat_min_y + if cursor_height <= 0: + cursor_height = entity.font_config.size + + c_center_y = (nat_min_y + nat_max_y) / 2 + + c_half_w = cursor_width / 2 + c_half_h = cursor_height / 2 + + # Cursor corners in natural space + pts_nat = [ + (sub_max_x - c_half_w, c_center_y - c_half_h), + (sub_max_x + c_half_w, c_center_y - c_half_h), + (sub_max_x + c_half_w, c_center_y + c_half_h), + (sub_max_x - c_half_w, c_center_y + c_half_h), + ] + + # Prepare transformation to Model Space + src_h = nat_max_y - nat_min_y + if abs(src_h) < 1e-9: + src_h = 1.0 + + u = (p_width.x - p_origin.x, p_width.y - p_origin.y) + v = (p_height.x - p_origin.x, p_height.y - p_origin.y) + origin = (p_origin.x, p_origin.y) + + def trans(px, py): + xn = px / advance_width + yn = (py - nat_min_y) / src_h + return ( + origin[0] + xn * u[0] + yn * v[0], + origin[1] + xn * u[1] + yn * v[1], + ) + + pts_model = [trans(*p) for p in pts_nat] + + ctx.move_to(*pts_model[0]) + for p in pts_model[1:]: + ctx.line_to(*p) + ctx.close_path() + self._set_text_color(ctx, entity) + ctx.fill() + + ctx.restore() + + def _set_text_color( + self, ctx: cairo.Context, entity: TextBoxEntity + ) -> None: + is_sketch_fully_constrained = self.element.sketch.is_fully_constrained + if entity.constrained: + if is_sketch_fully_constrained: + ctx.set_source_rgb(0.0, 0.6, 0.0) + else: + ctx.set_source_rgb(0.2, 0.8, 0.2) + else: + if self.element.canvas: + fg_rgba = self.element.canvas.get_color() + ctx.set_source_rgb(fg_rgba.red, fg_rgba.green, fg_rgba.blue) + else: + ctx.set_source_rgb(0.0, 0.0, 0.0) + + def _draw_selection_highlight( + self, + ctx: cairo.Context, + nat_min_y: float, + nat_max_y: float, + advance_width: float, + ): + """Draws the selection highlight for selected text.""" + if self.editing_entity_id is None: + return + + entity = cast( + TextBoxEntity, + self.element.sketch.registry.get_entity(self.editing_entity_id), + ) + if not isinstance(entity, TextBoxEntity): + return + + p_origin = self.element.sketch.registry.get_point(entity.origin_id) + p_width = self.element.sketch.registry.get_point(entity.width_id) + p_height = self.element.sketch.registry.get_point(entity.height_id) + + start, end = self._get_selection_range() + if start == end: + return + + src_h = nat_max_y - nat_min_y + if abs(src_h) < 1e-9: + src_h = 1.0 + + u = (p_width.x - p_origin.x, p_width.y - p_origin.y) + v = (p_height.x - p_origin.x, p_height.y - p_origin.y) + origin = (p_origin.x, p_origin.y) + + def trans(px, py): + xn = px / advance_width + yn = (py - nat_min_y) / src_h + return ( + origin[0] + xn * u[0] + yn * v[0], + origin[1] + xn * u[1] + yn * v[1], + ) + + def get_char_x(pos: int) -> float: + return entity.font_config.get_text_position(self.text_buffer, pos) + + start_x = get_char_x(start) + end_x = get_char_x(end) + + # Draw selection highlight as a rectangle + sel_nat_pts = [ + (start_x, nat_min_y), + (end_x, nat_min_y), + (end_x, nat_max_y), + (start_x, nat_max_y), + ] + + sel_model_pts = [trans(*p) for p in sel_nat_pts] + + ctx.save() + ctx.move_to(*sel_model_pts[0]) + for p in sel_model_pts[1:]: + ctx.line_to(*p) + ctx.close_path() + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.3) + ctx.fill() + ctx.restore() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/vertical_constraint_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/vertical_constraint_tool.py new file mode 100644 index 000000000..b799fded3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/vertical_constraint_tool.py @@ -0,0 +1,70 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Union + +from ...core.commands import AddItemsCommand +from ...core.constraints import VerticalConstraint +from ...core.entities import Entity, Line, Point +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + +logger = logging.getLogger(__name__) + + +class VerticalConstraintTool(SketchTool): + ICON = "sketch-constrain-vertical-symbolic" + LABEL = _("Vertical") + SHORTCUTS: ClassVar[list[str]] = ["v"] + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + return VerticalConstraint.can_apply_to( + self.element.selection, self.element.sketch + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._add_constraint() + self.element.set_tool("select") + + def _add_constraint(self): + sel = self.element.selection + sketch = self.element.sketch + editor = self.element.editor + + if not editor: + return + + constraints_to_add = [] + + if len(sel.point_ids) == 2 and not sel.entity_ids: + p1_id, p2_id = sel.point_ids + constraints_to_add.append(VerticalConstraint(p1_id, p2_id)) + elif len(sel.entity_ids) > 0 and not sel.point_ids: + for eid in sel.entity_ids: + e = sketch.registry.get_entity(eid) + if isinstance(e, Line): + constraints_to_add.append( + VerticalConstraint(e.p1_idx, e.p2_idx) + ) + + if constraints_to_add: + cmd = AddItemsCommand( + sketch, + _("Add Vertical Constraint"), + constraints=constraints_to_add, + ) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_sharp_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_sharp_tool.py new file mode 100644 index 000000000..9a90f278a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_sharp_tool.py @@ -0,0 +1,83 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Union + +from ...core.commands import SetWaypointTypeCommand +from ...core.entities import Bezier, Entity, Line, Point +from ...core.entities.point import WaypointType +from ...core.types import EntityID +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + + +class WaypointSharpTool(SketchTool): + ICON = "sketch-bezier-sharp-symbolic" + LABEL = _("Sharp") + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + pid = self._get_waypoint_pid() + if pid is None: + return False + return self._is_waypoint_at_bezier(pid) or self._is_waypoint_at_line( + pid + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._set_waypoint_type(WaypointType.SHARP) + self.element.set_tool("select") + + def _get_waypoint_pid(self) -> EntityID | None: + sel = self.element.selection + if sel.junction_pid is not None: + return sel.junction_pid + elif len(sel.point_ids) == 1: + return sel.point_ids[0] + return None + + def _is_waypoint_at_bezier(self, pid: EntityID) -> bool: + sketch = self.element.sketch + for entity in sketch.registry.entities: + if isinstance(entity, Bezier) and pid in ( + entity.start_idx, + entity.end_idx, + ): + return True + return False + + def _is_waypoint_at_line(self, pid: EntityID) -> bool: + sketch = self.element.sketch + for entity in sketch.registry.entities: + if isinstance(entity, Line) and pid in ( + entity.p1_idx, + entity.p2_idx, + ): + return True + return False + + def _set_waypoint_type(self, new_type: WaypointType): + editor = self.element.editor + sketch = self.element.sketch + + if not editor: + return + + pid = self._get_waypoint_pid() + if pid is None: + return + + cmd = SetWaypointTypeCommand(sketch, pid, new_type) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_smooth_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_smooth_tool.py new file mode 100644 index 000000000..161351466 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_smooth_tool.py @@ -0,0 +1,83 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Union + +from ...core.commands import SetWaypointTypeCommand +from ...core.entities import Bezier, Entity, Line, Point +from ...core.entities.point import WaypointType +from ...core.types import EntityID +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + + +class WaypointSmoothTool(SketchTool): + ICON = "sketch-bezier-smooth-symbolic" + LABEL = _("Smooth") + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + pid = self._get_waypoint_pid() + if pid is None: + return False + return self._is_waypoint_at_bezier(pid) or self._is_waypoint_at_line( + pid + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._set_waypoint_type(WaypointType.SMOOTH) + self.element.set_tool("select") + + def _get_waypoint_pid(self) -> EntityID | None: + sel = self.element.selection + if sel.junction_pid is not None: + return sel.junction_pid + elif len(sel.point_ids) == 1: + return sel.point_ids[0] + return None + + def _is_waypoint_at_bezier(self, pid: EntityID) -> bool: + sketch = self.element.sketch + for entity in sketch.registry.entities: + if isinstance(entity, Bezier) and pid in ( + entity.start_idx, + entity.end_idx, + ): + return True + return False + + def _is_waypoint_at_line(self, pid: EntityID) -> bool: + sketch = self.element.sketch + for entity in sketch.registry.entities: + if isinstance(entity, Line) and pid in ( + entity.p1_idx, + entity.p2_idx, + ): + return True + return False + + def _set_waypoint_type(self, new_type: WaypointType): + editor = self.element.editor + sketch = self.element.sketch + + if not editor: + return + + pid = self._get_waypoint_pid() + if pid is None: + return + + cmd = SetWaypointTypeCommand(sketch, pid, new_type) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_symmetric_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_symmetric_tool.py new file mode 100644 index 000000000..8efbfe02e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/waypoint_symmetric_tool.py @@ -0,0 +1,83 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Union + +from ...core.commands import SetWaypointTypeCommand +from ...core.entities import Bezier, Entity, Line, Point +from ...core.entities.point import WaypointType +from ...core.types import EntityID +from .base import SketchTool + +if TYPE_CHECKING: + from ...core.constraints import Constraint + + +class WaypointSymmetricTool(SketchTool): + ICON = "sketch-bezier-symmetric-symbolic" + LABEL = _("Symmetric") + + def is_available( + self, + target: Union[Point, Entity, "Constraint"] | None, + target_type: str | None, + ) -> bool: + pid = self._get_waypoint_pid() + if pid is None: + return False + return self._is_waypoint_at_bezier(pid) or self._is_waypoint_at_line( + pid + ) + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + return True + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_release(self, world_x: float, world_y: float): + pass + + def on_activate(self): + self._set_waypoint_type(WaypointType.SYMMETRIC) + self.element.set_tool("select") + + def _get_waypoint_pid(self) -> EntityID | None: + sel = self.element.selection + if sel.junction_pid is not None: + return sel.junction_pid + elif len(sel.point_ids) == 1: + return sel.point_ids[0] + return None + + def _is_waypoint_at_bezier(self, pid: EntityID) -> bool: + sketch = self.element.sketch + for entity in sketch.registry.entities: + if isinstance(entity, Bezier) and pid in ( + entity.start_idx, + entity.end_idx, + ): + return True + return False + + def _is_waypoint_at_line(self, pid: EntityID) -> bool: + sketch = self.element.sketch + for entity in sketch.registry.entities: + if isinstance(entity, Line) and pid in ( + entity.p1_idx, + entity.p2_idx, + ): + return True + return False + + def _set_waypoint_type(self, new_type: WaypointType): + editor = self.element.editor + sketch = self.element.sketch + + if not editor: + return + + pid = self._get_waypoint_pid() + if pid is None: + return + + cmd = SetWaypointTypeCommand(sketch, pid, new_type) + self.element.execute_command(cmd) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/worker.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/worker.py new file mode 100644 index 000000000..0e68bb279 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/worker.py @@ -0,0 +1,41 @@ +""" +Backend entry point for sketcher addon. + +Registers asset types and renderers with the main application. +""" + +from rayforge.core.hooks import hookimpl + +ADDON_NAME = "sketcher" + + +@hookimpl +def register_asset_types(asset_type_registry): + """Register Sketch asset type with the asset type registry.""" + from .core.sketch import Sketch + + asset_type_registry.register(Sketch, "sketch", ADDON_NAME) + + +@hookimpl +def register_renderers(renderer_registry): + """Register sketch renderer with the renderer registry.""" + from .image.renderer import SKETCH_RENDERER + + renderer_registry.register(SKETCH_RENDERER, ADDON_NAME) + + +@hookimpl +def register_exporters(exporter_registry): + """Register sketch exporter with the exporter registry.""" + from .image.exporter import SketchExporter + + exporter_registry.register(SketchExporter, ADDON_NAME) + + +@hookimpl +def register_importers(importer_registry): + """Register sketch importer with the importer registry.""" + from .image.importer import SketchImporter + + importer_registry.register(SketchImporter) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/assets/mouse.rfs b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/assets/mouse.rfs new file mode 100644 index 000000000..715def3c5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/assets/mouse.rfs @@ -0,0 +1 @@ +{"uid": "ff1403bb-43ca-4406-8c76-f72a0274a410", "type": "sketch", "name": "New Sketch", "input_parameters": {"vars": [{"class": "FloatVar", "key": "new_parameter", "label": "New Parameter", "description": null, "default": 10.0, "min_val": null, "max_val": null}]}, "params": {"expressions": {}}, "registry": {"points": [{"id": 0, "x": 0.0, "y": 0.0, "fixed": true}, {"id": 5, "x": 33.32373869296502, "y": -20.16176360356271, "fixed": false}, {"id": 1, "x": 1.1177485749069597e-10, "y": -30.86355969254492, "fixed": false}, {"id": 2, "x": -33.3237386927563, "y": -20.161763603595787, "fixed": false}, {"id": 10, "x": -9.999999999904345, "y": -2.6140207875798493, "fixed": false}, {"id": 7, "x": -29.88364921602863, "y": -0.45984023575914607, "fixed": false}, {"id": 15, "x": 10.000000000095657, "y": -2.6140207875699213, "fixed": false}, {"id": 12, "x": 29.883649216237558, "y": -0.4598402357190418, "fixed": false}, {"id": 17, "x": 1.2456930353761852e-10, "y": -29.476243765635488, "fixed": false}, {"id": 21, "x": 1.2703581763715155e-10, "y": -65.86355969254492, "fixed": false}, {"id": 22, "x": 9.475319507528313e-11, "y": -0.813043956075922, "fixed": false}, {"id": 42, "x": -12.424590164240136, "y": -25.901675464079123, "fixed": false}, {"id": 39, "x": -14.999999999894284, "y": -24.36308384236318, "fixed": false}, {"id": 47, "x": 13.707798911799287, "y": -27.070521071046503, "fixed": false}, {"id": 44, "x": 15.000000000107175, "y": -24.363083842348296, "fixed": false}], "entities": [{"id": 6, "type": "arc", "construction": false, "start_idx": 2, "end_idx": 5, "center_idx": 1, "clockwise": false}, {"id": 11, "type": "arc", "construction": false, "start_idx": 2, "end_idx": 10, "center_idx": 7, "clockwise": true}, {"id": 16, "type": "arc", "construction": false, "start_idx": 5, "end_idx": 15, "center_idx": 12, "clockwise": false}, {"id": 20, "type": "arc", "construction": false, "start_idx": 15, "end_idx": 10, "center_idx": 17, "clockwise": false}, {"id": 23, "type": "line", "construction": true, "p1_idx": 21, "p2_idx": 22}, {"id": 43, "type": "circle", "construction": false, "center_idx": 39, "radius_pt_idx": 42}, {"id": 48, "type": "circle", "construction": false, "center_idx": 44, "radius_pt_idx": 47}], "id_counter": 49}, "constraints": [{"type": "EqualDistanceConstraint", "p1": 1, "p2": 2, "p3": 1, "p4": 5, "user_visible": true}, {"type": "EqualDistanceConstraint", "p1": 7, "p2": 2, "p3": 7, "p4": 10, "user_visible": true}, {"type": "EqualDistanceConstraint", "p1": 12, "p2": 5, "p3": 12, "p4": 15, "user_visible": true}, {"type": "EqualDistanceConstraint", "p1": 17, "p2": 15, "p3": 17, "p4": 10, "user_visible": true}, {"type": "EqualLengthConstraint", "entity_ids": [16, 11], "user_visible": true}, {"type": "PointOnLineConstraint", "point_id": 0, "shape_id": 23, "user_visible": true}, {"type": "VerticalConstraint", "p1": 21, "p2": 22, "user_visible": true}, {"type": "PointOnLineConstraint", "point_id": 22, "shape_id": 20, "user_visible": true}, {"type": "SymmetryConstraint", "p1": 15, "p2": 10, "center": null, "axis": 23, "user_visible": true}, {"type": "PointOnLineConstraint", "point_id": 21, "shape_id": 6, "user_visible": true}, {"type": "SymmetryConstraint", "p1": 5, "p2": 2, "center": null, "axis": 23, "user_visible": true}, {"type": "DistanceConstraint", "p1": 15, "p2": 10, "value": 20.0, "user_visible": true}, {"type": "RadiusConstraint", "entity_id": 6, "value": 35.0, "user_visible": true}, {"type": "RadiusConstraint", "entity_id": 11, "value": 20.0, "user_visible": true}, {"type": "DistanceConstraint", "p1": 21, "p2": 22, "value": 65.050515736469, "user_visible": true}, {"type": "SymmetryConstraint", "p1": 44, "p2": 39, "center": null, "axis": 23, "user_visible": true}, {"type": "EqualLengthConstraint", "entity_ids": [48, 43], "user_visible": true}, {"type": "DiameterConstraint", "circle_id": 43, "value": 6.0, "user_visible": true}, {"type": "DistanceConstraint", "p1": 44, "p2": 39, "value": 30.0, "user_visible": true}], "fills": [{"uid": "553a309b-d8a8-416f-b352-f6bdf70aa69c", "boundary": [[6, true], [16, true], [20, true], [11, false]], "style": "solid", "color": [1.0, 0.47058823704719543, 0.0, 1.0]}, {"uid": "5ddbf382-87d9-4d2d-b17c-873c8cd49152", "boundary": [[48, true]], "style": "solid", "color": [0.1411764770746231, 0.12156862765550613, 0.1921568661928177, 1.0]}, {"uid": "14b14473-6838-4b2a-ac40-aea640a2fc58", "boundary": [[43, true]], "style": "solid", "color": [0.1411764770746231, 0.12156862765550613, 0.1921568661928177, 1.0]}], "origin_id": 0, "hidden": false} \ No newline at end of file diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/assets/sketch_project.ryp b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/assets/sketch_project.ryp new file mode 100644 index 000000000..01448412b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/assets/sketch_project.ryp @@ -0,0 +1,76 @@ +{ + "uid": "d81d4fae-7dec-11d0-a765-00a0c91e6bf9", + "type": "doc", + "active_layer_index": 0, + "children": [ + { + "uid": "g1b2c3d4-e5f6-7890-abcd-ef1234567896", + "type": "layer", + "name": "Layer 1", + "matrix": [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0] + ], + "visible": true, + "stock_item_uid": null, + "children": [ + { + "uid": "c47ac10b-58cc-4372-a567-0e02b2c3d47a", + "type": "workflow", + "name": "Layer 1 Workflow", + "matrix": [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0] + ], + "children": [] + }, + { + "uid": "8a7b8c9d-0e1f-2a3b-4c5d-6e7f8a9b0c1d", + "type": "workpiece", + "name": "Rectangle Sketch", + "matrix": [ + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0] + ], + "width_mm": 50.0, + "height_mm": 30.0, + "tabs": [], + "tabs_enabled": true, + "source_segment": null, + "edited_boundaries": null, + "sketch_uid": "9b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e", + "sketch_params": { + "width": 50.0, + "height": 30.0 + }, + "source_asset_uid": null + } + ] + } + ], + "assets": [ + { + "uid": "9b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e", + "type": "sketch", + "name": "Rectangle", + "input_parameters": { + "width": 50.0, + "height": 30.0 + }, + "params": { + "expressions": {} + }, + "registry": { + "points": [], + "entities": [], + "id_counter": 0 + }, + "constraints": [], + "fills": [], + "origin_id": null + } + ] +} diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/conftest.py new file mode 100644 index 000000000..2cd8cf729 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/conftest.py @@ -0,0 +1,48 @@ +""" +Pytest configuration for sketcher builtin addon tests. + +This conftest ensures that the sketcher module is importable +by adding the addon directory to sys.path before tests run. +""" + +import sys +from pathlib import Path + +import pytest + +_gtk_available = True +try: + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + import gi.repository.Adw +except (ValueError, ImportError): + _gtk_available = False + + +# Add the addon directory to sys.path so that the sketcher module +# can be imported from tests +addon_dir = Path(__file__).parent.parent +sys.path.insert(0, str(addon_dir)) + + +def pytest_ignore_collect(collection_path, config): + """Skip UI test files when GTK/Adw is not available.""" + if not _gtk_available: + path_str = str(collection_path) + if "/ui_gtk/" in path_str: + return True + return False + + +@pytest.fixture(scope="session", autouse=True) +def register_sketch_asset_type(): + """Register Sketch asset type for tests that need serialization.""" + from sketcher.core import Sketch + + from rayforge.core.asset_registry import asset_type_registry + + asset_type_registry.register(Sketch, "sketch", "sketcher") + yield + asset_type_registry.unregister("sketch") diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_angle_constraint_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_angle_constraint_cmd.py new file mode 100644 index 000000000..3a8c15720 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_angle_constraint_cmd.py @@ -0,0 +1,130 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + AngleConstraintCommand, + AngleConstraintParams, +) + + +@pytest.fixture +def sketch(): + return Sketch() + + +@pytest.fixture +def intersecting_lines(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line1_id = sketch.add_line(p1, p2) + + p3 = sketch.add_point(50, -50) + p4 = sketch.add_point(50, 50) + line2_id = sketch.add_line(p3, p4) + + return sketch, line1_id, line2_id + + +def test_calculate_constraint_params_intersecting_lines(intersecting_lines): + sketch, line1_id, line2_id = intersecting_lines + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, line1_id, line2_id + ) + + assert result is not None + assert isinstance(result, AngleConstraintParams) + assert result.value_deg == pytest.approx(90.0, abs=0.1) + + +def test_calculate_constraint_params_45_degree_angle(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line1_id = sketch.add_line(p1, p2) + + p3 = sketch.add_point(0, 0) + p4 = sketch.add_point(100, 100) + line2_id = sketch.add_line(p3, p4) + + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, line1_id, line2_id + ) + + assert result is not None + assert result.value_deg == pytest.approx(45.0, abs=0.1) + + +def test_calculate_constraint_params_parallel_lines(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line1_id = sketch.add_line(p1, p2) + + p3 = sketch.add_point(0, 50) + p4 = sketch.add_point(100, 50) + line2_id = sketch.add_line(p3, p4) + + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, line1_id, line2_id + ) + + assert result is None + + +def test_calculate_constraint_params_non_line_entity(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line_id = sketch.add_line(p1, p2) + + center = sketch.add_point(50, 50) + radius_p = sketch.add_point(60, 50) + arc_id = sketch.add_arc(center, radius_p, 0, 90) + + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, line_id, arc_id + ) + + assert result is None + + +def test_calculate_constraint_params_two_non_lines(sketch): + center1 = sketch.add_point(0, 0) + r1 = sketch.add_point(10, 0) + arc1_id = sketch.add_arc(center1, r1, 0, 90) + + center2 = sketch.add_point(50, 0) + r2 = sketch.add_point(60, 0) + arc2_id = sketch.add_arc(center2, r2, 0, 90) + + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, arc1_id, arc2_id + ) + + assert result is None + + +def test_calculate_constraint_params_returns_correct_ids(intersecting_lines): + sketch, line1_id, line2_id = intersecting_lines + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, line1_id, line2_id + ) + + assert result is not None + assert result.anchor_id in (line1_id, line2_id) + assert result.other_id in (line1_id, line2_id) + assert result.anchor_id != result.other_id + + +def test_calculate_constraint_params_obtuse_angle(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line1_id = sketch.add_line(p1, p2) + + p3 = sketch.add_point(0, 0) + p4 = sketch.add_point(50, -100) + line2_id = sketch.add_line(p3, p4) + + result = AngleConstraintCommand.calculate_constraint_params( + sketch.registry, line1_id, line2_id + ) + + assert result is not None + assert result.value_deg <= 180.0 + assert result.value_deg > 0.0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_arc_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_arc_cmd.py new file mode 100644 index 000000000..ca799b7ec --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_arc_cmd.py @@ -0,0 +1,593 @@ +import math + +from raygeo.geo.shape.arc import get_arc_direction +from sketcher.core import Sketch +from sketcher.core.commands import ArcCommand, ArcPreviewState +from sketcher.core.entities import Arc + + +def test_arc_start_preview(): + """Test start_preview creates preview arc entity.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + state = ArcCommand.start_preview( + sketch.registry, + 25, + 25, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + assert isinstance(state, ArcPreviewState) + assert state.center_id == center_id + assert state.start_id == start_id + assert state.temp_end_id is not None + assert state.temp_entity_id is not None + + arc = sketch.registry.get_entity(state.temp_entity_id) + assert isinstance(arc, Arc) + + +def test_arc_update_preview(): + """Test update_preview moves end point and updates direction.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + state = ArcCommand.start_preview( + sketch.registry, + 25, + 25, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + ArcCommand.update_preview(sketch.registry, state, 0, 50) + + assert state.temp_end_id is not None + end_p = sketch.registry.get_point(state.temp_end_id) + expected_radius = 50.0 + actual_radius = math.hypot(end_p.x, end_p.y) + assert abs(actual_radius - expected_radius) < 0.001 + + +def test_arc_cleanup_preview(): + """Test cleanup_preview removes preview entities.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + initial_entity_count = len(sketch.registry.entities) + initial_point_count = len(sketch.registry.points) + + state = ArcCommand.start_preview( + sketch.registry, + 25, + 25, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + assert len(sketch.registry.entities) > initial_entity_count + assert len(sketch.registry.points) > initial_point_count + + ArcCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == initial_entity_count + assert len(sketch.registry.points) == initial_point_count + + +def test_arc_preview_lifecycle(): + """Test full preview lifecycle: start -> update -> cleanup.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + state = ArcCommand.start_preview( + sketch.registry, + 0, + 50, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + ArcCommand.update_preview(sketch.registry, state, 0, 50) + + ArcCommand.cleanup_preview(sketch.registry, state) + + assert state.clockwise is not None + + +def test_arc_preview_clockwise_detection(): + """Test that preview detects clockwise vs counter-clockwise.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + state = ArcCommand.start_preview( + sketch.registry, + 0, + 50, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + ArcCommand.update_preview(sketch.registry, state, 0, 50) + ArcCommand.cleanup_preview(sketch.registry, state) + + assert isinstance(state.clockwise, bool) + + +def test_arc_command_execute(): + """Test ArcCommand execution creates proper geometry.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=False, + is_start_temp=False, + clockwise=False, + ) + cmd.execute() + + assert len(sketch.registry.entities) == 1 + assert len(sketch.constraints) == 1 + assert isinstance(sketch.registry.entities[0], Arc) + + +def test_arc_command_with_temp_points(): + """Test ArcCommand handles temporary center and start points.""" + sketch = Sketch() + + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=True, + is_start_temp=True, + clockwise=False, + ) + cmd.execute() + + assert cmd.add_cmd is not None + assert len(sketch.registry.entities) == 1 + + +def test_arc_command_undo(): + """Test ArcCommand can be undone.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + initial_entity_count = len(sketch.registry.entities) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=False, + is_start_temp=False, + clockwise=False, + ) + cmd.execute() + + assert len(sketch.registry.entities) > initial_entity_count + + cmd.undo() + + assert len(sketch.registry.entities) == initial_entity_count + + +def test_arc_command_undo_no_dangling_points(): + """Test undo removes all added points including end point.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + initial_point_count = len(sketch.registry.points) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=False, + is_start_temp=False, + clockwise=False, + ) + cmd.execute() + + # Execute adds 1 new end point + assert len(sketch.registry.points) == initial_point_count + 1 + + cmd.undo() + + # After undo, should be back to initial count + assert len(sketch.registry.points) == initial_point_count + + +def test_arc_command_undo_with_temp_center(): + """Test undo with temporary center point removes temp point.""" + sketch = Sketch() + + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=True, + is_start_temp=False, + clockwise=False, + ) + cmd.execute() + + # Execute: temp center is removed from registry and re-added + # by AddItemsCommand, plus 1 new end point + assert len(sketch.registry.entities) == 1 + + cmd.undo() + + # After undo: temp center should NOT be restored as it was temp + # Only the original origin + start point should remain + # (center was temp and shouldn't come back) + assert len(sketch.registry.entities) == 0 + + +def test_arc_command_undo_with_temp_start(): + """Test undo with temporary start point removes temp point.""" + sketch = Sketch() + + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=False, + is_start_temp=True, + clockwise=False, + ) + cmd.execute() + + assert len(sketch.registry.entities) == 1 + + cmd.undo() + + # After undo: temp start should NOT be restored + assert len(sketch.registry.entities) == 0 + + +def test_arc_direction_preserved_counter_clockwise(): + """Test that arc direction from preview is preserved on execute.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + # Mouse above the start-center line should give counter-clockwise + expected_clockwise = get_arc_direction((0, 0), (50, 0), (25, 50)) + assert expected_clockwise is False + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (25, 50), + is_center_temp=False, + is_start_temp=False, + clockwise=expected_clockwise, + ) + cmd.execute() + + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + assert arc.clockwise == expected_clockwise + + +def test_arc_direction_preserved_clockwise(): + """Test that clockwise direction from preview is preserved on execute.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + # Mouse below the start-center line should give clockwise + expected_clockwise = get_arc_direction((0, 0), (50, 0), (25, -50)) + assert expected_clockwise is True + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (25, -50), + is_center_temp=False, + is_start_temp=False, + clockwise=expected_clockwise, + ) + cmd.execute() + + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + assert arc.clockwise == expected_clockwise + + +def test_arc_direction_with_projected_endpoint(): + """Test arc direction when end point is projected onto circle.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + # End position is slightly off the circle - it will be projected + # Mouse is below center-start line (clockwise direction) + mouse_pos = (30, -40) + expected_clockwise = get_arc_direction((0, 0), (50, 0), mouse_pos) + assert expected_clockwise is True + + cmd = ArcCommand( + sketch, + center_id, + start_id, + mouse_pos, + is_center_temp=False, + is_start_temp=False, + clockwise=expected_clockwise, + ) + cmd.execute() + + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + # The arc direction should match what was determined during preview + assert arc.clockwise == expected_clockwise + + +def test_arc_full_workflow_with_preview(): + """Test complete arc creation workflow including preview state.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + # Simulate the preview workflow + state = ArcCommand.start_preview( + sketch.registry, + 25, + -30, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + # Update preview + ArcCommand.update_preview(sketch.registry, state, 25, -30) + + # Get the direction from preview + ArcCommand.cleanup_preview(sketch.registry, state) + clockwise = state.clockwise + + # Create the command with the preview direction + cmd = ArcCommand( + sketch, + center_id, + start_id, + (25, -30), + is_center_temp=False, + is_start_temp=False, + clockwise=clockwise, + ) + cmd.execute() + + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + # Direction should match what was in preview + assert arc.clockwise == clockwise + + +def test_arc_command_undo_with_both_temp_points(): + """Test undo removes both temp center and start points.""" + sketch = Sketch() + + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=True, + is_start_temp=True, + clockwise=False, + ) + cmd.execute() + + # After execute: origin + center + start + end = initial + 1 (end) + # because center and start are removed then re-added + assert len(sketch.registry.entities) == 1 + + cmd.undo() + + # After undo: all added points should be removed + # Only origin should remain + assert len(sketch.registry.entities) == 0 + # Check that temp points are NOT left dangling + # Origin (id=0) should be the only remaining point + remaining_ids = [p.id for p in sketch.registry.points] + assert 0 in remaining_ids # origin + + +def test_arc_direction_matches_preview_after_off_circle_click(): + """Test that direction matches preview even when click is off circle.""" + sketch = Sketch() + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + # Simulate preview with mouse exactly on circle at (0, 50) + state = ArcCommand.start_preview( + sketch.registry, + 0, + 50, + center_id=center_id, + center_temp=False, + start_id=start_id, + start_temp=False, + ) + + # Update preview with mouse on circle + ArcCommand.update_preview(sketch.registry, state, 0, 50) + ArcCommand.cleanup_preview(sketch.registry, state) + preview_clockwise = state.clockwise + + # Now simulate a click slightly off the circle + # The command will project this onto the circle + off_circle_pos = (2, 48) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + off_circle_pos, + is_center_temp=False, + is_start_temp=False, + clockwise=preview_clockwise, + ) + cmd.execute() + + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + # The arc direction should match the preview, not be recalculated + assert arc.clockwise == preview_clockwise + + +def test_arc_command_undo_restores_temp_points_to_original_state(): + """Test that undo removes all added items, temp points are not restored.""" + sketch = Sketch() + + # Manually add temp points to simulate tool state + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(50, 0) + + # Record state before command + entities_before = len(sketch.registry.entities) + + cmd = ArcCommand( + sketch, + center_id, + start_id, + (0, 50), + is_center_temp=True, + is_start_temp=True, + clockwise=False, + ) + cmd.execute() + + # Verify something was added + assert len(sketch.registry.entities) > entities_before + + cmd.undo() + + # After undo, entities should be back to original + assert len(sketch.registry.entities) == entities_before + + # Points should be back to original (only origin for a fresh sketch) + # The temp center and start should NOT be restored since they were temp + assert len(sketch.registry.points) == 1 # Only origin + assert sketch.registry.points[0].id == 0 # Only origin + + +def test_arc_preview_get_dimensions_returns_radius(): + """Test that dimension shows radius at arc midpoint.""" + sketch = Sketch() + state = ArcCommand.start_center_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + ArcCommand.set_start_point(sketch.registry, state, 50, 0) + ArcCommand.update_preview(sketch.registry, state, 0, 50) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 1 + assert dims[0].label == "R50.00" + assert dims[0].leader_end is None + + +def test_arc_preview_get_dimensions_no_dimensions_before_start(): + """Test that no dimensions before start point is set.""" + sketch = Sketch() + state = ArcCommand.start_center_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + dims = state.get_dimensions(sketch.registry) + + assert dims == [] + + +def test_arc_preview_get_dimensions_position_on_arc(): + """Test that dimension position is at arc midpoint on radius.""" + sketch = Sketch() + state = ArcCommand.start_center_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + ArcCommand.set_start_point(sketch.registry, state, 100, 0) + ArcCommand.update_preview(sketch.registry, state, 0, 100) + + dims = state.get_dimensions(sketch.registry) + + pos = dims[0].position + dist_to_center = math.hypot(pos[0], pos[1]) + assert abs(dist_to_center - 100.0) < 0.01 + + +def test_arc_preview_get_dimensions_label_on_arc(): + """Test that label is positioned on the arc radius.""" + sketch = Sketch() + state = ArcCommand.start_center_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + ArcCommand.set_start_point(sketch.registry, state, 50, 0) + ArcCommand.update_preview(sketch.registry, state, 0, 50) + + dims = state.get_dimensions(sketch.registry) + + pos = dims[0].position + dist_to_center = math.hypot(pos[0], pos[1]) + assert abs(dist_to_center - 50.0) < 0.01 + + +def test_arc_preview_get_dimensions_missing_point(): + """Test that missing points return empty list.""" + sketch = Sketch() + state = ArcCommand.start_center_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + ArcCommand.set_start_point(sketch.registry, state, 50, 0) + sketch.registry.points.clear() + + dims = state.get_dimensions(sketch.registry) + + assert dims == [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_base_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_base_cmd.py new file mode 100644 index 000000000..e86cbd612 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_base_cmd.py @@ -0,0 +1,148 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import SketchChangeCommand + + +class ConcreteSketchChangeCommand(SketchChangeCommand): + """Concrete implementation for testing SketchChangeCommand.""" + + def __init__(self, sketch: "Sketch", name: str = "Test Command"): + super().__init__(sketch, name) + self.executed = False + self.undone = False + + def _do_execute(self) -> None: + self.executed = True + + def _do_undo(self) -> None: + self.undone = True + + +@pytest.fixture +def sketch(): + """Create a basic sketch for testing.""" + return Sketch() + + +@pytest.fixture +def command(sketch): + """Create a concrete command for testing.""" + return ConcreteSketchChangeCommand(sketch, "Test Command") + + +def test_sketch_change_command_initialization(sketch): + """Test that SketchChangeCommand initializes correctly.""" + cmd = ConcreteSketchChangeCommand(sketch, "Test Command") + assert cmd.sketch is sketch + assert cmd.name == "Test Command" + assert cmd._snapshot is None + assert not cmd.executed + assert not cmd.undone + + +def test_capture_snapshot(sketch, command): + """Test that capture_snapshot stores point coordinates.""" + p1_id = sketch.add_point(10.0, 20.0) + p2_id = sketch.add_point(30.0, 40.0) + + command.capture_snapshot() + + snapshot = command._snapshot + assert snapshot is not None + points, _entities = snapshot + # Origin point (created by Sketch init) + 2 added points = 3 + assert len(points) == 3 + assert points[p1_id] == (10.0, 20.0) + assert points[p2_id] == (30.0, 40.0) + + +def test_restore_snapshot(sketch, command): + """Test that restore_snapshot restores point coordinates.""" + p1_id = sketch.add_point(10.0, 20.0) + p2_id = sketch.add_point(30.0, 40.0) + + command.capture_snapshot() + + sketch.registry.get_point(p1_id).x = 100.0 + sketch.registry.get_point(p1_id).y = 200.0 + sketch.registry.get_point(p2_id).x = 300.0 + sketch.registry.get_point(p2_id).y = 400.0 + + command.restore_snapshot() + + assert sketch.registry.get_point(p1_id).x == 10.0 + assert sketch.registry.get_point(p1_id).y == 20.0 + assert sketch.registry.get_point(p2_id).x == 30.0 + assert sketch.registry.get_point(p2_id).y == 40.0 + + +def test_restore_snapshot_with_missing_point(sketch, command): + """Test that restore_snapshot handles missing points gracefully.""" + sketch.add_point(10.0, 20.0) + command.capture_snapshot() + + sketch.registry.points = [] + + command.restore_snapshot() + + assert not command.executed + + +def test_execute_captures_snapshot_if_empty(sketch, command): + """Test that execute captures snapshot if not already done.""" + p1_id = sketch.add_point(10.0, 20.0) + + command.execute() + + snapshot = command._snapshot + assert snapshot is not None + points, _entities = snapshot + assert points[p1_id] == (10.0, 20.0) + assert command.executed + + +def test_execute_uses_existing_snapshot(sketch): + """Test that execute uses existing snapshot.""" + p1_id = sketch.add_point(10.0, 20.0) + cmd = ConcreteSketchChangeCommand(sketch, "Test Command") + + cmd.capture_snapshot() + sketch.registry.get_point(p1_id).x = 100.0 + + cmd.execute() + + snapshot = cmd._snapshot + assert snapshot is not None + points, _entities = snapshot + assert points[p1_id] == (10.0, 20.0) + assert cmd.executed + + +def test_undo_restores_snapshot(sketch, command): + """Test that undo restores the snapshot.""" + p1_id = sketch.add_point(10.0, 20.0) + + command.execute() + sketch.registry.get_point(p1_id).x = 100.0 + sketch.registry.get_point(p1_id).y = 200.0 + + command.undo() + + assert command.undone + assert sketch.registry.get_point(p1_id).x == 10.0 + assert sketch.registry.get_point(p1_id).y == 20.0 + + +def test_abstract_methods_raise_not_implemented(sketch): + """Test that abstract methods raise NotImplementedError.""" + + class IncompleteCommand(SketchChangeCommand): + pass + + cmd = IncompleteCommand(sketch, "Incomplete") + + with pytest.raises(NotImplementedError): + cmd._do_execute() + + with pytest.raises(NotImplementedError): + cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_chamfer_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_chamfer_cmd.py new file mode 100644 index 000000000..a681cc9c4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_chamfer_cmd.py @@ -0,0 +1,148 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ChamferCommand +from sketcher.core.constraints import ( + CollinearConstraint, + EqualDistanceConstraint, +) +from sketcher.core.entities import Line + +# This is a constant from the implementation, let's use it for consistency +DEFAULT_CHAMFER_DISTANCE = 10.0 + + +@pytest.fixture +def sketch_with_corner(): + """Creates a sketch with two lines forming a corner at (0,0).""" + s = Sketch() + # p1=(-100, 0), p2=(0, 0) [corner], p3=(0, 100) + # IDs: origin=0, p1=1, corner=2, p3=3, line1=4, line2=5 + p1_id = s.add_point(-100, 0) + corner_pid = s.add_point(0, 0) + p3_id = s.add_point(0, 100) + + # line1 from p1 to corner, line2 from corner to p3 + line1_id = s.add_line(p1_id, corner_pid) + line2_id = s.add_line(corner_pid, p3_id) + + return s, corner_pid, line1_id, line2_id + + +def test_chamfer_calculate_geometry(sketch_with_corner): + """Test the static geometry calculation for a valid chamfer.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + result = ChamferCommand.calculate_geometry( + sketch.registry, + corner_pid, + line1_id, + line2_id, + DEFAULT_CHAMFER_DISTANCE, + ) + assert result is not None + assert len(result["points"]) == 2 + assert len(result["entities"]) == 3 + assert len(result["constraints"]) == 3 + assert len(result["removed_entities"]) == 2 + + # Check that new points are in correct positions + p1, p2 = result["points"] + assert p1.x == pytest.approx(-10.0) + assert p1.y == pytest.approx(0.0) + assert p2.x == pytest.approx(0.0) + assert p2.y == pytest.approx(10.0) + + +def test_chamfer_calculate_geometry_too_short(sketch_with_corner): + """Test that calculation fails if lines are shorter than chamfer dist.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + result = ChamferCommand.calculate_geometry( + sketch.registry, + corner_pid, + line1_id, + line2_id, + 200.0, # Too large + ) + assert result is None + + +def test_chamfer_command_execute(sketch_with_corner): + """Test the direct execution of ChamferCommand.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + + initial_points_count = len(sketch.registry.points) + initial_entities_count = len(sketch.registry.entities) + initial_constraints_count = len(sketch.constraints) + + command = ChamferCommand( + sketch, corner_pid, line1_id, line2_id, DEFAULT_CHAMFER_DISTANCE + ) + command.execute() + + # Verify additions + assert len(sketch.registry.points) == initial_points_count + 2 + assert ( + len(sketch.registry.entities) == initial_entities_count + 1 + ) # 2 removed, 3 added + assert len(sketch.constraints) == initial_constraints_count + 3 + + # Verify new line and constraints + assert command.add_cmd is not None + assert len(command.add_cmd.entities) == 3 + chamfer_line = command.add_cmd.entities[0] + new_line = sketch.registry.get_entity(chamfer_line.id) + assert new_line is not None + assert isinstance(new_line, Line) + + new_constraints = sketch.constraints[-3:] + assert isinstance(new_constraints[0], CollinearConstraint) + assert isinstance(new_constraints[1], CollinearConstraint) + assert isinstance(new_constraints[2], EqualDistanceConstraint) + + # Verify original lines were removed + assert sketch.registry.get_entity(line1_id) is None + assert sketch.registry.get_entity(line2_id) is None + + +def test_chamfer_command_undo(sketch_with_corner): + """Test that undoing a ChamferCommand restores the original state.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + + line1 = sketch.registry.get_entity(line1_id) + line2 = sketch.registry.get_entity(line2_id) + assert isinstance(line1, Line) + assert isinstance(line2, Line) + + # Store initial state + initial_state = { + "points_count": len(sketch.registry.points), + "entities_count": len(sketch.registry.entities), + "constraints_count": len(sketch.constraints), + "line1_p1": line1.p1_idx, + "line1_p2": line1.p2_idx, + "line2_p1": line2.p1_idx, + "line2_p2": line2.p2_idx, + } + + command = ChamferCommand( + sketch, corner_pid, line1_id, line2_id, DEFAULT_CHAMFER_DISTANCE + ) + command.execute() + + # Sanity check that something changed + assert len(sketch.registry.points) != initial_state["points_count"] + + command.undo() + + # Verify state is restored + assert len(sketch.registry.points) == initial_state["points_count"] + assert len(sketch.registry.entities) == initial_state["entities_count"] + assert len(sketch.constraints) == initial_state["constraints_count"] + + restored_line1 = sketch.registry.get_entity(line1_id) + restored_line2 = sketch.registry.get_entity(line2_id) + assert isinstance(restored_line1, Line) + assert isinstance(restored_line2, Line) + assert restored_line1.p1_idx == initial_state["line1_p1"] + assert restored_line1.p2_idx == initial_state["line1_p2"] + assert restored_line2.p1_idx == initial_state["line2_p1"] + assert restored_line2.p2_idx == initial_state["line2_p2"] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_circle_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_circle_cmd.py new file mode 100644 index 000000000..94dbbd02d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_circle_cmd.py @@ -0,0 +1,336 @@ +import math + +from sketcher.core import Sketch +from sketcher.core.commands import CircleCommand, CirclePreviewState +from sketcher.core.entities import Circle, Point + + +def test_circle_command_execute_no_snap(): + """Test command execution with no point snapping.""" + sketch = Sketch() + center_pid = sketch.add_point(0, 0) + cmd = CircleCommand(sketch, center_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.points) == 3 + assert len(sketch.registry.entities) == 1 + + circle = sketch.registry.entities[0] + assert isinstance(circle, Circle) + assert circle.center_idx == center_pid + + +def test_circle_command_execute_with_snap(): + """Test command execution when snapping to an existing end point.""" + sketch = Sketch() + center_pid = sketch.add_point(0, 0) + radius_pid = sketch.add_point(100, 50) + cmd = CircleCommand(sketch, center_pid, (100, 50), end_pid=radius_pid) + cmd.execute() + + assert len(sketch.registry.points) == 3 + circle = sketch.registry.entities[0] + assert isinstance(circle, Circle) + assert circle.radius_pt_idx == radius_pid + + +def test_circle_command_execute_temp_center(): + """Test command execution when the center point was temporary.""" + sketch = Sketch() + center_pid = 100 + sketch.registry.points.append(Point(center_pid, 0, 0)) + assert len(sketch.registry.points) == 2 + + cmd = CircleCommand(sketch, center_pid, (100, 50), is_center_temp=True) + cmd.execute() + + assert len(sketch.registry.points) == 3 + assert len(sketch.registry.entities) == 1 + assert cmd.add_cmd is not None + + re_added_center = next( + p for p in cmd.add_cmd.points if p.x == 0 and p.y == 0 + ) + assert re_added_center.id != 100 + + +def test_circle_command_execute_center_equals_radius(): + """Test command does nothing if center equals radius point.""" + sketch = Sketch() + center_pid = sketch.add_point(0, 0) + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = CircleCommand(sketch, center_pid, (0, 0), end_pid=center_pid) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_circle_command_undo(): + """Test undo removes all added items.""" + sketch = Sketch() + center_pid = sketch.add_point(0, 0) + + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = CircleCommand(sketch, center_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + 1 + assert len(sketch.registry.entities) == initial_entity_count + 1 + + cmd.undo() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_circle_command_undo_with_temp_center(): + """Test undo with temporary center point.""" + sketch = Sketch() + center_pid = sketch.add_point(0, 0) + + cmd = CircleCommand(sketch, center_pid, (100, 50), is_center_temp=True) + cmd.execute() + + assert len(sketch.registry.entities) == 1 + + cmd.undo() + + assert len(sketch.registry.entities) == 0 + assert len(sketch.registry.points) == 1 + assert sketch.registry.points[0].id == 0 + + +def test_circle_start_preview_no_snap(): + """Test start_preview creates initial preview state with temp point.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None + ) + + assert isinstance(state, CirclePreviewState) + assert state.center_temp is True + assert state.radius_id is not None + assert state.entity_id is not None + + center_p = sketch.registry.get_point(state.center_id) + assert center_p.x == 10 + assert center_p.y == 20 + + radius_p = sketch.registry.get_point(state.radius_id) + assert radius_p.x == 10 + assert radius_p.y == 20 + + +def test_circle_start_preview_with_snap(): + """Test start_preview uses existing point when snapped.""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 60) + state = CircleCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=existing_pid + ) + + assert isinstance(state, CirclePreviewState) + assert state.center_temp is False + assert state.center_id == existing_pid + + +def test_circle_update_preview(): + """Test update_preview moves radius point.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + CircleCommand.update_preview(sketch.registry, state, 100, 50) + + radius_p = sketch.registry.get_point(state.radius_id) + assert radius_p.x == 100 + assert radius_p.y == 50 + + +def test_circle_cleanup_preview(): + """Test cleanup_preview removes preview entities but leaves center.""" + sketch = Sketch() + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + assert len(sketch.registry.points) > initial_point_count + assert len(sketch.registry.entities) > initial_entity_count + + CircleCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == initial_entity_count + remaining_preview_ids = {state.center_id} + for p in sketch.registry.points: + if p.id != 0: + assert p.id in remaining_preview_ids + + +def test_circle_cleanup_preview_with_snapped_center(): + """Test cleanup when center point was snapped (not temp).""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 50) + initial_point_count = len(sketch.registry.points) + + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=existing_pid + ) + + CircleCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.points) == initial_point_count + + +def test_circle_preview_lifecycle(): + """Test full preview lifecycle: start -> update -> cleanup.""" + sketch = Sketch() + + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + assert state.center_temp is True + + CircleCommand.update_preview(sketch.registry, state, 50, 50) + radius_p = sketch.registry.get_point(state.radius_id) + assert radius_p.x == 50 + assert radius_p.y == 50 + + CircleCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == 0 + assert state.center_id in [p.id for p in sketch.registry.points] + + +def test_circle_preview_then_execute(): + """Test executing a circle after preview.""" + sketch = Sketch() + + state = CircleCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None + ) + + CircleCommand.update_preview(sketch.registry, state, 50, 30) + + center_id = state.center_id + center_temp = state.center_temp + + CircleCommand.cleanup_preview(sketch.registry, state) + + cmd = CircleCommand( + sketch, center_id, (50, 30), is_center_temp=center_temp + ) + cmd.execute() + + assert len(sketch.registry.entities) == 1 + circle = sketch.registry.entities[0] + assert isinstance(circle, Circle) + assert cmd.add_cmd is not None + center_point = next( + (p for p in cmd.add_cmd.points if p.x == 10 and p.y == 20), None + ) + assert center_point is not None + assert circle.center_idx == center_point.id + + +def test_circle_undo_no_dangling_points(): + """Test undo removes all added points including temp radius.""" + sketch = Sketch() + center_pid = sketch.add_point(0, 0) + + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = CircleCommand(sketch, center_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + 1 + assert len(sketch.registry.entities) > initial_entity_count + + cmd.undo() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_circle_preview_get_dimensions_returns_diameter(): + """Test that dimension shows diameter at edge.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + CircleCommand.update_preview(sketch.registry, state, 50, 0) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 1 + assert dims[0].label == "Ø100.00" + assert dims[0].leader_end is None + assert dims[0].position == (50.0, 0.0) + + +def test_circle_preview_get_dimensions_diagonal_diameter(): + """Test diameter dimension for diagonal radius point.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + CircleCommand.update_preview(sketch.registry, state, 30, 40) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 1 + expected_diameter = 2 * math.hypot(30, 40) + assert dims[0].label == f"Ø{expected_diameter:.2f}" + assert dims[0].leader_end is None + + +def test_circle_preview_get_dimensions_position_on_circle_edge(): + """Test that position is on the circle's edge.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + CircleCommand.update_preview(sketch.registry, state, 100, 0) + + dims = state.get_dimensions(sketch.registry) + + pos = dims[0].position + dist_to_center = math.hypot(pos[0], pos[1]) + assert abs(dist_to_center - 100.0) < 0.01 + + +def test_circle_preview_get_dimensions_label_on_circle(): + """Test that label is positioned on the circle edge.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + CircleCommand.update_preview(sketch.registry, state, 50, 0) + + dims = state.get_dimensions(sketch.registry) + + pos = dims[0].position + dist_to_center = math.hypot(pos[0], pos[1]) + assert abs(dist_to_center - 50.0) < 0.01 + + +def test_circle_preview_get_dimensions_missing_point(): + """Test that missing points return empty list.""" + sketch = Sketch() + state = CircleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + sketch.registry.points.clear() + + dims = state.get_dimensions(sketch.registry) + + assert dims == [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_constraint_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_constraint_cmd.py new file mode 100644 index 000000000..5b6e01e8c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_constraint_cmd.py @@ -0,0 +1,111 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ModifyConstraintCommand +from sketcher.core.constraints import DistanceConstraint + + +@pytest.fixture +def sketch(): + """Create a basic sketch for testing.""" + return Sketch() + + +@pytest.fixture +def constraint(sketch): + """Create a distance constraint for testing.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + return DistanceConstraint(p1, p2, 10.0) + + +def test_modify_constraint_command_initialization(sketch, constraint): + """Test that ModifyConstraintCommand initializes correctly.""" + cmd = ModifyConstraintCommand( + sketch, constraint, 20.0, "20", "Edit Constraint" + ) + + assert cmd.sketch is sketch + assert cmd.constraint is constraint + assert cmd.new_value == 20.0 + assert cmd.new_expression == "20" + assert cmd.old_value == 10.0 + assert cmd.old_expression is None + + +def test_modify_constraint_command_with_existing_expression(sketch): + """Test initialization when constraint has an expression.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + constraint = DistanceConstraint(p1, p2, 10.0) + constraint.expression = "10" + + cmd = ModifyConstraintCommand(sketch, constraint, 20.0, "20") + + assert cmd.old_expression == "10" + + +def test_modify_constraint_command_execute(sketch, constraint): + """Test that execute modifies the constraint value.""" + cmd = ModifyConstraintCommand(sketch, constraint, 20.0, "20") + + assert constraint.value == 10.0 + assert constraint.expression is None + + cmd.execute() + + assert constraint.value == 20.0 + assert constraint.expression == "20" + + +def test_modify_constraint_cmd_execute_without_expression(sketch, constraint): + """Test execute when no new expression is provided.""" + cmd = ModifyConstraintCommand(sketch, constraint, 20.0) + + cmd.execute() + + assert constraint.value == 20.0 + assert constraint.expression is None + + +def test_modify_constraint_command_undo(sketch, constraint): + """Test that undo restores the original constraint value.""" + cmd = ModifyConstraintCommand(sketch, constraint, 20.0, "20") + + cmd.execute() + assert constraint.value == 20.0 + assert constraint.expression == "20" + + cmd.undo() + + assert constraint.value == 10.0 + assert constraint.expression is None + + +def test_modify_constraint_command_undo_with_expression(sketch): + """Test undo when original constraint had an expression.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + constraint = DistanceConstraint(p1, p2, 10.0) + constraint.expression = "10" + + cmd = ModifyConstraintCommand(sketch, constraint, 20.0, "20") + + cmd.execute() + cmd.undo() + + assert constraint.value == 10.0 + assert constraint.expression == "10" + + +def test_modify_constraint_command_execute_undo_cycle(sketch, constraint): + """Test that execute and undo can be called multiple times.""" + cmd = ModifyConstraintCommand(sketch, constraint, 20.0, "20") + + for _ in range(3): + cmd.execute() + assert constraint.value == 20.0 + assert constraint.expression == "20" + + cmd.undo() + assert constraint.value == 10.0 + assert constraint.expression is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_constraint_create_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_constraint_create_cmd.py new file mode 100644 index 000000000..d6aa7724d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_constraint_create_cmd.py @@ -0,0 +1,276 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import CreateOrEditConstraintCommand +from sketcher.core.constraints import ( + DiameterConstraint, + DistanceConstraint, + RadiusConstraint, +) + + +@pytest.fixture +def sketch(): + return Sketch() + + +def test_create_distance_constraint_for_line(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + cmd = CreateOrEditConstraintCommand(sketch, line) + cmd.execute() + + assert cmd.is_new_constraint + assert cmd.constraint is not None + assert isinstance(cmd.constraint, DistanceConstraint) + assert cmd.constraint.value == pytest.approx(50.0) + assert len(sketch.constraints) == 1 + + +def test_create_diameter_constraint_for_circle(sketch): + center_id = sketch.add_point(0, 0) + radius_id = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center_id, radius_id) + circle = sketch.registry.get_entity(circle_id) + + cmd = CreateOrEditConstraintCommand(sketch, circle) + cmd.execute() + + assert cmd.is_new_constraint + assert cmd.constraint is not None + assert isinstance(cmd.constraint, DiameterConstraint) + assert cmd.constraint.value == pytest.approx(20.0) + assert len(sketch.constraints) == 1 + + +def test_create_radius_constraint_for_arc(sketch): + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(10, 0) + end_id = sketch.add_point(0, 10) + arc_id = sketch.add_arc(center_id, start_id, end_id) + arc = sketch.registry.get_entity(arc_id) + + cmd = CreateOrEditConstraintCommand(sketch, arc) + cmd.execute() + + assert cmd.is_new_constraint + assert cmd.constraint is not None + assert isinstance(cmd.constraint, RadiusConstraint) + assert cmd.constraint.value == pytest.approx(10.0) + assert len(sketch.constraints) == 1 + + +def test_returns_existing_constraint_for_line(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + existing = DistanceConstraint(p1_id, p2_id, 50.0) + sketch.constraints.append(existing) + + cmd = CreateOrEditConstraintCommand(sketch, line) + cmd.execute() + + assert not cmd.is_new_constraint + assert cmd.constraint is existing + assert len(sketch.constraints) == 1 + + +def test_returns_existing_constraint_for_circle(sketch): + center_id = sketch.add_point(0, 0) + radius_id = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center_id, radius_id) + circle = sketch.registry.get_entity(circle_id) + + existing = DiameterConstraint(circle_id, 20.0) + sketch.constraints.append(existing) + + cmd = CreateOrEditConstraintCommand(sketch, circle) + cmd.execute() + + assert not cmd.is_new_constraint + assert cmd.constraint is existing + assert len(sketch.constraints) == 1 + + +def test_returns_existing_constraint_for_arc(sketch): + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(10, 0) + end_id = sketch.add_point(0, 10) + arc_id = sketch.add_arc(center_id, start_id, end_id) + arc = sketch.registry.get_entity(arc_id) + + existing = RadiusConstraint(arc_id, 10.0) + sketch.constraints.append(existing) + + cmd = CreateOrEditConstraintCommand(sketch, arc) + cmd.execute() + + assert not cmd.is_new_constraint + assert cmd.constraint is existing + assert len(sketch.constraints) == 1 + + +def test_undo_removes_created_constraint(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + cmd = CreateOrEditConstraintCommand(sketch, line) + cmd.execute() + + assert len(sketch.constraints) == 1 + + cmd.undo() + + assert len(sketch.constraints) == 0 + + +def test_undo_does_not_affect_existing_constraint(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + existing = DistanceConstraint(p1_id, p2_id, 50.0) + sketch.constraints.append(existing) + + cmd = CreateOrEditConstraintCommand(sketch, line) + cmd.execute() + cmd.undo() + + assert len(sketch.constraints) == 1 + assert sketch.constraints[0] is existing + + +def test_redo_recreates_constraint(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + cmd = CreateOrEditConstraintCommand(sketch, line) + cmd.execute() + cmd.undo() + cmd.execute() + + assert len(sketch.constraints) == 1 + assert isinstance(sketch.constraints[0], DistanceConstraint) + + +def test_command_label_uses_type_name(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + cmd = CreateOrEditConstraintCommand(sketch, line) + label = cmd._get_command_label(DistanceConstraint(p1_id, p2_id, 50.0)) + + assert label == "Add Distance" + + +def test_command_label_for_radius(sketch): + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(10, 0) + end_id = sketch.add_point(0, 10) + arc_id = sketch.add_arc(center_id, start_id, end_id) + arc = sketch.registry.get_entity(arc_id) + + cmd = CreateOrEditConstraintCommand(sketch, arc) + label = cmd._get_command_label(RadiusConstraint(arc_id, 10.0)) + + assert label == "Add Radius" + + +def test_command_label_for_diameter(sketch): + center_id = sketch.add_point(0, 0) + radius_id = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center_id, radius_id) + circle = sketch.registry.get_entity(circle_id) + + cmd = CreateOrEditConstraintCommand(sketch, circle) + label = cmd._get_command_label(DiameterConstraint(circle_id, 20.0)) + + assert label == "Add Diameter" + + +def test_get_constraint_for_entity_line(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + existing = DistanceConstraint(p1_id, p2_id, 50.0) + sketch.constraints.append(existing) + + result = CreateOrEditConstraintCommand.get_constraint_for_entity( + sketch, line + ) + + assert result is existing + + +def test_get_constraint_for_entity_circle(sketch): + center_id = sketch.add_point(0, 0) + radius_id = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center_id, radius_id) + circle = sketch.registry.get_entity(circle_id) + + existing = DiameterConstraint(circle_id, 20.0) + sketch.constraints.append(existing) + + result = CreateOrEditConstraintCommand.get_constraint_for_entity( + sketch, circle + ) + + assert result is existing + + +def test_get_constraint_for_entity_arc(sketch): + center_id = sketch.add_point(0, 0) + start_id = sketch.add_point(10, 0) + end_id = sketch.add_point(0, 10) + arc_id = sketch.add_arc(center_id, start_id, end_id) + arc = sketch.registry.get_entity(arc_id) + + existing = RadiusConstraint(arc_id, 10.0) + sketch.constraints.append(existing) + + result = CreateOrEditConstraintCommand.get_constraint_for_entity( + sketch, arc + ) + + assert result is existing + + +def test_get_constraint_for_entity_none(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + result = CreateOrEditConstraintCommand.get_constraint_for_entity( + sketch, line + ) + + assert result is None + + +def test_create_constraint_for_entity_with_initial_value(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + line_id = sketch.add_line(p1_id, p2_id) + line = sketch.registry.get_entity(line_id) + + result = CreateOrEditConstraintCommand.create_constraint_for_entity( + sketch, line, initial_value=100.0 + ) + + assert result is not None + assert isinstance(result, DistanceConstraint) + assert result.value == 100.0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_construction_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_construction_cmd.py new file mode 100644 index 000000000..06658270a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_construction_cmd.py @@ -0,0 +1,150 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ToggleConstructionCommand + + +@pytest.fixture +def sketch(): + """Create a basic sketch for testing.""" + return Sketch() + + +@pytest.fixture +def entities(sketch): + """Create entities for testing.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + line1_id = sketch.add_line(p1, p2) + p3 = sketch.add_point(10.0, 10.0) + line2_id = sketch.add_line(p2, p3) + return [line1_id, line2_id] + + +def test_toggle_construction_command_initialization(sketch, entities): + """Test that ToggleConstructionCommand initializes correctly.""" + cmd = ToggleConstructionCommand(sketch, "Toggle", entities) + + assert cmd.sketch is sketch + assert cmd.name == "Toggle" + assert cmd.entity_ids == entities + assert cmd.original_states == {} + assert cmd.new_state is None + + +def test_toggle_construction_command_normal_to_construction(sketch, entities): + """Test toggling from normal to construction state.""" + cmd = ToggleConstructionCommand(sketch, "Toggle", entities) + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert not ent.construction + + cmd.execute() + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert ent.construction + + assert cmd.new_state is True + + +def test_toggle_construction_command_construction_to_normal(sketch, entities): + """Test toggling from construction to normal state.""" + for eid in entities: + ent = sketch.registry.get_entity(eid) + ent.construction = True + + cmd = ToggleConstructionCommand(sketch, "Toggle", entities) + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert ent.construction + + cmd.execute() + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert not ent.construction + + assert cmd.new_state is False + + +def test_toggle_construction_command_mixed_states(sketch, entities): + """Test toggling with mixed initial states.""" + ent1 = sketch.registry.get_entity(entities[0]) + ent1.construction = True + + cmd = ToggleConstructionCommand(sketch, "Toggle", entities) + + cmd.execute() + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert ent.construction + + assert cmd.new_state is True + + +def test_toggle_construction_command_undo(sketch, entities): + """Test that undo restores original construction states.""" + ent1 = sketch.registry.get_entity(entities[0]) + ent1.construction = True + + cmd = ToggleConstructionCommand(sketch, "Toggle", entities) + cmd.execute() + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert ent.construction + + cmd.undo() + + assert sketch.registry.get_entity(entities[0]).construction is True + assert sketch.registry.get_entity(entities[1]).construction is False + + +def test_toggle_construction_command_with_invalid_entity_id(sketch): + """Test that invalid entity IDs are handled gracefully.""" + cmd = ToggleConstructionCommand(sketch, "Toggle", [9999]) + + cmd.execute() + + assert cmd.original_states == {} + assert cmd.new_state is None + + +def test_toggle_construction_cmd_with_mixed_valid_invalid(sketch, entities): + """Test with a mix of valid and invalid entity IDs.""" + cmd = ToggleConstructionCommand(sketch, "Toggle", entities + [9999]) + + cmd.execute() + + for eid in entities: + ent = sketch.registry.get_entity(eid) + assert ent.construction + + assert cmd.new_state is True + + +def test_toggle_construction_command_execute_undo_cycle(sketch, entities): + """Test that execute and undo can be called multiple times.""" + cmd = ToggleConstructionCommand(sketch, "Toggle", entities) + + for _ in range(3): + cmd.execute() + for eid in entities: + assert sketch.registry.get_entity(eid).construction + + cmd.undo() + for eid in entities: + assert not sketch.registry.get_entity(eid).construction + + +def test_toggle_construction_command_empty_entity_list(sketch): + """Test with an empty list of entity IDs.""" + cmd = ToggleConstructionCommand(sketch, "Toggle", []) + + cmd.execute() + + assert cmd.original_states == {} + assert cmd.new_state is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_distance_constraint_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_distance_constraint_cmd.py new file mode 100644 index 000000000..4b1a654cf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_distance_constraint_cmd.py @@ -0,0 +1,145 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + DistanceConstraintCommand, + DistanceConstraintParams, +) + + +@pytest.fixture +def sketch(): + return Sketch() + + +def test_calculate_distance_two_points(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [p1_id, p2_id], [] + ) + + assert result is not None + assert isinstance(result, DistanceConstraintParams) + assert result.distance == pytest.approx(50.0) + assert result.p1_id == p1_id + assert result.p2_id == p2_id + + +def test_calculate_distance_single_line(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(60, 80) + line_id = sketch.add_line(p1_id, p2_id) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [], [line_id] + ) + + assert result is not None + assert result.distance == pytest.approx(100.0) + assert result.p1_id == p1_id + assert result.p2_id == p2_id + + +def test_calculate_distance_no_selection(sketch): + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [], [] + ) + + assert result is None + + +def test_calculate_distance_one_point(sketch): + p1_id = sketch.add_point(0, 0) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [p1_id], [] + ) + + assert result is None + + +def test_calculate_distance_three_points(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + p3_id = sketch.add_point(20, 0) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [p1_id, p2_id, p3_id], [] + ) + + assert result is None + + +def test_calculate_distance_multiple_entities(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + p3_id = sketch.add_point(20, 0) + p4_id = sketch.add_point(30, 0) + line1_id = sketch.add_line(p1_id, p2_id) + line2_id = sketch.add_line(p3_id, p4_id) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [], [line1_id, line2_id] + ) + + assert result is None + + +def test_calculate_distance_non_line_entity(sketch): + center_id = sketch.add_point(0, 0) + radius_id = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center_id, radius_id) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [], [circle_id] + ) + + assert result is None + + +def test_calculate_distance_zero_distance(sketch): + p1_id = sketch.add_point(5, 5) + p2_id = sketch.add_point(5, 5) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [p1_id, p2_id], [] + ) + + assert result is not None + assert result.distance == pytest.approx(0.0) + + +def test_calculate_distance_from_points(sketch): + p1 = sketch.registry.get_point(sketch.add_point(0, 0)) + p2 = sketch.registry.get_point(sketch.add_point(3, 4)) + + result = DistanceConstraintCommand.calculate_distance_from_points(p1, p2) + + assert result == pytest.approx(5.0) + + +def test_calculate_distance_from_points_negative_coords(sketch): + p1 = sketch.registry.get_point(sketch.add_point(-10, -10)) + p2 = sketch.registry.get_point(sketch.add_point(-13, -14)) + + result = DistanceConstraintCommand.calculate_distance_from_points(p1, p2) + + assert result == pytest.approx(5.0) + + +def test_calculate_distance_prefers_points_over_entity(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(30, 40) + p3_id = sketch.add_point(0, 0) + p4_id = sketch.add_point(60, 80) + line_id = sketch.add_line(p3_id, p4_id) + + result = DistanceConstraintCommand.calculate_distance( + sketch.registry, [p1_id, p2_id], [line_id] + ) + + assert result is not None + assert result.distance == pytest.approx(50.0) + assert result.p1_id == p1_id + assert result.p2_id == p2_id diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_ellipse_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_ellipse_cmd.py new file mode 100644 index 000000000..2a8e5c761 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_ellipse_cmd.py @@ -0,0 +1,451 @@ +from sketcher.core import Sketch +from sketcher.core.commands import EllipseCommand, EllipsePreviewState +from sketcher.core.constraints import EqualDistanceConstraint +from sketcher.core.entities import Ellipse, Line, Point + + +def test_ellipse_command_execute_no_snap(): + """Test command execution with no point snapping.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = EllipseCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.entities) == 5 + assert len(sketch.registry.points) == 5 + + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + assert ellipse is not None + + +def test_ellipse_command_execute_with_snap(): + """Test command execution when snapping to an existing end point.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + cmd = EllipseCommand(sketch, start_pid, (100, 50), end_pid=end_pid) + cmd.execute() + + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + assert ellipse is not None + + +def test_ellipse_command_execute_temp_start(): + """Test command execution when the start point was temporary.""" + sketch = Sketch() + start_pid = 100 + sketch.registry.points.append(Point(start_pid, 0, 0)) + + cmd = EllipseCommand(sketch, start_pid, (100, 50), is_start_temp=True) + cmd.execute() + + assert len(sketch.registry.entities) == 5 + assert cmd.add_cmd is not None + + +def test_ellipse_command_execute_center_on_start(): + """Test command with center_on_start=True.""" + sketch = Sketch() + start_pid = sketch.add_point(50, 50) + cmd = EllipseCommand(sketch, start_pid, (100, 80), center_on_start=True) + cmd.execute() + + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + center = sketch.registry.get_point(ellipse.center_idx) + assert center.x == 50.0 + assert center.y == 50.0 + + +def test_ellipse_command_execute_constrain_circle(): + """Test command with constrain_circle=True.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = EllipseCommand(sketch, start_pid, (100, 50), constrain_circle=True) + cmd.execute() + + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + rx, ry = ellipse._get_radii(sketch.registry) + assert rx == ry + assert rx == 25.0 + + +def test_ellipse_command_execute_center_on_start_constrain_circle(): + """Test command with both center_on_start and constrain_circle.""" + sketch = Sketch() + start_pid = sketch.add_point(50, 50) + cmd = EllipseCommand( + sketch, + start_pid, + (100, 80), + center_on_start=True, + constrain_circle=True, + ) + cmd.execute() + + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + center = sketch.registry.get_point(ellipse.center_idx) + assert center.x == 50.0 + assert center.y == 50.0 + rx, ry = ellipse._get_radii(sketch.registry) + assert rx == ry + + +def test_ellipse_command_execute_zero_radii(): + """Test command does nothing if radii would be zero.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + initial_entity_count = len(sketch.registry.entities) + + cmd = EllipseCommand(sketch, start_pid, (0, 0)) + cmd.execute() + + assert len(sketch.registry.entities) == initial_entity_count + + +def test_ellipse_command_undo(): + """Test undo removes all added items.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + + initial_entity_count = len(sketch.registry.entities) + + cmd = EllipseCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.entities) > initial_entity_count + + cmd.undo() + + assert len(sketch.registry.entities) == initial_entity_count + + +def test_ellipse_command_undo_with_temp_start(): + """Test undo with temporary start point.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + + cmd = EllipseCommand(sketch, start_pid, (100, 50), is_start_temp=True) + cmd.execute() + + assert len(sketch.registry.entities) == 5 + + cmd.undo() + + assert len(sketch.registry.entities) == 0 + + +def test_calculate_ellipse_params_default(): + """Test _calculate_ellipse_params with default mode.""" + cx, cy, rx, ry = EllipseCommand._calculate_ellipse_params( + 0, 0, 100, 50, center_on_start=False, constrain_circle=False + ) + assert cx == 50.0 + assert cy == 25.0 + assert rx == 50.0 + assert ry == 25.0 + + +def test_calculate_ellipse_params_center_on_start(): + """Test _calculate_ellipse_params with center_on_start=True.""" + cx, cy, rx, ry = EllipseCommand._calculate_ellipse_params( + 50, 50, 100, 80, center_on_start=True, constrain_circle=False + ) + assert cx == 50.0 + assert cy == 50.0 + assert rx == 50.0 + assert ry == 30.0 + + +def test_calculate_ellipse_params_constrain_circle(): + """Test _calculate_ellipse_params with constrain_circle=True.""" + cx, cy, rx, ry = EllipseCommand._calculate_ellipse_params( + 0, 0, 100, 50, center_on_start=False, constrain_circle=True + ) + assert rx == ry + assert rx == 25.0 + assert cx == 25.0 + assert cy == 25.0 + + +def test_calculate_ellipse_params_constrain_circle_negative_direction(): + """Test constrain_circle with negative drag direction.""" + _cx, _cy, rx, ry = EllipseCommand._calculate_ellipse_params( + 100, 100, 0, 50, center_on_start=False, constrain_circle=True + ) + assert rx == ry + assert rx == 25.0 + + +def test_calculate_ellipse_params_center_on_start_constrain_circle(): + """Test _calculate_ellipse_params with both modifiers.""" + cx, cy, rx, ry = EllipseCommand._calculate_ellipse_params( + 50, 50, 100, 30, center_on_start=True, constrain_circle=True + ) + assert cx == 50.0 + assert cy == 50.0 + assert rx == ry + assert rx == 20.0 + + +def test_ellipse_start_preview_no_snap(): + """Test start_preview creates initial preview state with temp point.""" + sketch = Sketch() + state = EllipseCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None + ) + + assert isinstance(state, EllipsePreviewState) + assert state.start_temp is True + assert state.center_id is not None + assert state.radius_x_id is not None + assert state.radius_y_id is not None + assert state.entity_id is not None + + center_p = sketch.registry.get_point(state.center_id) + assert center_p.x == 10 + assert center_p.y == 20 + + +def test_ellipse_start_preview_with_snap(): + """Test start_preview uses existing point when snapped.""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 60) + state = EllipseCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=existing_pid + ) + + assert isinstance(state, EllipsePreviewState) + assert state.start_temp is False + assert state.start_id == existing_pid + + +def test_ellipse_update_preview(): + """Test update_preview moves ellipse points.""" + sketch = Sketch() + state = EllipseCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + EllipseCommand.update_preview( + sketch.registry, + state, + 100, + 50, + center_on_start=False, + constrain_circle=False, + ) + + center_p = sketch.registry.get_point(state.center_id) + radius_x_p = sketch.registry.get_point(state.radius_x_id) + radius_y_p = sketch.registry.get_point(state.radius_y_id) + + assert center_p.x == 50.0 + assert center_p.y == 25.0 + assert radius_x_p.x == 100.0 + assert radius_y_p.y == 50.0 + + +def test_ellipse_update_preview_center_on_start(): + """Test update_preview with center_on_start=True.""" + sketch = Sketch() + state = EllipseCommand.start_preview( + sketch.registry, 50, 50, snapped_pid=None + ) + + EllipseCommand.update_preview( + sketch.registry, + state, + 100, + 80, + center_on_start=True, + constrain_circle=False, + ) + + center_p = sketch.registry.get_point(state.center_id) + assert center_p.x == 50.0 + assert center_p.y == 50.0 + + +def test_ellipse_update_preview_constrain_circle(): + """Test update_preview with constrain_circle=True.""" + sketch = Sketch() + state = EllipseCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + EllipseCommand.update_preview( + sketch.registry, + state, + 100, + 50, + center_on_start=False, + constrain_circle=True, + ) + + center_p = sketch.registry.get_point(state.center_id) + radius_x_p = sketch.registry.get_point(state.radius_x_id) + radius_y_p = sketch.registry.get_point(state.radius_y_id) + + rx = abs(radius_x_p.x - center_p.x) + ry = abs(radius_y_p.y - center_p.y) + assert rx == ry + + +def test_ellipse_cleanup_preview(): + """Test cleanup_preview removes preview entities.""" + sketch = Sketch() + initial_entity_count = len(sketch.registry.entities) + + state = EllipseCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + assert len(sketch.registry.entities) > initial_entity_count + + EllipseCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == initial_entity_count + + +def test_ellipse_cleanup_preview_with_snapped_start(): + """Test cleanup when start point was snapped (not temp).""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 50) + + state = EllipseCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=existing_pid + ) + + EllipseCommand.cleanup_preview(sketch.registry, state) + + assert existing_pid in [p.id for p in sketch.registry.points] + + +def test_ellipse_preview_lifecycle(): + """Test full preview lifecycle: start -> update -> cleanup.""" + sketch = Sketch() + + state = EllipseCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + assert state.start_temp is True + + EllipseCommand.update_preview( + sketch.registry, + state, + 50, + 50, + center_on_start=False, + constrain_circle=False, + ) + center_p = sketch.registry.get_point(state.center_id) + assert center_p.x == 25.0 + assert center_p.y == 25.0 + + EllipseCommand.cleanup_preview(sketch.registry, state) + + assert ( + len([e for e in sketch.registry.entities if isinstance(e, Ellipse)]) + == 0 + ) + + +def test_ellipse_preview_get_preview_point_ids(): + """Test EllipsePreviewState.get_preview_point_ids.""" + state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + assert state.get_preview_point_ids() == {2, 3, 4} + + +def test_ellipse_preview_get_hidden_point_ids(): + """Test EllipsePreviewState.get_hidden_point_ids.""" + state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + assert state.get_hidden_point_ids() == {1} + + +def test_ellipse_creates_helper_lines(): + """Test that ellipse creation creates helper construction lines.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = EllipseCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + assert len(ellipse.helper_line_ids) == 2 + + lines = [e for e in sketch.registry.entities if isinstance(e, Line)] + assert len(lines) == 4 + + construction_lines = [ln for ln in lines if ln.construction] + assert len(construction_lines) == 2 + + invisible_lines = [ln for ln in lines if ln.invisible] + assert len(invisible_lines) == 2 + + +def test_ellipse_creates_perpendicular_constraint(): + """Test that ellipse creation creates perpendicular constraint.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = EllipseCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert len(sketch.constraints) == 1 + + +def test_ellipse_constrain_circle_creates_equal_distance(): + """Test that constrain_circle adds an EqualDistanceConstraint on radii.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = EllipseCommand(sketch, start_pid, (100, 50), constrain_circle=True) + cmd.execute() + + assert len(sketch.constraints) == 2 + equal_constr = next( + c for c in sketch.constraints if isinstance(c, EqualDistanceConstraint) + ) + assert equal_constr is not None + ellipse = next( + e for e in sketch.registry.entities if isinstance(e, Ellipse) + ) + assert equal_constr.p1 == ellipse.center_idx + assert equal_constr.p2 == ellipse.radius_x_pt_idx + assert equal_constr.p3 == ellipse.center_idx + assert equal_constr.p4 == ellipse.radius_y_pt_idx + + +def test_ellipse_no_constrain_circle_no_equal_distance(): + """Test that without constrain_circle, no EqualDistanceConstraint.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = EllipseCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert not any( + isinstance(c, EqualDistanceConstraint) for c in sketch.constraints + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_equal_constraint_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_equal_constraint_cmd.py new file mode 100644 index 000000000..69d2035eb --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_equal_constraint_cmd.py @@ -0,0 +1,155 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + EqualConstraintCommand, + EqualConstraintMergeResult, +) +from sketcher.core.constraints import EqualLengthConstraint + + +@pytest.fixture +def sketch(): + return Sketch() + + +@pytest.fixture +def lines_without_constraints(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + + return sketch, line1_id, line2_id + + +def test_find_and_merge_no_existing_constraints(lines_without_constraints): + sketch, line1_id, line2_id = lines_without_constraints + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, [line1_id, line2_id] + ) + + assert result is not None + assert isinstance(result, EqualConstraintMergeResult) + assert set(result.final_entity_ids) == {line1_id, line2_id} + assert result.constraints_to_remove == [] + + +def test_find_and_merge_with_existing_constraint(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + p5 = sketch.add_point(40, 0) + p6 = sketch.add_point(50, 0) + + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + line3_id = sketch.add_line(p5, p6) + + existing = EqualLengthConstraint([line1_id, line2_id]) + sketch.constraints.append(existing) + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, [line2_id, line3_id] + ) + + assert result is not None + assert set(result.final_entity_ids) == {line1_id, line2_id, line3_id} + assert existing in result.constraints_to_remove + + +def test_find_and_merge_all_new_entities(lines_without_constraints): + sketch, line1_id, line2_id = lines_without_constraints + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, [line1_id, line2_id] + ) + + assert result is not None + assert len(result.final_entity_ids) == 2 + assert len(result.constraints_to_remove) == 0 + + +def test_find_and_merge_single_entity(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, [line_id] + ) + + assert result is not None + assert result.final_entity_ids == [line_id] + assert result.constraints_to_remove == [] + + +def test_find_and_merge_multiple_constraints(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + p5 = sketch.add_point(40, 0) + p6 = sketch.add_point(50, 0) + p7 = sketch.add_point(60, 0) + p8 = sketch.add_point(70, 0) + + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + line3_id = sketch.add_line(p5, p6) + line4_id = sketch.add_line(p7, p8) + + constr1 = EqualLengthConstraint([line1_id, line2_id]) + constr2 = EqualLengthConstraint([line3_id, line4_id]) + sketch.constraints.append(constr1) + sketch.constraints.append(constr2) + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, [line2_id, line3_id] + ) + + assert result is not None + assert set(result.final_entity_ids) == { + line1_id, + line2_id, + line3_id, + line4_id, + } + assert constr1 in result.constraints_to_remove + assert constr2 in result.constraints_to_remove + + +def test_find_and_merge_empty_selection(sketch): + result = EqualConstraintCommand.find_and_merge_constraints(sketch, []) + + assert result is not None + assert result.final_entity_ids == [] + assert result.constraints_to_remove == [] + + +def test_find_and_merge_no_overlap_with_existing(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + p5 = sketch.add_point(40, 0) + p6 = sketch.add_point(50, 0) + + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + line3_id = sketch.add_line(p5, p6) + + existing = EqualLengthConstraint([line1_id, line2_id]) + sketch.constraints.append(existing) + + result = EqualConstraintCommand.find_and_merge_constraints( + sketch, [line3_id] + ) + + assert result is not None + assert result.final_entity_ids == [line3_id] + assert result.constraints_to_remove == [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_fill_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_fill_cmd.py new file mode 100644 index 000000000..05f107062 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_fill_cmd.py @@ -0,0 +1,162 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import AddFillCommand, RemoveFillCommand +from sketcher.core.sketch import Fill + + +@pytest.fixture +def sketch(): + """Create a basic sketch for testing.""" + return Sketch() + + +@pytest.fixture +def boundary(): + """Create a boundary for testing fills.""" + return [(1, True), (2, False), (3, True)] + + +def test_add_fill_command_initialization(sketch, boundary): + """Test that AddFillCommand initializes correctly.""" + cmd = AddFillCommand(sketch, boundary, name="Add Fill") + + assert cmd.sketch is sketch + assert cmd.name == "Add Fill" + assert cmd._boundary == boundary + assert cmd.fill is None + + +def test_add_fill_command_execute(sketch, boundary): + """Test that execute adds a fill to the sketch.""" + cmd = AddFillCommand(sketch, boundary, name="Add Fill") + + assert len(sketch.fills) == 0 + + cmd.execute() + + assert len(sketch.fills) == 1 + assert cmd.fill is not None + assert cmd.fill in sketch.fills + assert cmd.fill.boundary == boundary + + +def test_add_fill_command_creates_fill_once(sketch, boundary): + """Test that execute creates fill on first call.""" + cmd = AddFillCommand(sketch, boundary, name="Add Fill") + + assert cmd.fill is None + + cmd.execute() + + assert cmd.fill is not None + assert cmd.fill in sketch.fills + + +def test_add_fill_command_undo(sketch, boundary): + """Test that undo removes the fill from the sketch.""" + cmd = AddFillCommand(sketch, boundary, name="Add Fill") + + cmd.execute() + assert len(sketch.fills) == 1 + + cmd.undo() + + assert len(sketch.fills) == 0 + + +def test_add_fill_command_execute_undo_cycle(sketch, boundary): + """Test that execute and undo can be called multiple times.""" + cmd = AddFillCommand(sketch, boundary, name="Add Fill") + + for _ in range(3): + cmd.execute() + assert len(sketch.fills) == 1 + + cmd.undo() + assert len(sketch.fills) == 0 + + +def test_remove_fill_command_initialization(sketch): + """Test that RemoveFillCommand initializes correctly.""" + fill = Fill(uid="test-uid", boundary=[(1, True)]) + cmd = RemoveFillCommand(sketch, fill, "Remove Fill") + + assert cmd.sketch is sketch + assert cmd.name == "Remove Fill" + assert cmd.fill is fill + + +def test_remove_fill_command_execute(sketch): + """Test that execute removes a fill from the sketch.""" + fill = Fill(uid="test-uid", boundary=[(1, True)]) + sketch.fills.append(fill) + + assert len(sketch.fills) == 1 + + cmd = RemoveFillCommand(sketch, fill, "Remove Fill") + cmd.execute() + + assert len(sketch.fills) == 0 + assert fill not in sketch.fills + + +def test_remove_fill_command_execute_nonexistent_fill(sketch): + """Test that execute handles fill not in sketch gracefully.""" + fill = Fill(uid="test-uid", boundary=[(1, True)]) + + assert len(sketch.fills) == 0 + + cmd = RemoveFillCommand(sketch, fill, "Remove Fill") + cmd.execute() + + assert len(sketch.fills) == 0 + + +def test_remove_fill_command_undo(sketch): + """Test that undo restores the fill to the sketch.""" + fill = Fill(uid="test-uid", boundary=[(1, True)]) + sketch.fills.append(fill) + + cmd = RemoveFillCommand(sketch, fill, "Remove Fill") + cmd.execute() + + assert len(sketch.fills) == 0 + + cmd.undo() + + assert len(sketch.fills) == 1 + assert fill in sketch.fills + + +def test_remove_fill_command_execute_undo_cycle(sketch): + """Test that execute and undo can be called multiple times.""" + fill = Fill(uid="test-uid", boundary=[(1, True)]) + sketch.fills.append(fill) + + cmd = RemoveFillCommand(sketch, fill, "Remove Fill") + + for _ in range(3): + cmd.execute() + assert len(sketch.fills) == 0 + + cmd.undo() + assert len(sketch.fills) == 1 + + +def test_add_and_remove_fill_command_interaction(sketch, boundary): + """Test interaction between add and remove fill commands.""" + add_cmd = AddFillCommand(sketch, boundary, name="Add Fill") + add_cmd.execute() + + fill = add_cmd.fill + assert fill is not None + remove_cmd = RemoveFillCommand(sketch, fill, "Remove Fill") + + remove_cmd.execute() + assert len(sketch.fills) == 0 + + remove_cmd.undo() + assert len(sketch.fills) == 1 + + add_cmd.undo() + assert len(sketch.fills) == 0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_fillet_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_fillet_cmd.py new file mode 100644 index 000000000..56865185c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_fillet_cmd.py @@ -0,0 +1,155 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import FilletCommand +from sketcher.core.constraints import ( + CollinearConstraint, + EqualDistanceConstraint, + TangentConstraint, +) +from sketcher.core.entities import Arc, Line + +# Constant for consistency +DEFAULT_FILLET_RADIUS = 10.0 + + +@pytest.fixture +def sketch_with_corner(): + """Creates a sketch with two lines forming a corner at (0,0).""" + s = Sketch() + # p1=(-100, 0), p2=(0, 0) [corner], p3=(0, 100) + # IDs: origin=0, p1=1, corner=2, p3=3, line1=4, line2=5 + p1_id = s.add_point(-100, 0) + corner_pid = s.add_point(0, 0) + p3_id = s.add_point(0, 100) + + # line1 from p1 to corner, line2 from corner to p3 + line1_id = s.add_line(p1_id, corner_pid) + line2_id = s.add_line(corner_pid, p3_id) + + return s, corner_pid, line1_id, line2_id + + +def test_fillet_calculate_geometry(sketch_with_corner): + """Test the static geometry calculation for a valid fillet.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + result = FilletCommand.calculate_geometry( + sketch.registry, corner_pid, line1_id, line2_id, DEFAULT_FILLET_RADIUS + ) + + assert result is not None + assert len(result["points"]) == 3 + assert len(result["entities"]) == 3 + assert len(result["constraints"]) == 5 + assert len(result["removed_entities"]) == 2 + + p_tan1, p_tan2, p_center = result["points"] + assert p_tan1.x == pytest.approx(-10.0) + assert p_tan1.y == pytest.approx(0.0) + assert p_tan2.x == pytest.approx(0.0) + assert p_tan2.y == pytest.approx(10.0) + assert p_center.x == pytest.approx(-10.0) + assert p_center.y == pytest.approx(10.0) + + +def test_fillet_calculate_geometry_too_large(sketch_with_corner): + """Test that calculation fails if the fillet radius is too large.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + result = FilletCommand.calculate_geometry( + sketch.registry, + corner_pid, + line1_id, + line2_id, + 200.0, # Too large + ) + assert result is None + + +def test_fillet_command_execute(sketch_with_corner): + """Test the direct execution of FilletCommand.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + + initial_points_count = len(sketch.registry.points) + initial_entities_count = len(sketch.registry.entities) + initial_constraints_count = len(sketch.constraints) + + command = FilletCommand( + sketch, corner_pid, line1_id, line2_id, DEFAULT_FILLET_RADIUS + ) + command.execute() + + # Verify additions: + # Points: +3 (Tangent1, Tangent2, Center) + # Entities: +1 total (2 lines removed, 2 lines + 1 arc added) + # Constraints: +5 (2 Tangent, 2 Collinear, 1 EqualDistance) + assert len(sketch.registry.points) == initial_points_count + 3 + assert len(sketch.registry.entities) == initial_entities_count + 1 + assert len(sketch.constraints) == initial_constraints_count + 5 + + # Verify new geometry types + assert command.add_cmd is not None + assert len(command.add_cmd.entities) == 3 + # First two are lines connecting original points to tangents + assert isinstance(command.add_cmd.entities[0], Line) + assert isinstance(command.add_cmd.entities[1], Line) + # Third is the Arc + fillet_arc = command.add_cmd.entities[2] + assert isinstance(fillet_arc, Arc) + + # Verify constraints + new_constraints = sketch.constraints[-5:] + + # We expect 2 Tangent, 2 Collinear, 1 EqualDistance. + # We check presence using sets or types. + constraint_types = [type(c) for c in new_constraints] + assert constraint_types.count(TangentConstraint) == 2 + assert constraint_types.count(CollinearConstraint) == 2 + assert constraint_types.count(EqualDistanceConstraint) == 1 + + # Verify original lines were removed + assert sketch.registry.get_entity(line1_id) is None + assert sketch.registry.get_entity(line2_id) is None + + +def test_fillet_command_undo(sketch_with_corner): + """Test that undoing a FilletCommand restores the original state.""" + sketch, corner_pid, line1_id, line2_id = sketch_with_corner + + line1 = sketch.registry.get_entity(line1_id) + line2 = sketch.registry.get_entity(line2_id) + assert isinstance(line1, Line) + assert isinstance(line2, Line) + + # Store initial state + initial_state = { + "points_count": len(sketch.registry.points), + "entities_count": len(sketch.registry.entities), + "constraints_count": len(sketch.constraints), + "line1_p1": line1.p1_idx, + "line1_p2": line1.p2_idx, + "line2_p1": line2.p1_idx, + "line2_p2": line2.p2_idx, + } + + command = FilletCommand( + sketch, corner_pid, line1_id, line2_id, DEFAULT_FILLET_RADIUS + ) + command.execute() + + # Sanity check that something changed + assert len(sketch.registry.points) != initial_state["points_count"] + + command.undo() + + # Verify state is restored + assert len(sketch.registry.points) == initial_state["points_count"] + assert len(sketch.registry.entities) == initial_state["entities_count"] + assert len(sketch.constraints) == initial_state["constraints_count"] + + restored_line1 = sketch.registry.get_entity(line1_id) + restored_line2 = sketch.registry.get_entity(line2_id) + assert isinstance(restored_line1, Line) + assert isinstance(restored_line2, Line) + assert restored_line1.p1_idx == initial_state["line1_p1"] + assert restored_line1.p2_idx == initial_state["line1_p2"] + assert restored_line2.p1_idx == initial_state["line2_p1"] + assert restored_line2.p2_idx == initial_state["line2_p2"] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_grid_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_grid_cmd.py new file mode 100644 index 000000000..46e299065 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_grid_cmd.py @@ -0,0 +1,225 @@ +from sketcher.core import Sketch +from sketcher.core.commands import GridCommand +from sketcher.core.constraints import HorizontalConstraint, VerticalConstraint +from sketcher.core.entities import Line + + +def test_grid_calculate_geometry_2x2(): + result = GridCommand.calculate_geometry(2, 2, (0, 0), 10, 10) + assert result is not None + assert len(result["points"]) == 9 + assert len(result["entities"]) == 12 + assert len(result["constraints"]) == 12 + + +def test_grid_calculate_geometry_3x3(): + result = GridCommand.calculate_geometry(3, 3, (0, 0), 10, 10) + assert result is not None + assert len(result["points"]) == 16 + assert len(result["entities"]) == 24 + assert len(result["constraints"]) == 24 + + +def test_grid_calculate_geometry_with_origin(): + result = GridCommand.calculate_geometry(2, 2, (50, 100), 10, 20) + assert result is not None + points = result["points"] + assert len(points) == 9 + assert points[0].x == 50 and points[0].y == 100 + assert points[2].x == 70 and points[2].y == 100 + assert points[6].x == 50 and points[6].y == 140 + assert points[8].x == 70 and points[8].y == 140 + + +def test_grid_calculate_geometry_construction_flag(): + result = GridCommand.calculate_geometry( + 2, 2, (0, 0), 10, 10, construction=True + ) + assert result is not None + for entity in result["entities"]: + assert entity.construction is True + + result = GridCommand.calculate_geometry( + 2, 2, (0, 0), 10, 10, construction=False + ) + assert result is not None + for entity in result["entities"]: + assert entity.construction is False + + +def test_grid_calculate_geometry_invalid_rows(): + assert GridCommand.calculate_geometry(0, 2, (0, 0), 10, 10) is None + + +def test_grid_calculate_geometry_invalid_cols(): + assert GridCommand.calculate_geometry(2, 0, (0, 0), 10, 10) is None + + +def test_grid_calculate_geometry_invalid_cell_size(): + assert GridCommand.calculate_geometry(2, 2, (0, 0), 0, 10) is None + assert GridCommand.calculate_geometry(2, 2, (0, 0), 10, 0) is None + assert GridCommand.calculate_geometry(2, 2, (0, 0), -5, 10) is None + + +def test_grid_command_execute(): + sketch = Sketch() + initial_count = len(sketch.registry.points) + cmd = GridCommand(sketch, 3, 4, (0, 0), 10, 10) + cmd.execute() + + assert len(sketch.registry.points) == initial_count + 20 + assert len(sketch.registry.entities) == 31 + + +def test_grid_command_execute_with_origin(): + sketch = Sketch() + initial_count = len(sketch.registry.points) + cmd = GridCommand(sketch, 2, 2, (100, 200), 50, 75) + cmd.execute() + + points = sketch.registry.points + assert len(points) == initial_count + 9 + + p0 = points[initial_count] + p8 = points[initial_count + 8] + assert p0.x == 100 and p0.y == 200 + assert p8.x == 200 and p8.y == 350 + + +def test_grid_command_execute_construction(): + sketch = Sketch() + cmd = GridCommand(sketch, 2, 2, (0, 0), 10, 10, construction=True) + cmd.execute() + + for entity in sketch.registry.entities: + assert entity.construction is True + + +def test_grid_command_execute_normal(): + sketch = Sketch() + cmd = GridCommand(sketch, 2, 2, (0, 0), 10, 10, construction=False) + cmd.execute() + + for entity in sketch.registry.entities: + assert entity.construction is False + + +def test_grid_command_undo(): + sketch = Sketch() + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = GridCommand(sketch, 2, 2, (0, 0), 10, 10) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + 9 + assert len(sketch.registry.entities) == initial_entity_count + 12 + + cmd.undo() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_grid_command_invalid_does_nothing(): + sketch = Sketch() + initial_point_count = len(sketch.registry.points) + + cmd = GridCommand(sketch, 0, 0, (0, 0), 10, 10) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + + +def test_grid_command_horizontal_line_count(): + sketch = Sketch() + cmd = GridCommand(sketch, 3, 5, (0, 0), 10, 10) + cmd.execute() + + horizontal_lines = [ + e + for e in sketch.registry.entities + if isinstance(e, Line) + and abs( + sketch.registry.get_point(e.p1_idx).y + - sketch.registry.get_point(e.p2_idx).y + ) + < 1e-6 + ] + expected_horizontal = (3 + 1) * 5 + assert len(horizontal_lines) == expected_horizontal + + +def test_grid_command_vertical_line_count(): + sketch = Sketch() + cmd = GridCommand(sketch, 3, 5, (0, 0), 10, 10) + cmd.execute() + + vertical_lines = [ + e + for e in sketch.registry.entities + if isinstance(e, Line) + and abs( + sketch.registry.get_point(e.p1_idx).x + - sketch.registry.get_point(e.p2_idx).x + ) + < 1e-6 + ] + expected_vertical = (5 + 1) * 3 + assert len(vertical_lines) == expected_vertical + + +def test_grid_command_creates_horizontal_constraints(): + sketch = Sketch() + cmd = GridCommand(sketch, 3, 5, (0, 0), 10, 10) + cmd.execute() + + horizontal_constraints = [ + c for c in sketch.constraints if isinstance(c, HorizontalConstraint) + ] + expected_horizontal = (3 + 1) * 5 + assert len(horizontal_constraints) == expected_horizontal + + +def test_grid_command_creates_vertical_constraints(): + sketch = Sketch() + cmd = GridCommand(sketch, 3, 5, (0, 0), 10, 10) + cmd.execute() + + vertical_constraints = [ + c for c in sketch.constraints if isinstance(c, VerticalConstraint) + ] + expected_vertical = (5 + 1) * 3 + assert len(vertical_constraints) == expected_vertical + + +def test_grid_command_constraints_count(): + sketch = Sketch() + initial_constraint_count = len(sketch.constraints) + cmd = GridCommand(sketch, 2, 3, (0, 0), 10, 10) + cmd.execute() + + horizontal_lines = (2 + 1) * 3 + vertical_lines = (3 + 1) * 2 + expected_constraints = horizontal_lines + vertical_lines + assert ( + len(sketch.constraints) + == initial_constraint_count + expected_constraints + ) + + +def test_grid_command_undo_removes_constraints(): + sketch = Sketch() + initial_constraint_count = len(sketch.constraints) + + cmd = GridCommand(sketch, 2, 2, (0, 0), 10, 10) + cmd.execute() + + horizontal_lines = (2 + 1) * 2 + vertical_lines = (2 + 1) * 2 + expected = horizontal_lines + vertical_lines + assert len(sketch.constraints) == initial_constraint_count + expected + + cmd.undo() + + assert len(sketch.constraints) == initial_constraint_count diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_items_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_items_cmd.py new file mode 100644 index 000000000..8c6d8c268 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_items_cmd.py @@ -0,0 +1,244 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import AddItemsCommand +from sketcher.core.commands.items import RemoveItemsCommand +from sketcher.core.constraints import DistanceConstraint +from sketcher.core.entities import Line, Point + + +@pytest.fixture +def sketch(): + """Create a basic sketch for testing.""" + return Sketch() + + +def test_add_items_command_initialization(sketch): + """Test that AddItemsCommand initializes correctly.""" + p = Point(-1, 10.0, 20.0) + cmd = AddItemsCommand(sketch, "Add Items", points=[p]) + + assert cmd.sketch is sketch + assert cmd.name == "Add Items" + assert len(cmd.points) == 1 + assert len(cmd.entities) == 0 + assert len(cmd.constraints) == 0 + + +def test_add_items_command_add_points(sketch): + """Test that execute adds points to the sketch.""" + p1 = Point(-1, 10.0, 20.0) + p2 = Point(-2, 30.0, 40.0) + cmd = AddItemsCommand(sketch, "Add Items", points=[p1, p2]) + + initial_count = len(sketch.registry.points) + + cmd.execute() + + assert len(sketch.registry.points) == initial_count + 2 + assert p1.id >= 0 + assert p2.id >= 0 + + +def test_add_items_command_add_entities(sketch): + """Test that execute adds entities to the sketch.""" + p1 = Point(-1, 10.0, 20.0) + p2 = Point(-2, 30.0, 40.0) + line = Line(-1, p1.id, p2.id) + cmd = AddItemsCommand( + sketch, "Add Items", points=[p1, p2], entities=[line] + ) + + cmd.execute() + + assert len(sketch.registry.entities) == 1 + assert line.id >= 0 + assert line.p1_idx >= 0 + assert line.p2_idx >= 0 + assert p1.id >= 0 + assert p2.id >= 0 + + +def test_add_items_command_add_constraints(sketch): + """Test that execute adds constraints to the sketch.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + constraint = DistanceConstraint(p1, p2, 10.0) + cmd = AddItemsCommand(sketch, "Add Items", constraints=[constraint]) + + initial_count = len(sketch.constraints) + + cmd.execute() + + assert len(sketch.constraints) == initial_count + 1 + assert constraint in sketch.constraints + + +def test_add_items_command_undo(sketch): + """Test that undo removes added items from the sketch.""" + p1 = Point(-1, 10.0, 20.0) + p2 = Point(-2, 30.0, 40.0) + line = Line(-1, p1.id, p2.id) + cmd = AddItemsCommand( + sketch, "Add Items", points=[p1, p2], entities=[line] + ) + + cmd.execute() + assert len(sketch.registry.points) > 0 + assert len(sketch.registry.entities) > 0 + + cmd.undo() + + assert len(sketch.registry.points) == 1 + assert len(sketch.registry.entities) == 0 + + +def test_add_items_command_execute_undo_cycle(sketch): + """Test that execute and undo can be called multiple times.""" + p1 = Point(-1, 10.0, 20.0) + cmd = AddItemsCommand(sketch, "Add Items", points=[p1]) + + for _ in range(3): + cmd.execute() + assert len(sketch.registry.points) == 2 + + cmd.undo() + assert len(sketch.registry.points) == 1 + + +def test_remove_items_command_initialization(sketch): + """Test that RemoveItemsCommand initializes correctly.""" + p1 = sketch.add_point(10.0, 20.0) + cmd = RemoveItemsCommand(sketch, "Remove Items", points=[p1]) + + assert cmd.sketch is sketch + assert cmd.name == "Remove Items" + assert len(cmd.points) == 1 + assert len(cmd.entities) == 0 + assert len(cmd.constraints) == 0 + + +def test_remove_items_command_remove_points(sketch): + """Test that execute removes points from the sketch.""" + p1 = sketch.add_point(10.0, 20.0) + p2 = sketch.add_point(30.0, 40.0) + point1 = sketch.registry.get_point(p1) + point2 = sketch.registry.get_point(p2) + cmd = RemoveItemsCommand(sketch, "Remove Items", points=[point1, point2]) + + assert len(sketch.registry.points) == 3 + + cmd.execute() + + assert len(sketch.registry.points) == 1 + + +def test_remove_items_command_remove_entities(sketch): + """Test that execute removes entities from the sketch.""" + p1 = sketch.add_point(10.0, 20.0) + p2 = sketch.add_point(30.0, 40.0) + line = sketch.add_line(p1, p2) + line_ent = sketch.registry.get_entity(line) + + cmd = RemoveItemsCommand(sketch, "Remove Items", entities=[line_ent]) + + assert len(sketch.registry.entities) == 1 + + cmd.execute() + + assert len(sketch.registry.entities) == 0 + + +def test_remove_items_command_remove_constraints(sketch): + """Test that execute removes constraints from the sketch.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + constraint = DistanceConstraint(p1, p2, 10.0) + sketch.constraints.append(constraint) + + assert len(sketch.constraints) == 1 + + cmd = RemoveItemsCommand(sketch, "Remove Items", constraints=[constraint]) + cmd.execute() + + assert len(sketch.constraints) == 0 + + +def test_remove_items_command_undo(sketch): + """Test that undo restores removed items to the sketch.""" + p1 = sketch.add_point(10.0, 20.0) + p2 = sketch.add_point(30.0, 40.0) + line = sketch.add_line(p1, p2) + line_ent = sketch.registry.get_entity(line) + point1 = sketch.registry.get_point(p1) + point2 = sketch.registry.get_point(p2) + + cmd = RemoveItemsCommand( + sketch, "Remove Items", points=[point1, point2], entities=[line_ent] + ) + + cmd.execute() + assert len(sketch.registry.points) == 1 + assert len(sketch.registry.entities) == 0 + + cmd.undo() + + assert len(sketch.registry.points) == 3 + assert len(sketch.registry.entities) == 1 + + +def test_remove_items_command_execute_undo_cycle(sketch): + """Test that execute and undo can be called multiple times.""" + p1 = sketch.add_point(10.0, 20.0) + point1 = sketch.registry.get_point(p1) + cmd = RemoveItemsCommand(sketch, "Remove Items", points=[point1]) + + for _ in range(3): + cmd.execute() + assert len(sketch.registry.points) == 1 + + cmd.undo() + assert len(sketch.registry.points) == 2 + + +def test_add_and_remove_items_command_interaction(sketch): + """Test interaction between add and remove items commands.""" + p1 = Point(-1, 10.0, 20.0) + p2 = Point(-2, 30.0, 40.0) + + add_cmd = AddItemsCommand(sketch, "Add Items", points=[p1, p2]) + add_cmd.execute() + + point1 = sketch.registry.get_point(p1.id) + point2 = sketch.registry.get_point(p2.id) + + remove_cmd = RemoveItemsCommand( + sketch, "Remove Items", points=[point1, point2] + ) + + remove_cmd.execute() + assert len(sketch.registry.points) == 1 + + remove_cmd.undo() + assert len(sketch.registry.points) == 3 + + add_cmd.undo() + assert len(sketch.registry.points) == 1 + + +def test_add_items_command_with_temp_ids(sketch): + """Test that temporary IDs are properly reassigned.""" + p1 = Point(-1, 10.0, 20.0) + p2 = Point(-2, 30.0, 40.0) + line = Line(-3, p1.id, p2.id) + + cmd = AddItemsCommand( + sketch, "Add Items", points=[p1, p2], entities=[line] + ) + + cmd.execute() + + assert p1.id >= 0 + assert p2.id >= 0 + assert line.id >= 0 + assert line.p1_idx == p1.id + assert line.p2_idx == p2.id diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_line_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_line_cmd.py new file mode 100644 index 000000000..c0715151e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_line_cmd.py @@ -0,0 +1,304 @@ +import math + +from sketcher.core import Sketch +from sketcher.core.commands import LineCommand, LinePreviewState +from sketcher.core.entities import Line, Point + + +def test_line_command_execute_no_snap(): + """Test command execution with no point snapping.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = LineCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.points) == 3 + assert len(sketch.registry.entities) == 1 + + line = sketch.registry.entities[0] + assert isinstance(line, Line) + assert line.p1_idx == start_pid + + +def test_line_command_execute_with_snap(): + """Test command execution when snapping to an existing end point.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + cmd = LineCommand(sketch, start_pid, (100, 50), end_pid=end_pid) + cmd.execute() + + assert len(sketch.registry.points) == 3 + line = sketch.registry.entities[0] + assert isinstance(line, Line) + assert line.p2_idx == end_pid + + +def test_line_command_execute_temp_start(): + """Test command execution when the start point was temporary.""" + sketch = Sketch() + start_pid = 100 + sketch.registry.points.append(Point(start_pid, 0, 0)) + assert len(sketch.registry.points) == 2 + + cmd = LineCommand(sketch, start_pid, (100, 50), is_start_temp=True) + cmd.execute() + + assert len(sketch.registry.points) == 3 + assert len(sketch.registry.entities) == 1 + assert cmd.add_cmd is not None + + re_added_start = next( + p for p in cmd.add_cmd.points if p.x == 0 and p.y == 0 + ) + assert re_added_start.id != 100 + + +def test_line_command_execute_start_equals_end(): + """Test command does nothing if start equals end point.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = LineCommand(sketch, start_pid, (0, 0), end_pid=start_pid) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_line_command_undo(): + """Test undo removes all added items.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = LineCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + 1 + assert len(sketch.registry.entities) == initial_entity_count + 1 + + cmd.undo() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_line_command_undo_with_temp_start(): + """Test undo with temporary start point.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + + cmd = LineCommand(sketch, start_pid, (100, 50), is_start_temp=True) + cmd.execute() + + assert len(sketch.registry.entities) == 1 + + cmd.undo() + + assert len(sketch.registry.entities) == 0 + assert len(sketch.registry.points) == 1 + assert sketch.registry.points[0].id == 0 + + +def test_line_start_preview_no_snap(): + """Test start_preview creates initial preview state with temp point.""" + sketch = Sketch() + state = LineCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None + ) + + assert isinstance(state, LinePreviewState) + assert state.start_temp is True + assert state.end_id is not None + assert state.entity_id is not None + + start_p = sketch.registry.get_point(state.start_id) + assert start_p.x == 10 + assert start_p.y == 20 + + end_p = sketch.registry.get_point(state.end_id) + assert end_p.x == 10 + assert end_p.y == 20 + + +def test_line_start_preview_with_snap(): + """Test start_preview uses existing point when snapped.""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 60) + state = LineCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=existing_pid + ) + + assert isinstance(state, LinePreviewState) + assert state.start_temp is False + assert state.start_id == existing_pid + + +def test_line_update_preview(): + """Test update_preview moves end point.""" + sketch = Sketch() + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + + LineCommand.update_preview(sketch.registry, state, 100, 50) + + end_p = sketch.registry.get_point(state.end_id) + assert end_p.x == 100 + assert end_p.y == 50 + + +def test_line_cleanup_preview(): + """Test cleanup_preview removes preview entities but leaves start.""" + sketch = Sketch() + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + + assert len(sketch.registry.points) > initial_point_count + assert len(sketch.registry.entities) > initial_entity_count + + LineCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == initial_entity_count + remaining_preview_ids = {state.start_id} + for p in sketch.registry.points: + if p.id != 0: + assert p.id in remaining_preview_ids + + +def test_line_cleanup_preview_with_snapped_start(): + """Test cleanup when start point was snapped (not temp).""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 50) + initial_point_count = len(sketch.registry.points) + + state = LineCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=existing_pid + ) + + LineCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.points) == initial_point_count + + +def test_line_preview_lifecycle(): + """Test full preview lifecycle: start -> update -> cleanup.""" + sketch = Sketch() + + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + assert state.start_temp is True + + LineCommand.update_preview(sketch.registry, state, 50, 50) + end_p = sketch.registry.get_point(state.end_id) + assert end_p.x == 50 + assert end_p.y == 50 + + LineCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == 0 + assert state.start_id in [p.id for p in sketch.registry.points] + + +def test_line_preview_then_execute(): + """Test executing a line after preview.""" + sketch = Sketch() + + state = LineCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None + ) + + LineCommand.update_preview(sketch.registry, state, 50, 30) + + start_id = state.start_id + start_temp = state.start_temp + + LineCommand.cleanup_preview(sketch.registry, state) + + cmd = LineCommand(sketch, start_id, (50, 30), is_start_temp=start_temp) + cmd.execute() + + assert len(sketch.registry.entities) == 1 + line = sketch.registry.entities[0] + assert isinstance(line, Line) + assert cmd.add_cmd is not None + start_point = next( + (p for p in cmd.add_cmd.points if p.x == 10 and p.y == 20), None + ) + assert start_point is not None + assert line.p1_idx == start_point.id + + +def test_line_undo_no_dangling_points(): + """Test undo removes all added points including temp end.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = LineCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + assert len(sketch.registry.points) == initial_point_count + 1 + assert len(sketch.registry.entities) > initial_entity_count + + cmd.undo() + + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_line_preview_get_dimensions_returns_length_at_midpoint(): + """Test that dimension shows length at line midpoint.""" + sketch = Sketch() + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + LineCommand.update_preview(sketch.registry, state, 100, 0) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 1 + assert dims[0].label == "100.00" + assert dims[0].position == (50.0, 0.0) + assert dims[0].leader_end is None + + +def test_line_preview_get_dimensions_diagonal_line(): + """Test dimension for diagonal line.""" + sketch = Sketch() + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + LineCommand.update_preview(sketch.registry, state, 30, 40) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 1 + expected_length = math.hypot(30, 40) + assert dims[0].label == f"{expected_length:.2f}" + assert dims[0].position == (15.0, 20.0) + + +def test_line_preview_get_dimensions_very_small_length(): + """Test that very small lengths show as 0.00.""" + sketch = Sketch() + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + LineCommand.update_preview(sketch.registry, state, 0.005, 0) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 1 + assert dims[0].label == "0.00" + + +def test_line_preview_get_dimensions_missing_point(): + """Test that missing points return empty list.""" + sketch = Sketch() + state = LineCommand.start_preview(sketch.registry, 0, 0, snapped_pid=None) + sketch.registry.points.clear() + + dims = state.get_dimensions(sketch.registry) + + assert dims == [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_live_text_edit_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_live_text_edit_cmd.py new file mode 100644 index 000000000..0060b26d1 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_live_text_edit_cmd.py @@ -0,0 +1,372 @@ +from typing import cast +from unittest.mock import patch + +from sketcher.core import Sketch +from sketcher.core.commands import TextBoxCommand +from sketcher.core.commands.live_text_edit import LiveTextEditCommand +from sketcher.core.entities import TextBoxEntity + +from rayforge.core.undo.history import COALESCE_THRESHOLD + + +class MockTime: + """Helper class to mock time.time() for testing coalescing.""" + + def __init__(self): + self.current_time = 1000.0 + + def time(self): + return self.current_time + + def advance(self, seconds): + self.current_time += seconds + + +def test_live_text_edit_command_initialization(): + """Test command initialization.""" + sketch = Sketch() + cmd = LiveTextEditCommand(sketch, 1) + + assert cmd.text_entity_id == 1 + assert cmd._sketch is sketch + assert cmd.history == [] + assert cmd.current_index == -1 + assert cmd.cursor_pos == 0 + + +def test_live_text_edit_command_execute(): + """Test command execution captures initial state.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + text_box = cast(TextBoxEntity, sketch.registry.get_entity(text_box_id)) + text_box.content = "initial" + + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + assert len(cmd.history) == 1 + assert cmd.current_index == 0 + assert cmd.get_current_content() == "initial" + + +def test_live_text_edit_capture_state(): + """Test capturing state updates history.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("hello", 5) + assert len(cmd.history) == 2 + assert cmd.current_index == 1 + assert cmd.get_current_content() == "hello" + assert cmd.get_current_cursor_pos() == 5 + + +def test_live_text_edit_undo(): + """Test undo functionality.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + text_box = cast(TextBoxEntity, sketch.registry.get_entity(text_box_id)) + text_box.content = "initial" + + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("hello", 5) + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("hello world", 11) + + assert cmd.get_current_content() == "hello world" + + cmd.undo() + assert cmd.get_current_content() == "hello" + + cmd.undo() + assert cmd.get_current_content() == "initial" + + +def test_live_text_edit_redo(): + """Test redo functionality.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + text_box = cast(TextBoxEntity, sketch.registry.get_entity(text_box_id)) + text_box.content = "initial" + + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("hello", 5) + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("hello world", 11) + + cmd.undo() + assert cmd.get_current_content() == "hello" + + cmd.redo() + assert cmd.get_current_content() == "hello world" + + +def test_live_text_edit_coalesce_rapid_keystrokes(): + """Test that rapid keystrokes are coalesced into one history entry.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + initial_len = len(cmd.history) + + cmd.capture_state("h", 1) + mock_time.advance(0.05) + cmd.capture_state("he", 2) + mock_time.advance(0.05) + cmd.capture_state("hel", 3) + mock_time.advance(0.05) + cmd.capture_state("hell", 4) + mock_time.advance(0.05) + cmd.capture_state("hello", 5) + + assert len(cmd.history) == initial_len + assert cmd.get_current_content() == "hello" + + +def test_live_text_edit_coalesce_after_pause(): + """Test that a pause creates a new history entry.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + initial_len = len(cmd.history) + + cmd.capture_state("h", 1) + mock_time.advance(0.05) + cmd.capture_state("he", 2) + mock_time.advance(0.05) + cmd.capture_state("hel", 3) + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + + cmd.capture_state("hell", 4) + mock_time.advance(0.05) + cmd.capture_state("hello", 5) + + assert len(cmd.history) == initial_len + 1 + assert cmd.get_current_content() == "hello" + + +def test_live_text_edit_coalesce_undo_through_coalesced(): + """Test undo works correctly with coalesced states.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + text_box = cast(TextBoxEntity, sketch.registry.get_entity(text_box_id)) + text_box.content = "initial" + + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("h", 1) + mock_time.advance(0.05) + cmd.capture_state("he", 2) + mock_time.advance(0.05) + cmd.capture_state("hel", 3) + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + + cmd.capture_state("hell", 4) + mock_time.advance(0.05) + cmd.capture_state("hello", 5) + + assert cmd.get_current_content() == "hello" + + cmd.undo() + assert cmd.get_current_content() == "hel" + + cmd.undo() + assert cmd.get_current_content() == "initial" + + +def test_live_text_edit_coalesce_redo_through_coalesced(): + """Test redo works correctly with coalesced states.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + text_box = cast(TextBoxEntity, sketch.registry.get_entity(text_box_id)) + text_box.content = "initial" + + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + cmd.capture_state("h", 1) + mock_time.advance(0.05) + cmd.capture_state("he", 2) + mock_time.advance(0.05) + cmd.capture_state("hel", 3) + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + + cmd.capture_state("hell", 4) + mock_time.advance(0.05) + cmd.capture_state("hello", 5) + + cmd.undo() + assert cmd.get_current_content() == "hel" + + cmd.redo() + assert cmd.get_current_content() == "hello" + + +def test_live_text_edit_restore_state(): + """Test that _restore_state correctly updates entity content.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + text_box = cast(TextBoxEntity, sketch.registry.get_entity(text_box_id)) + text_box.content = "initial" + + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + mock_time.advance(COALESCE_THRESHOLD + 0.1) + cmd.capture_state("hello", 5) + + text_box.content = "modified" + + cmd._restore_state(1) + + assert text_box.content == "hello" + + +def test_live_text_edit_get_current_content_empty(): + """Test get_current_content returns empty string when no history.""" + sketch = Sketch() + cmd = LiveTextEditCommand(sketch, 1) + + assert cmd.get_current_content() == "" + + +def test_live_text_edit_get_current_cursor_pos_zero(): + """Test get_current_cursor_pos returns 0 when no history.""" + sketch = Sketch() + cmd = LiveTextEditCommand(sketch, 1) + + assert cmd.get_current_cursor_pos() == 0 + + +def test_live_text_edit_execute_with_invalid_entity(): + """Test execute handles invalid entity gracefully.""" + sketch = Sketch() + cmd = LiveTextEditCommand(sketch, 999) + + cmd.execute() + + assert len(cmd.history) == 0 + + +def test_live_text_edit_restore_state_with_invalid_entity(): + """Test _restore_state handles invalid entity gracefully.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + cmd = LiveTextEditCommand(sketch, 999) + cmd.history = [("test", 4, mock_time.time())] + + cmd._restore_state(0) + + assert cmd.history[0] == ("test", 4, cmd.history[0][2]) + + +def test_live_text_edit_undo_at_start(): + """Test undo does nothing when at start of history.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + initial_content = cmd.get_current_content() + cmd.undo() + + assert cmd.get_current_content() == initial_content + + +def test_live_text_edit_redo_at_end(): + """Test redo does nothing when at end of history.""" + mock_time = MockTime() + with patch("time.time", mock_time.time): + sketch = Sketch() + box_cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + box_cmd.execute() + assert box_cmd.text_box_id is not None + + text_box_id = box_cmd.text_box_id + cmd = LiveTextEditCommand(sketch, text_box_id) + cmd.execute() + + cmd.capture_state("hello", 5) + + initial_content = cmd.get_current_content() + cmd.redo() + + assert cmd.get_current_content() == initial_content diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_point_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_point_cmd.py new file mode 100644 index 000000000..1d17b0bbc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_point_cmd.py @@ -0,0 +1,297 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + MovePointCommand, + UnstickJunctionCommand, +) + + +@pytest.fixture +def sketch(): + """Create a basic sketch for testing.""" + return Sketch() + + +def test_move_point_command_initialization(sketch): + """Test that MovePointCommand initializes correctly.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + + assert cmd.sketch is sketch + assert cmd.point_id == p_id + assert cmd.start_pos == (10.0, 20.0) + assert cmd.end_pos == (30.0, 40.0) + assert cmd._point_ref is None + + +def test_move_point_command_initialization_with_snapshot(sketch): + """Test that MovePointCommand can be initialized with a snapshot.""" + p_id = sketch.add_point(10.0, 20.0) + snapshot = ({p_id: (10.0, 20.0)}, {}) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0), snapshot) + + assert cmd._snapshot == snapshot + + +def test_move_point_command_execute(sketch): + """Test that execute moves the point to end position.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + + assert sketch.registry.get_point(p_id).x == 10.0 + assert sketch.registry.get_point(p_id).y == 20.0 + + cmd.execute() + + assert sketch.registry.get_point(p_id).x == 30.0 + assert sketch.registry.get_point(p_id).y == 40.0 + + +def test_move_point_command_undo(sketch): + """Test that undo moves the point back to start position.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + + cmd.execute() + assert sketch.registry.get_point(p_id).x == 30.0 + assert sketch.registry.get_point(p_id).y == 40.0 + + cmd.undo() + + assert sketch.registry.get_point(p_id).x == 10.0 + assert sketch.registry.get_point(p_id).y == 20.0 + + +def test_move_point_command_execute_undo_cycle(sketch): + """Test that execute and undo can be called multiple times.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + + for _ in range(3): + cmd.execute() + assert sketch.registry.get_point(p_id).x == 30.0 + assert sketch.registry.get_point(p_id).y == 40.0 + + cmd.undo() + assert sketch.registry.get_point(p_id).x == 10.0 + assert sketch.registry.get_point(p_id).y == 20.0 + + +def test_move_point_command_can_coalesce_with(sketch): + """Test that can_coalesce_with works correctly.""" + p_id = sketch.add_point(10.0, 20.0) + cmd1 = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + cmd2 = MovePointCommand(sketch, p_id, (30.0, 40.0), (50.0, 60.0)) + p_id2 = sketch.add_point(100.0, 200.0) + cmd3 = MovePointCommand(sketch, p_id2, (100.0, 200.0), (150.0, 250.0)) + + assert cmd1.can_coalesce_with(cmd2) is True + assert cmd1.can_coalesce_with(cmd3) is False + + +def test_move_point_command_coalesce_with(sketch): + """Test that coalesce_with merges commands correctly.""" + p_id = sketch.add_point(10.0, 20.0) + cmd1 = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + cmd2 = MovePointCommand(sketch, p_id, (30.0, 40.0), (50.0, 60.0)) + + result = cmd1.coalesce_with(cmd2) + + assert result is True + assert cmd1.end_pos == (50.0, 60.0) + assert cmd1.start_pos == (10.0, 20.0) + + +def test_move_point_command_coalesce_with_invalid(sketch): + """Test that coalesce_with returns False for incompatible commands.""" + p_id = sketch.add_point(10.0, 20.0) + cmd1 = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + p_id2 = sketch.add_point(100.0, 200.0) + cmd2 = MovePointCommand(sketch, p_id2, (100.0, 200.0), (150.0, 250.0)) + + result = cmd1.coalesce_with(cmd2) + + assert result is False + + +def test_move_point_command_get_point(sketch): + """Test that _get_point returns the correct point.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + + point = cmd._get_point() + + assert point is not None + assert point.id == p_id + assert point.x == 10.0 + assert point.y == 20.0 + + +def test_move_point_command_get_point_caches(sketch): + """Test that _get_point caches the point reference.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = MovePointCommand(sketch, p_id, (10.0, 20.0), (30.0, 40.0)) + + point1 = cmd._get_point() + point2 = cmd._get_point() + + assert point1 is point2 + + +def test_move_point_command_get_point_missing(sketch): + """Test that _get_point returns None for missing point.""" + cmd = MovePointCommand(sketch, 9999, (0.0, 0.0), (10.0, 10.0)) + + point = cmd._get_point() + + assert point is None + + +def test_unstick_junction_command_initialization(sketch): + """Test that UnstickJunctionCommand initializes correctly.""" + p_id = sketch.add_point(10.0, 20.0) + cmd = UnstickJunctionCommand(sketch, p_id) + + assert cmd.sketch is sketch + assert cmd.junction_pid == p_id + assert cmd.new_point is None + assert cmd.modified_map == {} + + +def test_unstick_junction_command_execute_with_two_lines(sketch): + """Test that execute creates a new point for two lines.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + p3 = sketch.add_point(10.0, 10.0) + + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p2, p3) + + cmd = UnstickJunctionCommand(sketch, p2) + + initial_points_count = len(sketch.registry.points) + + cmd.execute() + + assert len(sketch.registry.points) == initial_points_count + 1 + assert cmd.new_point is not None + + line1 = sketch.registry.get_entity(line1_id) + line2 = sketch.registry.get_entity(line2_id) + + assert line1.p1_idx == p1 + assert line1.p2_idx == p2 + + assert line2.p1_idx == cmd.new_point.id + assert line2.p2_idx == p3 + + +def test_unstick_junction_command_execute_with_single_line(sketch): + """Test that execute does nothing with a single line.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + + sketch.add_line(p1, p2) + + cmd = UnstickJunctionCommand(sketch, p2) + + initial_points_count = len(sketch.registry.points) + + cmd.execute() + + assert len(sketch.registry.points) == initial_points_count + assert cmd.new_point is None + + +def test_unstick_junction_command_execute_with_no_entities(sketch): + """Test that execute does nothing when no entities at junction.""" + p1 = sketch.add_point(10.0, 20.0) + + cmd = UnstickJunctionCommand(sketch, p1) + + initial_points_count = len(sketch.registry.points) + + cmd.execute() + + assert len(sketch.registry.points) == initial_points_count + assert cmd.new_point is None + + +def test_unstick_junction_command_undo(sketch): + """Test that undo restores the original junction.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + p3 = sketch.add_point(10.0, 10.0) + + sketch.add_line(p1, p2) + line2_id = sketch.add_line(p2, p3) + + cmd = UnstickJunctionCommand(sketch, p2) + + cmd.execute() + + assert cmd.new_point is not None + line2 = sketch.registry.get_entity(line2_id) + assert line2.p1_idx == cmd.new_point.id + + cmd.undo() + + line2 = sketch.registry.get_entity(line2_id) + assert line2.p1_idx == p2 + + +def test_unstick_junction_command_execute_undo_cycle(sketch): + """Test that execute and undo can be called multiple times.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + p3 = sketch.add_point(10.0, 10.0) + + sketch.add_line(p1, p2) + line2_id = sketch.add_line(p2, p3) + + cmd = UnstickJunctionCommand(sketch, p2) + + for _ in range(3): + cmd.execute() + line2 = sketch.registry.get_entity(line2_id) + assert line2.p1_idx != p2 + + cmd.undo() + line2 = sketch.registry.get_entity(line2_id) + assert line2.p1_idx == p2 + + +def test_unstick_junction_command_with_arc(sketch): + """Test that execute works with arcs at junction.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + p3 = sketch.add_point(5.0, 5.0) + + sketch.add_line(p1, p2) + arc_id = sketch.add_arc(p2, p3, p1) + + cmd = UnstickJunctionCommand(sketch, p2) + + cmd.execute() + + assert cmd.new_point is not None + arc = sketch.registry.get_entity(arc_id) + assert arc.start_idx == cmd.new_point.id + + +def test_unstick_junction_command_with_circle(sketch): + """Test that execute works with circles at junction.""" + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 0.0) + p3 = sketch.add_point(10.0, 10.0) + + sketch.add_line(p1, p2) + circle_id = sketch.add_circle(p2, p3) + + cmd = UnstickJunctionCommand(sketch, p2) + + cmd.execute() + + assert cmd.new_point is not None + circle = sketch.registry.get_entity(circle_id) + assert circle.center_idx == cmd.new_point.id diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_rectangle_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_rectangle_cmd.py new file mode 100644 index 000000000..86ac87aab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_rectangle_cmd.py @@ -0,0 +1,388 @@ +from sketcher.core import Sketch +from sketcher.core.commands import ( + RectangleCommand, + RectanglePreviewState, +) +from sketcher.core.constraints import ( + HorizontalConstraint, + VerticalConstraint, +) +from sketcher.core.entities import Point + + +def test_rectangle_calculate_geometry_no_snap(): + """Test static calculation when no points are snapped.""" + result = RectangleCommand.calculate_geometry(0, 0, 100, 50, 0, None) + assert result is not None + points = result["points"] + assert len(points) == 4 + assert len(result["entities"]) == 4 + assert len(result["constraints"]) == 4 + + # p1 is the start point, represented by its ID + assert points["p1_id"] == 0 + + # p2, p3, p4 are new Point objects + assert points["p2"].x == 100 and points["p2"].y == 0 + assert points["p3"].x == 100 and points["p3"].y == 50 + assert points["p4"].x == 0 and points["p4"].y == 50 + + +def test_rectangle_calculate_geometry_with_snap(): + """Test static calculation when the end point is snapped.""" + result = RectangleCommand.calculate_geometry(0, 0, 100, 50, 0, 7) + assert result is not None + points = result["points"] + # Check that the snapped point ID is preserved + assert points["p3"].id == 7 + + +def test_rectangle_calculate_geometry_degenerate(): + """Test static calculation returns None for a zero-size rectangle.""" + assert RectangleCommand.calculate_geometry(0, 0, 0, 50, 0, None) is None + assert RectangleCommand.calculate_geometry(0, 0, 100, 0, 0, None) is None + + +def test_rectangle_command_execute_no_snap(): + """Test command execution with no point snapping.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = RectangleCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + # 1 origin + 1 start_point + 2 new corners + 1 new end corner + # = 5 points total + assert len(sketch.registry.points) == 5 + assert len(sketch.registry.entities) == 4 + assert len(sketch.constraints) == 4 + assert ( + sum(isinstance(c, HorizontalConstraint) for c in sketch.constraints) + == 2 + ) + assert ( + sum(isinstance(c, VerticalConstraint) for c in sketch.constraints) == 2 + ) + + +def test_rectangle_command_execute_with_snap(): + """Test command execution when snapping to an existing end point.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + cmd = RectangleCommand(sketch, start_pid, (100, 50), end_pid=end_pid) + cmd.execute() + + # 1 origin + 1 start + 1 end + 2 new corners = 5 points total + assert len(sketch.registry.points) == 5 + + +def test_rectangle_command_execute_temp_start(): + """Test command execution when the start point was temporary.""" + sketch = Sketch() + # Manually add a "temp" point to the registry + start_pid = 100 + sketch.registry.points.append(Point(start_pid, 0, 0)) + assert len(sketch.registry.points) == 2 # Origin + temp start + + cmd = RectangleCommand(sketch, start_pid, (100, 50), is_start_temp=True) + cmd.execute() + + # 1 origin + 3 new points (p2, p3, p4) + 1 re-added temp start point = 5 + assert len(sketch.registry.points) == 5 + # Verify the start point ID was reassigned by AddItemsCommand + assert cmd.add_cmd is not None + # Find the re-added start point in the command's point list + re_added_start_point = next( + p for p in cmd.add_cmd.points if p.x == 0 and p.y == 0 + ) + assert re_added_start_point.id != 100 + + cmd.undo() + # Temp points should NOT be restored on undo - only origin remains + assert len(sketch.registry.points) == 1 + assert sketch.registry.points[0].id == 0 # Only origin + + +def test_rectangle_command_undo_no_dangling_points(): + """Test undo removes all added points.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + cmd = RectangleCommand(sketch, start_pid, (100, 50)) + cmd.execute() + + # Added 3 new points (p2, p3, p4) + assert len(sketch.registry.points) == initial_point_count + 3 + assert len(sketch.registry.entities) > initial_entity_count + + cmd.undo() + + # All should be back to initial state + assert len(sketch.registry.points) == initial_point_count + assert len(sketch.registry.entities) == initial_entity_count + + +def test_rectangle_command_undo_with_temp_start(): + """Test undo with temporary start point.""" + sketch = Sketch() + + start_pid = sketch.add_point(0, 0) + + cmd = RectangleCommand(sketch, start_pid, (100, 50), is_start_temp=True) + cmd.execute() + + assert len(sketch.registry.entities) == 4 + + cmd.undo() + + # After undo: all entities removed, temp start should NOT be restored + assert len(sketch.registry.entities) == 0 + # Temp start point should NOT be restored - only origin remains + assert len(sketch.registry.points) == 1 + assert sketch.registry.points[0].id == 0 # Only origin + + +def test_rectangle_start_preview_no_snap(): + """Test start_preview creates initial preview state with temp point.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None + ) + + assert isinstance(state, RectanglePreviewState) + assert state.start_temp is True + assert state.p_end_id is not None + assert state.preview_ids is not None + assert len(state.preview_ids) > 0 + + start_p = sketch.registry.get_point(state.start_id) + assert start_p.x == 10 + assert start_p.y == 20 + + +def test_rectangle_start_preview_with_snap(): + """Test start_preview uses existing point when snapped.""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 60) + state = RectangleCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=existing_pid + ) + + assert isinstance(state, RectanglePreviewState) + assert state.start_temp is False + assert state.start_id == existing_pid + + +def test_rectangle_update_preview(): + """Test update_preview moves end point and refreshes geometry.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + RectangleCommand.update_preview(sketch.registry, state, 100, 50) + + end_p = sketch.registry.get_point(state.p_end_id) + assert end_p.x == 100 + assert end_p.y == 50 + + p2 = sketch.registry.get_point(state.preview_ids["p2"]) + assert p2.x == 100 + assert p2.y == 0 + + +def test_rectangle_cleanup_preview(): + """Test cleanup_preview removes all preview geometry except start.""" + sketch = Sketch() + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + + assert len(sketch.registry.points) > initial_point_count + assert len(sketch.registry.entities) > initial_entity_count + + RectangleCommand.cleanup_preview(sketch.registry, state) + + # cleanup_preview removes preview entities and points + # (p_end_id, preview_ids) but leaves the start_id point - + # it's the tool's responsibility to remove it if start_temp is True + assert len(sketch.registry.entities) == initial_entity_count + # Only start point remains from preview + remaining_preview_ids = { + state.start_id, + } + for p in sketch.registry.points: + if p.id != 0: # origin point + assert p.id in remaining_preview_ids + + +def test_rectangle_cleanup_preview_with_snapped_start(): + """Test cleanup when start point was snapped (not temp).""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 50) + initial_point_count = len(sketch.registry.points) + + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=existing_pid + ) + + RectangleCommand.cleanup_preview(sketch.registry, state) + + # Existing point should still be there + assert len(sketch.registry.points) == initial_point_count + + +def test_rectangle_preview_lifecycle(): + """Test full preview lifecycle: start -> update -> cleanup.""" + sketch = Sketch() + + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + assert state.start_temp is True + + RectangleCommand.update_preview(sketch.registry, state, 80, 60) + + end_p = sketch.registry.get_point(state.p_end_id) + assert end_p.x == 80 + assert end_p.y == 60 + + RectangleCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == 0 + + +def test_rectangle_create_preview_new(): + """Test create_preview creates new preview geometry.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + + preview_ids = RectangleCommand.create_preview( + sketch.registry, start_pid, end_pid + ) + + assert preview_ids is not None + assert "p2" in preview_ids + assert "p4" in preview_ids + assert "line1" in preview_ids + + +def test_rectangle_create_preview_update(): + """Test create_preview updates existing geometry.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + + preview_ids = RectangleCommand.create_preview( + sketch.registry, start_pid, end_pid + ) + assert preview_ids is not None + + sketch.registry.get_point(end_pid).x = 200 + sketch.registry.get_point(end_pid).y = 100 + + result = RectangleCommand.create_preview( + sketch.registry, start_pid, end_pid, preview_ids=preview_ids + ) + + assert result == preview_ids + p2 = sketch.registry.get_point(preview_ids["p2"]) + assert p2.x == 200 + + +def test_rectangle_preview_get_dimensions_returns_width_and_height(): + """Test that dimensions show width and height.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + RectangleCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 2 + width_dim = next(d for d in dims if d.label == "100.00") + height_dim = next(d for d in dims if d.label == "50.00") + assert width_dim is not None + assert height_dim is not None + + +def test_rectangle_preview_get_dimensions_width_position_on_top_edge(): + """Test width label is positioned on top edge.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + RectangleCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + width_dim = next(d for d in dims if d.label == "100.00") + assert width_dim.position[0] == 50.0 + assert width_dim.position[1] == 0 + + +def test_rectangle_preview_get_dimensions_height_position_on_right_edge(): + """Test height label is positioned on right edge.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + RectangleCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + height_dim = next(d for d in dims if d.label == "50.00") + assert height_dim.position[0] == 100 + assert height_dim.position[1] == 25.0 + + +def test_rectangle_preview_get_dimensions_negative_direction(): + """Test dimensions work when dragging in negative direction.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 100, 50, snapped_pid=None + ) + RectangleCommand.update_preview(sketch.registry, state, 0, 0) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 2 + width_dim = next(d for d in dims if d.label == "100.00") + height_dim = next(d for d in dims if d.label == "50.00") + assert width_dim is not None + assert height_dim is not None + + +def test_rectangle_preview_get_dimensions_no_leader_end(): + """Test that rectangle dimensions have no leader_end.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + RectangleCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + for dim in dims: + assert dim.leader_end is None + + +def test_rectangle_preview_get_dimensions_missing_point(): + """Test that missing points return empty list.""" + sketch = Sketch() + state = RectangleCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None + ) + sketch.registry.points.clear() + + dims = state.get_dimensions(sketch.registry) + + assert dims == [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_rounded_rect_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_rounded_rect_cmd.py new file mode 100644 index 000000000..822ec3a5a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_rounded_rect_cmd.py @@ -0,0 +1,309 @@ +from sketcher.core import Sketch +from sketcher.core.commands import ( + RoundedRectCommand, + RoundedRectPreviewState, +) +from sketcher.core.constraints import TangentConstraint +from sketcher.core.entities import Arc, Line + + +def test_rounded_rect_calculate_geometry(): + """Test the static geometry calculation for a rounded rectangle.""" + result = RoundedRectCommand.calculate_geometry(0, 0, 100, 50, 10.0) + assert result is not None + points = result["points"] + assert len(points) == 12 # 8 tangent + 4 center + assert len(result["entities"]) == 8 # 4 lines + 4 arcs + assert len(result["constraints"]) == 17 + + assert sum(isinstance(e, Line) for e in result["entities"]) == 4 + assert sum(isinstance(e, Arc) for e in result["entities"]) == 4 + assert ( + sum(isinstance(c, TangentConstraint) for c in result["constraints"]) + == 8 + ) + + +def test_rounded_rect_calculate_geometry_degenerate(): + """Test static calculation returns None for a zero-size rectangle.""" + assert RoundedRectCommand.calculate_geometry(0, 0, 0, 50, 10.0) is None + + +def test_rounded_rect_command_execute(): + """Test command execution creates the correct number of items.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + cmd = RoundedRectCommand(sketch, start_pid, (100, 50), 10.0) + cmd.execute() + + # Points: 1 origin + 1 existing start + (8 tangent + 4 center) added = 14 + # The virtual corner points are not added to the sketch. + assert len(sketch.registry.points) == 14 + assert len(sketch.registry.entities) == 8 + assert len(sketch.constraints) == 17 + + +def test_rounded_rect_start_preview_no_snap(): + """Test start_preview creates initial preview state with temp point.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=None, radius=10.0 + ) + + assert isinstance(state, RoundedRectPreviewState) + assert state.start_temp is True + assert state.p_end_id is not None + assert state.preview_ids is not None + assert len(state.preview_ids) > 0 + assert state.radius == 10.0 + + start_p = sketch.registry.get_point(state.start_id) + assert start_p.x == 10 + assert start_p.y == 20 + + +def test_rounded_rect_start_preview_with_snap(): + """Test start_preview uses existing point when snapped.""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 60) + state = RoundedRectCommand.start_preview( + sketch.registry, 10, 20, snapped_pid=existing_pid, radius=15.0 + ) + + assert isinstance(state, RoundedRectPreviewState) + assert state.start_temp is False + assert state.start_id == existing_pid + assert state.radius == 15.0 + + +def test_rounded_rect_update_preview(): + """Test update_preview moves end point and refreshes geometry.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + end_p = sketch.registry.get_point(state.p_end_id) + assert end_p.x == 100 + assert end_p.y == 50 + + +def test_rounded_rect_cleanup_preview(): + """Test cleanup_preview removes all preview geometry except start point.""" + sketch = Sketch() + initial_point_count = len(sketch.registry.points) + initial_entity_count = len(sketch.registry.entities) + + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + + assert len(sketch.registry.points) > initial_point_count + assert len(sketch.registry.entities) > initial_entity_count + + RoundedRectCommand.cleanup_preview(sketch.registry, state) + + # cleanup_preview removes preview entities and points (p_end_id, + # preview_ids) but leaves the start_id point - it's the tool's + # responsibility to remove it if start_temp is True + assert len(sketch.registry.entities) == initial_entity_count + # Only start point remains from preview + remaining_preview_ids = { + state.start_id, + } + for p in sketch.registry.points: + if p.id != 0: # origin point + assert p.id in remaining_preview_ids + + +def test_rounded_rect_cleanup_preview_with_snapped_start(): + """Test cleanup when start point was snapped (not temp).""" + sketch = Sketch() + existing_pid = sketch.add_point(50, 50) + initial_point_count = len(sketch.registry.points) + + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=existing_pid, radius=10.0 + ) + + RoundedRectCommand.cleanup_preview(sketch.registry, state) + + # Existing point should still be there + assert len(sketch.registry.points) == initial_point_count + + +def test_rounded_rect_preview_lifecycle(): + """Test full preview lifecycle: start -> update -> cleanup.""" + sketch = Sketch() + + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + assert state.start_temp is True + + RoundedRectCommand.update_preview(sketch.registry, state, 80, 60) + + end_p = sketch.registry.get_point(state.p_end_id) + assert end_p.x == 80 + assert end_p.y == 60 + + RoundedRectCommand.cleanup_preview(sketch.registry, state) + + assert len(sketch.registry.entities) == 0 + + +def test_rounded_rect_create_preview_new(): + """Test create_preview creates new preview geometry.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + + preview_ids = RoundedRectCommand.create_preview( + sketch.registry, start_pid, end_pid, 10.0 + ) + + assert preview_ids is not None + assert "t1" in preview_ids + assert "t2" in preview_ids + assert "line1" in preview_ids + assert "arc1" in preview_ids + + +def test_rounded_rect_create_preview_update(): + """Test create_preview updates existing geometry.""" + sketch = Sketch() + start_pid = sketch.add_point(0, 0) + end_pid = sketch.add_point(100, 50) + + preview_ids = RoundedRectCommand.create_preview( + sketch.registry, start_pid, end_pid, 10.0 + ) + + sketch.registry.get_point(end_pid).x = 200 + sketch.registry.get_point(end_pid).y = 100 + + result = RoundedRectCommand.create_preview( + sketch.registry, start_pid, end_pid, 10.0, preview_ids=preview_ids + ) + + assert result == preview_ids + + +def test_rounded_rect_preview_with_small_radius(): + """Test preview handles radius larger than half dimensions.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=50.0 + ) + + RoundedRectCommand.update_preview(sketch.registry, state, 30, 20) + + end_p = sketch.registry.get_point(state.p_end_id) + assert end_p.x == 30 + assert end_p.y == 20 + + +def test_rounded_rect_preview_get_dimensions_returns_all(): + """Test that dimensions show width, height, and radius.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 3 + labels = [d.label for d in dims] + assert "100.00" in labels + assert "50.00" in labels + assert "R10.00" in labels + + +def test_rounded_rect_preview_get_dimensions_zero_radius(): + """Test that zero radius doesn't add radius dimension.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=0.0 + ) + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + assert len(dims) == 2 + labels = [d.label for d in dims] + assert "100.00" in labels + assert "50.00" in labels + + +def test_rounded_rect_preview_get_dimensions_width_position_on_top_edge(): + """Test width label is positioned on top edge.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + width_dim = next(d for d in dims if d.label == "100.00") + assert width_dim.position[0] == 50.0 + assert width_dim.position[1] == 0 + + +def test_rounded_rect_preview_get_dimensions_height_position_on_right_edge(): + """Test height label is positioned on right edge.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + height_dim = next(d for d in dims if d.label == "50.00") + assert height_dim.position[0] == 100 + assert height_dim.position[1] == 25.0 + + +def test_rounded_rect_preview_get_dimensions_radius_label_format(): + """Test that radius label uses R prefix.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=15.5 + ) + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + radius_dim = next(d for d in dims if d.label.startswith("R")) + assert radius_dim.label == "R15.50" + + +def test_rounded_rect_preview_get_dimensions_no_leader_end(): + """Test that rounded rect dimensions have no leader_end.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + RoundedRectCommand.update_preview(sketch.registry, state, 100, 50) + + dims = state.get_dimensions(sketch.registry) + + for dim in dims: + assert dim.leader_end is None + + +def test_rounded_rect_preview_get_dimensions_missing_point(): + """Test that missing points return empty list.""" + sketch = Sketch() + state = RoundedRectCommand.start_preview( + sketch.registry, 0, 0, snapped_pid=None, radius=10.0 + ) + sketch.registry.points.clear() + + dims = state.get_dimensions(sketch.registry) + + assert dims == [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_straighten_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_straighten_cmd.py new file mode 100644 index 000000000..225230ecf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_straighten_cmd.py @@ -0,0 +1,82 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import StraightenBezierCommand +from sketcher.core.entities import Bezier, Line + + +@pytest.fixture +def sketch(): + return Sketch() + + +@pytest.fixture +def curved_bezier(sketch): + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 10.0) + bezier_id = sketch.registry.add_bezier(p1, p2) + bezier = sketch.registry.get_entity(bezier_id) + bezier.cp1 = (5.0, 0.0) + bezier.cp2 = (5.0, 0.0) + return bezier_id, p1, p2 + + +def test_straighten_converts_bezier_to_line(sketch, curved_bezier): + bezier_id, p1, p2 = curved_bezier + cmd = StraightenBezierCommand(sketch, bezier_id) + cmd.execute() + + entity = sketch.registry.get_entity(bezier_id) + assert isinstance(entity, Line) + assert entity.p1_idx == p1 + assert entity.p2_idx == p2 + + +def test_straighten_preserves_construction_flag(sketch, curved_bezier): + bezier_id, _p1, _p2 = curved_bezier + bezier = sketch.registry.get_entity(bezier_id) + bezier.construction = True + + cmd = StraightenBezierCommand(sketch, bezier_id) + cmd.execute() + + line = sketch.registry.get_entity(bezier_id) + assert isinstance(line, Line) + assert line.construction is True + + +def test_straighten_undo_restores_bezier(sketch, curved_bezier): + bezier_id, _p1, _p2 = curved_bezier + original_bezier = sketch.registry.get_entity(bezier_id) + original_cp1 = original_bezier.cp1 + original_cp2 = original_bezier.cp2 + + cmd = StraightenBezierCommand(sketch, bezier_id) + cmd.execute() + + assert isinstance(sketch.registry.get_entity(bezier_id), Line) + + cmd.undo() + + restored = sketch.registry.get_entity(bezier_id) + assert isinstance(restored, Bezier) + assert restored.cp1 == original_cp1 + assert restored.cp2 == original_cp2 + + +def test_straighten_with_invalid_entity_id(sketch): + cmd = StraightenBezierCommand(sketch, 9999) + cmd.execute() + + assert cmd._old_bezier is None + + +def test_straighten_with_line_entity_does_nothing(sketch): + p1 = sketch.add_point(0.0, 0.0) + p2 = sketch.add_point(10.0, 10.0) + line_id = sketch.add_line(p1, p2) + + cmd = StraightenBezierCommand(sketch, line_id) + cmd.execute() + + entity = sketch.registry.get_entity(line_id) + assert isinstance(entity, Line) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_symmetry_constraint_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_symmetry_constraint_cmd.py new file mode 100644 index 000000000..81a026478 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_symmetry_constraint_cmd.py @@ -0,0 +1,121 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + SymmetryConstraintCommand, + SymmetryConstraintParams, +) + + +@pytest.fixture +def sketch(): + return Sketch() + + +def test_determine_params_three_points(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + center_id = sketch.add_point(5, 0) + + result = SymmetryConstraintCommand.determine_constraint_params( + [p1_id, p2_id, center_id], [] + ) + + assert result is not None + assert isinstance(result, SymmetryConstraintParams) + assert result.p1_id == p1_id + assert result.p2_id == p2_id + assert result.center_id == center_id + assert result.axis_id is None + + +def test_determine_params_two_points_one_line(sketch): + p1_id = sketch.add_point(0, 10) + p2_id = sketch.add_point(0, -10) + + axis_p1 = sketch.add_point(-5, 0) + axis_p2 = sketch.add_point(5, 0) + axis_line_id = sketch.add_line(axis_p1, axis_p2) + + result = SymmetryConstraintCommand.determine_constraint_params( + [p1_id, p2_id], [axis_line_id] + ) + + assert result is not None + assert isinstance(result, SymmetryConstraintParams) + assert result.p1_id == p1_id + assert result.p2_id == p2_id + assert result.center_id is None + assert result.axis_id == axis_line_id + + +def test_determine_params_invalid_two_points_no_entity(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + + result = SymmetryConstraintCommand.determine_constraint_params( + [p1_id, p2_id], [] + ) + + assert result is None + + +def test_determine_params_invalid_one_point(sketch): + p1_id = sketch.add_point(0, 0) + + result = SymmetryConstraintCommand.determine_constraint_params([p1_id], []) + + assert result is None + + +def test_determine_params_invalid_four_points(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + p3_id = sketch.add_point(20, 0) + p4_id = sketch.add_point(30, 0) + + result = SymmetryConstraintCommand.determine_constraint_params( + [p1_id, p2_id, p3_id, p4_id], [] + ) + + assert result is None + + +def test_determine_params_invalid_two_points_two_entities(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + + line1_p1 = sketch.add_point(0, 5) + line1_p2 = sketch.add_point(10, 5) + line1_id = sketch.add_line(line1_p1, line1_p2) + + line2_p1 = sketch.add_point(0, -5) + line2_p2 = sketch.add_point(10, -5) + line2_id = sketch.add_line(line2_p1, line2_p2) + + result = SymmetryConstraintCommand.determine_constraint_params( + [p1_id, p2_id], [line1_id, line2_id] + ) + + assert result is None + + +def test_determine_params_empty_selection(): + result = SymmetryConstraintCommand.determine_constraint_params([], []) + + assert result is None + + +def test_determine_params_three_points_with_entity_ignored(sketch): + p1_id = sketch.add_point(0, 0) + p2_id = sketch.add_point(10, 0) + center_id = sketch.add_point(5, 0) + + axis_p1 = sketch.add_point(0, 5) + axis_p2 = sketch.add_point(10, 5) + axis_line_id = sketch.add_line(axis_p1, axis_p2) + + result = SymmetryConstraintCommand.determine_constraint_params( + [p1_id, p2_id, center_id], [axis_line_id] + ) + + assert result is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_tangent_constraint_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_tangent_constraint_cmd.py new file mode 100644 index 000000000..423ff2c75 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_tangent_constraint_cmd.py @@ -0,0 +1,159 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + TangentConstraintCommand, + TangentConstraintParams, +) + + +@pytest.fixture +def sketch(): + return Sketch() + + +@pytest.fixture +def line_and_arc(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line_id = sketch.add_line(p1, p2) + + center = sketch.add_point(50, 50) + radius = sketch.add_point(60, 50) + arc_id = sketch.add_arc(center, radius, 0, 180) + + return sketch, line_id, arc_id + + +@pytest.fixture +def line_and_circle(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line_id = sketch.add_line(p1, p2) + + center = sketch.add_point(50, 50) + radius = sketch.add_point(60, 50) + circle_id = sketch.add_circle(center, radius) + + return sketch, line_id, circle_id + + +def test_identify_entities_line_and_arc(line_and_arc): + sketch, line_id, arc_id = line_and_arc + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [line_id, arc_id] + ) + + assert result is not None + assert isinstance(result, TangentConstraintParams) + assert result.line_id == line_id + assert result.shape_id == arc_id + + +def test_identify_entities_line_and_circle(line_and_circle): + sketch, line_id, circle_id = line_and_circle + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [line_id, circle_id] + ) + + assert result is not None + assert isinstance(result, TangentConstraintParams) + assert result.line_id == line_id + assert result.shape_id == circle_id + + +def test_identify_entities_arc_and_line(line_and_arc): + sketch, line_id, arc_id = line_and_arc + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [arc_id, line_id] + ) + + assert result is not None + assert result.line_id == line_id + assert result.shape_id == arc_id + + +def test_identify_entities_no_line(sketch): + center = sketch.add_point(50, 50) + radius = sketch.add_point(60, 50) + circle_id = sketch.add_circle(center, radius) + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [circle_id] + ) + + assert result is None + + +def test_identify_entities_no_shape(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line_id = sketch.add_line(p1, p2) + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [line_id] + ) + + assert result is None + + +def test_identify_entities_two_lines(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line1_id = sketch.add_line(p1, p2) + + p3 = sketch.add_point(0, 50) + p4 = sketch.add_point(100, 50) + line2_id = sketch.add_line(p3, p4) + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [line1_id, line2_id] + ) + + assert result is None + + +def test_identify_entities_two_circles(sketch): + center1 = sketch.add_point(50, 50) + radius1 = sketch.add_point(60, 50) + circle1_id = sketch.add_circle(center1, radius1) + + center2 = sketch.add_point(150, 50) + radius2 = sketch.add_point(160, 50) + circle2_id = sketch.add_circle(center2, radius2) + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [circle1_id, circle2_id] + ) + + assert result is None + + +def test_identify_entities_empty_selection(sketch): + result = TangentConstraintCommand.identify_entities(sketch.registry, []) + + assert result is None + + +def test_identify_entities_multiple_shapes_uses_first(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + line_id = sketch.add_line(p1, p2) + + center1 = sketch.add_point(50, 50) + radius1 = sketch.add_point(60, 50) + circle_id = sketch.add_circle(center1, radius1) + + center2 = sketch.add_point(150, 50) + radius2 = sketch.add_point(160, 50) + arc_id = sketch.add_arc(center2, radius2, 0, 90) + + result = TangentConstraintCommand.identify_entities( + sketch.registry, [line_id, circle_id, arc_id] + ) + + assert result is not None + assert result.line_id == line_id + assert result.shape_id in (circle_id, arc_id) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_text_box_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_text_box_cmd.py new file mode 100644 index 000000000..3afcc460d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_text_box_cmd.py @@ -0,0 +1,200 @@ +from sketcher.core import Sketch +from sketcher.core.commands import TextBoxCommand +from sketcher.core.constraints import ( + AspectRatioConstraint, + HorizontalConstraint, + ParallelogramConstraint, + PerpendicularConstraint, +) +from sketcher.core.entities import Line, TextBoxEntity + + +def test_text_box_calculate_geometry_default(): + """Test static calculation with default dimensions.""" + result = TextBoxCommand.calculate_geometry((0, 0), 10.0, 10.0) + assert result is not None + points = result["points"] + entities = result["entities"] + constraints = result["constraints"] + + assert len(points) == 4 + assert len(entities) == 5 + assert len(constraints) == 4 + + assert points[0].x == 0 and points[0].y == 0 + assert points[1].x == 10 and points[1].y == 0 + assert points[2].x == 0 and points[2].y == 10 + assert points[3].x == 10 and points[3].y == 10 + + assert result["text_box_id"] == -9 + + +def test_text_box_calculate_geometry_custom_dimensions(): + """Test static calculation with custom dimensions.""" + result = TextBoxCommand.calculate_geometry((5, 10), 20.0, 15.0) + assert result is not None + points = result["points"] + + assert points[0].x == 5 and points[0].y == 10 + assert points[1].x == 25 and points[1].y == 10 + assert points[2].x == 5 and points[2].y == 25 + assert points[3].x == 25 and points[3].y == 25 + + +def test_text_box_calculate_geometry_entities(): + """Test that correct entities are created.""" + result = TextBoxCommand.calculate_geometry((0, 0), 10.0, 10.0) + entities = result["entities"] + + lines = [e for e in entities if isinstance(e, Line)] + text_boxes = [e for e in entities if isinstance(e, TextBoxEntity)] + + assert len(lines) == 4 + assert len(text_boxes) == 1 + + assert all(line.construction for line in lines) + + +def test_text_box_calculate_geometry_constraints(): + """Test that correct constraints are created.""" + result = TextBoxCommand.calculate_geometry((0, 0), 10.0, 10.0) + constraints = result["constraints"] + + aspect_ratio = [ + c for c in constraints if isinstance(c, AspectRatioConstraint) + ] + parallelogram = [ + c for c in constraints if isinstance(c, ParallelogramConstraint) + ] + horizontal = [ + c for c in constraints if isinstance(c, HorizontalConstraint) + ] + perpendicular = [ + c for c in constraints if isinstance(c, PerpendicularConstraint) + ] + + assert len(aspect_ratio) == 1 + assert aspect_ratio[0].user_visible is True + + assert len(parallelogram) == 1 + assert parallelogram[0].user_visible is False + + assert len(horizontal) == 1 + assert horizontal[0].user_visible is True + + assert len(perpendicular) == 1 + assert perpendicular[0].user_visible is True + + +def test_text_box_command_initialization(): + """Test command initialization.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (0, 0)) + + assert cmd.sketch is sketch + assert cmd.origin == (0, 0) + assert cmd.width == 10.0 + assert cmd.height == 10.0 + assert cmd.add_cmd is None + assert cmd.text_box_id is None + + +def test_text_box_command_initialization_custom_dimensions(): + """Test command initialization with custom dimensions.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (5, 10), 20.0, 15.0) + + assert cmd.origin == (5, 10) + assert cmd.width == 20.0 + assert cmd.height == 15.0 + + +def test_text_box_command_execute(): + """Test command execution.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + cmd.execute() + + assert len(sketch.registry.points) == 5 + assert len(sketch.registry.entities) == 5 + assert len(sketch.constraints) == 4 + assert cmd.text_box_id is not None + + +def test_text_box_command_execute_custom_dimensions(): + """Test command execution with custom dimensions.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (5, 10), 20.0, 15.0) + cmd.execute() + + assert len(sketch.registry.points) == 5 + assert len(sketch.registry.entities) == 5 + assert len(sketch.constraints) == 4 + assert cmd.text_box_id is not None + + text_box = sketch.registry.get_entity(cmd.text_box_id) + assert isinstance(text_box, TextBoxEntity) + + +def test_text_box_command_undo(): + """Test command undo.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + cmd.execute() + + assert len(sketch.registry.points) == 5 + assert len(sketch.registry.entities) == 5 + assert len(sketch.constraints) == 4 + + cmd.undo() + + assert len(sketch.registry.points) == 1 + assert len(sketch.registry.entities) == 0 + assert len(sketch.constraints) == 0 + + +def test_text_box_command_execute_after_undo(): + """Test command execution after undo.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + cmd.execute() + + initial_points = len(sketch.registry.points) + initial_entities = len(sketch.registry.entities) + initial_constraints = len(sketch.constraints) + + cmd.undo() + cmd.execute() + + assert len(sketch.registry.points) == initial_points + assert len(sketch.registry.entities) == initial_entities + assert len(sketch.constraints) == initial_constraints + + +def test_text_box_command_construction_lines(): + """Test that construction lines are created.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + cmd.execute() + + lines = [ + e + for e in sketch.registry.entities + if isinstance(e, Line) and e.construction + ] + + assert len(lines) == 4 + + +def test_text_box_command_text_box_entity(): + """Test that TextBoxEntity is created with correct properties.""" + sketch = Sketch() + cmd = TextBoxCommand(sketch, (0, 0), 10.0, 10.0) + cmd.execute() + + assert cmd.text_box_id is not None + text_box = sketch.registry.get_entity(cmd.text_box_id) + assert isinstance(text_box, TextBoxEntity) + assert text_box.content == "" + assert text_box.construction is False + assert len(text_box.construction_line_ids) == 4 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_text_property_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_text_property_cmd.py new file mode 100644 index 000000000..9c34f8730 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_text_property_cmd.py @@ -0,0 +1,744 @@ +import pytest +from raygeo.geo.shape.text import FontConfig +from sketcher.core import Sketch +from sketcher.core.commands import ModifyTextPropertyCommand +from sketcher.core.entities.text_box import TextBoxEntity + +from rayforge.core.undo import HistoryManager + + +@pytest.fixture +def sketch_with_text_box(): + """Create a sketch with a text box entity for testing.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig( + family="sans-serif", + size=10.0, + bold=False, + italic=False, + ), + ) + + return sketch, tb_id + + +def test_modify_text_property_command_initialization( + sketch_with_text_box, +): + """Test that ModifyTextPropertyCommand initializes correctly.""" + sketch, tb_id = sketch_with_text_box + + new_content = "New Text" + new_font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=False, + ) + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, new_content, new_font_config + ) + + assert cmd.sketch is sketch + assert cmd.text_entity_id == tb_id + assert cmd.new_content == new_content + assert cmd.new_font_config == new_font_config + assert cmd.old_content == "" + assert cmd.old_font_config is None + + +def test_modify_text_property_command_execute(sketch_with_text_box): + """Test that execute updates the text entity properties.""" + sketch, tb_id = sketch_with_text_box + + new_content = "New Text" + new_font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=False, + ) + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, new_content, new_font_config + ) + + tb = sketch.registry.get_entity(tb_id) + assert tb.content == "Original Text" + assert tb.font_config.family == "sans-serif" + + cmd.execute() + + assert tb.content == new_content + assert tb.font_config == new_font_config + assert cmd.old_content == "Original Text" + assert cmd.old_font_config is not None + assert cmd.old_font_config.family == "sans-serif" + assert cmd.old_font_config.size == 10.0 + + +def test_modify_text_property_command_undo(sketch_with_text_box): + """Test that undo restores the original text entity properties.""" + sketch, tb_id = sketch_with_text_box + + new_content = "New Text" + new_font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=False, + ) + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, new_content, new_font_config + ) + + cmd.execute() + + tb = sketch.registry.get_entity(tb_id) + assert tb.content == new_content + assert tb.font_config == new_font_config + + cmd.undo() + + assert tb.content == "Original Text" + assert tb.font_config.family == "sans-serif" + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + + +def test_modify_text_property_command_execute_undo_cycle(sketch_with_text_box): + """Test that execute and undo can be called multiple times.""" + sketch, tb_id = sketch_with_text_box + + new_content = "New Text" + new_font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=False, + ) + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, new_content, new_font_config + ) + + for _ in range(3): + cmd.execute() + tb = sketch.registry.get_entity(tb_id) + assert tb.content == new_content + assert tb.font_config == new_font_config + + cmd.undo() + tb = sketch.registry.get_entity(tb_id) + assert tb.content == "Original Text" + assert tb.font_config.family == "sans-serif" + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + + +def test_modify_text_property_command_with_missing_entity( + sketch_with_text_box, +): + """Test that execute handles missing entity gracefully.""" + sketch, _ = sketch_with_text_box + + cmd = ModifyTextPropertyCommand(sketch, 9999, "New Text", FontConfig()) + + cmd.execute() + assert cmd.old_content == "" + assert cmd.old_font_config is None + + cmd.undo() + assert cmd.old_content == "" + assert cmd.old_font_config is None + + +def test_modify_text_property_command_full_font_update( + sketch_with_text_box, +): + """Test that command replaces entire font_config.""" + sketch, tb_id = sketch_with_text_box + + new_font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=True, + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "New Text", new_font_config) + + cmd.execute() + + tb = sketch.registry.get_entity(tb_id) + assert tb.font_config.family == "serif" + assert tb.font_config.size == 14.0 + assert tb.font_config.bold is True + assert tb.font_config.italic is True + + +def test_text_property_command_undo_with_history_manager(): + """ + Test that text property command works correctly with history manager. + This is an integration test that verifies the full undo flow. + """ + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig( + family="sans-serif", + size=10.0, + bold=False, + italic=False, + ), + ) + + history = HistoryManager() + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Original Text" + assert tb.font_config.size == 10.0 + + new_content = "New Text" + new_font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=False, + ) + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, new_content, new_font_config + ) + + history.execute(cmd) + + assert history.can_undo() + assert not history.can_redo() + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == new_content + assert tb.font_config == new_font_config + + history.undo() + + assert not history.can_undo() + assert history.can_redo() + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Original Text" + assert tb.font_config.family == "sans-serif" + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + + +def test_text_property_command_redo_after_undo(): + """ + Test that text property command can be redone after undo. + """ + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + history = HistoryManager() + + cmd = ModifyTextPropertyCommand( + sketch, + tb_id, + "Modified", + FontConfig(family="serif", size=12.0), + ) + + history.execute(cmd) + history.undo() + history.redo() + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Modified" + assert tb.font_config.family == "serif" + assert tb.font_config.size == 12.0 + + +def test_text_property_command_multiple_edits_with_history(): + """ + Test that multiple text edits can be undone and redone correctly. + """ + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Initial", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + history = HistoryManager() + + cmd1 = ModifyTextPropertyCommand( + sketch, + tb_id, + "First Edit", + FontConfig(family="serif", size=12.0), + ) + cmd2 = ModifyTextPropertyCommand( + sketch, + tb_id, + "Second Edit", + FontConfig(family="monospace", size=14.0), + ) + + history.execute(cmd1) + history.execute(cmd2) + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Second Edit" + assert tb.font_config.family == "monospace" + + history.undo() + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "First Edit" + assert tb.font_config.family == "serif" + + history.undo() + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Initial" + assert tb.font_config.family == "sans-serif" + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + assert tb.font_config.size == 10.0 + assert tb.font_config.bold is False + + history.redo() + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "First Edit" + assert tb.font_config.family == "serif" + + +def test_should_skip_undo_both_empty(): + """Test should_skip_undo returns True when both contents empty.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + assert cmd.should_skip_undo() is True + + +def test_should_skip_undo_old_empty_new_not_empty(): + """Test should_skip_undo returns False when old empty, new not empty.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "New Text", FontConfig()) + + assert cmd.should_skip_undo() is False + + +def test_should_skip_undo_old_not_empty_new_empty(): + """Test should_skip_undo returns False when old not empty, new empty.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + cmd.execute() + + assert cmd.should_skip_undo() is False + + +def test_should_skip_undo_both_not_empty(): + """Test should_skip_undo returns False when both contents not empty.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "New Text", FontConfig()) + + assert cmd.should_skip_undo() is False + + +def test_empty_text_box_removed_on_execute(): + """Test that text box is removed when content becomes empty.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + cmd.execute() + + assert cmd._entity_was_removed is True + assert cmd._removed_entity is not None + assert cmd._removed_entity.id == tb_id + + tb = sketch.registry.get_entity(tb_id) + assert tb is None + + +def test_empty_text_box_points_removed(): + """Test that associated points are removed when text box is removed.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + cmd.execute() + + assert len(cmd._removed_points) == 3 + + point_ids = {pt.id for pt in cmd._removed_points} + for pt in sketch.registry.points: + assert pt.id not in point_ids + + +def test_empty_text_box_constraints_removed(): + """Test that constraints depending on removed points are removed.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + cmd.execute() + + point_ids = {pt.id for pt in cmd._removed_points} + + for constr in sketch.constraints or []: + assert not constr.depends_on_points(point_ids) + + +def test_empty_text_box_undo_restores_entity(): + """Test that undo restores the removed text entity.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + cmd.execute() + + assert sketch.registry.get_entity(tb_id) is None + + cmd.undo() + + tb = sketch.registry.get_entity(tb_id) + assert tb is not None + assert isinstance(tb, TextBoxEntity) + + +def test_empty_text_box_undo_restores_points(): + """Test that undo restores the removed points.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + cmd.execute() + + point_ids = {pt.id for pt in cmd._removed_points} + for pt in sketch.registry.points: + assert pt.id not in point_ids + + cmd.undo() + + for pt in cmd._removed_points: + restored = sketch.registry.get_point(pt.id) + assert restored is not None + assert restored.id == pt.id + + +def test_empty_text_box_with_history_manager(): + """Test empty text box removal works with history manager.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + history = HistoryManager() + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + history.execute(cmd) + + assert history.can_undo() is True + assert sketch.registry.get_entity(tb_id) is None + + history.undo() + + assert history.can_redo() is True + tb = sketch.registry.get_entity(tb_id) + assert tb is not None + assert isinstance(tb, TextBoxEntity) + + +def test_empty_text_box_undo_restores_constraints(): + """Test that undo restores the removed constraints.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Original Text", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand(sketch, tb_id, "", FontConfig()) + + cmd.execute() + + point_ids = {pt.id for pt in cmd._removed_points} + + for constr in sketch.constraints or []: + assert not constr.depends_on_points(point_ids) + + cmd.undo() + + for pt in cmd._removed_points: + restored = sketch.registry.get_point(pt.id) + assert restored is not None + assert restored.id == pt.id + + +def test_undo_removes_text_box_when_reverting_to_empty(): + """ + Test that undo removes the text box when reverting to empty content. + This handles the case where user types text into an empty box, then + undoes - the box should be removed, not left empty. + """ + sketch = Sketch() + + initial_point_count = len(sketch.registry.points) + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, "New Text", FontConfig(family="sans-serif") + ) + + cmd.execute() + + tb = sketch.registry.get_entity(tb_id) + assert tb is not None + assert isinstance(tb, TextBoxEntity) + assert tb.content == "New Text" + + cmd.undo() + + assert sketch.registry.get_entity(tb_id) is None + assert len(sketch.registry.points) == initial_point_count + + +def test_redo_restores_text_box_after_undo_to_empty(): + """ + Test that redo restores the text box after undoing to empty content. + Uses HistoryManager to properly test redo behavior. + """ + sketch = Sketch() + + initial_point_count = len(sketch.registry.points) + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + history = HistoryManager() + + cmd = ModifyTextPropertyCommand( + sketch, tb_id, "New Text", FontConfig(family="sans-serif") + ) + + history.execute(cmd) + + tb = sketch.registry.get_entity(tb_id) + assert tb is not None + assert isinstance(tb, TextBoxEntity) + assert tb.content == "New Text" + + history.undo() + + assert sketch.registry.get_entity(tb_id) is None + assert len(sketch.registry.points) == initial_point_count + + history.redo() + + tb = sketch.registry.get_entity(tb_id) + assert tb is not None + assert isinstance(tb, TextBoxEntity) + assert tb.content == "New Text" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_angle_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_angle_constraint.py new file mode 100644 index 000000000..98c947360 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_angle_constraint.py @@ -0,0 +1,236 @@ +import math +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from sketcher.core import Sketch +from sketcher.core.constraints import AngleConstraint +from sketcher.core.constraints.angle import ARC_RADIUS +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_angle_constraint_90_degrees(setup_env): + reg, params = setup_env + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(0, 10) + l2 = reg.add_line(p3, p4) + + c = AngleConstraint(l2, l1, 90.0) + assert c.error(reg, params) == pytest.approx(0.0, abs=1e-6) + + +def test_angle_constraint_45_degrees(setup_env): + reg, params = setup_env + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(10, 10) + l2 = reg.add_line(p3, p4) + + c = AngleConstraint(l2, l1, 45.0) + assert c.error(reg, params) == pytest.approx(0.0, abs=1e-6) + + +def test_angle_constraint_non_zero_error(setup_env): + reg, params = setup_env + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(0, 10) + l2 = reg.add_line(p3, p4) + + c = AngleConstraint(l2, l1, 45.0) + error = c.error(reg, params) + assert error == pytest.approx(math.pi / 4 * 10, abs=1e-6) + + +def test_angle_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(0, 10) + l2 = reg.add_line(p3, p4) + + c = AngleConstraint(l1, l2, 90.0, user_visible=False) + assert c.user_visible is False + + c2 = AngleConstraint(l1, l2, 90.0, user_visible=True) + assert c2.user_visible is True + + +def test_angle_constraint_with_expression(setup_env): + reg, params = setup_env + ctx = {"angle": 45.0} + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(10, 10) + l2 = reg.add_line(p3, p4) + + c = AngleConstraint(l2, l1, "angle") + c.update_from_context(ctx) + + assert c.error(reg, params) == pytest.approx(0.0, abs=1e-6) + + +def test_angle_constraint_invalid_entities(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + + c = AngleConstraint(p1, p1, 90.0) + assert c.error(reg, params) == 0.0 + assert c.gradient(reg, params) == {} + + +def test_angle_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(0, 10) + l2 = reg.add_line(p3, p4) + + original = AngleConstraint(l1, l2, 90.0) + + serialized = original.to_dict() + + restored = AngleConstraint.from_dict(serialized) + + assert original.error(reg, params) == pytest.approx( + restored.error(reg, params), abs=1e-6 + ) + assert original.user_visible == restored.user_visible + + +def test_angle_constraint_serialization_with_expression(setup_env): + reg, params = setup_env + ctx = {"angle": 90.0} + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(0, 10) + l2 = reg.add_line(p3, p4) + + original = AngleConstraint(l1, l2, "angle") + original.update_from_context(ctx) + + serialized = original.to_dict() + restored = AngleConstraint.from_dict(serialized) + restored.update_from_context(ctx) + + assert original.error(reg, params) == pytest.approx( + restored.error(reg, params), abs=1e-6 + ) + + +def test_angle_is_hit(setup_env): + reg, _params = setup_env + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + l1p1 = reg.add_point(0, 50) + l1p2 = reg.add_point(100, 50) + l1 = reg.add_line(l1p1, l1p2) + + l2p1 = reg.add_point(50, 0) + l2p2 = reg.add_point(50, 100) + l2 = reg.add_line(l2p1, l2p2) + + c = AngleConstraint(l2, l1, 90.0) + + hit_x, hit_y = ( + 50 + ARC_RADIUS * math.cos(math.pi), + 50 + ARC_RADIUS * math.sin(math.pi), + ) + assert ( + c.is_hit(hit_x, hit_y, reg, to_screen, mock_element, threshold) is True + ) + + +def test_angle_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 0) + p4 = reg.add_point(0, 10) + l2 = reg.add_line(p3, p4) + + c = AngleConstraint(l1, l2, 90.0) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_angle_constraint_get_type_name(): + assert AngleConstraint.get_type_name() == "Angle" + + +def test_angle_can_apply_to_two_lines(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(0, 10) + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p1, p3) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line1_id, line2_id] + assert AngleConstraint.can_apply_to(selection, sketch) is True + + +def test_angle_can_apply_to_one_line_invalid(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id] + assert AngleConstraint.can_apply_to(selection, sketch) is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_aspect_ratio_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_aspect_ratio_constraint.py new file mode 100644 index 000000000..487a4b2ba --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_aspect_ratio_constraint.py @@ -0,0 +1,304 @@ +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import AspectRatioConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_aspect_ratio_constraint(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + dist1 = 10.0 + dist2 = 5.0 + expected_error = dist1 - dist2 * 2.0 + assert c.error(reg, params) == pytest.approx(expected_error) + assert c.user_visible is True + + +def test_aspect_ratio_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0, user_visible=False) + assert c.user_visible is False + + c2 = AspectRatioConstraint(p1, p2, p3, p4, 2.0, user_visible=True) + assert c2.user_visible is True + + +def test_aspect_ratio_constraint_perfect_match(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + assert c.error(reg, params) == pytest.approx(0.0) + + +def test_aspect_ratio_constraint_diagonal_lines(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(6, 8) + p3 = reg.add_point(10, 10) + p4 = reg.add_point(13, 14) + + c = AspectRatioConstraint(p1, p2, p3, p4, 1.0) + dist1 = 10.0 + dist2 = 5.0 + expected_error = dist1 - dist2 * 1.0 + assert c.error(reg, params) == pytest.approx(expected_error) + + +def test_aspect_ratio_constraint_zero_ratio(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 0.0) + dist1 = 10.0 + expected_error = dist1 + assert c.error(reg, params) == pytest.approx(expected_error) + + +def test_aspect_ratio_constraint_zero_second_distance(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(0, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + dist1 = 10.0 + expected_error = dist1 + assert c.error(reg, params) == pytest.approx(expected_error) + + +def test_aspect_ratio_constrains_radius_method(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + + assert c.constrains_radius(reg, 999) is False + assert c.constrains_radius(reg, p1) is False + + +def test_aspect_ratio_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + + # Base implementation returns False + assert c.targets_segment(p1, p2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_aspect_ratio_constraint_gradient(setup_env): + reg, params = setup_env + p1_id = reg.add_point(1, 2) + p2_id = reg.add_point(5, 6) + p3_id = reg.add_point(0, 0) + p4_id = reg.add_point(3, 0) + mutable_pids = [p1_id, p2_id, p3_id, p4_id] + + constraint = AspectRatioConstraint( + p1=p1_id, p2=p2_id, p3=p3_id, p4=p4_id, ratio=2.0 + ) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([1, 2, 5, 6, 0, 0, 3, 0], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_aspect_ratio_constraint_gradient_zero_first_distance(setup_env): + reg, params = setup_env + p1_id = reg.add_point(0, 0) + p2_id = reg.add_point(0, 0) + p3_id = reg.add_point(0, 0) + p4_id = reg.add_point(3, 0) + + constraint = AspectRatioConstraint( + p1=p1_id, p2=p2_id, p3=p3_id, p4=p4_id, ratio=2.0 + ) + + grad_map = constraint.gradient(reg, params) + assert p1_id not in grad_map + assert p2_id not in grad_map + + +def test_aspect_ratio_constraint_gradient_zero_second_distance(setup_env): + reg, params = setup_env + p1_id = reg.add_point(0, 0) + p2_id = reg.add_point(3, 0) + p3_id = reg.add_point(0, 0) + p4_id = reg.add_point(0, 0) + + constraint = AspectRatioConstraint( + p1=p1_id, p2=p2_id, p3=p3_id, p4=p4_id, ratio=2.0 + ) + + grad_map = constraint.gradient(reg, params) + assert p3_id not in grad_map + assert p4_id not in grad_map + + +def test_aspect_ratio_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + original = AspectRatioConstraint(p1, p2, p3, p4, 2.5) + + serialized = original.to_dict() + + restored = AspectRatioConstraint.from_dict(serialized) + + assert original.error(reg, params) == restored.error(reg, params) + assert original.p1 == restored.p1 + assert original.p2 == restored.p2 + assert original.p3 == restored.p3 + assert original.p4 == restored.p4 + assert original.ratio == restored.ratio + assert original.user_visible == restored.user_visible + + +def test_aspect_ratio_constraint_serialization_dict_format(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.5) + + data = c.to_dict() + + assert data["type"] == "AspectRatioConstraint" + assert data["p1"] == p1 + assert data["p2"] == p2 + assert data["p3"] == p3 + assert data["p4"] == p4 + assert data["ratio"] == 2.5 + assert data["user_visible"] is True + + +def test_aspect_ratio_depends_on_points(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + + assert c.depends_on_points({p1}) is True + assert c.depends_on_points({p2}) is True + assert c.depends_on_points({p3}) is True + assert c.depends_on_points({p4}) is True + assert c.depends_on_points({p1, p2}) is True + assert c.depends_on_points({999}) is False + assert c.depends_on_points(set()) is False + + +def test_aspect_ratio_depends_on_entities(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + + assert c.depends_on_entities({999}) is False + assert c.depends_on_entities(set()) is False + + +def test_aspect_ratio_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + + c = AspectRatioConstraint(p1, p2, p3, p4, 2.0) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_aspect_ratio_can_apply_to_two_lines(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(0, 10) + p4 = sketch.add_point(0, 20) + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line1_id, line2_id] + assert AspectRatioConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_coincident_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_coincident_constraint.py new file mode 100644 index 000000000..f11bf8c98 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_coincident_constraint.py @@ -0,0 +1,163 @@ +from functools import partial +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import CoincidentConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_coincident_constraint(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(3, 4) + + c = CoincidentConstraint(p1, p2) + # Error is a tuple (dx, dy) + assert c.error(reg, params) == (0.0 - 3.0, 0.0 - 4.0) + assert c.user_visible is True + + +def test_coincident_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(3, 4) + + c = CoincidentConstraint(p1, p2, user_visible=False) + assert c.user_visible is False + + c2 = CoincidentConstraint(p1, p2, user_visible=True) + assert c2.user_visible is True + + +def test_coincident_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(3, 4) + + c = CoincidentConstraint(p1, p2) + + # Base implementation returns False (Coincident is topological) + assert c.targets_segment(p1, p2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_coincident_constraint_gradient(setup_env): + reg, params = setup_env + p1_id = reg.add_point(1, 2) + p2_id = reg.add_point(5, 6) + mutable_pids = [p1_id, p2_id] + + constraint = CoincidentConstraint(p1=p1_id, p2=p2_id) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([1, 2, 5, 6], dtype=float) + + for i in range(2): # Two error components for this constraint + func = partial(func_wrapper, error_index=i) + grad = partial(grad_wrapper, error_index=i) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_coincident_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(3, 4) + + # Create original constraint + original = CoincidentConstraint(p1, p2) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = CoincidentConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_coincident_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(10, 20) + p2 = reg.add_point(10, 20) + c = CoincidentConstraint(p1, p2) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace(sketch=SimpleNamespace(origin_id=-1)) + threshold = 15.0 + + # Hit the point + assert c.is_hit(10, 20, reg, to_screen, mock_element, threshold) is True + # Miss the point + assert c.is_hit(30, 20, reg, to_screen, mock_element, threshold) is False + + +def test_coincident_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(3, 4) + + c = CoincidentConstraint(p1, p2) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_coincident_can_apply_to(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + + selection = SketchSelection() + selection.point_ids = [p1, p2] + selection.entity_ids = [] + assert CoincidentConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_collinear_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_collinear_constraint.py new file mode 100644 index 000000000..b128f3480 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_collinear_constraint.py @@ -0,0 +1,142 @@ +from unittest.mock import MagicMock + +import pytest +from sketcher.core import Sketch +from sketcher.core.constraints import CollinearConstraint + + +@pytest.fixture +def sketch_with_points(): + """Create a sketch with three points for testing.""" + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 10) + p3 = s.add_point(20, 0) + return s, p1, p2, p3 + + +def test_collinear_constraint_serialization(): + """Test that the constraint can be serialized and deserialized.""" + constraint = CollinearConstraint(1, 2, 3) + data = constraint.to_dict() + assert data == { + "type": "CollinearConstraint", + "p1": 1, + "p2": 2, + "p3": 3, + "user_visible": True, + } + new_constraint = CollinearConstraint.from_dict(data) + assert isinstance(new_constraint, CollinearConstraint) + assert new_constraint.p1 == 1 + assert new_constraint.p2 == 2 + assert new_constraint.p3 == 3 + assert new_constraint.user_visible is True + + +def test_collinear_constraint_user_visible(): + """Test that user_visible can be set.""" + c = CollinearConstraint(1, 2, 3, user_visible=False) + assert c.user_visible is False + + c2 = CollinearConstraint(1, 2, 3, user_visible=True) + assert c2.user_visible is True + + +def test_collinear_targets_segment(): + """Test targets_segment default behavior.""" + c = CollinearConstraint(1, 2, 3) + # Default implementation returns False (Collinear is topological) + assert c.targets_segment(1, 2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_collinear_constraint_error_zero(sketch_with_points): + """Test the error is zero for perfectly collinear points.""" + sketch, p1, p2, p3 = sketch_with_points + # Move p3 to be on the line p1-p2 + pt3 = sketch.registry.get_point(p3) + pt3.x = 5 + pt3.y = 5 + + constraint = CollinearConstraint(p1, p2, p3) + error = constraint.error(sketch.registry, sketch.params) + assert abs(error) < 1e-9 + + +def test_collinear_constraint_error_non_zero(sketch_with_points): + """Test the error is non-zero for non-collinear points.""" + sketch, p1, p2, p3 = sketch_with_points + # p1=(0,0), p2=(10,10), p3=(20,0) + constraint = CollinearConstraint(p1, p2, p3) + error = constraint.error(sketch.registry, sketch.params) + + # Expected error: + # (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) + # = (10 - 0) * (0 - 0) - (10 - 0) * (20 - 0) = 0 - 10 * 20 = -200 + assert pytest.approx(error) == -200.0 + + +def test_collinear_constraint_gradient(sketch_with_points): + """Test the gradient calculation for the constraint.""" + sketch, p1, p2, p3 = sketch_with_points + constraint = CollinearConstraint(p1, p2, p3) + gradient = constraint.gradient(sketch.registry, sketch.params) + + pt1 = sketch.registry.get_point(p1) + pt2 = sketch.registry.get_point(p2) + pt3 = sketch.registry.get_point(p3) + + # dE/dp1x = p2y - p3y = 10 - 0 = 10 + # dE/dp1y = p3x - p2x = 20 - 10 = 10 + grad_p1 = (pt2.y - pt3.y, pt3.x - pt2.x) + assert pytest.approx(gradient[p1][0]) == grad_p1 + + # dE/dp2x = p3y - p1y = 0 - 0 = 0 + # dE/dp2y = -(p3x - p1x) = -(20 - 0) = -20 + grad_p2 = (pt3.y - pt1.y, -(pt3.x - pt1.x)) + assert pytest.approx(gradient[p2][0]) == grad_p2 + + # dE/dp3x = -(p2y - p1y) = -(10 - 0) = -10 + # dE/dp3y = p2x - p1x = 10 - 0 = 10 + grad_p3 = (-(pt2.y - pt1.y), pt2.x - pt1.x) + assert pytest.approx(gradient[p3][0]) == grad_p3 + + +def test_collinear_solver_integration(sketch_with_points): + """Test that applying the constraint and solving moves the point.""" + sketch, p1, p2, p3 = sketch_with_points + + # Fix p1 and p2 to define the line + sketch.registry.get_point(p1).fixed = True + sketch.registry.get_point(p2).fixed = True + + constraint = CollinearConstraint(p1, p2, p3) + sketch.constraints.append(constraint) + + success = sketch.solve() + assert success is True + + # After solving, p3 should be on the line defined by p1=(0,0) and + # p2=(10,10). The solver will find the closest point, which in this + # case will move p3. + # The exact final position depends on the solver's path. + # We verify the error is now zero. + error = constraint.error(sketch.registry, sketch.params) + assert abs(error) < 1e-6 + + +def test_collinear_draw(sketch_with_points): + """Test that the draw method can be called without errors.""" + sketch, p1, p2, p3 = sketch_with_points + constraint = CollinearConstraint(p1, p2, p3) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + constraint.draw(ctx, sketch.registry, to_screen) + constraint.draw(ctx, sketch.registry, to_screen, is_selected=True) + constraint.draw(ctx, sketch.registry, to_screen, is_hovered=True) + constraint.draw(ctx, sketch.registry, to_screen, point_radius=10.0) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_diameter_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_diameter_constraint.py new file mode 100644 index 000000000..52cfacb87 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_diameter_constraint.py @@ -0,0 +1,239 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import DiameterConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_diameter_constraint(setup_env): + reg, params = setup_env + center = reg.add_point(0, 0) + # Radius is 5, so diameter is 10 + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Target diam is 10, actual is 10. Error = 10 - 10 = 0 + c = DiameterConstraint(circ_id, 10.0) + assert c.error(reg, params) == pytest.approx(0.0) + assert c.user_visible is True + + # Target diam is 20, actual is 10. Error = 10 - 20 = -10 + c2 = DiameterConstraint(circ_id, 20.0) + assert c2.error(reg, params) == pytest.approx(-10.0) + + +def test_diameter_constraint_user_visible(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + c = DiameterConstraint(circ_id, 10.0, user_visible=False) + assert c.user_visible is False + + c2 = DiameterConstraint(circ_id, 10.0, user_visible=True) + assert c2.user_visible is True + + +def test_diameter_constrains_radius_method(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + other_circ_id = reg.add_circle(center, radius_pt) + + c = DiameterConstraint(circ_id, 10.0) + + # Should return True for the constrained circle + assert c.constrains_radius(reg, circ_id) is True + # Should return False for any other entity + assert c.constrains_radius(reg, other_circ_id) is False + assert c.constrains_radius(reg, 999) is False + + +def test_diameter_targets_segment(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + other_circ_id = reg.add_circle(center, radius_pt) + + c = DiameterConstraint(circ_id, 10.0) + + # Should match based on entity ID (points ignored for this check) + assert c.targets_segment(0, 0, circ_id) is True + + # Should not match other entities or None + assert c.targets_segment(0, 0, other_circ_id) is False + assert c.targets_segment(0, 0, None) is False + + +def test_diameter_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Create original constraint + original = DiameterConstraint(circ_id, 10.0) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = DiameterConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_diameter_constraint_with_expression(setup_env): + reg, params = setup_env + center = reg.add_point(0, 0) + # Radius is 5, so diameter is 10 + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Define context + ctx = {"target_diam": 20.0} + + # Create constraint with expression + c = DiameterConstraint(circ_id, "target_diam") + + # Verify initial state (value is 0.0 before update) + assert c.value == 0.0 + + # Update from context + c.update_from_context(ctx) + assert c.value == 20.0 + + # Actual diam is 10, Target is 20. Error = 10 - 20 = -10 + assert c.error(reg, params) == pytest.approx(-10.0) + + +def test_diameter_constraint_gradient(setup_env): + reg, params = setup_env + c_id = reg.add_point(0, 0) + r_id = reg.add_point(5, 0) + circ_id = reg.add_circle(c_id, r_id) + mutable_pids = [c_id, r_id] + + constraint = DiameterConstraint(circle_id=circ_id, value=12.0) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 5, 0], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_diameter_is_hit(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(100, 0) # radius=100 + circ_id = reg.add_circle(center, radius_pt) + + c = DiameterConstraint(circ_id, 200) + + # Mock canvas and element + mock_canvas = MagicMock() + mock_canvas.get_view_scale.return_value = (1.0, 1.0) + mock_element = SimpleNamespace(canvas=mock_canvas) + + def to_screen(pos): + return pos + + threshold = 15.0 + + # Label pos logic places it at (radius + 20) along radius vector + # (100 + 20, 0) = (120, 0) + label_pos_x, label_pos_y = 120, 0 + + # Hit + assert ( + c.is_hit( + label_pos_x, label_pos_y, reg, to_screen, mock_element, threshold + ) + is True + ) + # Miss + assert c.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_diameter_draw(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + c = DiameterConstraint(circ_id, 10.0) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_diameter_can_apply_to_circle(): + sketch = Sketch() + center = sketch.add_point(0, 0) + radius = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center, radius) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [circle_id] + assert DiameterConstraint.can_apply_to(selection, sketch) is True + + +def test_diameter_can_apply_to_arc(): + sketch = Sketch() + start = sketch.add_point(10, 0) + end = sketch.add_point(0, 10) + center = sketch.add_point(0, 0) + arc_id = sketch.add_arc(start, end, center) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [arc_id] + assert DiameterConstraint.can_apply_to(selection, sketch) is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_distance_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_distance_constraint.py new file mode 100644 index 000000000..5f7592fdd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_distance_constraint.py @@ -0,0 +1,258 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import DistanceConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_distance_constraint(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + # Target is 10, actual is 10. Error should be 10 - 10 = 0. + c = DistanceConstraint(p1, p2, 10.0) + assert c.error(reg, params) == pytest.approx(0.0) + assert c.user_visible is True + + # Target is 5, actual is 10. Error should be 10 - 5 = 5. + c2 = DistanceConstraint(p1, p2, 5.0) + assert c2.error(reg, params) == pytest.approx(5.0) + + +def test_distance_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + c = DistanceConstraint(p1, p2, 10.0, user_visible=False) + assert c.user_visible is False + + c2 = DistanceConstraint(p1, p2, 10.0, user_visible=True) + assert c2.user_visible is True + + +def test_distance_constrains_radius_method(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + c = DistanceConstraint(p1, p2, 10.0) + + # Distance constraint doesn't constrain radius of an entity + assert c.constrains_radius(reg, 999) is False + assert c.constrains_radius(reg, p1) is False + + +def test_distance_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(5, 5) + + c = DistanceConstraint(p1, p2, 10.0) + + # Should match exact pair (order independent) + assert c.targets_segment(p1, p2, None) is True + assert c.targets_segment(p2, p1, None) is True + + # Should not match subset or disjoint + assert c.targets_segment(p1, p3, None) is False + assert c.targets_segment(p3, p2, None) is False + + # Entity ID is ignored by DistanceConstraint logic (it only tracks points) + assert c.targets_segment(p1, p2, 999) is True + + +def test_distance_constraint_with_expression(setup_env): + reg, params = setup_env + # Mimic context values + ctx = {"width": 20.0} + + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + c = DistanceConstraint(p1, p2, "width") + + # Sync value + c.update_from_context(ctx) + + # Actual dist is 10, Target dist is 20. Error is 10 - 20 = -10. + assert c.error(reg, params) == pytest.approx(-10.0) + + +def test_distance_constraint_gradient(setup_env): + """ + Uses scipy.optimize.check_grad to numerically verify the analytical + gradient of DistanceConstraint. + """ + reg, params = setup_env + p1_id = reg.add_point(1, 2) + p2_id = reg.add_point(5, 6) + mutable_pids = [p1_id, p2_id] + + constraint = DistanceConstraint(p1=p1_id, p2=p2_id, value=10.0) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([1, 2, 5, 6], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_distance_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + # Create original constraint + original = DistanceConstraint(p1, p2, 5.0) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = DistanceConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_distance_constraint_serialization_round_trip_with_expression( + setup_env, +): + reg, params = setup_env + ctx = {"width": 20.0} + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + # Create original constraint with expression + original = DistanceConstraint(p1, p2, "width") + original.update_from_context(ctx) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = DistanceConstraint.from_dict(serialized) + + # Must sync restored constraint too + restored.update_from_context(ctx) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + + +def test_distance_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(100, 0) + c = DistanceConstraint(p1, p2, 100) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # Hit on the line segment (at midpoint) + assert c.is_hit(50, 0, reg, to_screen, mock_element, threshold) is True + + # Hit on the line segment (not at midpoint) + assert c.is_hit(70, 0, reg, to_screen, mock_element, threshold) is True + + # Hit near the line segment (within threshold) + assert c.is_hit(50, 10, reg, to_screen, mock_element, threshold) is True + + # Miss - far from the line segment (beyond threshold) + assert c.is_hit(50, 20, reg, to_screen, mock_element, threshold) is False + + +def test_distance_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(100, 0) + c = DistanceConstraint(p1, p2, 100) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_distance_can_apply_to_two_points(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + + selection = SketchSelection() + selection.point_ids = [p1, p2] + selection.entity_ids = [] + assert DistanceConstraint.can_apply_to(selection, sketch) is True + + +def test_distance_can_apply_to_single_line(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id] + assert DistanceConstraint.can_apply_to(selection, sketch) is True + + +def test_distance_can_apply_to_multiple_lines(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line1_id, line2_id] + assert DistanceConstraint.can_apply_to(selection, sketch) is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_drag_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_drag_constraint.py new file mode 100644 index 000000000..d0ff4975d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_drag_constraint.py @@ -0,0 +1,126 @@ +from functools import partial +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core.constraints import DragConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_drag_constraint(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + + # Target is (100, 0). Current is (0, 0). + # err_x = (0 - 100) * 0.1 = -10.0 + # err_y = (0 - 0) * 0.1 = 0.0 + c = DragConstraint(p1, 100.0, 0.0, weight=0.1) + err_x, err_y = c.error(reg, params) + assert err_x == pytest.approx(-10.0) + assert err_y == pytest.approx(0.0) + assert c.user_visible is True + + +def test_drag_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + + c = DragConstraint(p1, 100.0, 0.0, weight=0.1, user_visible=False) + assert c.user_visible is False + + c2 = DragConstraint(p1, 100.0, 0.0, weight=0.1, user_visible=True) + assert c2.user_visible is True + + +def test_drag_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + + c = DragConstraint(p1, 100.0, 0.0) + + # Base implementation returns False + assert c.targets_segment(p1, p1, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_drag_constraint_gradient(setup_env): + reg, params = setup_env + p1_id = reg.add_point(1, 2) + mutable_pids = [p1_id] + + constraint = DragConstraint( + point_id=p1_id, target_x=10, target_y=10, weight=0.5 + ) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([1, 2], dtype=float) + for i in range(2): + func = partial(func_wrapper, error_index=i) + grad = partial(grad_wrapper, error_index=i) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_drag_constraint_serialization_round_trip(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + + # Create original constraint + original = DragConstraint(p1, 100.0, 0.0, weight=0.1) + + # Serialize to dict + serialized = original.to_dict() + + # DragConstraint returns empty dict from to_dict() as it's not meant to be + # serialized + assert serialized == {} + + +def test_drag_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + + c = DragConstraint(p1, 100.0, 0.0, weight=0.1) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_equal_distance_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_equal_distance_constraint.py new file mode 100644 index 000000000..d1d39cdc3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_equal_distance_constraint.py @@ -0,0 +1,151 @@ +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core.constraints import EqualDistanceConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_equal_distance_constraint(setup_env): + reg, params = setup_env + # Segment 1: Length 10 + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + # Segment 2: Length 4 + p3 = reg.add_point(0, 10) + p4 = reg.add_point(0, 14) + + c = EqualDistanceConstraint(p1, p2, p3, p4) + + # Error should be 10 - 4 = 6 + assert c.error(reg, params) == pytest.approx(6.0) + assert c.user_visible is True + + +def test_equal_distance_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(0, 14) + + c = EqualDistanceConstraint(p1, p2, p3, p4, user_visible=False) + assert c.user_visible is False + + c2 = EqualDistanceConstraint(p1, p2, p3, p4, user_visible=True) + assert c2.user_visible is True + + +def test_equal_distance_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(0, 14) + p5 = reg.add_point(5, 5) + + c = EqualDistanceConstraint(p1, p2, p3, p4) + + # Matches pair 1 (order independent) + assert c.targets_segment(p1, p2, None) is True + assert c.targets_segment(p2, p1, None) is True + + # Matches pair 2 (order independent) + assert c.targets_segment(p3, p4, None) is True + assert c.targets_segment(p4, p3, None) is True + + # No match + assert c.targets_segment(p1, p3, None) is False + assert c.targets_segment(p1, p5, None) is False + + +def test_equal_distance_constraint_gradient(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(15, 15) + p4 = reg.add_point(15, 22) + mutable_pids = [p1, p2, p3, p4] + + constraint = EqualDistanceConstraint(p1, p2, p3, p4) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 10, 0, 15, 15, 15, 22], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_equal_distance_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + # Segment 1: Length 10 + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + + # Segment 2: Length 4 + p3 = reg.add_point(0, 10) + p4 = reg.add_point(0, 14) + + # Create original constraint + original = EqualDistanceConstraint(p1, p2, p3, p4) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = EqualDistanceConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_equal_distance_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 10) + p4 = reg.add_point(0, 14) + + c = EqualDistanceConstraint(p1, p2, p3, p4) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_equal_length_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_equal_length_constraint.py new file mode 100644 index 000000000..17e8a9f55 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_equal_length_constraint.py @@ -0,0 +1,505 @@ +from functools import partial +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import EqualLengthConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_equal_length_constraint(setup_env): + """Tests error calculation for EqualLengthConstraint.""" + reg, params = setup_env + # Line 1: length 10 + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + # Line 2: length 5 + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + l2 = reg.add_line(p3, p4) + + # Arc 1: radius 4 + c1_p = reg.add_point(20, 0) + s1 = reg.add_point(24, 0) + e1 = reg.add_point(20, 4) + a1 = reg.add_arc(s1, e1, c1_p) + + # Circle 1: radius 3 + c2_p = reg.add_point(30, 0) + r2 = reg.add_point(33, 0) + circ1 = reg.add_circle(c2_p, r2) + + # Test Line-Line (error is [len2 - len1]) + c = EqualLengthConstraint([l1, l2]) + assert c.error(reg, params) == pytest.approx([5.0 - 10.0]) + assert c.user_visible is True + + # Test Line-Arc (error is [rad1 - len1]) + c2 = EqualLengthConstraint([l1, a1]) + assert c2.error(reg, params) == pytest.approx([4.0 - 10.0]) + + # Test Arc-Circle (error is [rad_circ - rad_arc]) + c3 = EqualLengthConstraint([a1, circ1]) + assert c3.error(reg, params) == pytest.approx([3.0 - 4.0]) + + # Test multi-entity constraint + c_multi = EqualLengthConstraint([l1, l2, a1, circ1]) + # Errors are [len2-len1, rad_arc-len1, rad_circ-len1] + assert c_multi.error(reg, params) == pytest.approx( + [5.0 - 10.0, 4.0 - 10.0, 3.0 - 10.0] + ) + + # Test edge cases (no error for < 2 entities) + assert EqualLengthConstraint([]).error(reg, params) == [] + assert EqualLengthConstraint([l1]).error(reg, params) == [] + + +def test_equal_length_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + l2 = reg.add_line(p3, p4) + + c = EqualLengthConstraint([l1, l2], user_visible=False) + assert c.user_visible is False + + c2 = EqualLengthConstraint([l1, l2], user_visible=True) + assert c2.user_visible is True + + +def test_equal_length_constrains_radius_method(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + l2 = reg.add_line(p3, p4) + + # Entity 999 (not in list) + + c = EqualLengthConstraint([l1, l2]) + + # Should return True for entities in list + assert c.constrains_radius(reg, l1) is True + assert c.constrains_radius(reg, l2) is True + + # Should return False for entities not in list + assert c.constrains_radius(reg, 999) is False + + +def test_equal_length_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + l2 = reg.add_line(p3, p4) + + l3 = reg.add_line(p1, p3) + + c = EqualLengthConstraint([l1, l2]) + + # Should match entities in the list + assert c.targets_segment(0, 0, l1) is True + assert c.targets_segment(0, 0, l2) is True + + # Should not match entities not in the list or None + assert c.targets_segment(0, 0, l3) is False + assert c.targets_segment(0, 0, None) is False + + +def test_equal_length_constraint_gradient(setup_env): + reg, params = setup_env + p0 = reg.add_point(0, 0) + p1 = reg.add_point(10, 0) + p2 = reg.add_point(15, 15) + p3 = reg.add_point(15, 22) + l1 = reg.add_line(p0, p1) + l2 = reg.add_line(p2, p3) + mutable_pids = [p0, p1, p2, p3] + + constraint = EqualLengthConstraint(entity_ids=[l1, l2]) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 10, 0, 15, 15, 15, 22], dtype=float) + func = partial(func_wrapper, error_index=0) + grad = partial(grad_wrapper, error_index=0) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_equal_length_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + # Line 1: length 10 + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + # Line 2: length 5 + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + l2 = reg.add_line(p3, p4) + + # Create original constraint + original = EqualLengthConstraint([l1, l2]) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = EqualLengthConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_equal_length_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(100, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 50) + p4 = reg.add_point(0, 150) + l2 = reg.add_line(p3, p4) + + c = EqualLengthConstraint([l1, l2]) + + # Mock canvas and element + mock_canvas = MagicMock() + mock_canvas.get_view_scale.return_value = (1.0, 1.0) + mock_element = SimpleNamespace(canvas=mock_canvas) + + def to_screen(pos): + return pos + + threshold = 15.0 + + # Symbol pos for line 1: midpoint (50, 0), normal is (0, -1), offset 15 + # -> (50, -15) + l1_symbol_x, l1_symbol_y = 50, -15 + # Symbol pos for line 2: midpoint (0, 100), tangent angle pi/2, normal + # angle 0 -> (15, 100) + l2_symbol_x, l2_symbol_y = 15, 100 + + # Hit line 1 + assert ( + c.is_hit( + l1_symbol_x, l1_symbol_y, reg, to_screen, mock_element, threshold + ) + is True + ) + # Hit line 2 + assert ( + c.is_hit( + l2_symbol_x, l2_symbol_y, reg, to_screen, mock_element, threshold + ) + is True + ) + # Miss both + assert c.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_equal_length_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(0, 10) + p4 = reg.add_point(5, 10) + l2 = reg.add_line(p3, p4) + + c = EqualLengthConstraint([l1, l2]) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_equal_length_can_apply_to_two_lines(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p3, p4) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line1_id, line2_id] + assert EqualLengthConstraint.can_apply_to(selection, sketch) is True + + +def test_equal_length_can_apply_to_line_and_circle(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + center = sketch.add_point(50, 0) + radius = sketch.add_point(60, 0) + circle_id = sketch.add_circle(center, radius) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id, circle_id] + assert EqualLengthConstraint.can_apply_to(selection, sketch) is True + + +def test_equal_length_error_two_ellipses(setup_env): + reg, params = setup_env + c1 = reg.add_point(0, 0) + rx1 = reg.add_point(5, 0) + ry1 = reg.add_point(0, 3) + e1 = reg.add_ellipse(c1, rx1, ry1) + + c2 = reg.add_point(20, 0) + rx2 = reg.add_point(28, 0) + ry2 = reg.add_point(20, 5) + e2 = reg.add_ellipse(c2, rx2, ry2) + + constr = EqualLengthConstraint([e1, e2]) + err = constr.error(reg, params) + assert len(err) == 2 + assert err[0] == pytest.approx(8.0 - 5.0) + assert err[1] == pytest.approx(5.0 - 3.0) + + +def test_equal_length_error_three_ellipses(setup_env): + reg, params = setup_env + c1 = reg.add_point(0, 0) + rx1 = reg.add_point(10, 0) + ry1 = reg.add_point(0, 4) + e1 = reg.add_ellipse(c1, rx1, ry1) + + c2 = reg.add_point(30, 0) + rx2 = reg.add_point(37, 0) + ry2 = reg.add_point(30, 9) + e2 = reg.add_ellipse(c2, rx2, ry2) + + c3 = reg.add_point(60, 0) + rx3 = reg.add_point(65, 0) + ry3 = reg.add_point(60, 2) + e3 = reg.add_ellipse(c3, rx3, ry3) + + constr = EqualLengthConstraint([e1, e2, e3]) + err = constr.error(reg, params) + assert len(err) == 4 + assert err[0] == pytest.approx(7.0 - 10.0) + assert err[1] == pytest.approx(9.0 - 4.0) + assert err[2] == pytest.approx(5.0 - 10.0) + assert err[3] == pytest.approx(2.0 - 4.0) + + +def test_equal_length_error_ellipse_and_circle(setup_env): + reg, params = setup_env + cc = reg.add_point(0, 0) + cr = reg.add_point(6, 0) + circ = reg.add_circle(cc, cr) + + ec = reg.add_point(20, 0) + erx = reg.add_point(25, 0) + ery = reg.add_point(20, 3) + ell = reg.add_ellipse(ec, erx, ery) + + constr = EqualLengthConstraint([circ, ell]) + err = constr.error(reg, params) + assert len(err) == 2 + assert err[0] == pytest.approx(5.0 - 6.0) + assert err[1] == pytest.approx(3.0 - 6.0) + + +def test_equal_length_gradient_two_ellipses(setup_env): + reg, params = setup_env + c1 = reg.add_point(0, 0) + rx1 = reg.add_point(5, 0) + ry1 = reg.add_point(0, 3) + e1 = reg.add_ellipse(c1, rx1, ry1) + + c2 = reg.add_point(20, 0) + rx2 = reg.add_point(28, 0) + ry2 = reg.add_point(20, 5) + e2 = reg.add_ellipse(c2, rx2, ry2) + + mutable_pids = [c1, rx1, ry1, c2, rx2, ry2] + + constraint = EqualLengthConstraint(entity_ids=[e1, e2]) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 5, 0, 0, 3, 20, 0, 28, 0, 20, 5], dtype=float) + + for error_idx in range(2): + func = partial(func_wrapper, error_index=error_idx) + grad = partial(grad_wrapper, error_index=error_idx) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5, ( + f"Gradient check failed for error index {error_idx}: {diff}" + ) + + +def test_equal_length_gradient_ellipse_circle(setup_env): + reg, params = setup_env + cc = reg.add_point(0, 0) + cr = reg.add_point(6, 0) + circ = reg.add_circle(cc, cr) + + ec = reg.add_point(20, 0) + erx = reg.add_point(25, 0) + ery = reg.add_point(20, 3) + ell = reg.add_ellipse(ec, erx, ery) + + mutable_pids = [cc, cr, ec, erx, ery] + + constraint = EqualLengthConstraint(entity_ids=[circ, ell]) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 6, 0, 20, 0, 25, 0, 20, 3], dtype=float) + + for error_idx in range(2): + func = partial(func_wrapper, error_index=error_idx) + grad = partial(grad_wrapper, error_index=error_idx) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5, ( + f"Gradient check failed for error index {error_idx}: {diff}" + ) + + +def test_equal_length_can_apply_to_two_ellipses(): + sketch = Sketch() + c1 = sketch.add_point(0, 0) + rx1 = sketch.add_point(5, 0) + ry1 = sketch.add_point(0, 3) + e1 = sketch.registry.add_ellipse(c1, rx1, ry1) + + c2 = sketch.add_point(20, 0) + rx2 = sketch.add_point(25, 0) + ry2 = sketch.add_point(20, 3) + e2 = sketch.registry.add_ellipse(c2, rx2, ry2) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [e1, e2] + assert EqualLengthConstraint.can_apply_to(selection, sketch) is True + + +def test_equal_length_can_apply_to_ellipse_and_circle(): + sketch = Sketch() + cc = sketch.add_point(0, 0) + cr = sketch.add_point(5, 0) + circ = sketch.add_circle(cc, cr) + + ec = sketch.add_point(20, 0) + erx = sketch.add_point(25, 0) + ery = sketch.add_point(20, 3) + ell = sketch.registry.add_ellipse(ec, erx, ery) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [circ, ell] + assert EqualLengthConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_horizontal_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_horizontal_constraint.py new file mode 100644 index 000000000..07ea0aaa6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_horizontal_constraint.py @@ -0,0 +1,175 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import HorizontalConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_horizontal_constraint(setup_env): + reg, params = setup_env + # p1 y=0, p2 y=5. Error should be 0 - 5 = -5 + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 5) + + c = HorizontalConstraint(p1, p2) + assert c.error(reg, params) == pytest.approx(-5.0) + assert c.user_visible is True + + +def test_horizontal_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 5) + + c = HorizontalConstraint(p1, p2, user_visible=False) + assert c.user_visible is False + + c2 = HorizontalConstraint(p1, p2, user_visible=True) + assert c2.user_visible is True + + +def test_horizontal_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 5) + + c = HorizontalConstraint(p1, p2) + + # Base implementation returns False (Horizontal is topological) + assert c.targets_segment(p1, p2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_horizontal_constraint_gradient(setup_env): + reg, params = setup_env + p1_id = reg.add_point(1, 2) + p2_id = reg.add_point(5, 6) + mutable_pids = [p1_id, p2_id] + + constraint = HorizontalConstraint(p1=p1_id, p2=p2_id) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([1, 2, 5, 6], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_horizontal_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 5) + + # Create original constraint + original = HorizontalConstraint(p1, p2) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = HorizontalConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_horizontal_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 50) + p2 = reg.add_point(100, 50) + c = HorizontalConstraint(p1, p2) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # Symbol is at t=0.2 along line, offset by -10 in Y + # Point is at x=20, y=50. Symbol at x=20, y=40. + symbol_x, symbol_y = 20, 40 + + # Hit + assert ( + c.is_hit(symbol_x, symbol_y, reg, to_screen, mock_element, threshold) + is True + ) + # Miss + assert c.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_horizontal_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 5) + + c = HorizontalConstraint(p1, p2) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_horizontal_can_apply_to_two_points(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + + selection = SketchSelection() + selection.point_ids = [p1, p2] + selection.entity_ids = [] + assert HorizontalConstraint.can_apply_to(selection, sketch) is True + + +def test_horizontal_can_apply_to_single_line(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 10) + line_id = sketch.add_line(p1, p2) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id] + assert HorizontalConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_parallelogram_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_parallelogram_constraint.py new file mode 100644 index 000000000..9d80ca07d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_parallelogram_constraint.py @@ -0,0 +1,223 @@ +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core.constraints import ParallelogramConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_parallelogram_constraint_error(setup_env): + reg, params = setup_env + # Create a perfect parallelogram: (0,0), (10,0), (0,5), (10,5) + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + c = ParallelogramConstraint(p_origin, p_width, p_height, p4) + # Vector (p_width-p_origin) = (10, 0) + # Vector (p4-p_height) = (10, 0) + # Error = (10-10, 0-0) = (0, 0) + assert c.error(reg, params) == pytest.approx((0.0, 0.0)) + + +def test_parallelogram_constraint_error_non_parallelogram(setup_env): + reg, params = setup_env + # Create a non-parallelogram: (0,0), (10,0), (0,5), (12,6) + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(12, 6) + + c = ParallelogramConstraint(p_origin, p_width, p_height, p4) + # Vector (p_width-p_origin) = (10, 0) + # Vector (p4-p_height) = (12, 1) + # Error = (10-12, 0-1) = (-2, -1) + assert c.error(reg, params) == pytest.approx((-2.0, -1.0)) + + +def test_parallelogram_constraint_gradient(setup_env): + reg, params = setup_env + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(12, 6) + + constraint = ParallelogramConstraint(p_origin, p_width, p_height, p4) + mutable_pids = [p_origin, p_width, p_height, p4] + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + error = constraint.error(reg, params) + # Return sum of squared errors for gradient checking + return error[0] ** 2 + error[1] ** 2 + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + error = constraint.error(reg, params) + # grads[0] is (dE_x/dx, dE_x/dy) + # grads[1] is (dE_y/dx, dE_y/dy) + # Chain rule: d(E_x^2+E_y^2)/dx = 2*E_x*dE_x/dx + 2*E_y*dE_y/dx + grad_vec[idx] = ( + 2 * error[0] * grads[0][0] + 2 * error[1] * grads[1][0] + ) + grad_vec[idx + 1] = ( + 2 * error[0] * grads[0][1] + 2 * error[1] * grads[1][1] + ) + return grad_vec + + x0 = np.array([0, 0, 10, 0, 0, 5, 12, 6], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_parallelogram_constraint_gradient_direct(setup_env): + reg, params = setup_env + p_origin = reg.add_point(1, 2) + p_width = reg.add_point(5, 3) + p_height = reg.add_point(2, 6) + p4 = reg.add_point(7, 7) + + constraint = ParallelogramConstraint(p_origin, p_width, p_height, p4) + + grad = constraint.gradient(reg, params) + + # Check that all four points are in the gradient + assert p_origin in grad + assert p_width in grad + assert p_height in grad + assert p4 in grad + + # Check gradient values + # Error = (v1_x - v2_x, v1_y - v2_y) + # where v1 = p_width - p_origin, v2 = p4 - p_height + # For E_x: dE_x/d(p_origin.x) = -1, dE_x/d(p_origin.y) = 0 + # dE_x/d(p_width.x) = 1, dE_x/d(p_width.y) = 0 + # dE_x/d(p_height.x) = 1, dE_x/d(p_height.y) = 0 + # dE_x/d(p4.x) = -1, dE_x/d(p4.y) = 0 + # For E_y: dE_y/d(p_origin.x) = 0, dE_y/d(p_origin.y) = -1 + # dE_y/d(p_width.x) = 0, dE_y/d(p_width.y) = 1 + # dE_y/d(p_height.x) = 0, dE_y/d(p_height.y) = 1 + # dE_y/d(p4.x) = 0, dE_y/d(p4.y) = -1 + + assert grad[p_origin] == [(-1.0, 0.0), (0.0, -1.0)] + assert grad[p_width] == [(1.0, 0.0), (0.0, 1.0)] + assert grad[p_height] == [(1.0, 0.0), (0.0, 1.0)] + assert grad[p4] == [(-1.0, 0.0), (0.0, -1.0)] + + +def test_parallelogram_constraint_user_visible_false(setup_env): + reg, _params = setup_env + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + c = ParallelogramConstraint(p_origin, p_width, p_height, p4) + assert c.user_visible is False + + +def test_parallelogram_constraint_user_visible_true(setup_env): + reg, _params = setup_env + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + c = ParallelogramConstraint( + p_origin, p_width, p_height, p4, user_visible=True + ) + assert c.user_visible is True + + +def test_parallelogram_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + c = ParallelogramConstraint(p1, p2, p3, p4) + + # Default implementation returns False + assert c.targets_segment(p1, p2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_parallelogram_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + original = ParallelogramConstraint(p_origin, p_width, p_height, p4) + + serialized = original.to_dict() + + restored = ParallelogramConstraint.from_dict(serialized) + + assert original.user_visible == restored.user_visible + assert original.p_origin == restored.p_origin + assert original.p_width == restored.p_width + assert original.p_height == restored.p_height + assert original.p4 == restored.p4 + assert original.error(reg, params) == restored.error(reg, params) + + +def test_parallelogram_constraint_serialization_includes_user_visible( + setup_env, +): + reg, _params = setup_env + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + c = ParallelogramConstraint(p_origin, p_width, p_height, p4) + serialized = c.to_dict() + + assert "user_visible" in serialized + assert serialized["user_visible"] is False + + +def test_parallelogram_draw(setup_env): + reg, _params = setup_env + p_origin = reg.add_point(0, 0) + p_width = reg.add_point(10, 0) + p_height = reg.add_point(0, 5) + p4 = reg.add_point(10, 5) + + c = ParallelogramConstraint(p_origin, p_width, p_height, p4) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_perpendicular_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_perpendicular_constraint.py new file mode 100644 index 000000000..224c73a20 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_perpendicular_constraint.py @@ -0,0 +1,371 @@ +import math +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import PerpendicularConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_perpendicular_constraint_line_line(setup_env): + reg, params = setup_env + + # Line 1: Horizontal (0,0) -> (10,0) + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + # Line 2: Vertical (5,5) -> (5,15). Vector is (0, 10) + p3 = reg.add_point(5, 5) + p4 = reg.add_point(5, 15) + l2 = reg.add_line(p3, p4) + + c = PerpendicularConstraint(l1, l2) + # Dot product: (10, 0) . (0, 10) = 0 + assert c.error(reg, params) == pytest.approx(0.0) + assert c.user_visible is True + + # Make Line 2 NOT perpendicular (Slope 1) + # Move p4 to (15, 15). Vector (10, 10) + reg.get_point(p4).x = 15 + + # Dot product: (10, 0) . (10, 10) = 100 + assert c.error(reg, params) == pytest.approx(100.0) + + +def test_perpendicular_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(5, 5) + p4 = reg.add_point(5, 15) + l2 = reg.add_line(p3, p4) + + c = PerpendicularConstraint(l1, l2, user_visible=False) + assert c.user_visible is False + + c2 = PerpendicularConstraint(l1, l2, user_visible=True) + assert c2.user_visible is True + + +def test_perpendicular_constraint_extended(setup_env): + """Tests perpendicular constraint for Line-Circle and Circle-Circle.""" + reg, params = setup_env + + # --- Line-Circle Test --- + # Circle at (10,10), radius 5 + c1_p = reg.add_point(10, 10) + c1_r = reg.add_point(15, 10) + circ1 = reg.add_circle(c1_p, c1_r) + + # Line passing through center (0,10) -> (20,10) + l1_p1 = reg.add_point(0, 10) + l1_p2 = reg.add_point(20, 10) + line1 = reg.add_line(l1_p1, l1_p2) + + # Error is cross product of (L2-L1) and (C-L1) + # (20,0) x (10-0, 10-10) = 20*0 - 10*0 = 0 + lc_constraint = PerpendicularConstraint(line1, circ1) + assert lc_constraint.error(reg, params) == pytest.approx(0.0) + + # Move line so it doesn't pass through center + reg.get_point(l1_p1).y = 0 + reg.get_point(l1_p2).y = 0 + # Line is now (0,0)->(20,0). Center is (10,10). + # Vector L2-L1: (20, 0) + # Vector C-L1: (10, 10) + # Cross product: (20 * 10) - (10 * 0) = 200 + assert lc_constraint.error(reg, params) == pytest.approx(200.0) + + # --- Circle-Circle Test --- + # C1 at (0,0), radius 3 (r^2=9) + c2_p = reg.add_point(0, 0) + c2_r = reg.add_point(3, 0) + circ2 = reg.add_circle(c2_p, c2_r) + + # C2 at (5,0), radius 4 (r^2=16), distance between centers = 5 (d^2=25) + c3_p = reg.add_point(5, 0) + c3_r = reg.add_point(9, 0) # 5+4 + circ3 = reg.add_circle(c3_p, c3_r) + + # Error is r1^2 + r2^2 - d^2 = 9 + 16 - 25 = 0 + cc_constraint = PerpendicularConstraint(circ2, circ3) + assert cc_constraint.error(reg, params) == pytest.approx(0.0) + + # Move C2 center to (6,0), d^2 = 36 + reg.get_point(c3_p).x = 6.0 + # Radius of circ3 changes! New radius is (9-6)=3, so r2^2=9. + # Error = r1^2 + new_r2^2 - d^2 = 9 + 9 - 36 = -18 + assert cc_constraint.error(reg, params) == pytest.approx(-18.0) + + +def test_perpendicular_constraint_invalid_type(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + l1 = reg.add_line(p1, p1) # Dummy line + + # Pass Point ID instead of Line ID + c = PerpendicularConstraint(l1, p1) + assert c.error(reg, params) == 0.0 + + +def test_perpendicular_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + p3 = reg.add_point(5, 5) + p4 = reg.add_point(5, 15) + l2 = reg.add_line(p3, p4) + + c = PerpendicularConstraint(l1, l2) + + # Default implementation returns False + assert c.targets_segment(p1, p2, l1) is False + assert c.targets_segment(0, 0, l1) is False + + +def test_perpendicular_constraint_gradient(setup_env): + reg, params = setup_env + p0 = reg.add_point(0, 0) + p1 = reg.add_point(10, 2) + p2 = reg.add_point(5, 10) + p3 = reg.add_point(3, -5) + l1 = reg.add_line(p0, p1) + l2 = reg.add_line(p2, p3) + mutable_pids = [p0, p1, p2, p3] + + constraint = PerpendicularConstraint(e1_id=l1, e2_id=l2) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 10, 2, 5, 10, 3, -5], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_perpendicular_gradient_with_shared_points(setup_env): + reg, params = setup_env + p0 = reg.add_point(0, 0) # p0: C1 center AND C2 radius point + p1 = reg.add_point(3, 0) # p1: C1 radius point + p2 = reg.add_point(5, 0) # p2: C2 center + c1 = reg.add_circle(p0, p1) + c2 = reg.add_circle(p2, p0) + mutable_pids = [p0, p1, p2] + + constraint = PerpendicularConstraint(e1_id=c1, e2_id=c2) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 0, 3, 0, 5, 0], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_perpendicular_constraint_line_circle_gradient(setup_env): + reg, params = setup_env + l1p1 = reg.add_point(0, 5) + l1p2 = reg.add_point(20, 5) + c1p = reg.add_point(10, 10) + c1r = reg.add_point(15, 10) + line1 = reg.add_line(l1p1, l1p2) + circ1 = reg.add_circle(c1p, c1r) + mutable_pids = [l1p1, l1p2, c1p] + + constraint = PerpendicularConstraint(line1, circ1) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 5, 20, 5, 10, 10], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_perpendicular_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + + # Line 1: Horizontal (0,0) -> (10,0) + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + l1 = reg.add_line(p1, p2) + + # Line 2: Vertical (5,5) -> (5,15) + p3 = reg.add_point(5, 5) + p4 = reg.add_point(5, 15) + l2 = reg.add_line(p3, p4) + + # Create original constraint + original = PerpendicularConstraint(l1, l2) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = PerpendicularConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_perpendicular_is_hit(setup_env): + reg, _params = setup_env + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # --- Test Line-Line case --- + l1p1 = reg.add_point(0, 50) + l1p2 = reg.add_point(100, 50) + l1 = reg.add_line(l1p1, l1p2) + + l2p1 = reg.add_point(50, 0) + l2p2 = reg.add_point(50, 100) + l2 = reg.add_line(l2p1, l2p2) + + c_ll = PerpendicularConstraint(l1, l2) + + # Visuals are centered at intersection (50, 50) + # The dot is placed at mid-angle with radius 0.6 * 16 = 9.6 + # ang1 ~ 0 (from (100,50)), ang2 ~ PI/2 (from (50,100)) + # mid-angle is ~PI/4. Dot pos ~ (50+9.6*cos(PI/4), 50+9.6*sin(PI/4)) + # ~ (50+6.8, 50+6.8) = (56.8, 56.8) + hit_x, hit_y = ( + 50 + 9.6 * math.cos(math.pi / 4), + 50 + 9.6 * math.sin(math.pi / 4), + ) + assert ( + c_ll.is_hit(hit_x, hit_y, reg, to_screen, mock_element, threshold) + is True + ) + assert c_ll.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + # --- Test Line-Circle case --- + c1p = reg.add_point(150, 50) + c1r = reg.add_point(160, 50) + circ1 = reg.add_circle(c1p, c1r) + c_lc = PerpendicularConstraint(l1, circ1) + + # Visuals are a box at the intersection of line and circle. + # No intersection, so it defaults to the center of the circle (150, 50). + assert ( + c_lc.is_hit(150, 50, reg, to_screen, mock_element, threshold) is True + ) + assert c_lc.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_perpendicular_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(5, 5) + p4 = reg.add_point(5, 15) + l1 = reg.add_line(p1, p2) + l2 = reg.add_line(p3, p4) + + c = PerpendicularConstraint(l1, l2) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_perpendicular_can_apply_to_two_lines(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(0, 10) + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p1, p3) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line1_id, line2_id] + assert PerpendicularConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_point_on_line_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_point_on_line_constraint.py new file mode 100644 index 000000000..d444920b0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_point_on_line_constraint.py @@ -0,0 +1,317 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import PointOnLineConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_point_on_line_constraint(setup_env): + reg, params = setup_env + + # Line along X-axis: (0,0) -> (10,0) + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + line_id = reg.add_line(p1, p2) + + # Point at (5, 5). Distance is 5. + # Error is signed distance: cross product / length + # cross = (10-0)*(5-0) - (5-0)*(0-0) = 50 + # length = 10. Error = 50/10 = 5. + p3 = reg.add_point(5, 5) + + c = PointOnLineConstraint(p3, line_id) + assert c.error(reg, params) == pytest.approx(5.0) + assert c.user_visible is True + + # Move point to (5, 0). Error should be 0. + reg.get_point(p3).y = 0.0 + assert c.error(reg, params) == pytest.approx(0.0) + + +def test_point_on_line_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + line_id = reg.add_line(p1, p2) + + p3 = reg.add_point(5, 5) + + c = PointOnLineConstraint(p3, line_id, user_visible=False) + assert c.user_visible is False + + c2 = PointOnLineConstraint(p3, line_id, user_visible=True) + assert c2.user_visible is True + + +def test_point_on_arc_circle_constraint(setup_env): + """Tests PointOnLine constraint for Arc and Circle types.""" + reg, params = setup_env + + # Circle at (0,0) with radius 10 + center = reg.add_point(0, 0) + radius_pt = reg.add_point(10, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Point on the circle circumference + pt_on = reg.add_point(0, 10) + c1 = PointOnLineConstraint(pt_on, circ_id) + # Error: dist(pt, center) - radius = 10 - 10 = 0 + assert c1.error(reg, params) == pytest.approx(0.0) + + # Point outside the circle + pt_off = reg.add_point(0, 12) + c2 = PointOnLineConstraint(pt_off, circ_id) + # Error: dist(pt, center) - radius = 12 - 10 = 2 + assert c2.error(reg, params) == pytest.approx(2.0) + + +def test_point_on_line_constrains_radius(setup_env): + """ + Test that PointOnLineConstraint correctly reports constraining the radius + when the point on the circle is itself constrained. + """ + reg, _params = setup_env + + # Setup Circle + c = reg.add_point(0, 0, fixed=True) + r_pt = reg.add_point(10, 0, fixed=False) # Not fixed initially + circ_id = reg.add_circle(c, r_pt) + + # Setup Constrained Point on Circle + p_constrained = reg.add_point(0, 10, fixed=False) + reg.get_point(p_constrained).constrained = True # Simulate solver result + + # Setup Unconstrained Point on Circle + p_unconstrained = reg.add_point(0, -10, fixed=False) + reg.get_point(p_unconstrained).constrained = False + + # Constraint 1: Constrained Point -> Circle + c1 = PointOnLineConstraint(p_constrained, circ_id) + assert c1.constrains_radius(reg, circ_id) is True + + # Constraint 2: Unconstrained Point -> Circle + c2 = PointOnLineConstraint(p_unconstrained, circ_id) + assert c2.constrains_radius(reg, circ_id) is False + + # Constraint 3: Wrong Entity ID + assert c1.constrains_radius(reg, 999) is False + + +def test_point_on_line_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + line_id = reg.add_line(p1, p2) + p3 = reg.add_point(5, 5) + + c = PointOnLineConstraint(p3, line_id) + + # Base implementation returns False + assert c.targets_segment(p1, p2, line_id) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_point_on_line_invalid_entity(setup_env): + """Ensure it doesn't crash if passed a non-line ID.""" + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + # Pass a Point ID instead of a shape ID + c = PointOnLineConstraint(p2, p1) + assert c.error(reg, params) == 0.0 + + +def test_point_on_line_zero_length(setup_env): + """Test denominator protection for PointOnLine with a zero-length line.""" + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(0, 0) + line_id = reg.add_line(p1, p2) + p3 = reg.add_point(5, 5) + c_pol = PointOnLineConstraint(p3, line_id) + expected_dist = (5**2 + 5**2) ** 0.5 + assert c_pol.error(reg, params) == pytest.approx(expected_dist) + + +def test_point_on_line_gradient(setup_env): + reg, params = setup_env + pt_id = reg.add_point(5, 6) + l1_id = reg.add_point(0, 0) + l2_id = reg.add_point(10, 2) + line_id = reg.add_line(l1_id, l2_id) + mutable_pids = [pt_id, l1_id, l2_id] + + constraint = PointOnLineConstraint(point_id=pt_id, shape_id=line_id) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([5, 6, 0, 0, 10, 2], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_point_on_circle_gradient(setup_env): + reg, params = setup_env + pt_id = reg.add_point(10, 12) + c_id = reg.add_point(5, 5) + r_id = reg.add_point(15, 5) + circle_id = reg.add_circle(c_id, r_id) + mutable_pids = [pt_id, c_id, r_id] + + constraint = PointOnLineConstraint(point_id=pt_id, shape_id=circle_id) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([10, 12, 5, 5, 15, 5], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_point_on_line_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + + # Line along X-axis: (0,0) -> (10,0) + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + line_id = reg.add_line(p1, p2) + + # Point at (5, 5) + p3 = reg.add_point(5, 5) + + # Create original constraint + original = PointOnLineConstraint(p3, line_id) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = PointOnLineConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_point_on_line_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(10, 20) + l1 = reg.add_point(0, 0) + l2 = reg.add_point(100, 100) + line = reg.add_line(l1, l2) + c = PointOnLineConstraint(p1, line) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # Hit the point + assert c.is_hit(10, 20, reg, to_screen, mock_element, threshold) is True + # Miss the point + assert c.is_hit(30, 20, reg, to_screen, mock_element, threshold) is False + + +def test_point_on_line_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(5, 5) + line_id = reg.add_line(p1, p2) + + c = PointOnLineConstraint(p3, line_id) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_point_on_line_can_apply_to_valid(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + external_point = sketch.add_point(5, 5) + + selection = SketchSelection() + selection.point_ids = [external_point] + selection.entity_ids = [line_id] + assert PointOnLineConstraint.can_apply_to(selection, sketch) is True + + +def test_point_on_line_can_apply_to_endpoint_invalid(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + selection = SketchSelection() + selection.point_ids = [p1] + selection.entity_ids = [line_id] + assert PointOnLineConstraint.can_apply_to(selection, sketch) is False + + selection.point_ids = [p2] + assert PointOnLineConstraint.can_apply_to(selection, sketch) is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_radius_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_radius_constraint.py new file mode 100644 index 000000000..535b8a3df --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_radius_constraint.py @@ -0,0 +1,263 @@ +import math +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import RadiusConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_radius_constraint(setup_env): + reg, params = setup_env + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + + # Current radius is 10. Target is 10. Error = 10 - 10 = 0 + c = RadiusConstraint(arc_id, 10.0) + assert c.error(reg, params) == pytest.approx(0.0) + assert c.user_visible is True + + # Target is 5. Error = 10 - 5 = 5 + c2 = RadiusConstraint(arc_id, 5.0) + assert c2.error(reg, params) == pytest.approx(5.0) + + +def test_radius_constraint_user_visible(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(10, 0) + circ_id = reg.add_circle(center, radius_pt) + + c = RadiusConstraint(circ_id, 10.0, user_visible=False) + assert c.user_visible is False + + c2 = RadiusConstraint(circ_id, 10.0, user_visible=True) + assert c2.user_visible is True + + +def test_radius_constraint_on_circle(setup_env): + reg, params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(10, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Current radius is 10. Target is 10. Error = 10 - 10 = 0 + c = RadiusConstraint(circ_id, 10.0) + assert c.error(reg, params) == pytest.approx(0.0) + + # Target is 5. Error = 10 - 5 = 5 + c2 = RadiusConstraint(circ_id, 5.0) + assert c2.error(reg, params) == pytest.approx(5.0) + + +def test_radius_constrains_radius_method(setup_env): + reg, _params = setup_env + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + other_id = reg.add_arc(start, end, center) + + c = RadiusConstraint(arc_id, 10.0) + + # Should return True for the constrained entity + assert c.constrains_radius(reg, arc_id) is True + # Should return False for others + assert c.constrains_radius(reg, other_id) is False + assert c.constrains_radius(reg, 999) is False + + +def test_radius_targets_segment(setup_env): + reg, _params = setup_env + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + other_id = reg.add_arc(start, end, center) + + c = RadiusConstraint(arc_id, 10.0) + + # Should match based on entity ID + assert c.targets_segment(0, 0, arc_id) is True + + # Should not match others + assert c.targets_segment(0, 0, other_id) is False + assert c.targets_segment(0, 0, None) is False + + +def test_radius_constraint_invalid_entity(setup_env): + reg, params = setup_env + # Add a line, try to constrain radius (should fail gracefully/return 0) + p1 = reg.add_point(0, 0) + p2 = reg.add_point(10, 10) + line_id = reg.add_line(p1, p2) + + c = RadiusConstraint(line_id, 5.0) + assert c.error(reg, params) == 0.0 + + +def test_radius_constraint_gradient(setup_env): + reg, params = setup_env + c_id = reg.add_point(5, 5) + s_id = reg.add_point(15, 5) + e_id = reg.add_point(5, 15) + arc_id = reg.add_arc(s_id, e_id, c_id) + mutable_pids = [c_id, s_id] + + constraint = RadiusConstraint(entity_id=arc_id, value=12.0) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([5, 5, 15, 5], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_radius_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(10, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Create original constraint + original = RadiusConstraint(circ_id, 10.0) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = RadiusConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_radius_constraint_with_expression(setup_env): + reg, params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + ctx = {"r_val": 10.0} + c = RadiusConstraint(circ_id, "r_val") + c.update_from_context(ctx) + + # Current r=5, target=10. Error = 5 - 10 = -5 + assert c.error(reg, params) == pytest.approx(-5.0) + + +def test_radius_is_hit(setup_env): + reg, _params = setup_env + start = reg.add_point(100, 0) + end = reg.add_point(0, 100) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + + c = RadiusConstraint(arc_id, 100) + + # Mock the canvas and element + mock_canvas = MagicMock() + mock_canvas.get_view_scale.return_value = (1.0, 1.0) + mock_element = SimpleNamespace(canvas=mock_canvas) + + def to_screen(pos): + return pos + + threshold = 15.0 + + # Label pos for arc is at (radius + 20) along midpoint vector + # Mid-angle is 45deg. + # pos = (120 * cos(45), 120 * sin(45)) ~ (84.85, 84.85) + dist = 120 + angle = math.pi / 4 + label_pos_x, label_pos_y = dist * math.cos(angle), dist * math.sin(angle) + + # Hit + assert ( + c.is_hit( + label_pos_x, label_pos_y, reg, to_screen, mock_element, threshold + ) + is True + ) + # Miss + assert c.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_radius_draw(setup_env): + reg, _params = setup_env + center = reg.add_point(0, 0) + radius_pt = reg.add_point(5, 0) + circ_id = reg.add_circle(center, radius_pt) + + c = RadiusConstraint(circ_id, 10.0) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_radius_can_apply_to_arc(): + sketch = Sketch() + start = sketch.add_point(10, 0) + end = sketch.add_point(0, 10) + center = sketch.add_point(0, 0) + arc_id = sketch.add_arc(start, end, center) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [arc_id] + assert RadiusConstraint.can_apply_to(selection, sketch) is True + + +def test_radius_can_apply_to_circle(): + sketch = Sketch() + center = sketch.add_point(0, 0) + radius = sketch.add_point(10, 0) + circle_id = sketch.add_circle(center, radius) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [circle_id] + assert RadiusConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_symmetry_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_symmetry_constraint.py new file mode 100644 index 000000000..05d58168b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_symmetry_constraint.py @@ -0,0 +1,294 @@ +from functools import partial +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import SymmetryConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_symmetry_constraint_point(setup_env): + """Test symmetry between two points with respect to a center point.""" + reg, params = setup_env + # Center at (0,0) + pc = reg.add_point(0, 0) + # P1 at (-5, -2) + p1 = reg.add_point(-5, -2) + # P2 at (5, 2) (perfectly symmetric) + p2 = reg.add_point(5, 2) + + c = SymmetryConstraint(p1, p2, center=pc) + + # Error vector: [(x1+x2) - 2xc, (y1+y2) - 2yc] + # x: (-5 + 5) - 0 = 0 + # y: (-2 + 2) - 0 = 0 + assert c.error(reg, params) == [0.0, 0.0] + assert c.user_visible is True + + # Move P2 to (6, 2) + # x: (-5 + 6) - 0 = 1 + reg.get_point(p2).x = 6.0 + assert c.error(reg, params) == [1.0, 0.0] + + +def test_symmetry_constraint_user_visible(setup_env): + reg, _params = setup_env + pc = reg.add_point(0, 0) + p1 = reg.add_point(-5, -2) + p2 = reg.add_point(5, 2) + + c = SymmetryConstraint(p1, p2, center=pc, user_visible=False) + assert c.user_visible is False + + c2 = SymmetryConstraint(p1, p2, center=pc, user_visible=True) + assert c2.user_visible is True + + +def test_symmetry_targets_segment(setup_env): + reg, _params = setup_env + pc = reg.add_point(0, 0) + p1 = reg.add_point(-5, -2) + p2 = reg.add_point(5, 2) + + c = SymmetryConstraint(p1, p2, center=pc) + + # Base implementation returns False + assert c.targets_segment(p1, p2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_symmetry_constraint_line(setup_env): + """Test symmetry between two points with respect to an axis line.""" + reg, params = setup_env + # Axis on Y-axis: (0, -10) -> (0, 10) + l1 = reg.add_point(0, -10) + l2 = reg.add_point(0, 10) + axis_id = reg.add_line(l1, l2) + + # P1 at (-5, 5), P2 at (5, 5) (perfectly symmetric) + p1 = reg.add_point(-5, 5) + p2 = reg.add_point(5, 5) + + c = SymmetryConstraint(p1, p2, axis=axis_id) + assert c.error(reg, params) == [0.0, 0.0] + + # Move P2 up by 1 -> (5, 6) + reg.get_point(p2).y = 6.0 + + # 1. Perpendicularity check (Dot product) + # Axis Vector: (0, 20) + # Point Vector P1->P2: (10, 1) + # Dot: 0*10 + 20*1 = 20 + expected_perp_err = 20.0 + + # 2. Midpoint on line check (Cross product logic) + # Midpoint of P1(-5, 5) and P2(5, 6) is (0, 5.5) + # Line Start L1(0, -10). Vector L1->Mid is (0, 15.5) + # Axis Vector L1->L2 is (0, 20) + # Cross product 2D: (0 * 20) - (15.5 * 0) = 0 + expected_coll_err = 0.0 + + err = c.error(reg, params) + assert err[0] == pytest.approx(expected_perp_err) + assert err[1] == pytest.approx(expected_coll_err) + + +def test_symmetry_constraint_point_gradient(setup_env): + reg, params = setup_env + p1 = reg.add_point(-5, 2) + p2 = reg.add_point(5, -3) + c = reg.add_point(1, -1) + mutable_pids = [p1, p2, c] + + constraint = SymmetryConstraint(p1=p1, p2=p2, center=c) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([-5, 2, 5, -3, 1, -1], dtype=float) + for i in range(2): + func = partial(func_wrapper, error_index=i) + grad = partial(grad_wrapper, error_index=i) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_symmetry_constraint_line_gradient(setup_env): + reg, params = setup_env + p1 = reg.add_point(-5, 10) + p2 = reg.add_point(6, 11) + l1 = reg.add_point(0, 0) + l2 = reg.add_point(0, 20) + axis = reg.add_line(l1, l2) + mutable_pids = [p1, p2, l1, l2] + + constraint = SymmetryConstraint(p1=p1, p2=p2, axis=axis) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + err = constraint.error(reg, params) + return err[error_index] + + def grad_wrapper(x_vec, error_index=0): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + if error_index < len(grads): + dx, dy = grads[error_index] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([-5, 10, 6, 11, 0, 0, 0, 20], dtype=float) + for i in range(2): + func = partial(func_wrapper, error_index=i) + grad = partial(grad_wrapper, error_index=i) + diff = check_grad(func, grad, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_symmetry_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + # Center at (0,0) + pc = reg.add_point(0, 0) + # P1 at (-5, -2) + p1 = reg.add_point(-5, -2) + # P2 at (5, 2) + p2 = reg.add_point(5, 2) + + # Create original constraint + original = SymmetryConstraint(p1, p2, center=pc) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = SymmetryConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_symmetry_constraint_line_serialization_round_trip(setup_env): + reg, params = setup_env + l1 = reg.add_point(0, -10) + l2 = reg.add_point(0, 10) + axis_id = reg.add_line(l1, l2) + p1 = reg.add_point(-5, 5) + p2 = reg.add_point(5, 5) + original = SymmetryConstraint(p1, p2, axis=axis_id) + serialized = original.to_dict() + restored = SymmetryConstraint.from_dict(serialized) + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_symmetry_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(-50, 0) + p2 = reg.add_point(50, 0) + c = SymmetryConstraint(p1, p2) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # Midpoint is (0,0). Angle is 0. Offset is 12. + # Symbol points are at (-12, 0) and (12, 0) + # Hit left symbol + assert c.is_hit(-12, 0, reg, to_screen, mock_element, threshold) is True + # Hit right symbol + assert c.is_hit(12, 0, reg, to_screen, mock_element, threshold) is True + # Miss + assert c.is_hit(50, 50, reg, to_screen, mock_element, threshold) is False + + +def test_symmetry_can_apply_to_three_points(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + center = sketch.add_point(5, 0) + + selection = SketchSelection() + selection.point_ids = [p1, p2, center] + selection.entity_ids = [] + assert SymmetryConstraint.can_apply_to(selection, sketch) is True + + +def test_symmetry_can_apply_to_two_points_and_line(): + sketch = Sketch() + p1 = sketch.add_point(0, 10) + p2 = sketch.add_point(0, -10) + + axis_p1 = sketch.add_point(-5, 0) + axis_p2 = sketch.add_point(5, 0) + axis_line_id = sketch.add_line(axis_p1, axis_p2) + + selection = SketchSelection() + selection.point_ids = [p1, p2] + selection.entity_ids = [axis_line_id] + assert SymmetryConstraint.can_apply_to(selection, sketch) is True + + +def test_symmetry_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(-50, 0) + p2 = reg.add_point(50, 0) + c = SymmetryConstraint(p1, p2) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_tangent_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_tangent_constraint.py new file mode 100644 index 000000000..bae17d7d6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_tangent_constraint.py @@ -0,0 +1,311 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import TangentConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_tangent_constraint(setup_env): + reg, params = setup_env + + # Arc: Center at (0,0), Radius 10 + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + + # Line: Horizontal at y=10. dist_to_line = 10. radius = 10. Error = 0. + lp1 = reg.add_point(-5, 10) + lp2 = reg.add_point(5, 10) + line_id = reg.add_line(lp1, lp2) + + c = TangentConstraint(line_id, arc_id) + assert c.error(reg, params) == pytest.approx(0.0) + assert c.user_visible is True + + # Move line to y=20. dist=20, radius=10. Error = 10. + reg.get_point(lp1).y = 20 + reg.get_point(lp2).y = 20 + + assert c.error(reg, params) == pytest.approx(10.0) + + +def test_tangent_constraint_user_visible(setup_env): + reg, _params = setup_env + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + + lp1 = reg.add_point(-5, 10) + lp2 = reg.add_point(5, 10) + line_id = reg.add_line(lp1, lp2) + + c = TangentConstraint(line_id, arc_id, user_visible=False) + assert c.user_visible is False + + c2 = TangentConstraint(line_id, arc_id, user_visible=True) + assert c2.user_visible is True + + +def test_tangent_constraint_on_circle(setup_env): + reg, params = setup_env + + # Circle: Center at (0,0), Radius 10 + center = reg.add_point(0, 0) + radius_pt = reg.add_point(10, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Line: Horizontal at y=10. dist=10, radius=10. Error = 0. + lp1 = reg.add_point(-5, 10) + lp2 = reg.add_point(5, 10) + line_id = reg.add_line(lp1, lp2) + + c = TangentConstraint(line_id, circ_id) + assert c.error(reg, params) == pytest.approx(0.0) + + # Move line to y=20. dist=20, radius=10, Error = 10. + reg.get_point(lp1).y = 20 + reg.get_point(lp2).y = 20 + + assert c.error(reg, params) == pytest.approx(10.0) + + +def test_tangent_constraint_zero_length_line(setup_env): + """Test denominator protection for Tangent constraint.""" + reg, params = setup_env + p1 = reg.add_point(0, 0) + line_id = reg.add_line(p1, p1) + + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + + c_tan = TangentConstraint(line_id, arc_id) + # Error falls back to dist(center, line_pt) - radius + # dist = 0. radius = 10. Error = 0 - 10 = -10. + assert c_tan.error(reg, params) == pytest.approx(-10.0) + + +def test_tangent_targets_segment(setup_env): + reg, _params = setup_env + start = reg.add_point(10, 0) + end = reg.add_point(0, 10) + center = reg.add_point(0, 0) + arc_id = reg.add_arc(start, end, center) + + lp1 = reg.add_point(-5, 10) + lp2 = reg.add_point(5, 10) + line_id = reg.add_line(lp1, lp2) + + c = TangentConstraint(line_id, arc_id) + + # Base implementation returns False + assert c.targets_segment(0, 0, line_id) is False + assert c.targets_segment(0, 0, arc_id) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_tangent_constraint_gradient(setup_env): + reg, params = setup_env + lp1 = reg.add_point(0, 12) + lp2 = reg.add_point(10, 12) + cp = reg.add_point(5, 0) + rp = reg.add_point(15, 0) + line = reg.add_line(lp1, lp2) + circle = reg.add_circle(cp, rp) + mutable_pids = [lp1, lp2, cp, rp] + + constraint = TangentConstraint(line_id=line, shape_id=circle) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([0, 12, 10, 12, 5, 0, 15, 0], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_tangent_gradient_with_shared_points(setup_env): + reg, params = setup_env + p0 = reg.add_point(5, 0) # circle center + p1 = reg.add_point(0, 10) # line start + p2 = reg.add_point(10, 10) # line end AND circle radius point + line = reg.add_line(p1, p2) + circle = reg.add_circle(p0, p2) + mutable_pids = [p0, p1, p2] + + constraint = TangentConstraint(line_id=line, shape_id=circle) + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([5, 0, 0, 10, 10, 10], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_tangent_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + + # Circle: Center at (0,0), Radius 10 + center = reg.add_point(0, 0) + radius_pt = reg.add_point(10, 0) + circ_id = reg.add_circle(center, radius_pt) + + # Line: Horizontal at y=10 + lp1 = reg.add_point(-5, 10) + lp2 = reg.add_point(5, 10) + line_id = reg.add_line(lp1, lp2) + + # Create original constraint + original = TangentConstraint(line_id, circ_id) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = TangentConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_tangent_is_hit(setup_env): + reg, _params = setup_env + center = reg.add_point(50, 50) + radius_pt = reg.add_point(50, 60) # radius=10 + circ_id = reg.add_circle(center, radius_pt) + + lp1 = reg.add_point(0, 60) + lp2 = reg.add_point(100, 60) + line_id = reg.add_line(lp1, lp2) + c = TangentConstraint(line_id, circ_id) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # Tangent point is (50, 60). Normal angle is PI/2. Offset is 12. + # Symbol pos: (50, 60 + 12) = (50, 72) + symbol_x, symbol_y = 50, 72 + + # Hit + assert ( + c.is_hit(symbol_x, symbol_y, reg, to_screen, mock_element, threshold) + is True + ) + # Miss + assert c.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_tangent_draw(setup_env): + reg, _params = setup_env + center = reg.add_point(50, 50) + radius_pt = reg.add_point(50, 60) + circ_id = reg.add_circle(center, radius_pt) + + lp1 = reg.add_point(0, 60) + lp2 = reg.add_point(100, 60) + line_id = reg.add_line(lp1, lp2) + c = TangentConstraint(line_id, circ_id) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_tangent_can_apply_to_line_and_arc(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + start = sketch.add_point(50, 0) + end = sketch.add_point(0, 50) + center = sketch.add_point(0, 0) + arc_id = sketch.add_arc(start, end, center) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id, arc_id] + assert TangentConstraint.can_apply_to(selection, sketch) is True + + +def test_tangent_can_apply_to_line_and_circle(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + line_id = sketch.add_line(p1, p2) + + center = sketch.add_point(50, 0) + radius = sketch.add_point(60, 0) + circle_id = sketch.add_circle(center, radius) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id, circle_id] + assert TangentConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_vertical_constraint.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_vertical_constraint.py new file mode 100644 index 000000000..cbb09ae0d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/constraints/test_vertical_constraint.py @@ -0,0 +1,174 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +from scipy.optimize import check_grad +from sketcher.core import Sketch +from sketcher.core.constraints import VerticalConstraint +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +@pytest.fixture +def setup_env(): + reg = EntityRegistry() + params = ParameterContext() + return reg, params + + +def test_vertical_constraint(setup_env): + reg, params = setup_env + # p1 x=0, p2 x=5. Error should be 0 - 5 = -5 + p1 = reg.add_point(0, 0) + p2 = reg.add_point(5, 10) + + c = VerticalConstraint(p1, p2) + assert c.error(reg, params) == pytest.approx(-5.0) + assert c.user_visible is True + + +def test_vertical_constraint_user_visible(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(5, 10) + + c = VerticalConstraint(p1, p2, user_visible=False) + assert c.user_visible is False + + c2 = VerticalConstraint(p1, p2, user_visible=True) + assert c2.user_visible is True + + +def test_vertical_targets_segment(setup_env): + reg, _params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(5, 10) + + c = VerticalConstraint(p1, p2) + + # Base implementation returns False (Vertical is topological) + assert c.targets_segment(p1, p2, None) is False + assert c.targets_segment(0, 0, 999) is False + + +def test_vertical_constraint_gradient(setup_env): + reg, params = setup_env + p1_id = reg.add_point(1, 2) + p2_id = reg.add_point(5, 6) + mutable_pids = [p1_id, p2_id] + + constraint = VerticalConstraint(p1=p1_id, p2=p2_id) + + pid_to_idx_map = {pid: i for i, pid in enumerate(mutable_pids)} + + def update_state_from_vec(x_vec): + for pid, i in pid_to_idx_map.items(): + pt = reg.get_point(pid) + pt.x = x_vec[i * 2] + pt.y = x_vec[i * 2 + 1] + + def func_wrapper(x_vec): + update_state_from_vec(x_vec) + return constraint.error(reg, params) + + def grad_wrapper(x_vec): + update_state_from_vec(x_vec) + grad_map = constraint.gradient(reg, params) + grad_vec = np.zeros_like(x_vec) + for pid, grads in grad_map.items(): + if pid in pid_to_idx_map: + idx = pid_to_idx_map[pid] * 2 + dx, dy = grads[0] + grad_vec[idx] = dx + grad_vec[idx + 1] = dy + return grad_vec + + x0 = np.array([1, 2, 5, 6], dtype=float) + diff = check_grad(func_wrapper, grad_wrapper, x0, epsilon=1e-6) + assert diff < 1e-5 + + +def test_vertical_constraint_serialization_round_trip(setup_env): + reg, params = setup_env + p1 = reg.add_point(0, 0) + p2 = reg.add_point(5, 10) + + # Create original constraint + original = VerticalConstraint(p1, p2) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + restored = VerticalConstraint.from_dict(serialized) + + # Check that the restored constraint has the same error + assert original.error(reg, params) == restored.error(reg, params) + assert original.user_visible == restored.user_visible + + +def test_vertical_is_hit(setup_env): + reg, _params = setup_env + p1 = reg.add_point(50, 0) + p2 = reg.add_point(50, 100) + c = VerticalConstraint(p1, p2) + + def to_screen(pos): + return pos + + mock_element = SimpleNamespace() + threshold = 15.0 + + # Symbol is at t=0.2 along line, offset by +10 in X + # Point is at x=50, y=20. Symbol at x=60, y=20. + symbol_x, symbol_y = 60, 20 + + # Hit + assert ( + c.is_hit(symbol_x, symbol_y, reg, to_screen, mock_element, threshold) + is True + ) + # Miss + assert c.is_hit(0, 0, reg, to_screen, mock_element, threshold) is False + + +def test_vertical_draw(setup_env): + reg, _params = setup_env + p1 = reg.add_point(50, 0) + p2 = reg.add_point(50, 100) + c = VerticalConstraint(p1, p2) + + ctx = MagicMock() + + def to_screen(pos): + return pos + + c.draw(ctx, reg, to_screen) + c.draw(ctx, reg, to_screen, is_selected=True) + c.draw(ctx, reg, to_screen, is_hovered=True) + c.draw(ctx, reg, to_screen, point_radius=10.0) + + +def test_vertical_can_apply_to_two_points(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(0, 10) + + selection = SketchSelection() + selection.point_ids = [p1, p2] + selection.entity_ids = [] + assert VerticalConstraint.can_apply_to(selection, sketch) is True + + +def test_vertical_can_apply_to_single_line(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 10) + line_id = sketch.add_line(p1, p2) + + selection = SketchSelection() + selection.point_ids = [] + selection.entity_ids = [line_id] + assert VerticalConstraint.can_apply_to(selection, sketch) is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_arc_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_arc_entity.py new file mode 100644 index 000000000..b8e8ee0e2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_arc_entity.py @@ -0,0 +1,333 @@ +import math + +import pytest +from raygeo.geo import Arc as GeoArc +from raygeo.geo import Geometry, Move +from sketcher.core.entities import Arc +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_arc_serialization_round_trip(): + """Tests the to_dict and from_dict methods for a single Arc.""" + original_arc = Arc( + id=20, + start_idx=3, + end_idx=4, + center_idx=5, + clockwise=True, + construction=True, + ) + + data = original_arc.to_dict() + assert data == { + "id": 20, + "type": "arc", + "construction": True, + "start_idx": 3, + "end_idx": 4, + "center_idx": 5, + "clockwise": True, + } + + new_arc = Arc.from_dict(data) + assert isinstance(new_arc, Arc) + assert new_arc.id == original_arc.id + assert new_arc.start_idx == original_arc.start_idx + assert new_arc.end_idx == original_arc.end_idx + assert new_arc.center_idx == original_arc.center_idx + assert new_arc.clockwise == original_arc.clockwise + assert new_arc.construction == original_arc.construction + + +def test_arc_get_point_ids(registry): + """Tests that an arc correctly reports its defining point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(5, 5) + arc = registry.get_entity(registry.add_arc(p1, p2, p3)) + assert set(arc.get_point_ids()) == {p1, p2, p3} + + +def test_arc_get_endpoint_ids(registry): + """Tests that an arc correctly reports its endpoint IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(5, 5) + arc = registry.get_entity(registry.add_arc(p1, p2, p3)) + assert arc.get_endpoint_ids() == [p1, p2] + assert set(arc.get_point_ids()) == {p1, p2, p3} + + +def test_arc_to_polygon_vertices(registry): + """Tests that an arc linearizes into polygon vertices.""" + center = registry.add_point(0, 0) + start = registry.add_point(10, 0) + end = registry.add_point(0, 10) + arc = registry.get_entity(registry.add_arc(start, end, center, cw=False)) + + vertices = arc.to_polygon_vertices(registry, forward=True) + assert len(vertices) == 17 + assert vertices[0] == pytest.approx((10.0, 0.0)) + assert vertices[-1] == pytest.approx((0.0, 10.0)) + + vertices_rev = arc.to_polygon_vertices(registry, forward=False) + assert len(vertices_rev) == 17 + assert vertices_rev[0] == pytest.approx((0.0, 10.0)) + assert vertices_rev[-1] == pytest.approx((10.0, 0.0)) + + +def test_arc_get_junction_point_ids(registry): + """Tests that an arc correctly reports its junction point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(5, 5) + arc = registry.get_entity(registry.add_arc(p1, p2, p3)) + assert set(arc.get_junction_point_ids()) == {p1, p2, p3} + + +def test_arc_hit_test(registry): + """Tests Arc.hit_test method.""" + center = registry.add_point(0, 0) + start = registry.add_point(10, 0) + end = registry.add_point(0, 10) + arc = registry.get_entity(registry.add_arc(start, end, center, cw=False)) + threshold = 2.0 + + # Point on the arc (radius 10, 45 degrees) + assert arc.hit_test(10, 0, threshold, registry) is True + assert arc.hit_test(0, 10, threshold, registry) is True + angle_45 = 10 * math.sqrt(2) / 2 + assert arc.hit_test(angle_45, angle_45, threshold, registry) is True + + # Point within threshold distance + assert arc.hit_test(10 + 1, 0, threshold, registry) is True + assert arc.hit_test(10 - 1, 0, threshold, registry) is True + + # Point outside threshold + assert arc.hit_test(15, 0, threshold, registry) is False + assert arc.hit_test(5, 0, threshold, registry) is False + + # Point outside arc sweep (CW arc would include this, CCW does not) + assert arc.hit_test(-10, 0, threshold, registry) is False + assert arc.hit_test(0, -10, threshold, registry) is False + + +def test_arc_update_constrained_status(registry): + """Test Arc.update_constrained_status logic.""" + s = registry.add_point(10, 0) + e = registry.add_point(0, 10) + c = registry.add_point(0, 0) + aid = registry.add_arc(s, e, c) + arc = registry.get_entity(aid) + + pt_s = registry.get_point(s) + pt_e = registry.get_point(e) + pt_c = registry.get_point(c) + + # Initial state + pt_s.constrained = False + pt_e.constrained = False + pt_c.constrained = False + arc.update_constrained_status(registry, []) + assert arc.constrained is False + + # Fully constrained points + pt_s.constrained = True + pt_e.constrained = True + pt_c.constrained = True + arc.update_constrained_status(registry, []) + assert arc.constrained is True + + +def test_arc_get_midpoint(registry): + """Test calculation of the arc's midpoint.""" + center = registry.add_point(0, 0) + start = registry.add_point(10, 0) + end = registry.add_point(-10, 0) + + # Counter-clockwise arc (upper semi-circle) + aid_ccw = registry.add_arc(start, end, center, cw=False) + arc_ccw = registry.get_entity(aid_ccw) + mid_ccw = arc_ccw.get_midpoint(registry) + assert mid_ccw[0] == pytest.approx(0) + assert mid_ccw[1] == pytest.approx(10) + + # Clockwise arc (lower semi-circle) + aid_cw = registry.add_arc(start, end, center, cw=True) + arc_cw = registry.get_entity(aid_cw) + mid_cw = arc_cw.get_midpoint(registry) + assert mid_cw[0] == pytest.approx(0) + assert mid_cw[1] == pytest.approx(-10) + + +def test_arc_is_angle_within_sweep(registry): + """Test checking if an angle is within the arc's sweep.""" + center = registry.add_point(0, 0) + start = registry.add_point(10, 0) # 0 degrees + end = registry.add_point(0, 10) # 90 degrees (pi/2) + + # Counter-clockwise from 0 to 90 degrees + arc_ccw = registry.get_entity( + registry.add_arc(start, end, center, cw=False) + ) + + assert ( + arc_ccw.is_angle_within_sweep(math.pi / 4, registry) is True + ) # 45 deg + assert arc_ccw.is_angle_within_sweep(math.pi, registry) is False # 180 deg + assert ( + arc_ccw.is_angle_within_sweep(0, registry) is True + ) # on start boundary + assert ( + arc_ccw.is_angle_within_sweep(math.pi / 2, registry) is True + ) # on end boundary + + # Clockwise from 0 to 90 (sweep is the long way around) + arc_cw = registry.get_entity(registry.add_arc(start, end, center, cw=True)) + + assert ( + arc_cw.is_angle_within_sweep(math.pi / 4, registry) is False + ) # 45 deg + assert arc_cw.is_angle_within_sweep(math.pi, registry) is True # 180 deg + assert ( + arc_cw.is_angle_within_sweep(-math.pi / 2, registry) is True + ) # -90 deg + + +@pytest.fixture +def selection_setup(registry): + """Fixture for setting up arc entities for selection tests.""" + rect = (20, 20, 80, 80) + + # Arc fully inside (semi-circle with radius 10) + s_arc_in = registry.add_point(40, 50) + e_arc_in = registry.add_point(60, 50) + c_arc_in = registry.add_point(50, 50) + arc_in = registry.get_entity( + registry.add_arc(s_arc_in, e_arc_in, c_arc_in) + ) + + # Arc intersecting + s_arc_cross = registry.add_point(70, 50) + e_arc_cross = registry.add_point(90, 50) + c_arc_cross = registry.add_point(80, 50) + arc_cross = registry.get_entity( + registry.add_arc(s_arc_cross, e_arc_cross, c_arc_cross) + ) + + # Arc outside + s_arc_out = registry.add_point(0, 0) + e_arc_out = registry.add_point(10, 0) + c_arc_out = registry.add_point(5, 0) + arc_out = registry.get_entity( + registry.add_arc(s_arc_out, e_arc_out, c_arc_out) + ) + + return ( + registry, + rect, + { + "arc_in": arc_in, + "arc_cross": arc_cross, + "arc_out": arc_out, + }, + ) + + +def test_arc_is_contained_by(selection_setup): + """Test the is_contained_by method for Arc entities.""" + registry, rect, entities = selection_setup + assert entities["arc_in"].is_contained_by(rect, registry) is True + assert entities["arc_cross"].is_contained_by(rect, registry) is False + assert entities["arc_out"].is_contained_by(rect, registry) is False + + +def test_arc_intersects_rect(selection_setup): + """Test of intersects_rect method for Arc entities.""" + registry, rect, entities = selection_setup + assert entities["arc_in"].intersects_rect(rect, registry) is True + assert entities["arc_cross"].intersects_rect(rect, registry) is True + assert entities["arc_out"].intersects_rect(rect, registry) is False + + +def test_arc_to_geometry(registry): + """Test Arc.to_geometry method.""" + center = registry.add_point(0, 0) + start = registry.add_point(10, 0) + end = registry.add_point(-10, 0) + arc = registry.get_entity(registry.add_arc(start, end, center, cw=False)) + geo = arc.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) == 2 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoArc) + + +def test_arc_append_to_geometry(registry): + """Test Arc.append_to_geometry method.""" + center = registry.add_point(0, 0) + start = registry.add_point(10, 0) + end = registry.add_point(-10, 0) + arc_ccw = registry.get_entity( + registry.add_arc(start, end, center, cw=False) + ) + arc_cw = registry.get_entity(registry.add_arc(start, end, center, cw=True)) + + pt_start = registry.get_point(start) + pt_end = registry.get_point(end) + + geo = Geometry() + geo.move_to(pt_start.x, pt_start.y) + assert geo.data is not None + + arc_ccw.append_to_geometry(geo, registry, forward=True) + assert len(geo) == 2 + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoArc) + assert geo.data[1].clockwise is False + + geo2 = Geometry() + geo2.move_to(pt_end.x, pt_end.y) + assert geo2.data is not None + arc_cw.append_to_geometry(geo2, registry, forward=True) + assert len(geo2) == 2 + assert isinstance(geo2.data[0], Move) + assert isinstance(geo2.data[1], GeoArc) + assert geo2.data[1].clockwise is True + + geo3 = Geometry() + geo3.move_to(pt_start.x, pt_start.y) + assert geo3.data is not None + arc_ccw.append_to_geometry(geo3, registry, forward=False) + assert len(geo3) == 2 + assert isinstance(geo3.data[0], Move) + assert isinstance(geo3.data[1], GeoArc) + assert geo3.data[1].clockwise is True + + +def test_arc_get_set_state(registry): + """Test state capture and restoration for Undo/Redo.""" + s = registry.add_point(10, 0) + e = registry.add_point(0, 10) + c = registry.add_point(0, 0) + aid = registry.add_arc(s, e, c, cw=False) + arc = registry.get_entity(aid) + + # Verify initial state + state = arc.get_state() + assert state == {"construction": False, "clockwise": False} + + # Modify state + arc.construction = True + arc.clockwise = True + + # Restore state + arc.set_state(state) + assert arc.construction is False + assert arc.clockwise is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_bezier_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_bezier_entity.py new file mode 100644 index 000000000..5ed3c8b39 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_bezier_entity.py @@ -0,0 +1,390 @@ +import pytest +from raygeo.geo import Bezier as GeoBezier +from raygeo.geo import Geometry, Move +from raygeo.geo import Line as GeoLine +from sketcher.core.entities import Bezier +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_bezier_serialization_round_trip(): + original_bezier = Bezier( + id=10, + start_idx=1, + end_idx=4, + construction=True, + ) + + data = original_bezier.to_dict() + assert data == { + "id": 10, + "type": "bezier", + "construction": True, + "start_idx": 1, + "end_idx": 4, + } + + new_bezier = Bezier.from_dict(data) + assert isinstance(new_bezier, Bezier) + assert new_bezier.id == original_bezier.id + assert new_bezier.start_idx == original_bezier.start_idx + assert new_bezier.end_idx == original_bezier.end_idx + assert new_bezier.construction == original_bezier.construction + + +def test_bezier_get_point_ids(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(20, 0) + bezier = registry.get_entity(registry.add_bezier(p1, p2)) + assert set(bezier.get_point_ids()) == {p1, p2} + + +def test_bezier_get_endpoint_ids(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(20, 0) + bezier = registry.get_entity(registry.add_bezier(p1, p2)) + assert bezier.get_endpoint_ids() == [p1, p2] + + +def test_bezier_as_line(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + bid = registry.add_bezier(start, end) + bezier = registry.get_entity(bid) + + assert bezier.is_line(registry) is True + + vertices = bezier.to_polygon_vertices(registry, forward=True) + assert len(vertices) == 1 + assert vertices[0] == pytest.approx((20.0, 0.0)) + + vertices_rev = bezier.to_polygon_vertices(registry, forward=False) + assert len(vertices_rev) == 1 + assert vertices_rev[0] == pytest.approx((0.0, 0.0)) + + +def test_bezier_with_control_points(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + + bid = registry.add_bezier(start, end) + bezier = registry.get_entity(bid) + bezier.cp1 = (5.0, 10.0) + bezier.cp2 = (-5.0, 10.0) + + assert bezier.is_line(registry) is False + + vertices = bezier.to_polygon_vertices(registry, forward=True) + assert len(vertices) == 21 + assert vertices[0] == pytest.approx((0.0, 0.0)) + assert vertices[-1] == pytest.approx((20.0, 0.0)) + + +def test_bezier_get_junction_point_ids(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(20, 0) + bezier = registry.get_entity(registry.add_bezier(p1, p2)) + assert set(bezier.get_junction_point_ids()) == {p1, p2} + + +def test_bezier_hit_test_as_line(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + bezier = registry.get_entity(registry.add_bezier(start, end)) + threshold = 5.0 + + assert bezier.hit_test(0, 0, threshold, registry) is True + assert bezier.hit_test(20, 0, threshold, registry) is True + assert bezier.hit_test(10, 0, threshold, registry) is True + assert bezier.hit_test(10, 10, threshold, registry) is False + + +def test_bezier_hit_test_with_control_points(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + + bezier = registry.get_entity(registry.add_bezier(start, end)) + bezier.cp1 = (0.0, 20.0) + bezier.cp2 = (0.0, -20.0) + + threshold = 5.0 + + assert bezier.hit_test(0, 0, threshold, registry) is True + assert bezier.hit_test(20, 0, threshold, registry) is True + + +def test_bezier_update_constrained_status(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(20, 0) + bid = registry.add_bezier(p1, p2) + bezier = registry.get_entity(bid) + + pt1 = registry.get_point(p1) + pt2 = registry.get_point(p2) + + pt1.constrained = False + pt2.constrained = False + bezier.update_constrained_status(registry, []) + assert bezier.constrained is False + + pt1.constrained = True + pt2.constrained = True + bezier.update_constrained_status(registry, []) + assert bezier.constrained is True + + +def test_bezier_to_geometry_as_line(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + bezier = registry.get_entity(registry.add_bezier(start, end)) + geo = bezier.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) == 2 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoLine) + + +def test_bezier_to_geometry_with_control_points(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + + bezier = registry.get_entity(registry.add_bezier(start, end)) + bezier.cp1 = (5.0, 10.0) + bezier.cp2 = (-5.0, 10.0) + + geo = bezier.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) == 2 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoBezier) + + +def test_bezier_get_set_state(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(20, 0) + bid = registry.add_bezier(p1, p2) + bezier = registry.get_entity(bid) + + state = bezier.get_state() + assert state == {"construction": False} + + bezier.construction = True + + bezier.set_state(state) + assert bezier.construction is False + + +def test_bezier_with_own_control_points(registry): + start = registry.add_point(0, 0) + end = registry.add_point(20, 0) + + bid = registry.add_bezier(start, end) + bezier = registry.get_entity(bid) + bezier.cp1 = (5.0, 10.0) + bezier.cp2 = (-5.0, 10.0) + + assert bezier.is_line(registry) is False + + cp1_x, cp1_y, cp2_x, cp2_y = bezier.get_control_points(registry) + assert cp1_x == pytest.approx(5.0) + assert cp1_y == pytest.approx(10.0) + assert cp2_x == pytest.approx(15.0) + assert cp2_y == pytest.approx(10.0) + + +def test_bezier_control_points_serialization(): + original = Bezier( + id=10, + start_idx=1, + end_idx=4, + cp1=(5.0, 10.0), + cp2=(-3.0, 7.0), + ) + + data = original.to_dict() + assert data["cp1_dx"] == 5.0 + assert data["cp1_dy"] == 10.0 + assert data["cp2_dx"] == -3.0 + assert data["cp2_dy"] == 7.0 + + restored = Bezier.from_dict(data) + assert restored.cp1 == (5.0, 10.0) + assert restored.cp2 == (-3.0, 7.0) + + +def test_bezier_without_control_points_serialization(): + original = Bezier( + id=10, + start_idx=1, + end_idx=4, + ) + + data = original.to_dict() + assert "cp1_dx" not in data + assert "cp2_dx" not in data + + restored = Bezier.from_dict(data) + assert restored.cp1 is None + assert restored.cp2 is None + + +def test_bezier_get_control_points_none(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=None, cp2=None) + ) + + cp1_x, cp1_y, cp2_x, cp2_y = bezier.get_control_points(registry) + + assert cp1_x is None + assert cp1_y is None + assert cp2_x is None + assert cp2_y is None + + +def test_bezier_get_control_points_with_values(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=(20, 10), cp2=(-30, -15)) + ) + + cp1_x, cp1_y, cp2_x, cp2_y = bezier.get_control_points(registry) + + assert cp1_x == 20.0 + assert cp1_y == 10.0 + assert cp2_x == 70.0 + assert cp2_y == -15.0 + + +def test_bezier_get_control_points_or_endpoints_with_cps(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=(20, 10), cp2=(-30, -15)) + ) + + cp1_x, cp1_y, cp2_x, cp2_y = bezier.get_control_points_or_endpoints( + registry + ) + + assert cp1_x == 20.0 + assert cp1_y == 10.0 + assert cp2_x == 70.0 + assert cp2_y == -15.0 + + +def test_bezier_get_control_points_or_endpoints_without_cps(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 50) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=None, cp2=None) + ) + + cp1_x, cp1_y, cp2_x, cp2_y = bezier.get_control_points_or_endpoints( + registry + ) + + assert cp1_x == 0.0 + assert cp1_y == 0.0 + assert cp2_x == 100.0 + assert cp2_y == 50.0 + + +def test_bezier_sample_bezier_linear(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=None, cp2=None) + ) + + points = bezier._sample_bezier(0, 0, 0, 0, 100, 0, 100, 0, 5) + + assert len(points) == 6 + assert points[0] == (0.0, 0.0) + assert points[5] == (100.0, 0.0) + + for i, (x, y) in enumerate(points): + assert y == pytest.approx(0.0) + + +def test_bezier_sample_bezier_curved(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=(0, 100), cp2=(100, 100)) + ) + + points = bezier._sample_bezier(0, 0, 0, 100, 100, 100, 100, 0, 10) + + assert len(points) == 11 + assert points[0] == (0.0, 0.0) + assert points[10] == (100.0, 0.0) + + mid_point = points[5] + assert mid_point[0] == pytest.approx(50.0) + assert mid_point[1] == pytest.approx(75.0) + + +def test_bezier_sample_bezier_quarter_circle_approximation(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity(registry.add_bezier(p1, p2)) + + k = 0.5522847498 + points = bezier._sample_bezier( + 0, 100, k * 100, 100, 100, k * 100, 100, 0, 20 + ) + + assert len(points) == 21 + + start = points[0] + assert start[0] == pytest.approx(0.0) + assert start[1] == pytest.approx(100.0) + + end = points[20] + assert end[0] == pytest.approx(100.0) + assert end[1] == pytest.approx(0.0) + + +def test_bezier_get_bbox_linear(registry): + p1 = registry.add_point(10, 20) + p2 = registry.add_point(50, 60) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=None, cp2=None) + ) + + bbox = bezier._get_bbox(registry) + + assert bbox == (10.0, 20.0, 50.0, 60.0) + + +def test_bezier_get_bbox_curved(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + + bezier = registry.get_entity( + registry.add_bezier(p1, p2, cp1=(0, 100), cp2=(100, 100)) + ) + + bbox = bezier._get_bbox(registry) + + assert bbox[0] == pytest.approx(0.0) + assert bbox[1] == pytest.approx(0.0) + assert bbox[2] >= 100.0 + assert bbox[3] > 50.0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_circle_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_circle_entity.py new file mode 100644 index 000000000..7a32ed97f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_circle_entity.py @@ -0,0 +1,270 @@ +import pytest +from raygeo.geo import Arc as GeoArc +from raygeo.geo import Geometry, Move +from sketcher.core.entities import Circle +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_circle_serialization_round_trip(): + """Tests the to_dict and from_dict methods for a single Circle.""" + original_circle = Circle( + id=30, center_idx=6, radius_pt_idx=7, construction=True + ) + + data = original_circle.to_dict() + assert data == { + "id": 30, + "type": "circle", + "construction": True, + "center_idx": 6, + "radius_pt_idx": 7, + } + + new_circle = Circle.from_dict(data) + assert isinstance(new_circle, Circle) + assert new_circle.id == original_circle.id + assert new_circle.center_idx == original_circle.center_idx + assert new_circle.radius_pt_idx == original_circle.radius_pt_idx + assert new_circle.construction == original_circle.construction + + +def test_circle_get_point_ids(registry): + """Tests that a circle correctly reports its defining point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(p1, p2)) + assert set(circle.get_point_ids()) == {p1, p2} + + +def test_circle_get_endpoint_ids(registry): + """Tests that a circle has no endpoints (it's a closed loop).""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(p1, p2)) + assert circle.get_endpoint_ids() == [] + + +def test_circle_to_polygon_vertices(registry): + """Tests that a circle returns empty list (handled specially for fills).""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(p1, p2)) + assert circle.to_polygon_vertices(registry, forward=True) == [] + + +def test_circle_get_junction_point_ids(registry): + """Tests that a circle correctly reports its junction point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(p1, p2)) + assert set(circle.get_junction_point_ids()) == {p1, p2} + + +def test_circle_hit_test(registry): + """Tests Circle.hit_test method.""" + center = registry.add_point(0, 0) + radius_pt = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(center, radius_pt)) + threshold = 2.0 + + # Point on the circle (radius 10) + assert circle.hit_test(10, 0, threshold, registry) is True + assert circle.hit_test(-10, 0, threshold, registry) is True + assert circle.hit_test(0, 10, threshold, registry) is True + assert circle.hit_test(0, -10, threshold, registry) is True + + # Point within threshold distance + assert circle.hit_test(11, 0, threshold, registry) is True + assert circle.hit_test(9, 0, threshold, registry) is True + + # Point outside threshold + assert circle.hit_test(15, 0, threshold, registry) is False + assert circle.hit_test(5, 0, threshold, registry) is False + assert circle.hit_test(0, 0, threshold, registry) is False + + +class MockRadiusConstraint: + """Mock constraint class for testing circle status updates.""" + + def __init__(self, circle_id_to_constrain): + self._circle_id = circle_id_to_constrain + + def constrains_radius(self, registry, circle_id): + return circle_id == self._circle_id + + +def test_circle_update_constrained_status(registry): + """ + Test Circle.update_constrained_status logic. + Circle requires center point constrained AND radius defined. + """ + c = registry.add_point(0, 0) + r = registry.add_point(10, 0) + cid = registry.add_circle(c, r) + circle = registry.get_entity(cid) + + pt_c = registry.get_point(c) + pt_r = registry.get_point(r) + + # Case 1: Nothing constrained + pt_c.constrained = False + pt_r.constrained = False + circle.update_constrained_status(registry, []) + assert circle.constrained is False + + # Case 2: Only Center constrained (Radius undefined) + pt_c.constrained = True + circle.update_constrained_status(registry, []) + assert circle.constrained is False + + # Case 3: Center + Radius Point constrained (Fully defined by points) + pt_r.constrained = True + circle.update_constrained_status(registry, []) + assert circle.constrained is True + + # Case 4: Center constrained, but a Radius constraint exists + pt_c.constrained = True + pt_r.constrained = False # Radius point is not constrained + radius_constraint = MockRadiusConstraint(cid) + circle.update_constrained_status(registry, [radius_constraint]) + assert circle.constrained is True + + # Case 5: Only a radius constraint exists, center is not constrained + pt_c.constrained = False + pt_r.constrained = False + circle.update_constrained_status(registry, [radius_constraint]) + assert circle.constrained is False + + +def test_circle_get_midpoint(registry): + """Test getting a point on the circle's circumference.""" + center = registry.add_point(5, 5) + radius_pt_idx = registry.add_point(15, 5) + + cid = registry.add_circle(center, radius_pt_idx) + circle = registry.get_entity(cid) + + midpoint = circle.get_midpoint(registry) + radius_pt = registry.get_point(radius_pt_idx) + + assert midpoint is not None + assert midpoint == radius_pt.pos() + assert midpoint == (15.0, 5.0) + + +def test_circle_get_ignorable_unconstrained_points(registry): + """ + Tests that a Circle correctly identifies its radius point as ignorable + only when the circle itself is constrained. + """ + center = registry.add_point(0, 0) + radius_pt = registry.add_point(10, 0) + cid = registry.add_circle(center, radius_pt) + circle = registry.get_entity(cid) + + # When circle is not constrained, list should be empty + circle.constrained = False + assert circle.get_ignorable_unconstrained_points() == [] + + # When circle is constrained, radius point can be ignored + circle.constrained = True + assert circle.get_ignorable_unconstrained_points() == [radius_pt] + + +@pytest.fixture +def selection_setup(registry): + """Fixture for setting up circle entities for selection tests.""" + rect = (20, 20, 80, 80) + + # Circle fully inside + c_in = registry.add_point(50, 50) + r_in = registry.add_point(60, 50) # radius 10 + circle_in = registry.get_entity(registry.add_circle(c_in, r_in)) + + # Circle intersecting + c_cross = registry.add_point(85, 50) + r_cross = registry.add_point(95, 50) # radius 10 + circle_cross = registry.get_entity(registry.add_circle(c_cross, r_cross)) + + # Circle outside + c_out = registry.add_point(0, 0) + r_out = registry.add_point(5, 0) # radius 5 + circle_out = registry.get_entity(registry.add_circle(c_out, r_out)) + + return ( + registry, + rect, + { + "circle_in": circle_in, + "circle_cross": circle_cross, + "circle_out": circle_out, + }, + ) + + +def test_circle_is_contained_by(selection_setup): + """Test the is_contained_by method for Circle entities.""" + registry, rect, entities = selection_setup + assert entities["circle_in"].is_contained_by(rect, registry) is True + assert entities["circle_cross"].is_contained_by(rect, registry) is False + assert entities["circle_out"].is_contained_by(rect, registry) is False + + +def test_circle_intersects_rect(selection_setup): + """Test of intersects_rect method for Circle entities.""" + registry, rect, entities = selection_setup + assert entities["circle_in"].intersects_rect(rect, registry) is True + assert entities["circle_cross"].intersects_rect(rect, registry) is True + assert entities["circle_out"].intersects_rect(rect, registry) is False + + +def test_circle_to_geometry(registry): + """Test Circle.to_geometry method.""" + center = registry.add_point(0, 0) + radius_pt = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(center, radius_pt)) + geo = circle.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) == 3 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoArc) + assert isinstance(geo.data[2], GeoArc) + + +def test_circle_create_fill_geometry(registry): + """Test Circle.create_fill_geometry method.""" + center = registry.add_point(0, 0) + radius_pt = registry.add_point(10, 0) + circle = registry.get_entity(registry.add_circle(center, radius_pt)) + geo = circle.create_fill_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) == 3 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoArc) + assert isinstance(geo.data[2], GeoArc) + + +def test_circle_get_set_state(registry): + """Test state capture and restoration for Undo/Redo.""" + c = registry.add_point(0, 0) + r = registry.add_point(10, 0) + cid = registry.add_circle(c, r) + circle = registry.get_entity(cid) + + # Verify initial state + state = circle.get_state() + assert state == {"construction": False} + + # Modify state + circle.construction = True + + # Restore state + circle.set_state(state) + assert circle.construction is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_ellipse_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_ellipse_entity.py new file mode 100644 index 000000000..3725c3573 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_ellipse_entity.py @@ -0,0 +1,377 @@ +import pytest +from raygeo.geo import Geometry +from sketcher.core.entities import Ellipse +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_ellipse_serialization_round_trip(): + """Tests the to_dict and from_dict methods for a single Ellipse.""" + original = Ellipse( + id=30, + center_idx=6, + radius_x_pt_idx=7, + radius_y_pt_idx=8, + construction=True, + helper_line_ids=[100, 101], + ) + + data = original.to_dict() + assert data == { + "id": 30, + "type": "ellipse", + "construction": True, + "center_idx": 6, + "radius_x_pt_idx": 7, + "radius_y_pt_idx": 8, + "helper_line_ids": [100, 101], + } + + new_ellipse = Ellipse.from_dict(data) + assert isinstance(new_ellipse, Ellipse) + assert new_ellipse.id == original.id + assert new_ellipse.center_idx == original.center_idx + assert new_ellipse.radius_x_pt_idx == original.radius_x_pt_idx + assert new_ellipse.radius_y_pt_idx == original.radius_y_pt_idx + assert new_ellipse.construction == original.construction + assert new_ellipse.helper_line_ids == original.helper_line_ids + + +def test_ellipse_serialization_without_helper_lines(): + """Tests serialization when helper_line_ids is None.""" + original = Ellipse( + id=31, + center_idx=1, + radius_x_pt_idx=2, + radius_y_pt_idx=3, + helper_line_ids=None, + ) + data = original.to_dict() + assert data["helper_line_ids"] == [] + + restored = Ellipse.from_dict(data) + assert restored.helper_line_ids == [] + + +def test_ellipse_get_point_ids(registry): + """Tests that an ellipse correctly reports its defining point IDs.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + assert set(ellipse.get_point_ids()) == {center, rx, ry} + + +def test_ellipse_get_endpoint_ids(registry): + """Tests that an ellipse has no endpoints (it's a closed loop).""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + assert ellipse.get_endpoint_ids() == [] + + +def test_ellipse_get_junction_point_ids(registry): + """Tests that an ellipse correctly reports its junction point IDs.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + assert set(ellipse.get_junction_point_ids()) == {center, rx, ry} + + +def test_ellipse_hit_test_on_edge(registry): + """Tests Ellipse.hit_test on ellipse edge.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + threshold = 1.0 + + assert ellipse.hit_test(10, 0, threshold, registry) is True + assert ellipse.hit_test(-10, 0, threshold, registry) is True + assert ellipse.hit_test(0, 5, threshold, registry) is True + assert ellipse.hit_test(0, -5, threshold, registry) is True + + +def test_ellipse_hit_test_off_edge(registry): + """Tests Ellipse.hit_test for points off the ellipse.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + threshold = 1.0 + + assert ellipse.hit_test(5, 0, threshold, registry) is False + assert ellipse.hit_test(0, 2, threshold, registry) is False + assert ellipse.hit_test(15, 0, threshold, registry) is False + + +def test_ellipse_hit_test_rotated(registry): + """Tests hit_test for a rotated ellipse.""" + center = registry.add_point(0, 0) + rx = registry.add_point(5, 5) + ry = registry.add_point(-5, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + threshold = 1.0 + + import math + + dist = math.hypot(5, 5) + assert ellipse.hit_test(dist, 0, threshold, registry) is True + + +def test_ellipse_hit_test_missing_points(registry): + """Tests hit_test raises IndexError when points are missing.""" + ellipse = Ellipse( + id=999, center_idx=999, radius_x_pt_idx=998, radius_y_pt_idx=997 + ) + with pytest.raises(IndexError): + ellipse.hit_test(0, 0, 1.0, registry) + + +def test_ellipse_hit_test_zero_radii(registry): + """Tests hit_test returns False when radii are zero.""" + center = registry.add_point(0, 0) + same = registry.add_point(0, 0) + ellipse = registry.get_entity(registry.add_ellipse(center, same, same)) + assert ellipse.hit_test(0, 0, 1.0, registry) is False + + +class MockRadiusConstraint: + """Mock constraint class for testing ellipse status updates.""" + + def __init__(self, ellipse_id_to_constrain): + self._ellipse_id = ellipse_id_to_constrain + + def constrains_radius(self, registry, ellipse_id): + return ellipse_id == self._ellipse_id + + +def test_ellipse_update_constrained_status(registry): + """Test Ellipse.update_constrained_status logic.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + pt_c = registry.get_point(center) + pt_rx = registry.get_point(rx) + pt_ry = registry.get_point(ry) + + pt_c.constrained = False + pt_rx.constrained = False + pt_ry.constrained = False + ellipse.update_constrained_status(registry, []) + assert ellipse.constrained is False + + pt_c.constrained = True + ellipse.update_constrained_status(registry, []) + assert ellipse.constrained is False + + pt_rx.constrained = True + pt_ry.constrained = True + ellipse.update_constrained_status(registry, []) + assert ellipse.constrained is True + + pt_rx.constrained = False + pt_ry.constrained = False + radius_constraint = MockRadiusConstraint(eid) + ellipse.update_constrained_status(registry, [radius_constraint]) + assert ellipse.constrained is True + + pt_c.constrained = False + ellipse.update_constrained_status(registry, [radius_constraint]) + assert ellipse.constrained is False + + +def test_ellipse_get_midpoint(registry): + """Test getting the radius-x point position as midpoint.""" + center = registry.add_point(5, 5) + rx = registry.add_point(15, 5) + ry = registry.add_point(5, 10) + + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + midpoint = ellipse.get_midpoint(registry) + assert midpoint is not None + assert midpoint == (15.0, 5.0) + + +def test_ellipse_get_ignorable_unconstrained_points(registry): + """Tests ignorable points when ellipse is constrained.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + ellipse.constrained = False + assert ellipse.get_ignorable_unconstrained_points() == [] + + ellipse.constrained = True + assert set(ellipse.get_ignorable_unconstrained_points()) == {rx, ry} + + +def test_ellipse_get_rigidly_connected_points(registry): + """Test get_rigidly_connected_points returns all ellipse points.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + result = ellipse.get_rigidly_connected_points(center) + assert set(result) == {center, rx, ry} + + result = ellipse.get_rigidly_connected_points(rx) + assert result == [] + + +@pytest.fixture +def selection_setup(registry): + """Fixture for setting up ellipse entities for selection tests.""" + rect = (20, 20, 80, 80) + + c_in = registry.add_point(50, 50) + rx_in = registry.add_point(60, 50) + ry_in = registry.add_point(50, 60) + ellipse_in = registry.get_entity(registry.add_ellipse(c_in, rx_in, ry_in)) + + c_cross = registry.add_point(85, 50) + rx_cross = registry.add_point(95, 50) + ry_cross = registry.add_point(85, 60) + ellipse_cross = registry.get_entity( + registry.add_ellipse(c_cross, rx_cross, ry_cross) + ) + + c_out = registry.add_point(0, 0) + rx_out = registry.add_point(5, 0) + ry_out = registry.add_point(0, 3) + ellipse_out = registry.get_entity( + registry.add_ellipse(c_out, rx_out, ry_out) + ) + + return ( + registry, + rect, + { + "ellipse_in": ellipse_in, + "ellipse_cross": ellipse_cross, + "ellipse_out": ellipse_out, + }, + ) + + +def test_ellipse_is_contained_by(selection_setup): + """Test the is_contained_by method for Ellipse entities.""" + registry, rect, entities = selection_setup + assert entities["ellipse_in"].is_contained_by(rect, registry) is True + assert entities["ellipse_cross"].is_contained_by(rect, registry) is False + assert entities["ellipse_out"].is_contained_by(rect, registry) is False + + +def test_ellipse_intersects_rect(selection_setup): + """Test of intersects_rect method for Ellipse entities.""" + registry, rect, entities = selection_setup + assert entities["ellipse_in"].intersects_rect(rect, registry) is True + assert entities["ellipse_cross"].intersects_rect(rect, registry) is True + assert entities["ellipse_out"].intersects_rect(rect, registry) is False + + +def test_ellipse_to_geometry(registry): + """Test Ellipse.to_geometry method.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + geo = ellipse.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) > 0 + assert geo.data is not None + + +def test_ellipse_to_geometry_zero_radii(registry): + """Test to_geometry returns empty geometry for zero radii.""" + center = registry.add_point(0, 0) + same = registry.add_point(0, 0) + ellipse = registry.get_entity(registry.add_ellipse(center, same, same)) + geo = ellipse.to_geometry(registry) + assert len(geo) == 0 + + +def test_ellipse_create_fill_geometry(registry): + """Test Ellipse.create_fill_geometry method.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + ellipse = registry.get_entity(registry.add_ellipse(center, rx, ry)) + geo = ellipse.create_fill_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) > 0 + + +def test_ellipse_get_set_state(registry): + """Test state capture and restoration for Undo/Redo.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + state = ellipse.get_state() + assert state == {"construction": False} + + ellipse.construction = True + + ellipse.set_state(state) + assert ellipse.construction is False + + +def test_ellipse_repr(): + """Test Ellipse string representation.""" + ellipse = Ellipse( + id=1, + center_idx=2, + radius_x_pt_idx=3, + radius_y_pt_idx=4, + construction=False, + ) + r = repr(ellipse) + assert "Ellipse" in r + assert "id=1" in r + assert "center=2" in r + + +def test_ellipse_get_radii(registry): + """Test _get_radii returns correct values.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 0) + ry = registry.add_point(0, 5) + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + rx_val, ry_val = ellipse._get_radii(registry) + assert rx_val == 10.0 + assert ry_val == 5.0 + + +def test_ellipse_get_rotation(registry): + """Test _get_rotation returns correct angle.""" + center = registry.add_point(0, 0) + rx = registry.add_point(10, 10) + ry = registry.add_point(-10, 10) + eid = registry.add_ellipse(center, rx, ry) + ellipse = registry.get_entity(eid) + + import math + + rotation = ellipse._get_rotation(registry) + expected = math.atan2(10, 10) + assert abs(rotation - expected) < 1e-9 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_entity_registry.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_entity_registry.py new file mode 100644 index 000000000..2912ec4dd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_entity_registry.py @@ -0,0 +1,359 @@ +import pytest +from raygeo.geo.shape.text import FontConfig +from sketcher.core.entities import ( + Arc, + Circle, + Line, + Point, + TextBoxEntity, +) +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_add_point(registry): + pid = registry.add_point(10.0, 20.0, fixed=True) + assert pid == 0 + pt = registry.get_point(pid) + + # Check instance type and attributes + assert isinstance(pt, Point) + assert pt.x == 10.0 + assert pt.y == 20.0 + assert pt.fixed is True + assert pt.pos() == (10.0, 20.0) + + # Check defaults + assert pt.constrained is False + + +def test_add_line(registry): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 10) + + # Test standard line + lid = registry.add_line(p1, p2) + assert lid == 2 # 0, 1 used by points + + line = registry.get_entity(lid) + assert isinstance(line, Line) + assert line.p1_idx == p1 + assert line.p2_idx == p2 + assert line.type == "line" + assert line.construction is False + + +def test_add_construction_line(registry): + """Test that explicit construction flag is passed to the class.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + lid = registry.add_line(p1, p2, construction=True) + + line = registry.get_entity(lid) + assert line.construction is True + + +def test_add_arc(registry): + start = registry.add_point(0, 0) + end = registry.add_point(10, 0) + center = registry.add_point(5, 0) + + aid = registry.add_arc(start, end, center, cw=True) + arc = registry.get_entity(aid) + + assert isinstance(arc, Arc) + assert arc.start_idx == start + assert arc.center_idx == center + assert arc.clockwise is True + assert arc.type == "arc" + assert arc.construction is False + + +def test_add_circle(registry): + center = registry.add_point(0, 0) + radius_pt = registry.add_point(10, 0) + cid = registry.add_circle(center, radius_pt, construction=True) + assert cid == 2 + + circle = registry.get_entity(cid) + assert isinstance(circle, Circle) + assert circle.center_idx == center + assert circle.radius_pt_idx == radius_pt + assert circle.construction is True + assert circle.type == "circle" + + +def test_registry_indices(registry): + # Ensure ID counter increments across types + id1 = registry.add_point(0, 0) + id2 = registry.add_line(id1, id1) + id3 = registry.add_point(1, 1) + assert id1 == 0 + assert id2 == 1 + assert id3 == 2 + + +def test_registry_lookup_failures(registry): + """Test behavior when looking up invalid IDs.""" + with pytest.raises(IndexError): + registry.get_point(999) + + # get_entity returns None, doesn't raise + assert registry.get_entity(999) is None + + +def test_entity_registry_serialization_round_trip(): + """Tests to_dict and from_dict for the entire EntityRegistry.""" + reg = EntityRegistry() + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0) + p3 = reg.add_point(10, 10) + p4 = reg.add_point(0, 10) + _ = reg.add_line(p1, p2) + l2 = reg.add_line(p2, p3, construction=True) + arc = reg.add_arc(p3, p4, p1, cw=True) + circ = reg.add_circle(p1, p2) + + data = reg.to_dict() + + # Basic structure check + assert "points" in data + assert "entities" in data + assert "id_counter" in data + assert len(data["points"]) == 4 + assert len(data["entities"]) == 4 # Line, Line, Arc, Circle + assert data["id_counter"] == 8 # 4 points + 4 entities + + new_reg = EntityRegistry.from_dict(data) + + # Check integrity + assert new_reg._id_counter == 8 + assert len(new_reg.points) == 4 + assert len(new_reg.entities) == 4 + + # Check point details + new_p1 = new_reg.get_point(p1) + assert new_p1.x == 0 + assert new_p1.fixed is True + + # Check entity details and map + new_l2 = new_reg.get_entity(l2) + assert isinstance(new_l2, Line) + assert new_l2.p1_idx == p2 + assert new_l2.construction is True + + new_arc = new_reg.get_entity(arc) + assert isinstance(new_arc, Arc) + assert new_arc.center_idx == p1 + assert new_arc.clockwise is True + + new_circ = new_reg.get_entity(circ) + assert isinstance(new_circ, Circle) + assert new_circ.center_idx == p1 + assert new_circ.radius_pt_idx == p2 + + +def test_registry_is_point_used(registry): + """Test checking if a point is referenced by any entity.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(10, 10) + p4 = registry.add_point(0, 10) + p_unused = registry.add_point(100, 100) + + # Initially, no points are used + assert registry.is_point_used(p1) is False + assert registry.is_point_used(p_unused) is False + + # Add a line and check + registry.add_line(p1, p2) + assert registry.is_point_used(p1) is True + assert registry.is_point_used(p2) is True + assert registry.is_point_used(p3) is False + assert registry.is_point_used(p_unused) is False + + # Add an arc and check + registry.add_arc(p2, p3, p4) + assert registry.is_point_used(p2) is True + assert registry.is_point_used(p3) is True + assert registry.is_point_used(p4) is True + + # Add a circle and check + registry.add_circle(p1, p4) + assert registry.is_point_used(p1) is True + assert registry.is_point_used(p4) is True + assert registry.is_point_used(p_unused) is False + + +def test_add_text_box(registry): + """Test that add_text_box creates a TextBoxEntity correctly.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(50, 0) + p3 = registry.add_point(0, 10) + + tb_id = registry.add_text_box( + p1, + p2, + p3, + content="Hello World", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + tb = registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.origin_id == p1 + assert tb.width_id == p2 + assert tb.height_id == p3 + assert tb.content == "Hello World" + assert tb.font_config == FontConfig(family="sans-serif", size=10.0) + + +def test_add_text_box_with_default_font_params(registry): + """Test that add_text_box provides default font_config.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(50, 0) + p3 = registry.add_point(0, 10) + + tb_id = registry.add_text_box(p1, p2, p3, content="Test") + + tb = registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Test" + assert tb.font_config == FontConfig() + + +def test_add_text_box_increments_id_counter(registry): + """Test that add_text_box increments the ID counter correctly.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(50, 0) + p3 = registry.add_point(0, 10) + + tb_id = registry.add_text_box(p1, p2, p3, content="Test") + + assert tb_id == 3 + + +def test_single_entity_returns_self(): + """A single entity with no connections should return only itself.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + l1 = registry.add_line(p1, p2) + + connected = registry.get_connected_entity_ids(l1) + assert connected == {l1} + + +def test_two_connected_lines(): + """Two lines sharing a point should both be returned.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(20, 0) + l1 = registry.add_line(p1, p2) + l2 = registry.add_line(p2, p3) + + connected_from_l1 = registry.get_connected_entity_ids(l1) + assert connected_from_l1 == {l1, l2} + + connected_from_l2 = registry.get_connected_entity_ids(l2) + assert connected_from_l2 == {l1, l2} + + +def test_chain_of_connected_entities(): + """A chain of entities should all be returned.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(20, 0) + p4 = registry.add_point(30, 0) + l1 = registry.add_line(p1, p2) + l2 = registry.add_line(p2, p3) + l3 = registry.add_line(p3, p4) + + connected = registry.get_connected_entity_ids(l1) + assert connected == {l1, l2, l3} + + +def test_disconnected_entities(): + """Disconnected entities should not be included.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(30, 0) + p4 = registry.add_point(40, 0) + l1 = registry.add_line(p1, p2) + l2 = registry.add_line(p3, p4) + + connected_l1 = registry.get_connected_entity_ids(l1) + assert connected_l1 == {l1} + + connected_l2 = registry.get_connected_entity_ids(l2) + assert connected_l2 == {l2} + + +def test_star_topology(): + """Multiple entities sharing a single point (star pattern).""" + registry = EntityRegistry() + center = registry.add_point(0, 0) + p1 = registry.add_point(10, 0) + p2 = registry.add_point(0, 10) + p3 = registry.add_point(-10, 0) + l1 = registry.add_line(center, p1) + l2 = registry.add_line(center, p2) + l3 = registry.add_line(center, p3) + + connected = registry.get_connected_entity_ids(l1) + assert connected == {l1, l2, l3} + + +def test_mixed_entity_types(): + """Connected entities of different types (lines, arcs, circles).""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(10, 10) + center = registry.add_point(5, 5) + + l1 = registry.add_line(p1, p2) + l2 = registry.add_line(p2, p3) + arc = registry.add_arc(p1, p3, center) + + connected = registry.get_connected_entity_ids(l1) + assert connected == {l1, l2, arc} + + +def test_circle_is_standalone(): + """A circle shares only its center and radius points.""" + registry = EntityRegistry() + center = registry.add_point(0, 0) + radius_pt = registry.add_point(10, 0) + circle = registry.add_circle(center, radius_pt) + + connected = registry.get_connected_entity_ids(circle) + assert connected == {circle} + + +def test_circle_connected_via_shared_point(): + """A circle connected to a line via shared center point.""" + registry = EntityRegistry() + center = registry.add_point(0, 0) + p1 = registry.add_point(10, 0) + radius_pt = registry.add_point(5, 0) + + l1 = registry.add_line(p1, center) + circle = registry.add_circle(center, radius_pt) + + connected = registry.get_connected_entity_ids(l1) + assert connected == {l1, circle} + + +def test_invalid_entity_id_returns_empty(): + """Invalid entity ID should return empty set.""" + registry = EntityRegistry() + connected = registry.get_connected_entity_ids(999) + assert connected == set() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_line_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_line_entity.py new file mode 100644 index 000000000..8fb304a38 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_line_entity.py @@ -0,0 +1,226 @@ +import pytest +from raygeo.geo import Geometry, Move +from raygeo.geo import Line as GeoLine +from sketcher.core.entities import Line +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_line_serialization_round_trip(): + """Tests the to_dict and from_dict methods for a single Line.""" + original_line = Line(id=10, p1_idx=1, p2_idx=2, construction=True) + + data = original_line.to_dict() + assert data == { + "id": 10, + "type": "line", + "construction": True, + "p1_idx": 1, + "p2_idx": 2, + } + + new_line = Line.from_dict(data) + assert isinstance(new_line, Line) + assert new_line.id == original_line.id + assert new_line.p1_idx == original_line.p1_idx + assert new_line.p2_idx == original_line.p2_idx + assert new_line.construction == original_line.construction + + +def test_line_get_point_ids(registry): + """Tests that a line correctly reports its defining point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + assert set(line.get_point_ids()) == {p1, p2} + + +def test_line_get_endpoint_ids(registry): + """Tests that a line correctly reports its endpoint IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + assert line.get_endpoint_ids() == [p1, p2] + + +def test_line_to_polygon_vertices(registry): + """Tests that a line returns its start point as polygon vertex.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 5) + line = registry.get_entity(registry.add_line(p1, p2)) + + vertices_forward = line.to_polygon_vertices(registry, forward=True) + assert len(vertices_forward) == 1 + assert vertices_forward[0] == (0.0, 0.0) + + vertices_backward = line.to_polygon_vertices(registry, forward=False) + assert len(vertices_backward) == 1 + assert vertices_backward[0] == (10.0, 5.0) + + +def test_line_get_junction_point_ids(registry): + """Tests that a line correctly reports its junction point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + assert set(line.get_junction_point_ids()) == {p1, p2} + + +def test_line_hit_test(registry): + """Tests Line.hit_test method.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(100, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + threshold = 5.0 + + # Point on the line + assert line.hit_test(50, 0, threshold, registry) is True + assert line.hit_test(0, 0, threshold, registry) is True + assert line.hit_test(100, 0, threshold, registry) is True + + # Point within threshold distance + assert line.hit_test(50, 3, threshold, registry) is True + assert line.hit_test(50, -4, threshold, registry) is True + + # Point outside threshold + assert line.hit_test(50, 10, threshold, registry) is False + assert line.hit_test(50, -10, threshold, registry) is False + + # Point beyond line segment endpoints + assert line.hit_test(-10, 0, threshold, registry) is False + assert line.hit_test(110, 0, threshold, registry) is False + + +def test_line_update_constrained_status(registry): + """Test Line.update_constrained_status logic.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 10) + lid = registry.add_line(p1, p2) + line = registry.get_entity(lid) + + pt1 = registry.get_point(p1) + pt2 = registry.get_point(p2) + + # Initially unconstrained + pt1.constrained = False + pt2.constrained = False + line.update_constrained_status(registry, []) + assert line.constrained is False + + # One point constrained + pt1.constrained = True + line.update_constrained_status(registry, []) + assert line.constrained is False + + # Both points constrained + pt2.constrained = True + line.update_constrained_status(registry, []) + assert line.constrained is True + + +@pytest.fixture +def selection_setup(registry): + """Fixture for setting up line entities for selection tests.""" + rect = (20, 20, 80, 80) + + # Line fully inside + p_in1 = registry.add_point(30, 30) + p_in2 = registry.add_point(70, 70) + line_in = registry.get_entity(registry.add_line(p_in1, p_in2)) + + # Line intersecting + p_cross1 = registry.add_point(10, 50) + p_cross2 = registry.add_point(90, 50) + line_cross = registry.get_entity(registry.add_line(p_cross1, p_cross2)) + + # Line outside + p_out1 = registry.add_point(0, 0) + p_out2 = registry.add_point(10, 10) + line_out = registry.get_entity(registry.add_line(p_out1, p_out2)) + + return ( + registry, + rect, + { + "line_in": line_in, + "line_cross": line_cross, + "line_out": line_out, + }, + ) + + +def test_line_is_contained_by(selection_setup): + """Test the is_contained_by method for Line entities.""" + registry, rect, entities = selection_setup + assert entities["line_in"].is_contained_by(rect, registry) is True + assert entities["line_cross"].is_contained_by(rect, registry) is False + assert entities["line_out"].is_contained_by(rect, registry) is False + + +def test_line_intersects_rect(selection_setup): + """Test of intersects_rect method for Line entities.""" + registry, rect, entities = selection_setup + assert entities["line_in"].intersects_rect(rect, registry) is True + assert entities["line_cross"].intersects_rect(rect, registry) is True + assert entities["line_out"].intersects_rect(rect, registry) is False + + +def test_line_to_geometry(registry): + """Test Line.to_geometry method.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + geo = line.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) == 2 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoLine) + + +def test_line_append_to_geometry(registry): + """Test Line.append_to_geometry method.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + pt1 = registry.get_point(p1) + pt2 = registry.get_point(p2) + geo = Geometry() + geo.move_to(pt1.x, pt1.y) + + line.append_to_geometry(geo, registry, forward=True) + assert len(geo) == 2 + assert geo.data is not None + assert isinstance(geo.data[0], Move) + assert isinstance(geo.data[1], GeoLine) + + geo2 = Geometry() + geo2.move_to(pt2.x, pt2.y) + line.append_to_geometry(geo2, registry, forward=False) + assert geo2.data is not None + assert len(geo2) == 2 + assert isinstance(geo2.data[0], Move) + assert isinstance(geo2.data[1], GeoLine) + + +def test_line_get_set_state(registry): + """Test state capture and restoration for Undo/Redo.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 10) + lid = registry.add_line(p1, p2) + line = registry.get_entity(lid) + + # Verify initial state + state = line.get_state() + assert state == {"construction": False} + + # Modify state + line.construction = True + + # Restore state + line.set_state(state) + assert line.construction is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_point.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_point.py new file mode 100644 index 000000000..fada9c5ff --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_point.py @@ -0,0 +1,187 @@ +import math + +from sketcher.core.entities import Point +from sketcher.core.entities.bezier import Bezier +from sketcher.core.entities.point import WaypointType +from sketcher.core.registry import EntityRegistry + + +def test_point_instantiation(): + """Tests basic point creation and attribute access.""" + pt = Point(id=0, x=10.5, y=-20.0, fixed=True) + assert pt.id == 0 + assert pt.x == 10.5 + assert pt.y == -20.0 + assert pt.fixed is True + assert pt.pos() == (10.5, -20.0) + assert pt.constrained is False + + +def test_point_serialization_round_trip(): + """Tests the to_dict and from_dict methods for a Point.""" + original_point = Point(id=1, x=1.2, y=3.4, fixed=True) + data = original_point.to_dict() + assert data == {"id": 1, "x": 1.2, "y": 3.4, "fixed": True} + + new_point = Point.from_dict(data) + assert isinstance(new_point, Point) + assert new_point.id == original_point.id + assert new_point.x == original_point.x + assert new_point.y == original_point.y + assert new_point.fixed == original_point.fixed + + +def test_point_is_in_rect(): + """Tests the Point.is_in_rect method.""" + pt_inside = Point(0, 5, 5) + pt_outside = Point(1, 15, 15) + pt_on_edge = Point(2, 10, 5) + rect = (0, 0, 10, 10) # min_x, min_y, max_x, max_y + + assert pt_inside.is_in_rect(rect) is True + assert pt_outside.is_in_rect(rect) is False + assert pt_on_edge.is_in_rect(rect) is True # Edges are inclusive + + +def test_point_get_connected_beziers(): + """Tests finding beziers connected to a point.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(20, 0) + + b1_id = registry.add_bezier(p1, p2) + b2_id = registry.add_bezier(p2, p3) + + pt1 = registry.get_point(p1) + pt2 = registry.get_point(p2) + pt3 = registry.get_point(p3) + + connected_to_p1 = pt1.get_connected_beziers(registry) + assert len(connected_to_p1) == 1 + assert connected_to_p1[0].id == b1_id + + connected_to_p2 = pt2.get_connected_beziers(registry) + assert len(connected_to_p2) == 2 + ids = {b.id for b in connected_to_p2} + assert ids == {b1_id, b2_id} + + connected_to_p3 = pt3.get_connected_beziers(registry) + assert len(connected_to_p3) == 1 + assert connected_to_p3[0].id == b2_id + + +def test_point_get_paired_beziers(): + """Tests getting paired beziers (first two connected).""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(20, 0) + + b1_id = registry.add_bezier(p1, p2) + b2_id = registry.add_bezier(p2, p3) + + pt2 = registry.get_point(p2) + + b1, b2 = pt2.get_paired_beziers(registry) + assert b1 is not None + assert b2 is not None + assert b1.id == b1_id + assert b2.id == b2_id + + +def test_point_paired_beziers_single(): + """Tests paired beziers when only one is connected.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + + registry.add_bezier(p1, p2) + + pt1 = registry.get_point(p1) + b1, b2 = pt1.get_paired_beziers(registry) + assert b1 is not None + assert b2 is None + + +def test_point_apply_constraint_symmetric(): + """Tests symmetric constraint: mirrored control points.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 10) + p3 = registry.add_point(20, 0) + + b1_id = registry.add_bezier(p1, p2) + b2_id = registry.add_bezier(p2, p3) + + b1 = registry.get_entity(b1_id) + b2 = registry.get_entity(b2_id) + assert isinstance(b1, Bezier) + assert isinstance(b2, Bezier) + + pt2 = registry.get_point(p2) + pt2.waypoint_type = WaypointType.SYMMETRIC + + b1.cp2 = (5.0, 3.0) + + pt2.apply_constraint(registry, b1, cp_index=2) + + assert b2.cp1 == (-5.0, -3.0) + + +def test_point_apply_constraint_smooth(): + """Tests smooth constraint: collinear with preserved length.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 10) + p3 = registry.add_point(20, 0) + + b1_id = registry.add_bezier(p1, p2) + b2_id = registry.add_bezier(p2, p3) + + b1 = registry.get_entity(b1_id) + b2 = registry.get_entity(b2_id) + assert isinstance(b1, Bezier) + assert isinstance(b2, Bezier) + + pt2 = registry.get_point(p2) + pt2.waypoint_type = WaypointType.SMOOTH + + b2.cp1 = (2.0, 4.0) + b1.cp2 = (6.0, 8.0) + + pt2.apply_constraint(registry, b1, cp_index=2) + + assert b2.cp1 is not None + + expected_length = math.sqrt(2.0**2 + 4.0**2) + actual_length = math.sqrt(b2.cp1[0] ** 2 + b2.cp1[1] ** 2) + assert abs(actual_length - expected_length) < 0.001 + + assert b2.cp1[0] < 0 + assert b2.cp1[1] < 0 + + +def test_point_apply_constraint_sharp_noop(): + """Tests that SHARP waypoint type doesn't apply constraints.""" + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 10) + p3 = registry.add_point(20, 0) + + b1_id = registry.add_bezier(p1, p2) + b2_id = registry.add_bezier(p2, p3) + + b1 = registry.get_entity(b1_id) + b2 = registry.get_entity(b2_id) + assert isinstance(b1, Bezier) + assert isinstance(b2, Bezier) + + pt2 = registry.get_point(p2) + pt2.waypoint_type = WaypointType.SHARP + + b1.cp2 = (5.0, 3.0) + + pt2.apply_constraint(registry, b1, cp_index=2) + + assert b2.cp1 is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_text_box_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_text_box_entity.py new file mode 100644 index 000000000..a035f5e2f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/entities/test_text_box_entity.py @@ -0,0 +1,393 @@ +import pytest +from raygeo.geo import Geometry +from raygeo.geo.shape.text import FontConfig +from sketcher.core.entities import TextBoxEntity +from sketcher.core.registry import EntityRegistry + + +@pytest.fixture +def registry(): + return EntityRegistry() + + +def test_text_box_serialization_round_trip(): + """Tests to_dict and from_dict methods for a single TextBox.""" + original_box = TextBoxEntity( + id=10, + origin_id=1, + width_id=2, + height_id=3, + content="Hello World", + font_config=FontConfig( + family="sans-serif", + size=10.0, + bold=False, + italic=False, + ), + ) + + data = original_box.to_dict() + assert data["id"] == 10 + assert data["type"] == "text_box" + assert data["origin_id"] == 1 + assert data["width_id"] == 2 + assert data["height_id"] == 3 + assert data["content"] == "Hello World" + assert data["font_config"] == { + "font_family": "sans-serif", + "font_size": 10.0, + "bold": False, + "italic": False, + } + + new_box = TextBoxEntity.from_dict(data) + assert isinstance(new_box, TextBoxEntity) + assert new_box.id == original_box.id + assert new_box.origin_id == original_box.origin_id + assert new_box.width_id == original_box.width_id + assert new_box.height_id == original_box.height_id + assert new_box.content == original_box.content + assert new_box.font_config == original_box.font_config + + +def test_text_box_get_point_ids(registry): + """Tests that a text box correctly reports its defining point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + assert set(box.get_point_ids()) == {p1, p2, p3} + + +def test_text_box_get_endpoint_ids(registry): + """Tests that a text box has no endpoints (not a path entity).""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + assert box.get_endpoint_ids() == [] + + +def test_text_box_get_junction_point_ids(registry): + """Tests that a text box has no junction point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + assert box.get_junction_point_ids() == [] + + +def test_text_box_hit_test(registry): + """Tests TextBoxEntity.hit_test method.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + threshold = 5.0 + + # Point inside the text box + assert box.hit_test(2, 2, threshold, registry) is True + assert box.hit_test(5, 5, threshold, registry) is True + + # Point outside the text box + assert box.hit_test(15, 5, threshold, registry) is False + assert box.hit_test(5, 15, threshold, registry) is False + assert box.hit_test(-5, 5, threshold, registry) is False + + +def test_text_box_get_all_frame_point_ids(registry): + """Tests that a text box correctly reports all frame point IDs.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + + assert set(box.get_all_frame_point_ids(registry)) == {p1, p2, p3} + + p4 = registry.add_point(10, 10) + line_id = registry.add_line(p2, p4, construction=True) + box.construction_line_ids = [line_id] + + assert set(box.get_all_frame_point_ids(registry)) == {p1, p2, p3, p4} + + +def test_text_box_update_constrained_status(registry): + """Test TextBoxEntity.update_constrained_status logic.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + + pt1 = registry.get_point(p1) + pt2 = registry.get_point(p2) + pt3 = registry.get_point(p3) + + # Initially unconstrained + pt1.constrained = False + pt2.constrained = False + pt3.constrained = False + box.update_constrained_status(registry, []) + assert box.constrained is False + + # One point constrained + pt1.constrained = True + box.update_constrained_status(registry, []) + assert box.constrained is False + + # Two points constrained + pt2.constrained = True + box.update_constrained_status(registry, []) + assert box.constrained is False + + # All three points constrained + pt3.constrained = True + box.update_constrained_status(registry, []) + assert box.constrained is True + + +@pytest.fixture +def selection_setup(registry): + """Fixture for setting up text box entities for selection tests.""" + rect = (20, 20, 80, 80) + + # Text box fully inside + p_in1 = registry.add_point(30, 30) + p_in2 = registry.add_point(70, 30) + p_in3 = registry.add_point(30, 70) + box_in = registry.get_entity( + registry.add_text_box(p_in1, p_in2, p_in3, "Inside", {}) + ) + + # Text box intersecting + p_cross1 = registry.add_point(10, 50) + p_cross2 = registry.add_point(90, 50) + p_cross3 = registry.add_point(10, 90) + box_cross = registry.get_entity( + registry.add_text_box(p_cross1, p_cross2, p_cross3, "Crossing", {}) + ) + + # Text box outside + p_out1 = registry.add_point(0, 0) + p_out2 = registry.add_point(10, 0) + p_out3 = registry.add_point(0, 10) + box_out = registry.get_entity( + registry.add_text_box(p_out1, p_out2, p_out3, "Outside", {}) + ) + + return ( + registry, + rect, + { + "box_in": box_in, + "box_cross": box_cross, + "box_out": box_out, + }, + ) + + +def test_text_box_is_contained_by(selection_setup): + """Test is_contained_by method for TextBox entities.""" + registry, rect, entities = selection_setup + assert entities["box_in"].is_contained_by(rect, registry) is True + assert entities["box_cross"].is_contained_by(rect, registry) is False + assert entities["box_out"].is_contained_by(rect, registry) is False + + +def test_text_box_intersects_rect(selection_setup): + """Test of intersects_rect method for TextBox entities.""" + registry, rect, entities = selection_setup + assert entities["box_in"].intersects_rect(rect, registry) is True + assert entities["box_cross"].intersects_rect(rect, registry) is True + assert entities["box_out"].intersects_rect(rect, registry) is False + + +def test_text_box_to_geometry(registry): + """Test TextBoxEntity.to_geometry method.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + geo = box.to_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) > 0 + + +def test_text_box_create_text_fill_geometry(registry): + """Test TextBoxEntity.create_text_fill_geometry method.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box = registry.get_entity( + registry.add_text_box(p1, p2, p3, "Test", FontConfig()) + ) + geo = box.create_text_fill_geometry(registry) + assert isinstance(geo, Geometry) + assert len(geo) > 0 + + +def test_text_box_get_set_state(registry): + """Test state capture and restoration for Undo/Redo.""" + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + tb = registry.get_entity(registry.add_text_box(p1, p2, p3, "Test", {})) + + # Verify initial state (Inherited from Entity) + state = tb.get_state() + assert state == {"construction": False, "fill_color": None} + + # Modify state + tb.construction = True + + # Restore state + tb.set_state(state) + assert tb.construction is False + + +def test_text_box_get_fourth_corner_id(registry): + """Tests finding the fourth corner of a text box frame.""" + origin = registry.add_point(0, 0) + width = registry.add_point(100, 0) + height = registry.add_point(0, 50) + p4 = registry.add_point(100, 50) + + line2 = registry.add_line(width, p4) + + box = TextBoxEntity( + id=100, + origin_id=origin, + width_id=width, + height_id=height, + content="Test", + construction_line_ids=[line2], + ) + registry.entities.append(box) + + result = box.get_fourth_corner_id(registry) + + assert result == p4 + + +def test_text_box_get_fourth_corner_id_from_reverse_line(registry): + """Tests finding fourth corner from a line going the other direction.""" + origin = registry.add_point(0, 0) + width = registry.add_point(100, 0) + height = registry.add_point(0, 50) + p4 = registry.add_point(100, 50) + + line2 = registry.add_line(p4, width) + + box = TextBoxEntity( + id=100, + origin_id=origin, + width_id=width, + height_id=height, + content="Test", + construction_line_ids=[line2], + ) + registry.entities.append(box) + + result = box.get_fourth_corner_id(registry) + + assert result == p4 + + +def test_text_box_get_fourth_corner_id_no_construction_lines(registry): + """Tests that None is returned when no construction lines exist.""" + origin = registry.add_point(0, 0) + width = registry.add_point(100, 0) + height = registry.add_point(0, 50) + + box = TextBoxEntity( + id=100, + origin_id=origin, + width_id=width, + height_id=height, + content="Test", + construction_line_ids=[], + ) + registry.entities.append(box) + + result = box.get_fourth_corner_id(registry) + + assert result is None + + +def test_text_box_get_fourth_corner_id_wrong_line(registry): + """Tests that None is returned when no line connects to width point.""" + origin = registry.add_point(0, 0) + width = registry.add_point(100, 0) + height = registry.add_point(0, 50) + other = registry.add_point(200, 200) + + wrong_line = registry.add_line(origin, other) + + box = TextBoxEntity( + id=100, + origin_id=origin, + width_id=width, + height_id=height, + content="Test", + construction_line_ids=[wrong_line], + ) + registry.entities.append(box) + + result = box.get_fourth_corner_id(registry) + + assert result is None + + +def test_text_box_get_fourth_corner_id_ignores_origin_and_height(registry): + """Tests that lines to origin or height are ignored.""" + origin = registry.add_point(0, 0) + width = registry.add_point(100, 0) + height = registry.add_point(0, 50) + + line_to_origin = registry.add_line(width, origin) + line_to_height = registry.add_line(width, height) + + box = TextBoxEntity( + id=100, + origin_id=origin, + width_id=width, + height_id=height, + content="Test", + construction_line_ids=[line_to_origin, line_to_height], + ) + registry.entities.append(box) + + result = box.get_fourth_corner_id(registry) + + assert result is None + + +def _make_text_box(registry, content="Hello"): + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(0, 10) + box_id = registry.add_text_box(p1, p2, p3, content, FontConfig()) + return registry.get_entity(box_id) + + +def test_serialization_preserves_template(registry): + """Template content is preserved through serialization.""" + box = _make_text_box(registry, "Part {width:.1f}x{height:.1f}") + data = box.to_dict() + assert data["content"] == "Part {width:.1f}x{height:.1f}" + + restored = TextBoxEntity.from_dict(data) + assert restored.content == "Part {width:.1f}x{height:.1f}" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/rect.rfs b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/rect.rfs new file mode 100644 index 000000000..05a92ede4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/rect.rfs @@ -0,0 +1 @@ +{"uid": "483e24c6-2a83-483d-8c49-dfb754a1aa69", "type": "sketch", "name": "Rounded Rectangle", "input_parameters": {"title": "Input Parameters", "description": "Parameters that control this sketch's geometry.", "vars": [{"class": "IntVar", "key": "corner_radius", "label": "Corner Radius", "description": "Rounding of the corners", "default": 10, "value": 10, "min_val": null, "max_val": null}, {"class": "FloatVar", "key": "width", "label": "Width", "description": "The width of the rounded rectangle", "default": 100.0, "value": 100.0, "min_val": null, "max_val": null}, {"class": "FloatVar", "key": "height", "label": "Height", "description": "The height of the rectangle", "default": 60.0, "value": 60.0, "min_val": null, "max_val": null}]}, "params": {"expressions": {}}, "registry": {"points": [{"id": 0, "x": 0.0, "y": 0.0, "fixed": true}, {"id": 5, "x": -39.999974799857576, "y": 60.00001846363629, "fixed": false}, {"id": 1, "x": -40.00000000000001, "y": 50.00001846363629, "fixed": false}, {"id": 2, "x": -50.00000000000001, "y": 49.99999312058871, "fixed": false}, {"id": 11, "x": 39.9999789389857, "y": 60.00001846363629, "fixed": false}, {"id": 7, "x": 40.00000000000001, "y": 50.00001846363629, "fixed": false}, {"id": 8, "x": 50.00000000000001, "y": 49.999992806906654, "fixed": false}, {"id": 17, "x": 39.999977259436804, "y": -60.00001846363629, "fixed": false}, {"id": 13, "x": 40.00000000000001, "y": -50.00001846363629, "fixed": false}, {"id": 14, "x": 50.00000000000001, "y": -50.000007193093346, "fixed": false}, {"id": 23, "x": -50.00000000000001, "y": -50.00000687941129, "fixed": false}, {"id": 19, "x": -40.00000000000001, "y": -50.00001846363629, "fixed": false}, {"id": 20, "x": -39.999976479406484, "y": -60.00001846363629, "fixed": false}], "entities": [{"id": 6, "type": "arc", "construction": false, "start_idx": 2, "end_idx": 5, "center_idx": 1, "clockwise": true}, {"id": 12, "type": "arc", "construction": false, "start_idx": 8, "end_idx": 11, "center_idx": 7, "clockwise": false}, {"id": 18, "type": "arc", "construction": false, "start_idx": 14, "end_idx": 17, "center_idx": 13, "clockwise": true}, {"id": 24, "type": "arc", "construction": false, "start_idx": 20, "end_idx": 23, "center_idx": 19, "clockwise": true}, {"id": 25, "type": "line", "construction": false, "p1_idx": 23, "p2_idx": 2}, {"id": 27, "type": "line", "construction": false, "p1_idx": 5, "p2_idx": 11}, {"id": 28, "type": "line", "construction": false, "p1_idx": 8, "p2_idx": 14}, {"id": 29, "type": "line", "construction": false, "p1_idx": 17, "p2_idx": 20}], "id_counter": 30}, "constraints": [{"type": "EqualDistanceConstraint", "p1": 1, "p2": 2, "p3": 1, "p4": 5}, {"type": "EqualDistanceConstraint", "p1": 7, "p2": 8, "p3": 7, "p4": 11}, {"type": "EqualDistanceConstraint", "p1": 13, "p2": 14, "p3": 13, "p4": 17}, {"type": "EqualDistanceConstraint", "p1": 19, "p2": 20, "p3": 19, "p4": 23}, {"type": "HorizontalConstraint", "p1": 5, "p2": 11}, {"type": "HorizontalConstraint", "p1": 17, "p2": 20}, {"type": "VerticalConstraint", "p1": 8, "p2": 14}, {"type": "VerticalConstraint", "p1": 23, "p2": 2}, {"type": "EqualLengthConstraint", "entity_ids": [25, 28]}, {"type": "EqualLengthConstraint", "entity_ids": [27, 29]}, {"type": "EqualLengthConstraint", "entity_ids": [24, 18, 12, 6]}, {"type": "TangentConstraint", "line_id": 27, "shape_id": 12}, {"type": "TangentConstraint", "line_id": 27, "shape_id": 6}, {"type": "TangentConstraint", "line_id": 25, "shape_id": 6}, {"type": "TangentConstraint", "line_id": 28, "shape_id": 12}, {"type": "TangentConstraint", "line_id": 28, "shape_id": 18}, {"type": "TangentConstraint", "line_id": 25, "shape_id": 24}, {"type": "TangentConstraint", "line_id": 29, "shape_id": 24}, {"type": "TangentConstraint", "line_id": 29, "shape_id": 18}, {"type": "RadiusConstraint", "entity_id": 12, "value": 10.0, "expression": "corner_radius"}, {"type": "SymmetryConstraint", "p1": 13, "p2": 1, "center": 0, "axis": null}, {"type": "DistanceConstraint", "p1": 2, "p2": 8, "value": 100.0, "expression": "width"}, {"type": "DistanceConstraint", "p1": 11, "p2": 17, "value": 120.00003692727259}], "origin_id": 0} \ No newline at end of file diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/sketcherapp.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/sketcherapp.py new file mode 100755 index 000000000..f9bad0c45 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/sketcherapp.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python +# flake8: noqa: E402 +import sys +from pathlib import Path + +addon_dir = Path(__file__).parent.parent.parent +sys.path.insert(0, str(addon_dir)) + +project_root = Path(__file__).parents[5] +sys.path.insert(0, str(project_root)) + +import json +import logging + +import gi + +# -- Setup Logging -- +logging.basicConfig( + level=logging.DEBUG, format="[%(levelname)s] %(name)s: %(message)s" +) +logger = logging.getLogger("sketcherapp") + +base_path = Path(__file__).parent + +gi.require_version("Adw", "1") +gi.require_version("Gtk", "4.0") +from gi.repository import Adw, Gio, GLib, Gtk +from sketcher.core import Sketch +from sketcher.ui_gtk.studio import SketchStudio + + +class SketcherApp(Adw.Application): + def __init__(self): + super().__init__(application_id="com.example.SketcherApp") + self.studio: SketchStudio | None = None + self.window: Gtk.ApplicationWindow | None = None + + # Create and register the "quit" action + quit_action = Gio.SimpleAction.new("quit", None) + quit_action.connect("activate", self.on_quit_action) + self.add_action(quit_action) + + # Now, bind the accelerator to the action we just created. + self.set_accels_for_action("app.quit", ["q"]) + + def on_quit_action(self, action, param): + """Handler for the 'quit' action.""" + self.quit() + + def do_activate(self): + self.window = Gtk.ApplicationWindow(application=self) + self.window.set_default_size(1200, 800) + + # Initialize Studio + self.studio = SketchStudio(parent_window=self.window) + self.window.set_child(self.studio) + + # --- Add Open/Save Buttons --- + btn_open = Gtk.Button(label="Open...") + btn_open.set_tooltip_text("Open Sketch from File") + btn_open.connect("clicked", self.on_open_clicked) + self.studio.insert_child_after(btn_open, None) + + btn_save = Gtk.Button(label="Save...") + btn_save.set_tooltip_text("Save Sketch to File") + btn_save.connect("clicked", self.on_save_clicked) + self.studio.insert_child_after(btn_save, btn_open) + # --- End of added buttons --- + + # Connect signals for testing + self.studio.finished.connect(self.on_studio_finished) + self.studio.cancelled.connect(self.on_studio_cancelled) + + # Setup initial Element via Studio + self.add_initial_sketch() + + self.window.present() + + # Ensure the canvas has focus to receive key events immediately. + if self.studio and self.studio.canvas: + self.studio.canvas.grab_focus() + + def on_open_clicked(self, widget): + """Handles opening a sketch from a file using Gtk.FileDialog.""" + dialog = Gtk.FileDialog.new() + dialog.set_title("Open Sketch") + + filter_rfs = Gtk.FileFilter.new() + filter_rfs.set_name("RayForge Sketch Files") + filter_rfs.add_pattern("*.rfs") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(filter_rfs) + + dialog.set_filters(filters) + dialog.set_default_filter(filter_rfs) + + dialog.open(self.window, None, self._on_open_dialog_finish) + + def _on_open_dialog_finish(self, dialog, result): + try: + file = dialog.open_finish(result) + if file: + path = file.get_path() + if path and self.studio: + logger.info(f"Loading sketch from: {path}") + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + new_sketch = Sketch.from_dict(data) + self.studio.set_sketch(new_sketch) + except (OSError, ValueError, TypeError) as e: + logger.error( + f"Failed to load sketch file '{path}': {e}" + ) + except GLib.Error as e: + # This catches user cancellation + logger.debug(f"File open dialog cancelled: {e.message}") + + def on_save_clicked(self, widget): + """Handles saving the current sketch to a file using Gtk.FileDialog.""" + if not self.studio or not self.studio.canvas.sketch_element: + return + + dialog = Gtk.FileDialog.new() + dialog.set_title("Save Sketch") + dialog.set_initial_name("sketch.rfs") + + filter_rfs = Gtk.FileFilter.new() + filter_rfs.set_name("RayForge Sketch Files") + filter_rfs.add_pattern("*.rfs") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(filter_rfs) + + dialog.set_filters(filters) + dialog.set_default_filter(filter_rfs) + + dialog.save(self.window, None, self._on_save_dialog_finish) + + def _on_save_dialog_finish(self, dialog, result): + try: + file = dialog.save_finish(result) + if file: + path = file.get_path() + if path and self.studio and self.studio.canvas.sketch_element: + if not path.lower().endswith(".rfs"): + path += ".rfs" + logger.info(f"Saving sketch to: {path}") + try: + current_sketch = ( + self.studio.canvas.sketch_element.sketch + ) + data = current_sketch.to_dict() + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except (OSError, ValueError, TypeError) as e: + logger.error(f"Failed to save sketch to '{path}': {e}") + except GLib.Error as e: + logger.debug(f"File save dialog cancelled: {e.message}") + + def add_initial_sketch(self): + """Creates and adds the first sketch with demo geometry.""" + if not self.studio: + return + + sketch = Sketch() + origin_id = sketch.origin_id + + # Build a floating shape first + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(200, 0) + p3 = sketch.add_point(100, 150) + + # Then, constrain it to the origin + sketch.constrain_coincident(p1, origin_id) + + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + sketch.add_line(p3, p1) + + sketch.constrain_distance(p1, p2, 200.0) + sketch.constrain_horizontal(p1, p2) + + # Add a construction line for reference + mid_p = sketch.add_point(100, 0) + if sketch.registry.entities: + line_id = sketch.registry.entities[0].id + sketch.constrain_point_on_line(mid_p, line_id) + + sketch.add_line(mid_p, p3, construction=True) + sketch.constrain_vertical(mid_p, p3) + + sketch.solve() + + # Load the sketch into the studio + self.studio.set_sketch(sketch) + + def on_studio_finished(self, sender, sketch): + print( + "Studio Finished! Sketch has " + f"{len(sketch.registry.entities)} entities." + ) + + if not self.studio: + return + + # Reset with a fresh sketch to demonstrate lifecycle + new_sketch = Sketch() + print("Resetting studio with empty sketch...") + self.studio.set_sketch(new_sketch) + + def on_studio_cancelled(self, sender): + print("Studio Cancelled!") + + if not self.studio: + return + + # Reset with a fresh sketch to demonstrate lifecycle + new_sketch = Sketch() + print("Resetting studio with empty sketch...") + self.studio.set_sketch(new_sketch) + + +if __name__ == "__main__": + app = SketcherApp() + app.run([]) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_centers.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_centers.py new file mode 100644 index 000000000..4dbf3aa6d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_centers.py @@ -0,0 +1,360 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.producers.centers import CentersProducer +from sketcher.core.snap.types import DragContext, SnapLineType + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +@pytest.fixture +def producer(): + """Create a CentersProducer for testing.""" + return CentersProducer() + + +def test_centers_producer_initialization_default(): + """Tests CentersProducer initialization with defaults.""" + producer = CentersProducer() + assert producer._include_construction is True + + +def test_centers_producer_initialization_custom(): + """Tests CentersProducer initialization with custom settings.""" + producer = CentersProducer(include_construction=False) + assert producer._include_construction is False + + +def test_centers_producer_produce_circle_center( + producer, registry, drag_context +): + """Tests producing snap lines from circle center.""" + center_point = registry.add_point(10.0, 20.0) + radius_point = registry.add_point(20.0, 20.0) + registry.add_circle(center_point, radius_point) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + horizontal_lines = [sl for sl in snap_lines if sl.is_horizontal] + vertical_lines = [sl for sl in snap_lines if not sl.is_horizontal] + assert len(horizontal_lines) == 1 + assert len(vertical_lines) == 1 + assert horizontal_lines[0].coordinate == 20.0 + assert vertical_lines[0].coordinate == 10.0 + + +def test_centers_producer_produce_arc_center(producer, registry, drag_context): + """Tests producing snap lines from arc center.""" + start_point = registry.add_point(20.0, 20.0) + end_point = registry.add_point(20.0, 30.0) + center_point = registry.add_point(10.0, 20.0) + registry.add_arc(start_point, end_point, center_point) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + +def test_centers_producer_produce_ellipse_center( + producer, registry, drag_context +): + """Tests producing snap lines from ellipse center.""" + center_point = registry.add_point(10.0, 20.0) + radius_x_point = registry.add_point(20.0, 20.0) + radius_y_point = registry.add_point(10.0, 30.0) + registry.add_ellipse(center_point, radius_x_point, radius_y_point) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + +def test_centers_producer_produce_points(producer, registry, drag_context): + """Tests producing snap points from entity centers.""" + center_point = registry.add_point(10.0, 20.0) + radius_point = registry.add_point(20.0, 20.0) + registry.add_circle(center_point, radius_point) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == 10.0 + assert snap_points[0].y == 20.0 + assert snap_points[0].line_type == SnapLineType.CENTER + + +def test_centers_producer_dragged_entity_excluded( + producer, registry, drag_context +): + """Tests that dragged entities are excluded from snap generation.""" + center_point = registry.add_point(10.0, 20.0) + radius_point = registry.add_point(20.0, 20.0) + circle_id = registry.add_circle(center_point, radius_point) + + drag_context.dragged_entity_ids.add(circle_id) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_centers_producer_construction_excluded( + producer, registry, drag_context +): + """Tests that construction entities are excluded when configured.""" + producer = CentersProducer(include_construction=False) + + center_point_id = registry.add_point(10.0, 20.0) + radius_point_id = registry.add_point(20.0, 20.0) + circle_id = registry.add_circle(center_point_id, radius_point_id) + registry.get_entity(circle_id).construction = True + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + +def test_centers_producer_construction_included( + producer, registry, drag_context +): + """Tests that construction entities are included when configured.""" + center_point_id = registry.add_point(10.0, 20.0) + radius_point_id = registry.add_point(20.0, 20.0) + circle_id = registry.add_circle(center_point_id, radius_point_id) + registry.get_entity(circle_id).construction = True + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + +def test_centers_producer_outside_threshold(producer, registry, drag_context): + """Tests that centers outside threshold don't produce snaps.""" + center_point = registry.add_point(100.0, 200.0) + radius_point = registry.add_point(110.0, 200.0) + registry.add_circle(center_point, radius_point) + + drag_position = (10.0, 20.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_centers_producer_source_attribute(producer, registry, drag_context): + """Tests that source attribute is set to the entity.""" + center_point_id = registry.add_point(10.0, 20.0) + radius_point_id = registry.add_point(20.0, 20.0) + circle_id = registry.add_circle(center_point_id, radius_point_id) + circle = registry.get_entity(circle_id) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert all(sl.source == circle for sl in snap_lines) + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert snap_points[0].source == circle + + +def test_centers_producer_multiple_entities(producer, registry, drag_context): + """Tests producer with multiple entities.""" + center1_id = registry.add_point(10.0, 20.0) + radius1_id = registry.add_point(20.0, 20.0) + registry.add_circle(center1_id, radius1_id) + + center2_id = registry.add_point(30.0, 40.0) + radius2_id = registry.add_point(40.0, 40.0) + registry.add_circle(center2_id, radius2_id) + + drag_position = (15.0, 25.0) + threshold = 10.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_centers_producer_line_entity_no_snap( + producer, registry, drag_context +): + """Tests that line entities don't produce center snaps.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_centers_producer_negative_coordinates( + producer, registry, drag_context +): + """Tests snap generation with negative coordinates.""" + center_point = registry.add_point(-10.0, -20.0) + radius_point = registry.add_point(0.0, -20.0) + registry.add_circle(center_point, radius_point) + + drag_position = (-8.0, -18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == -10.0 + assert snap_points[0].y == -20.0 + + +def test_centers_producer_empty_registry(producer, registry, drag_context): + """Tests producer with empty registry.""" + drag_position = (10.0, 20.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_centers_producer_exact_match(producer, registry, drag_context): + """Tests snap generation when drag position exactly matches center.""" + center_point = registry.add_point(10.0, 20.0) + radius_point = registry.add_point(20.0, 20.0) + registry.add_circle(center_point, radius_point) + + drag_position = (10.0, 20.0) + threshold = 0.1 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == 10.0 + assert snap_points[0].y == 20.0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_entity_points.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_entity_points.py new file mode 100644 index 000000000..b3f9b6eff --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_entity_points.py @@ -0,0 +1,327 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.producers.entity_points import EntityPointsProducer +from sketcher.core.snap.types import DragContext, SnapLineType + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +@pytest.fixture +def producer(): + """Create an EntityPointsProducer for testing.""" + return EntityPointsProducer() + + +def test_entity_points_producer_produce_horizontal_lines( + producer, registry, drag_context +): + """Tests producing horizontal snap lines from entity points.""" + registry.add_point(10.0, 20.0) + registry.add_point(30.0, 20.0) + registry.add_point(50.0, 40.0) + + drag_position = (15.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 3 + horizontal_lines = [sl for sl in snap_lines if sl.is_horizontal] + assert len(horizontal_lines) == 2 + assert all( + sl.line_type == SnapLineType.ENTITY_POINT for sl in horizontal_lines + ) + assert all(sl.coordinate == 20.0 for sl in horizontal_lines) + + +def test_entity_points_producer_produce_vertical_lines( + producer, registry, drag_context +): + """Tests producing vertical snap lines from entity points.""" + registry.add_point(10.0, 20.0) + registry.add_point(10.0, 40.0) + registry.add_point(30.0, 50.0) + + drag_position = (12.0, 30.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + assert all(not sl.is_horizontal for sl in snap_lines) + assert all(sl.line_type == SnapLineType.ENTITY_POINT for sl in snap_lines) + assert all(sl.coordinate == 10.0 for sl in snap_lines) + + +def test_entity_points_producer_produce_both_axes( + producer, registry, drag_context +): + """Tests producing both horizontal and vertical snap lines.""" + registry.add_point(10.0, 20.0) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + horizontal_lines = [sl for sl in snap_lines if sl.is_horizontal] + vertical_lines = [sl for sl in snap_lines if not sl.is_horizontal] + assert len(horizontal_lines) == 1 + assert len(vertical_lines) == 1 + assert horizontal_lines[0].coordinate == 20.0 + assert vertical_lines[0].coordinate == 10.0 + + +def test_entity_points_producer_outside_threshold( + producer, registry, drag_context +): + """Tests that points outside threshold don't produce snap lines.""" + registry.add_point(10.0, 20.0) + registry.add_point(100.0, 200.0) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + +def test_entity_points_produce_points(producer, registry, drag_context): + """Tests producing snap points from entity points.""" + registry.add_point(10.0, 20.0) + registry.add_point(12.0, 22.0) + registry.add_point(100.0, 200.0) + + drag_position = (11.0, 21.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 2 + assert all(sp.line_type == SnapLineType.ENTITY_POINT for sp in snap_points) + assert (10.0, 20.0) in [(sp.x, sp.y) for sp in snap_points] + assert (12.0, 22.0) in [(sp.x, sp.y) for sp in snap_points] + + +def test_entity_points_producer_dragged_point_excluded( + producer, registry, drag_context +): + """Tests that dragged points are excluded from snap generation.""" + p1_id = registry.add_point(10.0, 20.0) + registry.add_point(12.0, 18.0) + + drag_context.dragged_point_ids.add(p1_id) + + drag_position = (11.0, 19.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == 12.0 + assert snap_points[0].y == 18.0 + + +def test_entity_points_producer_source_attribute( + producer, registry, drag_context +): + """Tests that source attribute is set to the point entity.""" + point_id = registry.add_point(10.0, 20.0) + point = registry.get_point(point_id) + + drag_position = (12.0, 18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert all(sl.source == point for sl in snap_lines) + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert all(sp.source == point for sp in snap_points) + + +def test_entity_points_producer_empty_registry( + producer, registry, drag_context +): + """Tests producer with empty registry.""" + drag_position = (10.0, 20.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + assert len(snap_lines) == 0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + assert len(snap_points) == 0 + + +def test_entity_points_producer_exact_match(producer, registry, drag_context): + """Tests snap generation when drag position exactly matches point.""" + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 20.0) + threshold = 0.1 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == 10.0 + assert snap_points[0].y == 20.0 + + +def test_entity_points_producer_multiple_points( + producer, registry, drag_context +): + """Tests producer with multiple points at different locations.""" + registry.add_point(10.0, 20.0) + registry.add_point(30.0, 20.0) + registry.add_point(10.0, 40.0) + registry.add_point(30.0, 40.0) + + drag_position = (15.0, 25.0) + threshold = 10.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 4 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_entity_points_producer_threshold_boundary( + producer, registry, drag_context +): + """Tests snap generation at threshold boundary.""" + registry.add_point(10.0, 20.0) + + drag_position = (15.0, 20.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + drag_position = (15.1, 20.0) + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 1 + + +def test_entity_points_producer_negative_coordinates( + producer, registry, drag_context +): + """Tests snap generation with negative coordinates.""" + registry.add_point(-10.0, -20.0) + registry.add_point(10.0, 20.0) + + drag_position = (-8.0, -18.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == -10.0 + assert snap_points[0].y == -20.0 + + +def test_entity_points_producer_all_dragged(producer, registry, drag_context): + """Tests producer when all points are dragged.""" + p1_id = registry.add_point(10.0, 20.0) + p2_id = registry.add_point(30.0, 40.0) + + drag_context.dragged_point_ids.update([p1_id, p2_id]) + + drag_position = (15.0, 25.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_equidistant.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_equidistant.py new file mode 100644 index 000000000..e0946ca12 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_equidistant.py @@ -0,0 +1,475 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.producers.equidistant import EquidistantLinesProducer +from sketcher.core.snap.types import DragContext, SnapLineType + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +@pytest.fixture +def producer(): + """Create an EquidistantLinesProducer for testing.""" + return EquidistantLinesProducer() + + +def test_equidistant_producer_initialization_defaults(): + """Tests EquidistantLinesProducer initialization with defaults.""" + producer = EquidistantLinesProducer() + assert producer._spacing_tolerance == 0.5 + assert producer._max_spacing == 100.0 + assert producer._include_construction is True + + +def test_equidistant_producer_initialization_custom(): + """Tests EquidistantLinesProducer initialization with custom settings.""" + producer = EquidistantLinesProducer( + spacing_tolerance=1.0, max_spacing=50.0, include_construction=False + ) + assert producer._spacing_tolerance == 1.0 + assert producer._max_spacing == 50.0 + assert producer._include_construction is False + + +def test_equidistant_producer_no_snap_lines(producer, registry, drag_context): + """Tests that producer doesn't generate snap lines.""" + registry.add_point(0.0, 0.0) + registry.add_point(0.0, 10.0) + registry.add_point(0.0, 20.0) + + drag_position = (0.0, 5.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + +def test_equidistant_vertical_pattern(producer, registry, drag_context): + """Tests detecting equidistant vertical pattern.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 10.0) < 0.001 + assert abs(snap_points[0].y - 30.0) < 0.001 + assert snap_points[0].line_type == SnapLineType.EQUIDISTANT + assert snap_points[0].is_horizontal is True + + +def test_equidistant_horizontal_pattern(producer, registry, drag_context): + """Tests detecting equidistant horizontal pattern.""" + registry.add_point(0.0, 10.0) + registry.add_point(10.0, 10.0) + registry.add_point(20.0, 10.0) + + drag_position = (30.0, 10.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 30.0) < 0.001 + assert abs(snap_points[0].y - 10.0) < 0.001 + assert snap_points[0].line_type == SnapLineType.EQUIDISTANT + assert snap_points[0].is_horizontal is False + + +def test_equidistant_spacing_attribute(producer, registry, drag_context): + """Tests that spacing attribute is set correctly.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].spacing - 10.0) < 0.001 + + +def test_equidistant_pattern_coords(producer, registry, drag_context): + """Tests that pattern_coords includes all pattern points.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].pattern_coords is not None + assert len(snap_points[0].pattern_coords) == 4 + + +def test_equidistant_axis_coord(producer, registry, drag_context): + """Tests that axis_coord is set correctly.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].axis_coord - 10.0) < 0.001 + + +def test_equidistant_insufficient_points(producer, registry, drag_context): + """Tests that fewer than 2 points don't produce snaps.""" + registry.add_point(10.0, 0.0) + + drag_position = (10.0, 10.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_equidistant_irregular_spacing(producer, registry, drag_context): + """Tests irregular spacing can produce snaps based on partial patterns.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 5.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) >= 1 + + +def test_equidistant_outside_threshold(producer, registry, drag_context): + """Tests that snaps outside threshold don't produce results.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 50.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_equidistant_max_spacing_exceeded(producer, registry, drag_context): + """Tests that spacings exceeding max_spacing are ignored.""" + producer = EquidistantLinesProducer(max_spacing=5.0) + + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 20.0) + registry.add_point(10.0, 40.0) + + drag_position = (10.0, 60.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_equidistant_dragged_point_excluded(producer, registry, drag_context): + """Tests that dragged points are excluded from pattern.""" + registry.add_point(10.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_context.dragged_point_ids.add(p2) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_equidistant_threshold_for_alignment(producer, registry, drag_context): + """Tests that threshold applies to point alignment.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.1, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_equidistant_tolerance_for_spacing(producer, registry, drag_context): + """Tests that spacing tolerance allows near-equal spacing.""" + producer = EquidistantLinesProducer(spacing_tolerance=1.0) + + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.5) + + drag_position = (10.0, 30.5) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) >= 1 + + +def test_equidistant_backward_extension(producer, registry, drag_context): + """Tests extending pattern backwards.""" + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + registry.add_point(10.0, 30.0) + + drag_position = (10.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].y - 0.0) < 0.001 + + +def test_equidistant_middle_of_pattern(producer, registry, drag_context): + """Tests detecting position in middle of pattern.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 30.0) + registry.add_point(10.0, 40.0) + + drag_position = (10.0, 20.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].y - 20.0) < 0.001 + + +def test_equidistant_negative_coordinates(producer, registry, drag_context): + """Tests snap generation with negative coordinates.""" + registry.add_point(10.0, -20.0) + registry.add_point(10.0, -10.0) + registry.add_point(10.0, 0.0) + + drag_position = (10.0, 10.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].y - 10.0) < 0.001 + + +def test_equidistant_empty_registry(producer, registry, drag_context): + """Tests producer with empty registry.""" + drag_position = (10.0, 10.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_equidistant_single_pattern(producer, registry, drag_context): + """Tests detection of a single equidistant pattern.""" + registry.add_point(0.0, 10.0) + registry.add_point(10.0, 10.0) + registry.add_point(20.0, 10.0) + + drag_position = (30.0, 10.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 30.0) < 0.001 + + +def test_equidistant_multiple_patterns(producer, registry, drag_context): + """Tests detection of equidistant pattern with merged columns.""" + registry.add_point(0.0, 0.0) + registry.add_point(0.0, 10.0) + registry.add_point(0.0, 20.0) + + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (5.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_equidistant_min_spacing_requirement(producer, registry, drag_context): + """Tests that spacing smaller than 1e-6 is ignored.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 1e-9) + registry.add_point(10.0, 2e-9) + + drag_position = (10.0, 3e-9) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_equidistant_duplicate_points(producer, registry, drag_context): + """Tests that duplicate points don't break pattern detection.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_equidistant_exact_match(producer, registry, drag_context): + """Tests snap generation at exact pattern position.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(10.0, 20.0) + + drag_position = (10.0, 30.0) + threshold = 0.1 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].y - 30.0) < 0.001 + + +def test_equidistant_non_aligned_points(producer, registry, drag_context): + """Tests non-aligned points excluded, aligned subset can form pattern.""" + registry.add_point(10.0, 0.0) + registry.add_point(10.0, 10.0) + registry.add_point(20.0, 20.0) + + drag_position = (10.0, 20.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_intersections.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_intersections.py new file mode 100644 index 000000000..3b2f1b1ab --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_intersections.py @@ -0,0 +1,537 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.producers.intersections import IntersectionsProducer +from sketcher.core.snap.types import DragContext + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +@pytest.fixture +def producer(): + """Create an IntersectionsProducer for testing.""" + return IntersectionsProducer() + + +def test_intersections_producer_initialization_default(): + """Tests IntersectionsProducer initialization with defaults.""" + producer = IntersectionsProducer() + assert producer._include_construction is True + + +def test_intersections_producer_initialization_custom(): + """Tests IntersectionsProducer initialization with custom settings.""" + producer = IntersectionsProducer(include_construction=False) + assert producer._include_construction is False + + +def test_line_line_intersection(producer, registry, drag_context): + """Tests finding intersection of two lines.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + registry.add_line(p3, p4) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 2 + horizontal_lines = [sl for sl in snap_lines if sl.is_horizontal] + vertical_lines = [sl for sl in snap_lines if not sl.is_horizontal] + assert len(horizontal_lines) == 1 + assert len(vertical_lines) == 1 + assert horizontal_lines[0].coordinate == 5.0 + assert vertical_lines[0].coordinate == 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 5.0) < 0.001 + assert abs(snap_points[0].y - 5.0) < 0.001 + + +def test_line_circle_intersection(producer, registry, drag_context): + """Tests finding intersection of line and circle.""" + p1 = registry.add_point(0.0, 5.0) + p2 = registry.add_point(10.0, 5.0) + registry.add_line(p1, p2) + + center = registry.add_point(5.0, 5.0) + radius_pt = registry.add_point(10.0, 5.0) + registry.add_circle(center, radius_pt) + + drag_position = (10.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_line_arc_intersection(producer, registry, drag_context): + """Tests finding intersection of line and arc.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + center = registry.add_point(5.0, 0.0) + start = registry.add_point(10.0, 0.0) + end = registry.add_point(0.0, 0.0) + registry.add_arc(start, end, center) + + drag_position = (10.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_circle_circle_intersection(producer, registry, drag_context): + """Tests finding intersection of two circles.""" + center1 = registry.add_point(0.0, 0.0) + radius1 = registry.add_point(5.0, 0.0) + registry.add_circle(center1, radius1) + + center2 = registry.add_point(5.0, 0.0) + radius2 = registry.add_point(10.0, 0.0) + registry.add_circle(center2, radius2) + + drag_position = (5.0, 4.0) + threshold = 10.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 2 + + +def test_arc_arc_intersection(producer, registry, drag_context): + """Concentric arcs (same center, same radius) produce no intersections.""" + center1 = registry.add_point(0.0, 0.0) + start1 = registry.add_point(5.0, 0.0) + end1 = registry.add_point(0.0, 5.0) + registry.add_arc(start1, end1, center1) + + center2 = registry.add_point(0.0, 0.0) + start2 = registry.add_point(0.0, 5.0) + end2 = registry.add_point(-5.0, 0.0) + registry.add_arc(start2, end2, center2) + + drag_position = (0.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_parallel_lines_no_intersection(producer, registry, drag_context): + """Tests that parallel lines don't produce intersections.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 5.0) + p4 = registry.add_point(10.0, 5.0) + registry.add_line(p3, p4) + + drag_position = (5.0, 2.5) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_outside_segment_no_intersection(producer, registry, drag_context): + """Tests that line intersections outside segments don't produce snaps.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(15.0, 5.0) + p4 = registry.add_point(25.0, -5.0) + registry.add_line(p3, p4) + + drag_position = (5.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_dragged_entity_excluded(producer, registry, drag_context): + """Tests that dragged entities are excluded.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + line2 = registry.add_line(p3, p4) + + drag_context.dragged_entity_ids.add(line2) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_dragged_point_excluded(producer, registry, drag_context): + """Tests that entities with dragged points are excluded.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + registry.add_line(p3, p4) + + drag_context.dragged_point_ids.add(p3) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_construction_excluded(producer, registry, drag_context): + """Tests that construction entities are excluded when configured.""" + producer = IntersectionsProducer(include_construction=False) + + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + line2_id = registry.add_line(p3, p4) + registry.get_entity(line2_id).construction = True + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_construction_included(producer, registry, drag_context): + """Tests that construction entities are included when configured.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + line1_id = registry.add_line(p1, p2) + registry.get_entity(line1_id).construction = True + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + line2_id = registry.add_line(p3, p4) + registry.get_entity(line2_id).construction = True + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_outside_threshold(producer, registry, drag_context): + """Tests that intersections outside threshold don't produce snaps.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + registry.add_line(p3, p4) + + drag_position = (100.0, 100.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_multiple_intersections(producer, registry, drag_context): + """Tests producer with multiple entity pairs.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + registry.add_line(p3, p4) + + p5 = registry.add_point(-5.0, 5.0) + p6 = registry.add_point(15.0, 5.0) + registry.add_line(p5, p6) + + drag_position = (5.0, 5.0) + threshold = 10.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) >= 2 + + +def test_tangent_line_circle(producer, registry, drag_context): + """Tests line tangent to circle.""" + p1 = registry.add_point(0.0, 5.0) + p2 = registry.add_point(10.0, 5.0) + registry.add_line(p1, p2) + + center = registry.add_point(5.0, 0.0) + radius_pt = registry.add_point(5.0, 5.0) + registry.add_circle(center, radius_pt) + + drag_position = (5.0, 5.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_circle_tangent_intersection(producer, registry, drag_context): + """Tests circles tangent at one point.""" + center1 = registry.add_point(0.0, 0.0) + radius1 = registry.add_point(5.0, 0.0) + registry.add_circle(center1, radius1) + + center2 = registry.add_point(10.0, 0.0) + radius2 = registry.add_point(15.0, 0.0) + registry.add_circle(center2, radius2) + + drag_position = (5.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_no_circles_overlap(producer, registry, drag_context): + """Tests non-overlapping circles.""" + center1 = registry.add_point(0.0, 0.0) + radius1 = registry.add_point(5.0, 0.0) + registry.add_circle(center1, radius1) + + center2 = registry.add_point(20.0, 0.0) + radius2 = registry.add_point(25.0, 0.0) + registry.add_circle(center2, radius2) + + drag_position = (5.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_circle_inside_circle(producer, registry, drag_context): + """Tests one circle inside another.""" + center1 = registry.add_point(0.0, 0.0) + radius1 = registry.add_point(10.0, 0.0) + registry.add_circle(center1, radius1) + + center2 = registry.add_point(0.0, 0.0) + radius2 = registry.add_point(5.0, 0.0) + registry.add_circle(center2, radius2) + + drag_position = (5.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_zero_radius_circle(producer, registry, drag_context): + """Tests that zero radius circle doesn't produce intersections.""" + center1 = registry.add_point(0.0, 0.0) + radius1 = registry.add_point(0.0, 0.0) + registry.add_circle(center1, radius1) + + p1 = registry.add_point(-5.0, 0.0) + p2 = registry.add_point(5.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (0.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_empty_registry(producer, registry, drag_context): + """Tests producer with empty registry.""" + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_single_entity_no_intersection(producer, registry, drag_context): + """Tests that single entity doesn't produce intersections.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_intersection_source_attribute(producer, registry, drag_context): + """Tests that intersection snap points don't have source set.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(0.0, 10.0) + p4 = registry.add_point(10.0, 0.0) + registry.add_line(p3, p4) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert snap_points[0].source is None + + +def test_arc_intersection_outside_sweep(producer, registry, drag_context): + """Tests that arc intersections outside sweep don't produce snaps.""" + center = registry.add_point(0.0, 0.0) + start = registry.add_point(10.0, 0.0) + end = registry.add_point(0.0, 10.0) + registry.add_arc(start, end, center) + + p1 = registry.add_point(5.0, -5.0) + p2 = registry.add_point(5.0, 15.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 0.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_midpoints.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_midpoints.py new file mode 100644 index 000000000..4d2b916ef --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_midpoints.py @@ -0,0 +1,337 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.producers.midpoints import MidpointsProducer +from sketcher.core.snap.types import DragContext, SnapLineType + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +@pytest.fixture +def producer(): + """Create a MidpointsProducer for testing.""" + return MidpointsProducer() + + +def test_midpoints_producer_no_snap_lines(producer, registry, drag_context): + """Tests that producer doesn't generate snap lines.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + +def test_midpoints_producer_line_midpoint(producer, registry, drag_context): + """Tests producing midpoint from a line.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 5.0) < 0.001 + assert abs(snap_points[0].y - 5.0) < 0.001 + assert snap_points[0].line_type == SnapLineType.MIDPOINT + + +def test_midpoints_producer_arc_midpoint(producer, registry, drag_context): + """Tests producing midpoint from an arc.""" + center = registry.add_point(0.0, 0.0) + start = registry.add_point(10.0, 0.0) + end = registry.add_point(0.0, 10.0) + registry.add_arc(start, end, center) + + drag_position = (7.07, 7.07) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 7.07) < 0.1 + assert abs(snap_points[0].y - 7.07) < 0.1 + assert snap_points[0].line_type == SnapLineType.MIDPOINT + + +def test_midpoints_producer_dragged_entity_excluded( + producer, registry, drag_context +): + """Tests that dragged entities are excluded from snap generation.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + line_id = registry.add_line(p1, p2) + + drag_context.dragged_entity_ids.add(line_id) + + drag_position = (5.0, 5.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_midpoints_producer_dragged_point_excluded( + producer, registry, drag_context +): + """Tests that entities with dragged points are excluded.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_context.dragged_point_ids.add(p1) + + drag_position = (5.0, 5.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_midpoints_producer_outside_threshold( + producer, registry, drag_context +): + """Tests that midpoints outside threshold don't produce snaps.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (50.0, 50.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_midpoints_producer_multiple_lines(producer, registry, drag_context): + """Tests producer with multiple lines.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + p3 = registry.add_point(20.0, 20.0) + p4 = registry.add_point(30.0, 30.0) + registry.add_line(p3, p4) + + drag_position = (5.0, 5.0) + threshold = 20.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + +def test_midpoints_producer_source_attribute(producer, registry, drag_context): + """Tests that source attribute is set to the entity.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + line_id = registry.add_line(p1, p2) + line = registry.get_entity(line_id) + + drag_position = (5.0, 5.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert snap_points[0].source == line + + +def test_midpoints_producer_negative_coordinates( + producer, registry, drag_context +): + """Tests snap generation with negative coordinates.""" + p1 = registry.add_point(-10.0, -10.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (0.0, 0.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x) < 0.001 + assert abs(snap_points[0].y) < 0.001 + + +def test_midpoints_producer_empty_registry(producer, registry, drag_context): + """Tests producer with empty registry.""" + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_midpoints_producer_line_midpoint_calculation( + producer, registry, drag_context +): + """Tests correct midpoint calculation for various lines.""" + test_cases = [ + ((0.0, 0.0), (10.0, 0.0), (5.0, 0.0)), + ((0.0, 0.0), (0.0, 10.0), (0.0, 5.0)), + ((-5.0, -5.0), (5.0, 5.0), (0.0, 0.0)), + ] + + for (x1, y1), (x2, y2), (expected_x, expected_y) in test_cases: + test_registry = EntityRegistry() + p1 = test_registry.add_point(x1, y1) + p2 = test_registry.add_point(x2, y2) + test_registry.add_line(p1, p2) + + drag_position = (expected_x, expected_y) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + test_registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - expected_x) < 0.001 + assert abs(snap_points[0].y - expected_y) < 0.001 + + +def test_midpoints_producer_exact_match(producer, registry, drag_context): + """Tests snap generation when drag position exactly matches midpoint.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 0.1 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 5.0) < 0.001 + assert abs(snap_points[0].y - 5.0) < 0.001 + + +def test_midpoints_producer_non_midpoint_entities( + producer, registry, drag_context +): + """Tests that non-midpoint entities don't produce snaps.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + center = registry.add_point(5.0, 5.0) + radius = registry.add_point(10.0, 5.0) + registry.add_circle(center, radius) + + drag_position = (7.5, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].line_type == SnapLineType.MIDPOINT + + +def test_midpoints_producer_horizontal_line(producer, registry, drag_context): + """Tests midpoint of horizontal line.""" + p1 = registry.add_point(0.0, 10.0) + p2 = registry.add_point(20.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (10.0, 10.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == 10.0 + assert snap_points[0].y == 10.0 + + +def test_midpoints_producer_vertical_line(producer, registry, drag_context): + """Tests midpoint of vertical line.""" + p1 = registry.add_point(10.0, 0.0) + p2 = registry.add_point(10.0, 20.0) + registry.add_line(p1, p2) + + drag_position = (10.0, 10.0) + threshold = 1.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert snap_points[0].x == 10.0 + assert snap_points[0].y == 10.0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_on_entity.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_on_entity.py new file mode 100644 index 000000000..e725dede9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/producers/test_producer_on_entity.py @@ -0,0 +1,433 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.producers.on_entity import OnEntityProducer +from sketcher.core.snap.types import DragContext, SnapLineType + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +@pytest.fixture +def producer(): + """Create an OnEntityProducer for testing.""" + return OnEntityProducer() + + +def test_on_entity_producer_no_snap_lines(producer, registry, drag_context): + """Tests that producer doesn't generate snap lines.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_lines = list( + producer.produce(registry, drag_position, drag_context, threshold) + ) + + assert len(snap_lines) == 0 + + +def test_on_entity_producer_line_snap(producer, registry, drag_context): + """Tests snapping to a line.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 5.0) < 0.001 + assert abs(snap_points[0].y - 0.0) < 0.001 + assert snap_points[0].line_type == SnapLineType.ON_ENTITY + + +def test_on_entity_producer_line_endpoint_snap( + producer, registry, drag_context +): + """Tests snapping to line endpoint.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (10.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 10.0) < 0.001 + assert abs(snap_points[0].y - 0.0) < 0.001 + + +def test_on_entity_producer_circle_snap(producer, registry, drag_context): + """Tests snapping to a circle.""" + center = registry.add_point(10.0, 10.0) + radius_pt = registry.add_point(20.0, 10.0) + registry.add_circle(center, radius_pt) + + drag_position = (20.0, 15.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 18.94) < 0.01 + assert abs(snap_points[0].y - 14.47) < 0.01 + + +def test_on_entity_producer_arc_snap(producer, registry, drag_context): + """Tests snapping to an arc.""" + center = registry.add_point(0.0, 0.0) + start = registry.add_point(10.0, 0.0) + end = registry.add_point(0.0, 10.0) + registry.add_arc(start, end, center) + + drag_position = (7.07, 7.07) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 7.07) < 0.1 + assert abs(snap_points[0].y - 7.07) < 0.1 + + +def test_on_entity_producer_arc_outside_sweep( + producer, registry, drag_context +): + """Tests that points outside arc sweep don't produce snaps.""" + center = registry.add_point(0.0, 0.0) + start = registry.add_point(10.0, 0.0) + end = registry.add_point(0.0, 10.0) + registry.add_arc(start, end, center) + + drag_position = (-7.07, -7.07) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_dragged_entity_excluded( + producer, registry, drag_context +): + """Tests that dragged entities are excluded from snap generation.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + line_id = registry.add_line(p1, p2) + + drag_context.dragged_entity_ids.add(line_id) + + drag_position = (5.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_dragged_point_excluded( + producer, registry, drag_context +): + """Tests that entities with dragged points are excluded.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + drag_context.dragged_point_ids.add(p1) + + drag_position = (5.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_outside_threshold( + producer, registry, drag_context +): + """Tests that points outside threshold don't produce snaps.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 50.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_multiple_entities( + producer, registry, drag_context +): + """Tests producer with multiple entities.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + center = registry.add_point(10.0, 10.0) + radius = registry.add_point(20.0, 10.0) + registry.add_circle(center, radius) + + drag_position = (5.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) >= 1 + + +def test_on_entity_producer_source_attribute(producer, registry, drag_context): + """Tests that source attribute is set to the entity.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + line_id = registry.add_line(p1, p2) + line = registry.get_entity(line_id) + + drag_position = (5.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert snap_points[0].source == line + + +def test_on_entity_producer_negative_coordinates( + producer, registry, drag_context +): + """Tests snap generation with negative coordinates.""" + p1 = registry.add_point(-10.0, -10.0) + p2 = registry.add_point(10.0, -10.0) + registry.add_line(p1, p2) + + drag_position = (0.0, -7.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x) < 0.001 + assert abs(snap_points[0].y - (-10.0)) < 0.001 + + +def test_on_entity_producer_empty_registry(producer, registry, drag_context): + """Tests producer with empty registry.""" + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_line_projection(producer, registry, drag_context): + """Tests projection onto line endpoint outside segment.""" + p1 = registry.add_point(10.0, 0.0) + p2 = registry.add_point(20.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 3.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_diagonal_line(producer, registry, drag_context): + """Tests snapping to diagonal line.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 7.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 6.0) < 0.001 + assert abs(snap_points[0].y - 6.0) < 0.001 + + +def test_on_entity_producer_circle_center_snap( + producer, registry, drag_context +): + """Tests snapping from near circle center.""" + center = registry.add_point(10.0, 10.0) + radius_pt = registry.add_point(20.0, 10.0) + registry.add_circle(center, radius_pt) + + drag_position = (10.0, 10.5) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_zero_radius_circle( + producer, registry, drag_context +): + """Tests that zero radius circle doesn't produce snaps.""" + center = registry.add_point(10.0, 10.0) + radius_pt = registry.add_point(10.0, 10.0) + registry.add_circle(center, radius_pt) + + drag_position = (10.0, 15.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 + + +def test_on_entity_producer_degenerate_line(producer, registry, drag_context): + """Tests that degenerate line (zero length) produces snap at endpoint.""" + p1 = registry.add_point(10.0, 10.0) + p2 = registry.add_point(10.0, 10.0) + registry.add_line(p1, p2) + + drag_position = (10.0, 15.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 10.0) < 0.001 + assert abs(snap_points[0].y - 10.0) < 0.001 + + +def test_on_entity_producer_exact_on_circle(producer, registry, drag_context): + """Tests snap when exactly on circle.""" + center = registry.add_point(10.0, 10.0) + radius_pt = registry.add_point(20.0, 10.0) + registry.add_circle(center, radius_pt) + + drag_position = (20.0, 10.0) + threshold = 0.1 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + assert abs(snap_points[0].x - 20.0) < 0.001 + assert abs(snap_points[0].y - 10.0) < 0.001 + + +def test_on_entity_producer_threshold_boundary( + producer, registry, drag_context +): + """Tests snap generation at threshold boundary.""" + p1 = registry.add_point(0.0, 0.0) + p2 = registry.add_point(10.0, 0.0) + registry.add_line(p1, p2) + + drag_position = (5.0, 5.0) + threshold = 5.0 + + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 1 + + drag_position = (5.0, 5.1) + snap_points = list( + producer.produce_points( + registry, drag_position, drag_context, threshold + ) + ) + + assert len(snap_points) == 0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_engine.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_engine.py new file mode 100644 index 000000000..ee5099911 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_engine.py @@ -0,0 +1,398 @@ +import pytest +from sketcher.core.registry import EntityRegistry +from sketcher.core.snap.engine import SnapEngine, SnapLineProducer +from sketcher.core.snap.types import ( + DragContext, + SnapLine, + SnapLineType, + SnapPoint, +) + + +class MockSnapLineProducer(SnapLineProducer): + """Mock producer for testing SnapEngine.""" + + def __init__(self, snap_lines=None, snap_points=None): + self.snap_lines = snap_lines or [] + self.snap_points = snap_points or [] + self.produce_called = False + self.produce_points_called = False + + def produce( + self, + registry: EntityRegistry, + drag_position, + drag_context: DragContext, + threshold: float, + ): + self.produce_called = True + return iter(self.snap_lines) + + def produce_points( + self, + registry: EntityRegistry, + drag_position, + drag_context: DragContext, + threshold: float, + ): + self.produce_points_called = True + return iter(self.snap_points) + + +@pytest.fixture +def registry(): + """Create a basic entity registry for testing.""" + return EntityRegistry() + + +@pytest.fixture +def engine(): + """Create a SnapEngine with default threshold.""" + return SnapEngine() + + +@pytest.fixture +def drag_context(): + """Create an empty drag context.""" + return DragContext() + + +def test_snap_engine_initialization_defaults(): + """Tests SnapEngine initialization with defaults.""" + engine = SnapEngine() + assert engine.threshold == SnapEngine.DEFAULT_THRESHOLD + assert engine.enabled is True + assert len(engine._producers) == 0 + assert len(engine._cached_points) == 0 + assert engine._last_query_pos is None + + +def test_snap_engine_initialization_custom_threshold(): + """Tests SnapEngine initialization with custom threshold.""" + engine = SnapEngine(threshold=10.0) + assert engine.threshold == 10.0 + + +def test_snap_engine_register_producer(engine): + """Tests registering a snap line producer.""" + producer = MockSnapLineProducer() + engine.register_producer(producer) + assert len(engine._producers) == 1 + assert producer in engine._producers + + +def test_snap_engine_unregister_producer(engine): + """Tests unregistering a snap line producer.""" + producer = MockSnapLineProducer() + engine.register_producer(producer) + assert len(engine._producers) == 1 + + engine.unregister_producer(producer) + assert len(engine._producers) == 0 + + +def test_snap_engine_unregister_nonexistent_producer(engine): + """Tests unregistering a producer that doesn't exist.""" + producer = MockSnapLineProducer() + engine.unregister_producer(producer) + assert len(engine._producers) == 0 + + +def test_snap_engine_clear_producers(engine): + """Tests clearing all producers.""" + producer1 = MockSnapLineProducer() + producer2 = MockSnapLineProducer() + engine.register_producer(producer1) + engine.register_producer(producer2) + assert len(engine._producers) == 2 + + engine.clear_producers() + assert len(engine._producers) == 0 + + +def test_snap_engine_enabled_property(engine): + """Tests the enabled property.""" + assert engine.enabled is True + engine.enabled = False + assert engine.enabled is False + engine.enabled = True + assert engine.enabled is True + + +def test_snap_engine_threshold_property(engine): + """Tests the threshold property.""" + assert engine.threshold == SnapEngine.DEFAULT_THRESHOLD + engine.threshold = 15.0 + assert engine.threshold == 15.0 + + +def test_snap_engine_query_disabled(engine, registry): + """Tests that query returns no_snap when engine is disabled.""" + engine.enabled = False + result = engine.query(registry, (10.0, 20.0)) + assert result.snapped is False + assert result.position == (10.0, 20.0) + + +def test_snap_engine_query_with_producer(engine, registry, drag_context): + """Tests query with a registered producer.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + producer = MockSnapLineProducer(snap_lines=[snap_line]) + engine.register_producer(producer) + + result = engine.query(registry, (5.0, 12.0), drag_context) + assert result.snapped is True + assert result.position == (5.0, 10.0) + assert producer.produce_called is True + + +def test_snap_engine_query_with_snap_point(engine, registry, drag_context): + """Tests query with a snap point from producer.""" + snap_point = SnapPoint(x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT) + producer = MockSnapLineProducer(snap_points=[snap_point]) + engine.register_producer(producer) + + result = engine.query(registry, (12.0, 22.0), drag_context) + assert result.snapped is True + assert result.position == (10.0, 20.0) + assert result.primary_snap_point == snap_point + + +def test_snap_engine_query_no_snap(engine, registry, drag_context): + """Tests query when no snap is found.""" + producer = MockSnapLineProducer() + engine.register_producer(producer) + + result = engine.query(registry, (100.0, 200.0), drag_context) + assert result.snapped is False + assert result.position == (100.0, 200.0) + + +def test_snap_engine_query_default_drag_context(engine, registry): + """Tests query with default drag context.""" + snap_point = SnapPoint(x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT) + producer = MockSnapLineProducer(snap_points=[snap_point]) + engine.register_producer(producer) + + result = engine.query(registry, (12.0, 22.0)) + assert result.snapped is True + + +def test_snap_engine_rebuild_index(engine, registry, drag_context): + """Tests rebuild_index clears and rebuilds the snap line index.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_point = SnapPoint(x=5.0, y=15.0, line_type=SnapLineType.CENTER) + producer = MockSnapLineProducer( + snap_lines=[snap_line], snap_points=[snap_point] + ) + engine.register_producer(producer) + + engine.rebuild_index(registry, (5.0, 12.0), drag_context) + + assert len(engine._index._horizontal) == 1 + assert len(engine._cached_points) == 1 + assert engine._last_query_pos == (5.0, 12.0) + + +def test_snap_engine_multiple_producers(engine, registry, drag_context): + """Tests that multiple producers are all used.""" + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + snap_point = SnapPoint(x=15.0, y=25.0, line_type=SnapLineType.MIDPOINT) + + producer1 = MockSnapLineProducer( + snap_lines=[snap_line1], snap_points=[snap_point] + ) + producer2 = MockSnapLineProducer(snap_lines=[snap_line2]) + engine.register_producer(producer1) + engine.register_producer(producer2) + + engine.rebuild_index(registry, (15.0, 25.0), drag_context) + + assert producer1.produce_called is True + assert producer2.produce_called is True + assert len(engine._index._horizontal) == 1 + assert len(engine._index._vertical) == 1 + assert len(engine._cached_points) == 1 + + +def test_snap_engine_query_caches_snap_points(engine, registry, drag_context): + """Tests that snap points are cached during query.""" + snap_point = SnapPoint(x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT) + producer = MockSnapLineProducer(snap_points=[snap_point]) + engine.register_producer(producer) + + result1 = engine.query(registry, (12.0, 22.0), drag_context) + result2 = engine.query(registry, (12.0, 22.0), drag_context) + + assert result1.primary_snap_point == result2.primary_snap_point + + +def test_snap_engine_query_different_positions(engine, registry, drag_context): + """Tests that query rebuilds index for different positions.""" + snap_point1 = SnapPoint( + x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT + ) + snap_point2 = SnapPoint(x=30.0, y=40.0, line_type=SnapLineType.CENTER) + + class DynamicProducer(MockSnapLineProducer): + def produce_points( + self, registry, drag_position, drag_context, threshold + ): + if drag_position[0] < 20.0: + return iter([snap_point1]) + else: + return iter([snap_point2]) + + producer = DynamicProducer() + engine.register_producer(producer) + + result1 = engine.query(registry, (12.0, 22.0), drag_context) + assert result1.primary_snap_point == snap_point1 + + result2 = engine.query(registry, (32.0, 42.0), drag_context) + assert result2.primary_snap_point == snap_point2 + + +def test_snap_engine_get_visible_snap_lines(engine, registry, drag_context): + """Tests get_visible_snap_lines method.""" + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + producer = MockSnapLineProducer(snap_lines=[snap_line1, snap_line2]) + engine.register_producer(producer) + + visible_lines = engine.get_visible_snap_lines( + registry, (5.0, 12.0), drag_context + ) + + assert len(visible_lines) == 2 + assert snap_line1 in visible_lines + assert snap_line2 in visible_lines + + +def test_snap_engine_get_visible_snap_lines_disabled(engine, registry): + """Tests that get_visible_snap_lines returns empty when disabled.""" + engine.enabled = False + visible_lines = engine.get_visible_snap_lines(registry, (5.0, 12.0)) + assert visible_lines == [] + + +def test_snap_engine_find_nearest_snap_point(engine, registry, drag_context): + """Tests finding nearest snap point.""" + snap_point1 = SnapPoint( + x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT + ) + snap_point2 = SnapPoint(x=15.0, y=25.0, line_type=SnapLineType.CENTER) + + producer = MockSnapLineProducer(snap_points=[snap_point1, snap_point2]) + engine.register_producer(producer) + + engine.rebuild_index(registry, (12.0, 22.0), drag_context) + result = engine.query(registry, (12.0, 22.0), drag_context) + + assert result.primary_snap_point == snap_point1 + + +def test_snap_engine_snap_point_priority(engine, registry, drag_context): + """Tests that higher priority snap points are preferred.""" + snap_point_low = SnapPoint(x=12.0, y=22.0, line_type=SnapLineType.CENTER) + snap_point_high = SnapPoint( + x=13.0, y=23.0, line_type=SnapLineType.ENTITY_POINT + ) + + producer = MockSnapLineProducer( + snap_points=[snap_point_low, snap_point_high] + ) + engine.register_producer(producer) + + result = engine.query(registry, (12.5, 22.5), drag_context) + + assert result.primary_snap_point == snap_point_high + + +def test_snap_engine_horizontal_vertical_snap(engine, registry, drag_context): + """Tests snapping to both horizontal and vertical lines.""" + h_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + v_line = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + + producer = MockSnapLineProducer(snap_lines=[h_line, v_line]) + engine.register_producer(producer) + + result = engine.query(registry, (22.0, 12.0), drag_context) + + assert result.snapped is True + assert result.position == (20.0, 10.0) + assert len(result.snap_lines) == 2 + + +def test_snap_line_producer_abstract(): + """Tests that SnapLineProducer raises NotImplementedError.""" + producer = SnapLineProducer() + registry = EntityRegistry() + drag_context = DragContext() + + with pytest.raises(NotImplementedError): + list(producer.produce(registry, (0.0, 0.0), drag_context, 5.0)) + + +def test_snap_line_producer_produce_points_default(): + """Tests that produce_points returns empty iterator by default.""" + producer = SnapLineProducer() + registry = EntityRegistry() + drag_context = DragContext() + + points = list( + producer.produce_points(registry, (0.0, 0.0), drag_context, 5.0) + ) + assert points == [] + + +def test_snap_engine_find_crossing_lines(engine, registry, drag_context): + """Tests finding crossing lines for a snap point.""" + h_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + v_line = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + snap_point = SnapPoint(x=20.0, y=10.0, line_type=SnapLineType.ENTITY_POINT) + + producer = MockSnapLineProducer( + snap_lines=[h_line, v_line], snap_points=[snap_point] + ) + engine.register_producer(producer) + + result = engine.query(registry, (20.0, 10.0), drag_context) + + assert result.primary_snap_point == snap_point + assert len(result.snap_lines) == 2 + assert h_line in result.snap_lines + assert v_line in result.snap_lines diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_spatial.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_spatial.py new file mode 100644 index 000000000..82071d319 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_spatial.py @@ -0,0 +1,376 @@ +import pytest +from sketcher.core.snap.spatial import IndexedLine, SnapLineIndex +from sketcher.core.snap.types import SnapLine, SnapLineType + + +def test_indexed_line_creation(): + """Tests IndexedLine creation.""" + indexed = IndexedLine(snap_line=None, coordinate=10.5) + assert indexed.snap_line is None + assert indexed.coordinate == 10.5 + + +def test_indexed_line_with_snap_line(): + """Tests IndexedLine with a SnapLine.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + indexed = IndexedLine(snap_line=snap_line, coordinate=10.0) + assert indexed.snap_line == snap_line + assert indexed.coordinate == 10.0 + + +def test_indexed_line_comparison(): + """Tests IndexedLine comparison by coordinate.""" + indexed1 = IndexedLine(None, 10.0) + indexed2 = IndexedLine(None, 20.0) + indexed3 = IndexedLine(None, 10.0) + + assert indexed1 < indexed2 + assert not (indexed2 < indexed1) + assert not (indexed1 < indexed3) + + +def test_snap_line_index_creation(): + """Tests SnapLineIndex creation.""" + index = SnapLineIndex() + assert len(index._horizontal) == 0 + assert len(index._vertical) == 0 + assert index._dirty is False + + +def test_snap_line_index_add_horizontal(): + """Tests adding a horizontal snap line to the index.""" + index = SnapLineIndex() + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + index.add(snap_line) + assert len(index._horizontal) == 1 + assert len(index._vertical) == 0 + assert index._horizontal[0].snap_line == snap_line + assert index._horizontal[0].coordinate == 10.0 + assert index._dirty is True + + +def test_snap_line_index_add_vertical(): + """Tests adding a vertical snap line to the index.""" + index = SnapLineIndex() + snap_line = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + index.add(snap_line) + assert len(index._horizontal) == 0 + assert len(index._vertical) == 1 + assert index._vertical[0].snap_line == snap_line + assert index._vertical[0].coordinate == 20.0 + assert index._dirty is True + + +def test_snap_line_index_add_multiple(): + """Tests adding multiple snap lines to the index.""" + index = SnapLineIndex() + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=20.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=True, coordinate=10.0, line_type=SnapLineType.CENTER + ) + snap_line3 = SnapLine( + is_horizontal=False, coordinate=15.0, line_type=SnapLineType.MIDPOINT + ) + + index.add(snap_line1) + index.add(snap_line2) + index.add(snap_line3) + + assert len(index._horizontal) == 2 + assert len(index._vertical) == 1 + assert index._horizontal[0].coordinate == 10.0 + assert index._horizontal[1].coordinate == 20.0 + + +def test_snap_line_index_add_all(): + """Tests adding multiple snap lines using add_all.""" + index = SnapLineIndex() + snap_lines = [ + SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ), + SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ), + SnapLine( + is_horizontal=True, + coordinate=30.0, + line_type=SnapLineType.MIDPOINT, + ), + ] + index.add_all(iter(snap_lines)) + + assert len(index._horizontal) == 2 + assert len(index._vertical) == 1 + + +def test_snap_line_index_clear(): + """Tests clearing the snap line index.""" + index = SnapLineIndex() + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + index.add(snap_line1) + index.add(snap_line2) + + assert len(index) == 2 + + index.clear() + + assert len(index._horizontal) == 0 + assert len(index._vertical) == 0 + assert index._dirty is False + assert len(index) == 0 + + +def test_snap_line_index_query_horizontal(): + """Tests querying horizontal snap lines.""" + index = SnapLineIndex() + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=True, coordinate=20.0, line_type=SnapLineType.CENTER + ) + snap_line3 = SnapLine( + is_horizontal=True, coordinate=30.0, line_type=SnapLineType.MIDPOINT + ) + + index.add(snap_line1) + index.add(snap_line2) + index.add(snap_line3) + + results = index.query_horizontal(22.0, 5.0) + assert len(results) == 1 + snap_lines, distances = zip(*results) + assert snap_line2 in snap_lines + assert 2.0 in distances + + +def test_snap_line_index_query_vertical(): + """Tests querying vertical snap lines.""" + index = SnapLineIndex() + snap_line1 = SnapLine( + is_horizontal=False, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + snap_line3 = SnapLine( + is_horizontal=False, coordinate=30.0, line_type=SnapLineType.MIDPOINT + ) + + index.add(snap_line1) + index.add(snap_line2) + index.add(snap_line3) + + results = index.query_vertical(22.0, 5.0) + assert len(results) == 1 + snap_lines, distances = zip(*results) + assert snap_line2 in snap_lines + assert 2.0 in distances + + +def test_snap_line_index_query_combined(): + """Tests querying both horizontal and vertical snap lines.""" + index = SnapLineIndex() + h_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + h_line2 = SnapLine( + is_horizontal=True, coordinate=20.0, line_type=SnapLineType.CENTER + ) + v_line1 = SnapLine( + is_horizontal=False, coordinate=15.0, line_type=SnapLineType.MIDPOINT + ) + v_line2 = SnapLine( + is_horizontal=False, + coordinate=25.0, + line_type=SnapLineType.INTERSECTION, + ) + + index.add(h_line1) + index.add(h_line2) + index.add(v_line1) + index.add(v_line2) + + results = index.query(22.0, 18.0, 5.0) + assert len(results) == 2 + snap_lines, _distances = zip(*results) + assert h_line2 in snap_lines + assert v_line2 in snap_lines + + +def test_snap_line_index_query_empty(): + """Tests querying an empty index.""" + index = SnapLineIndex() + results = index.query(10.0, 20.0, 5.0) + assert results == [] + + +def test_snap_line_index_query_no_results(): + """Tests querying with no results.""" + index = SnapLineIndex() + h_line = SnapLine( + is_horizontal=True, + coordinate=100.0, + line_type=SnapLineType.ENTITY_POINT, + ) + v_line = SnapLine( + is_horizontal=False, coordinate=200.0, line_type=SnapLineType.CENTER + ) + index.add(h_line) + index.add(v_line) + + results = index.query(10.0, 20.0, 5.0) + assert results == [] + + +def test_snap_line_index_query_exact_match(): + """Tests querying with exact coordinate match.""" + index = SnapLineIndex() + h_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + v_line = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + index.add(h_line) + index.add(v_line) + + results = index.query(20.0, 10.0, 1.0) + assert len(results) == 2 + snap_lines, distances = zip(*results) + assert h_line in snap_lines + assert v_line in snap_lines + assert 0.0 in distances + + +def test_snap_line_index_len(): + """Tests SnapLineIndex __len__ method.""" + index = SnapLineIndex() + assert len(index) == 0 + + h_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + index.add(h_line1) + assert len(index) == 1 + + v_line1 = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + index.add(v_line1) + assert len(index) == 2 + + h_line2 = SnapLine( + is_horizontal=True, coordinate=30.0, line_type=SnapLineType.MIDPOINT + ) + index.add(h_line2) + assert len(index) == 3 + + +def test_snap_line_index_sorting(): + """Tests that snap lines are kept sorted by coordinate.""" + index = SnapLineIndex() + snap_lines = [ + SnapLine( + is_horizontal=True, + coordinate=30.0, + line_type=SnapLineType.MIDPOINT, + ), + SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ), + SnapLine( + is_horizontal=True, coordinate=20.0, line_type=SnapLineType.CENTER + ), + ] + for sl in snap_lines: + index.add(sl) + + assert index._horizontal[0].coordinate == 10.0 + assert index._horizontal[1].coordinate == 20.0 + assert index._horizontal[2].coordinate == 30.0 + + +def test_snap_line_index_query_sorting(): + """Tests that query results are sorted by distance and priority.""" + index = SnapLineIndex() + h_line1 = SnapLine( + is_horizontal=True, coordinate=10.0, line_type=SnapLineType.MIDPOINT + ) + h_line2 = SnapLine( + is_horizontal=True, + coordinate=12.0, + line_type=SnapLineType.ENTITY_POINT, + ) + v_line1 = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + + index.add(h_line1) + index.add(h_line2) + index.add(v_line1) + + results = index.query(20.0, 11.0, 5.0) + assert len(results) == 3 + assert results[0][1] == 0.0 + assert results[1][1] == 1.0 + assert results[1][0].line_type == SnapLineType.ENTITY_POINT + + +def test_snap_line_index_query_with_none_snap_line(): + """Tests that None snap lines are skipped in queries.""" + index = SnapLineIndex() + index._horizontal.append(IndexedLine(None, 10.0)) + index._horizontal.append(IndexedLine(None, 20.0)) + + results = index.query_horizontal(15.0, 5.0) + assert results == [] + + +def test_snap_line_index_add_all_with_none(): + """Tests that add_all doesn't handle None values.""" + index = SnapLineIndex() + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + + with pytest.raises(AttributeError): + index.add_all(iter([snap_line1, None])) # type: ignore diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_types.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_types.py new file mode 100644 index 000000000..d9d01dd62 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/snap/test_snap_types.py @@ -0,0 +1,288 @@ +import pytest +from sketcher.core.snap.types import ( + SNAP_LINE_STYLES, + DragContext, + SnapLine, + SnapLineStyle, + SnapLineType, + SnapPoint, + SnapResult, +) + + +def test_snap_line_type_priority(): + """Tests that SnapLineType priority values are correct.""" + assert SnapLineType.ENTITY_POINT.priority == 100 + assert SnapLineType.MIDPOINT.priority == 90 + assert SnapLineType.ON_ENTITY.priority == 80 + assert SnapLineType.INTERSECTION.priority == 70 + assert SnapLineType.EQUIDISTANT.priority == 60 + assert SnapLineType.TANGENT.priority == 40 + assert SnapLineType.CENTER.priority == 30 + + +def test_snap_line_style_defaults(): + """Tests default SnapLineStyle values.""" + style = SnapLineStyle() + assert style.color == (0.0, 0.6, 1.0, 0.8) + assert style.dash is None + assert style.line_width == 1.0 + + +def test_snap_line_styles_dict(): + """Tests that SNAP_LINE_STYLES contains all SnapLineType entries.""" + assert SnapLineType.ENTITY_POINT in SNAP_LINE_STYLES + assert SnapLineType.ON_ENTITY in SNAP_LINE_STYLES + assert SnapLineType.INTERSECTION in SNAP_LINE_STYLES + assert SnapLineType.MIDPOINT in SNAP_LINE_STYLES + assert SnapLineType.EQUIDISTANT in SNAP_LINE_STYLES + assert SnapLineType.TANGENT in SNAP_LINE_STYLES + assert SnapLineType.CENTER in SNAP_LINE_STYLES + + +def test_snap_point_creation(): + """Tests basic SnapPoint creation and properties.""" + snap_point = SnapPoint(x=10.5, y=20.3, line_type=SnapLineType.ENTITY_POINT) + assert snap_point.x == 10.5 + assert snap_point.y == 20.3 + assert snap_point.line_type == SnapLineType.ENTITY_POINT + assert snap_point.source is None + assert snap_point.spacing is None + assert snap_point.is_horizontal is False + assert snap_point.pattern_coords is None + assert snap_point.axis_coord is None + assert snap_point.pos == (10.5, 20.3) + + +def test_snap_point_with_all_fields(): + """Tests SnapPoint creation with all fields.""" + snap_point = SnapPoint( + x=5.0, + y=10.0, + line_type=SnapLineType.EQUIDISTANT, + source="test_source", + spacing=2.5, + is_horizontal=True, + pattern_coords=(0.0, 2.5, 5.0, 7.5, 10.0), + axis_coord=5.0, + ) + assert snap_point.x == 5.0 + assert snap_point.y == 10.0 + assert snap_point.line_type == SnapLineType.EQUIDISTANT + assert snap_point.source == "test_source" + assert snap_point.spacing == 2.5 + assert snap_point.is_horizontal is True + assert snap_point.pattern_coords == (0.0, 2.5, 5.0, 7.5, 10.0) + assert snap_point.axis_coord == 5.0 + + +def test_snap_line_creation_horizontal(): + """Tests SnapLine creation for horizontal line.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.5, + line_type=SnapLineType.ENTITY_POINT, + source="test_source", + ) + assert snap_line.is_horizontal is True + assert snap_line.coordinate == 10.5 + assert snap_line.line_type == SnapLineType.ENTITY_POINT + assert snap_line.source == "test_source" + assert snap_line.style == SNAP_LINE_STYLES[SnapLineType.ENTITY_POINT] + + +def test_snap_line_creation_vertical(): + """Tests SnapLine creation for vertical line.""" + snap_line = SnapLine( + is_horizontal=False, + coordinate=20.3, + line_type=SnapLineType.CENTER, + ) + assert snap_line.is_horizontal is False + assert snap_line.coordinate == 20.3 + assert snap_line.line_type == SnapLineType.CENTER + assert snap_line.source is None + + +def test_snap_line_distance_to_horizontal(): + """Tests SnapLine.distance_to for horizontal line.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + assert snap_line.distance_to(5.0, 12.0) == 2.0 + assert snap_line.distance_to(5.0, 8.0) == 2.0 + assert snap_line.distance_to(5.0, 10.0) == 0.0 + + +def test_snap_line_distance_to_vertical(): + """Tests SnapLine.distance_to for vertical line.""" + snap_line = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + assert snap_line.distance_to(22.0, 5.0) == 2.0 + assert snap_line.distance_to(18.0, 5.0) == 2.0 + assert snap_line.distance_to(20.0, 5.0) == 0.0 + + +def test_snap_line_get_snap_position_horizontal(): + """Tests SnapLine.get_snap_position for horizontal line.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + assert snap_line.get_snap_position(5.0, 15.0) == (5.0, 10.0) + assert snap_line.get_snap_position(20.0, 8.0) == (20.0, 10.0) + + +def test_snap_line_get_snap_position_vertical(): + """Tests SnapLine.get_snap_position for vertical line.""" + snap_line = SnapLine( + is_horizontal=False, coordinate=20.0, line_type=SnapLineType.CENTER + ) + assert snap_line.get_snap_position(25.0, 5.0) == (20.0, 5.0) + assert snap_line.get_snap_position(18.0, 10.0) == (20.0, 10.0) + + +def test_snap_result_no_snap(): + """Tests SnapResult.no_snap classmethod.""" + result = SnapResult.no_snap((10.0, 20.0)) + assert result.snapped is False + assert result.position == (10.0, 20.0) + assert result.snap_lines == [] + assert result.snap_points == [] + assert result.primary_snap_line is None + assert result.primary_snap_point is None + assert result.distance == float("inf") + + +def test_snap_result_from_snap_line(): + """Tests SnapResult.from_snap_line classmethod.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + result = SnapResult.from_snap_line(snap_line, (5.0, 15.0), 5.0) + assert result.snapped is True + assert result.position == (5.0, 10.0) + assert len(result.snap_lines) == 1 + assert result.primary_snap_line == snap_line + assert result.primary_snap_point is None + assert result.distance == 5.0 + + +def test_snap_result_from_snap_point(): + """Tests SnapResult.from_snap_point classmethod.""" + snap_point = SnapPoint(x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT) + result = SnapResult.from_snap_point(snap_point, 5.0) + assert result.snapped is True + assert result.position == (10.0, 20.0) + assert len(result.snap_points) == 1 + assert result.primary_snap_point == snap_point + assert result.primary_snap_line is None + assert result.distance == 5.0 + assert result.snap_lines == [] + + +def test_snap_result_from_snap_point_with_lines(): + """Tests SnapResult.from_snap_point with snap lines.""" + snap_point = SnapPoint(x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT) + snap_line1 = SnapLine( + is_horizontal=True, + coordinate=20.0, + line_type=SnapLineType.ENTITY_POINT, + ) + snap_line2 = SnapLine( + is_horizontal=False, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + result = SnapResult.from_snap_point( + snap_point, 3.0, [snap_line1, snap_line2] + ) + assert result.snapped is True + assert result.position == (10.0, 20.0) + assert len(result.snap_points) == 1 + assert len(result.snap_lines) == 2 + assert snap_line1 in result.snap_lines + assert snap_line2 in result.snap_lines + assert result.primary_snap_point == snap_point + assert result.primary_snap_line is None + assert result.distance == 3.0 + + +def test_snap_result_defaults(): + """Tests SnapResult default values.""" + result = SnapResult() + assert result.snapped is False + assert result.position == (0.0, 0.0) + assert result.snap_lines == [] + assert result.snap_points == [] + assert result.primary_snap_line is None + assert result.primary_snap_point is None + assert result.distance == float("inf") + + +def test_drag_context_creation_empty(): + """Tests DragContext creation with no arguments.""" + context = DragContext() + assert context.dragged_point_ids == set() + assert context.dragged_entity_ids == set() + assert context.initial_positions == {} + + +def test_drag_context_creation_with_args(): + """Tests DragContext creation with arguments.""" + context = DragContext( + dragged_point_ids={1, 2, 3}, + dragged_entity_ids={10, 20}, + initial_positions={1: (0.0, 0.0), 2: (10.0, 10.0)}, + ) + assert context.dragged_point_ids == {1, 2, 3} + assert context.dragged_entity_ids == {10, 20} + assert context.initial_positions == {1: (0.0, 0.0), 2: (10.0, 10.0)} + + +def test_drag_context_is_point_dragged(): + """Tests DragContext.is_point_dragged method.""" + context = DragContext(dragged_point_ids={1, 2, 3}) + assert context.is_point_dragged(1) is True + assert context.is_point_dragged(2) is True + assert context.is_point_dragged(3) is True + assert context.is_point_dragged(4) is False + + +def test_drag_context_is_entity_dragged(): + """Tests DragContext.is_entity_dragged method.""" + context = DragContext(dragged_entity_ids={10, 20}) + assert context.is_entity_dragged(10) is True + assert context.is_entity_dragged(20) is True + assert context.is_entity_dragged(30) is False + + +def test_snap_line_frozen(): + """Tests that SnapLine is immutable.""" + snap_line = SnapLine( + is_horizontal=True, + coordinate=10.0, + line_type=SnapLineType.ENTITY_POINT, + ) + with pytest.raises(AttributeError): + snap_line.coordinate = 20.0 # type: ignore + + +def test_snap_point_frozen(): + """Tests that SnapPoint is immutable.""" + snap_point = SnapPoint(x=10.0, y=20.0, line_type=SnapLineType.ENTITY_POINT) + with pytest.raises(AttributeError): + snap_point.x = 30.0 # type: ignore + + +def test_snap_line_style_frozen(): + """Tests that SnapLineStyle is immutable.""" + style = SnapLineStyle() + with pytest.raises(AttributeError): + style.color = (1.0, 0.0, 0.0, 1.0) # type: ignore diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_constraint_conflicts.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_constraint_conflicts.py new file mode 100644 index 000000000..382c7e772 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_constraint_conflicts.py @@ -0,0 +1,275 @@ +""" +Tests for constraint conflict detection in the sketcher. +""" + +from sketcher.core.constraints import ( + ConstraintStatus, + DistanceConstraint, + HorizontalConstraint, +) +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.sketch import Sketch +from sketcher.core.solver import CONFLICT_ERROR_THRESHOLD, Solver + + +class TestSolverConflictDetection: + """Tests for the Solver.get_conflicting_constraints method.""" + + def test_no_conflicts_satisfied_constraints(self): + """When constraints are satisfied, no conflicts should be reported.""" + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0, fixed=False) + + constraints = [ + HorizontalConstraint(p1, p2), + DistanceConstraint(p1, p2, 10.0), + ] + + solver = Solver(reg, params, constraints) + solver.solve() + + conflicting = solver.get_conflicting_constraints() + assert conflicting == set() + + def test_conflicting_distance_constraints(self): + """ + Two distance constraints on the same points with incompatible values + should both be detected as conflicting. + """ + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(15, 0, fixed=False) + + constraints = [ + DistanceConstraint(p1, p2, 10.0), + DistanceConstraint(p1, p2, 20.0), + ] + + solver = Solver(reg, params, constraints) + solver.solve() + + conflicting = solver.get_conflicting_constraints() + assert 0 in conflicting + assert 1 in conflicting + + def test_partial_conflict_some_satisfied(self): + """ + When some constraints can be satisfied but others cannot, + only the unsatisfied ones should be marked as conflicting. + """ + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0, fixed=True) + p3 = reg.add_point(5, 0, fixed=False) + + constraints = [ + HorizontalConstraint(p1, p2), + DistanceConstraint(p1, p3, 10.0), + DistanceConstraint(p2, p3, 10.0), + ] + + solver = Solver(reg, params, constraints) + solver.solve() + + conflicting = solver.get_conflicting_constraints() + assert 0 not in conflicting + assert 1 in conflicting + assert 2 in conflicting + + def test_fixed_points_impossible_constraint(self): + """ + An impossible constraint between two fixed points should be detected. + """ + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0, fixed=True) + + constraints = [DistanceConstraint(p1, p2, 5.0)] + + solver = Solver(reg, params, constraints) + solver.solve() + + conflicting = solver.get_conflicting_constraints() + assert 0 in conflicting + + def test_conflict_threshold(self): + """ + Test that the threshold parameter works correctly. + Constraints with error just below threshold should not be reported. + """ + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10.0001, 0, fixed=True) + + constraints = [DistanceConstraint(p1, p2, 10.0)] + + solver = Solver(reg, params, constraints) + solver.solve() + + conflicting = solver.get_conflicting_constraints( + threshold=CONFLICT_ERROR_THRESHOLD + ) + assert 0 not in conflicting + + conflicting_strict = solver.get_conflicting_constraints(threshold=1e-6) + assert 0 in conflicting_strict + + +class TestSketchConflictTracking: + """Tests for the Sketch class conflict tracking functionality.""" + + def test_sketch_updates_constraint_status(self): + """ + After solving, conflicting constraints should have CONFLICTING status. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + + sketch.constrain_distance(p1, p2, 5.0) + sketch.constrain_distance(p1, p2, 15.0) + + sketch.solve() + + assert sketch.constraints[0].status == ConstraintStatus.CONFLICTING + assert sketch.constraints[1].status == ConstraintStatus.CONFLICTING + + def test_sketch_has_conflicts_property(self): + """The has_conflicts property should return True when conflicts.""" + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + + sketch.constrain_distance(p1, p2, 5.0) + sketch.constrain_distance(p1, p2, 15.0) + + sketch.solve() + + assert sketch.has_conflicts is True + + def test_sketch_no_conflicts_property(self): + """The has_conflicts property should return False when no conflicts.""" + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + + sketch.constrain_horizontal(p1, p2) + sketch.constrain_distance(p1, p2, 10.0) + + sketch.solve() + + assert sketch.has_conflicts is False + + def test_sketch_conflicting_constraints_property(self): + """ + The conflicting_constraints property should return only conflicting + constraints. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + + sketch.constrain_horizontal(p1, p2) + sketch.constrain_distance(p1, p2, 5.0) + sketch.constrain_distance(p1, p2, 15.0) + + sketch.solve() + + conflicting = sketch.conflicting_constraints + assert len(conflicting) == 2 + assert all( + c.status == ConstraintStatus.CONFLICTING for c in conflicting + ) + + def test_conflict_cleared_when_resolved(self): + """ + When a conflict is resolved (e.g., constraint removed or value + changed), the CONFLICTING status should be cleared. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + + c1 = sketch.constrain_distance(p1, p2, 5.0) + c2 = sketch.constrain_distance(p1, p2, 15.0) + + sketch.solve() + assert sketch.has_conflicts is True + + sketch.constraints.remove(c1) + sketch.solve() + + assert sketch.has_conflicts is False + assert c2.status == ConstraintStatus.VALID + + def test_error_status_preserved(self): + """ + Constraints with ERROR status should not be changed to CONFLICTING. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + + c = sketch.constrain_distance(p1, p2, "invalid_var_name") + c.status = ConstraintStatus.ERROR + + sketch.solve() + + assert c.status == ConstraintStatus.ERROR + + +class TestConflictingWithEntities: + """Test conflict detection with actual sketch entities.""" + + def test_line_with_conflicting_length_constraints(self): + """ + Test conflict detection on a line with conflicting length constraints. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + sketch.add_line(p1, p2) + + sketch.constrain_distance(p1, p2, 10.0) + sketch.constrain_distance(p1, p2, 20.0) + + sketch.solve() + + assert sketch.has_conflicts is True + + def test_multiple_lines_partial_conflict(self): + """ + Test a scenario where some constraints conflict but others don't. + """ + sketch = Sketch() + + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + + sketch.constrain_distance(p1, p2, 10.0) + sketch.constrain_distance(p2, p3, 10.0) + + sketch.solve() + + assert sketch.has_conflicts is False + + sketch.constrain_distance(p1, p2, 50.0) + sketch.solve() + + assert sketch.has_conflicts is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_fill_methods.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_fill_methods.py new file mode 100644 index 000000000..b6231be5f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_fill_methods.py @@ -0,0 +1,210 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.entities import Line +from sketcher.core.sketch import Fill + + +@pytest.fixture +def sketch(): + return Sketch() + + +@pytest.fixture +def triangle_sketch(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + p3 = sketch.add_point(50, 86.6) + + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + sketch.add_line(p3, p1) + + return sketch + + +def test_validate_and_cleanup_fills_removes_invalid(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + p3 = sketch.add_point(50, 86.6) + + line1_id = sketch.add_line(p1, p2) + line2_id = sketch.add_line(p2, p3) + line3_id = sketch.add_line(p3, p1) + + valid_fill = Fill( + "valid-fill", + [(line1_id, True), (line2_id, True), (line3_id, True)], + ) + sketch.fills.append(valid_fill) + + invalid_fill = Fill( + "invalid-fill", + [(line1_id, True), (9999, True)], + ) + sketch.fills.append(invalid_fill) + + sketch._validate_and_cleanup_fills() + + assert valid_fill in sketch.fills + assert invalid_fill not in sketch.fills + + +def test_validate_and_cleanup_fills_keeps_circle(sketch): + center = sketch.add_point(50, 50) + radius = sketch.add_point(60, 50) + circle_id = sketch.add_circle(center, radius) + + fill = Fill("circle-fill", [(circle_id, True)]) + sketch.fills.append(fill) + + sketch._validate_and_cleanup_fills() + + assert fill in sketch.fills + + +def test_validate_and_cleanup_fills_keeps_all_valid(triangle_sketch): + line_ids = [ + e.id for e in triangle_sketch.registry.entities if isinstance(e, Line) + ] + + fill = Fill( + "triangle-fill", + [(line_ids[0], True), (line_ids[1], True), (line_ids[2], True)], + ) + triangle_sketch.fills.append(fill) + + triangle_sketch._validate_and_cleanup_fills() + + assert fill in triangle_sketch.fills + + +def test_get_fill_render_data_empty(sketch): + result = sketch.get_fill_render_data() + assert result == [] + + +def test_get_fill_render_data_circle(sketch): + center = sketch.add_point(50, 50) + radius = sketch.add_point(70, 50) + circle_id = sketch.add_circle(center, radius) + + fill = Fill("circle-fill", [(circle_id, True)]) + sketch.fills.append(fill) + + render_data = sketch.get_fill_render_data() + + assert len(render_data) == 1 + + +def test_get_fill_render_data_triangle(triangle_sketch): + line_ids = [ + e.id for e in triangle_sketch.registry.entities if isinstance(e, Line) + ] + + fill = Fill( + "triangle-fill", + [(line_ids[0], True), (line_ids[1], True), (line_ids[2], True)], + ) + triangle_sketch.fills.append(fill) + + render_data = triangle_sketch.get_fill_render_data() + + assert len(render_data) == 1 + + +def test_get_fill_render_data_excludes_ids(sketch): + center = sketch.add_point(50, 50) + radius = sketch.add_point(70, 50) + circle_id = sketch.add_circle(center, radius) + + fill = Fill("circle-fill", [(circle_id, True)]) + sketch.fills.append(fill) + + render_data = sketch.get_fill_render_data(exclude_ids={circle_id}) + + assert len(render_data) == 0 + + +def test_get_fill_render_data_missing_entity(sketch): + fill = Fill("missing-fill", [(9999, True)]) + sketch.fills.append(fill) + + render_data = sketch.get_fill_render_data() + + assert len(render_data) == 0 + + +def test_get_loop_at_point_inside_triangle(triangle_sketch): + loop = triangle_sketch.get_loop_at_point(50, 30) + + assert loop is not None + assert len(loop) == 3 + + +def test_get_loop_at_point_outside_triangle(triangle_sketch): + loop = triangle_sketch.get_loop_at_point(200, 200) + + assert loop is None + + +def test_get_loop_at_point_inside_circle(sketch): + center = sketch.add_point(50, 50) + radius = sketch.add_point(80, 50) + sketch.add_circle(center, radius) + + loop = sketch.get_loop_at_point(50, 50) + + assert loop is not None + assert len(loop) == 1 + + +def test_get_loop_at_point_outside_circle(sketch): + center = sketch.add_point(50, 50) + radius = sketch.add_point(80, 50) + sketch.add_circle(center, radius) + + loop = sketch.get_loop_at_point(200, 200) + + assert loop is None + + +def test_get_loop_at_point_nested_loops(sketch): + outer_p1 = sketch.add_point(0, 0) + outer_p2 = sketch.add_point(100, 0) + outer_p3 = sketch.add_point(100, 100) + outer_p4 = sketch.add_point(0, 100) + + sketch.add_line(outer_p1, outer_p2) + sketch.add_line(outer_p2, outer_p3) + sketch.add_line(outer_p3, outer_p4) + sketch.add_line(outer_p4, outer_p1) + + inner_p1 = sketch.add_point(25, 25) + inner_p2 = sketch.add_point(75, 25) + inner_p3 = sketch.add_point(75, 75) + inner_p4 = sketch.add_point(25, 75) + + sketch.add_line(inner_p1, inner_p2) + sketch.add_line(inner_p2, inner_p3) + sketch.add_line(inner_p3, inner_p4) + sketch.add_line(inner_p4, inner_p1) + + outer_loop = sketch.get_loop_at_point(10, 10) + inner_loop = sketch.get_loop_at_point(50, 50) + + assert outer_loop is not None + assert inner_loop is not None + assert len(inner_loop) == 4 + + +def test_get_loop_at_point_open_path(sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + p3 = sketch.add_point(50, 86.6) + + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + + loop = sketch.get_loop_at_point(50, 30) + + assert loop is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_from_geometry.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_from_geometry.py new file mode 100644 index 000000000..4cd77cb0a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_from_geometry.py @@ -0,0 +1,215 @@ +import pytest +from raygeo.geo import Geometry +from sketcher.core import Sketch +from sketcher.core.entities import Arc, Bezier, Line + + +class TestSketchFromGeometry: + """Tests for Sketch.from_geometry() method.""" + + def test_from_empty_geometry(self): + """Converting an empty geometry should return an empty sketch.""" + geo = Geometry() + sketch = Sketch.from_geometry(geo) + + assert sketch is not None + assert isinstance(sketch, Sketch) + assert sketch.is_empty + + def test_from_geometry_with_single_line(self): + """Converting a geometry with a single line segment.""" + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(10, 10) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 1 + assert isinstance(sketch.registry.entities[0], Line) + + points = sketch.registry.points + assert len(points) == 3 + assert points[0].fixed is True + + def test_from_geometry_with_multiple_lines(self): + """Converting a geometry with multiple connected lines.""" + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(10, 0) + geo.line_to(10, 10) + geo.line_to(0, 10) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 3 + for entity in sketch.registry.entities: + assert isinstance(entity, Line) + + def test_from_geometry_with_arc(self): + """Converting a geometry with an arc.""" + geo = Geometry() + geo.move_to(10, 0) + geo.arc_to(0, 10, i=-10, j=0, clockwise=False) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 1 + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + assert arc.clockwise is False + + def test_from_geometry_with_clockwise_arc(self): + """Converting a geometry with a clockwise arc.""" + geo = Geometry() + geo.move_to(0, 10) + geo.arc_to(10, 0, i=0, j=-10, clockwise=True) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 1 + arc = sketch.registry.entities[0] + assert isinstance(arc, Arc) + assert arc.clockwise is True + + def test_from_geometry_with_bezier(self): + """Converting a geometry with a bezier curve.""" + geo = Geometry() + geo.move_to(0, 0) + geo.bezier_to(10, 10, 3, 3, 7, 7) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 1 + bezier = sketch.registry.entities[0] + assert isinstance(bezier, Bezier) + + def test_from_geometry_with_multiple_beziers(self): + """Converting geometry with multiple beziers.""" + geo = Geometry() + geo.move_to(0, 0) + geo.bezier_to(10, 10, 3, 3, 7, 7) + geo.bezier_to(20, 0, 13, 13, 17, 7) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 2 + for entity in sketch.registry.entities: + assert isinstance(entity, Bezier) + + def test_from_geometry_mixed_with_bezier(self): + """Converting mixed geometry with bezier.""" + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(10, 0) + geo.bezier_to(20, 10, 13, 3, 17, 7) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 2 + assert isinstance(sketch.registry.entities[0], Line) + assert isinstance(sketch.registry.entities[1], Bezier) + + def test_from_geometry_bezier_roundtrip(self): + """Test bezier roundtrip conversion.""" + original_geo = Geometry() + original_geo.move_to(0, 0) + original_geo.bezier_to(10, 10, 3, 3, 7, 7) + + sketch = Sketch.from_geometry(original_geo) + result_geo = sketch.to_geometry() + + original_rect = original_geo.rect() + result_rect = result_geo.rect() + + assert original_rect == pytest.approx(result_rect, rel=1e-6) + + def test_from_geometry_point_deduplication(self): + """Points at the same coordinates should be deduplicated.""" + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(10, 0) + geo.line_to(10, 10) + geo.line_to(0, 0) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.points) == 4 + assert len(sketch.registry.entities) == 3 + + def test_from_geometry_with_disconnected_paths(self): + """Converting geometry with multiple disconnected paths.""" + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(10, 0) + geo.move_to(20, 20) + geo.line_to(30, 20) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 2 + assert len(sketch.registry.points) == 5 + + def test_from_geometry_mixed_lines_and_arcs(self): + """Converting geometry with both lines and arcs.""" + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(10, 0) + geo.arc_to(0, 10, i=-10, j=0, clockwise=False) + + sketch = Sketch.from_geometry(geo) + + assert len(sketch.registry.entities) == 2 + assert isinstance(sketch.registry.entities[0], Line) + assert isinstance(sketch.registry.entities[1], Arc) + + def test_from_geometry_roundtrip(self): + """Test that converting to sketch and back to geometry preserves + the shape.""" + original_geo = Geometry() + original_geo.move_to(0, 0) + original_geo.line_to(10, 0) + original_geo.line_to(10, 10) + original_geo.line_to(0, 10) + original_geo.close_path() + + sketch = Sketch.from_geometry(original_geo) + result_geo = sketch.to_geometry() + + original_rect = original_geo.rect() + result_rect = result_geo.rect() + + assert original_rect == pytest.approx(result_rect, rel=1e-6) + + def test_from_geometry_arc_roundtrip(self): + """Test arc roundtrip conversion.""" + original_geo = Geometry() + original_geo.move_to(10, 0) + original_geo.arc_to(0, 10, i=-10, j=0, clockwise=False) + + sketch = Sketch.from_geometry(original_geo) + result_geo = sketch.to_geometry() + + original_rect = original_geo.rect() + result_rect = result_geo.rect() + + assert original_rect == pytest.approx(result_rect, rel=1e-6) + + def test_from_geometry_preserves_arc_direction(self): + """Arc direction (clockwise/counter-clockwise) should be preserved.""" + geo_cw = Geometry() + geo_cw.move_to(0, 10) + geo_cw.arc_to(10, 0, i=0, j=-10, clockwise=True) + + sketch_cw = Sketch.from_geometry(geo_cw) + arc_cw = sketch_cw.registry.entities[0] + assert isinstance(arc_cw, Arc) + assert arc_cw.clockwise is True + + geo_ccw = Geometry() + geo_ccw.move_to(10, 0) + geo_ccw.arc_to(0, 10, i=-10, j=0, clockwise=False) + + sketch_ccw = Sketch.from_geometry(geo_ccw) + arc_ccw = sketch_ccw.registry.entities[0] + assert isinstance(arc_ccw, Arc) + assert arc_ccw.clockwise is False diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_properties.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_properties.py new file mode 100644 index 000000000..6140bcb19 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_properties.py @@ -0,0 +1,146 @@ +import pytest +from sketcher.core import Sketch +from sketcher.core.constraints import ConstraintStatus + + +@pytest.fixture +def sketch(): + return Sketch() + + +class TestIsFullyConstrained: + def test_empty_sketch(self, sketch): + origin = sketch.registry.get_point(sketch.origin_id) + origin.constrained = True + assert sketch.is_fully_constrained is True + + def test_unconstrained_line(self, sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + sketch.add_line(p1, p2) + + assert sketch.is_fully_constrained is False + + def test_fixed_points(self, sketch): + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0, fixed=True) + sketch.add_line(p1, p2) + + for pt in sketch.registry.points: + pt.constrained = True + for ent in sketch.registry.entities: + ent.constrained = True + + assert sketch.is_fully_constrained is True + + def test_partially_constrained(self, sketch): + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(10, 0) + sketch.add_line(p1, p2) + + origin = sketch.registry.get_point(sketch.origin_id) + origin.constrained = True + + pt1 = sketch.registry.get_point(p1) + pt1.constrained = True + + for ent in sketch.registry.entities: + ent.constrained = True + + assert sketch.is_fully_constrained is False + + def test_circle_with_unconstrained_radius_point(self, sketch): + center = sketch.add_point(50, 50, fixed=True) + radius = sketch.add_point(60, 50) + circle_id = sketch.add_circle(center, radius) + + center_pt = sketch.registry.get_point(center) + center_pt.constrained = True + + circle = sketch.registry.get_entity(circle_id) + circle.constrained = True + + origin = sketch.registry.get_point(sketch.origin_id) + origin.constrained = True + + assert sketch.is_fully_constrained is True + + def test_circle_with_shared_radius_point(self, sketch): + center = sketch.add_point(50, 50, fixed=True) + radius = sketch.add_point(60, 50) + circle_id = sketch.add_circle(center, radius) + + p1 = sketch.add_point(60, 50) + sketch.add_line(radius, p1) + + center_pt = sketch.registry.get_point(center) + center_pt.constrained = True + radius_pt = sketch.registry.get_point(radius) + radius_pt.constrained = False + + circle = sketch.registry.get_entity(circle_id) + circle.constrained = True + + origin = sketch.registry.get_point(sketch.origin_id) + origin.constrained = True + + assert sketch.is_fully_constrained is False + + +class TestConflictingConstraints: + def test_no_conflicts(self, sketch): + assert sketch.conflicting_constraints == [] + assert sketch.has_conflicts is False + + def test_with_conflicting_constraint(self, sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + sketch.add_line(p1, p2) + + from sketcher.core.constraints import DistanceConstraint + + constr = DistanceConstraint(p1, p2, 10.0) + constr.status = ConstraintStatus.CONFLICTING + sketch.constraints.append(constr) + + assert len(sketch.conflicting_constraints) == 1 + assert sketch.has_conflicts is True + + def test_mixed_constraints(self, sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + sketch.add_line(p1, p2) + + from sketcher.core.constraints import DistanceConstraint + + constr1 = DistanceConstraint(p1, p2, 10.0) + constr1.status = ConstraintStatus.VALID + sketch.constraints.append(constr1) + + constr2 = DistanceConstraint(p1, p2, 20.0) + constr2.status = ConstraintStatus.CONFLICTING + sketch.constraints.append(constr2) + + assert len(sketch.conflicting_constraints) == 1 + assert sketch.conflicting_constraints[0] == constr2 + assert sketch.has_conflicts is True + + def test_multiple_conflicts(self, sketch): + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + + from sketcher.core.constraints import DistanceConstraint + + constr1 = DistanceConstraint(p1, p2, 10.0) + constr1.status = ConstraintStatus.CONFLICTING + sketch.constraints.append(constr1) + + constr2 = DistanceConstraint(p2, p3, 10.0) + constr2.status = ConstraintStatus.CONFLICTING + sketch.constraints.append(constr2) + + assert len(sketch.conflicting_constraints) == 2 + assert sketch.has_conflicts is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_text_templates.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_text_templates.py new file mode 100644 index 000000000..50d63e78b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_text_templates.py @@ -0,0 +1,234 @@ +from datetime import datetime, timezone +from typing import cast + +from raygeo.geo.shape.text import FontConfig +from sketcher.core import Sketch +from sketcher.core.entities.text_box import TextBoxEntity + +from rayforge.core.varset import FloatVar, Var + + +def _add_text_box( + sketch: Sketch, content: str, x: float = 0, y: float = 0 +) -> TextBoxEntity: + """Helper to add a text box to a sketch and return it.""" + origin = sketch.add_point(x, y) + width_pt = sketch.add_point(x + 10, y) + height_pt = sketch.add_point(x, y + 10) + box_id = sketch.registry.add_text_box( + origin, width_pt, height_pt, content, FontConfig() + ) + return cast(TextBoxEntity, sketch.registry.get_entity(box_id)) + + +def test_resolve_plain_text(): + """Plain text content passes through resolve unchanged.""" + s = Sketch() + box = _add_text_box(s, "Hello") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "Hello" + + +def test_resolve_template_from_params(): + """Template content is resolved using sketch parameters.""" + s = Sketch() + s.set_param("width", 50.0) + box = _add_text_box(s, "W={width}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "W=50.0" + + +def test_resolve_template_from_input_parameters(): + """Template content is resolved using input_parameters VarSet.""" + s = Sketch() + s.input_parameters.add(FloatVar(key="count", label="Count", value=7)) + box = _add_text_box(s, "{count:.0f}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "7" + + +def test_resolve_template_with_format_spec(): + """Format specs in templates are applied.""" + s = Sketch() + s.set_param("ratio", 3.14159) + box = _add_text_box(s, "{ratio:.2f}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "3.14" + + +def test_resolve_template_with_expression(): + """Math expressions in templates are evaluated.""" + s = Sketch() + s.set_param("width", 25.0) + box = _add_text_box(s, "{sqrt(width):.1f}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "5.0" + + +def test_resolve_empty_content_returns_none(): + """Empty text boxes return None from resolve.""" + s = Sketch() + box = _add_text_box(s, "") + s.solve() + assert s._resolve_text_content(box) is None + + +def test_resolve_multiple_text_boxes(): + """Multiple text boxes are each resolved independently.""" + s = Sketch() + s.set_param("a", 10.0) + s.set_param("b", 20.0) + + box1 = _add_text_box(s, "A={a}", x=0, y=0) + box2 = _add_text_box(s, "B={b}", x=50, y=0) + + s.solve() + assert s._resolve_text_content(box1) == "A=10.0" + assert s._resolve_text_content(box2) == "B=20.0" + + +def test_to_geometry_uses_resolved_content(): + """to_geometry produces geometry from the resolved content.""" + s = Sketch() + s.set_param("val", 42.0) + _add_text_box(s, "{val}") + s.solve() + + geo = s.to_geometry() + assert len(geo) > 0 + + +def test_resolve_date_today(): + """today() is available as a template function.""" + s = Sketch() + box = _add_text_box(s, "{today()}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved is not None + assert datetime.now(tz=timezone.utc).date().isoformat() in resolved + + +def test_resolve_uuid4(): + """uuid4() is available as a template function.""" + s = Sketch() + box = _add_text_box(s, "{uuid4()}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved is not None + assert len(resolved) == 8 + assert "{" not in resolved + + +def test_resolve_string_input_parameter(): + """String-type input parameters are available in templates.""" + s = Sketch() + s.input_parameters.add( + Var(key="name", label="Name", var_type=str, default="Widget") + ) + box = _add_text_box(s, "Part: {name}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "Part: Widget" + + +def test_resolve_string_param_overrides_numeric_ctx(): + """String input parameters take precedence over ParameterContext.""" + s = Sketch() + s.input_parameters.add( + Var(key="label", label="Label", var_type=str, default="Hello") + ) + box = _add_text_box(s, "{label}") + s.solve() + resolved = s._resolve_text_content(box) + assert resolved == "Hello" + + +def test_entity_content_unchanged_after_solve(): + """Entity.content stays as raw template after solve.""" + s = Sketch() + s.set_param("width", 50.0) + box = _add_text_box(s, "W={width}") + s.solve() + assert box.content == "W={width}" + + +def test_get_geometry_resolves_templates(): + """get_geometry resolves templates in the clone's export.""" + s = Sketch() + s.set_param("width", 50.0) + _add_text_box(s, "W={width}") + s.solve() + + geo, _ = s.get_geometry() + assert len(geo) > 0 + + +def test_uuid4_consistent_within_solve_cycle(): + """uuid4() returns the same value within one solve cycle.""" + s = Sketch() + box = _add_text_box(s, "{uuid4()}") + s.solve() + + r1 = s._resolve_text_content(box) + r2 = s._resolve_text_content(box) + assert r1 == r2 + + +def test_uuid4_consistent_across_stroke_and_fill(): + """to_geometry and get_fill_render_data see the same uuid4.""" + s = Sketch() + _add_text_box(s, "{uuid4()}") + s.solve() + + stroke_resolved = s._resolve_text_content( + next(e for e in s.registry.entities if isinstance(e, TextBoxEntity)) + ) + fill_resolved = s._resolve_text_content( + next(e for e in s.registry.entities if isinstance(e, TextBoxEntity)) + ) + assert stroke_resolved == fill_resolved + + +def test_uuid4_changes_on_re_solve(): + """uuid4() produces a new value after a fresh solve.""" + s = Sketch() + box = _add_text_box(s, "{uuid4()}") + s.solve() + first = s._resolve_text_content(box) + + s.solve() + second = s._resolve_text_content(box) + assert second != first + + +def test_uuid4_stable_with_external_cache(): + """get_geometry with a cache dict preserves uuid4 across calls.""" + s = Sketch() + _add_text_box(s, "{uuid4()}") + s.solve() + + cache_a: dict = {} + _geo1, _ = s.get_geometry(resolved_text_cache=cache_a) + assert len(cache_a) == 1 + + cache_a_copy = dict(cache_a) + _geo2, _ = s.get_geometry(resolved_text_cache=cache_a) + assert cache_a == cache_a_copy + + +def test_uuid4_external_cache_gives_fresh_per_caller(): + """Two callers get different uuid4 values with separate caches.""" + s = Sketch() + _add_text_box(s, "{uuid4()}") + s.solve() + + cache_a: dict = {} + cache_b: dict = {} + s.get_geometry(resolved_text_cache=cache_a) + s.get_geometry(resolved_text_cache=cache_b) + assert cache_a != cache_b diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_params.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_params.py new file mode 100644 index 000000000..eaa54087b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_params.py @@ -0,0 +1,144 @@ +import math + +import pytest +from sketcher.core.params import ParameterContext + + +@pytest.fixture +def params(): + return ParameterContext() + + +def test_set_get_simple(params): + params.set("width", 100) + assert params.get("width") == 100.0 + + +def test_expression_evaluation(params): + params.set("a", 10) + params.set("b", 20) + params.set("c", "a + b") + assert params.get("c") == 30.0 + + +def test_math_functions(params): + params.set("x", "sqrt(16)") + params.set("y", "pi") + assert params.get("x") == 4.0 + assert params.get("y") == pytest.approx(math.pi) + + +def test_dependency_resolution_order(params): + # 'b' depends on 'a', but 'b' is defined first (conceptually) + # The solver iterates, so order of set() shouldn't strictly matter + # if evaluate() is called after. + params.set("b", "a * 2") + params.set("a", 10) + assert params.get("b") == 20.0 + + +def test_chained_dependencies(params): + params.set("val1", 10) + params.set("val2", "val1 + 5") # 15 + params.set("val3", "val2 * 2") # 30 + assert params.get("val3") == 30.0 + + +def test_dirty_flag_logic(params): + params.set("x", 10) + assert params.get("x") == 10.0 + # Modifying a dependency should mark dirty and re-eval + params.set("x", 20) + params.set("y", "x + 5") + assert params.get("y") == 25.0 + + +def test_missing_dependency_safe_fail(params): + # Should not crash, returns 0.0 or stays unresolved + params.set("z", "non_existent + 5") + assert params.get("z") == 0.0 + + +def test_evaluate_arbitrary_string(params): + params.set("w", 50) + result = params.evaluate("w / 2") + assert result == 25.0 + + +def test_circular_dependency_protection(params): + """ + Test that circular dependencies don't cause infinite recursion/hanging. + """ + params.set("a", "b") + params.set("b", "a") + + # This should evaluate without crashing (likely returning 0.0 or failing + # resolution). The current implementation limits passes to len(exprs), + # so it is safe. + assert params.get("a") == 0.0 + assert params.get("b") == 0.0 + + +def test_parameter_syntax_error(params): + """Test graceful handling of bad math strings.""" + params.set("bad", "sqrt(") # Incomplete syntax + assert params.get("bad") == 0.0 + + +def test_parameter_overwrite(params): + """Test overwriting a parameter updates dependents.""" + params.set("base", 10) + params.set("res", "base * 2") + assert params.get("res") == 20.0 + + params.set("base", 5) + assert params.get("res") == 10.0 + + +def test_parameter_context_serialization_round_trip(params): + """Tests to_dict and from_dict for ParameterContext.""" + params.set("width", 100) + params.set("height", "width / 2") + params.set("depth", "sqrt(width)") + + data = params.to_dict() + assert data == { + "expressions": { + "width": "100", + "height": "width / 2", + "depth": "sqrt(width)", + } + } + + new_params = ParameterContext.from_dict(data) + + # Check that expressions were loaded and evaluate correctly + assert new_params.get("width") == 100.0 + assert new_params.get("height") == 50.0 + assert new_params.get("depth") == 10.0 + + +def test_get_all_values(params): + """Test getting a dictionary of all evaluated parameters.""" + params.set("a", 10) + params.set("b", "a * 2") + params.set("c", "sqrt(a + 6)") # sqrt(16) = 4 + + expected = {"a": 10.0, "b": 20.0, "c": 4.0} + result = params.get_all_values() + assert result == expected + + # Ensure it's a copy and not a reference to the internal cache + result["a"] = 999 + assert params.get("a") == 10.0 + + +def test_get_with_default(params): + """Test the default value functionality of get().""" + params.set("exists", 42) + # The default for `get` in the method signature is 0.0 + assert params.get("does_not_exist") == 0.0 + # Test providing a custom default value + assert params.get("does_not_exist_either", default=-1.0) == -1.0 + # Test that existing keys don't use the provided default + assert params.get("exists", default=99.0) == 42.0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_selection.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_selection.py new file mode 100644 index 000000000..6401c4805 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_selection.py @@ -0,0 +1,404 @@ +from sketcher.core.registry import EntityRegistry +from sketcher.core.selection import SketchSelection + + +class TestClear: + def test_clears_all_selections(self): + selection = SketchSelection() + selection.point_ids.extend([1, 2]) + selection.entity_ids.extend([3, 4]) + selection.constraint_idx = 5 + selection.junction_pid = 6 + + selection.clear() + + assert selection.point_ids == [] + assert selection.entity_ids == [] + assert selection.constraint_idx is None + assert selection.junction_pid is None + + def test_emits_changed_signal(self): + selection = SketchSelection() + signal_received = [] + + def on_changed(sender): + signal_received.append(sender) + + selection.changed.connect(on_changed) + selection.clear() + + assert len(signal_received) == 1 + assert signal_received[0] is selection + + +class TestCopy: + def test_creates_shallow_copy(self): + selection = SketchSelection() + selection.point_ids.extend([1, 2]) + selection.entity_ids.extend([3, 4]) + selection.constraint_idx = 5 + selection.junction_pid = 6 + + copy = selection.copy() + + assert copy is not selection + assert copy.point_ids == selection.point_ids + assert copy.entity_ids == selection.entity_ids + assert copy.constraint_idx == selection.constraint_idx + assert copy.junction_pid == selection.junction_pid + + def test_copy_is_independent(self): + selection = SketchSelection() + selection.point_ids.append(1) + selection.entity_ids.append(2) + + copy = selection.copy() + copy.point_ids.append(3) + copy.entity_ids.append(4) + + assert 3 not in selection.point_ids + assert 4 not in selection.entity_ids + + +class TestSelectConstraint: + def test_selects_constraint_single_mode(self): + selection = SketchSelection() + selection.point_ids.append(1) + selection.entity_ids.append(2) + selection.junction_pid = 3 + + selection.select_constraint(5, is_multi=False) + + assert selection.constraint_idx == 5 + assert selection.point_ids == [] + assert selection.entity_ids == [] + assert selection.junction_pid is None + + def test_selects_constraint_multi_mode(self): + selection = SketchSelection() + selection.point_ids.append(1) + selection.entity_ids.append(2) + selection.junction_pid = 3 + + selection.select_constraint(5, is_multi=True) + + assert selection.constraint_idx == 5 + assert selection.point_ids == [1] + assert selection.entity_ids == [2] + assert selection.junction_pid == 3 + + def test_emits_changed_signal(self): + selection = SketchSelection() + signal_received = [] + + def on_changed(sender): + signal_received.append(sender) + + selection.changed.connect(on_changed) + selection.select_constraint(1, is_multi=False) + + assert len(signal_received) == 1 + + +class TestSelectJunction: + def test_selects_junction_single_mode(self): + selection = SketchSelection() + selection.point_ids.append(1) + selection.entity_ids.append(2) + selection.constraint_idx = 3 + + selection.select_junction(5, is_multi=False) + + assert selection.junction_pid == 5 + assert selection.point_ids == [] + assert selection.entity_ids == [] + assert selection.constraint_idx is None + + def test_selects_junction_multi_mode(self): + selection = SketchSelection() + selection.point_ids.append(1) + selection.entity_ids.append(2) + selection.constraint_idx = 3 + + selection.select_junction(5, is_multi=True) + + assert selection.junction_pid == 5 + assert selection.point_ids == [1] + assert selection.entity_ids == [2] + assert selection.constraint_idx == 3 + + def test_emits_changed_signal(self): + selection = SketchSelection() + signal_received = [] + + def on_changed(sender): + signal_received.append(sender) + + selection.changed.connect(on_changed) + selection.select_junction(1, is_multi=False) + + assert len(signal_received) == 1 + + +class TestSelectPoint: + def test_selects_point_single_mode(self): + selection = SketchSelection() + selection.entity_ids.append(2) + selection.constraint_idx = 3 + selection.junction_pid = 4 + + selection.select_point(5, is_multi=False) + + assert selection.point_ids == [5] + assert selection.entity_ids == [] + assert selection.constraint_idx is None + assert selection.junction_pid is None + + def test_selects_point_multi_mode_adds(self): + selection = SketchSelection() + + selection.select_point(1, is_multi=True) + selection.select_point(2, is_multi=True) + + assert selection.point_ids == [1, 2] + + def test_selects_point_multi_mode_toggles(self): + selection = SketchSelection() + selection.select_point(1, is_multi=True) + selection.select_point(1, is_multi=True) + + assert selection.point_ids == [] + + def test_selects_point_single_mode_no_change_if_same(self): + selection = SketchSelection() + selection.select_point(1, is_multi=False) + + selection.select_point(1, is_multi=False) + + assert selection.point_ids == [1] + + def test_emits_changed_signal(self): + selection = SketchSelection() + signal_received = [] + + def on_changed(sender): + signal_received.append(sender) + + selection.changed.connect(on_changed) + selection.select_point(1, is_multi=False) + + assert len(signal_received) == 1 + + +class TestSelectEntity: + def test_selects_entity_single_mode(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + assert line is not None + + selection = SketchSelection() + selection.point_ids.append(1) + selection.constraint_idx = 2 + selection.junction_pid = 3 + + selection.select_entity(line, is_multi=False) + + assert selection.entity_ids == [line.id] + assert selection.point_ids == [] + assert selection.constraint_idx is None + assert selection.junction_pid is None + + def test_selects_entity_multi_mode_adds(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(20, 0) + line1 = registry.get_entity(registry.add_line(p1, p2)) + line2 = registry.get_entity(registry.add_line(p2, p3)) + assert line1 is not None + assert line2 is not None + + selection = SketchSelection() + selection.select_entity(line1, is_multi=True) + selection.select_entity(line2, is_multi=True) + + assert set(selection.entity_ids) == {line1.id, line2.id} + + def test_selects_entity_multi_mode_toggles(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + assert line is not None + + selection = SketchSelection() + selection.select_entity(line, is_multi=True) + selection.select_entity(line, is_multi=True) + + assert selection.entity_ids == [] + + def test_selects_entity_single_mode_no_change_if_same(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + assert line is not None + + selection = SketchSelection() + selection.select_entity(line, is_multi=False) + initial_id = line.id + selection.select_entity(line, is_multi=False) + + assert selection.entity_ids == [initial_id] + + def test_emits_changed_signal(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + line = registry.get_entity(registry.add_line(p1, p2)) + + selection = SketchSelection() + signal_received = [] + + def on_changed(sender): + signal_received.append(sender) + + selection.changed.connect(on_changed) + selection.select_entity(line, is_multi=False) + + assert len(signal_received) == 1 + + +class TestSelectConnected: + def test_adds_to_existing_selection(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + p3 = registry.add_point(20, 0) + p4 = registry.add_point(30, 0) + l1 = registry.add_line(p1, p2) + l2 = registry.add_line(p2, p3) + l3 = registry.add_line(p3, p4) + + selection = SketchSelection() + selection.entity_ids.append(l3) + selection.point_ids.append(p1) + + selection.select_connected_entities(l1, registry) + + assert set(selection.entity_ids) == {l1, l2, l3} + assert selection.point_ids == [] + assert selection.constraint_idx is None + assert selection.junction_pid is None + + def test_clears_constraint_and_junction(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + l1 = registry.add_line(p1, p2) + + selection = SketchSelection() + selection.constraint_idx = 0 + selection.junction_pid = p1 + + selection.select_connected_entities(l1, registry) + + assert selection.constraint_idx is None + assert selection.junction_pid is None + + def test_emits_changed_signal(self): + registry = EntityRegistry() + p1 = registry.add_point(0, 0) + p2 = registry.add_point(10, 0) + l1 = registry.add_line(p1, p2) + + selection = SketchSelection() + signal_received = [] + + def on_changed(sender): + signal_received.append(sender) + + selection.changed.connect(on_changed) + selection.select_connected_entities(l1, registry) + + assert len(signal_received) == 1 + assert signal_received[0] is selection + + +class TestUpdateList: + def test_single_mode_adds_if_not_present(self): + selection = SketchSelection() + collection = [] + + selection._update_list(collection, 1, is_multi=False) + + assert collection == [1] + + def test_single_mode_no_change_if_already_present(self): + selection = SketchSelection() + collection = [1] + + selection._update_list(collection, 1, is_multi=False) + + assert collection == [1] + + def test_single_mode_replaces_with_new_item(self): + selection = SketchSelection() + collection = [1, 2, 3] + + selection._update_list(collection, 4, is_multi=False) + + assert collection == [4] + + def test_multi_mode_adds_if_not_present(self): + selection = SketchSelection() + collection = [1] + + selection._update_list(collection, 2, is_multi=True) + + assert collection == [1, 2] + + def test_multi_mode_removes_if_present(self): + selection = SketchSelection() + collection = [1, 2, 3] + + selection._update_list(collection, 2, is_multi=True) + + assert collection == [1, 3] + + +class TestIsEmpty: + def test_empty_selection_returns_true(self): + selection = SketchSelection() + assert selection.is_empty() is True + + def test_with_point_returns_false(self): + selection = SketchSelection() + selection.point_ids.append(1) + assert selection.is_empty() is False + + def test_with_entity_returns_false(self): + selection = SketchSelection() + selection.entity_ids.append(1) + assert selection.is_empty() is False + + def test_with_constraint_returns_false(self): + selection = SketchSelection() + selection.constraint_idx = 0 + assert selection.is_empty() is False + + def test_with_junction_returns_false(self): + selection = SketchSelection() + selection.junction_pid = 1 + assert selection.is_empty() is False + + def test_after_clear_returns_true(self): + selection = SketchSelection() + selection.point_ids.append(1) + selection.entity_ids.append(2) + selection.constraint_idx = 0 + selection.junction_pid = 3 + selection.clear() + assert selection.is_empty() is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_sketch.py new file mode 100644 index 000000000..4a2664256 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_sketch.py @@ -0,0 +1,1259 @@ +import math +import uuid +from pathlib import Path + +import pytest +from raygeo.geo import Arc as GeoArc +from raygeo.geo import Geometry, Move +from raygeo.geo import Line as GeoLine +from sketcher.core import Sketch +from sketcher.core.constraints import ( + CoincidentConstraint, + EqualDistanceConstraint, + EqualLengthConstraint, + PerpendicularConstraint, + PointOnLineConstraint, + SymmetryConstraint, +) +from sketcher.core.sketch import Fill + +from rayforge.core.varset import FloatVar + + +def test_sketch_workflow(): + s = Sketch() + + # 1. Define params + s.set_param("side", 10.0) + + # 2. Add geometry (approximate square) + p1 = s.add_point(0, 0, fixed=True) + p2 = s.add_point(5, 0) + p3 = s.add_point(5, 5) + p4 = s.add_point(0, 5) + + s.add_line(p1, p2) + s.add_line(p2, p3) + s.add_line(p3, p4) + s.add_line(p4, p1) + + # 3. Constrain + s.constrain_horizontal(p1, p2) + s.constrain_vertical(p2, p3) + s.constrain_horizontal(p4, p3) + s.constrain_vertical(p1, p4) + + s.constrain_distance(p1, p2, "side") + s.constrain_distance(p2, p3, "side") + + # 4. Solve + assert s.solve() is True + + # 5. Check geometry export + geo = s.to_geometry() + assert isinstance(geo, Geometry) + # The implementation should chain the lines into one continuous + # path: 1 MoveTo + 4 LineTo commands. + assert len(geo) == 5 + + # Check bounding box is approx 10x10 + min_x, min_y, max_x, max_y = geo.rect() + assert min_x == pytest.approx(0.0, abs=1e-4) + assert min_y == pytest.approx(0.0, abs=1e-4) + assert max_x == pytest.approx(10.0, abs=1e-4) + assert max_y == pytest.approx(10.0, abs=1e-4) + + +def test_sketch_is_empty(): + """ + Verifies the is_empty property logic: + - True for a new sketch (only origin point). + - True if only points are added (points are not drawable entities). + - False once a drawable entity (Line/Arc/Circle) is added. + """ + s = Sketch() + + # 1. Initially empty (contains origin point, but no entities) + assert s.is_empty is True + # Verify internal state: Origin exists, so points list is not empty + assert len(s.registry.points) == 1 + assert len(s.registry.entities) == 0 + + # 2. Adding standalone points doesn't change emptiness regarding drawable + # geometry + p1 = s.add_point(10, 0) + assert s.is_empty is True + + # 3. Adding an entity (Line) makes it not empty + s.add_line(s.origin_id, p1) + assert s.is_empty is False + + +def test_sketch_arc_export(): + s = Sketch() + # Simple quarter circle arc + p1 = s.add_point(10, 0) # Start + p2 = s.add_point(0, 10) # End + c = s.add_point(0, 0) # Center + + s.add_arc(p1, p2, c, clockwise=False) + + geo = s.to_geometry() + data = geo.data + assert data is not None + + arc_cmds = [c for c in data if isinstance(c, GeoArc)] + assert len(arc_cmds) == 1 + + arc = arc_cmds[0] + assert arc.end[0] == pytest.approx(0.0) + assert arc.end[1] == pytest.approx(10.0) + assert arc.end[2] == pytest.approx(0.0) + assert arc.center_offset[0] == pytest.approx(-10.0) + assert arc.center_offset[1] == pytest.approx(0.0) + assert arc.clockwise is False + + +def test_sketch_circle_workflow(): + s = Sketch() + s.set_param("diam", 20.0) + + center = s.add_point(10, 10, fixed=True) + radius_pt = s.add_point(15, 10) # Initial radius is 5 + circ_id = s.add_circle(center, radius_pt) + + s.constrain_diameter(circ_id, "diam") + + assert s.solve() is True + + # After solve, radius should be 10, diameter 20. + # The radius point should be 10 units away from center. + p = s.registry.get_point(radius_pt) + c = s.registry.get_point(center) + dist = ((p.x - c.x) ** 2 + (p.y - c.y) ** 2) ** 0.5 + assert dist == pytest.approx(10.0) + + geo = s.to_geometry() + # Should export as two semi-circles -> 2 ArcToCommands + assert isinstance(geo, Geometry) + data = geo.data + assert data is not None + arcs = [c for c in data if isinstance(c, GeoArc)] + assert len(arcs) == 2 + + +def test_sketch_construction_geometry_is_ignored_on_roundtrip(): + """ + Tests that geometry marked 'construction' is not exported, even after a + full serialization and deserialization cycle. + """ + s = Sketch() + assert s.uid is not None + + # Add a regular line that should be exported + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + s.add_line(p1, p2, construction=False) + + # Add various construction entities that should be ignored + p3 = s.add_point(0, 10) + p4 = s.add_point(10, 10) + s.add_line(p3, p4, construction=True) + + p5_s = s.add_point(20, 0) + p5_e = s.add_point(30, 10) + p5_c = s.add_point(20, 10) + s.add_arc(p5_s, p5_e, p5_c, construction=True) + + p6_c = s.add_point(40, 0) + p6_r = s.add_point(45, 0) + s.add_circle(p6_c, p6_r, construction=True) + + # 1. Serialize the sketch to a dictionary + sketch_data = s.to_dict() + assert "uid" in sketch_data + + # Verify that the data contains the correct flags + construction_entities = [ + e for e in sketch_data["registry"]["entities"] if e["construction"] + ] + assert len(construction_entities) == 3 + + # 2. Create a new sketch from the serialized data + s2 = Sketch.from_dict(sketch_data) + assert s2.uid == s.uid + + # 3. Generate the geometry from the new sketch + geo = s2.to_geometry() + + # The simple export creates a MoveTo and a LineTo for each line. + # We only have one non-construction line. + # So we expect exactly 2 commands total after the round trip. + assert len(geo) == 2 + + +def test_sketch_equal_length_workflow(): + """Test a full workflow using an equal length constraint.""" + s = Sketch() + + # Line 1 will be fixed at length 10 + p1 = s.add_point(0, 0, fixed=True) + p2 = s.add_point(10, 0) + l1 = s.add_line(p1, p2) + s.constrain_horizontal(p1, p2) + s.constrain_distance(p1, p2, 10.0) + + # Line 2 will start at length 5 and should be solved to 10 + p3 = s.add_point(20, 0, fixed=True) + p4 = s.add_point(25, 0) + l2 = s.add_line(p3, p4) + s.constrain_horizontal(p3, p4) + + # Apply the Equal Length constraint + s.constrain_equal_length([l1, l2]) + + assert s.solve() is True + + # Check that p4 has moved to make line 2 have length 10 + pt4 = s.registry.get_point(p4) + pt3 = s.registry.get_point(p3) + dist = ((pt4.x - pt3.x) ** 2 + (pt4.y - pt3.y) ** 2) ** 0.5 + assert dist == pytest.approx(10.0) + assert pt4.x == pytest.approx(30.0) + assert pt4.y == pytest.approx(0.0) + + +def test_sketch_symmetry_workflow(): + """Test full workflow for adding symmetry constraints.""" + s = Sketch() + + # --- Test Point Symmetry --- + # Center at (0,0), P1 at (-10, 0), P2 at (10, 5) [wrong y] + c = s.add_point(0, 0, fixed=True) + p1 = s.add_point(-10, 0) + p2 = s.add_point(10, 5) + + # Constrain P1, P2 symmetric to C + s.constrain_symmetry([c, p1, p2], []) + + s.solve() + + pt1 = s.registry.get_point(p1) + pt2 = s.registry.get_point(p2) + + # Assert X symmetry (sum of x relative to center should be 0) + # They started at -10 and 10, so they should stay there or move + # symmetrically. + assert (pt1.x + pt2.x) == pytest.approx(0.0, abs=1e-4) + + # Assert Y symmetry + # Since center Y is 0, P1.y + P2.y should equal 0. + # Initial: 0 and 5. Solver will move them to approx -2.5 and 2.5 + assert (pt1.y + pt2.y) == pytest.approx(0.0, abs=1e-4) + # Check they are actually separated and symmetric, not just both at 0 + assert pt2.y == pytest.approx(2.5, abs=1.0) + assert pt1.y == pytest.approx(-2.5, abs=1.0) + + # --- Test Line Symmetry --- + s2 = Sketch() + # Axis on Y-axis + l1 = s2.add_point(0, 10, fixed=True) + l2 = s2.add_point(0, 20, fixed=True) + axis = s2.add_line(l1, l2) + + # P3 at (-5, 15), P4 at (5, 16) [wrong y] + p3 = s2.add_point(-5, 15) + p4 = s2.add_point(5, 16) + + s2.constrain_symmetry([p3, p4], [axis]) + s2.solve() + + pt3 = s2.registry.get_point(p3) + pt4 = s2.registry.get_point(p4) + assert pt4.y == pytest.approx(pt3.y, abs=1e-4) + + +def test_sketch_parameter_updates(): + """Test that changing a parameter and re-solving updates geometry.""" + s = Sketch() + s.set_param("len", 10.0) + + p1 = s.add_point(0, 0, fixed=True) + p2 = s.add_point(5, 0) + s.constrain_distance(p1, p2, "len") + + assert s.solve() is True + assert s.registry.get_point(p2).x == pytest.approx(10.0) + + # Change param + s.set_param("len", 20.0) + s.solve() + assert s.registry.get_point(p2).x == pytest.approx(20.0) + + +def test_solve_with_variable_overrides(): + """ + Tests that variable overrides in solve() work correctly and are temporary. + """ + # 1. Setup a sketch with a parameter + s = Sketch() + s.set_param("width", 10.0) + p1 = s.add_point(0, 0, fixed=True) + p2 = s.add_point(1, 0) # Initial position doesn't matter much + s.constrain_distance(p1, p2, "width") + + # 2. Solve with the default parameter value + assert s.solve() is True + pt2 = s.registry.get_point(p2) + assert pt2.x == pytest.approx(10.0) + # Check that the parameter context has the correct value + assert s.params.get("width") == 10.0 + + # 3. Solve again, this time with an override + overrides = {"width": 25.0} + assert s.solve(variable_overrides=overrides) is True + pt2_override = s.registry.get_point(p2) + # The geometry should reflect the overridden value + assert pt2_override.x == pytest.approx(25.0) + + # 4. Check that the override was temporary + assert s.params.get("width") == 10.0 + + # 5. Solve again without overrides to confirm it uses the original value. + pt2.x = 1.0 + pt2.y = 0.0 + assert s.solve() is True + pt2_final = s.registry.get_point(p2) + assert pt2_final.x == pytest.approx(10.0) + + +def test_solve_with_expression_override(): + """ + Tests that variable overrides can also accept string expressions. + """ + s = Sketch() + s.set_param("base_len", 10.0) + s.set_param("width", "base_len") # width = 10.0 + p1 = s.add_point(0, 0, fixed=True) + p2 = s.add_point(1, 0) + s.constrain_distance(p1, p2, "width") + + overrides = {"width": 30.0} + assert s.solve(variable_overrides=overrides) is True + assert s.registry.get_point(p2).x == pytest.approx(30.0) + assert s.params.get("width") == 10.0 # Verify it was temporary + + +def test_sketch_constraint_shortcuts(): + """Verify all constraint shortcut methods properly register constraints.""" + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + p3 = s.add_point(0, 10) + p4 = s.add_point(0, 14) + c = s.add_point(5, 5) + l1 = s.add_line(p1, p2) + l2 = s.add_line(p3, p4) + circ = s.add_circle(c, p1) + + # Call shortcuts not covered in main workflow test + s.constrain_equal_distance(p1, p2, p3, p4) + s.constrain_coincident(p1, p3) + s.constrain_point_on_line(p3, l1) + s.constrain_perpendicular(l1, l2) + s.constrain_diameter(circ, 20.0) + s.constrain_equal_length([l1, circ]) + s.constrain_symmetry([p1, p2, p3], []) # Point symmetry + s.constrain_symmetry([p3, p4], [l1]) # Line symmetry + + assert len(s.constraints) == 8 + assert isinstance(s.constraints[0], EqualDistanceConstraint) + assert isinstance(s.constraints[2], PointOnLineConstraint) + assert isinstance(s.constraints[3], PerpendicularConstraint) + assert isinstance(s.constraints[5], EqualLengthConstraint) + assert isinstance(s.constraints[6], SymmetryConstraint) + assert isinstance(s.constraints[7], SymmetryConstraint) + + +def test_sketch_serialization_from_file(): + """ + Tests that a sketch can be loaded from a file, serialized back to a + dictionary, and re-loaded from that dictionary without data loss. + """ + # 1. Locate the file relative to this test file + test_dir = Path(__file__).parent + file_path = test_dir / "rect.rfs" + + if not file_path.exists(): + pytest.skip(f"Test data file not found: {file_path}") + + # 2. Load the sketch from the project file + sketch1 = Sketch.from_file(file_path) + assert sketch1.uid is not None + assert isinstance(sketch1.uid, str) + + # 3. Serialize the loaded sketch back into a dictionary + data_from_sketch1 = sketch1.to_dict() + assert "uid" in data_from_sketch1 + assert data_from_sketch1["uid"] == sketch1.uid + + # 4. Create a second sketch instance from the serialized dictionary + sketch2 = Sketch.from_dict(data_from_sketch1) + assert sketch2.uid == sketch1.uid + + # 5. Serialize the second sketch + data_from_sketch2 = sketch2.to_dict() + + # 6. The serialized data from both sketches must be identical. + # This proves that the `from_dict` -> `to_dict` round trip is perfect. + assert data_from_sketch1 == data_from_sketch2, ( + "Serialization round-trip failed" + ) + + # 7. As a final check, ensure both sketches are functionally equivalent + # by solving and comparing a key result. + assert sketch1.solve() is True + assert sketch2.solve() is True + p_final_s1 = sketch1.registry.get_point(8) # A point from the sketch + p_final_s2 = sketch2.registry.get_point(8) + assert p_final_s1.pos() == pytest.approx(p_final_s2.pos()) + + +def test_sketch_initializes_with_empty_varset(): + """Test that a new Sketch has a valid, empty input_parameters VarSet.""" + sketch = Sketch() + assert sketch.input_parameters is not None + assert len(sketch.input_parameters) == 0 + + +def test_bridge_from_varset_to_solver(): + """ + Verify that a value from input_parameters is correctly used by the solver. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + + # 1. Define an input parameter + width_var = FloatVar(key="width", label="Overall Width", default=123.45) + sketch.input_parameters.add(width_var) + + # 2. Use it in a constraint + sketch.constrain_distance(p1, p2, "width") + + # 3. Solve the sketch + assert sketch.solve() is True + + # 4. Verify the result + pt1 = sketch.registry.get_point(p1) + pt2 = sketch.registry.get_point(p2) + final_dist = math.hypot(pt2.x - pt1.x, pt2.y - pt1.y) + + assert final_dist == pytest.approx(123.45) + + +def test_input_parameter_overrides_internal_parameter(): + """ + Verify that if an input_parameter has the same key as an internal + parameter, the input_parameter's value takes precedence. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(100, 0) + + # 1. Set an internal parameter with a "wrong" value + sketch.set_param("width", 999.9) + + # 2. Define an input parameter with the "correct" value + width_var = FloatVar(key="width", label="Width", default=50.0) + sketch.input_parameters.add(width_var) + + # 3. Use it in a constraint + sketch.constrain_distance(p1, p2, "width") + + # 4. Solve the sketch + assert sketch.solve() is True + + # 5. Verify the result uses the value from the input_parameter + pt1 = sketch.registry.get_point(p1) + pt2 = sketch.registry.get_point(p2) + final_dist = math.hypot(pt2.x - pt1.x, pt2.y - pt1.y) + + assert final_dist == pytest.approx(50.0) + + +def test_solve_with_no_input_parameters(): + """ + Verify that a sketch with no input parameters still solves correctly. + """ + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 20) + + # Use a hard-coded value in the constraint + sketch.constrain_distance(p1, p2, 25.0) + assert sketch.solve() is True + + pt1 = sketch.registry.get_point(p1) + pt2 = sketch.registry.get_point(p2) + final_dist = math.hypot(pt2.x - pt1.x, pt2.y - pt1.y) + + assert final_dist == pytest.approx(25.0) + + +def test_sketch_serialization_roundtrip_with_input_parameters(): + """ + Verify that a sketch with input_parameters can be serialized and + deserialized correctly, respecting the include_input_values flag. + """ + # 1. Create a sketch and add an input parameter + sketch = Sketch() + assert sketch.uid is not None + width_var = FloatVar( + key="width", label="Overall Width", default=140.0, value=120.0 + ) + sketch.input_parameters.add(width_var) + + # Add some geometry that uses the parameter + p1 = sketch.add_point(0, 0, fixed=True) + p2 = sketch.add_point(100, 0) + sketch.constrain_distance(p1, p2, "width") + + # 2. Serialize to dict WITH value (state) + sketch_data_with_values = sketch.to_dict(include_input_values=True) + + # 3. Assert serialized state data is correct + assert "uid" in sketch_data_with_values + assert "input_parameters" in sketch_data_with_values + var_data_with_value = sketch_data_with_values["input_parameters"]["vars"][ + 0 + ] + assert var_data_with_value["key"] == "width" + assert var_data_with_value["default"] == 140.0 + assert "value" in var_data_with_value + assert var_data_with_value["value"] == 120.0 + + # 4. Serialize to dict WITHOUT value (definition) + sketch_data_definition_only = sketch.to_dict(include_input_values=False) + + # 5. Assert serialized definition data is correct + var_data_def_only = sketch_data_definition_only["input_parameters"][ + "vars" + ][0] + assert var_data_def_only["key"] == "width" + assert var_data_def_only["default"] == 140.0 + assert "value" not in var_data_def_only + + # 6. Deserialize back into a new sketch from the state data + new_sketch = Sketch.from_dict(sketch_data_with_values) + assert new_sketch.uid == sketch.uid + + # 7. Assert the new sketch is reconstructed correctly + reloaded_var = new_sketch.input_parameters.get("width") + assert isinstance(reloaded_var, FloatVar) + assert reloaded_var.default == 140.0 + assert reloaded_var.value == 120.0 # Value is restored + + # 8. Check: does it still solve correctly with restored value? + assert new_sketch.solve() is True + pt2_reloaded = new_sketch.registry.get_point(p2) + final_dist = math.hypot(pt2_reloaded.x, pt2_reloaded.y) + assert final_dist == pytest.approx(120.0) + + # 9. Test rehydration from definition-only data + new_sketch_from_def = Sketch.from_dict(sketch_data_definition_only) + reloaded_var_from_def = new_sketch_from_def.input_parameters.get("width") + assert reloaded_var_from_def is not None + # Value should be the default, not the original value + assert reloaded_var_from_def.value == 140.0 + + # 10. Check that it solves with the default value + assert new_sketch_from_def.solve() is True + pt2_reloaded_from_def = new_sketch_from_def.registry.get_point(p2) + final_dist_from_def = math.hypot( + pt2_reloaded_from_def.x, pt2_reloaded_from_def.y + ) + assert final_dist_from_def == pytest.approx(140.0) + + +def test_sketch_deserialization_backward_compatibility(): + """ + Verify that a sketch dictionary without the 'input_parameters' and 'uid' + keys can be loaded without crashing. + """ + # 1. Create a dictionary representing an old file format + old_sketch_data = { + "params": {"expressions": {"width": "100"}}, + "registry": { + "points": [ + {"id": 0, "x": 0.0, "y": 0.0, "fixed": True, "label": None} + ], + "entities": [], + }, + "constraints": [], + "origin_id": 0, + } + + # 2. Try to load it + try: + sketch = Sketch.from_dict(old_sketch_data) + except (KeyError, ValueError, TypeError) as e: + pytest.fail(f"Sketch.from_dict failed to load old format: {e}") + + # 3. Assert the result is a valid sketch + assert sketch is not None + assert isinstance(sketch, Sketch) + # Assert a new UID was generated + assert sketch.uid is not None + assert isinstance(sketch.uid, str) + # Assert an empty VarSet was created + assert sketch.input_parameters is not None + assert len(sketch.input_parameters) == 0 + # Assert fills list is initialized and empty for backward compatibility + assert sketch.fills == [] + # Make sure other parts loaded correctly + assert sketch.params.get("width") == 100.0 + assert sketch.name == "" + + +def test_sketch_serialization_roundtrip_with_fill(): + """ + Verify that a sketch with a Fill can be serialized and deserialized + correctly. + """ + # 1. Create a sketch with a simple closed shape + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + p3 = s.add_point(10, 10) + p4 = s.add_point(0, 10) + s.add_line(p1, p2) + s.add_line(p2, p3) + s.add_line(p3, p4) + s.add_line(p4, p1) + + # 2. Find the loop and create a Fill object for it + loops = s._find_all_closed_loops() + assert len(loops) == 1 + boundary = loops[0] + fill_uid = str(uuid.uuid4()) + s.fills.append(Fill(uid=fill_uid, boundary=boundary)) + + # 3. Serialize the sketch to a dictionary + sketch_data = s.to_dict() + assert "fills" in sketch_data + assert len(sketch_data["fills"]) == 1 + fill_data = sketch_data["fills"][0] + assert fill_data["uid"] == fill_uid + # to_dict() should return lists for JSON compatibility + expected_boundary = [list(item) for item in boundary] + assert fill_data["boundary"] == expected_boundary + + # 4. Deserialize back into a new sketch + new_sketch = Sketch.from_dict(sketch_data) + assert new_sketch.uid == s.uid + assert len(new_sketch.fills) == 1 + reloaded_fill = new_sketch.fills[0] + assert isinstance(reloaded_fill, Fill) + assert reloaded_fill.uid == fill_uid + assert reloaded_fill.boundary == boundary + + +def test_fill_is_removed_when_boundary_is_broken(): + """ + Tests that a Fill is automatically removed if its boundary is no longer + a valid closed loop after a geometry change. + """ + # 1. Create a sketch with a filled square + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + p3 = s.add_point(10, 10) + p4 = s.add_point(0, 10) + s.add_line(p1, p2) + s.add_line(p2, p3) + l3 = s.add_line(p3, p4) + s.add_line(p4, p1) + + loops = s._find_all_closed_loops() + assert len(loops) == 1 + s.fills.append(Fill(uid=str(uuid.uuid4()), boundary=loops[0])) + assert len(s.fills) == 1 + + # 2. Get the entity to remove and call the public removal method + l3_entity = s.registry.get_entity(l3) + assert l3_entity is not None + s.remove_entities([l3_entity]) + + # 3. Assert that the fill has been automatically removed by the method + assert len(s.fills) == 0 + + +def test_remove_point_if_unused(): + """ + Tests the remove_point_if_unused method. + """ + s = Sketch() + + # 1. Test removing a point that is not used by any entity + unused_pid = s.add_point(100, 100) + assert s.remove_point_if_unused(unused_pid) is True + assert unused_pid not in [p.id for p in s.registry.points] + + # 2. Test that None returns False and does nothing + assert s.remove_point_if_unused(None) is False + + # 3. Test that a point used by an entity is not removed + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + s.add_line(p1, p2) + assert s.remove_point_if_unused(p1) is False + assert p1 in [p.id for p in s.registry.points] + + # 4. Test removing a point after its entity is removed + p3 = s.add_point(20, 0) + p4 = s.add_point(30, 0) + l2 = s.add_line(p3, p4) + entity = s.registry.get_entity(l2) + if entity is not None: + s.remove_entities([entity]) + assert s.remove_point_if_unused(p3) is True + assert s.remove_point_if_unused(p4) is True + assert p3 not in [p.id for p in s.registry.points] + assert p4 not in [p.id for p in s.registry.points] + + # 5. Test that origin point is removed when not used by any entity + # (in a new sketch with no entities, origin is considered unused) + origin_pid = s.origin_id + assert s.remove_point_if_unused(origin_pid) is True + assert origin_pid not in [p.id for p in s.registry.points] + + # 6. Test that origin point is NOT removed when used by an entity + s2 = Sketch() + origin_pid2 = s2.origin_id + p_other = s2.add_point(10, 0) + s2.add_line(origin_pid2, p_other) + assert s2.remove_point_if_unused(origin_pid2) is False + assert origin_pid2 in [p.id for p in s2.registry.points] + + +class TestSketchLoopFindingHelpers: + @pytest.fixture + def cross_sketch(self): + """A sketch with 4 lines meeting at the origin.""" + s = Sketch() + p_center = s.origin_id + p_right = s.add_point(10, 0) + p_up = s.add_point(0, 10) + p_left = s.add_point(-10, 0) + p_down = s.add_point(0, -10) + l_right = s.add_line(p_center, p_right) # Angle 0 + l_up = s.add_line(p_center, p_up) # Angle pi/2 + l_left = s.add_line(p_center, p_left) # Angle pi + l_down = s.add_line(p_center, p_down) # Angle -pi/2 + return s, { + "center": p_center, + "l_right": l_right, + "l_up": l_up, + "l_left": l_left, + "l_down": l_down, + } + + def test_build_adjacency_list(self): + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + l1 = s.add_line(p1, p2) + adj = s._build_adjacency_list() + + assert p1 in adj + assert p2 in adj + assert len(adj[p1]) == 1 + assert len(adj[p2]) == 1 + + edge_from_p1 = adj[p1][0] + assert edge_from_p1["id"] == l1 + assert edge_from_p1["to"] == p2 + assert edge_from_p1["fwd"] is True + + edge_from_p2 = adj[p2][0] + assert edge_from_p2["id"] == l1 + assert edge_from_p2["to"] == p1 + assert edge_from_p2["fwd"] is False + + def test_sort_edges_by_angle(self, cross_sketch): + s, ids = cross_sketch + adj = s._build_adjacency_list() + sorted_adj = s._sort_edges_by_angle(adj) + + center_edges = sorted_adj[ids["center"]] + assert len(center_edges) == 4 + + # Expected order: down (-pi/2), right (0), up (pi/2), left (pi) + sorted_ids = [e["id"] for e in center_edges] + assert sorted_ids == [ + ids["l_down"], + ids["l_right"], + ids["l_up"], + ids["l_left"], + ] + + def test_get_next_edge_ccw(self, cross_sketch): + s, ids = cross_sketch + adj = s._build_adjacency_list() + sorted_adj = s._sort_edges_by_angle(adj) + + center_id = ids["center"] + + # To test finding the next edge in a loop, we simulate arriving + # at the center. + # + # Scene: We arrive at Center from the Right. + # The entity 'l_right' connects Center(0,0) and Right(10,0). + # We traveled Right -> Center. This is the reverse direction of + # l_right. So incoming_fwd = False. + # + # To continue the loop CCW (keeping face on left), we need to make a + # LEFT turn at the Center. + # In: (-1, 0) (Right -> Center) + # Options: Up (0, 1), Left (-1, 0), Down (0, -1), Right (1, 0). + # A left turn relative to (-1, 0) is Down (0, -1). + # Note: In standard Cartesian coords: + # Vector A=(-1,0). Vector B=(0,-1). + # Cross Z = (-1*-1) - 0 = 1 (Positive/CCW). + # Vector B=(0,1) (Up). Cross Z = (-1*1) - 0 = -1 (Negative/CW). + # So Down is the correct Left turn. + + next_edge = s._get_next_edge_ccw( + center_id, ids["l_right"], False, sorted_adj + ) + assert next_edge["id"] == ids["l_down"] + + def test_calculate_loop_signed_area(self): + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + p3 = s.add_point(10, 10) + p4 = s.add_point(0, 10) + l1 = s.add_line(p1, p2) + l2 = s.add_line(p2, p3) + l3 = s.add_line(p3, p4) + l4 = s.add_line(p4, p1) + + # CCW Loop: P1->P2->P3->P4->P1 + ccw_loop = [(l1, True), (l2, True), (l3, True), (l4, True)] + area_ccw = s._calculate_loop_signed_area(ccw_loop) + assert area_ccw == pytest.approx(100.0) + + # CW Loop: P1->P4->P3->P2->P1 + # L4 is P4->P1. False is P1->P4. + # L3 is P3->P4. False is P4->P3. + # L2 is P2->P3. False is P3->P2. + # L1 is P1->P2. False is P2->P1. + cw_loop = [(l4, False), (l3, False), (l2, False), (l1, False)] + area_cw = s._calculate_loop_signed_area(cw_loop) + assert area_cw == pytest.approx(-100.0) + + # Degenerate Loop (back and forth) + degen_loop = [(l1, True), (l1, False)] + area_degen = s._calculate_loop_signed_area(degen_loop) + assert area_degen == pytest.approx(0.0) + + +def test_sketch_find_all_closed_loops(): + """Tests the core graph traversal algorithm for finding faces.""" + + def get_loop_ids(loops): + """Helper to get sets of entity IDs for easy comparison.""" + return [{item[0] for item in loop} for loop in loops] + + # --- Test Case 1: Simple Square --- + s1 = Sketch() + p1 = s1.add_point(0, 0) + p2 = s1.add_point(10, 0) + p3 = s1.add_point(10, 10) + p4 = s1.add_point(0, 10) + l1 = s1.add_line(p1, p2) + l2 = s1.add_line(p2, p3) + l3 = s1.add_line(p3, p4) + l4 = s1.add_line(p4, p1) + loops1 = s1._find_all_closed_loops() + assert len(loops1) == 1 + assert get_loop_ids(loops1)[0] == {l1, l2, l3, l4} + + # --- Test Case 2: Dangling Edge --- + s2 = Sketch() + p1 = s2.add_point(0, 0) + p2 = s2.add_point(10, 0) + p3 = s2.add_point(10, 10) + p4 = s2.add_point(0, 10) + p5 = s2.add_point(20, 0) + l1 = s2.add_line(p1, p2) + l2 = s2.add_line(p2, p3) + l3 = s2.add_line(p3, p4) + l4 = s2.add_line(p4, p1) + s2.add_line(p2, p5) # Dangling line, no assignment needed + loops2 = s2._find_all_closed_loops() + assert len(loops2) == 1 + assert get_loop_ids(loops2)[0] == {l1, l2, l3, l4} + + # --- Test Case 3: Two Separate Shapes --- + s3 = Sketch() + # Shape A + p1 = s3.add_point(0, 0) + p2 = s3.add_point(1, 0) + p3 = s3.add_point(1, 1) + p4 = s3.add_point(0, 1) + la1 = s3.add_line(p1, p2) + la2 = s3.add_line(p2, p3) + la3 = s3.add_line(p3, p4) + la4 = s3.add_line(p4, p1) + # Shape B + p5 = s3.add_point(10, 10) + p6 = s3.add_point(11, 10) + p7 = s3.add_point(11, 11) + p8 = s3.add_point(10, 11) + lb1 = s3.add_line(p5, p6) + lb2 = s3.add_line(p6, p7) + lb3 = s3.add_line(p7, p8) + lb4 = s3.add_line(p8, p5) + loops3 = s3._find_all_closed_loops() + assert len(loops3) == 2 + loop_sets3 = get_loop_ids(loops3) + assert {la1, la2, la3, la4} in loop_sets3 + assert {lb1, lb2, lb3, lb4} in loop_sets3 + + # --- Test Case 4: Figure-Eight (Shared Point/Edge) --- + s4 = Sketch() + # Left loop + p1 = s4.add_point(-1, -1) + p2 = s4.add_point(0, -1) + p3 = s4.add_point(0, 0) # Shared point + p4 = s4.add_point(-1, 0) + ll1 = s4.add_line(p1, p2) + ll2 = s4.add_line(p2, p3) + ll3 = s4.add_line(p3, p4) + ll4 = s4.add_line(p4, p1) + # Right loop + p5 = s4.add_point(1, 0) + p6 = s4.add_point(1, -1) + rl1 = s4.add_line(p3, p5) + rl2 = s4.add_line(p5, p6) + rl3 = s4.add_line(p6, p2) + loops4 = s4._find_all_closed_loops() + assert len(loops4) == 2 + loop_sets4 = get_loop_ids(loops4) + assert {ll1, ll2, ll3, ll4} in loop_sets4 + assert {rl1, rl2, rl3, ll2} in loop_sets4 # ll2 is shared edge + + # --- Test Case 5: Shape with an Arc (D-Shape) --- + s5 = Sketch() + p1 = s5.add_point(-10, 0) + p2 = s5.add_point(10, 0) + c = s5.add_point(0, 0) + l1 = s5.add_line(p1, p2) + a1 = s5.add_arc(p2, p1, c, clockwise=False) # Semicircle + loops5 = s5._find_all_closed_loops() + assert len(loops5) == 1 + assert get_loop_ids(loops5)[0] == {l1, a1} + + # --- Test Case 6: Circle --- + s6 = Sketch() + c = s6.add_point(0, 0) + r = s6.add_point(10, 0) + c1 = s6.add_circle(c, r) + loops6 = s6._find_all_closed_loops() + assert len(loops6) == 1 + assert get_loop_ids(loops6)[0] == {c1} + + # --- Test Case 7: Shape with Hole --- + s7 = Sketch() + # Outer + op1 = s7.add_point(0, 0) + op2 = s7.add_point(10, 0) + op3 = s7.add_point(10, 10) + op4 = s7.add_point(0, 10) + ol1 = s7.add_line(op1, op2) + ol2 = s7.add_line(op2, op3) + ol3 = s7.add_line(op3, op4) + ol4 = s7.add_line(op4, op1) + # Inner + ip1 = s7.add_point(2, 2) + ip2 = s7.add_point(8, 2) + ip3 = s7.add_point(8, 8) + ip4 = s7.add_point(2, 8) + il1 = s7.add_line(ip1, ip2) + il2 = s7.add_line(ip2, ip3) + il3 = s7.add_line(ip3, ip4) + il4 = s7.add_line(ip4, ip1) + loops7 = s7._find_all_closed_loops() + assert len(loops7) == 2 + loop_sets7 = get_loop_ids(loops7) + assert {ol1, ol2, ol3, ol4} in loop_sets7 + assert {il1, il2, il3, il4} in loop_sets7 + + +def test_find_all_closed_loops_with_circle_hole(): + """Tests finding loops when a circle is used as a hole.""" + s = Sketch() + # Outer square + p1 = s.add_point(-10, -10) + p2 = s.add_point(10, -10) + p3 = s.add_point(10, 10) + p4 = s.add_point(-10, 10) + l1 = s.add_line(p1, p2) + l2 = s.add_line(p2, p3) + l3 = s.add_line(p3, p4) + l4 = s.add_line(p4, p1) + # Inner circle + c = s.add_point(0, 0) + r = s.add_point(5, 0) + c1 = s.add_circle(c, r) + + loops = s._find_all_closed_loops() + assert len(loops) == 2 + + loop_sets = [{item[0] for item in loop} for loop in loops] + assert {l1, l2, l3, l4} in loop_sets + assert {c1} in loop_sets + + # Verify area calculation is correct for the circle loop + circle_loop = next(loop for loop in loops if loop[0][0] == c1) + area = s._calculate_loop_signed_area(circle_loop) + assert area == pytest.approx(math.pi * 5**2) + + +def test_sketch_fill_geometry_generation_circle(): + """Test generating fill geometry for a single Circle loop.""" + sketch = Sketch() + c = sketch.add_point(0, 0) + r = sketch.add_point(10, 0) + + # Create circle + cid = sketch.add_circle(c, r) + + # Define fill + fill = Fill(uid="fill1", boundary=[(cid, True)]) + sketch.fills.append(fill) + + # Generate geometries + render_data = sketch.get_fill_render_data() + + assert len(render_data) == 1 + geo = render_data[0].geometry + data = geo.data + assert data is not None + + # Check commands: should be 1 MoveTo + 2 ArcTo (semicircles) + assert len(geo) == 3 + + # Start at radius point (10, 0) + assert isinstance(data[0], Move) + assert data[0].end[0] == pytest.approx(10.0) + assert data[0].end[1] == pytest.approx(0.0) + + # First arc to (-10, 0). Center is (0,0). Offset from (10,0) is (-10, 0). + arc1 = data[1] + assert isinstance(arc1, GeoArc) + assert arc1.end[0] == pytest.approx(-10.0) + assert arc1.end[1] == pytest.approx(0.0) + assert arc1.center_offset[0] == pytest.approx(-10.0) + assert arc1.center_offset[1] == pytest.approx(0.0) + assert arc1.clockwise is False + + # Second arc to (10, 0). Offset from (-10,0) is (10, 0). + arc2 = data[2] + assert isinstance(arc2, GeoArc) + assert arc2.end[0] == pytest.approx(10.0) + assert arc2.end[1] == pytest.approx(0.0) + assert arc2.center_offset[0] == pytest.approx(10.0) + assert arc2.center_offset[1] == pytest.approx(0.0) + assert arc2.clockwise is False + + +def test_sketch_fill_geometry_generation_rect(): + """Test generating fill geometry for a rectangle (multi-segment lines).""" + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(10, 10) + p4 = sketch.add_point(0, 10) + + l1 = sketch.add_line(p1, p2) + l2 = sketch.add_line(p2, p3) + l3 = sketch.add_line(p3, p4) + l4 = sketch.add_line(p4, p1) + + # Define fill loop + boundary = [ + (l1, True), # p1 -> p2 + (l2, True), # p2 -> p3 + (l3, True), # p3 -> p4 + (l4, True), # p4 -> p1 + ] + fill = Fill(uid="fill_rect", boundary=boundary) + sketch.fills.append(fill) + + render_data = sketch.get_fill_render_data() + assert len(render_data) == 1 + geo = render_data[0].geometry + data = geo.data + assert data is not None + + assert len(geo) == 5 # MoveTo + 4 LineTo + + # Check start + assert data[0].end[0] == pytest.approx(0.0) + assert data[0].end[1] == pytest.approx(0.0) + + # Check sequence + assert isinstance(data[1], GeoLine) + assert data[1].end[0] == pytest.approx(10.0) + assert data[1].end[1] == pytest.approx(0.0) + + assert isinstance(data[2], GeoLine) + assert data[2].end[0] == pytest.approx(10.0) + assert data[2].end[1] == pytest.approx(10.0) + + assert isinstance(data[3], GeoLine) + assert data[3].end[0] == pytest.approx(0.0) + assert data[3].end[1] == pytest.approx(10.0) + + assert isinstance(data[4], GeoLine) + assert data[4].end[0] == pytest.approx(0.0) + assert data[4].end[1] == pytest.approx(0.0) + + +def test_sketch_fill_geometry_generation_arc_shape(): + """Test fill with mixed Lines and Arcs, including reverse traversal.""" + sketch = Sketch() + + # Shape: A semi-circle connected by a straight line. + # Points: (-10, 0) and (10, 0). Center (0,0). + + p_left = sketch.add_point(-10, 0) + p_right = sketch.add_point(10, 0) + p_center = sketch.add_point(0, 0) + + # Line from Left to Right + line_id = sketch.add_line(p_left, p_right) + + # Arc from Left to Right (top half). Clockwise. + # Start: Left, End: Right, Center: Center. + # If CW: goes Left -> Top -> Right. + arc_id = sketch.add_arc(p_left, p_right, p_center, clockwise=True) + + # Loop definition: + # 1. Line: Left -> Right (Forward) + # 2. Arc: Right -> Left (Reverse of Arc definition Left->Right) + + boundary = [ + (line_id, True), # (-10,0) -> (10,0) + (arc_id, False), # (10,0) -> (-10,0) via arc + ] + + fill = Fill(uid="fill_arc", boundary=boundary) + sketch.fills.append(fill) + + render_data = sketch.get_fill_render_data() + assert len(render_data) == 1 + geo = render_data[0].geometry + data = geo.data + assert data is not None + + assert len(geo) == 3 # MoveTo + LineTo + ArcTo + + # 1. MoveTo Start of Line (-10, 0) + assert isinstance(data[0], Move) + assert data[0].end[0] == pytest.approx(-10.0) + assert data[0].end[1] == pytest.approx(0.0) + + # 2. LineTo End of Line (10, 0) + assert isinstance(data[1], GeoLine) + assert data[1].end[0] == pytest.approx(10.0) + assert data[1].end[1] == pytest.approx(0.0) + + # 3. ArcTo back to (-10, 0) + # Entity is CW. Traversal is Reverse. + # In `get_fill_render_data`: is_cw = not entity.clockwise if not fwd. + # So is_cw should be False (CCW). + + cmd_arc = data[2] + assert isinstance(cmd_arc, GeoArc) + assert cmd_arc.end[0] == pytest.approx(-10.0) + assert cmd_arc.end[1] == pytest.approx(0.0) + + # Center offset relative to current point (10, 0). Center is (0,0). + # Offset = (0 - 10, 0 - 0) = (-10, 0) + assert cmd_arc.center_offset[0] == pytest.approx(-10.0) + assert cmd_arc.center_offset[1] == pytest.approx(0.0) + + # Direction check + assert cmd_arc.clockwise is False + + +def test_get_coincident_points_no_constraints(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + sketch.add_point(10, 0) + + result = sketch.get_coincident_points(p1) + + assert result == {p1} + + +def test_get_coincident_points_single_pair(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + + sketch.constraints.append(CoincidentConstraint(p1, p2)) + + result = sketch.get_coincident_points(p1) + + assert result == {p1, p2} + + +def test_get_coincident_points_transitive(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + + sketch.constraints.append(CoincidentConstraint(p1, p2)) + sketch.constraints.append(CoincidentConstraint(p2, p3)) + + result = sketch.get_coincident_points(p1) + + assert result == {p1, p2, p3} + + +def test_get_coincident_points_chain_from_middle(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + + sketch.constraints.append(CoincidentConstraint(p1, p2)) + sketch.constraints.append(CoincidentConstraint(p2, p3)) + + result = sketch.get_coincident_points(p2) + + assert result == {p1, p2, p3} + + +def test_get_coincident_points_separate_groups(): + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 0) + p3 = sketch.add_point(20, 0) + p4 = sketch.add_point(30, 0) + + sketch.constraints.append(CoincidentConstraint(p1, p2)) + sketch.constraints.append(CoincidentConstraint(p3, p4)) + + result1 = sketch.get_coincident_points(p1) + result3 = sketch.get_coincident_points(p3) + + assert result1 == {p1, p2} + assert result3 == {p3, p4} diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_solver.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_solver.py new file mode 100644 index 000000000..e1714f97d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketcher_solver.py @@ -0,0 +1,212 @@ +import math + +import pytest +from sketcher.core.constraints import ( + DistanceConstraint, + DragConstraint, + HorizontalConstraint, + VerticalConstraint, +) +from sketcher.core.params import ParameterContext +from sketcher.core.registry import EntityRegistry +from sketcher.core.solver import Solver + + +def test_solver_simple_move(): + """Test moving a single point to satisfy a distance constraint.""" + reg = EntityRegistry() + params = ParameterContext() + + # p1 fixed at origin + p1 = reg.add_point(0, 0, fixed=True) + # p2 at (5, 0), want it at (10, 0) + p2 = reg.add_point(5, 0, fixed=False) + + # Constrain distance to 10 + constraints = [ + HorizontalConstraint(p1, p2), # Keep it on x-axis + DistanceConstraint(p1, p2, 10.0), + ] + + solver = Solver(reg, params, constraints) + success = solver.solve() + + assert success is True + pt2 = reg.get_point(p2) + assert pt2.x == pytest.approx(10.0, abs=1e-4) + assert pt2.y == pytest.approx(0.0, abs=1e-4) + + +def test_solver_fixed_point(): + """Test that fixed points do not move.""" + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0, fixed=True) # THIS IS FIXED + + # Impossible constraint: fixed points are 10 apart, we want 5 + constraints = [DistanceConstraint(p1, p2, 5.0)] + + solver = Solver(reg, params, constraints) + solver.solve() + + pt2 = reg.get_point(p2) + assert pt2.x == 10.0 # Should not have moved + + # Check if constraints are actually satisfied + err = constraints[0].error(reg, params) + assert abs(err) > 1.0 # Constraint definitely failed + + +def test_solver_degrees_of_freedom(): + """Test a system with multiple moving parts.""" + reg = EntityRegistry() + params = ParameterContext() + + # p3 -- p4 + # | | + # p1 -- p2 + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0) # Free + p3 = reg.add_point(0, 10) # Free + + # Right triangle 3-4-5 logic check + # Make p1-p2 length 3 + # Make p1-p3 length 4 + # Distance p2-p3 should naturally become 5 + + constraints = [ + HorizontalConstraint(p1, p2), + VerticalConstraint(p1, p3), + DistanceConstraint(p1, p2, 3.0), + DistanceConstraint(p1, p3, 4.0), + ] + + solver = Solver(reg, params, constraints) + assert solver.solve() is True + + dist_hypotenuse = math.hypot( + reg.get_point(p2).x - reg.get_point(p3).x, + reg.get_point(p2).y - reg.get_point(p3).y, + ) + assert dist_hypotenuse == pytest.approx(5.0, abs=1e-4) + + +def test_solver_drag_behavior(): + """Test that DragConstraint moves unconstrained points.""" + reg = EntityRegistry() + params = ParameterContext() + + # Point at origin + p1 = reg.add_point(0, 0) + + # Drag constraint to (10, 10) + c = DragConstraint(p1, 10.0, 10.0) + constraints = [c] + + solver = Solver(reg, params, constraints) + success = solver.solve() + + assert success is True + pt = reg.get_point(p1) + # Since there are no competing constraints, it should reach the target + assert pt.x == pytest.approx(10.0, abs=1e-4) + assert pt.y == pytest.approx(10.0, abs=1e-4) + + +def test_solver_drag_vs_geometry(): + """ + Test that geometric constraints overpower DragConstraint due to weight. + """ + reg = EntityRegistry() + params = ParameterContext() + + p1 = reg.add_point(0, 0, fixed=True) + p2 = reg.add_point(10, 0) + + # Hard Geometric Constraint: Distance must be 10 + # Note: Horizontal is implied by y=0 init and no vertical drag, + # but let's add Horizontal explicitly to be safe. + constraints = [ + HorizontalConstraint(p1, p2), + DistanceConstraint(p1, p2, 10.0), + # Drag Constraint: Try to pull p2 way out to (20, 0) with a low weight + DragConstraint(p2, 20.0, 0.0, weight=0.05), + ] + + solver = Solver(reg, params, constraints) + + # We do not assert success here because the solver returns False when + # a soft constraint (DragConstraint) causes the residual cost to remain + # above the strict tolerance (1e-6), even though it found the optimal + # solution. + solver.solve() + + pt2 = reg.get_point(p2) + + # Because DragConstraint has a small weight vs the implicit 1.0 of + # geometric constraints, the solver will prioritize DistanceConstraint. + # The point should be at x=10 (distance 10 from 0,0), not x=20. + assert pt2.x == pytest.approx(10.0, abs=0.1) + # Ensure it didn't get dragged all the way to 20 + assert pt2.x != pytest.approx(20.0, abs=0.1) + + +def test_solver_no_constraints(): + """ + Test solver behavior when there are mutable points but no constraints. + """ + reg = EntityRegistry() + params = ParameterContext() + reg.add_point(0, 0) # Mutable + + solver = Solver(reg, params, []) + # Should succeed immediately (residuals return [0.0]) + assert solver.solve() is True + + +def test_solver_no_mutable_points(): + """Test solver behavior when everything is fixed.""" + reg = EntityRegistry() + params = ParameterContext() + reg.add_point(0, 0, fixed=True) + + # Constraint exists, but nothing can move + # Solver calculates error, sees it can't move anything, + # returns True immediately (optimization check) + solver = Solver(reg, params, []) + assert solver.solve() is True + + +def test_solver_impossible_constraints(): + """Test solver reporting failure on impossible geometry.""" + reg = EntityRegistry() + params = ParameterContext() + p1 = reg.add_point(0, 0, fixed=True) + + # Initialize p2 near the compromise to test failure reporting + # logic rather than optimizer descent capabilities in singular landscapes. + p2 = reg.add_point(15.1, 0) # Start slightly off + + constraints = [ + # Must be at dist 10 from p1 + DistanceConstraint(p1, p2, 10.0), + # BUT must also be at dist 20 from p1 + DistanceConstraint(p1, p2, 20.0), + ] + + solver = Solver(reg, params, constraints) + success = solver.solve() + + # success flag should be False because residuals > tolerance + # even though it is at the best possible location. + assert success is False + + # The solver minimizes sum of squares of linear errors: + # E = (d-10)^2 + (d-20)^2 + # The minimum for this is at d = (10+20)/2 = 15.0 + optimal_dist = 15.0 + # Relax tolerance slightly as the solver may terminate just short + assert reg.get_point(p2).x == pytest.approx(optimal_dist, abs=1e-2) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/image/test_sketch_exporter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/image/test_sketch_exporter.py new file mode 100644 index 000000000..0522169de --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/image/test_sketch_exporter.py @@ -0,0 +1,77 @@ +import json + +import pytest + +# Sketcher components +from sketcher.core import Sketch + +# Exporter to test +from sketcher.image.exporter import SketchExporter + +# Core components +from rayforge.core.doc import Doc +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def simple_sketch() -> Sketch: + """Creates a simple sketch object.""" + sketch = Sketch() + p1 = sketch.add_point(0, 0) + p2 = sketch.add_point(10, 10) + sketch.add_line(p1, p2) + sketch.constrain_distance(p1, p2, "14.14") + return sketch + + +def test_sketch_exporter_success(simple_sketch: Sketch): + """ + Tests that the SketchExporter correctly extracts the sketch definition + from a properly configured sketch-based WorkPiece. + """ + # 1. Create a document and add the sketch to its registry. + doc = Doc() + doc.add_asset(simple_sketch) + + # 2. Create a workpiece and link it to the sketch via UID. + # For this test, it doesn't need a source_segment. + workpiece = WorkPiece(name="MySketchWP") + workpiece.geometry_provider_uid = simple_sketch.uid + doc.add_workpiece(workpiece) + + # 3. Instantiate the exporter and run it + exporter = SketchExporter(workpiece) + exported_bytes = exporter.export() + + # 4. Assert that the exported data matches the original sketch data + expected_dict = simple_sketch.to_dict() + exported_dict = json.loads(exported_bytes) + assert exported_dict == expected_dict + + +def test_sketch_exporter_wrong_source_type(): + """ + Tests that the SketchExporter raises a ValueError if the WorkPiece + is not based on a sketch (i.e., has no sketch_uid). + """ + # 1. Create a workpiece that is NOT linked to a sketch + workpiece = WorkPiece(name="NotASketch") + doc = Doc() + doc.add_workpiece(workpiece) + + # 2. Assert that creating the exporter and calling export raises an error + exporter = SketchExporter(workpiece) + with pytest.raises(ValueError, match="not based on a sketch"): + exporter.export() + + +def test_sketch_exporter_init_with_wrong_item_type(): + """ + Tests that the SketchExporter raises a TypeError if initialized with + something other than a WorkPiece. + """ + doc = Doc() # A DocItem that is not a WorkPiece + with pytest.raises(TypeError, match="can only export WorkPiece items"): + # This line is intentionally incorrect for testing purposes. + # We tell the static type checker to ignore it. + SketchExporter(doc) # type: ignore diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/image/test_sketch_importer.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/image/test_sketch_importer.py new file mode 100644 index 000000000..4205f903d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/image/test_sketch_importer.py @@ -0,0 +1,420 @@ +import json +from pathlib import Path +from typing import cast + +import pytest +from raygeo.geo import Geometry +from sketcher.core import Sketch +from sketcher.image.importer import SketchImporter + +from rayforge.core.vectorization_spec import LayerImportMode, PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image.base_importer import ImporterFeature + + +@pytest.fixture +def complex_sketch() -> Sketch: + """Creates a moderately complex sketch for serialization testing.""" + s = Sketch(name="Complex Sketch") # Give fixture a more descriptive name + + # Parameters + s.set_param("width", 100) + s.set_param("height", "width * 0.5") # 50 + s.set_param("radius", 10) + + # Geometry + # A simple constrained rectangle + p0 = s.origin_id # ID 0 + p1 = s.add_point(100, 0) + p2 = s.add_point(100, 50) + p3 = s.add_point(0, 50) + + s.add_line(p0, p1) + s.add_line(p1, p2) + s.add_line(p2, p3) + s.add_line(p3, p0) + + # Constraints + s.constrain_horizontal(p0, p1) + s.constrain_vertical(p1, p2) + s.constrain_horizontal(p3, p2) + s.constrain_vertical(p0, p3) + s.constrain_distance(p0, p1, "width") + s.constrain_distance(p0, p3, "height") + + # An arc for good measure + p_center = s.add_point(120, 25) + p_start = s.add_point(130, 25) + p_end = s.add_point(120, 35) + + arc_id = s.add_arc(p_start, p_end, p_center, clockwise=False) + s.constrain_radius(entity_id=arc_id, radius="radius") + + s.solve() # Solve it to ensure a consistent state + return s + + +class TestSketchImporterContract: + """Tests for the Importer contract compliance of SketchImporter.""" + + def test_class_attributes(self): + """Tests that importer class has required attributes.""" + assert SketchImporter.label == "Rayforge Sketch" + assert SketchImporter.extensions == (".rfs",) + assert SketchImporter.features == {ImporterFeature.DIRECT_VECTOR} + + def test_scan_returns_manifest(self, complex_sketch: Sketch): + """Tests that scan() returns ImportManifest with correct data.""" + sketch_dict = complex_sketch.to_dict() + sketch_bytes = json.dumps( + sketch_dict, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + importer = SketchImporter( + data=sketch_bytes, source_file=Path("test.rfs") + ) + manifest = importer.scan() + + assert manifest.title == "Complex Sketch" + assert manifest.natural_size_mm is None + assert len(manifest.warnings) == 0 + assert len(manifest.errors) == 0 + + def test_scan_handles_invalid_data(self): + """Tests that scan() handles invalid JSON gracefully.""" + importer = SketchImporter(b"not json", Path("invalid.rfs")) + manifest = importer.scan() + + assert manifest.title == "invalid.rfs" + assert len(manifest.errors) > 0 + + def test_parse_returns_parsing_result(self, complex_sketch: Sketch): + """Tests that parse() returns ParsingResult with correct data.""" + sketch_dict = complex_sketch.to_dict() + sketch_bytes = json.dumps( + sketch_dict, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + importer = SketchImporter( + data=sketch_bytes, source_file=Path("test.rfs") + ) + parse_result = importer.parse() + + assert parse_result is not None + assert parse_result.is_y_down is False + assert parse_result.native_unit_to_mm == 1.0 + assert len(parse_result.layers) == 1 + assert parse_result.layers[0].layer_id == "__default__" + assert parse_result.layers[0].name == "__default__" + assert parse_result.world_frame_of_reference is not None + assert parse_result.background_world_transform is not None + + def test_parse_handles_invalid_data(self): + """Tests that parse() returns None for invalid JSON.""" + importer = SketchImporter(b"not json") + parse_result = importer.parse() + + assert parse_result is None + assert len(importer._errors) > 0 + + def test_vectorize_returns_vectorization_result( + self, complex_sketch: Sketch + ): + """Tests that vectorize() returns VectorizationResult.""" + sketch_dict = complex_sketch.to_dict() + sketch_bytes = json.dumps( + sketch_dict, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + importer = SketchImporter( + data=sketch_bytes, source_file=Path("test.rfs") + ) + parse_result = importer.parse() + assert parse_result is not None + + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + vec_result = importer.vectorize(parse_result, spec) + + assert vec_result is not None + assert vec_result.source_parse_result is parse_result + assert "__default__" in vec_result.geometries_by_layer + assert isinstance( + vec_result.geometries_by_layer["__default__"], Geometry + ) + assert "__default__" in vec_result.fills_by_layer + + def test_create_source_asset(self, complex_sketch: Sketch): + """Tests that create_source_asset() returns SourceAsset.""" + sketch_dict = complex_sketch.to_dict() + sketch_bytes = json.dumps( + sketch_dict, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + importer = SketchImporter( + data=sketch_bytes, source_file=Path("test.rfs") + ) + parse_result = importer.parse() + assert parse_result is not None + + source_asset = importer.create_source_asset(parse_result) + + assert source_asset.original_data == sketch_bytes + assert source_asset.metadata is not None + assert source_asset.metadata["is_vector"] is True + + +def test_sketch_importer_round_trip(complex_sketch: Sketch): + """ + Tests that a sketch can be serialized, imported, and correctly + reconstructed as a WorkPiece and Sketch object. + """ + # 1. Serialize the original sketch to bytes + original_dict = complex_sketch.to_dict() + sketch_bytes = json.dumps( + original_dict, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + # 2. Instantiate the importer with the serialized data + importer = SketchImporter( + data=sketch_bytes, source_file=Path("MyTestSketch.rfs") + ) + + # 3. Call get_doc_items() to get the payload + # Force a merge strategy + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + import_result = importer.get_doc_items(spec) + assert import_result is not None, "Importer failed to return result" + payload = import_result.payload + + assert payload is not None + assert importer.parsed_sketch is not None + assert len(payload.assets) == 1 + imported_sketch_template = payload.assets[0] + + # 4. Check the WorkPiece in the payload + assert len(payload.items) == 1 + item = payload.items[0] + + assert isinstance(item, WorkPiece) + # The importer should prioritize the serialized name over the filename. + assert item.name == complex_sketch.name + + assert item.source_segment is not None + assert item.source_segment.source_asset_uid == payload.source.uid + + assert item.geometry_provider_uid == complex_sketch.uid + + # 5. Verify the dimensions were set correctly on the WorkPiece + geo = complex_sketch.to_geometry() + min_x, min_y, max_x, max_y = geo.rect() + expected_width = max_x - min_x + expected_height = max_y - min_y + assert item.natural_width_mm == pytest.approx(expected_width) + assert item.natural_height_mm == pytest.approx(expected_height) + assert item.natural_size == pytest.approx( + (expected_width, expected_height) + ) + + # 6. Verify the sketch template itself was parsed correctly + parsed_sketch_dict = imported_sketch_template.to_dict() + assert parsed_sketch_dict == original_dict + + +def test_sketch_importer_naming_logic_serialized_priority( + complex_sketch: Sketch, +): + """ + Tests that if the JSON contains a name, it takes precedence over the + file name. + """ + complex_sketch.name = "SerializedName" + data = json.dumps(complex_sketch.to_dict()).encode("utf-8") + + # Pass a conflicting filename + importer = SketchImporter(data=data, source_file=Path("Filename.rfs")) + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + import_result = importer.get_doc_items(spec) + assert import_result is not None + payload = import_result.payload + + assert payload is not None + # Should use the name from JSON + assert payload.assets[0].name == "SerializedName" + assert payload.items[0].name == "SerializedName" + + +def test_sketch_importer_naming_logic_filename_fallback( + complex_sketch: Sketch, +): + """ + Tests that if the JSON has no name (or empty), it falls back to the + file name. + """ + # Create data with empty name + d = complex_sketch.to_dict() + d["name"] = "" + data = json.dumps(d).encode("utf-8") + + importer = SketchImporter(data=data, source_file=Path("MyDesign.rfs")) + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + import_result = importer.get_doc_items(spec) + assert import_result is not None + payload = import_result.payload + + assert payload is not None + # Should fall back to file stem + assert payload.assets[0].name == "MyDesign" + assert payload.items[0].name == "MyDesign" + + +def test_sketch_importer_naming_logic_default_fallback(complex_sketch: Sketch): + """ + Tests that if JSON has no name and no file is provided, the name + defaults to "Untitled". + """ + # Create data with missing name key entirely + d = complex_sketch.to_dict() + del d["name"] + data = json.dumps(d).encode("utf-8") + + importer = SketchImporter(data=data, source_file=None) + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + import_result = importer.get_doc_items(spec) + assert import_result is not None + payload = import_result.payload + + assert payload is not None + # Should fall back to the default name "Untitled". + assert payload.assets[0].name == "Untitled" + assert payload.items[0].name == "Untitled" + + +def test_sketch_importer_bad_data(): + """ + Tests that the importer returns ImportResult with None payload for invalid + or corrupted data. + """ + bad_data_1 = b"this is not json" + bad_data_2 = b'{"not": "a sketch"}' + + importer1 = SketchImporter(data=bad_data_1) + import_result1 = importer1.get_doc_items() + assert import_result1 is not None + assert import_result1.payload is None + assert import_result1.parse_result is None + assert len(import_result1.errors) > 0 + assert importer1.parsed_sketch is None + + importer2 = SketchImporter(data=bad_data_2) + import_result2 = importer2.get_doc_items() + assert import_result2 is not None + assert import_result2.payload is None + assert import_result2.parse_result is None + assert len(import_result2.errors) > 0 + assert importer2.parsed_sketch is None + + +def test_sketch_importer_round_trip_mouse(): + """ + Tests that the mouse.rfs sketch can be imported and correctly + reconstructed as a WorkPiece and Sketch object. + """ + # 1. Read the original mouse.rfs file + mouse_file = Path(__file__).parent.parent / "assets" / "mouse.rfs" + original_data = mouse_file.read_bytes() + + # 2. Parse the original JSON for comparison + original_dict = json.loads(original_data) + + # 3. Instantiate the importer with the serialized data + importer = SketchImporter(data=original_data, source_file=mouse_file) + + # 4. Call get_doc_items() to get the payload + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + import_result = importer.get_doc_items(spec) + assert import_result is not None, "Importer failed to return result" + payload = import_result.payload + assert payload is not None + + assert importer.parsed_sketch is not None + assert len(payload.assets) == 1 + imported_sketch_template = payload.assets[0] + + # 5. Check the WorkPiece in the payload + assert len(payload.items) == 1 + item = payload.items[0] + + assert isinstance(item, WorkPiece) + # The importer should prioritize the serialized name over the filename. + assert item.name == original_dict["name"] + + assert item.source_segment is not None + assert item.source_segment.source_asset_uid == payload.source.uid + + assert item.geometry_provider_uid == original_dict["uid"] + + # 6. Verify the dimensions were set correctly on the WorkPiece + # The importer has already solved the sketch, so use the current geometry + imported_sketch = cast(Sketch, imported_sketch_template) + geo = imported_sketch.to_geometry() + min_x, min_y, max_x, max_y = geo.rect() + expected_width = max_x - min_x + expected_height = max_y - min_y + assert item.natural_width_mm == pytest.approx(expected_width) + assert item.natural_height_mm == pytest.approx(expected_height) + assert item.natural_size == pytest.approx( + (expected_width, expected_height) + ) + + # 7. Verify the sketch template itself was parsed correctly + # Note: Point coordinates may be np.float64 instead of plain floats + parsed_sketch_dict = imported_sketch_template.to_dict() + + # Check top-level fields + assert parsed_sketch_dict["uid"] == original_dict["uid"] + assert parsed_sketch_dict["name"] == original_dict["name"] + assert parsed_sketch_dict["type"] == original_dict["type"] + assert parsed_sketch_dict["origin_id"] == original_dict["origin_id"] + + # Check input parameters + assert ( + parsed_sketch_dict["input_parameters"] + == original_dict["input_parameters"] + ) + + # Check params + assert parsed_sketch_dict["params"] == original_dict["params"] + + # Check registry points (values may be np.float64) + for orig_point, parsed_point in zip( + original_dict["registry"]["points"], + parsed_sketch_dict["registry"]["points"], + ): + assert orig_point["id"] == parsed_point["id"] + assert orig_point["fixed"] == parsed_point["fixed"] + assert pytest.approx(orig_point["x"]) == float(parsed_point["x"]) + assert pytest.approx(orig_point["y"]) == float(parsed_point["y"]) + + # Check registry entities + assert ( + parsed_sketch_dict["registry"]["entities"] + == original_dict["registry"]["entities"] + ) + + # Check constraints + assert parsed_sketch_dict["constraints"] == original_dict["constraints"] + + # Check fills - compare boundary and uid, allow for new style/color fields + assert len(parsed_sketch_dict["fills"]) == len(original_dict["fills"]) + for orig_fill, parsed_fill in zip( + original_dict["fills"], parsed_sketch_dict["fills"] + ): + assert orig_fill["uid"] == parsed_fill["uid"] + assert orig_fill["boundary"] == parsed_fill["boundary"] + # New fields have defaults if not present in original + if "style" in orig_fill: + assert orig_fill["style"] == parsed_fill["style"] + else: + assert parsed_fill["style"] == "solid" + if "color" in orig_fill: + assert orig_fill["color"] == parsed_fill["color"] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_asset_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_asset_cmd.py new file mode 100644 index 000000000..5eaf0090a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_asset_cmd.py @@ -0,0 +1,65 @@ +import pytest +from sketcher.core import Sketch + +from rayforge.core.doc import Doc +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.asset_cmd import AssetCmd + + +@pytest.fixture +def doc(): + """Provides a Doc instance.""" + return Doc() + + +@pytest.fixture +def asset_cmd(doc): + """Provides an AssetCmd instance.""" + return AssetCmd(doc) + + +def test_rename_sketch_asset(asset_cmd: AssetCmd): + """Test renaming a Sketch asset also renames its dependent WorkPiece.""" + doc = asset_cmd.doc + sketch = Sketch(name="Old Sketch Name") + workpiece = WorkPiece.from_geometry_provider(sketch) + workpiece.name = "Old Sketch Name" + doc.add_asset(sketch) + doc.add_workpiece(workpiece) + + new_name = "New Sketch Name" + asset_cmd.rename_asset(sketch, new_name) + + assert sketch.name == new_name + assert workpiece.name == new_name + assert len(doc.history_manager.undo_stack) == 1 + + doc.history_manager.undo() + assert sketch.name == "Old Sketch Name" + assert workpiece.name == "Old Sketch Name" + + +def test_delete_sketch_and_workpiece(asset_cmd: AssetCmd): + """Test deleting a Sketch also removes its dependent WorkPiece.""" + doc = asset_cmd.doc + sketch = Sketch(name="Sketch To Delete") + workpiece = WorkPiece.from_geometry_provider(sketch) + doc.add_asset(sketch) + doc.add_workpiece(workpiece) + + assert len(doc.get_assets_by_type("sketch")) == 1 + assert len(doc.all_workpieces) == 1 + + asset_cmd.delete_asset(sketch) + + assert len(doc.get_assets_by_type("sketch")) == 0 + assert len(doc.all_workpieces) == 0 + assert len(doc.history_manager.undo_stack) == 1 + + doc.history_manager.undo() + assert len(doc.get_assets_by_type("sketch")) == 1 + assert len(doc.all_workpieces) == 1 + restored_sketch = next(iter(doc.get_assets_by_type("sketch").values())) + restored_wp = doc.all_workpieces[0] + assert restored_sketch.uid == sketch.uid + assert restored_wp.uid == workpiece.uid diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_commands.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_commands.py new file mode 100644 index 000000000..907011062 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_commands.py @@ -0,0 +1,210 @@ +# flake8: noqa: E402 +import os +import sys +from unittest.mock import MagicMock + +import pytest + +if sys.platform.startswith("linux"): + os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + if 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 sketcher.core import Sketch +from sketcher.core.commands import ChamferCommand, FilletCommand +from sketcher.core.entities import Line +from sketcher.ui_gtk.sketchelement import SketchElement + + +@pytest.fixture +def sketch_with_corner(): + """Creates a sketch with two lines forming a corner at (0,0).""" + s = Sketch() + p1_id = s.add_point(-100, 0) + corner_pid = s.add_point(0, 0) + p3_id = s.add_point(0, 100) + + line1_id = s.add_line(p1_id, corner_pid) + line2_id = s.add_line(corner_pid, p3_id) + + return s, corner_pid, line1_id, line2_id + + +@pytest.fixture +def element_with_corner(sketch_with_corner): + """Creates a SketchElement with a corner and mocked editor.""" + sketch, _, _, _ = sketch_with_corner + element = SketchElement(sketch=sketch) + element.editor = MagicMock() + element.execute_command = element.editor.history_manager.execute + return element + + +@pytest.mark.ui +def test_is_action_supported_chamfer_valid( + element_with_corner, sketch_with_corner +): + """Test chamfer is supported when a valid corner junction is selected.""" + _, corner_pid, _, _ = sketch_with_corner + element_with_corner.selection.select_junction(corner_pid, is_multi=False) + + assert element_with_corner.is_action_supported("chamfer") is True + + +@pytest.mark.parametrize( + "setup_selection", + [ + "no_selection", + "point_selection", + "single_line_junction", + "triple_line_junction", + ], +) +@pytest.mark.ui +def test_is_action_supported_chamfer_invalid(setup_selection): + """Test chamfer is not supported for invalid selections.""" + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + p3 = s.add_point(10, 10) + p4 = s.add_point(0, 10) + s.add_line(p1, p2) + + element = SketchElement(sketch=s) + + if setup_selection == "no_selection": + pass + elif setup_selection == "point_selection": + element.selection.select_point(p1, is_multi=False) + elif setup_selection == "single_line_junction": + element.selection.select_junction(p1, is_multi=False) + elif setup_selection == "triple_line_junction": + s.add_line(p1, p3) + s.add_line(p1, p4) + element.selection.select_junction(p1, is_multi=False) + + assert element.is_action_supported("chamfer") is False + + +@pytest.mark.ui +def test_add_chamfer_action_executes_command( + element_with_corner, sketch_with_corner +): + """Test that add_chamfer_action creates and executes a ChamferCommand.""" + _, corner_pid, _, _ = sketch_with_corner + element_with_corner.selection.select_junction(corner_pid, is_multi=False) + + element_with_corner.add_chamfer_action() + + element_with_corner.execute_command.assert_called_once() + command_instance = element_with_corner.execute_command.call_args[0][0] + assert isinstance(command_instance, ChamferCommand) + + +@pytest.mark.ui +def test_add_chamfer_action_on_short_lines( + element_with_corner, sketch_with_corner +): + """Test that chamfer action is aborted if lines are too short.""" + sketch, corner_pid, line1_id, _ = sketch_with_corner + element = element_with_corner + + line1 = sketch.registry.get_entity(line1_id) + + assert isinstance(line1, Line) + p1 = sketch.registry.get_point(line1.p1_idx) + p1.x = -1e-7 + + element.selection.select_junction(corner_pid, is_multi=False) + element.add_chamfer_action() + + element.execute_command.assert_not_called() + + +@pytest.mark.ui +def test_is_action_supported_fillet_valid( + element_with_corner, sketch_with_corner +): + """Test fillet is supported when a valid corner junction is selected.""" + _, corner_pid, _, _ = sketch_with_corner + element_with_corner.selection.select_junction(corner_pid, is_multi=False) + + assert element_with_corner.is_action_supported("fillet") is True + + +@pytest.mark.parametrize( + "setup_selection", + [ + "no_selection", + "point_selection", + "single_line_junction", + "triple_line_junction", + ], +) +@pytest.mark.ui +def test_is_action_supported_fillet_invalid(setup_selection): + """Test fillet is not supported for invalid selections.""" + s = Sketch() + p1 = s.add_point(0, 0) + p2 = s.add_point(10, 0) + p3 = s.add_point(10, 10) + p4 = s.add_point(0, 10) + s.add_line(p1, p2) + + element = SketchElement(sketch=s) + + if setup_selection == "no_selection": + pass + elif setup_selection == "point_selection": + element.selection.select_point(p1, is_multi=False) + elif setup_selection == "single_line_junction": + element.selection.select_junction(p1, is_multi=False) + elif setup_selection == "triple_line_junction": + s.add_line(p1, p3) + s.add_line(p1, p4) + element.selection.select_junction(p1, is_multi=False) + + assert element.is_action_supported("fillet") is False + + +@pytest.mark.ui +def test_add_fillet_action_executes_command( + element_with_corner, sketch_with_corner +): + """Test that add_fillet_action creates and executes a FilletCommand.""" + _, corner_pid, _, _ = sketch_with_corner + element_with_corner.selection.select_junction(corner_pid, is_multi=False) + + element_with_corner.add_fillet_action() + + element_with_corner.execute_command.assert_called_once() + command_instance = element_with_corner.execute_command.call_args[0][0] + assert isinstance(command_instance, FilletCommand) + + +@pytest.mark.ui +def test_add_fillet_action_on_short_lines( + element_with_corner, sketch_with_corner +): + """Test that fillet action is aborted if lines are too short.""" + sketch, corner_pid, line1_id, _ = sketch_with_corner + element = element_with_corner + + line1 = sketch.registry.get_entity(line1_id) + + assert isinstance(line1, Line) + p1 = sketch.registry.get_point(line1.p1_idx) + p1.x = -1e-7 + + element.selection.select_junction(corner_pid, is_multi=False) + element.add_fillet_action() + + element.execute_command.assert_not_called() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_file_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_file_cmd.py new file mode 100644 index 000000000..07af55403 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_file_cmd.py @@ -0,0 +1,99 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from sketcher.core import Sketch + +from rayforge.core.doc import Doc +from rayforge.core.source_asset import SourceAsset +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.editor import DocEditor +from rayforge.doceditor.file_cmd import FileCmd +from rayforge.image.svg.renderer import SVG_RENDERER +from rayforge.shared.tasker.manager import TaskManager + + +@pytest.fixture +def context_initializer(): + """Mock context initializer.""" + return MagicMock() + + +@pytest.fixture +def mock_editor(context_initializer): + """Provides a DocEditor instance with mocked dependencies.""" + task_manager = MagicMock(spec=TaskManager) + doc = Doc() + editor = DocEditor(task_manager, context_initializer, doc) + yield editor + editor.cleanup() + + +@pytest.fixture +def file_cmd(mock_editor, context_initializer): + """Provides a FileCmd instance for testing.""" + return FileCmd(mock_editor, context_initializer) + + +@pytest.fixture +def sample_workpiece(): + """Provides a simple WorkPiece instance for testing.""" + return WorkPiece("test_workpiece") + + +class TestCommitWithSketches: + """Tests for committing items with sketches.""" + + def test_commit_with_sketches(self, file_cmd, sample_workpiece): + """Test committing items with sketches.""" + source = SourceAsset( + source_file=Path("test.svg"), + original_data=b"", + renderer=SVG_RENDERER, + ) + sketch = Sketch(name="Test Sketch") + filename = Path("test.svg") + + file_cmd._commit_items_to_document( + [sample_workpiece], source, filename, assets=[sketch] + ) + + assert sketch in file_cmd._editor.doc.get_all_assets() + + +class TestRoundTripSketch: + """Tests for round trip with sketch projects.""" + + def test_round_trip_sketch(self, file_cmd, tmp_path): + """Test round trip for project with sketches.""" + import_file = Path(__file__).parent / "assets" / "sketch_project.ryp" + export_file = tmp_path / "sketch_export.ryp" + + result = file_cmd.load_project_from_path(import_file) + assert result is True + + sketches = [ + a + for a in file_cmd._editor.doc.get_all_assets() + if isinstance(a, Sketch) + ] + assert len(sketches) == 1 + assert sketches[0].name == "Rectangle" + + workpieces = file_cmd._editor.doc.all_workpieces + assert len(workpieces) == 1 + assert workpieces[0].geometry_provider_uid is not None + + result = file_cmd.save_project_to_path(export_file) + assert result is True + assert export_file.exists() + + result = file_cmd.load_project_from_path(export_file) + assert result is True + + sketches = [ + a + for a in file_cmd._editor.doc.get_all_assets() + if isinstance(a, Sketch) + ] + assert len(sketches) == 1 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py new file mode 100644 index 000000000..38efb0769 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py @@ -0,0 +1,380 @@ +from pathlib import Path + +import pytest +from raygeo.geo import Geometry, Matrix +from sketcher.core import Sketch + +from rayforge.core.doc import Doc +from rayforge.core.source_asset_segment import SourceAssetSegment +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def doc(): + """Provides a Doc instance.""" + return Doc() + + +@pytest.fixture +def doc_with_workpiece(doc): + """Provides a doc with a workpiece.""" + from rayforge.core.source_asset import SourceAsset + from rayforge.image.svg.renderer import SVG_RENDERER + + source = SourceAsset( + source_file=Path("test.svg"), + original_data=b"", + renderer=SVG_RENDERER, + ) + doc.add_asset(source) + + segment = SourceAssetSegment( + source_asset_uid=source.uid, + pristine_geometry=Geometry(), + normalization_matrix=Matrix.identity(), + vectorization_spec=PassthroughSpec(), + ) + + wp = WorkPiece("test_wp") + wp.source_segment = segment + doc.active_layer.add_child(wp) + + return doc, wp, source + + +def make_sketch_with_geometry(width, height): + """Create a sketch with a simple rectangle of given dimensions.""" + sketch = Sketch(name=f"Sketch {width}x{height}") + p0 = sketch.origin_id + p1 = sketch.add_point(width, 0) + p2 = sketch.add_point(width, height) + p3 = sketch.add_point(0, height) + + sketch.add_line(p0, p1) + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + sketch.add_line(p3, p0) + + sketch.constrain_horizontal(p0, p1) + sketch.constrain_vertical(p1, p2) + sketch.constrain_horizontal(p3, p2) + sketch.constrain_vertical(p0, p3) + sketch.solve() + + return sketch + + +class TestWorkPieceWithSketch: + """Tests for WorkPiece with Sketch geometry provider.""" + + def test_serialization_deserialization(self, doc_with_workpiece): + """ + Test serialization of geometry_provider_uid and params with Sketch. + """ + doc, wp, _source = doc_with_workpiece + + sketch = make_sketch_with_geometry(100, 50) + doc.add_asset(sketch) + wp.geometry_provider_uid = sketch.uid + wp.geometry_provider_params = {"width": 123.45} + + wp.set_size(80.0, 40.0) + wp.pos = (10.5, 20.2) + wp.angle = 90 + + data_dict = wp.to_dict() + + assert data_dict["geometry_provider_uid"] == sketch.uid + assert data_dict["geometry_provider_params"] == {"width": 123.45} + + new_wp = WorkPiece.from_dict(data_dict) + + assert new_wp.geometry_provider_uid == sketch.uid + assert new_wp.geometry_provider_params == {"width": 123.45} + + def test_in_world_hydrates_sketch_definition(self, doc_with_workpiece): + """ + Tests that the in_world method correctly populates the transient + sketch definition for use in subprocesses. + """ + doc, wp, _ = doc_with_workpiece + + sketch = make_sketch_with_geometry(100, 50) + doc.add_asset(sketch) + wp.geometry_provider_uid = sketch.uid + wp.geometry_provider_params = {"width": 50.0} + + world_wp = wp.in_world() + + assert world_wp.geometry_provider_uid == sketch.uid + assert world_wp.geometry_provider_params == {"width": 50.0} + + transient_def = world_wp._transient_geometry_provider + assert transient_def is not None + assert isinstance(transient_def, Sketch) + assert transient_def is not sketch + assert transient_def.uid == sketch.uid + + def test_get_geometry_provider(self, doc_with_workpiece): + """ + Tests retrieving the geometry provider from the document or from the + transient field. + """ + doc, wp, _ = doc_with_workpiece + + assert wp.geometry_provider_uid is None + assert wp.get_geometry_provider() is None + + sketch_from_doc = make_sketch_with_geometry(100, 50) + doc.add_asset(sketch_from_doc) + wp.geometry_provider_uid = sketch_from_doc.uid + + retrieved_provider = wp.get_geometry_provider() + assert retrieved_provider is not None + assert retrieved_provider is sketch_from_doc + assert retrieved_provider.uid == sketch_from_doc.uid + + transient_provider = make_sketch_with_geometry(50, 25) + transient_provider.uid = "transient-uid-123" + + wp_with_transient = WorkPiece( + "transient_test", source_segment=wp.source_segment + ) + wp_with_transient.geometry_provider_uid = sketch_from_doc.uid + wp_with_transient._transient_geometry_provider = transient_provider + + doc.active_layer.add_child(wp_with_transient) + + retrieved_transient = wp_with_transient.get_geometry_provider() + assert retrieved_transient is not None + assert retrieved_transient is transient_provider + assert retrieved_transient is not sketch_from_doc + assert retrieved_transient.uid == "transient-uid-123" + + def test_from_geometry_provider_factory_behavior(self): + """ + Tests the WorkPiece.from_geometry_provider factory method logic. + """ + sketch = make_sketch_with_geometry(10, 20) + + wp = WorkPiece.from_geometry_provider(sketch) + + assert wp.geometry_provider_uid == sketch.uid + assert wp.name == sketch.name + assert wp.natural_width_mm == pytest.approx(10.0) + assert wp.natural_height_mm == pytest.approx(20.0) + + sx, sy = wp.matrix.get_scale() + assert sx == pytest.approx(10.0) + assert sy == pytest.approx(20.0) + + empty_sketch = Sketch(name="Empty") + empty_sketch.solve() + wp_empty = WorkPiece.from_geometry_provider(empty_sketch) + + assert wp_empty.natural_width_mm == 0.0 + assert wp_empty.natural_height_mm == 0.0 + + def test_sketch_params_setter_triggers_update(self, doc_with_workpiece): + """ + Tests that setting geometry_provider_params triggers regeneration + and updates natural dimensions. + """ + doc, wp, _ = doc_with_workpiece + + sketch = Sketch(name="ParametricSketch") + sketch.set_param("W", 100) + sketch.set_param("H", 50) + + p0 = sketch.origin_id + p1 = sketch.add_point(100, 0) + p2 = sketch.add_point(100, 50) + p3 = sketch.add_point(0, 50) + + sketch.add_line(p0, p1) + sketch.add_line(p1, p2) + sketch.add_line(p2, p3) + sketch.add_line(p3, p0) + + sketch.constrain_horizontal(p0, p1) + sketch.constrain_vertical(p1, p2) + sketch.constrain_horizontal(p3, p2) + sketch.constrain_vertical(p0, p3) + sketch.constrain_distance(p0, p1, "W") + sketch.constrain_distance(p0, p3, "H") + sketch.solve() + + doc.add_asset(sketch) + wp.geometry_provider_uid = sketch.uid + + updated_signals = [] + wp.updated.connect(lambda s: updated_signals.append(s), weak=False) + + new_params = {"W": 50, "H": 25} + wp.geometry_provider_params = new_params + + assert wp.geometry_provider_params == new_params + assert len(updated_signals) > 0 + + assert wp.natural_width_mm == pytest.approx(50.0) + assert wp.natural_height_mm == pytest.approx(25.0) + + def test_sketch_boundaries_normalization(self, doc_with_workpiece): + """ + Tests that boundaries generated from a sketch are correctly normalized + to the 0-1 unit square, regardless of the sketch's physical size. + """ + doc, wp, _ = doc_with_workpiece + + sketch = make_sketch_with_geometry(100, 200) + + doc.add_asset(sketch) + wp.geometry_provider_uid = sketch.uid + wp.clear_render_cache() + + bounds = wp.boundaries + assert bounds is not None + + min_x, min_y, max_x, max_y = bounds.rect() + assert min_x == pytest.approx(0.0) + assert min_y == pytest.approx(0.0) + assert max_x == pytest.approx(1.0) + assert max_y == pytest.approx(1.0) + + assert wp._boundaries_cache is bounds + + def test_uuid4_unique_per_workpiece_instance(self, doc): + """ + Two workpiece instances from the same sketch each get their own + uuid4 value, and it stays stable across boundary accesses. + """ + from raygeo.geo.shape.text import FontConfig + + sketch = Sketch(name="UUID Sketch") + origin = sketch.add_point(0, 0) + w_pt = sketch.add_point(10, 0) + h_pt = sketch.add_point(0, 10) + sketch.registry.add_text_box( + origin, w_pt, h_pt, "{uuid4()}", FontConfig() + ) + sketch.solve() + doc.add_asset(sketch) + + wp_a = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp_a) + + wp_b = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp_b) + + assert wp_a._resolved_text_cache != {} + assert wp_b._resolved_text_cache != {} + cache_a_val = next(iter(wp_a._resolved_text_cache.values())) + cache_b_val = next(iter(wp_b._resolved_text_cache.values())) + assert cache_a_val != cache_b_val + + def test_uuid4_stable_within_workpiece(self, doc): + """ + A workpiece instance returns the same uuid4 on repeated + boundary accesses (cache survives). + """ + from raygeo.geo.shape.text import FontConfig + + sketch = Sketch(name="UUID Sketch") + origin = sketch.add_point(0, 0) + w_pt = sketch.add_point(10, 0) + h_pt = sketch.add_point(0, 10) + sketch.registry.add_text_box( + origin, w_pt, h_pt, "{uuid4()}", FontConfig() + ) + sketch.solve() + doc.add_asset(sketch) + + wp = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp) + + first_cache = dict(wp._resolved_text_cache) + wp.clear_render_cache() + wp._boundaries_cache = None + wp._fills_cache = None + _ = wp.boundaries + assert wp._resolved_text_cache == first_cache + + def test_uuid4_survives_in_world(self, doc): + """ + in_world carries the resolved_text_cache so the subprocess + uses the same uuid4. + """ + from raygeo.geo.shape.text import FontConfig + + sketch = Sketch(name="UUID Sketch") + origin = sketch.add_point(0, 0) + w_pt = sketch.add_point(10, 0) + h_pt = sketch.add_point(0, 10) + sketch.registry.add_text_box( + origin, w_pt, h_pt, "{uuid4()}", FontConfig() + ) + sketch.solve() + doc.add_asset(sketch) + + wp = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp) + original_cache = dict(wp._resolved_text_cache) + + world = wp.in_world() + assert world._resolved_text_cache == original_cache + + def test_uuid4_survives_serialization(self, doc): + """ + The resolved_text_cache round-trips through + to_dict / from_dict. + """ + from raygeo.geo.shape.text import FontConfig + + sketch = Sketch(name="UUID Sketch") + origin = sketch.add_point(0, 0) + w_pt = sketch.add_point(10, 0) + h_pt = sketch.add_point(0, 10) + sketch.registry.add_text_box( + origin, w_pt, h_pt, "{uuid4()}", FontConfig() + ) + sketch.solve() + doc.add_asset(sketch) + + wp = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp) + original_cache = dict(wp._resolved_text_cache) + + data = wp.to_dict() + wp2 = WorkPiece.from_dict(data) + assert wp2._resolved_text_cache == original_cache + + def test_uuid4_cleared_on_sketch_edit(self, doc): + """ + When the sketch is edited, the workpiece's cache is cleared + and a new uuid4 is generated. + """ + from raygeo.geo.shape.text import FontConfig + + sketch = Sketch(name="UUID Sketch") + origin = sketch.add_point(0, 0) + w_pt = sketch.add_point(10, 0) + h_pt = sketch.add_point(0, 10) + sketch.registry.add_text_box( + origin, w_pt, h_pt, "{uuid4()}", FontConfig() + ) + sketch.solve() + doc.add_asset(sketch) + + wp = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp) + first_val = next(iter(wp._resolved_text_cache.values())) + + wp.get_geometry_provider() + + sketch.solve() + sketch.notify_update() + + _ = wp.boundaries + new_val = next(iter(wp._resolved_text_cache.values())) + assert new_val != first_val diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/test_text_box.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/test_text_box.py new file mode 100644 index 000000000..a49bc5708 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/test_text_box.py @@ -0,0 +1,403 @@ +import os +import sys +from unittest.mock import MagicMock, Mock + +import cairo +import pytest + +if sys.platform.startswith("linux"): + os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + if not os.environ.get("DISPLAY"): + pytest.skip( + "DISPLAY not set on Linux, skipping UI tests. Run with xvfb-run.", + allow_module_level=True, + ) + +from raygeo.geo.shape.text import FontConfig +from sketcher.core import Sketch +from sketcher.core.entities import TextBoxEntity +from sketcher.ui_gtk.renderer import SketchRenderer +from sketcher.ui_gtk.sketchelement import SketchElement +from sketcher.ui_gtk.tools import TextBoxTool +from sketcher.ui_gtk.tools.text_box_tool import TextBoxState + + +@pytest.fixture +def sketch_with_text_box(): + """Create a sketch with a text box entity for testing.""" + sketch = Sketch() + + p_origin = sketch.add_point(0, 0) + p_width = sketch.add_point(50, 0) + p_height = sketch.add_point(0, 10) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="Hello", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + return sketch, tb_id + + +@pytest.fixture +def sketch_with_empty_text_box(): + """Create a sketch with an empty text box entity for testing.""" + sketch = Sketch() + + p_origin = sketch.add_point(10, 20) + p_width = sketch.add_point(60, 20) + p_height = sketch.add_point(10, 30) + + tb_id = sketch.registry.add_text_box( + p_origin, + p_width, + p_height, + content="", + font_config=FontConfig(family="sans-serif", size=10.0), + ) + + return sketch, tb_id + + +@pytest.fixture +def element_with_text_box(sketch_with_text_box): + """Create a SketchElement with a text box and mocked canvas.""" + sketch, tb_id = sketch_with_text_box + element = SketchElement(sketch=sketch) + + element.canvas = Mock() + element.canvas.get_view_scale.return_value = (1.0, 1.0) + element.canvas.get_color.return_value = Mock(red=0.0, green=0.0, blue=0.0) + element.canvas.edit_context = element + + mock_matrix = Mock() + mock_matrix.for_cairo.return_value = (1, 0, 0, 1, 0, 0) + element.hittester = Mock() + element.hittester.get_model_to_screen_transform.return_value = mock_matrix + + return element, tb_id + + +@pytest.fixture +def element_with_empty_text_box(sketch_with_empty_text_box): + """Create a SketchElement with an empty text box and mocked canvas.""" + sketch, tb_id = sketch_with_empty_text_box + element = SketchElement(sketch=sketch) + + element.canvas = Mock() + element.canvas.get_view_scale.return_value = (1.0, 1.0) + element.canvas.get_color.return_value = Mock(red=0.0, green=0.0, blue=0.0) + element.canvas.edit_context = element + + mock_matrix = Mock() + mock_matrix.for_cairo.return_value = (1, 0, 0, 1, 0, 0) + element.hittester = Mock() + element.hittester.get_model_to_screen_transform.return_value = mock_matrix + + return element, tb_id + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + element.content_transform = Mock() + element.content_transform.for_cairo.return_value = (1, 0, 0, 1, 0) + element.canvas = Mock() + element.canvas.get_color.return_value = Mock(red=0.0, green=0.0, blue=0.0) + element.canvas.get_view_scale.return_value = (1.0, 1.0) + element.line_width = 1.0 + element.selection = Mock() + element.selection.entity_ids = [] + return element + + +@pytest.fixture +def mock_cairo_context(): + """Create a mock Cairo context for testing.""" + ctx = MagicMock(spec=cairo.Context) + return ctx + + +@pytest.mark.ui +def test_empty_text_box_cursor_at_origin( + element_with_empty_text_box, mock_cairo_context +): + """ + Test that the cursor is drawn at the element origin when text buffer + is empty. This verifies the fix for the bug where the cursor was drawn + at canvas zero. + """ + element, tb_id = element_with_empty_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + assert text_tool.state == TextBoxState.EDITING + assert text_tool.text_buffer == "" + assert text_tool.cursor_pos == 0 + + text_tool.draw_overlay(mock_cairo_context) + + mock_cairo_context.save.assert_called() + mock_cairo_context.fill.assert_called() + mock_cairo_context.restore.assert_called() + + +@pytest.mark.ui +def test_empty_text_box_cursor_moves_after_typing( + element_with_empty_text_box, mock_cairo_context +): + """ + Test that the cursor moves to the correct position after typing. + This verifies that the cursor transformation works correctly when text + is added to an initially empty text box. + """ + element, tb_id = element_with_empty_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + assert text_tool.text_buffer == "" + + mock_cairo_context.reset_mock() + + text_tool.handle_text_input("A") + assert text_tool.text_buffer == "A" + assert text_tool.cursor_pos == 1 + + text_tool.draw_overlay(mock_cairo_context) + + mock_cairo_context.fill.assert_called() + + +@pytest.mark.ui +def test_text_box_rendering_in_sketch_renderer( + sketch_with_text_box, mock_element, mock_cairo_context +): + """Test that a text box with content is rendered correctly.""" + sketch, tb_id = sketch_with_text_box + mock_element.sketch = sketch + + renderer = SketchRenderer(mock_element) + renderer.draw(mock_cairo_context) + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Hello" + + +@pytest.mark.ui +def test_text_box_rendering_with_empty_content( + sketch_with_empty_text_box, mock_element, mock_cairo_context +): + """Test that an empty text box is handled correctly.""" + sketch, tb_id = sketch_with_empty_text_box + mock_element.sketch = sketch + + renderer = SketchRenderer(mock_element) + renderer.draw(mock_cairo_context) + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "" + + +@pytest.mark.ui +def test_text_box_rendering_produces_visible_output( + sketch_with_text_box, mock_element, mock_cairo_context +): + """ + Test that calling draw() on the renderer produces visible output. + This verifies that the text geometry is generated and transformed + before being drawn to the Cairo context. + """ + sketch, tb_id = sketch_with_text_box + mock_element.sketch = sketch + + renderer = SketchRenderer(mock_element) + renderer.draw(mock_cairo_context) + + tb = sketch.registry.get_entity(tb_id) + assert isinstance(tb, TextBoxEntity) + assert tb.content == "Hello" + + mock_cairo_context.save.assert_called() + mock_cairo_context.restore.assert_called() + mock_cairo_context.fill.assert_called() + + +@pytest.mark.ui +def test_text_box_rendering_with_different_font_params( + sketch_with_text_box, mock_element, mock_cairo_context +): + """Test that text boxes with different font params are rendered.""" + sketch, tb_id = sketch_with_text_box + mock_element.sketch = sketch + + tb = sketch.registry.get_entity(tb_id) + tb.font_config = FontConfig( + family="serif", + size=14.0, + bold=True, + italic=False, + ) + + renderer = SketchRenderer(mock_element) + renderer.draw(mock_cairo_context) + + assert tb.font_config.family == "serif" + assert tb.font_config.size == 14.0 + assert tb.font_config.bold is True + + +@pytest.mark.ui +def test_text_box_cursor_draws_when_visible( + element_with_text_box, mock_cairo_context +): + """ + Test that the text cursor is drawn when cursor_visible is True. + This test verifies that the cursor drawing code path is executed + when the cursor should be visible. + """ + element, tb_id = element_with_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + assert text_tool.state == TextBoxState.EDITING + assert text_tool.cursor_visible is True + + text_tool.draw_overlay(mock_cairo_context) + + mock_cairo_context.save.assert_called() + mock_cairo_context.fill.assert_called() + mock_cairo_context.restore.assert_called() + + +@pytest.mark.ui +def test_text_box_cursor_not_drawn_when_hidden( + element_with_text_box, mock_cairo_context +): + """ + Test that the text cursor is NOT drawn when cursor_visible is False. + This test verifies that the cursor drawing code path is skipped + when the cursor should be hidden. + """ + element, tb_id = element_with_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + text_tool.cursor_visible = False + + text_tool.draw_overlay(mock_cairo_context) + + mock_cairo_context.save.assert_called() + mock_cairo_context.fill.assert_called_once() + mock_cairo_context.restore.assert_called() + + +@pytest.mark.ui +def test_text_box_cursor_toggles_visibility( + element_with_text_box, mock_cairo_context +): + """ + Test that the cursor visibility can be toggled. + This simulates the UI timer behavior that blinks the cursor. + """ + element, tb_id = element_with_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + + mock_cairo_context.reset_mock() + + text_tool.draw_overlay(mock_cairo_context) + assert mock_cairo_context.fill.call_count == 2 + + text_tool.toggle_cursor_visibility() + mock_cairo_context.reset_mock() + + text_tool.draw_overlay(mock_cairo_context) + assert mock_cairo_context.fill.call_count == 1 + + text_tool.toggle_cursor_visibility() + mock_cairo_context.reset_mock() + + text_tool.draw_overlay(mock_cairo_context) + assert mock_cairo_context.fill.call_count == 2 + + +@pytest.mark.ui +def test_text_box_cursor_visible_after_text_input( + element_with_text_box, mock_cairo_context +): + """ + Test that the cursor becomes visible after text input. + This ensures the cursor is shown when user types. + """ + element, tb_id = element_with_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + text_tool.cursor_visible = False + + text_tool.handle_text_input("X") + + assert text_tool.cursor_visible is True + + +@pytest.mark.ui +def test_text_box_cursor_has_visible_width( + element_with_text_box, mock_cairo_context +): + """ + Test that cursor has a visible width in its geometry. + This ensures cursor is thick enough to be seen on screen. + """ + element, tb_id = element_with_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + text_tool.cursor_visible = True + + text_tool.draw_overlay(mock_cairo_context) + + mock_cairo_context.fill.assert_called() + + +@pytest.mark.ui +def test_text_box_cursor_visible_with_large_scale( + element_with_text_box, mock_cairo_context +): + """ + Test that cursor remains visible even with large scale. + This ensures cursor is visible when zoomed out. + """ + element, tb_id = element_with_text_box + + text_tool = element.tools["text_box"] + assert isinstance(text_tool, TextBoxTool) + + text_tool.start_editing(tb_id) + text_tool.cursor_visible = True + + element.canvas.get_view_scale.return_value = (10.0, 10.0) + + text_tool.draw_overlay(mock_cairo_context) + + mock_cairo_context.fill.assert_called() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_arc_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_arc_tool.py new file mode 100644 index 000000000..bafe24f2a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_arc_tool.py @@ -0,0 +1,161 @@ +from unittest.mock import MagicMock, Mock + +import pytest +from sketcher.core.commands import ArcPreviewState +from sketcher.ui_gtk.tools.arc_tool import ArcTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + element.sketch = Mock() + element.sketch.registry = Mock() + element.sketch.registry.points = [] + element.sketch.registry.entities = [] + element.sketch.registry._id_counter = 0 + element.sketch.registry._entity_map = {} + element.sketch.add_point = Mock(return_value=0) + element.sketch.add_arc = Mock(return_value=0) + element.selection = Mock() + element.selection.clear = Mock() + element.selection.select_point = Mock() + element.hittester = Mock() + element.hittester.screen_to_model = Mock(return_value=(0.0, 0.0)) + element.hittester.get_hit_data = Mock(return_value=(None, None)) + element.remove_point_if_unused = Mock() + element.mark_dirty = Mock() + element.update_bounds_from_sketch = Mock() + element.editor = None + element.canvas = Mock() + element.canvas.view_transform = Mock() + element.canvas.view_transform.get_scale = Mock(return_value=(1.0, 1.0)) + element.snap_engine = Mock() + element.snap_engine.query = Mock( + return_value=MagicMock( + snapped=False, + position=(0.0, 0.0), + snap_lines=[], + primary_snap_point=None, + ) + ) + return element + + +@pytest.fixture +def arc_tool(mock_element): + """Create an ArcTool instance for testing.""" + return ArcTool(mock_element) + + +@pytest.mark.ui +def test_arc_tool_initialization(arc_tool, mock_element): + """Test that ArcTool initializes correctly.""" + assert arc_tool.element == mock_element + assert arc_tool._preview_state is None + + +@pytest.mark.ui +def test_arc_tool_on_deactivate(arc_tool, mock_element): + """Test that on_deactivate cleans up state.""" + arc_tool._preview_state = ArcPreviewState( + center_id=1, + center_temp=True, + start_id=2, + start_temp=True, + temp_end_id=3, + temp_entity_id=4, + ) + + arc_tool.on_deactivate() + + assert arc_tool._preview_state is None + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_arc_tool_on_press_no_hit(arc_tool, mock_element): + """Test on_press when no point is hit.""" + mock_element.hittester.get_hit_data.return_value = (None, None) + mock_element.hittester.screen_to_model.return_value = (10.0, 20.0) + mock_element.sketch.registry.add_point = Mock(return_value=0) + + result = arc_tool.on_press(100.0, 200.0, 1) + + assert result is True + assert arc_tool._preview_state is not None + assert arc_tool._preview_state.center_id == 0 + assert arc_tool._preview_state.center_temp is True + + +@pytest.mark.ui +def test_arc_tool_on_drag(arc_tool): + """Test on_drag does nothing.""" + arc_tool.on_drag(10.0, 20.0) + assert True + + +@pytest.mark.ui +def test_arc_tool_on_release(arc_tool): + """Test on_release does nothing.""" + arc_tool.on_release(10.0, 20.0) + assert True + + +@pytest.mark.ui +def test_arc_tool_on_hover_motion_no_preview(arc_tool): + """Test on_hover_motion when not in preview stage.""" + arc_tool._preview_state = None + arc_tool.on_hover_motion(100.0, 200.0) + assert True + + +@pytest.mark.ui +def test_arc_tool_on_hover_motion_with_preview(arc_tool, mock_element): + """Test on_hover_motion updates preview when in preview stage.""" + arc_tool._preview_state = ArcPreviewState( + center_id=0, + center_temp=False, + start_id=1, + start_temp=False, + temp_end_id=2, + temp_entity_id=3, + ) + + mock_point_center = Mock() + mock_point_center.x = 0.0 + mock_point_center.y = 0.0 + mock_point_start = Mock() + mock_point_start.x = 10.0 + mock_point_start.y = 0.0 + mock_point_end = Mock() + mock_point_end.x = 0.0 + mock_point_end.y = 0.0 + mock_arc = Mock() + mock_arc.clockwise = False + + mock_element.sketch.registry.get_point.side_effect = [ + mock_point_center, + mock_point_start, + mock_point_end, + ] + mock_element.sketch.registry.get_entity.return_value = mock_arc + mock_element.hittester.screen_to_model.return_value = (10.0, 10.0) + + arc_tool.on_hover_motion(100.0, 200.0) + + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_arc_tool_handle_click_center_point(arc_tool, mock_element): + """Test _handle_click for setting center point.""" + mock_element.hittester.screen_to_model.return_value = (10.0, 20.0) + mock_element.sketch.registry.add_point = Mock(return_value=0) + + result = arc_tool._handle_click(None, 10.0, 20.0) + + assert result is True + assert arc_tool._preview_state is not None + assert arc_tool._preview_state.center_id == 0 + assert arc_tool._preview_state.center_temp is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_base_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_base_tool.py new file mode 100644 index 000000000..b4ab47542 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_base_tool.py @@ -0,0 +1,57 @@ +from unittest.mock import Mock + +import cairo +import pytest +from sketcher.ui_gtk.tools.base import SketchTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + return element + + +@pytest.fixture +def sketch_tool(mock_element): + """Create a concrete SketchTool subclass for testing.""" + + class ConcreteSketchTool(SketchTool): + def on_press(self, world_x, world_y, n_press): + return False + + def on_drag(self, world_dx, world_dy): + pass + + def on_release(self, world_x, world_y): + pass + + return ConcreteSketchTool(mock_element) + + +@pytest.mark.ui +def test_sketch_tool_initialization(sketch_tool, mock_element): + """Test that SketchTool initializes correctly.""" + assert sketch_tool.element == mock_element + + +@pytest.mark.ui +def test_sketch_tool_on_hover_motion_default(sketch_tool): + """Test that on_hover_motion does nothing by default.""" + sketch_tool.on_hover_motion(100.0, 200.0) + assert True + + +@pytest.mark.ui +def test_sketch_tool_on_deactivate_default(sketch_tool): + """Test that on_deactivate does nothing by default.""" + sketch_tool.on_deactivate() + assert True + + +@pytest.mark.ui +def test_sketch_tool_draw_overlay_default(sketch_tool): + """Test that draw_overlay does nothing by default.""" + ctx = Mock(spec=cairo.Context) + sketch_tool.draw_overlay(ctx) + assert True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_circle_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_circle_tool.py new file mode 100644 index 000000000..0a81e5a6c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_circle_tool.py @@ -0,0 +1,637 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest +from sketcher.core.commands.ellipse import EllipsePreviewState +from sketcher.core.entities import Point as SketchPoint +from sketcher.core.snap import SnapLineType +from sketcher.ui_gtk.tools.base import SketcherKey +from sketcher.ui_gtk.tools.circle_tool import CircleTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + element.sketch = Mock() + element.sketch.registry = Mock() + element.sketch.registry.points = [] + element.sketch.registry.entities = [] + element.sketch.registry._id_counter = 0 + element.sketch.registry._entity_map = {} + element.sketch.registry.add_point = Mock(return_value=0) + element.sketch.registry.add_ellipse = Mock(return_value=0) + element.sketch.registry.get_point = Mock( + side_effect=lambda pid: MagicMock(x=0.0, y=0.0) + ) + element.sketch.remove_point_if_unused = Mock() + element.selection = Mock() + element.selection.clear = Mock() + element.selection.select_point = Mock() + element.hittester = Mock() + element.hittester.screen_to_model = Mock(return_value=(0.0, 0.0)) + element.hittester.get_hit_data = Mock(return_value=(None, None)) + element.remove_point_if_unused = Mock() + element.mark_dirty = Mock() + element.update_bounds_from_sketch = Mock() + element.execute_command = Mock() + element.editor = None + element.canvas = Mock() + element.canvas._shift_pressed = False + element.canvas.view_transform = Mock() + element.canvas.view_transform.get_scale = Mock(return_value=(1.0, 1.0)) + element.snap_engine = Mock() + element.snap_engine.query = Mock( + return_value=MagicMock( + snapped=False, + position=(0.0, 0.0), + snap_lines=[], + primary_snap_point=None, + ) + ) + return element + + +@pytest.fixture +def circle_tool(mock_element): + """Create a CircleTool instance for testing.""" + return CircleTool(mock_element) + + +@pytest.mark.ui +def test_circle_tool_initialization(circle_tool, mock_element): + """Test that CircleTool initializes correctly.""" + assert circle_tool.element == mock_element + assert circle_tool._preview_state is None + assert circle_tool._ctrl_held is False + assert circle_tool._shift_held is False + + +@pytest.mark.ui +def test_circle_tool_is_available(circle_tool): + """Test is_available returns True only when target is None.""" + assert circle_tool.is_available(None, None) is True + assert circle_tool.is_available("something", "point") is False + + +@pytest.mark.ui +def test_circle_tool_shortcut_is_active(circle_tool): + """Test shortcut_is_active always returns True.""" + assert circle_tool.shortcut_is_active() is True + + +@pytest.mark.ui +def test_circle_tool_get_preview_state(circle_tool): + """Test get_preview_state returns current preview state.""" + assert circle_tool.get_preview_state() is None + + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + assert circle_tool.get_preview_state() is not None + + +@pytest.mark.ui +def test_circle_tool_on_deactivate_no_preview(circle_tool, mock_element): + """Test that on_deactivate works when no preview state.""" + circle_tool.on_deactivate() + assert circle_tool._preview_state is None + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_circle_tool_on_deactivate_with_preview(circle_tool, mock_element): + """Test that on_deactivate cleans up preview state.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.cleanup_preview" + ): + circle_tool.on_deactivate() + + assert circle_tool._preview_state is None + mock_element.remove_point_if_unused.assert_called_once_with(1) + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_circle_tool_on_deactivate_with_snapped_start( + circle_tool, mock_element +): + """Test on_deactivate when start was snapped (not temp).""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=False, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.cleanup_preview" + ): + circle_tool.on_deactivate() + + mock_element.remove_point_if_unused.assert_not_called() + + +@pytest.mark.ui +def test_circle_tool_on_press_no_hit(circle_tool, mock_element): + """Test on_press when no point is hit.""" + mock_element.hittester.get_hit_data.return_value = (None, None) + mock_element.hittester.screen_to_model.return_value = (10.0, 20.0) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.start_preview" + ) as mock_start: + mock_start.return_value = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + result = circle_tool.on_press(100.0, 200.0, 1) + + assert result is True + assert circle_tool._preview_state is not None + assert circle_tool._preview_state.start_id == 0 + + +@pytest.mark.ui +def test_circle_tool_on_press_with_snapped_point(circle_tool, mock_element): + """Test on_press when snapping to an existing point.""" + mock_element.hittester.screen_to_model.return_value = (10.0, 20.0) + mock_point = SketchPoint(99, 10.0, 20.0) + mock_snap_point = MagicMock() + mock_snap_point.line_type = SnapLineType.ENTITY_POINT + mock_snap_point.source = mock_point + mock_snap_point.x = 10.0 + mock_snap_point.y = 20.0 + mock_element.snap_engine.query.return_value = MagicMock( + snapped=True, + position=(10.0, 20.0), + snap_lines=[], + primary_snap_point=mock_snap_point, + ) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.start_preview" + ) as mock_start: + mock_start.return_value = EllipsePreviewState( + start_id=99, + start_temp=False, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + result = circle_tool.on_press(100.0, 200.0, 1) + + assert result is True + mock_start.assert_called_once_with( + mock_element.sketch.registry, 10.0, 20.0, snapped_pid=99 + ) + + +@pytest.mark.ui +def test_circle_tool_on_press_already_in_preview(circle_tool, mock_element): + """Test on_press when already in preview mode does nothing.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + mock_element.hittester.screen_to_model.return_value = (10.0, 20.0) + + result = circle_tool.on_press(100.0, 200.0, 1) + + assert result is True + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_circle_tool_on_drag(circle_tool): + """Test on_drag does nothing.""" + circle_tool.on_drag(10.0, 20.0) + assert True + + +@pytest.mark.ui +def test_circle_tool_on_release_no_preview(circle_tool, mock_element): + """Test on_release when no preview state does nothing.""" + circle_tool._preview_state = None + circle_tool.on_release(10.0, 20.0) + mock_element.execute_command.assert_not_called() + + +@pytest.mark.ui +def test_circle_tool_on_release_with_preview(circle_tool, mock_element): + """Test on_release creates command and cleans up.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + mock_element.hittester.screen_to_model.return_value = (50.0, 50.0) + mock_element.hittester.get_hit_data.return_value = (None, None) + + with ( + patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.cleanup_preview" + ), + patch("sketcher.ui_gtk.tools.circle_tool.EllipseCommand"), + ): + circle_tool.on_release(100.0, 200.0) + + assert circle_tool._preview_state is None + mock_element.execute_command.assert_called_once() + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_circle_tool_on_release_with_snapped_endpoint( + circle_tool, mock_element +): + """Test on_release snaps to existing point.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + mock_element.hittester.screen_to_model.return_value = (50.0, 50.0) + mock_point = SketchPoint(99, 50.0, 50.0) + mock_snap_point = MagicMock() + mock_snap_point.line_type = SnapLineType.ENTITY_POINT + mock_snap_point.source = mock_point + mock_snap_point.x = 50.0 + mock_snap_point.y = 50.0 + mock_element.snap_engine.query.return_value = MagicMock( + snapped=True, + position=(50.0, 50.0), + snap_lines=[], + primary_snap_point=mock_snap_point, + ) + + with ( + patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.cleanup_preview" + ), + patch("sketcher.ui_gtk.tools.circle_tool.EllipseCommand") as MockCmd, + ): + mock_cmd_instance = Mock() + MockCmd.return_value = mock_cmd_instance + circle_tool.on_release(100.0, 200.0) + + call_kwargs = MockCmd.call_args[1] + assert call_kwargs["end_pid"] == 99 + + +@pytest.mark.ui +def test_circle_tool_on_release_ignores_preview_points( + circle_tool, mock_element +): + """Test on_release ignores snapped points that are preview points.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + mock_element.hittester.screen_to_model.return_value = (50.0, 50.0) + mock_point = SketchPoint(3, 50.0, 50.0) + mock_snap_point = MagicMock() + mock_snap_point.line_type = SnapLineType.ENTITY_POINT + mock_snap_point.source = mock_point + mock_snap_point.x = 50.0 + mock_snap_point.y = 50.0 + mock_element.snap_engine.query.return_value = MagicMock( + snapped=True, + position=(50.0, 50.0), + snap_lines=[], + primary_snap_point=mock_snap_point, + ) + + with ( + patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.cleanup_preview" + ), + patch("sketcher.ui_gtk.tools.circle_tool.EllipseCommand") as MockCmd, + ): + mock_cmd_instance = Mock() + MockCmd.return_value = mock_cmd_instance + circle_tool.on_release(100.0, 200.0) + + call_kwargs = MockCmd.call_args[1] + assert call_kwargs["end_pid"] is None + + +@pytest.mark.ui +def test_circle_tool_on_release_with_modifiers(circle_tool, mock_element): + """Test on_release passes modifier state to command.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=1, + start_temp=True, + center_id=2, + radius_x_id=3, + radius_y_id=4, + entity_id=5, + ) + circle_tool._shift_held = True + circle_tool._ctrl_held = True + mock_element.hittester.screen_to_model.return_value = (50.0, 50.0) + mock_element.hittester.get_hit_data.return_value = (None, None) + + with ( + patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.cleanup_preview" + ), + patch("sketcher.ui_gtk.tools.circle_tool.EllipseCommand") as MockCmd, + ): + mock_cmd_instance = Mock() + MockCmd.return_value = mock_cmd_instance + circle_tool.on_release(100.0, 200.0) + + call_kwargs = MockCmd.call_args[1] + assert call_kwargs["center_on_start"] is True + assert call_kwargs["constrain_circle"] is True + + +@pytest.mark.ui +def test_circle_tool_on_hover_motion_no_preview(circle_tool): + """Test on_hover_motion when not in preview stage.""" + circle_tool._preview_state = None + circle_tool.on_hover_motion(100.0, 200.0) + assert True + + +@pytest.mark.ui +def test_circle_tool_on_hover_motion_with_preview(circle_tool, mock_element): + """Test on_hover_motion updates preview when in preview stage.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + + mock_point = Mock() + mock_point.x = 0.0 + mock_point.y = 0.0 + mock_element.sketch.registry.get_point.return_value = mock_point + mock_element.hittester.screen_to_model.return_value = (10.0, 10.0) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.update_preview" + ): + circle_tool.on_hover_motion(100.0, 200.0) + + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_circle_tool_on_hover_motion_with_modifiers(circle_tool, mock_element): + """Test on_hover_motion passes modifier state to update_preview.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + circle_tool._shift_held = True + circle_tool._ctrl_held = True + mock_element.hittester.screen_to_model.return_value = (10.0, 10.0) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.update_preview" + ) as mock_update: + circle_tool.on_hover_motion(100.0, 200.0) + + call_kwargs = mock_update.call_args[1] + assert call_kwargs["center_on_start"] is True + assert call_kwargs["constrain_circle"] is True + + +@pytest.mark.ui +def test_circle_tool_on_hover_motion_error_deactivates( + circle_tool, mock_element +): + """Test on_hover_motion deactivates on IndexError.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + mock_element.hittester.screen_to_model.return_value = (10.0, 10.0) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.update_preview", + side_effect=IndexError, + ): + circle_tool.on_hover_motion(100.0, 200.0) + + assert circle_tool._preview_state is None + + +@pytest.mark.ui +def test_circle_tool_on_hover_motion_key_error_deactivates( + circle_tool, mock_element +): + """Test on_hover_motion deactivates on KeyError.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + mock_element.hittester.screen_to_model.return_value = (10.0, 10.0) + + with patch( + "sketcher.ui_gtk.tools.circle_tool.EllipseCommand.update_preview", + side_effect=KeyError, + ): + circle_tool.on_hover_motion(100.0, 200.0) + + assert circle_tool._preview_state is None + + +@pytest.mark.ui +def test_circle_tool_handle_key_event_escape(circle_tool, mock_element): + """Test handle_key_event with Escape deactivates.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + + result = circle_tool.handle_key_event(SketcherKey.ESCAPE) + + assert result is True + assert circle_tool._preview_state is None + + +@pytest.mark.ui +def test_circle_tool_handle_key_event_other_key(circle_tool): + """Test handle_key_event with non-Escape key returns False.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + + result = circle_tool.handle_key_event(SketcherKey.RETURN) + + assert result is False + + +@pytest.mark.ui +def test_circle_tool_handle_key_event_no_preview(circle_tool): + """Test handle_key_event with no preview state returns False.""" + result = circle_tool.handle_key_event(SketcherKey.ESCAPE) + assert result is False + + +@pytest.mark.ui +def test_circle_tool_on_modifier_change_no_preview(circle_tool): + """Test on_modifier_change with no preview does nothing.""" + circle_tool.on_modifier_change(shift=True, ctrl=True) + assert circle_tool._shift_held is False + assert circle_tool._ctrl_held is False + + +@pytest.mark.ui +def test_circle_tool_on_modifier_change_with_preview( + circle_tool, mock_element +): + """Test on_modifier_change updates state and marks dirty.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + + circle_tool.on_modifier_change(shift=True, ctrl=True) + + assert circle_tool._shift_held is True + assert circle_tool._ctrl_held is True + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_circle_tool_on_modifier_change_no_change(circle_tool, mock_element): + """Test on_modifier_change doesn't mark dirty if no change.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + circle_tool._shift_held = True + circle_tool._ctrl_held = True + + circle_tool.on_modifier_change(shift=True, ctrl=True) + + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_circle_tool_on_modifier_change_partial(circle_tool, mock_element): + """Test on_modifier_change with only one modifier changed.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + circle_tool._shift_held = False + circle_tool._ctrl_held = False + + circle_tool.on_modifier_change(shift=True, ctrl=False) + + assert circle_tool._shift_held is True + assert circle_tool._ctrl_held is False + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_circle_tool_get_active_shortcuts_no_preview(circle_tool): + """Test get_active_shortcuts with no preview.""" + shortcuts = circle_tool.get_active_shortcuts() + assert shortcuts == [] + + +@pytest.mark.ui +def test_circle_tool_get_active_shortcuts_with_preview(circle_tool): + """Test get_active_shortcuts returns shortcuts during preview.""" + circle_tool._preview_state = EllipsePreviewState( + start_id=0, + start_temp=True, + center_id=1, + radius_x_id=2, + radius_y_id=3, + entity_id=4, + ) + + shortcuts = circle_tool.get_active_shortcuts() + + assert len(shortcuts) == 4 + keys = [s[0] for s in shortcuts] + assert "Shift" in keys + assert "Ctrl" in keys + assert "Tab" in keys + assert "Esc" in keys + + +@pytest.mark.ui +def test_circle_tool_class_attributes(): + """Test CircleTool class attributes.""" + assert CircleTool.ICON == "sketch-circle-symbolic" + assert CircleTool.LABEL == "Ellipse" + assert CircleTool.SHORTCUTS == ["gc"] + assert CircleTool.CURSOR_ICON == "sketch-circle-symbolic" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_fill_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_fill_tool.py new file mode 100644 index 000000000..2a6de92b0 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_fill_tool.py @@ -0,0 +1,92 @@ +from unittest.mock import Mock + +import pytest +from sketcher.core.sketch import Fill +from sketcher.ui_gtk.tools.fill_tool import FillTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + element.sketch = Mock() + element.sketch.registry = Mock() + element.sketch.registry.entities = [] + element.sketch.fills = [] + element.sketch.get_loop_at_point = Mock(return_value=None) + element.sketch._calculate_loop_signed_area = Mock(return_value=100.0) + element.hittester = Mock() + element.hittester.screen_to_model = Mock(return_value=(0.0, 0.0)) + element.hittester.get_hit_data = Mock(return_value=(None, None)) + element.mark_dirty = Mock() + element.execute_command = Mock() + element.editor = None + return element + + +@pytest.fixture +def fill_tool(mock_element): + """Create a FillTool instance for testing.""" + return FillTool(mock_element) + + +@pytest.mark.ui +def test_fill_tool_initialization(fill_tool, mock_element): + """Test that FillTool initializes correctly.""" + assert fill_tool.element == mock_element + + +@pytest.mark.ui +def test_fill_tool_on_press_no_loops(fill_tool, mock_element): + """Test on_press when no loops are found.""" + mock_element.sketch.get_loop_at_point.return_value = None + + result = fill_tool.on_press(100.0, 200.0, 1) + + assert result is False + + +@pytest.mark.ui +def test_fill_tool_on_press_double_click(fill_tool): + """Test on_press with double click (n_press != 1).""" + result = fill_tool.on_press(100.0, 200.0, 2) + + assert result is False + + +@pytest.mark.ui +def test_fill_tool_on_drag(fill_tool): + """Test on_drag does nothing.""" + fill_tool.on_drag(10.0, 20.0) + assert True + + +@pytest.mark.ui +def test_fill_tool_on_release(fill_tool): + """Test on_release does nothing.""" + fill_tool.on_release(10.0, 20.0) + assert True + + +@pytest.mark.ui +def test_fill_tool_on_press_with_loop(fill_tool, mock_element): + """Test on_press when a loop is found.""" + mock_element.sketch.get_loop_at_point.return_value = [(1, True), (2, True)] + mock_element.hittester.screen_to_model.return_value = (50.0, 50.0) + + result = fill_tool.on_press(100.0, 200.0, 1) + + assert result is True + + +@pytest.mark.ui +def test_fill_tool_on_press_existing_fill(fill_tool, mock_element): + """Test on_press when a fill already exists.""" + existing_fill = Fill(uid="test", boundary=[(1, True), (2, True)]) + mock_element.sketch.fills = [existing_fill] + mock_element.sketch.get_loop_at_point.return_value = [(1, True), (2, True)] + mock_element.hittester.screen_to_model.return_value = (50.0, 50.0) + + result = fill_tool.on_press(100.0, 200.0, 1) + + assert result is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_grid_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_grid_tool.py new file mode 100644 index 000000000..da422fd1c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_grid_tool.py @@ -0,0 +1,226 @@ +from unittest.mock import MagicMock, patch + +import pytest +from sketcher.core.commands import GridCommand +from sketcher.ui_gtk.tools import GridTool + + +@pytest.fixture +def mock_element(): + element = MagicMock() + element.sketch = MagicMock() + element.editor = MagicMock() + element.editor.parent_window = MagicMock() + element.execute_command = MagicMock() + element.selection.entity_ids = [] + element.canvas = MagicMock() + element.canvas._shift_pressed = False + return element + + +@pytest.fixture +def grid_tool(mock_element): + return GridTool(mock_element) + + +@pytest.mark.ui +def test_grid_tool_initialization(grid_tool): + assert grid_tool.ICON == "sketch-grid-symbolic" + assert grid_tool.LABEL is not None + assert "gg" in grid_tool.SHORTCUTS + + +@pytest.mark.ui +def test_grid_tool_is_available_no_target(grid_tool): + assert grid_tool.is_available(None, None) is True + + +@pytest.mark.ui +def test_grid_tool_is_available_with_target(grid_tool): + mock_entity = MagicMock() + assert grid_tool.is_available(mock_entity, "entity") is False + + +@pytest.mark.ui +def test_grid_tool_on_press_returns_true(grid_tool): + result = grid_tool.on_press(100, 200, 1) + assert result is True + + +@pytest.mark.ui +def test_grid_tool_on_activate_shows_dialog(grid_tool, mock_element): + with patch.object(grid_tool, "_show_dialog") as mock_show_dialog: + grid_tool.on_activate() + mock_show_dialog.assert_called_once() + mock_element.set_tool.assert_called_once_with("select") + + +@pytest.mark.ui +def test_grid_tool_show_dialog_creates_command_on_create( + grid_tool, mock_element +): + mock_dialog = MagicMock() + mock_rows_row = MagicMock() + mock_cols_row = MagicMock() + + responses = {} + + def mock_connect(signal, callback): + responses[signal] = callback + + mock_dialog.connect = mock_connect + mock_dialog.present = MagicMock() + + with ( + patch("gi.repository.Adw.MessageDialog") as MockDialog, + patch("sketcher.ui_gtk.tools.grid_tool.SpinRow") as MockSpinRow, + patch("gi.repository.Gtk.ListBox") as MockListBox, + ): + MockDialog.return_value = mock_dialog + MockSpinRow.side_effect = [mock_rows_row, mock_cols_row] + mock_rows_row.get_int_value.return_value = 4 + mock_cols_row.get_int_value.return_value = 5 + MockListBox.return_value = MagicMock() + + grid_tool._show_dialog() + + assert "response" in responses + responses["response"](mock_dialog, "create") + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert isinstance(cmd, GridCommand) + assert cmd.rows == 4 + assert cmd.cols == 5 + + +@pytest.mark.ui +def test_grid_tool_show_dialog_cancels_on_cancel(grid_tool, mock_element): + mock_dialog = MagicMock() + responses = {} + + def mock_connect(signal, callback): + responses[signal] = callback + + mock_dialog.connect = mock_connect + mock_dialog.present = MagicMock() + + with ( + patch("gi.repository.Adw.MessageDialog") as MockDialog, + patch("sketcher.ui_gtk.tools.grid_tool.SpinRow") as MockSpinRow, + patch("gi.repository.Gtk.ListBox") as MockListBox, + ): + MockDialog.return_value = mock_dialog + MockSpinRow.return_value = MagicMock() + MockListBox.return_value = MagicMock() + + grid_tool._show_dialog() + + responses["response"](mock_dialog, "cancel") + + mock_element.execute_command.assert_not_called() + + +@pytest.mark.ui +def test_grid_tool_show_dialog_handles_invalid_input(grid_tool, mock_element): + mock_dialog = MagicMock() + mock_rows_row = MagicMock() + mock_cols_row = MagicMock() + + responses = {} + + def mock_connect(signal, callback): + responses[signal] = callback + + mock_dialog.connect = mock_connect + mock_dialog.present = MagicMock() + + with ( + patch("gi.repository.Adw.MessageDialog") as MockDialog, + patch("sketcher.ui_gtk.tools.grid_tool.SpinRow") as MockSpinRow, + patch("gi.repository.Gtk.ListBox") as MockListBox, + ): + MockDialog.return_value = mock_dialog + MockSpinRow.side_effect = [mock_rows_row, mock_cols_row] + mock_rows_row.get_int_value.return_value = 2 + mock_cols_row.get_int_value.return_value = 2 + MockListBox.return_value = MagicMock() + + grid_tool._show_dialog() + + responses["response"](mock_dialog, "create") + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert cmd.rows == 2 + assert cmd.cols == 2 + + +@pytest.mark.ui +def test_grid_tool_show_dialog_handles_too_small_grid(grid_tool, mock_element): + mock_dialog = MagicMock() + mock_rows_row = MagicMock() + mock_cols_row = MagicMock() + + responses = {} + + def mock_connect(signal, callback): + responses[signal] = callback + + mock_dialog.connect = mock_connect + mock_dialog.present = MagicMock() + + with ( + patch("gi.repository.Adw.MessageDialog") as MockDialog, + patch("sketcher.ui_gtk.tools.grid_tool.SpinRow") as MockSpinRow, + patch("gi.repository.Gtk.ListBox") as MockListBox, + ): + MockDialog.return_value = mock_dialog + MockSpinRow.side_effect = [mock_rows_row, mock_cols_row] + mock_rows_row.get_int_value.return_value = 2 + mock_cols_row.get_int_value.return_value = 3 + MockListBox.return_value = MagicMock() + + grid_tool._show_dialog() + + responses["response"](mock_dialog, "create") + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert cmd.rows == 2 + assert cmd.cols == 3 + + +@pytest.mark.ui +def test_grid_tool_creates_construction_geometry_by_default( + grid_tool, mock_element +): + mock_dialog = MagicMock() + mock_rows_row = MagicMock() + mock_cols_row = MagicMock() + + responses = {} + + def mock_connect(signal, callback): + responses[signal] = callback + + mock_dialog.connect = mock_connect + mock_dialog.present = MagicMock() + + with ( + patch("gi.repository.Adw.MessageDialog") as MockDialog, + patch("sketcher.ui_gtk.tools.grid_tool.SpinRow") as MockSpinRow, + patch("gi.repository.Gtk.ListBox") as MockListBox, + ): + MockDialog.return_value = mock_dialog + MockSpinRow.side_effect = [mock_rows_row, mock_cols_row] + mock_rows_row.get_int_value.return_value = 2 + mock_cols_row.get_int_value.return_value = 2 + MockListBox.return_value = MagicMock() + + grid_tool._show_dialog() + + responses["response"](mock_dialog, "create") + + cmd = mock_element.execute_command.call_args[0][0] + assert cmd.construction is True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_path_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_path_tool.py new file mode 100644 index 000000000..a2dd4d5ac --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_path_tool.py @@ -0,0 +1,533 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest +from sketcher.core.commands.bezier import BezierPreviewState +from sketcher.core.constraints import HorizontalConstraint, VerticalConstraint +from sketcher.core.entities import Point as SketchPoint +from sketcher.core.snap.types import SnapLine, SnapLineType, SnapResult +from sketcher.ui_gtk.tools.path_tool import PathTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + element.sketch = Mock() + element.sketch.registry = Mock() + element.sketch.registry.points = [] + element.sketch.registry.entities = [] + element.sketch.registry._id_counter = 0 + element.sketch.registry._entity_map = {} + element.sketch.registry.add_point = Mock(return_value=0) + element.sketch.registry.add_line = Mock(return_value=0) + element.sketch.registry.add_bezier = Mock(return_value=0) + element.sketch.registry.get_point = Mock( + side_effect=lambda pid: MagicMock(x=0.0, y=0.0) + ) + element.sketch.remove_point_if_unused = Mock() + element.selection = Mock() + element.selection.clear = Mock() + element.selection.select_point = Mock() + element.hittester = Mock() + element.hittester.screen_to_model = Mock(return_value=(0.0, 0.0)) + element.hittester.get_hit_data = Mock(return_value=(None, None)) + element.remove_point_if_unused = Mock() + element.mark_dirty = Mock() + element.update_bounds_from_sketch = Mock() + element.execute_command = Mock() + element.editor = None + element.canvas = Mock() + element.canvas._shift_pressed = False + element.canvas.view_transform = Mock() + element.canvas.view_transform.get_scale = Mock(return_value=(1.0, 1.0)) + element.snap_engine = Mock() + element.snap_engine.query = Mock( + return_value=MagicMock( + snapped=False, + position=(0.0, 0.0), + snap_lines=[], + primary_snap_point=None, + ) + ) + return element + + +@pytest.fixture +def path_tool(mock_element): + """Create a PathTool instance for testing.""" + return PathTool(mock_element) + + +@pytest.mark.ui +def test_path_tool_initialization(path_tool, mock_element): + """Test that BezierTool initializes correctly.""" + assert path_tool.element == mock_element + assert path_tool._preview_state is None + assert path_tool._press_pos is None + assert path_tool._dragging is False + + +@pytest.mark.ui +def test_path_tool_on_deactivate_no_preview(path_tool, mock_element): + """Test that on_deactivate works when no preview state.""" + path_tool.on_deactivate() + assert path_tool._preview_state is None + assert path_tool._press_pos is None + assert path_tool._dragging is False + + +@pytest.mark.ui +def test_path_tool_on_deactivate_with_preview(path_tool, mock_element): + """Test that on_deactivate cleans up preview state.""" + path_tool._preview_state = BezierPreviewState( + start_id=1, + start_temp=True, + end_id=2, + end_temp=True, + temp_entity_id=3, + is_line_preview=True, + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_deactivate() + + assert path_tool._preview_state is None + mock_element.remove_point_if_unused.assert_called_once_with(1) + + +@pytest.mark.ui +def test_path_tool_first_press_starts_preview(path_tool, mock_element): + """Test first press starts line preview.""" + mock_element.hittester.get_hit_data.return_value = (None, None) + mock_element.hittester.screen_to_model.return_value = (10.0, 20.0) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.start_preview" + ) as mock_start: + mock_start.return_value = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=True, + ) + result = path_tool.on_press(100.0, 200.0, 1) + + assert result is False + assert path_tool._preview_state is not None + assert path_tool._waypoint_model_pos == (10.0, 20.0) + assert path_tool._press_pos == (100.0, 200.0) + assert path_tool._dragging is False + + +@pytest.mark.ui +def test_path_tool_on_drag_below_threshold(path_tool, mock_element): + """Test on_drag does nothing below threshold.""" + path_tool._press_pos = (100.0, 200.0) + path_tool._waypoint_model_pos = (10.0, 20.0) + path_tool._preview_state = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=True, + ) + + path_tool.on_drag(2.0, 2.0) + + assert path_tool._dragging is False + + +@pytest.mark.ui +def test_path_tool_on_drag_starts_bezier(path_tool, mock_element): + """Test on_drag converts line to bezier above threshold.""" + path_tool._press_pos = (100.0, 200.0) + path_tool._waypoint_model_pos = (10.0, 20.0) + path_tool._preview_state = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=True, + ) + + def get_point_side_effect(pid): + if pid == 0: + return MagicMock(x=0.0, y=0.0) + elif pid == 1: + return MagicMock(x=10.0, y=20.0) + return MagicMock(x=0.0, y=0.0) + + mock_element.sketch.registry.get_point.side_effect = get_point_side_effect + mock_element.hittester.screen_to_model.return_value = (15.0, 25.0) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.convert_to_bezier" + ) as mock_convert: + path_tool.on_drag(10.0, 10.0) + + mock_convert.assert_called_once_with( + mock_element.sketch.registry, + path_tool._preview_state, + 10.0, + 20.0, + 15.0, + 25.0, + mirror_cp_offset=None, + ) + + assert path_tool._dragging is True + + +@pytest.mark.ui +def test_path_tool_on_release_without_drag_creates_line( + path_tool, mock_element +): + """Test on_release creates line when not dragging and end has moved.""" + path_tool._press_pos = (100.0, 200.0) + path_tool._waypoint_model_pos = (20.0, 30.0) + path_tool._dragging = False + path_tool._snapped_pid = None + path_tool._preview_state = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=True, + ) + + def get_point_side_effect(pid): + if pid == 0: + return MagicMock(x=10.0, y=15.0) + else: + return MagicMock(x=20.0, y=30.0) + + mock_element.sketch.registry.get_point.side_effect = get_point_side_effect + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert cmd.is_line is True + + +@pytest.mark.ui +def test_path_tool_on_release_with_drag_creates_bezier( + path_tool, mock_element +): + """Test on_release creates bezier when dragging.""" + path_tool._press_pos = (100.0, 200.0) + path_tool._waypoint_model_pos = (10.0, 20.0) + path_tool._dragging = True + path_tool._snapped_pid = None + path_tool._preview_state = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=4, + is_line_preview=False, + virtual_cp=(2.0, 2.0), + ) + + mock_start_pt = MagicMock(x=10.0, y=20.0) + mock_end_pt = MagicMock(x=15.0, y=25.0) + mock_entity = MagicMock(cp1=(1.0, 1.0), cp2=(-2.0, -2.0)) + + def get_point_side_effect(pid): + if pid == 0: + return mock_start_pt + elif pid == 1: + return mock_end_pt + return None + + def get_entity_side_effect(eid): + if eid == 4: + return mock_entity + return None + + mock_element.sketch.registry.get_point.side_effect = get_point_side_effect + mock_element.sketch.registry.get_entity.side_effect = ( + get_entity_side_effect + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert cmd.is_line is False + + +@pytest.mark.ui +def test_path_tool_on_hover_motion_updates_preview(path_tool, mock_element): + """Test on_hover_motion updates preview when in preview stage.""" + path_tool._preview_state = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=True, + ) + path_tool._in_press = False + mock_element.hittester.screen_to_model.return_value = (15.0, 25.0) + + with patch("sketcher.ui_gtk.tools.path_tool.BezierCommand.update_preview"): + path_tool.on_hover_motion(100.0, 200.0) + + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_path_tool_on_hover_motion_skips_when_in_press( + path_tool, mock_element +): + """Test on_hover_motion skips when in press sequence.""" + path_tool._preview_state = BezierPreviewState( + start_id=0, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=False, + ) + path_tool._in_press = True + + path_tool.on_hover_motion(100.0, 200.0) + + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_path_tool_shortcut(path_tool): + """Test that PathTool has correct shortcut.""" + assert PathTool.SHORTCUTS == ["gp", "gl"] + + +def _setup_line_finalization(path_tool, mock_element, start_id=0): + path_tool._press_pos = (100.0, 200.0) + path_tool._dragging = False + path_tool._snapped_pid = None + path_tool._preview_state = BezierPreviewState( + start_id=start_id, + start_temp=True, + end_id=1, + end_temp=True, + temp_entity_id=2, + is_line_preview=True, + ) + start_pt = SketchPoint(start_id, 0.0, 0.0) + end_pt = MagicMock(x=100.0, y=0.0) + + def get_point(pid): + if pid == start_id: + return start_pt + return end_pt + + mock_element.sketch.registry.get_point.side_effect = get_point + mock_element.sketch.registry._id_counter = 10 + mock_element.sketch.constraints = [] + return start_pt + + +@pytest.mark.ui +def test_auto_constraint_horizontal(path_tool, mock_element): + """Line snapped to horizontal guide gets a HorizontalConstraint.""" + start_pt = _setup_line_finalization(path_tool, mock_element) + path_tool.current_snap_result = SnapResult( + snapped=True, + position=(100.0, 0.0), + snap_lines=[ + SnapLine( + is_horizontal=True, + coordinate=0.0, + line_type=SnapLineType.ENTITY_POINT, + source=start_pt, + ), + ], + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + horiz = [ + c for c in cmd.constraints if isinstance(c, HorizontalConstraint) + ] + assert len(horiz) == 1 + assert horiz[0].p1 == 0 + + +@pytest.mark.ui +def test_auto_constraint_vertical(path_tool, mock_element): + """Line snapped to vertical guide gets a VerticalConstraint.""" + start_pt = _setup_line_finalization(path_tool, mock_element) + path_tool.current_snap_result = SnapResult( + snapped=True, + position=(0.0, 100.0), + snap_lines=[ + SnapLine( + is_horizontal=False, + coordinate=0.0, + line_type=SnapLineType.ENTITY_POINT, + source=start_pt, + ), + ], + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + vert = [ + c for c in cmd.constraints if isinstance(c, VerticalConstraint) + ] + assert len(vert) == 1 + assert vert[0].p1 == 0 + + +@pytest.mark.ui +def test_auto_constraint_no_snap_no_constraint(path_tool, mock_element): + """Line without snap result gets no axis constraint.""" + _setup_line_finalization(path_tool, mock_element) + path_tool.current_snap_result = None + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + axis = [ + c + for c in cmd.constraints + if isinstance(c, (HorizontalConstraint, VerticalConstraint)) + ] + assert len(axis) == 0 + + +@pytest.mark.ui +def test_auto_constraint_from_any_existing_point(path_tool, mock_element): + """Snap line from any point (not just start) creates constraint.""" + _setup_line_finalization(path_tool, mock_element, start_id=0) + other_pt = SketchPoint(99, 50.0, 0.0) + mock_element.sketch.constraints = [] + path_tool.current_snap_result = SnapResult( + snapped=True, + position=(100.0, 0.0), + snap_lines=[ + SnapLine( + is_horizontal=True, + coordinate=0.0, + line_type=SnapLineType.ENTITY_POINT, + source=other_pt, + ), + ], + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + horiz = [ + c for c in cmd.constraints if isinstance(c, HorizontalConstraint) + ] + assert len(horiz) == 1 + assert horiz[0].p1 == 99 + + +@pytest.mark.ui +def test_auto_constraint_skips_duplicate(path_tool, mock_element): + """Does not add axis constraint if one already exists.""" + _setup_line_finalization(path_tool, mock_element, start_id=0) + start_pt = SketchPoint(0, 0.0, 0.0) + existing = [HorizontalConstraint(0, 10)] + mock_element.sketch.constraints = existing + path_tool.current_snap_result = SnapResult( + snapped=True, + position=(100.0, 0.0), + snap_lines=[ + SnapLine( + is_horizontal=True, + coordinate=0.0, + line_type=SnapLineType.ENTITY_POINT, + source=start_pt, + ), + ], + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + horiz = [ + c for c in cmd.constraints if isinstance(c, HorizontalConstraint) + ] + assert len(horiz) == 0 + + +@pytest.mark.ui +def test_auto_constraint_skips_self_constraint(path_tool, mock_element): + """Does not add constraint where source equals end point.""" + end_pt = SketchPoint(99, 100.0, 0.0) + _setup_line_finalization(path_tool, mock_element, start_id=0) + mock_element.sketch.constraints = [] + path_tool._snapped_pid = 99 + + def get_point(pid): + if pid == 0: + return SketchPoint(0, 0.0, 0.0) + return end_pt + + mock_element.sketch.registry.get_point.side_effect = get_point + path_tool.current_snap_result = SnapResult( + snapped=True, + position=(100.0, 0.0), + snap_lines=[ + SnapLine( + is_horizontal=True, + coordinate=0.0, + line_type=SnapLineType.ENTITY_POINT, + source=end_pt, + ), + ], + ) + + with patch( + "sketcher.ui_gtk.tools.path_tool.BezierCommand.cleanup_preview" + ): + path_tool.on_release(100.0, 200.0) + + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + axis = [ + c + for c in cmd.constraints + if isinstance(c, (HorizontalConstraint, VerticalConstraint)) + ] + assert len(axis) == 0 diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_rectangle_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_rectangle_tool.py new file mode 100644 index 000000000..e09e623a2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_rectangle_tool.py @@ -0,0 +1,245 @@ +from unittest.mock import MagicMock, patch + +import pytest +from sketcher.core.commands import ( + RectangleCommand, + RectanglePreviewState, +) +from sketcher.core.entities import Point +from sketcher.core.entities import Point as SketchPoint +from sketcher.core.snap import SnapLineType +from sketcher.ui_gtk.tools import RectangleTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing tools.""" + element = MagicMock() + element.sketch = MagicMock() + element.sketch.registry._id_counter = 0 + element.hittester.get_hit_data.return_value = (None, None) + element.execute_command = MagicMock() + element.canvas = MagicMock() + element.canvas.view_transform = MagicMock() + element.canvas.view_transform.get_scale = MagicMock( + return_value=(1.0, 1.0) + ) + element.snap_engine = MagicMock() + element.snap_engine.query = MagicMock( + return_value=MagicMock( + snapped=False, + position=(0.0, 0.0), + snap_lines=[], + primary_snap_point=None, + ) + ) + return element + + +@pytest.fixture +def rect_tool(mock_element): + """Create a RectangleTool instance with a mocked element.""" + return RectangleTool(mock_element) + + +@pytest.mark.ui +def test_rectangle_tool_initialization(rect_tool): + """Test tool's initial state.""" + assert rect_tool._preview_state is None + + +@pytest.mark.ui +def test_first_click_no_hit_starts_preview(rect_tool, mock_element): + """Test that first click on an empty space starts preview mode.""" + mock_element.sketch.registry.add_point.side_effect = [0, 1] + mock_element.hittester.screen_to_model.return_value = (10, 20) + + with patch.object( + RectangleCommand, "create_preview" + ) as mock_create_preview: + mock_create_preview.return_value = {"p2": 2, "line1": 10} + result = rect_tool.on_press(100, 200, 1) + + assert result is True + assert rect_tool._preview_state is not None + assert rect_tool._preview_state.start_id == 0 + assert rect_tool._preview_state.start_temp is True + assert rect_tool._preview_state.p_end_id == 1 + mock_element.sketch.registry.add_point.assert_called() + + +@pytest.mark.ui +def test_first_click_with_hit_starts_preview(rect_tool, mock_element): + """ + Test that first click on an existing point starts preview mode. + """ + mock_point = SketchPoint(5, 10, 20) + mock_snap_point = MagicMock() + mock_snap_point.line_type = SnapLineType.ENTITY_POINT + mock_snap_point.source = mock_point + mock_snap_point.x = 10.0 + mock_snap_point.y = 20.0 + mock_element.snap_engine.query.return_value = MagicMock( + snapped=True, + position=(10.0, 20.0), + snap_lines=[], + primary_snap_point=mock_snap_point, + ) + mock_element.sketch.registry.add_point.return_value = 6 + mock_element.hittester.screen_to_model.return_value = (10, 20) + + with patch.object( + RectangleCommand, "create_preview" + ) as mock_create_preview: + mock_create_preview.return_value = {"p2": 7, "line1": 10} + result = rect_tool.on_press(100, 200, 1) + + assert result is True + assert rect_tool._preview_state is not None + assert rect_tool._preview_state.start_id == 5 + assert rect_tool._preview_state.start_temp is False + assert rect_tool._preview_state.p_end_id == 6 + + +@pytest.mark.ui +def test_second_click_no_hit_creates_rectangle(rect_tool, mock_element): + """Test that second click creates final rectangle geometry.""" + # --- Setup first click state --- + rect_tool._preview_state = RectanglePreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"p2": 2, "line1": 10}, + ) + mock_element.sketch.registry.get_point.return_value = Point(0, 0, 0) + + # --- Simulate second click --- + mock_element.hittester.screen_to_model.return_value = (100, 50) + result = rect_tool.on_press(100, 200, 1) + + assert result is True + + # Verify command execution + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert isinstance(cmd, RectangleCommand) + + # Verify command contents + assert cmd.start_pid == 0 + assert cmd.end_pos == (100, 50) + assert cmd.end_pid is None + assert cmd.is_start_temp is True + + # Verify tool reset + assert rect_tool._preview_state is None + + +@pytest.mark.ui +def test_second_click_with_hit_creates_rectangle(rect_tool, mock_element): + """Test creating a rectangle by snapping to second corner a point.""" + # --- Setup first click state --- + rect_tool._preview_state = RectanglePreviewState( + start_id=0, + start_temp=False, + p_end_id=1, + preview_ids={"p2": 2, "line1": 10}, + ) + mock_element.sketch.registry.get_point.side_effect = [ + Point(0, 0, 0), + SketchPoint(7, 100, 50), + ] + + # --- Simulate second click --- + mock_point = SketchPoint(7, 100, 50) + mock_snap_point = MagicMock() + mock_snap_point.line_type = SnapLineType.ENTITY_POINT + mock_snap_point.source = mock_point + mock_snap_point.x = 100.0 + mock_snap_point.y = 50.0 + mock_element.snap_engine.query.return_value = MagicMock( + snapped=True, + position=(100.0, 50.0), + snap_lines=[], + primary_snap_point=mock_snap_point, + ) + mock_element.hittester.screen_to_model.return_value = (100, 50) + result = rect_tool.on_press(100, 200, 1) + + assert result is True + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert isinstance(cmd, RectangleCommand) + + # Check command contents + assert cmd.start_pid == 0 + assert cmd.end_pos == (100, 50) + assert cmd.end_pid == 7 + assert cmd.is_start_temp is False + + +@pytest.mark.ui +def test_on_hover_motion_updates_preview(rect_tool, mock_element): + """Test that hovering updates preview geometry.""" + rect_tool._preview_state = RectanglePreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"p2": 2, "line1": 10}, + ) + + with patch.object(RectangleCommand, "update_preview") as mock_update: + mock_element.hittester.screen_to_model.return_value = (75, 85) + rect_tool.on_hover_motion(100, 200) + + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[0][2] == 75 + assert call_args[0][3] == 85 + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_on_deactivate_cleans_up(rect_tool, mock_element): + """Test that deactivating tool cleans up temporary state.""" + rect_tool._preview_state = RectanglePreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"p2": 2, "line1": 10}, + ) + + rect_tool.on_deactivate() + + assert rect_tool._preview_state is None + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_degenerate_rectangle_aborts_creation(rect_tool, mock_element): + """Test that a zero-width or zero-height rect is not created.""" + rect_tool._preview_state = RectanglePreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"p2": 2, "line1": 10}, + ) + mock_element.sketch.registry.get_point.return_value = Point(0, 10, 20) + mock_element.sketch.remove_point_if_unused = MagicMock() + + # --- Simulate second click at nearly same spot --- + mock_element.hittester.screen_to_model.return_value = (10, 20.0000001) + result = rect_tool.on_press(100, 200, 1) + + assert result is True + + # The command should be executed... + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert isinstance(cmd, RectangleCommand) + + # ...but its internal logic should detect degenerate case and do + # nothing except clean up temporary start point. + cmd.sketch = mock_element.sketch + cmd._do_execute() + mock_element.sketch.remove_point_if_unused.assert_called_once_with(0) + assert cmd.add_cmd is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_rounded_rect_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_rounded_rect_tool.py new file mode 100644 index 000000000..fc2feae71 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_rounded_rect_tool.py @@ -0,0 +1,169 @@ +from unittest.mock import MagicMock, patch + +import pytest +from sketcher.core.commands import ( + RoundedRectCommand, + RoundedRectPreviewState, +) +from sketcher.core.entities import Point +from sketcher.ui_gtk.tools import RoundedRectTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing tools.""" + element = MagicMock() + element.sketch = MagicMock() + element.sketch.registry._id_counter = 0 + element.hittester.get_hit_data.return_value = (None, None) + element.execute_command = MagicMock() + return element + + +@pytest.fixture +def tool(mock_element): + """Create a RoundedRectTool instance with a mocked element.""" + return RoundedRectTool(mock_element) + + +@pytest.mark.ui +def test_rounded_rect_tool_initialization(tool): + """Test tool's initial state.""" + assert tool._preview_state is None + + +@pytest.mark.ui +def test_first_click_no_hit_starts_preview(tool, mock_element): + """Test that first click on an empty space starts preview mode.""" + mock_element.sketch.registry.add_point.side_effect = [0, 1] + mock_element.hittester.screen_to_model.return_value = (10, 20) + + with patch.object( + RoundedRectCommand, "create_preview" + ) as mock_create_preview: + mock_create_preview.return_value = {"t2": 2, "line1": 10} + result = tool.on_press(100, 200, 1) + + assert result is True + assert tool._preview_state is not None + assert tool._preview_state.start_id == 0 + assert tool._preview_state.start_temp is True + assert tool._preview_state.p_end_id == 1 + mock_element.sketch.registry.add_point.assert_called() + + +@pytest.mark.ui +def test_first_click_with_hit_starts_preview(tool, mock_element): + """Test a first click on an existing point starts preview mode.""" + mock_element.hittester.get_hit_data.return_value = ("point", 5) + mock_element.sketch.registry.add_point.return_value = 6 + mock_element.hittester.screen_to_model.return_value = (10, 20) + + with patch.object( + RoundedRectCommand, "create_preview" + ) as mock_create_preview: + mock_create_preview.return_value = {"t2": 7, "line1": 10} + result = tool.on_press(100, 200, 1) + + assert result is True + assert tool._preview_state is not None + assert tool._preview_state.start_id == 5 + assert tool._preview_state.start_temp is False + assert tool._preview_state.p_end_id == 6 + + +@pytest.mark.ui +def test_second_click_creates_rounded_rectangle(tool, mock_element): + """Test a second click creates final rounded rectangle geometry.""" + tool._preview_state = RoundedRectPreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"t2": 2, "line1": 10}, + radius=10.0, + ) + mock_element.sketch.registry.get_point.return_value = Point(0, 0, 0) + + mock_element.hittester.screen_to_model.return_value = (100, 50) + result = tool.on_press(100, 200, 1) + + assert result is True + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert isinstance(cmd, RoundedRectCommand) + assert cmd.start_pid == 0 + assert cmd.end_pos == (100, 50) + assert cmd.is_start_temp is True + assert cmd.radius == tool.DEFAULT_RADIUS + assert tool._preview_state is None + + +@pytest.mark.ui +def test_on_hover_motion_updates_preview(tool, mock_element): + """Test that hovering updates preview geometry.""" + tool._preview_state = RoundedRectPreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"t2": 2, "line1": 10}, + radius=10.0, + ) + + with patch.object(RoundedRectCommand, "update_preview") as mock_update: + mock_element.hittester.screen_to_model.return_value = (75, 85) + tool.on_hover_motion(100, 200) + + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[0][2] == 75 + assert call_args[0][3] == 85 + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_on_deactivate_cleans_up(tool, mock_element): + """Test that deactivating tool cleans up temporary state.""" + tool._preview_state = RoundedRectPreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"t2": 2, "line1": 10}, + radius=10.0, + ) + + tool.on_deactivate() + + assert tool._preview_state is None + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_degenerate_rounded_rectangle_aborts_creation(tool, mock_element): + """Test that a zero-width or zero-height rect is not created.""" + # --- Setup first click state --- + tool._preview_state = RoundedRectPreviewState( + start_id=0, + start_temp=True, + p_end_id=1, + preview_ids={"t2": 2, "line1": 10}, + radius=10.0, + ) + mock_element.sketch.registry.get_point.return_value = Point(0, 10, 20) + mock_element.sketch.remove_point_if_unused = MagicMock() + + # --- Simulate second click at nearly same spot --- + mock_element.hittester.screen_to_model.return_value = (10, 20.0000001) + result = tool.on_press(100, 200, 1) + + assert result is True + # The command should be executed... + mock_element.execute_command.assert_called_once() + cmd = mock_element.execute_command.call_args[0][0] + assert isinstance(cmd, RoundedRectCommand) + + # ...but its internal logic should detect degenerate case and do + # nothing except clean up temporary start point. + cmd.sketch = mock_element.sketch + cmd._do_execute() + mock_element.sketch.remove_point_if_unused.assert_called_once_with(0) + assert cmd.add_cmd is None diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_select_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_select_tool.py new file mode 100644 index 000000000..4a449da85 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_select_tool.py @@ -0,0 +1,208 @@ +from unittest.mock import Mock + +import cairo +import pytest +from sketcher.core.entities import Line +from sketcher.ui_gtk.tools.select_tool import SelectTool + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing.""" + element = Mock() + element.sketch = Mock() + element.sketch.registry = Mock() + element.sketch.registry.points = [] + element.sketch.registry.entities = [] + element.sketch.constraints = [] + element.sketch.get_coincident_points = Mock(return_value=[]) + element.selection = Mock() + element.selection.clear = Mock() + element.selection.point_ids = [] + element.selection.entity_ids = [] + element.selection.constraint_idx = None + element.selection.junction_pid = None + element.selection.select_point = Mock() + element.selection.select_entity = Mock() + element.selection.select_constraint = Mock() + element.selection.select_junction = Mock() + element.selection.copy = Mock(return_value=Mock()) + element.hittester = Mock() + element.hittester.get_hit_data = Mock(return_value=(None, None)) + element.hittester.get_objects_in_rect = Mock(return_value=([], [])) + element.canvas = None + element.mark_dirty = Mock() + element.get_world_transform = Mock() + element.content_transform = Mock() + element.constraint_edit_requested = Mock() + return element + + +@pytest.fixture +def select_tool(mock_element): + """Create a SelectTool instance for testing.""" + return SelectTool(mock_element) + + +@pytest.mark.ui +def test_select_tool_initialization(select_tool, mock_element): + """Test that SelectTool initializes correctly.""" + assert select_tool.element == mock_element + assert select_tool.hovered_point_id is None + assert select_tool.hovered_constraint_idx is None + assert select_tool.hovered_junction_pid is None + assert select_tool.is_box_selecting is False + assert select_tool.drag_start_world_pos is None + assert select_tool.drag_current_world_pos is None + assert select_tool.dragged_point_id is None + assert select_tool.drag_point_start_pos is None + assert select_tool.dragged_entity is None + assert select_tool.drag_start_model_pos is None + + +@pytest.mark.ui +def test_select_tool_on_press_no_hit(select_tool, mock_element): + """Test on_press when nothing is hit.""" + mock_element.hittester.get_hit_data.return_value = (None, None) + mock_element.canvas = Mock() + mock_element.canvas._shift_pressed = False + mock_element.canvas._ctrl_pressed = False + + result = select_tool.on_press(100.0, 200.0, 1) + + assert result is False + assert select_tool.is_box_selecting is True + assert select_tool.drag_start_world_pos == (100.0, 200.0) + + +@pytest.mark.ui +def test_select_tool_on_press_point_hit(select_tool, mock_element): + """Test on_press when a point is hit.""" + mock_element.hittester.get_hit_data.return_value = ("point", 5) + + result = select_tool.on_press(100.0, 200.0, 1) + + assert result is False + mock_element.selection.select_point.assert_called_once_with(5, False) + + +@pytest.mark.ui +def test_select_tool_on_press_entity_hit(select_tool, mock_element): + """Test on_press when an entity is hit.""" + mock_entity = Mock(spec=Line) + mock_entity.id = 10 + mock_entity.p1_idx = 1 + mock_entity.p2_idx = 2 + mock_element.hittester.get_hit_data.return_value = ("entity", mock_entity) + mock_element.hittester.screen_to_model.return_value = (100.0, 200.0) + + result = select_tool.on_press(100.0, 200.0, 1) + + assert result is False + mock_element.selection.select_entity.assert_called_once() + + +@pytest.mark.ui +def test_select_tool_on_drag_no_state(select_tool): + """Test on_drag when no drag state is set.""" + select_tool.on_drag(10.0, 20.0) + assert True + + +@pytest.mark.ui +def test_select_tool_on_drag_box_select(select_tool, mock_element): + """Test on_drag during box selection.""" + select_tool.is_box_selecting = True + select_tool.drag_start_world_pos = (100.0, 200.0) + mock_element.hittester.get_objects_in_rect.return_value = ([1, 2], [3]) + mock_element.hittester.screen_to_model.return_value = (150.0, 300.0) + + select_tool.on_drag(50.0, 100.0) + + assert select_tool.drag_current_world_pos == (150.0, 300.0) + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_select_tool_on_release_box_select(select_tool): + """Test on_release after box selection.""" + select_tool.is_box_selecting = True + select_tool.drag_start_world_pos = (100.0, 200.0) + select_tool.drag_current_world_pos = (150.0, 300.0) + + select_tool.on_release(150.0, 300.0) + + assert select_tool.is_box_selecting is False + assert select_tool.drag_start_world_pos is None + assert select_tool.drag_current_world_pos is None + + +@pytest.mark.ui +def test_select_tool_on_hover_motion_no_change(select_tool, mock_element): + """Test on_hover_motion when hit type doesn't change.""" + mock_element.hittester.get_hit_data.return_value = ("point", 5) + select_tool.hovered_point_id = 5 + + select_tool.on_hover_motion(100.0, 200.0) + + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_select_tool_on_hover_motion_change(select_tool, mock_element): + """Test on_hover_motion when hit type changes.""" + mock_element.hittester.get_hit_data.return_value = ("point", 5) + select_tool.hovered_point_id = None + + select_tool.on_hover_motion(100.0, 200.0) + + assert select_tool.hovered_point_id == 5 + mock_element.mark_dirty.assert_called_once() + + +@pytest.mark.ui +def test_select_tool_prepare_point_drag(select_tool, mock_element): + """Test _prepare_point_drag sets up drag state.""" + mock_point = Mock() + mock_point.x = 10.0 + mock_point.y = 20.0 + mock_element.sketch.registry.get_point.return_value = mock_point + mock_element.get_world_transform.return_value.invert.return_value = Mock() + mock_element.content_transform.invert.return_value = Mock() + + select_tool._prepare_point_drag(5) + + assert select_tool.dragged_point_id == 5 + assert select_tool.drag_point_start_pos == (10.0, 20.0) + assert select_tool.dragged_entity is None + + +@pytest.mark.ui +def test_select_tool_draw_overlay_no_box(select_tool): + """Test draw_overlay when not box selecting.""" + ctx = Mock(spec=cairo.Context) + select_tool.is_box_selecting = False + + select_tool.draw_overlay(ctx) + + ctx.save.assert_not_called() + + +@pytest.mark.ui +def test_select_tool_draw_overlay_with_box(select_tool, mock_element): + """Test draw_overlay when box selecting.""" + ctx = Mock(spec=cairo.Context) + select_tool.is_box_selecting = True + select_tool.drag_start_world_pos = (100.0, 200.0) + select_tool.drag_current_world_pos = (150.0, 300.0) + mock_element.canvas = Mock() + mock_element.canvas.view_transform = Mock() + mock_element.canvas.view_transform.transform_point.return_value = ( + 100.0, + 200.0, + ) + + select_tool.draw_overlay(ctx) + + ctx.save.assert_called_once() + ctx.rectangle.assert_called_once() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_text_box_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_text_box_tool.py new file mode 100644 index 000000000..37417c559 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_text_box_tool.py @@ -0,0 +1,465 @@ +from unittest.mock import MagicMock, Mock + +import pytest +from raygeo.geo.shape.text import FontConfig +from sketcher.core.entities import TextBoxEntity +from sketcher.ui_gtk.tools import TextBoxTool +from sketcher.ui_gtk.tools.base import SketcherKey +from sketcher.ui_gtk.tools.text_box_tool import TextBoxState + + +@pytest.fixture +def mock_element(): + """Create a mock SketchElement for testing tools.""" + element = Mock() + element.sketch = Mock() + element.sketch.registry._id_counter = 0 + element.sketch.registry.entities = [] + element.sketch.is_fully_constrained = False + element.hittester.get_hit_data.return_value = (None, None) + element.execute_command = MagicMock() + element.mark_dirty = MagicMock() + element.sketch.registry.get_entity = Mock( + side_effect=lambda x: Mock() if x == 5 else None + ) + element.content_transform = Mock() + element.content_transform.transform_point = Mock(return_value=(100, 200)) + return element + + +@pytest.fixture +def text_box_tool(mock_element): + """Create a TextBoxTool instance with a mocked element.""" + return TextBoxTool(mock_element) + + +@pytest.mark.ui +def test_text_box_tool_initialization(text_box_tool): + """Test tool's initial state.""" + assert text_box_tool.state == TextBoxState.IDLE + assert text_box_tool.editing_entity_id is None + assert text_box_tool.text_buffer == "" + assert text_box_tool.cursor_pos == 0 + assert text_box_tool.cursor_visible is True + + +@pytest.mark.ui +def test_text_box_tool_on_press_creates_box(text_box_tool, mock_element): + """Test that first press creates a text box and enters EDITING state.""" + mock_element.hittester.screen_to_model.return_value = (10, 20) + mock_element.sketch.registry.entities = [] + + mock_entity = TextBoxEntity( + 5, 0, 1, 2, content="", construction_line_ids=[] + ) + + def mock_execute(cmd): + cmd.text_box_id = 5 + + mock_element.execute_command.side_effect = mock_execute + + def get_entity_side_effect(eid): + if eid == 5: + return mock_entity + return None + + mock_element.sketch.registry.get_entity.side_effect = ( + get_entity_side_effect + ) + + result = text_box_tool.on_press(100, 200, 1) + + assert result is True + assert text_box_tool.state == TextBoxState.EDITING + assert text_box_tool.editing_entity_id == 5 + assert text_box_tool.text_buffer == "" + assert mock_element.execute_command.call_count == 1 + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_text_box_tool_on_press_outside_box_creates_new( + text_box_tool, mock_element +): + """Test that clicking outside box creates a new text box.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.editing_entity_id = 5 + text_box_tool.text_buffer = "Test Text" + + mock_element.hittester.screen_to_model.return_value = (1000, 1000) + mock_element.content_transform.transform_point.return_value = (1000, 1000) + mock_element.sketch.registry.entities = [] + + mock_entity = MagicMock(spec=TextBoxEntity) + mock_entity.origin_id = 0 + mock_entity.width_id = 1 + mock_entity.height_id = 2 + mock_entity.content = "Test Text" + mock_entity.font_config = FontConfig(family="sans-serif", size=10.0) + + new_entity = TextBoxEntity( + 6, 0, 1, 2, content="", construction_line_ids=[] + ) + + def get_entity_side_effect(eid): + if eid == 5: + return mock_entity + if eid == 6: + return new_entity + return None + + mock_element.sketch.registry.get_entity.side_effect = ( + get_entity_side_effect + ) + + def get_point_side_effect(pid): + vals = {0: (0, 0), 1: (50, 0), 2: (0, 10)} + if pid in vals: + return Mock(x=vals[pid][0], y=vals[pid][1]) + return Mock(x=0, y=0) + + mock_element.sketch.registry.get_point.side_effect = get_point_side_effect + + def mock_execute(cmd): + cmd.text_box_id = 6 + + mock_element.execute_command.side_effect = mock_execute + + result = text_box_tool.on_press(100, 200, 1) + + assert result is True + assert text_box_tool.state == TextBoxState.EDITING + assert text_box_tool.editing_entity_id == 6 + assert text_box_tool.text_buffer == "" + assert mock_element.execute_command.call_count == 2 + + +@pytest.mark.ui +def test_text_box_tool_handle_text_input_appends_character( + text_box_tool, mock_element +): + """Test that text input appends character to buffer.""" + text_box_tool.state = TextBoxState.EDITING + + result = text_box_tool.handle_text_input("A") + + assert result is True + assert text_box_tool.text_buffer == "A" + assert text_box_tool.cursor_pos == 1 + + +@pytest.mark.ui +def test_text_box_tool_handle_text_input_inserts_at_cursor( + text_box_tool, mock_element +): + """Test that text input inserts at cursor position.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.text_buffer = "AB" + text_box_tool.cursor_pos = 1 + + result = text_box_tool.handle_text_input("X") + + assert result is True + assert text_box_tool.text_buffer == "AXB" + assert text_box_tool.cursor_pos == 2 + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_backspace(text_box_tool, mock_element): + """Test that backspace key is handled.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.text_buffer = "Test" + text_box_tool.cursor_pos = 4 + + result = text_box_tool.handle_key_event(SketcherKey.BACKSPACE) + + assert result is True + assert text_box_tool.text_buffer == "Tes" + assert text_box_tool.cursor_pos == 3 + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_delete(text_box_tool, mock_element): + """Test that delete key is handled.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.text_buffer = "Test" + text_box_tool.cursor_pos = 1 + + result = text_box_tool.handle_key_event(SketcherKey.DELETE) + + assert result is True + assert text_box_tool.text_buffer == "Tst" + assert text_box_tool.cursor_pos == 1 + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_arrow_left( + text_box_tool, mock_element +): + """Test that arrow left key moves cursor.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.text_buffer = "Test" + text_box_tool.cursor_pos = 4 + + result = text_box_tool.handle_key_event(SketcherKey.ARROW_LEFT) + + assert result is True + assert text_box_tool.cursor_pos == 3 + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_arrow_right( + text_box_tool, mock_element +): + """Test that arrow right key moves cursor.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.text_buffer = "Test" + text_box_tool.cursor_pos = 1 + + result = text_box_tool.handle_key_event(SketcherKey.ARROW_RIGHT) + + assert result is True + assert text_box_tool.cursor_pos == 2 + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_return(text_box_tool, mock_element): + """Test that return key finalizes edit.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.editing_entity_id = 5 + text_box_tool.text_buffer = "Test" + + mock_entity = Mock() + mock_entity.id = 5 + mock_entity.font_config = FontConfig(family="sans-serif", size=10.0) + mock_element.sketch.registry.get_entity = Mock(return_value=mock_entity) + + result = text_box_tool.handle_key_event(SketcherKey.RETURN) + + assert result is True + assert text_box_tool.state == TextBoxState.IDLE + assert text_box_tool.editing_entity_id is None + assert text_box_tool.text_buffer == "" + mock_element.execute_command.assert_called_once() + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_escape(text_box_tool, mock_element): + """Test that escape key cancels edit.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.editing_entity_id = 5 + text_box_tool.text_buffer = "Test" + + mock_entity = Mock() + mock_entity.id = 5 + mock_entity.font_params = {"family": "sans-serif", "size": 10.0} + mock_element.sketch.registry.get_entity = Mock(return_value=mock_entity) + + result = text_box_tool.handle_key_event(SketcherKey.ESCAPE) + + assert result is True + assert text_box_tool.state == TextBoxState.IDLE + assert text_box_tool.editing_entity_id is None + assert text_box_tool.text_buffer == "" + + +@pytest.mark.ui +def test_text_box_tool_handle_key_event_idle_state( + text_box_tool, mock_element +): + """Test that key event is ignored in IDLE state.""" + text_box_tool.state = TextBoxState.IDLE + + result = text_box_tool.handle_key_event(SketcherKey.BACKSPACE) + + assert result is False + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_text_box_tool_handle_text_input_idle_state( + text_box_tool, mock_element +): + """Test that text input is ignored in IDLE state.""" + text_box_tool.state = TextBoxState.IDLE + + result = text_box_tool.handle_text_input("A") + + assert result is False + mock_element.mark_dirty.assert_not_called() + + +@pytest.mark.ui +def test_text_box_tool_start_editing(text_box_tool, mock_element): + """Test starting to edit an existing text box.""" + mock_entity = TextBoxEntity( + 5, 0, 1, 2, content="Existing text", construction_line_ids=[] + ) + mock_entity.font_config = FontConfig(family="sans-serif", size=10.0) + mock_element.sketch.registry.get_entity = Mock(return_value=mock_entity) + + text_box_tool.start_editing(5) + + assert text_box_tool.state == TextBoxState.EDITING + assert text_box_tool.editing_entity_id == 5 + assert text_box_tool.text_buffer == "Existing text" + assert text_box_tool.cursor_pos == 13 + assert text_box_tool.cursor_visible is True + mock_element.mark_dirty.assert_called() + + +@pytest.mark.ui +def test_text_box_tool_on_deactivate(text_box_tool, mock_element): + """Test that deactivating cleans up state.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.editing_entity_id = 5 + text_box_tool.text_buffer = "Test" + text_box_tool.cursor_pos = 4 + + text_box_tool.on_deactivate() + + assert text_box_tool.state == TextBoxState.IDLE + assert text_box_tool.editing_entity_id is None + assert text_box_tool.text_buffer == "" + assert text_box_tool.cursor_pos == 0 + + +@pytest.mark.ui +def test_text_box_tool_toggle_cursor_visibility(text_box_tool, mock_element): + """Test toggling cursor visibility.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.cursor_visible = True + + text_box_tool.toggle_cursor_visibility() + + assert text_box_tool.cursor_visible is False + mock_element.mark_dirty.assert_called() + + text_box_tool.toggle_cursor_visibility() + + assert text_box_tool.cursor_visible is True + + +@pytest.mark.ui +def test_text_box_tool_on_drag_does_nothing(text_box_tool, mock_element): + """Test that drag does nothing.""" + result = text_box_tool.on_drag(10, 20) + + assert result is None + + +@pytest.mark.ui +def test_text_box_tool_on_release_does_nothing(text_box_tool, mock_element): + """Test that release does nothing.""" + result = text_box_tool.on_release(10, 20) + + assert result is None + + +@pytest.mark.ui +def test_text_box_tool_is_click_outside_box(text_box_tool, mock_element): + """Test checking if click is outside box bounds.""" + text_box_tool.editing_entity_id = 5 + + mock_entity = MagicMock(spec=TextBoxEntity) + mock_entity.origin_id = 0 + mock_entity.width_id = 1 + mock_entity.height_id = 2 + + mock_element.sketch.registry.get_entity.return_value = mock_entity + + # Robust get_point mock + def get_point_side_effect(pid): + vals = {0: (0, 0), 1: (50, 0), 2: (0, 10)} + if pid in vals: + return Mock(x=vals[pid][0], y=vals[pid][1]) + return Mock(x=0, y=0) + + mock_element.sketch.registry.get_point.side_effect = get_point_side_effect + + mock_element.hittester.screen_to_model.return_value = (100, 100) + + result = text_box_tool._is_point_inside_box(100, 200) + + assert result is False + + +@pytest.mark.ui +def test_text_box_tool_is_click_inside_box(text_box_tool, mock_element): + """Test checking if click is inside box bounds.""" + text_box_tool.editing_entity_id = 5 + + mock_entity = TextBoxEntity( + 5, 0, 1, 2, content="", construction_line_ids=[] + ) + mock_entity.font_config = FontConfig(family="sans-serif", size=10.0) + + mock_element.sketch.registry.get_entity = Mock(return_value=mock_entity) + + def get_point_side_effect(pid): + vals = {0: (0, 0), 1: (50, 0), 2: (0, 10)} + if pid in vals: + return Mock(x=vals[pid][0], y=vals[pid][1]) + return Mock(x=0, y=0) + + mock_element.sketch.registry.get_point.side_effect = get_point_side_effect + mock_element.hittester.screen_to_model.return_value = (25, 5) + + result = text_box_tool._is_point_inside_box(25, 5) + + assert result is True + + +@pytest.mark.ui +def test_text_box_tool_draw_overlay_idle_state(text_box_tool, mock_element): + """Test that draw_overlay does nothing in IDLE state.""" + ctx = MagicMock() + + text_box_tool.draw_overlay(ctx) + + ctx.save.assert_not_called() + + +@pytest.mark.ui +def test_text_box_tool_draw_overlay_editing_state(text_box_tool, mock_element): + """Test that draw_overlay draws in EDITING state.""" + text_box_tool.state = TextBoxState.EDITING + text_box_tool.editing_entity_id = 5 + text_box_tool.text_buffer = "Test" + text_box_tool.cursor_pos = 4 + text_box_tool.cursor_visible = True + + mock_entity = Mock() + mock_entity.origin_id = 0 + mock_entity.width_id = 1 + mock_entity.height_id = 2 + mock_entity.font_config = FontConfig( + family="sans-serif", + size=10.0, + bold=False, + italic=False, + ) + mock_entity.get_font_metrics = Mock(return_value=(10.0, -2.0, 12.0)) + + mock_element.sketch.registry.get_entity = Mock(return_value=mock_entity) + mock_element.sketch.registry.get_point.side_effect = [ + Mock(x=0, y=0), + Mock(x=50, y=0), + Mock(x=0, y=10), + ] + mock_matrix = Mock() + mock_matrix.for_cairo.return_value = (1, 0, 0, 1, 0, 0) + mock_element.hittester.get_model_to_screen_transform.return_value = ( + mock_matrix + ) + mock_element.canvas = Mock() + mock_element.canvas.get_view_scale.return_value = (1.0, 1.0) + + ctx = MagicMock() + + text_box_tool.draw_overlay(ctx) + + ctx.save.assert_called() + ctx.restore.assert_called() diff --git a/rayforge/builtin_addons/rayforge-addon-tools/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-tools/rayforge-addon.yaml new file mode 100644 index 000000000..733dac9c2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/rayforge-addon.yaml @@ -0,0 +1,14 @@ +name: tool_library +display_name: "Tool Library" +description: "Cutting-tool library for CNC machining" +api_version: 18 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "tool_library" + frontend: "tool_library.frontend" +license: + name: "MIT" +default_state: "disabled" +maturity: experimental diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tests/test_singleton.py b/rayforge/builtin_addons/rayforge-addon-tools/tests/test_singleton.py new file mode 100644 index 000000000..87327437a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tests/test_singleton.py @@ -0,0 +1,25 @@ +""" +Tests for the get_tool_manager singleton accessor. +""" + +import tool_library +from tool_library import get_tool_manager + + +def test_singleton_is_cached(monkeypatch, tmp_path): + monkeypatch.setattr(tool_library, "_manager", None) + monkeypatch.setattr("rayforge.config.CONFIG_DIR", tmp_path, raising=False) + + first = get_tool_manager() + second = get_tool_manager() + assert first is second + + +def test_singleton_reset_reconstructs(monkeypatch, tmp_path): + monkeypatch.setattr(tool_library, "_manager", None) + monkeypatch.setattr("rayforge.config.CONFIG_DIR", tmp_path, raising=False) + + first = get_tool_manager() + monkeypatch.setattr(tool_library, "_manager", None) + second = get_tool_manager() + assert first is not second diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tests/test_tool.py b/rayforge/builtin_addons/rayforge-addon-tools/tests/test_tool.py new file mode 100644 index 000000000..becbead67 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tests/test_tool.py @@ -0,0 +1,121 @@ +""" +Tests for the Tool dataclass and YAML (de)serialization. +""" + +import pytest +from tool_library.tool import ( + CATEGORY_BY_NAME, + CATEGORY_NAMES, + TOOL_MATERIAL_BY_NAME, + TOOL_MATERIAL_NAMES, + Tool, + category_to_name, + tool_material_to_name, +) + + +def test_create_default_has_endmill_carbide(): + tool = Tool.create_default() + assert tool.category == CATEGORY_BY_NAME["END_MILL"] + assert tool.tool_material == TOOL_MATERIAL_BY_NAME["CARBIDE"] + assert tool.diameter() == pytest.approx(6.0) + assert tool.name + + +@pytest.mark.parametrize("cat_name", CATEGORY_NAMES) +def test_category_round_trip(cat_name): + tool = Tool.create_default() + tool = Tool( + uid=tool.uid, + name=tool.name, + max_rpm=tool.max_rpm, + label=tool.label, + category=CATEGORY_BY_NAME[cat_name], + tool_material=tool.tool_material, + stickout=tool.stickout, + coating=tool.coating, + model=tool.model, + ) + data = tool.to_dict() + assert data["category"] == cat_name + restored = Tool.from_dict(data) + assert restored.category == CATEGORY_BY_NAME[cat_name] + assert category_to_name(restored.category) == cat_name + + +@pytest.mark.parametrize("mat_name", TOOL_MATERIAL_NAMES) +def test_tool_material_round_trip(mat_name): + tool = Tool.create_default() + tool = Tool( + uid=tool.uid, + name=tool.name, + max_rpm=tool.max_rpm, + label=tool.label, + category=tool.category, + tool_material=TOOL_MATERIAL_BY_NAME[mat_name], + stickout=tool.stickout, + coating=tool.coating, + model=tool.model, + ) + restored = Tool.from_dict(tool.to_dict()) + assert restored.tool_material == TOOL_MATERIAL_BY_NAME[mat_name] + assert tool_material_to_name(restored.tool_material) == mat_name + + +def test_full_round_trip_preserves_model_params(): + tool = Tool.create_default("My EM") + tool = Tool( + uid="abc-123", + name="My EM", + max_rpm=18000.0, + label="6mm", + category=CATEGORY_BY_NAME["BALL_NOSE"], + tool_material=TOOL_MATERIAL_BY_NAME["HSS"], + stickout=20.0, + coating="AlTiN", + model=tool.model, + ) + data = tool.to_dict() + restored = Tool.from_dict(data) + + assert restored.uid == "abc-123" + assert restored.name == "My EM" + assert restored.max_rpm == pytest.approx(18000.0) + assert restored.label == "6mm" + assert restored.category == CATEGORY_BY_NAME["BALL_NOSE"] + assert restored.tool_material == TOOL_MATERIAL_BY_NAME["HSS"] + assert restored.stickout == pytest.approx(20.0) + assert restored.coating == "AlTiN" + assert restored.diameter() == pytest.approx(6.0) + assert restored.model.get_parameters() == tool.model.get_parameters() + + +def test_round_trip_with_no_coating(): + tool = Tool.create_default() + tool = Tool( + uid=tool.uid, + name=tool.name, + max_rpm=tool.max_rpm, + label=tool.label, + category=tool.category, + tool_material=tool.tool_material, + stickout=tool.stickout, + coating=None, + model=tool.model, + ) + assert tool.to_dict()["coating"] is None + assert Tool.from_dict(tool.to_dict()).coating is None + + +def test_from_dict_assigns_uid_when_missing(): + data = Tool.create_default().to_dict() + del data["uid"] + restored = Tool.from_dict(data) + assert restored.uid + + +def test_from_dict_unknown_category_falls_back(): + data = Tool.create_default().to_dict() + data["category"] = "Nonexistent" + restored = Tool.from_dict(data) + assert restored.category == CATEGORY_BY_NAME["END_MILL"] diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tests/test_tool_manager.py b/rayforge/builtin_addons/rayforge-addon-tools/tests/test_tool_manager.py new file mode 100644 index 000000000..e3a0a55aa --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tests/test_tool_manager.py @@ -0,0 +1,95 @@ +""" +Tests for the ToolManager: CRUD, YAML persistence, changed signal. +""" + +import pytest +from tool_library.manager import ToolManager +from tool_library.tool import CATEGORY_BY_NAME, Tool + + +@pytest.fixture +def mgr(tmp_path): + return ToolManager(tmp_path) + + +def test_starts_empty(mgr): + assert mgr.get_all() == [] + assert mgr.get("nope") is None + + +def test_save_persists_and_indexes(mgr, tmp_path): + tool = Tool.create_default("First") + mgr.save(tool) + + assert mgr.get(tool.uid) is tool + assert [t.name for t in mgr.get_all()] == ["First"] + assert (tmp_path / f"{tool.uid}.yaml").exists() + + +def test_load_reads_existing_files(tmp_path): + tool = Tool.create_default("Persisted") + mgr = ToolManager(tmp_path) + mgr.save(tool) + + fresh = ToolManager(tmp_path) + loaded = fresh.get(tool.uid) + assert loaded is not None + assert loaded.name == "Persisted" + assert loaded.diameter() == pytest.approx(6.0) + + +def test_update_existing_tool(mgr): + tool = Tool.create_default("Rename Me") + mgr.save(tool) + + updated = Tool( + uid=tool.uid, + name="Renamed", + max_rpm=tool.max_rpm, + label=tool.label, + category=CATEGORY_BY_NAME["CHAMFER"], + tool_material=tool.tool_material, + stickout=tool.stickout, + coating=tool.coating, + model=tool.model, + ) + mgr.save(updated) + + assert mgr.get(tool.uid).name == "Renamed" + assert mgr.get(tool.uid).category == CATEGORY_BY_NAME["CHAMFER"] + assert len(mgr.get_all()) == 1 + + +def test_delete_removes_from_memory_and_disk(mgr, tmp_path): + tool = Tool.create_default("ToDelete") + mgr.save(tool) + assert (tmp_path / f"{tool.uid}.yaml").exists() + + assert mgr.delete(tool.uid) is True + assert mgr.get(tool.uid) is None + assert not (tmp_path / f"{tool.uid}.yaml").exists() + assert mgr.delete(tool.uid) is False + + +def test_get_all_sorted_by_name(mgr): + b = Tool.create_default("Banana") + a = Tool.create_default("Apple") + mgr.save(b) + mgr.save(a) + assert [t.name for t in mgr.get_all()] == ["Apple", "Banana"] + + +def test_changed_signal_emitted_on_mutations(mgr): + fired = [] + + def handler(_sender): + fired.append(True) + + mgr.changed.connect(handler) + + tool = Tool.create_default() + mgr.save(tool) + assert len(fired) == 1 + + mgr.delete(tool.uid) + assert len(fired) == 2 diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tests/ui_gtk/conftest.py b/rayforge/builtin_addons/rayforge-addon-tools/tests/ui_gtk/conftest.py new file mode 100644 index 000000000..b05f7146c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tests/ui_gtk/conftest.py @@ -0,0 +1,47 @@ +"""UI fixtures for tool_library settings-page tests.""" + +import asyncio +import logging + +import pytest + +from rayforge import config as config_module +from rayforge import context as context_module +from rayforge.context import get_context +from rayforge.shared import tasker +from rayforge.shared.tasker.manager import TaskManager +from rayforge.shared.util.glib import idle_add + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def ui_task_mgr(): + tm = TaskManager(main_thread_scheduler=idle_add) + yield tm + if tm.has_tasks(): + logger.warning("Task manager had pending tasks at teardown.") + tm.shutdown() + + +@pytest.fixture +def ui_context(ui_task_mgr, monkeypatch, tmp_path): + temp_config_dir = tmp_path / "config" + temp_machine_dir = temp_config_dir / "machines" + temp_addons_dir = temp_config_dir / "addons" + monkeypatch.setattr(config_module, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(config_module, "MACHINE_DIR", temp_machine_dir) + monkeypatch.setattr(config_module, "ADDONS_DIR", temp_addons_dir) + monkeypatch.setattr(tasker.task_mgr, "_instance", ui_task_mgr) + + # Reset the tool_library singleton so each test gets a fresh manager + # rooted at the test's CONFIG_DIR. + import tool_library + + monkeypatch.setattr(tool_library, "_manager", None) + + context = get_context() + yield context + + asyncio.run(context.shutdown()) + context_module._context_instance = None diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tests/ui_gtk/test_tool_page.py b/rayforge/builtin_addons/rayforge-addon-tools/tests/ui_gtk/test_tool_page.py new file mode 100644 index 000000000..2fb6ef18a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tests/ui_gtk/test_tool_page.py @@ -0,0 +1,74 @@ +""" +UI tests: the tool-manager settings page is contributed and rendered. +""" + +from typing import Any, cast + +import pytest +from gi.repository import Gtk +from tool_library.edit_dialog import AddEditToolDialog +from tool_library.frontend import ToolManagerPage, register_settings_pages +from tool_library.tool import CATEGORY_NAMES, CATEGORY_PARAMS, Tool + +from rayforge.ui_gtk.settings.registry import settings_page_registry +from rayforge.ui_gtk.settings.settings_dialog import SettingsWindow + +pytestmark = pytest.mark.ui + + +def test_tool_page_registered_via_hook(ui_context): + """The frontend hook registers ToolManagerPage when addons load.""" + before = len(settings_page_registry.get_pages()) + register_settings_pages(settings_page_registry) + register_settings_pages(settings_page_registry) + pages = settings_page_registry.get_pages() + + assert ToolManagerPage in pages + assert len(pages) == before + 1 # deduped + + +def test_tool_page_present_in_settings_dialog(ui_context): + """SettingsWindow renders a 'Tools' page from the addon registry.""" + win = SettingsWindow() + pages = cast(Any, win.content_stack.get_pages()) + titles = [ + pages.get_item(i).get_title() for i in range(pages.get_n_items()) + ] + assert "Tools" in titles + win.destroy() + + +def test_dialog_geometry_adapts_to_category(ui_context): + """Switching category rebuilds the geometry rows for that shape.""" + win = SettingsWindow() + root = win.get_root() + dlg = AddEditToolDialog(cast(Gtk.Window, root) if root else None) + try: + assert "corner_radius" not in dlg.param_keys() + dlg._category.set_selected(CATEGORY_NAMES.index("BULL_NOSE")) + assert "corner_radius" in dlg.param_keys() + dlg._category.set_selected(CATEGORY_NAMES.index("DRILL")) + assert "corner_radius" not in dlg.param_keys() + assert "shank_diameter" not in dlg.param_keys() + cat = CATEGORY_NAMES[dlg._category.get_selected()] + assert set(dlg.param_keys()) == {s.key for s in CATEGORY_PARAMS[cat]} + finally: + dlg.destroy() + win.destroy() + + +def test_dialog_length_rows_use_unit_helper(ui_context): + """Length fields are unit-aware and round-trip base mm.""" + win = SettingsWindow() + root = win.get_root() + tool = cast(Any, Tool.create_default("Mine")) + dlg = AddEditToolDialog( + cast(Gtk.Window, root) if root else None, tool=tool + ) + try: + assert dlg.is_length_param("diameter") + assert not dlg.is_length_param("flute_count") + assert dlg.get_tool().diameter() == pytest.approx(6.0) + finally: + dlg.destroy() + win.destroy() diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tool_library/__init__.py b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/__init__.py new file mode 100644 index 000000000..3a8a76e1a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/__init__.py @@ -0,0 +1,40 @@ +""" +tool_library addon: cutting-tool library for CNC machining. + +Worker entry point. Publishes the :func:`get_tool_manager` accessor as +the ``"tool_manager"`` service, which other addons resolve via the +global service registry:: + + from rayforge.core.service_registry import service_registry + + get_tool_manager = service_registry.get("tool_manager") + if get_tool_manager is not None: + tool = get_tool_manager().get(tool_uid) +""" + +import logging + +from rayforge.config import CONFIG_DIR +from rayforge.core.hooks import hookimpl +from rayforge.core.service_registry import ServiceRegistry + +from .manager import ToolManager + +logger = logging.getLogger(__name__) + +_manager: ToolManager | None = None + + +def get_tool_manager() -> ToolManager: + """Return the process-wide :class:`ToolManager` singleton (lazy).""" + global _manager + if _manager is None: + _manager = ToolManager(CONFIG_DIR / "tools") + return _manager + + +@hookimpl +def register_services(service_registry: ServiceRegistry) -> None: + service_registry.register( + "tool_manager", get_tool_manager, addon_name="tool_library" + ) diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tool_library/edit_dialog.py b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/edit_dialog.py new file mode 100644 index 000000000..8207d266f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/edit_dialog.py @@ -0,0 +1,348 @@ +""" +Add/edit dialog for a single tool. + +Follows the recipe-editor convention: a :class:`PatchedDialogWindow` +with an ``Adw.ToolbarView`` header, a custom toggle-button tab bar in +the header, and an ``Adw.ViewStack`` of ``Adw.PreferencesPage`` tabs. +The geometry rows are rebuilt from +:data:`~tool_library.tool.CATEGORY_PARAMS` when the category changes, so +the editor only asks for the attributes relevant to the selected shape. +Length fields use +:class:`~rayforge.ui_gtk.shared.pref_rows.length_choice_spin_row.LengthChoiceSpinRow` +so they are stored in base mm but shown in a per-row unit chosen by the +user via the dropdown (defaulting to their preferred unit). +""" + +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, Gtk +from raygeo.cnc.tool import ToolModel + +from rayforge.ui_gtk.icons import get_icon +from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow +from rayforge.ui_gtk.shared.pref_rows import ( + LengthChoiceSpinRow, + SpinRow, +) + +from .tool import ( + CATEGORY_BY_NAME, + CATEGORY_LABELS, + CATEGORY_NAMES, + CATEGORY_PARAMS, + TOOL_MATERIAL_BY_NAME, + TOOL_MATERIAL_LABELS, + TOOL_MATERIAL_NAMES, + ParamSpec, + Tool, + category_to_name, + tool_material_to_name, +) + + +class AddEditToolDialog(PatchedDialogWindow): + """Add or edit a :class:`Tool`.""" + + def __init__( + self, + parent: Gtk.Window | None, + tool: Tool | None = None, + ): + super().__init__(transient_for=parent, modal=True) + self.response = Signal() + self._tool = tool + is_editing = tool is not None + self.set_title(_("Edit Tool") if is_editing else _("Add Tool")) + self.set_default_size(560, 500) + self._positive = "save" if is_editing else "add" + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + self.set_content(toolbar) + + cancel_btn = Gtk.Button(label=_("Cancel")) + cancel_btn.connect("clicked", lambda _w: self._send_response("cancel")) + header.pack_start(cancel_btn) + + save_btn = Gtk.Button(label=_("Save") if is_editing else _("Add")) + save_btn.add_css_class("suggested-action") + save_btn.connect( + "clicked", lambda _w: self._send_response(self._positive) + ) + header.pack_end(save_btn) + + self.view_stack = Adw.ViewStack() + toolbar.set_content(self.view_stack) + + self.switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.switcher_box.add_css_class("linked") + header.set_title_widget(self.switcher_box) + self._tab_buttons: dict[str, Gtk.ToggleButton] = {} + + self._params = tool.model.get_parameters() if tool else {} + + self._build_general_page(tool) + self._build_geometry_page(tool) + self._build_setup_page(tool) + + self._tab_buttons["general"].set_active(True) + + # --- Tab wiring ----------------------------------------------------- + + def _create_tab_child(self, text: str, icon_name: str) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.append(get_icon(icon_name)) + box.append(Gtk.Label(label=text)) + return box + + def _add_page( + self, + page: Gtk.Widget, + name: str, + title: str, + icon_name: str, + ): + group = self._tab_buttons["general"] if self._tab_buttons else None + button = Gtk.ToggleButton(group=group) if group else Gtk.ToggleButton() + button.set_child(self._create_tab_child(title, icon_name)) + button.connect("toggled", self._on_tab_toggled, name) + self.switcher_box.append(button) + self.view_stack.add_named(page, name) + self._tab_buttons[name] = button + + def _on_tab_toggled(self, button: Gtk.ToggleButton, page_name: str): + if button.get_active(): + self.view_stack.set_visible_child_name(page_name) + + # --- Pages ---------------------------------------------------------- + + def _build_general_page(self, tool: Tool | None) -> None: + page = Adw.PreferencesPage() + group = Adw.PreferencesGroup( + title=_("General"), + description=_("Identity and coating of the tool."), + ) + page.add(group) + self._name = Adw.EntryRow(title=_("Name")) + self._label = Adw.EntryRow(title=_("Label")) + self._coating = Adw.EntryRow(title=_("Coating (optional)")) + group.add(self._name) + group.add(self._label) + group.add(self._coating) + + if tool is not None: + self._name.set_text(tool.name) + self._label.set_text(tool.label) + if tool.coating: + self._coating.set_text(tool.coating) + + for entry in (self._name, self._label, self._coating): + entry.connect( + "apply", lambda _w: self._send_response(self._positive) + ) + + self._add_page(page, "general", _("General"), "general-symbolic") + self._name.grab_focus() + + def _build_geometry_page(self, tool: Tool | None) -> None: + page = Adw.PreferencesPage() + self._geom = Adw.PreferencesGroup( + title=_("Geometry"), + description=_("Cutting shape, tool material, and dimensions."), + ) + page.add(self._geom) + + category_labels = [CATEGORY_LABELS[n] for n in CATEGORY_NAMES] + self._category = Adw.ComboRow( + title=_("Category"), + subtitle=_("Classification used for operation compatibility"), + model=Gtk.StringList.new(category_labels), + ) + self._category.set_selected( + CATEGORY_NAMES.index( + category_to_name(tool.category) if tool else CATEGORY_NAMES[0] + ) + ) + self._geom.add(self._category) + + material_labels = [ + TOOL_MATERIAL_LABELS[n] for n in TOOL_MATERIAL_NAMES + ] + self._tool_material = Adw.ComboRow( + title=_("Tool Material"), + subtitle=_("Substrate the tool is made of"), + model=Gtk.StringList.new(material_labels), + ) + self._tool_material.set_selected( + TOOL_MATERIAL_NAMES.index( + tool_material_to_name(tool.tool_material) + if tool + else TOOL_MATERIAL_NAMES[0] + ) + ) + self._geom.add(self._tool_material) + + self._param_rows: dict[str, Gtk.Widget] = {} + self._param_helpers: dict[str, LengthChoiceSpinRow] = {} + self._rebuild_params(self._params) + self._category.connect( + "notify::selected", lambda *_: self._on_category_changed() + ) + + self._add_page(page, "geometry", _("Geometry"), "tool-change-symbolic") + + def _build_setup_page(self, tool: Tool | None) -> None: + page = Adw.PreferencesPage() + setup = Adw.PreferencesGroup( + title=_("Setup"), + description=_("Holder protrusion and spindle limits."), + ) + page.add(setup) + self._stickout_row, self._stickout_helper = self._add_length( + setup, + _("Stickout"), + _("Protrusion from the holder"), + 200.0, + tool.stickout if tool else None, + ) + self._max_rpm = self._add_plain( + setup, + _("Max RPM"), + _("Spindle speed cap; steps clamp to this"), + 60000.0, + tool.max_rpm if tool else None, + 0, + ) + + self._add_page(page, "setup", _("Setup"), "step-settings-symbolic") + + # --- Geometry param rows -------------------------------------------- + + def _on_category_changed(self) -> None: + self._rebuild_params(self._read_params()) + + def _rebuild_params(self, values: dict[str, float]) -> None: + for row in self._param_rows.values(): + self._geom.remove(row) + self._param_rows.clear() + self._param_helpers.clear() + + cat = CATEGORY_NAMES[self._category.get_selected()] + for spec in CATEGORY_PARAMS.get(cat, []): + value = values.get(spec.key) + row, helper = self._make_param_row(spec, value) + self._geom.add(row) + self._param_rows[spec.key] = row + if helper is not None: + self._param_helpers[spec.key] = helper + + def _make_param_row( + self, + spec: ParamSpec, + value: float | None, + ) -> tuple[Gtk.Widget, LengthChoiceSpinRow | None]: + if spec.quantity == "length": + return self._add_length( + None, spec.title, spec.subtitle, spec.upper, value + ) + return ( + self._add_plain( + None, + spec.title, + spec.subtitle, + spec.upper, + value, + spec.digits, + spec.is_int, + ), + None, + ) + + def _add_length( + self, + group: Adw.PreferencesGroup | None, + title: str, + subtitle: str, + upper: float, + value: float | None = None, + ) -> tuple[LengthChoiceSpinRow, LengthChoiceSpinRow]: + row = LengthChoiceSpinRow( + title=title, + subtitle=subtitle, + upper=upper, + ) + if value is not None: + row.set_value_in_base_units(float(value)) + if group is not None: + group.add(row) + return row, row + + def _add_plain( + self, + group: Adw.PreferencesGroup | None, + title: str, + subtitle: str, + upper: float, + value: float | None = None, + digits: int = 1, + is_int: bool = False, + ) -> SpinRow: + step = 1.0 if is_int or digits == 0 else 0.1 + row = SpinRow( + title, + subtitle, + lower=0.0, + upper=upper, + step_increment=step, + digits=digits, + numeric=True, + value=float(value) if value is not None else None, + ) + if group is not None: + group.add(row) + return row + + # --- Result ---------------------------------------------------------- + + def param_keys(self) -> list[str]: + """Return the geometry param keys shown for the current category.""" + return list(self._param_rows) + + def is_length_param(self, key: str) -> bool: + """True if ``key`` is a unit-aware (length) geometry field.""" + return key in self._param_helpers + + def _read_params(self) -> dict[str, float]: + params: dict[str, float] = {} + for key, row in self._param_rows.items(): + helper = self._param_helpers.get(key) + if helper is not None: + params[key] = helper.get_value_in_base_units() + else: + params[key] = float(cast(SpinRow, row).get_value()) + return params + + def _send_response(self, response_id: str) -> None: + self.response.send(self, response_id=response_id) + + def get_tool(self) -> Tool: + """Build a :class:`Tool` from the dialog fields.""" + params = self._read_params() + return Tool( + uid=self._tool.uid if self._tool else Tool.create_default().uid, + name=self._name.get_text().strip() or _("Unnamed Tool"), + max_rpm=float(self._max_rpm.get_value()), + label=self._label.get_text().strip(), + category=CATEGORY_BY_NAME[ + CATEGORY_NAMES[self._category.get_selected()] + ], + tool_material=TOOL_MATERIAL_BY_NAME[ + TOOL_MATERIAL_NAMES[self._tool_material.get_selected()] + ], + stickout=self._stickout_helper.get_value_in_base_units(), + coating=self._coating.get_text().strip() or None, + model=ToolModel(**params), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tool_library/frontend.py b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/frontend.py new file mode 100644 index 000000000..b281618ff --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/frontend.py @@ -0,0 +1,202 @@ +""" +Frontend for the tool_library addon. + +Registers :class:`ToolManagerPage` in the Settings dialog via the +``register_settings_pages`` hook. The page, list widget, and row mirror +the materials/recipes UI conventions +(:class:`~rayforge.ui_gtk.shared.preferences_group.PreferencesGroupWithButton` +list + ``Adw.MessageDialog`` confirmations/edit). +""" + +from collections.abc import Callable +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.icons import get_icon +from rayforge.ui_gtk.settings.registry import SettingsPageRegistry +from rayforge.ui_gtk.shared.gtk import apply_css +from rayforge.ui_gtk.shared.preferences_group import PreferencesGroupWithButton +from rayforge.ui_gtk.shared.preferences_page import TrackedPreferencesPage + +from . import get_tool_manager +from .edit_dialog import AddEditToolDialog +from .manager import ToolManager +from .tool import CATEGORY_LABELS, Tool, category_to_name + +ADDON_NAME = "tool_library" + +apply_css(""" +.maturity-warning { + background-color: alpha(@warning_color, 0.15); + padding: 10px 28px; +} +""") + + +class ToolRow(Gtk.Box): + """A single tool entry in the list.""" + + def __init__( + self, + tool: Tool, + on_edit: Callable[[Tool], None], + on_delete: Callable[[Tool], None], + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.tool = tool + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + icon = get_icon("tool-change-symbolic") + icon.set_valign(Gtk.Align.CENTER) + self.append(icon) + + labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True) + self.append(labels) + + labels.append( + Gtk.Label(label=tool.name, halign=Gtk.Align.START, xalign=0) + ) + subtitle = Gtk.Label( + label=_("{category} \u00b7 \u2300 {diam:g} mm").format( + category=CATEGORY_LABELS[category_to_name(tool.category)], + diam=tool.diameter(), + ), + halign=Gtk.Align.START, + xalign=0, + ) + subtitle.add_css_class("dim-label") + labels.append(subtitle) + + suffix = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix) + + edit_btn = Gtk.Button(child=get_icon("edit-symbolic")) + edit_btn.add_css_class("flat") + edit_btn.connect("clicked", lambda _w: on_edit(tool)) + suffix.append(edit_btn) + + del_btn = Gtk.Button(child=get_icon("delete-symbolic")) + del_btn.add_css_class("flat") + del_btn.connect("clicked", lambda _w: on_delete(tool)) + suffix.append(del_btn) + + +class ToolListWidget(PreferencesGroupWithButton): + """Editable list of tools, backed by the :class:`ToolManager`.""" + + def __init__(self, manager: ToolManager, **kwargs): + super().__init__( + button_label=_("Add Tool"), + empty_placeholder=_("No tools configured."), + **kwargs, + ) + self._mgr = manager + self._mgr.changed.connect(self._on_changed) + self._refresh() + + def create_row_widget(self, item: Tool) -> Gtk.Widget: + return ToolRow(item, self._on_edit, self._on_delete) + + def _on_changed(self, _sender: object) -> None: + self._refresh() + + def _refresh(self) -> None: + self.set_items(self._mgr.get_all()) + + def _on_add_clicked(self, button: Gtk.Button) -> None: + self._open_dialog() + + def _on_edit(self, tool: Tool) -> None: + self._open_dialog(tool) + + def _open_dialog(self, tool: Tool | None = None) -> None: + root = self.get_root() + dialog = AddEditToolDialog( + cast(Gtk.Window, root) if root else None, + tool=tool, + ) + + def on_response(d, *, response_id): + if response_id in ("add", "save"): + self._mgr.save(d.get_tool()) + d.destroy() + + dialog.response.connect(on_response, weak=False) + dialog.present() + + def _on_delete(self, tool: Tool) -> None: + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Delete '{name}'?").format(name=tool.name), + body=_( + "The tool will be permanently removed. This cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + + def on_response(d, response_id): + if response_id == "delete": + self._mgr.delete(tool.uid) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + +class ToolManagerPage(TrackedPreferencesPage): + """Settings page managing the tool library.""" + + key = "tools" + + def __init__(self): + super().__init__(title=_("Tools"), icon_name="tool-change-symbolic") + warning_group = Adw.PreferencesGroup() + banner = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + hexpand=True, + ) + banner.add_css_class("maturity-warning") + icon = get_icon("warning-symbolic") + icon.add_css_class("warning") + label = Gtk.Label( + label=_( + "The tool library is experimental and unfinished. " + "It may not offer backward compatibility." + ), + wrap=True, + xalign=0, + hexpand=True, + ) + label.add_css_class("warning-label") + banner.append(icon) + banner.append(label) + warning_group.add(banner) + self.add(warning_group) + self.add( + ToolListWidget( + get_tool_manager(), + title=_("Tool Library"), + description=_("Cutting tools for CNC machining."), + ) + ) + + +@hookimpl +def register_settings_pages( + settings_page_registry: SettingsPageRegistry, +) -> None: + """Contribute the tool-manager page to the Settings dialog.""" + settings_page_registry.register(ToolManagerPage, addon_name=ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tool_library/manager.py b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/manager.py new file mode 100644 index 000000000..b6bfad28c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/manager.py @@ -0,0 +1,88 @@ +""" +ToolManager: loads, stores, and persists :class:`Tool` objects as YAML. + +Modelled on :class:`rayforge.core.recipe_manager.RecipeManager`. One +``*.yaml`` file per tool, named ``.yaml``. Emits a ``changed`` +signal (blinker) after any mutation so dependent UI can refresh. +""" + +import logging +from pathlib import Path + +import yaml +from blinker import Signal + +from .tool import Tool + +logger = logging.getLogger(__name__) + + +class ToolManager: + """CRUD manager for the user tool library.""" + + def __init__(self, base_dir: Path): + self.base_dir = Path(base_dir) + self.base_dir.mkdir(parents=True, exist_ok=True) + self.tools: dict[str, Tool] = {} + self.changed = Signal() + self.load() + + def _file_for(self, uid: str) -> Path: + return self.base_dir / f"{uid}.yaml" + + def load(self) -> None: + """Reload every tool YAML from ``base_dir`` into memory.""" + self.tools.clear() + for path in self.base_dir.glob("*.yaml"): + try: + with open(path, "r") as f: + data = yaml.safe_load(f) + if not data: + continue + tool = Tool.from_dict(data) + self.tools[tool.uid] = tool + except (OSError, ValueError, TypeError, yaml.YAMLError) as e: + logger.error(f"Error loading tool {path.name}: {e}") + logger.info(f"Loaded {len(self.tools)} tools.") + + def _save(self, tool: Tool) -> None: + path = self._file_for(tool.uid) + try: + with open(path, "w") as f: + yaml.safe_dump(tool.to_dict(), f, sort_keys=False) + except (OSError, ValueError, TypeError, yaml.YAMLError) as e: + logger.error(f"Failed to save tool {tool.uid}: {e}") + + def save(self, tool: Tool) -> None: + """ + Add or update a tool, persist it, and emit ``changed``. + """ + is_new = tool.uid not in self.tools + self.tools[tool.uid] = tool + self._save(tool) + logger.debug( + f"{'Added' if is_new else 'Updated'} tool {tool.uid} ({tool.name})" + ) + self.changed.send(self) + + def delete(self, uid: str) -> bool: + """Remove a tool by uid, delete its file, and emit ``changed``.""" + if uid not in self.tools: + return False + del self.tools[uid] + path = self._file_for(uid) + if path.exists(): + try: + path.unlink() + except OSError as e: + logger.error(f"Failed to delete tool file {path}: {e}") + self.changed.send(self) + return True + + def get(self, uid: str) -> Tool | None: + """Return the tool with this uid, or ``None``.""" + return self.tools.get(uid) + + def get_all(self) -> list[Tool]: + """Return all tools, sorted by name for stable display.""" + return sorted(self.tools.values(), key=lambda t: t.name.lower()) diff --git a/rayforge/builtin_addons/rayforge-addon-tools/tool_library/tool.py b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/tool.py new file mode 100644 index 000000000..804a97175 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-tools/tool_library/tool.py @@ -0,0 +1,281 @@ +""" +Tool dataclass: a rayforge-side wrapper around the raygeo +:class:`~raygeo.cnc.tool.Tool` that adds the persistence/identity +fields rayforge needs (``uid``, ``name``, ``max_rpm``). + +The geometry itself (diameter, flute count, ...) lives in the raygeo +``ToolModel`` param bag; this wrapper only adds scalar bookkeeping and +YAML round-trip. +""" + +import uuid +from dataclasses import dataclass +from gettext import gettext as _ +from typing import Any + +from raygeo.cnc.tool import ( + ToolCategory, + ToolMaterial, + ToolModel, +) + +CATEGORY_NAMES = [ + "END_MILL", + "BALL_NOSE", + "BULL_NOSE", + "CHAMFER", + "DRILL", + "PROBE", + "VBIT", + "SLITTING_SAW", + "REAMER", + "TAP", + "THREAD_MILL", + "DOVETAIL", +] +TOOL_MATERIAL_NAMES = [ + "CARBIDE", + "HSS", + "HSSE", + "DIAMOND", + "CBN", + "CERAMIC", +] + +CATEGORY_BY_NAME: dict[str, ToolCategory] = { + name: getattr(ToolCategory, name) for name in CATEGORY_NAMES +} +TOOL_MATERIAL_BY_NAME: dict[str, ToolMaterial] = { + name: getattr(ToolMaterial, name) for name in TOOL_MATERIAL_NAMES +} + +CATEGORY_LABELS: dict[str, str] = { + "END_MILL": _("End Mill"), + "BALL_NOSE": _("Ball Nose"), + "BULL_NOSE": _("Bull Nose"), + "CHAMFER": _("Chamfer"), + "DRILL": _("Drill"), + "PROBE": _("Probe"), + "VBIT": _("V-Bit"), + "SLITTING_SAW": _("Slitting Saw"), + "REAMER": _("Reamer"), + "TAP": _("Tap"), + "THREAD_MILL": _("Thread Mill"), + "DOVETAIL": _("Dovetail"), +} +TOOL_MATERIAL_LABELS: dict[str, str] = { + "CARBIDE": _("Carbide"), + "HSS": _("HSS"), + "HSSE": _("HSSE"), + "DIAMOND": _("Diamond"), + "CBN": _("CBN"), + "CERAMIC": _("Ceramic"), +} + + +def category_to_name(category: ToolCategory) -> str: + """Return the canonical name of a ``ToolCategory`` member.""" + for name, member in CATEGORY_BY_NAME.items(): + if member == category: + return name + return CATEGORY_NAMES[0] + + +def tool_material_to_name(tool_material: ToolMaterial) -> str: + """Return the canonical name of a ``ToolMaterial`` member.""" + for name, member in TOOL_MATERIAL_BY_NAME.items(): + if member == tool_material: + return name + return TOOL_MATERIAL_NAMES[0] + + +@dataclass(frozen=True) +class ParamSpec: + """ + One editable geometry parameter for a tool category. + + ``quantity="length"`` fields are edited through the app's + :class:`~rayforge.ui_gtk.shared.pref_rows.length_choice_spin_row.LengthChoiceSpinRow` + (stored in base mm, shown in a per-row unit chosen via dropdown); + ``None`` fields are a plain spin (angle, count). + """ + + key: str + title: str + subtitle: str + quantity: str | None + upper: float + digits: int = 1 + is_int: bool = False + + +def _length( + key: str, + title: str, + subtitle: str, + upper: float, + digits: int = 2, +) -> ParamSpec: + return ParamSpec(key, title, subtitle, "length", upper, digits) + + +def _int( + key: str, + title: str, + subtitle: str, + upper: float, +) -> ParamSpec: + return ParamSpec(key, title, subtitle, None, upper, 0, True) + + +def _plain( + key: str, + title: str, + subtitle: str, + upper: float, + digits: int = 1, +) -> ParamSpec: + return ParamSpec(key, title, subtitle, None, upper, digits) + + +_DIAM = _length("diameter", _("Diameter"), _("Cutting diameter"), 100.0) +_FLUTES = _int( + "flute_count", _("Flute count"), _("Number of cutting flutes"), 20 +) +_CEH = _length( + "cutting_edge_height", + _("Cutting edge height"), + _("Length of the cutting flutes"), + 300.0, +) +_SHANK = _length( + "shank_diameter", _("Shank diameter"), _("Holder-side diameter"), 100.0 +) +_OVERALL = _length( + "overall_length", _("Overall length"), _("Total tool length"), 500.0 +) +_CORNER = _length( + "corner_radius", + _("Corner radius"), + _("Radius of the cutting-edge corner"), + 50.0, +) +_ANGLE = _plain("tip_angle", _("Tip angle"), _("Half-angle of the tip"), 90.0) +_PITCH = _length( + "pitch", _("Thread pitch"), _("Distance per thread turn"), 10.0 +) +_BLADE = _length( + "blade_thickness", + _("Blade thickness"), + _("Thickness of the saw blade"), + 50.0, +) +_ARBOR = _length( + "arbor_diameter", _("Arbor diameter"), _("Mounting hole diameter"), 50.0 +) + +CATEGORY_PARAMS: dict[str, list[ParamSpec]] = { + "END_MILL": [_DIAM, _FLUTES, _CEH, _SHANK, _OVERALL], + "BALL_NOSE": [_DIAM, _FLUTES, _CEH, _SHANK, _OVERALL], + "BULL_NOSE": [_DIAM, _CORNER, _FLUTES, _CEH, _SHANK, _OVERALL], + "CHAMFER": [_DIAM, _ANGLE, _FLUTES, _CEH, _SHANK, _OVERALL], + "DRILL": [_DIAM, _FLUTES, _OVERALL], + "PROBE": [_DIAM, _OVERALL], + "VBIT": [_DIAM, _ANGLE, _FLUTES, _OVERALL], + "SLITTING_SAW": [_DIAM, _BLADE, _ARBOR, _OVERALL], + "REAMER": [_DIAM, _FLUTES, _OVERALL], + "TAP": [_DIAM, _PITCH, _OVERALL], + "THREAD_MILL": [_DIAM, _PITCH, _FLUTES, _OVERALL], + "DOVETAIL": [_DIAM, _ANGLE, _FLUTES, _OVERALL], +} + + +@dataclass +class Tool: + """ + A persisted cutting tool. + + Attributes: + uid: Stable unique identifier (filename stem). + name: Human-readable label shown in pickers. + max_rpm: Spindle speed cap; CNC steps clamp ``spindle_rpm`` to it. + label: Short label forwarded to the raygeo ``Tool``. + category: :class:`ToolCategory` classification. + tool_material: :class:`ToolMaterial` substrate. + stickout: Tool stickout (mm) set at the holder. + coating: Optional coating name, or ``None``. + model: raygeo :class:`ToolModel` param bag (geometry). + """ + + uid: str + name: str + max_rpm: float + label: str + category: ToolCategory + tool_material: ToolMaterial + stickout: float + coating: str | None + model: ToolModel + + def diameter(self) -> float: + """Cutting diameter (mm), delegated to the model.""" + return self.model.diameter() + + def to_dict(self) -> dict[str, Any]: + """Serialize to a dict suitable for YAML.""" + return { + "uid": self.uid, + "name": self.name, + "max_rpm": self.max_rpm, + "label": self.label, + "category": category_to_name(self.category), + "tool_material": tool_material_to_name(self.tool_material), + "stickout": self.stickout, + "coating": self.coating, + "model": dict(self.model.get_parameters()), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Tool": + """Deserialize from a dict (YAML-loaded).""" + category = CATEGORY_BY_NAME.get( + data.get("category", CATEGORY_NAMES[0]), + CATEGORY_BY_NAME[CATEGORY_NAMES[0]], + ) + tool_material = TOOL_MATERIAL_BY_NAME.get( + data.get("tool_material", TOOL_MATERIAL_NAMES[0]), + TOOL_MATERIAL_BY_NAME[TOOL_MATERIAL_NAMES[0]], + ) + model = ToolModel(**(data.get("model") or {})) + return cls( + uid=data.get("uid") or str(uuid.uuid4()), + name=data.get("name", _("Unnamed Tool")), + max_rpm=float(data.get("max_rpm", 0.0)), + label=data.get("label", ""), + category=category, + tool_material=tool_material, + stickout=float(data.get("stickout", 0.0)), + coating=data.get("coating"), + model=model, + ) + + @classmethod + def create_default(cls, name: str = "") -> "Tool": + """Create a sensible default 6 mm flat end mill.""" + return cls( + uid=str(uuid.uuid4()), + name=name or _("6 mm End Mill"), + max_rpm=24000.0, + label=_("6mm EM"), + category=ToolCategory.END_MILL, + tool_material=ToolMaterial.CARBIDE, + stickout=18.0, + coating=None, + model=ToolModel( + diameter=6.0, + shank_diameter=6.0, + cutting_edge_height=15.0, + flute_count=3.0, + overall_length=50.0, + ), + ) diff --git a/rayforge/util/__init__.py b/rayforge/camera/__init__.py similarity index 100% rename from rayforge/util/__init__.py rename to rayforge/camera/__init__.py diff --git a/rayforge/camera/calibration/__init__.py b/rayforge/camera/calibration/__init__.py new file mode 100644 index 000000000..69ee9827b --- /dev/null +++ b/rayforge/camera/calibration/__init__.py @@ -0,0 +1,10 @@ +from .calibrator import CameraCalibrator +from .charuco import CharucoBoard, CharucoConfig +from .result import CalibrationResult + +__all__ = [ + "CalibrationResult", + "CameraCalibrator", + "CharucoBoard", + "CharucoConfig", +] diff --git a/rayforge/camera/calibration/calibrator.py b/rayforge/camera/calibration/calibrator.py new file mode 100644 index 000000000..99182ff4b --- /dev/null +++ b/rayforge/camera/calibration/calibrator.py @@ -0,0 +1,245 @@ +import logging +from typing import Any, cast + +import cv2 +import numpy as np +from blinker import Signal + +from .charuco import CharucoBoard +from .result import CalibrationResult + +logger = logging.getLogger(__name__) + + +class CameraCalibrator: + MIN_FRAMES = 3 + MIN_CORNERS_PER_FRAME = 4 + MIN_UNIQUE_CORNERS = 10 + + def __init__(self, board: CharucoBoard): + self.board = board + self._all_corners: list[list[tuple[float, float]]] = [] + self._all_ids: list[list[int]] = [] + self._frame_count = 0 + self._image_size: tuple[int, int] | None = None + + self.frame_added = Signal() + self.frame_rejected = Signal() + + @property + def frame_count(self) -> int: + return self._frame_count + + @property + def total_corners(self) -> int: + return sum(len(c) for c in self._all_corners) + + @property + def unique_corners(self) -> int: + return len({c for ids in self._all_ids for c in ids}) + + @property + def corners_per_frame(self) -> list[int]: + return [len(c) for c in self._all_corners] + + def clear(self): + self._all_corners.clear() + self._all_ids.clear() + self._frame_count = 0 + self._image_size = None + + def detect_and_add_frame( + self, image: np.ndarray + ) -> tuple[bool, int, list[tuple[float, float]] | None]: + detection = self.board.detect(image) + + if detection is None: + self.frame_rejected.send(self, reason="no_detection") + return False, 0, None + + corners, ids = detection + + if len(corners) < self.MIN_CORNERS_PER_FRAME: + self.frame_rejected.send( + self, reason="insufficient_corners", count=len(corners) + ) + return False, len(corners), corners + + if self._image_size is None: + h, w = image.shape[:2] + self._image_size = (w, h) + + self._all_corners.append(corners) + self._all_ids.append(ids) + self._frame_count += 1 + + self.frame_added.send( + self, count=len(corners), total=self._frame_count + ) + return True, len(corners), corners + + def can_calibrate(self) -> bool: + if self._frame_count < self.MIN_FRAMES: + return False + + total_unique_corners = len( + {corner for ids in self._all_ids for corner in ids} + ) + + return total_unique_corners >= self.MIN_UNIQUE_CORNERS + + def get_coverage(self) -> tuple[float, float, float, float]: + """ + Get the spatial coverage of detected corners. + + Returns normalized coverage (0-1) for each quadrant: + (top-left, top-right, bottom-left, bottom-right) + """ + if self._image_size is None or not self._all_corners: + return (0.0, 0.0, 0.0, 0.0) + + w, h = self._image_size + mid_x, mid_y = w / 2, h / 2 + + quadrants = [[0, 0], [0, 0], [0, 0], [0, 0]] + corner_count = self.board.chessboard_corners + + for corners in self._all_corners: + for x, y in corners: + q = (1 if x > mid_x else 0) + (2 if y > mid_y else 0) + quadrants[q][0] += 1 + + for q in quadrants: + q[1] = len([c for c in self._all_corners for _ in c]) + + coverage = tuple( + min(1.0, q[0] / max(1, corner_count)) for q in quadrants + ) + return (coverage[0], coverage[1], coverage[2], coverage[3]) + + def get_coverage_quality(self) -> tuple[str, str]: + """ + Assess the quality of spatial coverage. + + Returns (level, message) where level is 'good', 'warning', or 'poor'. + """ + coverage = self.get_coverage() + min_coverage = min(coverage) + avg_coverage = sum(coverage) / 4 + + if min_coverage < 0.2: + return ( + "poor", + "Poor coverage: move card to all corners of the view", + ) + elif min_coverage < 0.4 or avg_coverage < 0.5: + return ( + "warning", + "Limited coverage: ensure card reaches image edges", + ) + else: + return ("good", "Good coverage") + + def calibration_status(self) -> tuple[bool, str]: + if self._frame_count < self.MIN_FRAMES: + return ( + False, + ( + f"Need at least {self.MIN_FRAMES} frames " + f"(have {self._frame_count})" + ), + ) + + total_unique_corners = len( + {corner for ids in self._all_ids for corner in ids} + ) + + if total_unique_corners < self.MIN_UNIQUE_CORNERS: + return ( + False, + ( + f"Need {self.MIN_UNIQUE_CORNERS}+ unique corners " + f"(have {total_unique_corners}). " + f"Move card to different positions." + ), + ) + + coverage_level, coverage_msg = self.get_coverage_quality() + if coverage_level == "poor": + return (True, coverage_msg) + elif coverage_level == "warning": + return (True, f"Ready ({coverage_msg})") + + return True, "Ready to calibrate" + + def calibrate( + self, image_size: tuple[int, int] + ) -> CalibrationResult | None: + can_calib, reason = self.calibration_status() + if not can_calib: + logger.error(f"Calibration not ready: {reason}") + return None + + board_obj = self.board.board + if board_obj is None: + logger.error("Board not initialized") + return None + + all_object_points = board_obj.getChessboardCorners() + + object_points_list = [] + image_points_list = [] + + for corners, ids in zip(self._all_corners, self._all_ids): + corners_array = np.array(corners, dtype=np.float32) + ids_array = np.array(ids, dtype=np.int32) + + obj_pts = all_object_points[cast(Any, ids_array)] + object_points_list.append(obj_pts) + image_points_list.append(corners_array) + + try: + rms, camera_matrix, dist_coeffs, rvecs, tvecs = ( + cv2.calibrateCamera( + objectPoints=object_points_list, + imagePoints=image_points_list, + imageSize=image_size, + cameraMatrix=cast(Any, None), + distCoeffs=cast(Any, None), + ) + ) + except cv2.error as e: + logger.error(f"Calibration failed: {e}") + return None + + reprojection_errors = [] + for i, (corners, rvec, tvec) in enumerate( + zip(image_points_list, rvecs, tvecs) + ): + if corners is None or len(corners) == 0: + continue + + obj_pts = object_points_list[i] + + projected, _ = cv2.projectPoints( + obj_pts, rvec, tvec, camera_matrix, dist_coeffs + ) + + projected = projected.reshape(-1, 2) + error = cv2.norm(corners, projected, cv2.NORM_L2) / len(corners) + reprojection_errors.append(float(error)) + + result = CalibrationResult( + camera_matrix=camera_matrix, + distortion_coeffs=dist_coeffs.flatten(), + rms_error=rms, + image_size=image_size, + num_frames_used=self._frame_count, + reprojection_errors=reprojection_errors, + ) + + logger.info( + f"Calibration complete: RMS={rms:.4f}, frames={self._frame_count}" + ) + + return result diff --git a/rayforge/camera/calibration/charuco.py b/rayforge/camera/calibration/charuco.py new file mode 100644 index 000000000..6f7069178 --- /dev/null +++ b/rayforge/camera/calibration/charuco.py @@ -0,0 +1,218 @@ +import logging +from dataclasses import dataclass + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + + +@dataclass +class CharucoConfig: + squares_x: int = 5 + squares_y: int = 7 + square_length_mm: float = 20.0 + marker_length_mm: float = 15.0 + dictionary_id: int = cv2.aruco.DICT_6X6_250 + + def to_dict(self) -> dict: + return { + "squares_x": self.squares_x, + "squares_y": self.squares_y, + "square_length_mm": self.square_length_mm, + "marker_length_mm": self.marker_length_mm, + "dictionary_id": self.dictionary_id, + } + + @classmethod + def from_dict(cls, data: dict) -> "CharucoConfig": + return cls( + squares_x=data.get("squares_x", 5), + squares_y=data.get("squares_y", 7), + square_length_mm=data.get("square_length_mm", 20.0), + marker_length_mm=data.get("marker_length_mm", 15.0), + dictionary_id=data.get("dictionary_id", cv2.aruco.DICT_6X6_250), + ) + + +class CharucoBoard: + MIN_SQUARES_X = 4 + MIN_SQUARES_Y = 5 + MIN_MARKER_PIXELS = 10 + + def __init__(self, config: CharucoConfig): + self.config = config + self._board = None + self._detector = None + self._create_board() + + def _create_board(self): + dictionary = cv2.aruco.getPredefinedDictionary( + self.config.dictionary_id + ) + self._board = cv2.aruco.CharucoBoard( + size=(self.config.squares_x, self.config.squares_y), + squareLength=self.config.square_length_mm, + markerLength=self.config.marker_length_mm, + dictionary=dictionary, + ) + charuco_params = cv2.aruco.CharucoParameters() + detector_params = cv2.aruco.DetectorParameters() + detector_params.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX + detector_params.cornerRefinementWinSize = 5 + detector_params.cornerRefinementMaxIterations = 30 + detector_params.cornerRefinementMinAccuracy = 0.1 + refine_params = cv2.aruco.RefineParameters() + self._detector = cv2.aruco.CharucoDetector( + self._board, charuco_params, detector_params, refine_params + ) + + @property + def board(self): + return self._board + + @property + def chessboard_corners(self) -> int: + return (self.config.squares_x - 1) * (self.config.squares_y - 1) + + @property + def card_size_mm(self) -> tuple[float, float]: + return ( + self.config.squares_x * self.config.square_length_mm, + self.config.squares_y * self.config.square_length_mm, + ) + + @classmethod + def recommend_config( + cls, + card_width_mm: float, + card_height_mm: float, + camera_resolution: tuple[int, int] = (640, 480), + surface_size_mm: tuple[float, float] | None = None, + ) -> CharucoConfig: + min_marker_pixels = cls.MIN_MARKER_PIXELS + min_dim = min(camera_resolution) + + if surface_size_mm: + surface_min = min(surface_size_mm) + mm_per_pixel = surface_min / min_dim + else: + mm_per_pixel = card_width_mm / min_dim + + target_square_size = 20.0 + target_squares_x = max( + cls.MIN_SQUARES_X, int(card_width_mm / target_square_size) + ) + target_squares_y = max( + cls.MIN_SQUARES_Y, int(card_height_mm / target_square_size) + ) + + square_length = min( + card_width_mm / target_squares_x, + card_height_mm / target_squares_y, + ) + + estimated_marker_pixels = (square_length * 0.75) / mm_per_pixel + if estimated_marker_pixels < min_marker_pixels: + scale = min_marker_pixels / estimated_marker_pixels + square_length *= scale + + squares_x = max(cls.MIN_SQUARES_X, int(card_width_mm / square_length)) + squares_y = max(cls.MIN_SQUARES_Y, int(card_height_mm / square_length)) + + squares_x = min(squares_x, 12) + squares_y = min(squares_y, 14) + + marker_length = square_length * 0.75 + + return CharucoConfig( + squares_x=squares_x, + squares_y=squares_y, + square_length_mm=round(square_length, 1), + marker_length_mm=round(marker_length, 1), + ) + + def generate_image( + self, + output_size: tuple[int, int] | None = None, + margin_px: int = 10, + border_bits: int = 1, + ) -> np.ndarray: + if output_size is None: + px_per_mm = 10 + w = int( + self.config.squares_x + * self.config.square_length_mm + * px_per_mm + ) + h = int( + self.config.squares_y + * self.config.square_length_mm + * px_per_mm + ) + output_size = (w + 2 * margin_px, h + 2 * margin_px) + + assert self._board is not None + image = self._board.generateImage(output_size, marginSize=margin_px) + return image + + def detect( + self, image: np.ndarray + ) -> tuple[list[tuple[float, float]], list[int]] | None: + if len(image.shape) == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image + + assert self._detector is not None + try: + charuco_corners, charuco_ids, _, _ = self._detector.detectBoard( + gray + ) + except cv2.error as e: + logger.debug(f"ChArUco detection error: {e}") + return None + + if charuco_corners is None or charuco_ids is None: + return None + + try: + corners_array = np.asarray( + charuco_corners, dtype=np.float32 + ).reshape(-1, 2) + ids_array = np.asarray(charuco_ids).reshape(-1) + except (TypeError, ValueError) as error: + logger.debug("Invalid ChArUco detection result: %s", error) + return None + + if len(corners_array) < 4 or len(corners_array) != len(ids_array): + return None + + try: + corners = [(float(x), float(y)) for x, y in corners_array] + ids = [int(value) for value in ids_array] + except (TypeError, ValueError, OverflowError) as error: + logger.debug("Invalid ChArUco detection result: %s", error) + return None + + return corners, ids + + def draw_detection( + self, + image: np.ndarray, + corners: list[tuple[float, float]], + ids: list[int], + color: tuple[int, int, int] = (0, 255, 0), + ) -> np.ndarray: + result = image.copy() + + charuco_corners = np.array( + [[[c[0], c[1]]] for c in corners], dtype=np.float32 + ) + charuco_ids = np.array([[i] for i in ids], dtype=np.int32) + + cv2.aruco.drawDetectedCornersCharuco( + result, charuco_corners, charuco_ids, color + ) + + return result diff --git a/rayforge/camera/calibration/result.py b/rayforge/camera/calibration/result.py new file mode 100644 index 000000000..b55bd55e8 --- /dev/null +++ b/rayforge/camera/calibration/result.py @@ -0,0 +1,138 @@ +import logging +from dataclasses import dataclass, field +from datetime import datetime + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + + +@dataclass +class CalibrationResult: + camera_matrix: np.ndarray + distortion_coeffs: np.ndarray + rms_error: float + image_size: tuple[int, int] + num_frames_used: int + reprojection_errors: list[float] = field(default_factory=list) + calibration_date: datetime = field(default_factory=datetime.now) + + def __post_init__(self): + self.camera_matrix = np.array(self.camera_matrix, dtype=np.float64) + self.distortion_coeffs = np.array( + self.distortion_coeffs, dtype=np.float64 + ).flatten() + + @property + def fx(self) -> float: + return float(self.camera_matrix[0, 0]) + + @property + def fy(self) -> float: + return float(self.camera_matrix[1, 1]) + + @property + def cx(self) -> float: + return float(self.camera_matrix[0, 2]) + + @property + def cy(self) -> float: + return float(self.camera_matrix[1, 2]) + + @property + def k1(self) -> float: + return float(self.distortion_coeffs[0]) + + @property + def k2(self) -> float: + return float(self.distortion_coeffs[1]) + + @property + def p1(self) -> float: + return ( + float(self.distortion_coeffs[2]) + if len(self.distortion_coeffs) > 2 + else 0.0 + ) + + @property + def p2(self) -> float: + return ( + float(self.distortion_coeffs[3]) + if len(self.distortion_coeffs) > 3 + else 0.0 + ) + + @property + def k3(self) -> float: + return ( + float(self.distortion_coeffs[4]) + if len(self.distortion_coeffs) > 4 + else 0.0 + ) + + @property + def quality_rating(self) -> str: + if self.rms_error < 0.5: + return "excellent" + elif self.rms_error < 1.0: + return "good" + elif self.rms_error < 2.0: + return "acceptable" + else: + return "poor" + + def to_dict(self) -> dict: + return { + "camera_matrix": self.camera_matrix.tolist(), + "distortion_coeffs": self.distortion_coeffs.tolist(), + "rms_error": self.rms_error, + "image_size": list(self.image_size), + "num_frames_used": self.num_frames_used, + "reprojection_errors": self.reprojection_errors, + "calibration_date": self.calibration_date.isoformat(), + } + + @classmethod + def from_dict(cls, data: dict) -> "CalibrationResult": + return cls( + camera_matrix=np.array(data["camera_matrix"], dtype=np.float64), + distortion_coeffs=np.array( + data["distortion_coeffs"], dtype=np.float64 + ), + rms_error=data["rms_error"], + image_size=tuple(data["image_size"]), + num_frames_used=data["num_frames_used"], + reprojection_errors=data.get("reprojection_errors", []), + calibration_date=datetime.fromisoformat(data["calibration_date"]), + ) + + def get_undistort_maps( + self, image_size: tuple[int, int] | None = None + ) -> tuple[np.ndarray, np.ndarray] | None: + size = image_size or self.image_size + if size[0] <= 0 or size[1] <= 0: + return None + + try: + new_cam_mtx, _ = cv2.getOptimalNewCameraMatrix( + self.camera_matrix, + self.distortion_coeffs, + size, + 1, + size, + ) + R = np.eye(3, dtype=np.float64) + map1, map2 = cv2.initUndistortRectifyMap( + self.camera_matrix, + self.distortion_coeffs, + R, + new_cam_mtx, + size, + cv2.CV_16SC2, + ) + return map1, map2 + except cv2.error as e: + logger.error(f"Failed to compute undistort maps: {e}") + return None diff --git a/rayforge/camera/controller.py b/rayforge/camera/controller.py new file mode 100644 index 000000000..d1f9fa074 --- /dev/null +++ b/rayforge/camera/controller.py @@ -0,0 +1,808 @@ +import logging +import multiprocessing as mp +import sys +import threading +import time +from typing import TYPE_CHECKING, Optional + +import cv2 +import numpy as np +from blinker import Signal + +from ..image.util.srgb import resize_linear_nd +from ..shared.util.glib import idle_add +from .models.camera import Camera, Pos + +if TYPE_CHECKING: + from gi.repository import GdkPixbuf + +logger = logging.getLogger(__name__) + +# A comprehensive list of standard resolutions to populate the UI dropdown. +COMMON_RESOLUTIONS = [ + (320, 240), + (640, 480), + (800, 600), + (1024, 768), + (1280, 720), + (1280, 960), + (1600, 1200), + (1920, 1080), + (2048, 1536), + (2560, 1440), + (2592, 1944), + (3264, 2448), + (3840, 2160), + (4096, 2160), + (5120, 3840), + (6144, 3456), + (7680, 4320), +] + + +def _get_linux_scan_targets() -> list[str]: + """Get device identifiers to scan on Linux. + + Prefers persistent /dev/v4l/by-id/ paths. Falls back to + numeric indices if by-id is not available. + """ + from .v4l import get_sorted_by_id_paths + + by_id_paths = get_sorted_by_id_paths() + if by_id_paths: + return by_id_paths + return [str(i) for i in range(10)] + + +def _probe_camera_device(args): + """Probe a single camera device. Runs in subprocess.""" + device_id, backend = args + try: + cap = cv2.VideoCapture(device_id, backend) + if cap.isOpened(): + cap.release() + return device_id + if cap: + cap.release() + except cv2.error: + pass + return None + + +def _scan_cameras_in_subprocess() -> list[str]: + """Scan for cameras in a separate process to isolate crashes.""" + if sys.platform.startswith("linux"): + backends = [cv2.CAP_V4L2, cv2.CAP_ANY] + elif sys.platform == "win32": + backends = [cv2.CAP_DSHOW, cv2.CAP_MSMF, cv2.CAP_ANY] + else: + backends = [cv2.CAP_ANY] + + if sys.platform.startswith("linux"): + targets = _get_linux_scan_targets() + else: + targets = [str(i) for i in range(10)] + + devices = [] + work = [(t, b) for t in targets for b in backends] + + try: + ctx = mp.get_context("spawn") + with ctx.Pool(processes=1) as pool: + async_result = pool.map_async(_probe_camera_device, work) + results = async_result.get(timeout=30) + for r in results: + if r is not None and str(r) not in devices: + devices.append(str(r)) + except (mp.ProcessError, mp.TimeoutError, OSError) as e: + logger.warning(f"Subprocess camera scan failed: {e}") + return _scan_cameras_fallback() + + return devices + + +def _scan_cameras_fallback() -> list[str]: + """Fallback camera scan if subprocess fails.""" + devices = [] + if sys.platform.startswith("linux"): + backends = [(cv2.CAP_V4L2, "V4L2"), (cv2.CAP_ANY, "default")] + elif sys.platform == "win32": + backends = [(cv2.CAP_DSHOW, "DirectShow"), (cv2.CAP_ANY, "default")] + else: + backends = [(cv2.CAP_ANY, "default")] + + if sys.platform.startswith("linux"): + targets = _get_linux_scan_targets() + else: + targets = [str(i) for i in range(10)] + + for target in targets: + for backend, name in backends: + try: + cap = cv2.VideoCapture(target, backend) + if cap.isOpened(): + devices.append(str(target)) + cap.release() + break + if cap: + cap.release() + except (cv2.error, OSError) as e: + logger.debug(f"Error probing camera {target}: {e}") + + return devices + + +def get_backends_for_platform(): + """Return list of (backend_constant, name) tuples for current platform.""" + if sys.platform.startswith("linux"): + return [ + (cv2.CAP_V4L2, "V4L2"), + (cv2.CAP_ANY, "default"), + ] + if sys.platform == "win32": + return [ + (cv2.CAP_DSHOW, "DirectShow"), + (cv2.CAP_MSMF, "MediaFoundation"), + (cv2.CAP_ANY, "default"), + ] + return [(cv2.CAP_ANY, "default")] + + +def try_open_camera(device_id: int, backend: int, backend_name: str): + """Try to open camera with specific backend. Returns cap or None.""" + logger.debug(f"Opening camera {device_id} with {backend_name} backend") + cap = cv2.VideoCapture(device_id, backend) + if cap.isOpened(): + logger.info(f"Camera {device_id} opened with {backend_name} backend") + return cap + if cap: + cap.release() + return None + + +class VideoCaptureDevice: + """Context manager for safely opening and releasing camera devices.""" + + MAX_OPEN_RETRIES = 3 + RETRY_DELAY = 0.5 + + def __init__(self, device_id): + self.device_id = device_id + self.cap = None + self._backend_used = None + + def __enter__(self): + device_id_int = self._parse_device_id() + logger.debug(f"Opening camera {device_id_int} on {sys.platform}") + + backends = get_backends_for_platform() + last_error = None + + for backend, name in backends: + cap = self._try_backend(device_id_int, backend, name) + if cap: + return cap + last_error = self._retry_backend( + device_id_int, backend, name, last_error + ) + + self._raise_open_error(backends, last_error) + + def _parse_device_id(self): + if isinstance(self.device_id, str) and self.device_id.isdigit(): + return int(self.device_id) + return self.device_id + + def _try_backend(self, device_id_int, backend, name): + for attempt in range(self.MAX_OPEN_RETRIES): + try: + cap = try_open_camera(device_id_int, backend, name) + if cap: + self._backend_used = name + self.cap = cap + return cap + except cv2.error as e: + logger.warning( + f"OpenCV error camera {device_id_int} {name} " + f"(attempt {attempt + 1}): {e}" + ) + except OSError as e: + logger.warning( + f"Error camera {device_id_int} {name} " + f"(attempt {attempt + 1}): {e}" + ) + + if attempt < self.MAX_OPEN_RETRIES - 1: + time.sleep(self.RETRY_DELAY) + return None + + def _retry_backend(self, device_id_int, backend, name, last_error): + return last_error + + def _raise_open_error(self, backends, last_error): + names = [b[1] for b in backends] + msg = ( + f"Cannot open camera {self.device_id}. " + f"Tried: {names}. Last error: {last_error}" + ) + logger.error(msg) + raise OSError(msg) + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.cap is None: + return + try: + if self.cap.isOpened(): + logger.debug( + f"Releasing camera {self.device_id} " + f"(backend: {self._backend_used})" + ) + self.cap.release() + except (cv2.error, OSError) as e: + logger.warning(f"Error releasing camera {self.device_id}: {e}") + finally: + self.cap = None + + +class CameraController: + """Manages camera capture and provides image data.""" + + MAX_CONSECUTIVE_FAILURES = 10 + FRAME_READ_TIMEOUT = 1 / 30 + RECONNECT_DELAY = 2.0 + + def __init__(self, config: Camera): + self.config = config + self._image_data: np.ndarray | None = None + self._raw_image_data: np.ndarray | None = None + # For Temporal Smoothing + self._accumulator: np.ndarray | None = None + self._active_subscribers: int = 0 + self._capture_thread: threading.Thread | None = None + self._running: bool = False + self._settings_dirty: bool = True # Flag to re-apply settings + self._consecutive_failures: int = 0 + + # We no longer probe hardware directly because V4L2 and DirectShow + # drivers often crash or drop buffers when aggressively queried. + self._available_resolutions: list[tuple[int, int]] = COMMON_RESOLUTIONS + self._resolutions_probed: bool = True + + # Signals + self.image_captured = Signal() + self.resolutions_probed = Signal() + + self.config.changed.connect(self._on_config_changed) + self.config.settings_changed.connect(self._on_config_changed) + + def _on_config_changed(self, sender): + """Reacts to changes in the data model.""" + self._settings_dirty = True + self._accumulator = None # Reset smoothing if settings change + if self.config.enabled and self._active_subscribers > 0: + self._start_capture_stream() + elif not self.config.enabled: + # Also stop if it's disabled, regardless of subscribers + self._stop_capture_stream() + + @staticmethod + def list_available_devices() -> list[str]: + """ + Lists available camera device IDs. + Returns a list of strings, where each string is a device ID. + On Linux, prefers persistent /dev/v4l/by-id/ paths. + """ + logger.debug("Scanning for camera devices...") + devices = [] + backends = get_backends_for_platform() + + if sys.platform.startswith("linux"): + targets = _get_linux_scan_targets() + else: + targets = [str(i) for i in range(10)] + + for target in targets: + for backend, name in backends: + try: + cap = cv2.VideoCapture(target, backend) + if cap.isOpened(): + devices.append(str(target)) + cap.release() + logger.debug(f"Found camera {target} via {name}") + break + except cv2.error as e: + logger.debug(f"OpenCV error camera {target} {name}: {e}") + except OSError as e: + logger.debug(f"Error camera {target}: {e}") + + logger.info(f"Available cameras: {devices}") + return devices + + @property + def image_data(self) -> np.ndarray | None: + return self._image_data + + @property + def raw_image_data(self) -> np.ndarray | None: + return self._raw_image_data + + @property + def pixbuf(self) -> Optional["GdkPixbuf.Pixbuf"]: + # Import the UI library ONLY when this method is actually called. + from gi.repository import GdkPixbuf, GLib + + if self._image_data is None: + return None + + height, width, channels = self._image_data.shape + if channels == 3: + # OpenCV uses BGR, GdkPixbuf expects RGB + np_array = cv2.cvtColor(self._image_data, cv2.COLOR_BGR2RGB) + has_alpha = False + elif channels == 4: + np_array = self._image_data + has_alpha = True + else: + return None + + # Ensure the array is contiguous + np_array = np.ascontiguousarray(np_array) + + # Create GBytes from the numpy array + pixels = GLib.Bytes.new(np_array.tobytes()) + + pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( + pixels, + GdkPixbuf.Colorspace.RGB, + has_alpha, + 8, # bits per sample + width, + height, + width * channels, # rowstride + ) + return pixbuf + + @property + def resolution(self) -> tuple[int, int]: + if self._image_data is None: + return 640, 480 + height, width, _ = self._image_data.shape + return width, height + + @property + def available_resolutions(self) -> list[tuple[int, int]]: + return self._available_resolutions + + @property + def aspect(self) -> float: + return self.resolution[1] / self.resolution[0] + + def subscribe(self): + """ + Registers a subscriber to the camera's image stream. + + The stream will start if this is the first subscriber and the camera + is enabled. + """ + self._active_subscribers += 1 + logger.debug( + f"Camera {self.config.name} subscribed " + f"(count: {self._active_subscribers})" + ) + if self._active_subscribers > 0 and self.config.enabled: + self._start_capture_stream() + + def unsubscribe(self): + """ + Unregisters a subscriber. + + The stream will stop if this was the last active subscriber. + """ + if self._active_subscribers > 0: + self._active_subscribers -= 1 + logger.debug( + f"Camera {self.config.name} unsubscribed " + f"(count: {self._active_subscribers})" + ) + if self._active_subscribers == 0: + self._stop_capture_stream() + + def _compute_homography(self, image_height: int) -> np.ndarray: + """ + Compute the homography matrix from corresponding points. + + Args: + image_height: The height of the image in pixels. + + Returns: + 3x3 homography matrix mapping world to image coordinates + """ + if self.config.image_to_world is None: + raise ValueError("Corresponding points are not set") + + image_points_raw, world_points = self.config.image_to_world + + # Invert y-coordinates of image_points to align with world coordinates + # (y-up) + image_points_y_up = [ + (p[0], image_height - p[1]) for p in image_points_raw + ] + + # Compute homography (world to image_y_up) + H, _ = cv2.findHomography( + np.array(world_points, dtype=np.float32), + np.array(image_points_y_up, dtype=np.float32), + ) + return H + + def get_work_surface_image( + self, + output_size: tuple[int, int], + physical_area: tuple[Pos, Pos], + ) -> np.ndarray | None: + """ + Get an image aligned to world coordinates. + + For cameras with perspective calibration (image_to_world), this uses + homography transformation. For cameras without calibration, this + applies a simple resize to match the output size. + + Both the canvas and stock detection addon should use this method + to ensure consistent image transformation. + + The returned image has pixel coordinates that correspond to world + coordinates via: + world_x = pixel_x * (physical_width / output_width) + x_min + world_y = pixel_y * (physical_height / output_height) + y_min + + Note: Y=0 in the output image corresponds to y_min in world coords, + and Y increases downward (image space) while world Y increases upward. + + Args: + output_size: Desired output image size (width, height) in pixels + physical_area: Physical area ((x_min, y_min), (x_max, y_max)) + in real-world coordinates (mm) + + Returns: + Aligned image as a NumPy array in BGR format, or None on failure + """ + if self._image_data is None: + logger.warning("No image data available.") + return None + + if self.config.image_to_world is not None: + return self._transform_with_homography( + self._image_data, output_size, physical_area + ) + + out_width, out_height = output_size + try: + return resize_linear_nd(self._image_data, (out_width, out_height)) + except cv2.error as e: + logger.error(f"Failed to resize image: {e}") + return None + + def _transform_with_homography( + self, + image: np.ndarray, + output_size: tuple[int, int], + physical_area: tuple[Pos, Pos], + ) -> np.ndarray | None: + """ + Transform an image using homography to world coordinates. + + Args: + image: Source image to transform + output_size: Desired output image size (width, height) in pixels + physical_area: Physical area ((x_min, y_min), (x_max, y_max)) + + Returns: + Transformed image, or None on failure + """ + if self.config.image_to_world is None: + logger.error("Cannot transform: no calibration points set") + return None + + try: + H = self._compute_homography(image.shape[0]) + except ValueError as e: + logger.error(f"Cannot compute homography: {e}") + return None + + # Define transformation from output pixels to world coordinates + (x_min, y_min), (x_max, y_max) = physical_area + width_px, height_px = output_size + + # Calculate the actual physical width and height of the area being + # viewed + physical_width = x_max - x_min + physical_height = y_max - y_min + + # Calculate the scaling factors from output pixels to world coordinates + scale_x = physical_width / width_px + scale_y = -physical_height / height_px + + offset_x = x_min + offset_y = y_max + T = np.array( + [ + [scale_x, 0, offset_x], + [0, scale_y, offset_y], + [0, 0, 1], + ], + dtype=np.float32, + ) + + # Overall transformation: output pixels -> world -> image + M = H @ T + + try: + aligned_image = cv2.warpPerspective( + image, np.linalg.inv(M), output_size + ) + return aligned_image + except cv2.error as e: + logger.error(f"Failed to apply perspective warp: {e}") + return None + + def _apply_settings(self, cap: cv2.VideoCapture): + """Applies the current settings to the VideoCapture object.""" + try: + if self.config.resolution is not None: + w, h = self.config.resolution + # Applying it once here is perfectly safe and natively clamped + # by drivers + cap.set(cv2.CAP_PROP_FRAME_WIDTH, w) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h) + actual_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + actual_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + if actual_w != w or actual_h != h: + logger.debug( + f"Camera driver accepted nearest hardware resolution: " + f"requested {w}x{h}, got {actual_w}x{actual_h}" + ) + + if self.config.prefer_yuyv: + yuyv = cv2.VideoWriter_fourcc(*"YUYV") # type: ignore + if not cap.set(cv2.CAP_PROP_FOURCC, yuyv): + logger.info( + "YUYV not accepted by camera, leaving default format" + ) + else: + try: + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + except cv2.error: + pass + + if self.config.white_balance is None: + cap.set(cv2.CAP_PROP_AUTO_WB, 1) # Enable auto white balance + else: + cap.set(cv2.CAP_PROP_AUTO_WB, 0) # Disable auto white balance + cap.set(cv2.CAP_PROP_WB_TEMPERATURE, self.config.white_balance) + cap.set(cv2.CAP_PROP_CONTRAST, self.config.contrast) + cap.set(cv2.CAP_PROP_BRIGHTNESS, self.config.brightness) + + self._settings_dirty = False + logger.debug("Applied camera hardware settings.") + except (cv2.error, OSError, ValueError) as e: + # We log as a warning because the stream may still work + logger.warning(f"Could not apply one or more camera settings: {e}") + + def _get_effective_calibration(self, h, w): + if self.config.has_calibration: + calib_size = self.config.calibration_image_size + cam_mat = self.config.get_camera_matrix() + dist = self.config.get_distortion_coeffs() + + if calib_size is not None and cam_mat is not None: + calib_w, calib_h = calib_size + if calib_w != w or calib_h != h: + scale_x = w / calib_w + scale_y = h / calib_h + cam_mat = cam_mat.copy() + cam_mat[0, 0] *= scale_x + cam_mat[1, 1] *= scale_y + cam_mat[0, 2] *= scale_x + cam_mat[1, 2] *= scale_y + + return cam_mat, dist + + k1 = self.config.distortion_k1 + k2 = self.config.distortion_k2 + p1 = self.config.distortion_p1 + p2 = self.config.distortion_p2 + k3 = self.config.distortion_k3 + + if k1 != 0.0 or k2 != 0.0 or p1 != 0.0 or p2 != 0.0 or k3 != 0.0: + f = max(h, w) + cam_mat = np.array( + [[f, 0, w / 2], [0, f, h / 2], [0, 0, 1]], dtype=np.float32 + ) + dist_coeffs = np.array([k1, k2, p1, p2, k3], dtype=np.float32) + return cam_mat, dist_coeffs + + return None, None + + def _process_frame(self, frame: np.ndarray) -> np.ndarray: + """Applies denoise, fisheye correction, and boundary stretching.""" + + # 0. Temporal Denoising (Accumulate Weighted) + denoise_strength = getattr(self.config, "denoise", 0.0) + + if denoise_strength > 0.0: + if ( + self._accumulator is None + or self._accumulator.shape != frame.shape + ): + self._accumulator = frame.astype(np.float32) + else: + alpha = 1.0 - denoise_strength + cv2.accumulateWeighted(frame, self._accumulator, alpha) + + # Use the denoised result for subsequent processing + frame_to_process = self._accumulator.astype(np.uint8) + else: + self._accumulator = None + frame_to_process = frame + + h, w = frame_to_process.shape[:2] + + # 1. Undistort (Fisheye Correction) + cam_mat, dist = self._get_effective_calibration(h, w) + if cam_mat is not None and dist is not None: + try: + frame_to_process = cv2.undistort( + frame_to_process, cam_mat, dist + ) + except cv2.error as e: + logger.error(f"Failed to undistort frame: {e}") + + return frame_to_process + + def _read_frame(self, cap) -> bool: + """Read frame from cap. Returns True on success.""" + try: + ret, frame = cap.read() + if not ret or frame is None: + logger.warning("Failed to capture frame from camera.") + self._image_data = None + self._raw_image_data = None + return False + + self._raw_image_data = frame.copy() + + # Apply all visual corrections + self._image_data = self._process_frame(frame) + + # Emit the signal in a GLib-safe way + idle_add(self.image_captured.send, self) + return True + except (cv2.error, OSError, ValueError) as e: + logger.error(f"Error reading frame: {e}") + return False + + def _handle_frame_failure(self): + """Handle a failed frame read. Returns True if should reconnect.""" + self._consecutive_failures += 1 + self._image_data = None + logger.warning( + f"Frame failure {self._consecutive_failures}/" + f"{self.MAX_CONSECUTIVE_FAILURES} for {self.config.name}" + ) + return self._consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES + + def _capture_frames_from_device(self, cap): + """Capture frames from an opened device. Returns when should stop.""" + self._settings_dirty = True + self._consecutive_failures = 0 + self._accumulator = None + + while self._running and cap is not None: + if self._settings_dirty: + self._apply_settings(cap) + + if self._read_frame(cap): + self._consecutive_failures = 0 + elif self._handle_frame_failure(): + logger.error( + f"Too many failures for {self.config.name}, " + "reconnecting..." + ) + return + + time.sleep(self.FRAME_READ_TIMEOUT) + + def _capture_loop(self): + """ + Internal method to continuously capture images from the camera. + Runs in a separate thread. + """ + logger.info( + f"Capture loop starting for {self.config.name} " + f"(device: {self.config.device_id})" + ) + + while self._running: + try: + # Open the device ONCE + with VideoCaptureDevice(self.config.device_id) as cap: + if cap is None: + raise OSError("VideoCapture returned None") + self._capture_frames_from_device(cap) + except OSError as e: + logger.error(f"IO error for {self.config.name}: {e}") + except cv2.error as e: + logger.error(f"OpenCV error for {self.config.name}: {e}") + except Exception: + logger.exception(f"Unexpected error for {self.config.name}") + + if self._running: + logger.info( + f"Waiting {self.RECONNECT_DELAY}s before " + f"reconnecting {self.config.name}..." + ) + time.sleep(self.RECONNECT_DELAY) + + logger.debug( + f"Camera capture loop stopped for camera {self.config.name}." + ) + + def _start_capture_stream(self): + """ + Starts a continuous image capture stream in a separate thread. + """ + if self._running: + logger.debug( + f"Capture stream already running for camera {self.config.name}" + ) + return + + logger.debug(f"Starting capture stream for camera {self.config.name}.") + self._running = True + self._capture_thread = threading.Thread(target=self._capture_loop) + self._capture_thread.daemon = True # Allow the main program to exit + self._capture_thread.start() + + def _stop_capture_stream(self): + """ + Stops the continuous image capture stream. + """ + if not self._running: + logger.debug( + f"Capture stream not running for camera {self.config.name}." + ) + return + + logger.debug(f"Stopping capture stream for camera {self.config.name}.") + self._running = False + if self._capture_thread and self._capture_thread.is_alive(): + self._capture_thread.join(timeout=1.0) # Wait for thread to finish + if self._capture_thread.is_alive(): + logger.warning("Capture thread did not terminate gracefully.") + self._capture_thread = None + + def capture_image(self): + """ + Captures a single image from this camera device. + """ + try: + with VideoCaptureDevice(self.config.device_id) as cap: + if cap is None: + logger.error( + f"Cannot capture: VideoCapture is None for " + f"{self.config.device_id}" + ) + self._image_data = None + return + # Apply settings before capturing the single frame + self._apply_settings(cap) + self._read_frame(cap) + except OSError as e: + logger.error(f"IO error capturing image: {e}") + self._image_data = None + except cv2.error as e: + logger.error(f"OpenCV error capturing image: {e}") + self._image_data = None + except Exception: + logger.exception("Unexpected error capturing image") + self._image_data = None diff --git a/rayforge/camera/manager.py b/rayforge/camera/manager.py new file mode 100644 index 000000000..eb8814a80 --- /dev/null +++ b/rayforge/camera/manager.py @@ -0,0 +1,155 @@ +import logging + +from blinker import Signal + +from ..context import RayforgeContext +from ..machine.models.machine import Machine +from .controller import CameraController +from .models.camera import Camera + +logger = logging.getLogger(__name__) + + +class CameraManager: + """ + Manages the lifecycle of CameraController instances. + + This class acts as the single source of truth for live camera controllers. + It listens for changes in the application's configuration (specifically, + the active machine and its list of cameras) and reconciles its internal + list of controllers to match. It creates, destroys, and provides access + to CameraController instances, emitting signals as the list of active + controllers changes. + """ + + def __init__(self, context: RayforgeContext): + self._context = context + self._controllers: dict[str, CameraController] = {} + self._active_machine: Machine | None = None + + # Signals + self.controller_added = Signal() + self.controller_removed = Signal() + + def initialize(self): + """ + Performs initial setup after all managers are available. + This is where signal connections are made to avoid circular imports. + """ + config = self._context.config + if not config: + logger.error( + "Cannot initialize CameraManager: Config not found in context." + ) + return + + config.changed.connect(self._on_config_changed) + # Manually trigger the first check to set up the initial machine + self._on_config_changed(config) + + def shutdown(self): + """Shuts down all active camera controllers.""" + logger.info("Shutting down all camera controllers.") + config = self._context.config + if config: + config.changed.disconnect(self._on_config_changed) + + # Disconnect from the last active machine + if self._active_machine: + self._active_machine.changed.disconnect( + self._on_active_machine_changed + ) + self._active_machine = None + + for controller in list(self._controllers.values()): + self._destroy_controller(controller.config.device_id) + logger.info("All camera controllers shut down.") + + @property + def controllers(self) -> list[CameraController]: + """Returns a list of all active CameraController instances.""" + return list(self._controllers.values()) + + def get_controller(self, device_id: str) -> CameraController | None: + """Gets a specific controller by its device ID.""" + return self._controllers.get(device_id) + + def _on_config_changed(self, sender, **kwargs): + """ + Handler for when the global config changes. This method is now + responsible for tracking the active machine and connecting/ + disconnecting from its `changed` signal. + """ + config = self._context.config + if not config: + return + + if self._active_machine is not config.machine: + logger.debug( + "Active machine changed, updating signal connections." + ) + # Disconnect from the old machine if it exists + if self._active_machine: + self._active_machine.changed.disconnect( + self._on_active_machine_changed + ) + + # Connect to the new machine + self._active_machine = config.machine + if self._active_machine: + self._active_machine.changed.connect( + self._on_active_machine_changed + ) + + # Always reconcile when the config changes, as this is the top-level + # trigger for a machine switch. + self._reconcile_controllers() + + def _on_active_machine_changed(self, sender, **kwargs): + """ + Handler for when the currently active machine's properties change + (e.g., a camera is added or removed). + """ + logger.debug( + "Active machine's properties changed, reconciling cameras." + ) + self._reconcile_controllers() + + def _destroy_controller(self, device_id: str): + """Safely unsubscribes, stops, and removes a controller.""" + if device_id in self._controllers: + controller = self._controllers.pop(device_id) + controller.unsubscribe() # Stops the thread if it's the last sub + self.controller_removed.send(self, controller=controller) + logger.info(f"Destroyed controller for camera {device_id}") + + def _reconcile_controllers(self): + """ + Synchronizes the set of active CameraControllers with the cameras + defined in the currently active machine model. + """ + config = self._context.config + if not config: + return + + active_machine = config.machine + camera_configs_in_model: dict[str, Camera] = {} + if active_machine: + camera_configs_in_model = { + c.device_id: c for c in active_machine.cameras + } + + model_ids = set(camera_configs_in_model.keys()) + active_controller_ids = set(self._controllers.keys()) + + # Destroy controllers for cameras that were removed from the model + for device_id in active_controller_ids - model_ids: + self._destroy_controller(device_id) + + # Create controllers for new cameras added to the model + for device_id in model_ids - active_controller_ids: + config_model = camera_configs_in_model[device_id] + controller = CameraController(config_model) + self._controllers[device_id] = controller + self.controller_added.send(self, controller=controller) + logger.info(f"Created controller for camera {device_id}") diff --git a/rayforge/camera/models/__init__.py b/rayforge/camera/models/__init__.py new file mode 100644 index 000000000..0bb95aa5d --- /dev/null +++ b/rayforge/camera/models/__init__.py @@ -0,0 +1,3 @@ +from .camera import Camera + +__all__ = ["Camera"] diff --git a/rayforge/camera/models/camera.py b/rayforge/camera/models/camera.py new file mode 100644 index 000000000..6b768f108 --- /dev/null +++ b/rayforge/camera/models/camera.py @@ -0,0 +1,697 @@ +import json +import logging +from collections.abc import Sequence +from datetime import datetime, timezone +from typing import Any + +import numpy as np +from blinker import Signal + +logger = logging.getLogger(__name__) +Pos = tuple[float, float] +PointList = Sequence[Pos] + + +class Camera: + """A pure data model representing the configuration of a camera.""" + + def __init__(self, name: str, device_id: str): + self._name: str = name + self._device_id: str = device_id + self._enabled: bool = False + # None indicates auto white balance, float for manual Kelvin + self._white_balance: float | None = None + self._contrast: float = 50.0 + self._brightness: float = 0.0 # Default brightness (0 = no change) + self._transparency: float = 0.2 + + # Noise Reduction (0.0 to 1.0) + # 0.0 = No denoise, 1.0 = Max smoothing (high latency/ghosting) + self._denoise: float = 0.0 + + self._prefer_yuyv: bool = False + + self._resolution: tuple[int, int] | None = None + + # Lens calibration parameters + # Distortion coefficients: k1, k2, p1, p2, k3 (OpenCV order) + self._distortion_k1: float = 0.0 + self._distortion_k2: float = 0.0 + self._distortion_p1: float = 0.0 + self._distortion_p2: float = 0.0 + self._distortion_k3: float = 0.0 + + # Camera matrix parameters (needed for proper undistortion) + self._camera_matrix_fx: float | None = None + self._camera_matrix_fy: float | None = None + self._camera_matrix_cx: float | None = None + self._camera_matrix_cy: float | None = None + + # Calibration metadata + self._calibration_rms: float | None = None + self._calibration_date: datetime | None = None + self._calibration_image_size: tuple[int, int] | None = None + self._calibration_frames_used: int | None = None + + # Properties for camera alignment points + self._image_to_world: tuple[PointList, PointList] | None = None + self._alignment_date: datetime | None = None + + # Signals + self.changed = Signal() + self.settings_changed = Signal() + self.extra: dict[str, Any] = {} + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str): + if self._name == value: + return + logger.debug(f"Camera name changed from '{self._name}' to '{value}'") + self._name = value + self.changed.send(self) + + @property + def device_id(self) -> str: + return self._device_id + + @device_id.setter + def device_id(self, value: str): + if self._device_id == value: + return + logger.debug( + f"Camera device_id changed from '{self._device_id}' to '{value}'" + ) + self._device_id = value + self.changed.send(self) + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, value: bool): + if self._enabled == value: + return + logger.debug(f"Camera enabled changed from {self._enabled} to {value}") + self._enabled = value + self.changed.send(self) + + @property + def white_balance(self) -> float | None: + return self._white_balance + + @white_balance.setter + def white_balance(self, value: float | None): + if value is not None: + if not isinstance(value, (int, float)): + raise ValueError("White balance must be a number or None.") + if not (2500 <= value <= 10000): + logger.warning( + f"White balance value {value} is outside range " + "(2500-10000). Clamping to nearest bound." + ) + value = max(2500, min(value, 10000)) + if self._white_balance == value: + return + logger.debug( + f"Camera white_balance changed from {self._white_balance} to " + f"{value}" + ) + self._white_balance = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def contrast(self) -> float: + return self._contrast + + @contrast.setter + def contrast(self, value: float): + if not isinstance(value, (int, float)): + raise TypeError("Contrast must be a number.") + if not (0.0 <= value <= 100.0): + logger.warning( + f"Contrast value {value} is outside range (0.0-100.0). " + "Clamping to nearest bound." + ) + value = max(0.0, min(value, 100.0)) + if self._contrast == value: + return + logger.debug( + f"Camera contrast changed from {self._contrast} to {value}" + ) + self._contrast = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def brightness(self) -> float: + return self._brightness + + @brightness.setter + def brightness(self, value: float): + if not isinstance(value, (int, float)): + raise TypeError("Brightness must be a number.") + if not (-100.0 <= value <= 100.0): + logger.warning( + f"Brightness value {value} is outside range (-100.0-100.0). " + "Clamping to nearest bound." + ) + value = max(-100.0, min(value, 100.0)) + if self._brightness == value: + return + logger.debug( + f"Camera brightness changed from {self._brightness} to {value}" + ) + self._brightness = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def transparency(self) -> float: + return self._transparency + + @transparency.setter + def transparency(self, value: float): + if not isinstance(value, (int, float)): + raise TypeError("Transparency must be a number.") + if not (0.0 <= value <= 1.0): + logger.warning( + f"Transparency value {value} is outside range (0.0-1.0). " + "Clamping to nearest bound." + ) + value = max(0.0, min(value, 1.0)) + if self._transparency == value: + return + logger.debug( + f"Camera transparency changed from {self._transparency} to {value}" + ) + self._transparency = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def denoise(self) -> float: + """Temporal noise reduction factor (0.0 - 1.0).""" + return self._denoise + + @denoise.setter + def denoise(self, value: float): + if not isinstance(value, (int, float)): + raise TypeError("Denoise must be a number.") + # Clamp between 0.0 (off) and 0.95 (extreme smoothing) + value = max(0.0, min(value, 0.95)) + if self._denoise == value: + return + logger.debug(f"Camera denoise changed from {self._denoise} to {value}") + self._denoise = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def prefer_yuyv(self) -> bool: + """Whether to prefer YUYV format over MJPEG (fixes green artifacts).""" + return self._prefer_yuyv + + @prefer_yuyv.setter + def prefer_yuyv(self, value: bool): + if not isinstance(value, bool): + raise TypeError("prefer_yuyv must be a boolean.") + if self._prefer_yuyv == value: + return + logger.debug( + f"Camera prefer_yuyv changed from {self._prefer_yuyv} to {value}" + ) + self._prefer_yuyv = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def resolution(self) -> tuple[int, int] | None: + return self._resolution + + @resolution.setter + def resolution(self, value: tuple[int, int] | None): + if value is not None: + if not ( + isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], int) + and isinstance(value[1], int) + ): + raise ValueError( + "Resolution must be a tuple of two ints (w, h) or None." + ) + if value[0] <= 0 or value[1] <= 0: + raise ValueError("Resolution values must be positive.") + if self._resolution == value: + return + logger.debug( + f"Camera resolution changed from {self._resolution} to {value}" + ) + self._resolution = value + self.changed.send(self) + self.settings_changed.send(self) + + # --- Fisheye/Distortion Properties --- + + @property + def distortion_k1(self) -> float: + return self._distortion_k1 + + @distortion_k1.setter + def distortion_k1(self, value: float): + self._distortion_k1 = float(value) + self._calibration_date = datetime.now(tz=timezone.utc) + self.changed.send(self) + self.settings_changed.send(self) + + @property + def distortion_k2(self) -> float: + return self._distortion_k2 + + @distortion_k2.setter + def distortion_k2(self, value: float): + self._distortion_k2 = float(value) + self._calibration_date = datetime.now(tz=timezone.utc) + self.changed.send(self) + self.settings_changed.send(self) + + @property + def distortion_p1(self) -> float: + return self._distortion_p1 + + @distortion_p1.setter + def distortion_p1(self, value: float): + self._distortion_p1 = float(value) + self._calibration_date = datetime.now(tz=timezone.utc) + self.changed.send(self) + self.settings_changed.send(self) + + @property + def distortion_p2(self) -> float: + return self._distortion_p2 + + @distortion_p2.setter + def distortion_p2(self, value: float): + self._distortion_p2 = float(value) + self._calibration_date = datetime.now(tz=timezone.utc) + self.changed.send(self) + self.settings_changed.send(self) + + @property + def distortion_k3(self) -> float: + return self._distortion_k3 + + @distortion_k3.setter + def distortion_k3(self, value: float): + self._distortion_k3 = float(value) + self._calibration_date = datetime.now(tz=timezone.utc) + self.changed.send(self) + self.settings_changed.send(self) + + @property + def camera_matrix_fx(self) -> float | None: + return self._camera_matrix_fx + + @camera_matrix_fx.setter + def camera_matrix_fx(self, value: float | None): + self._camera_matrix_fx = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def camera_matrix_fy(self) -> float | None: + return self._camera_matrix_fy + + @camera_matrix_fy.setter + def camera_matrix_fy(self, value: float | None): + self._camera_matrix_fy = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def camera_matrix_cx(self) -> float | None: + return self._camera_matrix_cx + + @camera_matrix_cx.setter + def camera_matrix_cx(self, value: float | None): + self._camera_matrix_cx = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def camera_matrix_cy(self) -> float | None: + return self._camera_matrix_cy + + @camera_matrix_cy.setter + def camera_matrix_cy(self, value: float | None): + self._camera_matrix_cy = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def has_calibration(self) -> bool: + return all( + v is not None + for v in [ + self._camera_matrix_fx, + self._camera_matrix_fy, + self._camera_matrix_cx, + self._camera_matrix_cy, + ] + ) + + def get_camera_matrix(self) -> np.ndarray | None: + if not self.has_calibration: + return None + return np.array( + [ + [self._camera_matrix_fx, 0, self._camera_matrix_cx], + [0, self._camera_matrix_fy, self._camera_matrix_cy], + [0, 0, 1], + ], + dtype=np.float64, + ) + + def get_distortion_coeffs(self) -> np.ndarray: + return np.array( + [ + self._distortion_k1, + self._distortion_k2, + self._distortion_p1, + self._distortion_p2, + self._distortion_k3, + ], + dtype=np.float64, + ) + + # --------------------------------------------- + + @property + def image_to_world(self) -> tuple[PointList, PointList] | None: + return self._image_to_world + + @image_to_world.setter + def image_to_world(self, value: tuple[PointList, PointList] | None): + if value is not None: + if not (isinstance(value, tuple) and len(value) == 2): + raise ValueError( + "Corresponding points must be a tuple of two point lists." + ) + image_points, world_points = value + if not ( + isinstance(image_points, Sequence) + and isinstance(world_points, Sequence) + ): + raise ValueError( + "Both elements of corresponding points must be sequences." + ) + if len(image_points) < 4 or len(world_points) < 4: + raise ValueError( + "At least 4 corresponding points are required." + ) + if len(image_points) != len(world_points): + raise ValueError( + "Image points and world points must have the same number " + "of entries." + ) + for points in [image_points, world_points]: + for p in points: + if not ( + isinstance(p, tuple) + and len(p) == 2 + and isinstance(p[0], (int, float)) + and isinstance(p[1], (int, float)) + ): + raise ValueError( + "Each point must be a tuple of two floats " + "(e.g., (x, y))." + ) + if self._image_to_world == value: + return + logger.debug( + f"Camera image_to_world changed from " + f"{self._image_to_world} to {value}" + ) + self._image_to_world = value + if value is not None: + self._alignment_date = datetime.now(tz=timezone.utc) + else: + self._alignment_date = None + self.changed.send(self) + self.settings_changed.send(self) + + def set_calibration_result(self, result): + """ + Set camera calibration from a CalibrationResult object. + + Populates both the camera matrix parameters and distortion + coefficients into the unified storage. + + Args: + result: CalibrationResult from the calibrator + """ + from ..calibration.result import CalibrationResult + + if not isinstance(result, CalibrationResult): + raise TypeError("Expected CalibrationResult object") + + self._camera_matrix_fx = float(result.camera_matrix[0, 0]) + self._camera_matrix_fy = float(result.camera_matrix[1, 1]) + self._camera_matrix_cx = float(result.camera_matrix[0, 2]) + self._camera_matrix_cy = float(result.camera_matrix[1, 2]) + + dist = result.distortion_coeffs.flatten() + self._distortion_k1 = float(dist[0]) if len(dist) > 0 else 0.0 + self._distortion_k2 = float(dist[1]) if len(dist) > 1 else 0.0 + self._distortion_p1 = float(dist[2]) if len(dist) > 2 else 0.0 + self._distortion_p2 = float(dist[3]) if len(dist) > 3 else 0.0 + self._distortion_k3 = float(dist[4]) if len(dist) > 4 else 0.0 + + self._calibration_rms = result.rms_error + self._calibration_date = result.calibration_date + self._calibration_image_size = result.image_size + self._calibration_frames_used = result.num_frames_used + + logger.debug( + f"Camera calibration set from result: RMS={result.rms_error:.4f}" + ) + self.changed.send(self) + self.settings_changed.send(self) + + @property + def calibration_rms(self) -> float | None: + return self._calibration_rms + + @property + def calibration_date(self) -> datetime | None: + return self._calibration_date + + @property + def calibration_image_size(self) -> tuple[int, int] | None: + return self._calibration_image_size + + @property + def calibration_frames_used(self) -> int | None: + return self._calibration_frames_used + + @property + def alignment_date(self) -> datetime | None: + return self._alignment_date + + @alignment_date.setter + def alignment_date(self, value: datetime | None): + if self._alignment_date == value: + return + self._alignment_date = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def has_alignment(self) -> bool: + return self._image_to_world is not None + + @property + def alignment_valid(self) -> bool: + if not self.has_alignment: + return False + if self._calibration_date is None: + return True + if self._alignment_date is None: + return False + return self._alignment_date >= self._calibration_date + + def to_dict(self) -> dict[str, Any]: + data = { + "name": self.name, + "device_id": self.device_id, + "enabled": self.enabled, + "white_balance": self.white_balance, + "contrast": self.contrast, + "brightness": self.brightness, + "transparency": self.transparency, + "denoise": self.denoise, + "prefer_yuyv": self.prefer_yuyv, + "distortion_k1": self.distortion_k1, + "distortion_k2": self.distortion_k2, + "distortion_p1": self.distortion_p1, + "distortion_p2": self.distortion_p2, + "distortion_k3": self.distortion_k3, + "resolution": ( + list(self.resolution) if self.resolution is not None else None + ), + } + if self.image_to_world is not None: + image_points, world_points = self.image_to_world + data["image_to_world"] = [ + { + "image": f"{img[0]}, {img[1]}", + "world": f"{wld[0]}, {wld[1]}", + } + for img, wld in zip(image_points, world_points) + ] + else: + data["image_to_world"] = None + + if self._alignment_date is not None: + data["alignment_date"] = self._alignment_date.isoformat() + + if self._calibration_date is not None: + data["calibration_date"] = self._calibration_date.isoformat() + + if self.has_calibration: + data["camera_matrix_fx"] = self._camera_matrix_fx + data["camera_matrix_fy"] = self._camera_matrix_fy + data["camera_matrix_cx"] = self._camera_matrix_cx + data["camera_matrix_cy"] = self._camera_matrix_cy + data["calibration_rms"] = self._calibration_rms + data["calibration_date"] = ( + self._calibration_date.isoformat() + if self._calibration_date + else None + ) + data["calibration_image_size"] = ( + list(self._calibration_image_size) + if self._calibration_image_size + else None + ) + data["calibration_frames_used"] = self._calibration_frames_used + + data.update(self.extra) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Camera": + known_keys = { + "name", + "device_id", + "enabled", + "white_balance", + "contrast", + "brightness", + "transparency", + "denoise", + "prefer_yuyv", + "image_to_world", + "distortion_k1", + "distortion_k2", + "distortion_p1", + "distortion_p2", + "distortion_k3", + "resolution", + "camera_matrix_fx", + "camera_matrix_fy", + "camera_matrix_cx", + "camera_matrix_cy", + "calibration_rms", + "calibration_date", + "calibration_image_size", + "calibration_frames_used", + "alignment_date", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + camera = cls(data["name"], data["device_id"]) + camera.enabled = data.get("enabled", camera.enabled) + camera.white_balance = data.get("white_balance", None) + camera.contrast = data.get("contrast", camera.contrast) + camera.brightness = data.get("brightness", camera.brightness) + camera.transparency = data.get("transparency", camera.transparency) + camera.denoise = data.get("denoise", 0.0) + camera.prefer_yuyv = data.get("prefer_yuyv", False) + + camera.distortion_k1 = data.get("distortion_k1", 0.0) + camera.distortion_k2 = data.get("distortion_k2", 0.0) + camera.distortion_p1 = data.get("distortion_p1", 0.0) + camera.distortion_p2 = data.get("distortion_p2", 0.0) + camera.distortion_k3 = data.get("distortion_k3", 0.0) + + resolution_data = data.get("resolution") + if resolution_data is not None: + camera._resolution = tuple(resolution_data) + + image_to_world_data = data.get("image_to_world") + if image_to_world_data is not None: + image_points = [] + world_points = [] + for entry in image_to_world_data: + image_str = entry["image"].split(",") + world_str = entry["world"].split(",") + image_points.append( + (float(image_str[0].strip()), float(image_str[1].strip())) + ) + world_points.append( + (float(world_str[0].strip()), float(world_str[1].strip())) + ) + camera.image_to_world = (image_points, world_points) + else: + camera.image_to_world = None + + if data.get("alignment_date"): + camera._alignment_date = datetime.fromisoformat( + data["alignment_date"] + ) + elif camera._image_to_world is not None: + camera._alignment_date = datetime.now(tz=timezone.utc) + + camera._camera_matrix_fx = data.get("camera_matrix_fx") + camera._camera_matrix_fy = data.get("camera_matrix_fy") + camera._camera_matrix_cx = data.get("camera_matrix_cx") + camera._camera_matrix_cy = data.get("camera_matrix_cy") + camera._calibration_rms = data.get("calibration_rms") + if data.get("calibration_date"): + camera._calibration_date = datetime.fromisoformat( + data["calibration_date"] + ) + elif any( + v != 0.0 + for v in [ + camera._distortion_k1, + camera._distortion_k2, + camera._distortion_k3, + camera._distortion_p1, + camera._distortion_p2, + ] + ): + camera._calibration_date = datetime.now(tz=timezone.utc) + if data.get("calibration_image_size"): + camera._calibration_image_size = tuple( + data["calibration_image_size"] + ) + camera._calibration_frames_used = data.get("calibration_frames_used") + + camera.extra = extra + return camera + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=4) + + @classmethod + def from_json(cls, json_str: str): + data = json.loads(json_str) + return cls.from_dict(data) diff --git a/rayforge/camera/v4l.py b/rayforge/camera/v4l.py new file mode 100644 index 000000000..002a2ded2 --- /dev/null +++ b/rayforge/camera/v4l.py @@ -0,0 +1,163 @@ +"""Linux V4L persistent device identification. + +On Linux, /dev/videoN device numbers are assigned by the kernel based on +discovery order and can change between reboots. This module resolves +cameras using persistent /dev/v4l/by-id/ symlinks instead. +""" + +import logging +import sys +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +V4L_BY_ID_DIR = Path("/dev/v4l/by-id") + + +def _is_video_capture_symlink(name: str) -> bool: + """Check if a by-id symlink refers to a video capture device. + + Video capture symlinks end with '-video-indexN'. Other entries + (like '-inputN' or '-altN') refer to non-capture interfaces. + """ + return "-video-index" in name + + +def scan_v4l_by_id() -> dict[str, str]: + """Scan /dev/v4l/by-id/ for video capture devices. + + Returns: + Dict mapping resolved '/dev/videoN' to the persistent + '/dev/v4l/by-id/...' symlink path. + """ + if not sys.platform.startswith("linux"): + return {} + + if not V4L_BY_ID_DIR.is_dir(): + logger.debug("/dev/v4l/by-id/ does not exist") + return {} + + result: dict[str, str] = {} + + try: + for entry in sorted(V4L_BY_ID_DIR.iterdir()): + if not entry.is_symlink(): + continue + if not _is_video_capture_symlink(entry.name): + continue + + try: + resolved = str(entry.resolve()) + if not resolved.startswith("/dev/video"): + continue + if resolved not in result: + result[resolved] = str(entry) + except OSError: + continue + except OSError as e: + logger.debug(f"Error scanning /dev/v4l/by-id/: {e}") + + logger.debug(f"Found {len(result)} V4L by-id devices") + return result + + +def get_sorted_by_id_paths() -> list[str]: + """Return by-id paths sorted by underlying /dev/videoN number.""" + mapping = scan_v4l_by_id() + + def sort_key(by_id_path: str) -> int: + resolved = Path(by_id_path).resolve() + try: + return int(resolved.name.replace("video", "")) + except ValueError: + return 999 + + return sorted(mapping.values(), key=sort_key) + + +def resolve_device_id(device_id: str) -> str: + """Resolve a numeric device ID to a persistent by-id path. + + If the device_id is already a by-id path or not a plain integer + string, it is returned unchanged. If it is a numeric string like + "0" on Linux, attempts to find the corresponding by-id path. + + Returns: + The best available device identifier string. + """ + if not sys.platform.startswith("linux"): + return device_id + + if not device_id.isdigit(): + return device_id + + mapping = scan_v4l_by_id() + target = f"/dev/video{device_id}" + + if target in mapping: + logger.info( + f"Migrated camera device_id from '{device_id}' " + f"to '{mapping[target]}'" + ) + return mapping[target] + + logger.debug(f"No by-id path found for {target}, keeping numeric ID") + return device_id + + +def display_name(device_id: str) -> str: + """Return a human-readable display name for a camera device. + + For by-id paths, extracts the friendly name. For other IDs, + returns the device_id as-is. + """ + if device_id.startswith("/dev/v4l/by-id/"): + return friendly_name_from_by_id(device_id) + return device_id + + +def migrate_camera_data(data: dict[str, Any]) -> dict[str, Any]: + """Migrate numeric device IDs to persistent by-id paths. + + On Linux, numeric device IDs like "0" are replaced with + their corresponding /dev/v4l/by-id/ path when available. + Returns a copy with the migrated device_id. + """ + if "device_id" not in data: + return data + + migrated = dict(data) + migrated["device_id"] = resolve_device_id(data["device_id"]) + return migrated + + +def friendly_name_from_by_id(by_id_path: str) -> str: + """Extract a human-readable name from a by-id path. + + E.g. '/dev/v4l/by-id/usb-046d_Logitech_Webcam_C930e_1234' + -> 'Logitech Webcam C930e' + """ + filename = Path(by_id_path).stem + + for prefix in ("usb-", "pci-"): + if filename.startswith(prefix): + filename = filename[len(prefix) :] + break + + idx = filename.find("-video-index") + if idx >= 0: + filename = filename[:idx] + + parts = filename.split("_") + if len(parts) <= 1: + return parts[0] if parts else "" + + # First segment is the hex vendor ID (e.g. '046d'), drop it. + # Last segment is the serial number, drop it. + if len(parts) > 2: + parts = parts[1:-1] + else: + parts = parts[:-1] + + return " ".join(parts) diff --git a/rayforge/config.py b/rayforge/config.py index a88dcfb5c..32ad6de71 100644 --- a/rayforge/config.py +++ b/rayforge/config.py @@ -1,41 +1,82 @@ +import logging import os from pathlib import Path -from platformdirs import user_config_dir -from .models.machine import MachineManager -from .models.config import ConfigManager +from platformdirs import user_config_dir, user_log_dir + +logger = logging.getLogger(__name__) + + +def _get_config_dir() -> Path: + """Get the config directory, respecting RAYFORGE_CONFIG_DIR env var.""" + env_config = os.environ.get("RAYFORGE_CONFIG_DIR") + if env_config: + return Path(env_config) + return Path(user_config_dir("rayforge")) + + +CONFIG_DIR = _get_config_dir() +logger.info(f"Config dir is {CONFIG_DIR}") -CONFIG_DIR = Path(user_config_dir("rayforge")) MACHINE_DIR = CONFIG_DIR / "machines" +logger.debug(f"MACHINE_DIR is {MACHINE_DIR}") MACHINE_DIR.mkdir(parents=True, exist_ok=True) + +DIALECT_DIR = CONFIG_DIR / "dialects" +logger.debug(f"DIALECT_DIR is {DIALECT_DIR}") +DIALECT_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE = CONFIG_DIR / "config.yaml" -print(f"Config dir is {CONFIG_DIR}") +ADDONS_DIR = CONFIG_DIR / "addons" +LICENSES_DIR = CONFIG_DIR / "licenses" +ADDON_DATA_DIR = CONFIG_DIR / "addon_data" +AI_CONFIG_FILE = CONFIG_DIR / "ai.yaml" + + +def get_addon_data_dir(addon_name: str) -> Path: + """ + Get the data directory for an addon. + + Args: + addon_name: The canonical name of the addon. + + Returns: + Path to the addon's data directory. + """ + path = ADDON_DATA_DIR / addon_name + path.mkdir(parents=True, exist_ok=True) + return path + + +BUILTIN_ADDONS_DIR = Path(__file__).parent / "builtin_addons" +PRIVATE_ADDONS_DIR = Path(__file__).parent / "private_addons" + +USER_DEVICES_DIR = CONFIG_DIR / "devices" +BUILTIN_DEVICES_DIR = Path(__file__).parent / "resources" / "devices" + +# State files (like logs) +LOG_DIR = Path(user_log_dir("rayforge")) +logger.info(f"Log dir is {LOG_DIR}") +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# Material directories +USER_MATERIALS_DIR = CONFIG_DIR / "materials" +USER_RECIPES_DIR = CONFIG_DIR / "recipes" +USER_COLOR_PRESETS_DIR = CONFIG_DIR / "color_presets" + +ADDON_REGISTRY_URL = ( + "https://raw.githubusercontent.com/barebaric/rayforge-registry/" + "main/registry.yaml" +) + +PATREON_CLIENT_ID = ( + "nx7wTdFBp5Cc3NtMU4xYK7mkzlaqLg5hXgLlY6WAtAMq62je5WDE_x8ewrrCvJ34" +) + +UMAMI_URL = "https://analytics.barebaric.com/api/send" +UMAMI_WEBSITE_ID = "3b301b16-48d2-4007-977a-ccfb738eab52" def getflag(name, default=False): - default = 'true' if default else 'false' - return os.environ.get(name, default).lower() in ('true', '1') - - -""" -TODO # Load machine templates. -from .util.resources import get_machine_template_path -machine_template_path = get_machine_template_path() -machine_template_mgr = MachineManager(machine_template_path) -print(f"Loaded {len(machine_template_mgr.machines)} machine templates") -""" - -# Load all machines. If none exist, create a default machine. -machine_mgr = MachineManager(MACHINE_DIR) -print(f"Loaded {len(machine_mgr.machines)} machines") -if not machine_mgr.machines: - machine = machine_mgr.create_default_machine() - print(f"Created default machine {machine.id}") - -# Load the config file. -config_mgr = ConfigManager(CONFIG_FILE, machine_mgr) -config = config_mgr.config -if not config.machine: - machine = list(sorted(machine_mgr.machines.values()))[0] - config.set_machine(machine) -print(f"Config loaded. Using machine {config.machine.id}") + default = "true" if default else "false" + return os.environ.get(name, default).lower() in ("true", "1") diff --git a/rayforge/const.py b/rayforge/const.py new file mode 100644 index 000000000..6bb31ca20 --- /dev/null +++ b/rayforge/const.py @@ -0,0 +1,12 @@ +"""Constants for Rayforge application.""" + +APP_NAME = "Rayforge" +MIME_TYPE_PROJECT = "application/x-rayforge-project" +MIME_TYPE_SKETCH = "application/x-rayforge-sketch" + +GITHUB_RELEASES_API = ( + "https://api.github.com/repos/barebaric/rayforge/releases/latest" +) +GITHUB_URL = "https://github.com/barebaric/rayforge" +ISSUES_URL = "https://github.com/barebaric/rayforge/issues" +DOWNLOAD_URL = "https://rayforge.org/docs/getting-started/installation" diff --git a/rayforge/context.py b/rayforge/context.py new file mode 100644 index 000000000..2bae2a54f --- /dev/null +++ b/rayforge/context.py @@ -0,0 +1,422 @@ +import logging +import threading +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + import pluggy + + from .addon_mgr.addon_manager import AddonManager + from .camera.manager import CameraManager + from .core.addon_config import AddonConfig + from .core.ai.ai_service import AIService + from .core.ai.config import AIConfigManager + from .core.color_preset import ColorPresetManager + from .core.config import Config, ConfigManager + from .core.library_manager import LibraryManager + from .core.model_manager import ModelManager + from .core.recipe_manager import RecipeManager + from .debug import DebugDumpManager + from .license import LicenseValidator + from .machine.device.manager import DeviceProfileManager + from .machine.models.dialect_manager import DialectManager + from .machine.models.machine import Machine + from .machine.models.manager import MachineManager + + +logger = logging.getLogger(__name__) + +_context_instance: Optional["RayforgeContext"] = None +_context_lock = threading.Lock() + + +class RayforgeContext: + """ + A central, singleton context for managing the lifecycle of major + application services. + """ + + def __init__(self): + """ + Initializes the context. This constructor is lightweight and safe + to call from any process. Only ArtifactStore is created eagerly + to ensure it's available before any subtask starts. + """ + from .debug import DebugDumpManager + from .pipeline.artifact.store import ArtifactStore + from .shared.util.localized import get_system_language + + self.artifact_store = ArtifactStore() + self._debug_dump_manager = DebugDumpManager() + self._language: str = get_system_language() + self.exit_after_settle = False + self.exit_pending = False + self._headless: bool = False + + self._dialect_mgr: DialectManager | None = None + self._plugin_mgr: pluggy.PluginManager | None = None + self._addon_config: AddonConfig | None = None + self._license_validator: LicenseValidator | None = None + self._addon_mgr: AddonManager | None = None + self._ai_service: AIService | None = None + self._ai_config_mgr: AIConfigManager | None = None + self._machine_mgr: MachineManager | None = None + self._config_mgr: ConfigManager | None = None + self._config: Config | None = None + self._camera_mgr: CameraManager | None = None + self._material_mgr: LibraryManager | None = None + self._model_mgr: ModelManager | None = None + self._recipe_mgr: RecipeManager | None = None + self._device_profile_mgr: DeviceProfileManager | None = None + self._color_preset_mgr: ColorPresetManager | None = None + self._theme_service = None + + @property + def theme(self): + """ + Returns the shared theme colour service. + + The service is created lazily on first access (deferred import so + GTK is never imported eagerly in headless/worker processes) and + bound to a widget by the main window on realize. + """ + if self._theme_service is None: + from .ui_gtk.shared.theme_service import ThemeColorService + + self._theme_service = ThemeColorService() + return self._theme_service + + @property + def machine(self) -> Optional["Machine"]: + """ + Returns the active machine from the config, or None if + the config or machine is not set. + """ + return self.config.machine + + @property + def dialect_mgr(self) -> "DialectManager": + """Returns the dialect manager.""" + if self._dialect_mgr is None: + from .config import DIALECT_DIR + from .machine.models.dialect_manager import DialectManager + + self._dialect_mgr = DialectManager(DIALECT_DIR) + return self._dialect_mgr + + @property + def plugin_mgr(self) -> "pluggy.PluginManager": + """Returns the plugin manager.""" + if self._plugin_mgr is None: + import pluggy + + from .core.hooks import RayforgeSpecs + + self._plugin_mgr = pluggy.PluginManager("rayforge") + self._plugin_mgr.add_hookspecs(RayforgeSpecs) + return self._plugin_mgr + + @property + def license_validator(self) -> "LicenseValidator": + """Returns the license validator.""" + if self._license_validator is None: + from .config import LICENSES_DIR, PATREON_CLIENT_ID + from .license import LicenseValidator + + self._license_validator = LicenseValidator( + LICENSES_DIR, PATREON_CLIENT_ID + ) + return self._license_validator + + @property + def addon_config(self) -> "AddonConfig": + """Returns the addon configuration.""" + if self._addon_config is None: + from .config import CONFIG_DIR + from .core.addon_config import AddonConfig + + self._addon_config = AddonConfig(CONFIG_DIR) + self._addon_config.load() + return self._addon_config + + @property + def addon_mgr(self) -> "AddonManager": + """Returns the addon manager.""" + if self._addon_mgr is None: + from .addon_mgr.addon_manager import AddonManager + from .config import ( + ADDONS_DIR, + BUILTIN_ADDONS_DIR, + PRIVATE_ADDONS_DIR, + ) + from .shared.tasker import task_mgr + + self._addon_mgr = AddonManager( + [BUILTIN_ADDONS_DIR, PRIVATE_ADDONS_DIR, ADDONS_DIR], + ADDONS_DIR, + self.plugin_mgr, + task_mgr, + self.addon_config, + license_validator=self.license_validator, + ) + self._load_addons_and_call_hooks() + return self._addon_mgr + + def _load_addons_and_call_hooks(self): + """ + Loads addons and calls registration hooks. + + This is called automatically when addon_mgr is first accessed. + In headless mode, only worker entry points are loaded. + """ + if self._addon_mgr is None: + return + + from .core.registration import ( + call_registration_hooks, + get_registries, + ) + from .doceditor.layout.registry import ( + register_builtin_layout_strategies, + ) + + register_builtin_layout_strategies() + + registries = get_registries(headless=self._headless) + self._addon_mgr.set_registries(registries) + self._addon_mgr.load_installed_addons(worker_only=self._headless) + call_registration_hooks( + self.plugin_mgr, + headless=self._headless, + registries=registries, + ) + self.plugin_mgr.hook.rayforge_init(context=self) + + logger.info(f"Addons loaded (headless={self._headless})") + + @property + def ai_service(self) -> "AIService": + """Returns the AI service.""" + if self._ai_service is None: + from .core.ai.ai_service import AIService + + self._ai_service = AIService() + _ = self.ai_config_mgr + return self._ai_service + + @property + def ai_config_mgr(self) -> "AIConfigManager": + """Returns the AI configuration manager.""" + if self._ai_config_mgr is None: + from .config import AI_CONFIG_FILE + from .core.ai.config import AIConfigManager + + self._ai_config_mgr = AIConfigManager( + AI_CONFIG_FILE, self.ai_service + ) + self._ai_config_mgr.load() + return self._ai_config_mgr + + @property + def machine_mgr(self) -> "MachineManager": + """Returns the machine manager.""" + if self._machine_mgr is None: + from .config import MACHINE_DIR + from .machine.models.manager import MachineManager + + logger.info("Lazy loading machine manager") + self._machine_mgr = MachineManager(MACHINE_DIR) + if not self._machine_mgr.machines: + self._machine_mgr.create_default_machine() + return self._machine_mgr + + @property + def config_mgr(self) -> "ConfigManager": + """Returns the config manager.""" + if self._config_mgr is None: + from .config import CONFIG_FILE + from .core.config import ConfigManager as CoreConfigManager + + logger.info("Lazy loading config manager") + self._config_mgr = CoreConfigManager(CONFIG_FILE, self.machine_mgr) + self._config = self._config_mgr.config + if not self._config.machine: + machine = min( + self.machine_mgr.machines.values(), key=lambda m: m.id + ) + self._config.set_machine(machine) + # Sync the context language with the configured preference. + # This overrides the system-detected language if the user has + # explicitly chosen one in settings. + if self._config.language: + self.language = self._config.language + return self._config_mgr + + @property + def config(self) -> "Config": + """Returns the config.""" + return self.config_mgr.config + + @property + def camera_mgr(self) -> "CameraManager": + """Returns the camera manager.""" + if self._camera_mgr is None: + from .camera.manager import CameraManager + + logger.info("Lazy loading camera manager") + self._camera_mgr = CameraManager(self) + self._camera_mgr.initialize() + return self._camera_mgr + + @property + def material_mgr(self) -> "LibraryManager": + """Returns the material manager.""" + if self._material_mgr is None: + from .config import USER_MATERIALS_DIR + from .core.library_manager import LibraryManager + + logger.info("Lazy loading material manager") + self._material_mgr = LibraryManager(USER_MATERIALS_DIR) + self._material_mgr.load_all_libraries() + + if not self._headless: + self.addon_mgr.registries["library_manager"] = ( + self._material_mgr + ) + self.plugin_mgr.hook.register_material_libraries( + library_manager=self._material_mgr + ) + return self._material_mgr + + @property + def model_mgr(self) -> "ModelManager": + """Returns the model manager.""" + if self._model_mgr is None: + from .core.model_manager import ModelManager + + logger.info("Lazy loading model manager") + self._model_mgr = ModelManager() + self._model_mgr.register_bundled_library() + + if not self._headless: + self.addon_mgr.registries["model_manager"] = self._model_mgr + self.plugin_mgr.hook.register_model_libraries( + model_manager=self._model_mgr + ) + return self._model_mgr + + @property + def recipe_mgr(self) -> "RecipeManager": + """Returns the recipe manager.""" + if self._recipe_mgr is None: + from .config import USER_RECIPES_DIR + from .core.recipe_manager import RecipeManager + + logger.info("Lazy loading recipe manager") + self._recipe_mgr = RecipeManager(USER_RECIPES_DIR) + return self._recipe_mgr + + @property + def color_preset_mgr(self) -> "ColorPresetManager": + """Returns the color preset manager.""" + if self._color_preset_mgr is None: + from .core.color_preset import get_color_preset_mgr + + logger.info("Lazy loading color preset manager") + self._color_preset_mgr = get_color_preset_mgr() + return self._color_preset_mgr + + @property + def device_profile_mgr(self) -> "DeviceProfileManager": + """Returns the device profile manager.""" + if self._device_profile_mgr is None: + from .config import BUILTIN_DEVICES_DIR, USER_DEVICES_DIR + from .machine.device.manager import DeviceProfileManager + + logger.info("Lazy loading device profile manager") + self._device_profile_mgr = DeviceProfileManager( + [BUILTIN_DEVICES_DIR, USER_DEVICES_DIR], + install_dir=USER_DEVICES_DIR, + ) + self._device_profile_mgr.discover(context=self) + return self._device_profile_mgr + + @property + def debug_dump_manager(self) -> "DebugDumpManager": + """Returns the debug dump manager.""" + return self._debug_dump_manager + + @property + def language(self) -> str: + """ + Get the current language code for localized content. + + Returns: + Language code (e.g., 'en', 'de', 'zh_CN') + """ + return self._language + + @language.setter + def language(self, value: str): + """ + Set the current language for localized content. + + Args: + value: Language code (will be normalized) + """ + from .shared.util.localized import normalize_language_code + + normalized = normalize_language_code(value) + if normalized: + self._language = normalized + + def initialize_lite_context(self, machine_dir): + """ + Initializes a minimal context for testing. Sets up MachineManager + and Config without cameras, materials, recipes, or addons. + """ + from pathlib import Path + + from .core.config import ConfigManager as CoreConfigManager + from .machine.models.manager import MachineManager + + self._headless = True + self._machine_mgr = MachineManager(machine_dir) + + if not self._machine_mgr.machines: + self._machine_mgr.create_default_machine() + + config_file = Path(machine_dir) / ".." / "config.yaml" + self._config_mgr = CoreConfigManager(config_file, self._machine_mgr) + self._config = self._config_mgr.config + if not self._config.machine: + machine = min( + self._machine_mgr.machines.values(), key=lambda m: m.id + ) + self._config.set_machine(machine) + + async def shutdown(self): + """ + Shuts down all managed services in the correct order. + """ + logger.info("RayforgeContext shutting down...") + if self._camera_mgr: + self._camera_mgr.shutdown() + if self._machine_mgr: + await self._machine_mgr.shutdown() + if self._ai_service: + await self._ai_service.close_all() + self.artifact_store.shutdown() + logger.info("RayforgeContext shutdown complete.") + + +def get_context() -> "RayforgeContext": + """ + A thread-safe, lazy-initializing accessor for the global RayforgeContext + singleton. + """ + global _context_instance + if _context_instance is None: + with _context_lock: + if _context_instance is None: + _context_instance = RayforgeContext() + return _context_instance diff --git a/rayforge/widgets/__init__.py b/rayforge/core/__init__.py similarity index 100% rename from rayforge/widgets/__init__.py rename to rayforge/core/__init__.py diff --git a/rayforge/core/addon_config.py b/rayforge/core/addon_config.py new file mode 100644 index 000000000..ce11508b1 --- /dev/null +++ b/rayforge/core/addon_config.py @@ -0,0 +1,175 @@ +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + + +class AddonState: + """Represents the enabled/disabled state of an addon.""" + + ENABLED = "enabled" + DISABLED = "disabled" + + +@dataclass +class AddonConfigEntry: + """ + Configuration entry for a single addon. + + Attributes: + state: The enabled/disabled state of the addon. + version: The installed version string, or None for builtin/legacy. + """ + + state: str + version: str | None = None + + def to_dict(self) -> dict[str, str | None]: + """Convert to a dictionary for YAML serialization.""" + return {"state": self.state, "version": self.version} + + @classmethod + def from_dict(cls, data: Mapping[str, str | None]) -> "AddonConfigEntry": + """ + Create an AddonConfigEntry from a dictionary. + + Handles legacy format where state was stored directly as a string. + """ + if isinstance(data, dict): + state_value = data.get("state") + state: str = state_value if state_value else AddonState.ENABLED + version = data.get("version") + return cls(state=state, version=version) + return cls(state=AddonState.ENABLED, version=None) + + +class AddonConfig: + """Manages persistent addon configuration including state and version.""" + + def __init__(self, config_dir: Path): + self.config_file = config_dir / "addons.yaml" + self._entries: dict[str, AddonConfigEntry] = {} + + def load(self): + """Load addon configuration from the config file.""" + if not self.config_file.exists(): + logger.debug( + f"Addon config file not found at {self.config_file}, " + "using defaults" + ) + return + + try: + with open(self.config_file, "r") as f: + data = yaml.safe_load(f) + if isinstance(data, dict): + for addon_name, entry_data in data.items(): + if isinstance(entry_data, dict): + self._entries[addon_name] = AddonConfigEntry.from_dict( + entry_data + ) + elif isinstance(entry_data, str): + self._entries[addon_name] = AddonConfigEntry( + state=entry_data, version=None + ) + logger.debug(f"Loaded addon config from {self.config_file}") + except (OSError, yaml.YAMLError) as e: + logger.warning(f"Failed to load addon config: {e}") + self._entries = {} + + def save(self): + """Save addon configuration to the config file.""" + try: + if self._entries: + self.config_file.parent.mkdir(parents=True, exist_ok=True) + data = { + name: entry.to_dict() + for name, entry in self._entries.items() + } + with open(self.config_file, "w") as f: + yaml.dump(data, f, default_flow_style=False) + elif self.config_file.exists(): + self.config_file.unlink() + logger.debug(f"Saved addon config to {self.config_file}") + except OSError as e: + logger.error(f"Failed to save addon config: {e}") + + def get_state(self, addon_name: str, default: str | None = None) -> str: + """ + Get the state of an addon. + + Args: + addon_name: The canonical name of the addon. + default: The state to return if no config entry exists. + If None, defaults to ENABLED. + """ + entry = self._entries.get(addon_name) + if entry is None: + return default if default is not None else AddonState.ENABLED + if entry.state not in (AddonState.ENABLED, AddonState.DISABLED): + logger.warning( + f"Invalid state '{entry.state}' for addon '{addon_name}', " + "defaulting to ENABLED" + ) + return AddonState.ENABLED + return entry.state + + def set_state(self, addon_name: str, state: str): + """Set the state of an addon.""" + if state not in (AddonState.ENABLED, AddonState.DISABLED): + raise ValueError(f"Invalid addon state: {state}") + entry = self._entries.get(addon_name) + if entry: + entry.state = state + else: + self._entries[addon_name] = AddonConfigEntry(state=state) + self.save() + logger.info(f"Set addon '{addon_name}' state to '{state}'") + + def get_version(self, addon_name: str) -> str | None: + """Get the stored version string of an addon.""" + entry = self._entries.get(addon_name) + if entry is None: + return None + return entry.version + + def set_version(self, addon_name: str, version: str): + """Set the version of an addon.""" + entry = self._entries.get(addon_name) + if entry: + entry.version = version + else: + self._entries[addon_name] = AddonConfigEntry( + state=AddonState.ENABLED, version=version + ) + self.save() + logger.debug(f"Set addon '{addon_name}' version to '{version}'") + + def set_entry( + self, addon_name: str, state: str, version: str | None = None + ): + """Set both state and version for an addon in one operation.""" + if state not in (AddonState.ENABLED, AddonState.DISABLED): + raise ValueError(f"Invalid addon state: {state}") + self._entries[addon_name] = AddonConfigEntry( + state=state, version=version + ) + self.save() + logger.info( + f"Set addon '{addon_name}' state='{state}', version='{version}'" + ) + + def remove_state(self, addon_name: str): + """Remove an addon's configuration entry.""" + if addon_name in self._entries: + del self._entries[addon_name] + self.save() + logger.info(f"Removed addon '{addon_name}' from config") + + def get_entry(self, addon_name: str) -> AddonConfigEntry | None: + """Get the full configuration entry for an addon.""" + return self._entries.get(addon_name) diff --git a/rayforge/core/ai/__init__.py b/rayforge/core/ai/__init__.py new file mode 100644 index 000000000..af96d026d --- /dev/null +++ b/rayforge/core/ai/__init__.py @@ -0,0 +1,21 @@ +from .ai_service import AIService +from .config import AIConfigManager +from .provider import ( + AIProvider, + AIProviderConfig, + AIProviderType, + AIServiceError, + ChatMessage, + ChatResponse, +) + +__all__ = [ + "AIConfigManager", + "AIProvider", + "AIProviderConfig", + "AIProviderType", + "AIService", + "AIServiceError", + "ChatMessage", + "ChatResponse", +] diff --git a/rayforge/core/ai/ai_service.py b/rayforge/core/ai/ai_service.py new file mode 100644 index 000000000..49a20cc26 --- /dev/null +++ b/rayforge/core/ai/ai_service.py @@ -0,0 +1,239 @@ +import logging + +from blinker import Signal + +from .openai_provider import OpenAICompatibleProvider +from .provider import ( + AIProvider, + AIProviderConfig, + AIProviderType, + ChatResponse, +) + +logger = logging.getLogger(__name__) + + +class AIService: + """ + Central service for AI operations. + + Manages multiple AI providers and exposes a simple interface for addons. + Addons should use this service rather than configuring providers directly. + """ + + def __init__(self): + self._providers: dict[str, AIProvider] = {} + self._configs: dict[str, AIProviderConfig] = {} + self._default_provider_id: str | None = None + self.changed = Signal() + + @property + def providers(self) -> dict[str, AIProviderConfig]: + """Return a copy of provider configs.""" + return dict(self._configs) + + @property + def default_provider_id(self) -> str | None: + """Return the ID of the default provider.""" + return self._default_provider_id + + @default_provider_id.setter + def default_provider_id(self, value: str | None): + if value and value not in self._configs: + raise ValueError(f"Unknown provider: {value}") + self._default_provider_id = value + self.changed.send(self) + + def add_provider(self, config: AIProviderConfig) -> AIProvider: + """ + Register a new AI provider. + + Args: + config: Provider configuration. + + Returns: + The created provider instance. + """ + if config.id in self._providers: + raise ValueError(f"Provider already exists: {config.id}") + + provider = self._create_provider(config) + self._providers[config.id] = provider + self._configs[config.id] = config + + if self._default_provider_id is None: + self._default_provider_id = config.id + + self.changed.send(self) + logger.info(f"Added AI provider: {config.name} ({config.id})") + return provider + + def update_provider(self, config: AIProviderConfig) -> AIProvider: + """ + Update an existing provider's configuration. + + Args: + config: New provider configuration. + + Returns: + The recreated provider instance. + """ + if config.id not in self._providers: + raise ValueError(f"Provider not found: {config.id}") + + old_provider = self._providers[config.id] + import asyncio + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.create_task(old_provider.close()) + else: + loop.run_until_complete(old_provider.close()) + except Exception: + logger.debug("Failed to close old AI provider", exc_info=True) + + provider = self._create_provider(config) + self._providers[config.id] = provider + self._configs[config.id] = config + self.changed.send(self) + logger.info(f"Updated AI provider: {config.name} ({config.id})") + return provider + + def remove_provider(self, provider_id: str): + """ + Remove a provider. + + Args: + provider_id: ID of the provider to remove. + """ + if provider_id not in self._providers: + raise ValueError(f"Provider not found: {provider_id}") + + provider = self._providers[provider_id] + import asyncio + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.create_task(provider.close()) + else: + loop.run_until_complete(provider.close()) + except Exception: + logger.debug("Failed to close AI provider", exc_info=True) + + del self._providers[provider_id] + del self._configs[provider_id] + + if self._default_provider_id == provider_id: + remaining = list(self._configs.keys()) + self._default_provider_id = remaining[0] if remaining else None + + self.changed.send(self) + logger.info(f"Removed AI provider: {provider_id}") + + def _create_provider(self, config: AIProviderConfig) -> AIProvider: + if config.provider_type == AIProviderType.OPENAI_COMPATIBLE: + return OpenAICompatibleProvider(config) + raise ValueError(f"Unknown provider type: {config.provider_type}") + + def get_provider( + self, provider_id: str | None = None + ) -> AIProvider | None: + """ + Get a provider by ID, or the default provider. + + Args: + provider_id: Specific provider ID, or None for default. + + Returns: + Provider instance if found and enabled, else None. + """ + pid = provider_id or self._default_provider_id + if pid and pid in self._providers: + config = self._configs[pid] + if config.enabled: + return self._providers[pid] + return None + + def get_config(self, provider_id: str) -> AIProviderConfig | None: + """Get a provider configuration by ID.""" + return self._configs.get(provider_id) + + async def chat( + self, messages: list, provider_id: str | None = None, **kwargs + ) -> ChatResponse | None: + """ + Send a chat request using the specified or default provider. + + Args: + messages: List of ChatMessage objects. + provider_id: Specific provider ID, or None for default. + **kwargs: Additional arguments passed to the provider. + + Returns: + ChatResponse if successful, None if no provider available. + """ + provider = self.get_provider(provider_id) + if provider is None: + return None + return await provider.chat(messages, **kwargs) + + async def chat_stream( + self, messages: list, provider_id: str | None = None, **kwargs + ): + """ + Stream a chat response using the specified or default provider. + + Args: + messages: List of ChatMessage objects. + provider_id: Specific provider ID, or None for default. + **kwargs: Additional arguments passed to the provider. + + Yields: + Content chunks as they arrive. + """ + provider = self.get_provider(provider_id) + if provider is None: + return + async for chunk in provider.chat_stream(messages, **kwargs): + yield chunk + + def load_from_config(self, data: dict): + """ + Load providers from persisted configuration. + + Args: + data: Dictionary with "providers" list and "default_provider". + """ + providers_data = data.get("providers", []) + for provider_data in providers_data: + try: + config = AIProviderConfig.from_dict(provider_data) + self.add_provider(config) + except ValueError as e: + logger.error(f"Failed to load provider: {e}") + + default_id = data.get("default_provider") + if default_id and default_id in self._configs: + self._default_provider_id = default_id + + def to_dict(self) -> dict: + """ + Serialize service state for persistence. + + Returns: + Dictionary with providers and default_provider. + """ + return { + "providers": [cfg.to_dict() for cfg in self._configs.values()], + "default_provider": self._default_provider_id, + } + + async def close_all(self): + """Close all provider connections.""" + for provider in self._providers.values(): + try: + await provider.close() + except (OSError, TimeoutError, ValueError) as e: + logger.warning(f"Error closing provider: {e}") diff --git a/rayforge/core/ai/config.py b/rayforge/core/ai/config.py new file mode 100644 index 000000000..64519a756 --- /dev/null +++ b/rayforge/core/ai/config.py @@ -0,0 +1,55 @@ +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from .ai_service import AIService + +logger = logging.getLogger(__name__) + + +class AIConfigManager: + """Manages AI configuration persistence.""" + + def __init__(self, filepath: Path, ai_service: "AIService"): + self.filepath = filepath + self.ai_service = ai_service + self._saving = False + self.ai_service.changed.connect(self._on_service_changed) + + def _on_service_changed(self, sender): + self.save() + + def save(self): + """Save AI configuration to file.""" + if self._saving: + return + + self._saving = True + try: + self.filepath.parent.mkdir(parents=True, exist_ok=True) + data = self.ai_service.to_dict() + with open(self.filepath, "w") as f: + yaml.safe_dump(data, f, default_flow_style=False) + logger.debug(f"Saved AI config to {self.filepath}") + except OSError as e: + logger.error(f"Failed to save AI config: {e}") + finally: + self._saving = False + + def load(self): + """Load AI configuration from file.""" + if not self.filepath.exists(): + logger.debug("AI config file not found, using defaults") + return + + try: + with open(self.filepath, "r") as f: + data = yaml.safe_load(f) + if data: + self.ai_service.load_from_config(data) + logger.info(f"Loaded AI config from {self.filepath}") + except (OSError, yaml.YAMLError) as e: + logger.error(f"Failed to load AI config: {e}") diff --git a/rayforge/core/ai/openai_provider.py b/rayforge/core/ai/openai_provider.py new file mode 100644 index 000000000..0891d94fc --- /dev/null +++ b/rayforge/core/ai/openai_provider.py @@ -0,0 +1,159 @@ +import json +import logging +from collections.abc import AsyncGenerator +from gettext import gettext as _ + +import aiohttp + +from .provider import ( + AIProvider, + AIProviderConfig, + AIServiceError, + ChatMessage, + ChatResponse, + make_api_error_message, +) + +logger = logging.getLogger(__name__) + + +class OpenAICompatibleProvider(AIProvider): + """ + Provider for OpenAI and compatible APIs. + + Supports OpenAI, Ollama, LocalAI, and any OpenAI-compatible endpoint. + """ + + def __init__(self, config: AIProviderConfig): + self.config = config + self._session: aiohttp.ClientSession | None = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + headers = { + "Authorization": f"Bearer {self.config.api_key}", + "Content-Type": "application/json", + } + self._session = aiohttp.ClientSession( + base_url=self.config.base_url, headers=headers + ) + return self._session + + async def chat( + self, + messages: list[ChatMessage], + model: str | None = None, + **kwargs, + ) -> ChatResponse: + session = await self._get_session() + payload = { + "model": model or self.config.default_model, + "messages": [m.to_dict() for m in messages], + **kwargs, + } + + try: + async with session.post("chat/completions", json=payload) as resp: + if resp.status != 200: + text = await resp.text() + raise AIServiceError( + make_api_error_message(resp.status, text) + ) + data = await resp.json() + except aiohttp.ClientError as e: + raise AIServiceError( + _("Connection failed - please check your network") + ) from e + + choice = data["choices"][0] + return ChatResponse( + content=choice["message"]["content"], + model=data["model"], + usage=data.get("usage", {}), + ) + + async def chat_stream( + self, + messages: list[ChatMessage], + model: str | None = None, + **kwargs, + ) -> AsyncGenerator[str, None]: + session = await self._get_session() + payload = { + "model": model or self.config.default_model, + "messages": [m.to_dict() for m in messages], + "stream": True, + **kwargs, + } + + try: + async with session.post("chat/completions", json=payload) as resp: + if resp.status != 200: + text = await resp.text() + raise AIServiceError( + make_api_error_message(resp.status, text) + ) + + buffer = "" + async for line in resp.content: + line = line.decode("utf-8").strip() + if not line or line == "data: [DONE]": + continue + if line.startswith("data: "): + buffer = line[6:] + try: + data = json.loads(buffer) + delta = data["choices"][0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + except ( + json.JSONDecodeError, + KeyError, + IndexError, + ): + continue + except aiohttp.ClientError as e: + raise AIServiceError( + _("Connection failed - please check your network") + ) from e + + async def list_models(self) -> list[str]: + session = await self._get_session() + try: + async with session.get("models") as resp: + if resp.status != 200: + text = await resp.text() + raise AIServiceError( + make_api_error_message(resp.status, text) + ) + data = await resp.json() + return [m["id"] for m in data.get("data", [])] + except aiohttp.ClientError as e: + raise AIServiceError( + _("Connection failed - please check your network") + ) from e + + async def test_connection(self) -> tuple[bool, str]: + try: + models = await self.list_models() + except AIServiceError as e: + return False, str(e) + + model_name = self.config.default_model + if model_name and models and model_name not in models: + return False, _( + "Model '{model}' not found. Available: {available}" + ).format( + model=model_name, + available=", ".join(sorted(models)[:10]), + ) + + if models: + return True, f"Connected. {len(models)} models available." + return True, "Connected (no models listed)." + + async def close(self): + if self._session and not self._session.closed: + await self._session.close() + self._session = None diff --git a/rayforge/core/ai/provider.py b/rayforge/core/ai/provider.py new file mode 100644 index 000000000..28852e6b0 --- /dev/null +++ b/rayforge/core/ai/provider.py @@ -0,0 +1,145 @@ +import json +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field +from enum import Enum +from gettext import gettext as _ + + +class AIServiceError(Exception): + """Raised when AI service encounters an error.""" + + +HTTP_STATUS_MESSAGES = { + 400: _("Bad request"), + 401: _("Authentication failed - please check your API key"), + 403: _("Access forbidden - please check your API key permissions"), + 404: _("API endpoint not found - please check the base URL"), + 429: _("Rate limited - please wait and try again"), + 500: _("Server error - please try again later"), + 502: _("Server error - please try again later"), + 503: _("Service unavailable - please try again later"), +} + + +def extract_api_error(body: str) -> str | None: + """Extract a human-readable error from a JSON API response body.""" + try: + data = json.loads(body) + if isinstance(data, list): + data = data[0] if data else {} + error = data.get("error", data) + if isinstance(error, dict): + return error.get("message") or error.get("status") + return str(error) if error else None + except (json.JSONDecodeError, AttributeError): + return None + + +def make_api_error_message(status: int, body: str) -> str: + """Build a user-friendly error message from an HTTP status and body.""" + generic = HTTP_STATUS_MESSAGES.get( + status, + _("Server returned error {code}").format(code=status), + ) + detail = extract_api_error(body) if body else None + if detail: + return f"{generic}: {detail}" + return generic + + +class AIProviderType(Enum): + OPENAI_COMPATIBLE = "openai_compatible" + + +@dataclass +class AIProviderConfig: + id: str + name: str + provider_type: AIProviderType + api_key: str + base_url: str + default_model: str + enabled: bool = True + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "name": self.name, + "provider_type": self.provider_type.value, + "api_key": self.api_key, + "base_url": self.base_url, + "default_model": self.default_model, + "enabled": self.enabled, + } + + @classmethod + def from_dict(cls, data: dict[str, object]) -> "AIProviderConfig": + return cls( + id=str(data.get("id", "")), + name=str(data.get("name", "")), + provider_type=AIProviderType(str(data.get("provider_type", ""))), + api_key=str(data.get("api_key", "")), + base_url=str(data.get("base_url", "")), + default_model=str(data.get("default_model", "")), + enabled=bool(data.get("enabled", True)), + ) + + +@dataclass +class ChatMessage: + role: str + content: str + + def to_dict(self) -> dict[str, str]: + return {"role": self.role, "content": self.content} + + +@dataclass +class ChatResponse: + content: str + model: str + usage: dict[str, int] = field(default_factory=dict) + + +class AIProvider(ABC): + """Base interface for AI providers.""" + + @abstractmethod + async def chat( + self, + messages: list[ChatMessage], + model: str | None = None, + **kwargs, + ) -> ChatResponse: + """Send a chat completion request.""" + + @abstractmethod + def chat_stream( + self, + messages: list[ChatMessage], + model: str | None = None, + **kwargs, + ) -> AsyncGenerator[str, None]: + """ + Stream chat completion response. + + This is an async generator that yields content chunks. + """ + + @abstractmethod + async def list_models(self) -> list[str]: + """List available models.""" + + @abstractmethod + async def test_connection(self) -> tuple[bool, str]: + """ + Test if the provider is reachable and configured. + + Returns: + Tuple of (success, message) where message describes the result. + """ + + @abstractmethod + async def close(self): + """Close any open connections.""" diff --git a/rayforge/core/ai/spec_lookup.py b/rayforge/core/ai/spec_lookup.py new file mode 100644 index 000000000..234249370 --- /dev/null +++ b/rayforge/core/ai/spec_lookup.py @@ -0,0 +1,213 @@ +"""AI-powered machine specification lookup. + +This module queries the configured AI provider for known machine +specifications by vendor + model, returning a structured dictionary +that the unified machine wizard can present as editable suggestion +chips for hardware dimensions, speeds, and head parameters. + +The lookup is best-effort: any error (no provider configured, network +failure, unparseable response) results in an empty ``{}`` return so +the wizard falls back gracefully to manual entry. +""" + +import json +import logging +import re +from typing import TYPE_CHECKING, Any, Optional + +from ...context import get_context +from . import AIServiceError +from .provider import ChatMessage + +if TYPE_CHECKING: + from ...context import RayforgeContext + +logger = logging.getLogger(__name__) + + +SYSTEM_PROMPT = """You are an expert assistant for CNC, laser, and 3D printer \ +specifications. When asked about a specific machine model, respond ONLY \ +with a single JSON object (no markdown fences, no explanation) following \ +this exact schema: +{ + "axis_extents": [x_mm, y_mm], + "max_travel_speed": mm_per_min, + "max_cut_speed": mm_per_min, + "acceleration": mm_per_s2, + "origin": "bottom_left" | "top_left" | "top_right" | "bottom_right", + "head_type": "laser" | "spindle", + "max_power": int, // S-value for laser heads + "max_rpm": int, // for spindle heads + "min_rpm": int, // for spindle heads + "spot_size_mm": [x, y], // for laser heads + "pwm_frequency": int_hz, // for laser heads + "focal_distance": float_mm, // for laser heads + "home_on_start": bool +} +Omit any field that you do not know with high confidence. Use the \ +manufacturer's official specifications when available.""" + + +def _extract_json_object(content: str) -> dict[str, Any] | None: + """Pull the first balanced JSON object out of an LLM response. + + LLMs occasionally wrap JSON in markdown fences or surrounding + prose even when asked not to. We try a few strategies in order: + plain ``json.loads``, then a fenced-codeblock regex, then a + naive brace-matching scan. + """ + content = content.strip() + if not content: + return None + + try: + result = json.loads(content) + if isinstance(result, dict): + return result + except json.JSONDecodeError: + pass + + code_block = re.search( + r"```(?:json)?\s*\n(.*?)\n```", content, re.DOTALL | re.IGNORECASE + ) + if code_block: + try: + result = json.loads(code_block.group(1).strip()) + if isinstance(result, dict): + return result + except json.JSONDecodeError: + pass + + first = content.find("{") + if first < 0: + return None + depth = 0 + in_string = False + escape = False + for i in range(first, len(content)): + ch = content[i] + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + try: + result = json.loads(content[first : i + 1]) + if isinstance(result, dict): + return result + except json.JSONDecodeError: + return None + return None + + +def _coerce_specs(raw: dict[str, Any]) -> dict[str, Any]: + """Normalize the parsed JSON into wizard-consumable fields. + + Drops keys with ``None`` values and converts numeric strings to + floats/ints. Leaves unknown keys intact so the wizard can decide + what to surface — callers consume defensively. + """ + out: dict[str, Any] = {} + for key, value in raw.items(): + if value is None: + continue + if isinstance(value, str): + stripped = value.strip() + if not stripped: + continue + try: + if "." in stripped: + coerced: Any = float(stripped) + else: + coerced = int(stripped) + except ValueError: + coerced = stripped + else: + coerced = value + out[key] = coerced + return out + + +async def lookup_machine_specs( + vendor: str, + model: str, + context: Optional["RayforgeContext"] = None, +) -> dict[str, Any]: + """Query the AI for machine specifications. + + Args: + vendor: Manufacturer name (e.g. "Sculpfun"). + model: Machine model (e.g. "S30 Pro"). + context: RayforgeContext. When None, ``get_context()`` is + used. The default AI provider is queried. + + Returns: + A normalized dict of spec fields that the wizard knows how + to surface, or ``{}`` on any error / when no AI provider is + configured. Callers MUST treat an empty dict as "no info" and + fall back to manual entry. + """ + if context is None: + context = get_context() + + ai_service = context.ai_service + if not ai_service.get_provider(): + logger.debug("spec_lookup: no AI provider configured, returning empty") + return {} + + prompt = ( + f"What are the official specifications for the {vendor} " + f"{model} desktop CNC/laser/3D printer? Include work-area " + f"dims in mm, max travel and cut speeds in mm/min, " + f"acceleration, default coordinate origin, head type and " + f"its key parameters." + ) + messages = [ + ChatMessage(role="system", content=SYSTEM_PROMPT), + ChatMessage(role="user", content=prompt), + ] + + try: + response = await ai_service.chat(messages) + except AIServiceError as exc: + logger.info("spec_lookup: AI service error: %s", exc) + return {} + except Exception as exc: + logger.warning( + "spec_lookup: unexpected AI error: %s", exc, exc_info=True + ) + return {} + + if response is None or not response.content: + logger.debug("spec_lookup: empty AI response") + return {} + + parsed = _extract_json_object(response.content) + if parsed is None: + logger.info( + "spec_lookup: could not parse JSON from response: %s", + response.content[:200], + ) + return {} + + return _coerce_specs(parsed) + + +__all__ = ["is_ai_configured", "lookup_machine_specs"] + + +def is_ai_configured(context: Optional["RayforgeContext"] = None) -> bool: + """Return True when a default AI provider is enabled.""" + if context is None: + context = get_context() + return context.ai_service.get_provider() is not None diff --git a/rayforge/core/asset.py b/rayforge/core/asset.py new file mode 100644 index 000000000..338e94ca6 --- /dev/null +++ b/rayforge/core/asset.py @@ -0,0 +1,126 @@ +import uuid +from dataclasses import dataclass, field +from typing import Any, ClassVar, Protocol, runtime_checkable + +from blinker import Signal + + +@runtime_checkable +class IAsset(Protocol): + """ + A protocol defining the common interface for all document assets. + + This allows for structural subtyping (static duck typing), so any class + that provides these properties will be considered an IAsset. + """ + + is_addable: ClassVar[bool] + asset_type_name: ClassVar[str] + display_icon_name: ClassVar[str] + is_reorderable: ClassVar[bool] + is_draggable_to_canvas: ClassVar[bool] + type_display_name: ClassVar[str] + can_edit: ClassVar[bool] + add_action: ClassVar[str | None] + activate_action: ClassVar[str | None] + edit_item_action: ClassVar[str | None] + + @property + def uid(self) -> str: + """The unique identifier of the asset instance.""" + ... + + @property + def name(self) -> str: + """The user-facing name of the asset instance.""" + ... + + @name.setter + def name(self, value: str) -> None: ... + + @property + def updated(self) -> Signal: + """Signal emitted when the asset changes.""" + ... + + def to_dict(self) -> dict[str, Any]: + """Serializes the asset to a dictionary.""" + ... + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "IAsset": + """Deserializes the asset from a dictionary.""" + ... + + @property + def hidden(self) -> bool: + """Indicates if this asset should be hidden from UI.""" + return False + + def get_thumbnail(self, size: int) -> bytes | None: + """ + Returns PNG thumbnail bytes at the given max pixel size, + or None if no thumbnail is available. + """ + return None + + +@dataclass +class UnknownAsset(IAsset): + """Placeholder for unknown asset types during deserialization.""" + + is_addable: ClassVar[bool] = False + asset_type_name: ClassVar[str] = "unknown" + display_icon_name: ClassVar[str] = "question-mark-symbolic" + is_reorderable: ClassVar[bool] = False + is_draggable_to_canvas: ClassVar[bool] = False + type_display_name: ClassVar[str] = "Unknown Asset" + can_edit: ClassVar[bool] = False + add_action: ClassVar[str | None] = None + activate_action: ClassVar[str | None] = None + edit_item_action: ClassVar[str | None] = None + + _original_type: str = field(init=False) + _data: dict[str, Any] = field(init=False, default_factory=dict) + _uid: str = field(init=False, default_factory=lambda: str(uuid.uuid4())) + _name: str = field(init=False) + _updated: Signal = field(init=False, default_factory=Signal) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "UnknownAsset": + """Deserializes a dictionary into an UnknownAsset instance.""" + instance = cls.__new__(cls) + instance._original_type = data.get("type", "unknown") + instance._data = dict(data) + instance._uid = instance._data.get("uid", str(uuid.uuid4())) + default_name = f"Unknown ({instance._original_type})" + instance._name = instance._data.get("name", default_name) + return instance + + @property + def uid(self) -> str: + """The unique identifier of the asset instance.""" + return self._uid + + @property + def updated(self) -> Signal: + return self._updated + + @property + def name(self) -> str: + """The user-facing name of the asset instance.""" + return self._name + + @name.setter + def name(self, value: str) -> None: + """Sets the asset name.""" + self._name = value + self._data["name"] = value + + def to_dict(self) -> dict[str, Any]: + """Serializes UnknownAsset to the original dictionary.""" + return self._data + + def get_thumbnail(self, size: int) -> bytes | None: + """No thumbnail available for unknown assets.""" + return None diff --git a/rayforge/core/asset_registry.py b/rayforge/core/asset_registry.py new file mode 100644 index 000000000..2bc20b676 --- /dev/null +++ b/rayforge/core/asset_registry.py @@ -0,0 +1,120 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .asset import IAsset + + +class AssetTypeRegistry: + """ + Registry for IAsset classes. + + Allows explicit registration of asset types for lookup by type name. + This enables dynamic asset deserialization and supports addon-provided + asset types. + """ + + def __init__(self): + self._types: dict[str, type[IAsset]] = {} + self._addon_items: dict[str, set[str]] = {} + self._builtins_registered: bool = False + + def _register_builtins(self) -> None: + """ + Register built-in asset types. + + Called automatically on first access to ensure core assets + are available even without full context initialization. + """ + if self._builtins_registered: + return + + from .source_asset import SourceAsset + from .stock_asset import StockAsset + + self._types["stock"] = StockAsset + self._types["source"] = SourceAsset + self._builtins_registered = True + + def register( + self, + asset_class: type["IAsset"], + type_name: str, + addon_name: str | None = None, + ) -> None: + """ + Register an asset class. + + Args: + asset_class: The IAsset subclass to register. + type_name: The type name for serialization (e.g., "sketch"). + addon_name: Optional name of the addon registering this asset. + Used for cleanup when addon is unloaded. + """ + self._types[type_name] = asset_class + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(type_name) + + def unregister(self, type_name: str) -> bool: + """ + Unregister an asset class by type name. + + Args: + type_name: The type name of the asset to unregister. + + Returns: + True if the asset was unregistered, False if not found. + """ + if type_name in self._types: + del self._types[type_name] + for items in self._addon_items.values(): + items.discard(type_name) + return True + return False + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all assets registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of assets unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for type_name in items: + if type_name in self._types: + del self._types[type_name] + count += 1 + return count + + def get(self, type_name: str) -> type["IAsset"] | None: + """ + Look up an asset class by type name. + + Args: + type_name: The type name of the asset. + + Returns: + The asset class, or None if not found. + """ + self._register_builtins() + return self._types.get(type_name) + + def all_types(self) -> dict[str, type["IAsset"]]: + """ + Return a copy of all registered asset types. + + Returns: + Dictionary mapping type names to asset classes. + """ + self._register_builtins() + return self._types.copy() + + +asset_type_registry = AssetTypeRegistry() diff --git a/rayforge/core/capability.py b/rayforge/core/capability.py new file mode 100644 index 000000000..f720ad053 --- /dev/null +++ b/rayforge/core/capability.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import enum +from gettext import gettext as _ + + +class MachineCapability(enum.Enum): + """ + Hardware capabilities of a machine (e.g., LASER, MILL). + + These describe what the machine's hardware can do and are used to + filter which steps are offered to the user. + """ + + LASER = "LASER" + MILL = "MILL" + PWM = "PWM" + ROTARY = "ROTARY" + # Future: PROBE, DWELL, ... + + @property + def label(self) -> str: + """User-facing label for this capability.""" + return _MACHINE_CAPABILITY_LABELS[self] + + @property + def description(self) -> str: + """User-facing description for this capability.""" + return _MACHINE_CAPABILITY_DESCRIPTIONS[self] + + +_MACHINE_CAPABILITY_LABELS = { + MachineCapability.LASER: _("Laser"), + MachineCapability.MILL: _("Mill"), + MachineCapability.PWM: _("PWM"), + MachineCapability.ROTARY: _("Rotary"), +} + +_MACHINE_CAPABILITY_DESCRIPTIONS = { + MachineCapability.LASER: _("Cutting and engraving with a laser"), + MachineCapability.MILL: _("Milling and routing with a spindle"), + MachineCapability.PWM: _("Pulse-width-modulated laser power control"), + MachineCapability.ROTARY: _( + "Rotary axis attachment for cylindrical objects" + ), +} diff --git a/rayforge/core/color.py b/rayforge/core/color.py new file mode 100644 index 000000000..8752c0e32 --- /dev/null +++ b/rayforge/core/color.py @@ -0,0 +1,377 @@ +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +logger = logging.getLogger(__name__) + +# A fully resolved, render-ready RGBA color. +ColorRGBA = tuple[float, float, float, float] + +ColorAtom = ( + str | tuple[float, float, float] | tuple[float, float, float, float] +) +ColorSpec = ColorAtom | tuple[ColorAtom, float] +GradientSpec = tuple[ColorSpec, ColorSpec] +ColorSpecDict = dict[str, ColorSpec | GradientSpec] + +OPS_COLOR_SPEC: ColorSpecDict = { + "cut": ("#ffeeff", "#ff00ff"), + "engrave": ("#FFFFFF", "#000000"), + "travel": ("#FF6600", 0.7), + "zero_power": ("@accent_color", 0.5), +} + + +def hex_to_rgba(hex_color: str) -> ColorRGBA: + """Convert a hex color string to an RGBA tuple.""" + hex_color = hex_color.lstrip("#") + if len(hex_color) == 6: + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + return (r, g, b, 1.0) + elif len(hex_color) == 8: + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + a = int(hex_color[6:8], 16) / 255.0 + return (r, g, b, a) + else: + raise ValueError(f"Invalid hex color: {hex_color}") + + +_CSS_NAMED_COLORS: dict[str, str] = { + "aliceblue": "f0f8ff", + "antiquewhite": "faebd7", + "aqua": "00ffff", + "aquamarine": "7fffd4", + "azure": "f0ffff", + "beige": "f5f5dc", + "bisque": "ffe4c4", + "black": "000000", + "blanchedalmond": "ffebcd", + "blue": "0000ff", + "blueviolet": "8a2be2", + "brown": "a52a2a", + "burlywood": "deb887", + "cadetblue": "5f9ea0", + "chartreuse": "7fff00", + "chocolate": "d2691e", + "coral": "ff7f50", + "cornflowerblue": "6495ed", + "cornsilk": "fff8dc", + "crimson": "dc143c", + "cyan": "00ffff", + "darkblue": "00008b", + "darkcyan": "008b8b", + "darkgoldenrod": "b8860b", + "darkgray": "a9a9a9", + "darkgreen": "006400", + "darkgrey": "a9a9a9", + "darkkhaki": "bdb76b", + "darkmagenta": "8b008b", + "darkolivegreen": "556b2f", + "darkorange": "ff8c00", + "darkorchid": "9932cc", + "darkred": "8b0000", + "darksalmon": "e9967a", + "darkseagreen": "8fbc8f", + "darkslateblue": "483d8b", + "darkslategray": "2f4f4f", + "darkslategrey": "2f4f4f", + "darkturquoise": "00ced1", + "darkviolet": "9400d3", + "deeppink": "ff1493", + "deepskyblue": "00bfff", + "dimgray": "696969", + "dimgrey": "696969", + "dodgerblue": "1e90ff", + "firebrick": "b22222", + "floralwhite": "fffaf0", + "forestgreen": "228b22", + "fuchsia": "ff00ff", + "gainsboro": "dcdcdc", + "ghostwhite": "f8f8ff", + "gold": "ffd700", + "goldenrod": "daa520", + "gray": "808080", + "grey": "808080", + "green": "008000", + "greenyellow": "adff2f", + "honeydew": "f0fff0", + "hotpink": "ff69b4", + "indianred": "cd5c5c", + "indigo": "4b0082", + "ivory": "fffff0", + "khaki": "f0e68c", + "lavender": "e6e6fa", + "lavenderblush": "fff0f5", + "lawngreen": "7cfc00", + "lemonchiffon": "fffacd", + "lightblue": "add8e6", + "lightcoral": "f08080", + "lightcyan": "e0ffff", + "lightgoldenrodyellow": "fafad2", + "lightgray": "d3d3d3", + "lightgreen": "90ee90", + "lightgrey": "d3d3d3", + "lightpink": "ffb6c1", + "lightsalmon": "ffa07a", + "lightseagreen": "20b2aa", + "lightskyblue": "87cefa", + "lightslategray": "778899", + "lightslategrey": "778899", + "lightsteelblue": "b0c4de", + "lightyellow": "ffffe0", + "lime": "00ff00", + "limegreen": "32cd32", + "linen": "faf0e6", + "magenta": "ff00ff", + "maroon": "800000", + "mediumaquamarine": "66cdaa", + "mediumblue": "0000cd", + "mediumorchid": "ba55d3", + "mediumpurple": "9370db", + "mediumseagreen": "3cb371", + "mediumslateblue": "7b68ee", + "mediumspringgreen": "00fa9a", + "mediumturquoise": "48d1cc", + "mediumvioletred": "c71585", + "midnightblue": "191970", + "mintcream": "f5fffa", + "mistyrose": "ffe4e1", + "moccasin": "ffe4b5", + "navajowhite": "ffdead", + "navy": "000080", + "oldlace": "fdf5e6", + "olive": "808000", + "olivedrab": "6b8e23", + "orange": "ffa500", + "orangered": "ff4500", + "orchid": "da70d6", + "palegoldenrod": "eee8aa", + "palegreen": "98fb98", + "paleturquoise": "afeeee", + "palevioletred": "db7093", + "papayawhip": "ffefd5", + "peachpuff": "ffdab9", + "peru": "cd853f", + "pink": "ffc0cb", + "plum": "dda0dd", + "powderblue": "b0e0e6", + "purple": "800080", + "rebeccapurple": "663399", + "red": "ff0000", + "rosybrown": "bc8f8f", + "royalblue": "4169e1", + "saddlebrown": "8b4513", + "salmon": "fa8072", + "sandybrown": "f4a460", + "seagreen": "2e8b57", + "seashell": "fff5ee", + "sienna": "a0522d", + "silver": "c0c0c0", + "skyblue": "87ceeb", + "slateblue": "6a5acd", + "slategray": "708090", + "slategrey": "708090", + "snow": "fffafa", + "springgreen": "00ff7f", + "steelblue": "4682b4", + "tan": "d2b48c", + "teal": "008080", + "thistle": "d8bfd8", + "tomato": "ff6347", + "turquoise": "40e0d0", + "violet": "ee82ee", + "wheat": "f5deb3", + "white": "ffffff", + "whitesmoke": "f5f5f5", + "yellow": "ffff00", + "yellowgreen": "9acd32", +} + +_HEX_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") +_RGB_RE = re.compile( + r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*[\d.]+)?\s*\)$" +) + + +def normalize_color(color: str | None) -> str | None: + """ + Normalize a color string to a canonical lowercase 6-digit hex value. + + Accepts ``#rrggbb``, ``#RGB``, ``#rrggbbaa`` (alpha dropped), CSS + color names and ``rgb(...)`` strings. + + Args: + color: The color string to normalize. + + Returns: + The normalized ``#rrggbb`` value, or ``None`` for empty or + unresolvable input. + """ + if not color: + return None + value = color.strip() + if not value: + return None + + m = _HEX_RE.match(value) + if m: + h = m.group(1).lower() + if len(h) == 3: + h = "".join(c * 2 for c in h) + return f"#{h[:6]}" + + m = _RGB_RE.match(value) + if m: + r, g, b = (min(int(m.group(i)), 255) for i in (1, 2, 3)) + return f"#{r:02x}{g:02x}{b:02x}" + + name = value.lower().replace(" ", "") + hexval = _CSS_NAMED_COLORS.get(name) + if hexval is not None: + return f"#{hexval}" + + return None + + +@dataclass(frozen=True) +class ColorSet: + """ + A generic, UI-agnostic container for resolved, render-ready color data. + It holds pre-calculated lookup tables (LUTs) and RGBA tuples, accessed by + name. + + This object is immutable and thread-safe. + """ + + _data: dict[str, Any] = field(default_factory=dict) + + def get_lut(self, name: str) -> np.ndarray: + """ + Gets a pre-calculated 256x4 color lookup table (LUT) by name. + Returns a default magenta LUT if not found or invalid. + """ + lut = self._data.get(name) + if isinstance(lut, np.ndarray) and lut.shape == (256, 4): + return lut + + logger.warning( + f"LUT '{name}' not found or invalid in ColorSet. " + f"Returning default." + ) + # Create a magenta LUT to indicate a missing color + default_lut = np.zeros((256, 4), dtype=np.float32) + default_lut[:, 0] = 1.0 # R + default_lut[:, 2] = 1.0 # B + default_lut[:, 3] = 1.0 # A + return default_lut + + def get_lut_argb32(self, name: str) -> np.ndarray: + """ + Gets the named LUT as a 256×4 ``np.uint8`` array in **pre-multiplied + ARGB32** order (``[B×α, G×α, R×α, A]``), ready for + :class:`~raygeo.ops.convert.ViewSpec`. + """ + lut = self.get_lut(name) + argb32 = np.empty((256, 4), dtype=np.uint8) + argb32[:, 0] = np.clip(lut[:, 2] * lut[:, 3] * 255 + 0.5, 0, 255) + argb32[:, 1] = np.clip(lut[:, 1] * lut[:, 3] * 255 + 0.5, 0, 255) + argb32[:, 2] = np.clip(lut[:, 0] * lut[:, 3] * 255 + 0.5, 0, 255) + argb32[:, 3] = np.clip(lut[:, 3] * 255 + 0.5, 0, 255) + return argb32 + + def get_rgba(self, name: str) -> ColorRGBA: + """ + Gets a resolved RGBA color tuple by name. + Returns a default magenta color if the name is not found. + """ + rgba = self._data.get(name) + if isinstance(rgba, tuple) and len(rgba) == 4: + return rgba + if isinstance(rgba, np.ndarray) and rgba.shape == (256, 4): + return tuple(rgba[255]) + + logger.warning( + f"RGBA color '{name}' not found or invalid in ColorSet. " + f"Returning default." + ) + return 1.0, 0.0, 1.0, 1.0 + + def get_argb32(self, name: str) -> list: + """ + Gets a named colour as a 4-element byte list in **pre-multiplied + ARGB32** order (``[B×α, G×α, R×α, A]``), ready for + :class:`~raygeo.ops.convert.ViewSpec`. + + Returns a magenta fallback when the name is missing. + """ + r, g, b, a = self.get_rgba(name) + return [ + round(b * a * 255), + round(g * a * 255), + round(r * a * 255), + round(a * 255), + ] + + def __repr__(self) -> str: + keys = sorted(self._data.keys()) + return f"ColorSet(keys={keys})" + + def to_dict(self) -> dict[str, Any]: + """Serializes the ColorSet to a dictionary.""" + serialized_data: dict[str, Any] = {} + for key, value in self._data.items(): + if isinstance(value, np.ndarray): + serialized_data[key] = { + "__type__": "numpy", + "data": value.tolist(), + "dtype": str(value.dtype), + } + else: + serialized_data[key] = {"__type__": "tuple", "data": value} + return {"_data": serialized_data} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ColorSet": + """Deserializes a ColorSet from a dictionary.""" + deserialized_data: dict[str, Any] = {} + source_data = data.get("_data", data) # Handle both formats + for key, value in source_data.items(): + if isinstance(value, dict) and "__type__" in value: + if value["__type__"] == "numpy": + deserialized_data[key] = np.array( + value["data"], dtype=value["dtype"] + ) + else: + deserialized_data[key] = tuple(value["data"]) + else: + deserialized_data[key] = value # Assume raw data for test + return cls(_data=deserialized_data) + + +COLOR_PALETTE = [ + "#00ccff", + "#ff6600", + "#33cc33", + "#ffcc00", + "#cc3366", + "#66cccc", + "#ff9999", + "#9966ff", + "#00cc99", +] + + +def pick_unused_color(used_colors: set) -> str: + """Return the first color from COLOR_PALETTE not in used_colors.""" + normalized = {c.upper() for c in used_colors} + for color in COLOR_PALETTE: + if color.upper() not in normalized: + return color + return COLOR_PALETTE[0] diff --git a/rayforge/core/color_preset.py b/rayforge/core/color_preset.py new file mode 100644 index 000000000..e68084aca --- /dev/null +++ b/rayforge/core/color_preset.py @@ -0,0 +1,193 @@ +""" +Color rules: map SVG colors to step types at import time. + +A :class:`ColorPreset` assigns a step class (e.g. ``ContourStep``, +``EngraveStep``, or any addon-provided step) to a normalized hex color. +The SVG importer resolves these during vectorization and the resulting +layer is given the corresponding step type, after which the regular +recipe matching applies its settings. +""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from .. import config as rf_config +from .color import normalize_color + +logger = logging.getLogger(__name__) + + +@dataclass +class ColorPreset: + """ + A single color rule: a color maps to a step class name. + + The step class name refers to a class registered in + ``step_registry``. It may reference a step that is currently + unregistered (e.g. its addon is uninstalled); importing tolerates + this by falling back to the default behavior. + """ + + color: str + step_type: str + label: str = "" + uid: str = field(default_factory=lambda: str(uuid.uuid4())) + + def to_dict(self) -> dict[str, Any]: + """Serializes the preset to a dictionary suitable for YAML.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ColorPreset: + """Deserializes a preset from a dictionary.""" + return cls( + color=data.get("color", ""), + step_type=data.get("step_type", ""), + label=data.get("label", ""), + uid=data.get("uid", str(uuid.uuid4())), + ) + + +class ColorPresetManager: + """ + Manages loading and saving ColorPreset objects from a directory. + + Presets are keyed by normalized color; a color maps to at most one + preset. The YAML file is only created when a preset is first saved. + """ + + def __init__(self, base_dir: Path): + self.base_dir = base_dir + self._presets_by_color: dict[str, ColorPreset] = {} + self.load() + + @property + def _presets_file(self) -> Path: + return self.base_dir / "color_presets.yaml" + + def load(self) -> None: + """Loads all presets from the presets file.""" + self._presets_by_color.clear() + if not self._presets_file.exists(): + return + try: + with open(self._presets_file, "r") as f: + data = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as e: + logger.error(f"Failed to load color presets: {e}") + return + if not isinstance(data, dict): + logger.warning("Color presets file has an invalid structure.") + return + for color, entry in data.items(): + if not isinstance(entry, dict): + continue + preset = ColorPreset.from_dict(entry) + preset.color = color + self._presets_by_color[color] = preset + logger.debug(f"Loaded {len(self._presets_by_color)} color presets.") + + def _save(self) -> None: + """Persists all presets to the presets file.""" + self.base_dir.mkdir(parents=True, exist_ok=True) + data = { + color: preset.to_dict() + for color, preset in self._presets_by_color.items() + } + try: + with open(self._presets_file, "w") as f: + yaml.safe_dump(data, f, sort_keys=True) + except (OSError, yaml.YAMLError) as e: + logger.error(f"Failed to save color presets: {e}") + + def add_preset(self, preset: ColorPreset) -> None: + """ + Adds or replaces a preset for its color. + + The color is normalized before storage. Replacing an existing + preset for the same color preserves nothing from the old one. + + Args: + preset: The preset to store. + """ + normalized = normalize_color(preset.color) + if not normalized: + logger.warning( + f"Not adding color preset: unreadable color '{preset.color}'." + ) + return + preset.color = normalized + self._presets_by_color[normalized] = preset + self._save() + + def delete_preset(self, color: str) -> bool: + """ + Deletes the preset for a given color. + + Args: + color: The color to remove (normalized before lookup). + + Returns: + True if a preset was removed, False otherwise. + """ + normalized = normalize_color(color) + if normalized and normalized in self._presets_by_color: + del self._presets_by_color[normalized] + self._save() + return True + return False + + def get_preset(self, color: str) -> ColorPreset | None: + """ + Returns the preset for a color, or None if none matches. + + Args: + color: The color to look up (normalized before lookup). + + Returns: + The matching preset, or None. + """ + normalized = normalize_color(color) + if not normalized: + return None + return self._presets_by_color.get(normalized) + + def all_presets(self) -> list[ColorPreset]: + """Returns a list of all stored presets.""" + return list(self._presets_by_color.values()) + + +_color_preset_mgr_instance: ColorPresetManager | None = None + + +def get_color_preset_mgr() -> ColorPresetManager: + """ + Returns the process-wide ColorPresetManager, creating it on first use. + + The manager is backed by the user color presets directory and is + shared between the import pipeline and the settings UI. + """ + global _color_preset_mgr_instance + if _color_preset_mgr_instance is None: + _color_preset_mgr_instance = ColorPresetManager( + rf_config.USER_COLOR_PRESETS_DIR + ) + return _color_preset_mgr_instance + + +def reset_color_preset_mgr() -> None: + """ + Resets the process-wide ColorPresetManager singleton. + + Intended for tests that need a fresh manager against a different + directory. + """ + global _color_preset_mgr_instance + _color_preset_mgr_instance = None diff --git a/rayforge/core/config.py b/rayforge/core/config.py new file mode 100644 index 000000000..fb7b3b72a --- /dev/null +++ b/rayforge/core/config.py @@ -0,0 +1,411 @@ +import logging +from dataclasses import dataclass, fields +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml +from blinker import Signal + +from ..machine.models.machine import Machine + +logger = logging.getLogger(__name__) + + +class OpsColorMode(Enum): + """Enum for ops color source options.""" + + LASER = "laser" + LAYER = "layer" + + +class StartupBehavior(Enum): + """Enum for application startup behavior options.""" + + NONE = "none" + LAST_PROJECT = "last_project" + SPECIFIC_PROJECT = "specific_project" + + +@dataclass +class CanvasViewState: + """Persistent view toggle states for the 2D/3D canvases.""" + + show_workpieces: bool = True + show_camera: bool = True + show_travel_lines: bool = False + show_nogo_zones: bool = True + show_grid: bool = True + show_models: bool = True + show_tabs: bool = True + perspective_mode: bool = False + + def to_dict(self) -> dict[str, bool]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CanvasViewState": + valid_keys = {f.name for f in fields(cls)} + filtered = {k: v for k, v in data.items() if k in valid_keys} + return cls(**filtered) + + +class Config: + def __init__(self): + self.machine: Machine | None = None + self.theme: str = "system" + # Default user preferences for units. Key is quantity, value is + # unit name. + self.unit_preferences: dict[str, str] = { + "length": "mm", + "speed": "mm/min", + "acceleration": "mm/s²", + } + # Startup behavior: "none", "last_project", or "specific_project" + self.startup_behavior: str = StartupBehavior.NONE.value + # Path to the specific project to open on startup (when + # startup_behavior is SPECIFIC_PROJECT) + self.startup_project_path: Path | None = None + # Track the last opened project path + self.last_opened_project: Path | None = None + # UI visibility states + self.bottom_panel: dict[str, Any] | None = None + self.right_panel_visible: bool = True + self.canvas_view: CanvasViewState = CanvasViewState() + self.auto_pipeline: bool = True + self.ops_color_mode: OpsColorMode = OpsColorMode.LASER + self.check_for_app_updates: 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 + # Default DPI for unitless SVG imports + self.import_dpi: float = 96.0 + # Cache budget for the raygeo pipeline (default 2 GiB) + self.cache_budget_bytes: int = 2 * 1024 * 1024 * 1024 + # Language preference: None = system default, or a code like "de" + self.language: str | None = None + self.changed = Signal() + + def set_machine(self, machine: Machine | None): + if self.machine == machine: + return + if self.machine: + self.machine.changed.disconnect(self.changed.send) + self.machine = machine + self.changed.send(self) + if self.machine: + self.machine.changed.connect(self.changed.send) + + def set_theme(self, theme: str): + """Sets the application theme preference.""" + if self.theme == theme: + return + self.theme = theme + self.changed.send(self) + + def set_unit_preference(self, quantity: str, unit_name: str): + """Sets the user's preferred display unit for a quantity.""" + if self.unit_preferences.get(quantity) == unit_name: + return + self.unit_preferences[quantity] = unit_name + self.changed.send(self) + + def set_startup_behavior(self, behavior: StartupBehavior): + """Sets the startup behavior preference.""" + behavior_value = behavior.value + if self.startup_behavior == behavior_value: + return + self.startup_behavior = behavior_value + self.changed.send(self) + + def set_startup_project_path(self, path: Path | None): + """Sets the specific project path to open on startup.""" + if self.startup_project_path == path: + return + self.startup_project_path = path + self.changed.send(self) + + def set_last_opened_project(self, path: Path | None): + """Sets the last opened project path.""" + if self.last_opened_project == path: + return + self.last_opened_project = path + self.changed.send(self) + + def set_bottom_panel(self, data: dict[str, Any] | None): + if self.bottom_panel == data: + return + self.bottom_panel = data + self.changed.send(self) + + def set_right_panel_visible(self, visible: bool): + """Sets the right panel visibility state.""" + if self.right_panel_visible == visible: + return + self.right_panel_visible = visible + self.changed.send(self) + + def set_import_dpi(self, dpi: float): + """Sets the default DPI for unitless SVG imports.""" + if self.import_dpi == dpi: + return + self.import_dpi = dpi + self.changed.send(self) + + def set_cache_budget_bytes(self, budget: int): + """Sets the pipeline cache budget in bytes.""" + if self.cache_budget_bytes == budget: + return + self.cache_budget_bytes = budget + self.changed.send(self) + + def set_auto_pipeline(self, enabled: bool): + """Sets whether the pipeline recalculates automatically.""" + if self.auto_pipeline == enabled: + return + self.auto_pipeline = enabled + self.changed.send(self) + + def set_check_for_app_updates(self, enabled: bool): + """Sets whether to check for application updates on startup.""" + if self.check_for_app_updates == enabled: + return + self.check_for_app_updates = enabled + self.changed.send(self) + + def set_ops_color_mode(self, mode: OpsColorMode): + """Sets the ops color mode.""" + if self.ops_color_mode == mode: + return + self.ops_color_mode = mode + self.changed.send(self) + + def set_language(self, language: str | None): + """Sets the UI language preference. + + Args: + language: Language code (e.g. "de") or None for system default. + """ + if self.language == language: + return + self.language = language + self.changed.send(self) + + def set_usage_consent(self, consent: bool): + """Sets the usage tracking consent preference.""" + new_value = "" + if consent: + new_value = datetime.now(tz=timezone.utc).isoformat() + if self.usage_consent_date == new_value: + return + self.usage_consent_date = new_value + self.changed.send(self) + + @property + def has_consented_tracking(self) -> bool: + """Returns True if user has consented to usage tracking after + the current policy date.""" + if not self.usage_consent_date or self.usage_consent_date == "": + return False + try: + consent_date = datetime.fromisoformat(self.usage_consent_date) + policy_date = datetime(2026, 2, 24, tzinfo=timezone.utc) + return consent_date >= policy_date + except (ValueError, TypeError): + return False + + @property + def has_declined_tracking(self) -> bool: + """Returns True if user has explicitly declined usage tracking.""" + return self.usage_consent_date == "" + + def to_dict(self) -> dict[str, Any]: + return { + "machine": self.machine.id if self.machine else None, + "theme": self.theme, + "unit_preferences": self.unit_preferences, + "startup_behavior": self.startup_behavior, + "startup_project_path": ( + str(self.startup_project_path) + if self.startup_project_path + else None + ), + "last_opened_project": ( + str(self.last_opened_project) + if self.last_opened_project + else None + ), + "bottom_panel": self.bottom_panel, + "right_panel_visible": self.right_panel_visible, + "canvas_view": self.canvas_view.to_dict(), + "auto_pipeline": self.auto_pipeline, + "check_for_app_updates": self.check_for_app_updates, + "ops_color_mode": self.ops_color_mode.value, + "usage_consent_date": self.usage_consent_date, + "import_dpi": self.import_dpi, + "cache_budget_bytes": self.cache_budget_bytes, + "language": self.language, + } + + @classmethod + def from_dict(cls, data: dict[str, Any], get_machine_by_id) -> "Config": + config = cls() + config.theme = data.get("theme", "system") + + # Load unit preferences, falling back to defaults for safety + default_prefs = { + "length": "mm", + "speed": "mm/min", + "acceleration": "mm/s²", + } + loaded_prefs = data.get("unit_preferences", default_prefs) + # Ensure all default keys are present + default_prefs.update(loaded_prefs) + config.unit_preferences = default_prefs + + # Load startup behavior + default_behavior = StartupBehavior.NONE.value + startup_behavior = data.get("startup_behavior", default_behavior) + try: + StartupBehavior(startup_behavior) + config.startup_behavior = startup_behavior + except ValueError: + logger.warning( + f"Invalid startup behavior in config: {startup_behavior}. " + f"Using default: {default_behavior}" + ) + config.startup_behavior = default_behavior + + # Load startup project path + startup_project_path_str = data.get("startup_project_path") + if startup_project_path_str: + config.startup_project_path = Path(startup_project_path_str) + + # Load last opened project path + last_opened_project_str = data.get("last_opened_project") + if last_opened_project_str: + config.last_opened_project = Path(last_opened_project_str) + + # Load UI visibility states + config.bottom_panel = data.get("bottom_panel", None) + config.right_panel_visible = data.get("right_panel_visible", True) + config.canvas_view = CanvasViewState.from_dict( + data.get("canvas_view", {}) + ) + config.auto_pipeline = data.get("auto_pipeline", True) + config.check_for_app_updates = data.get("check_for_app_updates", True) + + ops_color_mode_str = data.get( + "ops_color_mode", OpsColorMode.LASER.value + ) + try: + config.ops_color_mode = OpsColorMode(ops_color_mode_str) + except ValueError: + config.ops_color_mode = OpsColorMode.LASER + + # Load usage tracking consent date + config.usage_consent_date = data.get("usage_consent_date", None) + + # Load import DPI + config.import_dpi = data.get("import_dpi", 96.0) + + # Load cache budget + config.cache_budget_bytes = data.get( + "cache_budget_bytes", 2 * 1024 * 1024 * 1024 + ) + + # Load language preference (None = system default) + config.language = data.get("language", None) + + # Get the machine by ID. add fallbacks in case the machines + # no longer exist. + machine_id = data.get("machine") + machine = None + if machine_id is not None: + machine = get_machine_by_id(machine_id) + if machine is None: + msg = f"config references unknown machine {machine_id}" + logger.error(msg) + if machine: + config.set_machine(machine) + + return config + + +class ConfigManager: + def __init__(self, filepath: Path, machine_mgr): + self.filepath = filepath + self.machine_mgr = machine_mgr + self.config: Config = Config() + + # Load first, which may trigger 'changed' signals if defaults are set + self.load() + # Connect the auto-save handler *after* loading is complete. + self.config.changed.connect(self._on_config_changed) + # Listen to machine removal to update config if needed + self.machine_mgr.machine_removed.connect(self._on_machine_removed) + + def _on_config_changed(self, sender, **kwargs): + self.save() + + def _on_machine_removed(self, sender, machine_id): + """Handle machine removal by clearing config reference if needed.""" + if self.config.machine and self.config.machine.id == machine_id: + msg = f"Current machine {machine_id} removed, clearing config" + logger.info(msg) + # Clear the machine reference + self.config.set_machine(None) + # If there are other machines available, select the first one + if self.machine_mgr.machines: + # Sort by ID for deterministic selection + first_machine = min( + self.machine_mgr.machines.values(), key=lambda m: m.id + ) + self.config.set_machine(first_machine) + logger.info(f"Selected new machine {first_machine.id}") + + def save(self): + if not self.config: + return + self.filepath.parent.mkdir(parents=True, exist_ok=True) + with open(self.filepath, "w") as f: + yaml.safe_dump(self.config.to_dict(), f) + + def load(self) -> "Config": + if not self.filepath.exists(): + logger.info("Config file does not exist, creating default config.") + self.config = Config() + return self.config + + try: + with open(self.filepath, "r") as f: + data = yaml.safe_load(f) + if not data: + logger.info( + "Config file is empty, creating default config." + ) + self.config = Config() + else: + machine_id = data.get("machine") + logger.info( + f"Loading config with machine_id: {machine_id}" + ) + self.config = Config.from_dict( + data, self.machine_mgr.get_machine_by_id + ) + if self.config.machine: + logger.info( + f"Config loaded with machine: " + f"{self.config.machine.id} " + f"({self.config.machine.name})" + ) + else: + logger.info("Config loaded but no machine set.") + except (OSError, yaml.YAMLError) as e: + logger.error( + f"Failed to load config file: {e}. Creating a default config." + ) + self.config = Config() + + return self.config diff --git a/rayforge/core/cut_side.py b/rayforge/core/cut_side.py new file mode 100644 index 000000000..5f5512bb5 --- /dev/null +++ b/rayforge/core/cut_side.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from enum import Enum, auto +from gettext import gettext as _ + + +class CutSide(Enum): + """Defines which side of a path the cut should be on.""" + + CENTERLINE = auto() + """The center of the beam follows the path directly.""" + INSIDE = auto() + """The final cut will be inside the original path.""" + OUTSIDE = auto() + """The final cut will be outside the original path.""" + + def label(self) -> str: + """Return a translatable label for this cut side.""" + labels = { + self.CENTERLINE: _("Centerline"), + self.INSIDE: _("Inside"), + self.OUTSIDE: _("Outside"), + } + return labels[self] + + +class CutOrder(Enum): + """Defines the processing order for nested paths.""" + + INSIDE_OUTSIDE = auto() + OUTSIDE_INSIDE = auto() + + def label(self) -> str: + """Return a translatable label for this cut order.""" + labels = { + self.INSIDE_OUTSIDE: _("Inside-Outside"), + self.OUTSIDE_INSIDE: _("Outside-Inside"), + } + return labels[self] diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py new file mode 100644 index 000000000..8a1da3d1d --- /dev/null +++ b/rayforge/core/doc.py @@ -0,0 +1,526 @@ +import logging +from collections.abc import Iterable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + TypeVar, + cast, +) + +from blinker import Signal +from raygeo.geo import Geometry + +from ..core.undo import HistoryManager +from .asset import IAsset, UnknownAsset +from .color import COLOR_PALETTE +from .item import DocItem +from .layer import Layer +from .source_asset import SourceAsset +from .workpiece import WorkPiece + +if TYPE_CHECKING: + from .stock import StockItem + from .stock_asset import StockAsset + +logger = logging.getLogger(__name__) + +# For generic type hinting in add_child +T = TypeVar("T", bound="DocItem") + + +class Doc(DocItem): + """ + Represents a loaded Rayforge document. Serves as the root of the + document's object tree. + """ + + def __init__(self): + super().__init__() + self.history_manager = HistoryManager() + self.active_layer_changed = Signal() + self.job_assembly_invalidated = Signal() + + # Asset Management + self.assets: dict[str, IAsset] = {} + self.asset_order: list[str] = [] + + # A new document starts with three empty workpiece layers + for i in range(3): + workpiece_layer = Layer(_("Layer {}").format(i + 1)) + workpiece_layer.color = COLOR_PALETTE[i % len(COLOR_PALETTE)] + self.add_child(workpiece_layer) + + # The new workpiece layer should be active by default + self._active_layer_index: int = 0 + + @classmethod + def from_dict(cls, data: dict) -> "Doc": + """Deserializes the document from a dictionary.""" + from raygeo.geo import Matrix + + from .asset_registry import asset_type_registry + from .source_asset import SourceAsset + from .stock import StockItem + from .stock_asset import StockAsset + + # --- Polymorphic Deserialization Factories --- + item_class_map = {"layer": Layer, "stockitem": StockItem} + + def _deserialize_asset(asset_data: dict) -> IAsset: + asset_type = asset_data.get("type") + if not asset_type: + raise TypeError("Asset data missing 'type' field") + asset_class = asset_type_registry.get(asset_type) + if not asset_class: + logger.warning( + f"Unknown asset type '{asset_type}', creating UnknownAsset" + ) + return UnknownAsset.from_dict(asset_data) + return asset_class.from_dict(asset_data) + + def _deserialize_item(item_data: dict) -> DocItem: + item_type = item_data.get("type") + item_class = None + if item_type: + item_class = item_class_map.get(item_type) + if not item_class: + raise TypeError(f"Unknown document item type '{item_type}'") + return item_class.from_dict(item_data) + + doc = cls() + doc.uid = data.get("uid", doc.uid) + + # Clear the default layer created by __init__ + doc.set_children([]) + doc._active_layer_index = -1 + + # Load assets first from unified list + for asset_data in data.get("assets", []): + doc.add_asset(_deserialize_asset(asset_data)) + + # Legacy Asset Loading (from separate dictionaries) + stock_assets_data = data.get("stock_assets", {}) + for sa_data in stock_assets_data.values(): + doc.add_asset(StockAsset.from_dict(sa_data)) + sketches_data = data.get("sketches", {}) + for s_data in sketches_data.values(): + sketch_cls = asset_type_registry.get("sketch") + if sketch_cls: + doc.add_asset(sketch_cls.from_dict(s_data)) + source_assets_data = data.get("source_assets", {}) + for src_data in source_assets_data.values(): + doc.add_asset(SourceAsset.from_dict(src_data)) + + # Load children (Layers and StockItems) from unified list + children = [] + children_data = data.get("children", []) + for d in children_data: + children.append(_deserialize_item(d)) + + # Legacy stock item loading (from separate list) + stock_items_data = data.get("stock_items", []) + for d in stock_items_data: + if "geometry" in d and "stock_asset_uid" not in d: + # Legacy format: create a StockAsset from item data + asset = StockAsset(name=d.get("name", "Stock")) + asset.geometry = ( + Geometry.from_dict(d["geometry"]) + if d.get("geometry") + else Geometry() + ) + asset.thickness = d.get("thickness") + asset.material_uid = d.get("material_uid") + doc.add_asset(asset) + + # Create a StockItem instance linked to the new asset + item = StockItem( + stock_asset_uid=asset.uid, name=d.get("name", "Stock") + ) + item.uid = d["uid"] + item.matrix = Matrix.from_list(d["matrix"]) + item.visible = d.get("visible", True) + children.append(item) + else: + children.append(StockItem.from_dict(d)) + + doc.set_children(children) + doc._active_layer_index = data.get("active_layer_index", 0) + + return doc + + @property + def stock_items(self) -> list["StockItem"]: + """Returns a list of all child items that are StockItems.""" + from .stock import StockItem + + return [ + child for child in self.children if isinstance(child, StockItem) + ] + + def get_source_asset_by_uid(self, uid: str) -> SourceAsset | None: + """ + Retrieves a SourceAsset from the document's registry by its UID. + """ + return self.source_assets.get(uid) + + def to_dict(self) -> dict: + """Serializes the document and its children to a dictionary.""" + return { + "uid": self.uid, + "type": "doc", + "active_layer_index": self._active_layer_index, + "children": [child.to_dict() for child in self.children], + "assets": [asset.to_dict() for asset in self.get_all_assets()], + } + + def add_asset( + self, asset: IAsset, index: int | None = None, silent: bool = False + ): + """ + Adds or updates an asset in the document's unified registry and + maintains its order. + """ + if not isinstance(asset, IAsset): + raise TypeError("Only IAsset objects can be added.") + if asset.uid in self.assets: + return # Asset already exists + + self.assets[asset.uid] = asset + if index is None: + self.asset_order.append(asset.uid) + else: + self.asset_order.insert(index, asset.uid) + + if not silent: + self.updated.send(self) + + def remove_asset_by_uid(self, uid: str): + """Removes an asset from the document by its UID.""" + if self.assets.pop(uid, None): + try: + self.asset_order.remove(uid) + except ValueError: + logger.warning( + f"Asset UID {uid} was in asset dict but not in order list." + ) + self.updated.send(self) + + def remove_asset(self, asset: "IAsset"): + """Removes an asset from the document.""" + self.remove_asset_by_uid(asset.uid) + + def get_asset_by_uid(self, uid: str) -> IAsset | None: + """Retrieves any asset from the document's registry by its UID.""" + return self.assets.get(uid) + + def set_asset_order(self, new_order_uids: list[str]): + """Sets the canonical order for all assets.""" + if set(new_order_uids) != set(self.assets.keys()): + raise ValueError( + "New order list must contain all and only existing asset UIDs." + ) + self.asset_order = new_order_uids + self.updated.send(self) + + def get_all_assets(self) -> list[IAsset]: + """Returns a unified list of all assets in the canonical order.""" + return [ + self.assets[uid] for uid in self.asset_order if uid in self.assets + ] + + def get_assets_by_type(self, type_name: str) -> dict[str, IAsset]: + """ + Returns a dictionary of all assets of a specific type. + + Args: + type_name: The asset type name (e.g., "sketch", "stock", "source") + + Returns: + Dictionary mapping UIDs to assets of the specified type. + """ + return { + uid: asset + for uid, asset in self.assets.items() + if asset.asset_type_name == type_name + } + + @property + def source_assets(self) -> dict[str, "SourceAsset"]: + """ + Returns a dictionary of all SourceAssets for compatibility. + NOTE: The order of this dictionary is not guaranteed. + """ + return { + uid: cast(SourceAsset, asset) + for uid, asset in self.assets.items() + if asset.asset_type_name == "source" + } + + @property + def stock_assets(self) -> dict[str, "StockAsset"]: + """ + Returns a dictionary of all StockAssets for compatibility. + NOTE: The order of this dictionary is not guaranteed. + """ + from .stock_asset import StockAsset + + return { + uid: cast(StockAsset, asset) + for uid, asset in self.assets.items() + if asset.asset_type_name == "stock" + } + + @property + def doc(self) -> "Doc": + """The root Doc object is itself.""" + return self + + @property + def layers(self) -> list[Layer]: + """Returns a list of all child items that are Layers.""" + return [child for child in self.children if isinstance(child, Layer)] + + @staticmethod + def is_default_layer_name(name: str) -> bool: + """ + Returns True for names produced by this document's auto-naming + scheme, such as "Layer 1". + + Layers created by :meth:`Doc.__init__` are named this way. They + were never manually renamed, so their names may be overwritten + with imported layer names. + """ + base = _("Layer") + if name == base: + return True + if not name.startswith(base): + return False + return name[len(base) :].strip().isdigit() + + @property + def has_rotary_layer(self) -> bool: + """Whether any layer has rotary mode enabled. + + Doc-level fact used to skip the per-layer kinematic walk in + :meth:`KinematicMapping.apply_to_job_ops` for flat jobs, since + that walk forces a copy-on-write clone of the command array for + every layer even when the callback does nothing. + """ + return any(layer.rotary_enabled for layer in self.layers) + + @property + def all_workpieces(self) -> list[WorkPiece]: + """ + Recursively finds and returns a flattened list of all WorkPiece + objects contained within this document. + """ + wps = [] + for layer in self.layers: + wps.extend(layer.all_workpieces) + return wps + + def add_workpiece(self, workpiece: WorkPiece): + """Adds a workpiece to the currently active layer.""" + self.active_layer.add_workpiece(workpiece) + + def remove_workpiece(self, workpiece: WorkPiece): + """Removes a workpiece from the layer that owns it.""" + if workpiece.parent: + workpiece.parent.remove_child(workpiece) + + def get_top_level_items(self) -> list["DocItem"]: + """ + Returns a list of all top-level, user-facing items in the document by + querying each layer for its content. + """ + top_items = [] + for layer in self.layers: + top_items.extend(layer.get_content_items()) + return top_items + + @property + def active_layer(self) -> Layer: + """Returns the currently active layer.""" + if not self.layers: + raise IndexError("Document has no layers.") + return self.layers[self._active_layer_index] + + @active_layer.setter + def active_layer(self, layer: Layer): + """Sets the active layer by instance.""" + try: + new_index = self.layers.index(layer) + if self._active_layer_index != new_index: + self._active_layer_index = new_index + self.updated.send(self) + self.active_layer_changed.send(self) + except ValueError: + logger.warning("Attempted to set a non-existent layer as active.") + + def _on_layer_per_step_transformer_changed(self, sender): + """Special-case bubbling for a non-standard signal.""" + self.job_assembly_invalidated.send(self) + + def add_child(self, child: T, index: int | None = None) -> T: + if isinstance(child, Layer): + child.per_step_transformer_changed.connect( + self._on_layer_per_step_transformer_changed + ) + super().add_child(child, index) + return child + + def remove_child(self, child: DocItem): + if isinstance(child, Layer) and child.workflow: + child.per_step_transformer_changed.disconnect( + self._on_layer_per_step_transformer_changed + ) + super().remove_child(child) + + def set_children(self, new_children: Iterable[DocItem]): + new_children_list = list(new_children) + + old_layers = self.layers + for layer in old_layers: + # Ensure the layer has a workflow before disconnecting + if layer.workflow: + layer.per_step_transformer_changed.disconnect( + self._on_layer_per_step_transformer_changed + ) + + new_layers = [c for c in new_children_list if isinstance(c, Layer)] + for layer in new_layers: + layer.per_step_transformer_changed.connect( + self._on_layer_per_step_transformer_changed + ) + super().set_children(new_children_list) + + def add_layer(self, layer: Layer): + self.add_child(layer) + + def remove_layer(self, layer: Layer): + if layer not in self.layers: + return + + if len(self.layers) <= 1: + msg = "A document must have at least one workpiece layer." + logger.warning(msg) + return + + # Safely adjust active layer index before removal + old_active_layer = self.active_layer + layers_before_remove = self.layers + layer_index_to_remove = layers_before_remove.index(layer) + + # Remove the child. This will trigger signals. + self.remove_child(layer) + + # After removal, the list of layers is shorter. We need to ensure + # _active_layer_index is still valid. + if old_active_layer is layer: + # The active layer was deleted. Choose the one before it, or 0. + new_index = max(0, layer_index_to_remove - 1) + self._active_layer_index = new_index + self.active_layer_changed.send(self) + elif layer_index_to_remove < self._active_layer_index: + # A layer before the active one was removed, so the active index + # must shift. + self._active_layer_index -= 1 + # The active layer instance hasn't changed, so no change signal + # needed. + + def set_layers(self, layers: list[Layer]): + new_layers_list = list(layers) + + # A document must always have at least one workpiece layer. + if len(new_layers_list) < 1: + raise ValueError( + "A document must have at least one workpiece layer." + ) + + # Preserve the active layer if it still exists in the new list + old_active_layer = None + if self.layers and self._active_layer_index >= 0: + old_active_layer = self.active_layer + + try: + if old_active_layer: + new_active_index = new_layers_list.index(old_active_layer) + else: + new_active_index = 0 + except ValueError: + # The old active layer is not in the new list, so pick a default. + new_active_index = 0 + + self._active_layer_index = new_active_index + # Preserve non-layer children (like StockItems) + non_layer_children = [ + c for c in self.children if not isinstance(c, Layer) + ] + new_children_list = new_layers_list + non_layer_children + self.set_children(new_children_list) + + # After the state is consistent, send the active_layer_changed signal + # if the active layer instance has actually changed. + if old_active_layer is not self.active_layer: + self.active_layer_changed.send(self) + + def has_workpiece(self): + return bool(self.all_workpieces) + + def has_result(self): + # A result is possible if there's a workpiece and at least one + # workflow (in any layer) has at least one visible step. + return self.has_workpiece() and any( + step.visible + for layer in self.layers + if layer.workflow + for step in layer.workflow.steps + ) + + def get_laser_uid_for_step(self, step_uid: str) -> str | None: + """ + Look up the laser_uid for a step by its UID. + + Args: + step_uid: The unique identifier of the step. + + Returns: + The selected_head_uid for the step, or None if not found. + """ + for layer in self.layers: + if layer.workflow: + for step in layer.workflow.steps: + if step.uid == step_uid: + return step.selected_head_uid + return None + + def get_layer_uid_for_step(self, step_uid: str) -> str | None: + """ + Look up the layer_uid for a step by its UID. + + Args: + step_uid: The unique identifier of the step. + + Returns: + The uid of the layer containing the step, or None if not found. + """ + for layer in self.layers: + if layer.workflow: + for step in layer.workflow.steps: + if step.uid == step_uid: + return layer.uid + return None + + @property + def missing_step_types(self) -> set[str]: + """Step type names referenced but not registered in step_registry.""" + from .step_registry import step_registry + + missing: set[str] = set() + for layer in self.layers: + if layer.workflow: + for step in layer.workflow.steps: + name = type(step).__name__ + if step_registry.get(name) is None: + missing.add(step.original_step_type or step.typelabel) + return missing diff --git a/rayforge/core/expression/__init__.py b/rayforge/core/expression/__init__.py new file mode 100644 index 000000000..5c2c014f7 --- /dev/null +++ b/rayforge/core/expression/__init__.py @@ -0,0 +1,20 @@ +from .context import ExpressionContext +from .errors import ValidationResult, ValidationStatus +from .evaluator import safe_evaluate +from .expression_map import ExpressionMap +from .parser import ExpressionParser +from .tokenizer import ExpressionTokenizer, Token, TokenType +from .validator import ExpressionValidator + +__all__ = [ + "ExpressionContext", + "ExpressionMap", + "ExpressionParser", + "ExpressionTokenizer", + "ExpressionValidator", + "Token", + "TokenType", + "ValidationResult", + "ValidationStatus", + "safe_evaluate", +] diff --git a/rayforge/core/expression/context.py b/rayforge/core/expression/context.py new file mode 100644 index 000000000..c8078c44b --- /dev/null +++ b/rayforge/core/expression/context.py @@ -0,0 +1,36 @@ +from collections.abc import Callable + + +class ExpressionContext: + """ + Represents the set of available variables and functions for an expression. + + This class acts as a symbol table, providing types and callables to the + parser, validator, and UI components. + """ + + def __init__( + self, + variables: dict[str, type] | None = None, + functions: dict[str, Callable] | None = None, + ): + """ + Args: + variables: A dictionary mapping variable names to their Python + types. + functions: A dictionary mapping function names to their callables. + """ + self.variables: dict[str, type] = variables or {} + self.functions: dict[str, Callable] = functions or {} + + def is_variable(self, name: str) -> bool: + """Checks if a name corresponds to a known variable.""" + return name in self.variables + + def is_function(self, name: str) -> bool: + """Checks if a name corresponds to a known function.""" + return name in self.functions + + def get_variable_type(self, name: str) -> type | None: + """Returns the type of a known variable.""" + return self.variables.get(name) diff --git a/rayforge/core/expression/errors.py b/rayforge/core/expression/errors.py new file mode 100644 index 000000000..d848c530f --- /dev/null +++ b/rayforge/core/expression/errors.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import enum +from gettext import gettext as _ + + +class ValidationStatus(enum.Enum): + """Indicates the result of an expression validation.""" + + OK = "ok" + ERROR = "error" + + +class ErrorInfo: + """Base class for detailed error information.""" + + def get_message(self) -> str: + """Returns a user-friendly, translatable error message.""" + raise NotImplementedError + + +class SyntaxErrorInfo(ErrorInfo): + """Details for a syntax error.""" + + def __init__(self, message: str, offset: int): + self.message = message + self.offset = offset + + def get_message(self) -> str: + return _("Syntax Error: {message}").format( + message=self.message.capitalize() + ) + + +class UnknownVariableInfo(ErrorInfo): + """Details for an undefined variable or function.""" + + def __init__(self, name: str): + self.name = name + + def get_message(self) -> str: + return _("Unknown variable or function: '{name}'").format( + name=self.name + ) + + +class TypeMismatchInfo(ErrorInfo): + """Details for an operation between incompatible types.""" + + def __init__( + self, + operator: str, + left_type: type, + right_type: type, + ): + self.operator = operator + self.left_type = left_type.__name__ + self.right_type = right_type.__name__ + + def get_message(self) -> str: + return _( + "Cannot use operator '{op}' between types '{left}' and '{right}'" + ).format(op=self.operator, left=self.left_type, right=self.right_type) + + +class ValidationResult: + """A container for the complete result of a validation check.""" + + def __init__( + self, + status: ValidationStatus, + error_info: ErrorInfo | None = None, + ): + self.status = status + self.error_info = error_info + + @property + def is_valid(self) -> bool: + """Returns True if the validation status is OK.""" + return self.status == ValidationStatus.OK + + @classmethod + def success(cls) -> ValidationResult: + """Factory method for a successful result.""" + return cls(ValidationStatus.OK) + + @classmethod + def failure(cls, error_info: ErrorInfo) -> ValidationResult: + """Factory method for a failed result.""" + return cls(ValidationStatus.ERROR, error_info) diff --git a/rayforge/core/expression/evaluator.py b/rayforge/core/expression/evaluator.py new file mode 100644 index 000000000..8debe3184 --- /dev/null +++ b/rayforge/core/expression/evaluator.py @@ -0,0 +1,49 @@ +import logging +import math +from typing import Any + +logger = logging.getLogger(__name__) + +# Allowed functions and constants in expressions +MATH_CONTEXT = { + k: v for k, v in math.__dict__.items() if not k.startswith("__") +} + + +def safe_evaluate(expression: str, context: dict[str, Any]) -> float: + """ + Evaluates a mathematical expression string using a specific context + (variable names) and standard math functions. + + Args: + expression: The string to evaluate (e.g., "width / 2 + 5"). + context: A dictionary of variable names to values. + + Returns: + float: The calculated value. + + Raises: + ValueError: If evaluation fails or syntax is invalid. + """ + if not expression: + return 0.0 + + # Clean whitespace + expr = expression.strip() + + # Create the evaluation namespace + # Note: Context overrides math functions if names collide. + namespace = MATH_CONTEXT.copy() + namespace.update(context) + + # Add the restricted builtins to the same namespace + namespace["__builtins__"] = {} + + try: + # Use Python's eval, passing the unified namespace as globals. + # Eval will use this for both globals and locals. + result = eval(expr, namespace) + return float(result) + except Exception as e: # noqa: BLE001 - arbitrary user expression + logger.error(f"Failed to evaluate expression '{expression}': {e}") + raise ValueError(f"Invalid expression: {e}") diff --git a/rayforge/core/expression/expression_map.py b/rayforge/core/expression/expression_map.py new file mode 100644 index 000000000..67cfac38a --- /dev/null +++ b/rayforge/core/expression/expression_map.py @@ -0,0 +1,58 @@ +import logging +import re +from typing import Any + +from .evaluator import MATH_CONTEXT + +logger = logging.getLogger(__name__) + +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") + + +class ExpressionMap: + """ + Evaluates ``{expression}`` placeholders in template strings using + a sandboxed eval namespace. + + Uses the same ``MATH_CONTEXT`` as ``safe_evaluate()``, but returns + whatever type the expression produces (no float coercion). + + Supports Python format specs: ``{value:04d}``, ``{width:.1f}``, etc. + + Unlike ``str.format_map()``, this uses regex-based substitution + so expressions can contain attribute access + (e.g. ``{date.today()}``). + + Usage:: + + values = {"width": 50.0, "height": 30.0} + tpl = "Part {width:.1f}x{height:.1f}" + resolved = ExpressionMap(values).format(tpl) + # "Part 50.0x30.0" + """ + + def __init__(self, values: dict[str, Any] | None = None): + self._namespace: dict[str, Any] = MATH_CONTEXT.copy() + if values: + self._namespace.update(values) + self._namespace["__builtins__"] = {} + + def format(self, template: str) -> str: + """Resolve all ``{expression}`` placeholders in *template*.""" + + def _replace(match: re.Match) -> str: + key = match.group(1) + expr, _, fmt = key.partition(":") + try: + result = eval(expr.strip(), self._namespace) + except Exception as e: # noqa: BLE001 - arbitrary user expression + logger.debug(f"Failed to evaluate expression '{expr}': {e}") + return match.group(0) + if fmt: + try: + return format(result, fmt) + except (ValueError, TypeError): + return str(result) + return str(result) + + return _PLACEHOLDER_RE.sub(_replace, template) diff --git a/rayforge/core/expression/parser.py b/rayforge/core/expression/parser.py new file mode 100644 index 000000000..9eb2e6e75 --- /dev/null +++ b/rayforge/core/expression/parser.py @@ -0,0 +1,49 @@ +import ast + + +class ExpressionParser: + """ + Parses an expression string into an Abstract Syntax Tree (AST) and + extracts information like used variable names. + """ + + def parse(self, expression: str) -> ast.AST | None: + """ + Parses an expression string into an AST object. + + Args: + expression: The string to parse. + + Returns: + The root ast.AST node if parsing is successful, otherwise None. + """ + if not expression: + return None + try: + return ast.parse(expression, mode="eval") + except (SyntaxError, ValueError): + return None + + def get_used_variables(self, ast_node: ast.AST) -> set[str]: + """ + Traverses a parsed AST to find all names used as variables or + functions. + + Args: + ast_node: The root of the AST to traverse. + + Returns: + A set of all unique names found in the expression. + """ + + class VariableVisitor(ast.NodeVisitor): + def __init__(self): + self.names = set() + + def visit_Name(self, node: ast.Name): + self.names.add(node.id) + self.generic_visit(node) + + visitor = VariableVisitor() + visitor.visit(ast_node) + return visitor.names diff --git a/rayforge/core/expression/tokenizer.py b/rayforge/core/expression/tokenizer.py new file mode 100644 index 000000000..d18748534 --- /dev/null +++ b/rayforge/core/expression/tokenizer.py @@ -0,0 +1,100 @@ +import enum +import io +import token as py_token +import tokenize as py_tokenize +from typing import ClassVar, NamedTuple + + +class TokenType(enum.Enum): + """Simplified token types for syntax highlighting and parsing.""" + + UNKNOWN = 0 + NAME = 1 + NUMBER = 2 + STRING = 3 + OPERATOR = 4 + PARENTHESIS = 5 + COMMA = 6 + + +class Token(NamedTuple): + """Represents a single token with its type, value, and position.""" + + type: TokenType + value: str + start: int + end: int + + +class ExpressionTokenizer: + """ + Breaks an expression string into a sequence of classified tokens for + syntax highlighting. + """ + + _TYPE_MAP: ClassVar[dict[int, TokenType]] = { + py_token.NAME: TokenType.NAME, + py_token.NUMBER: TokenType.NUMBER, + py_token.STRING: TokenType.STRING, + py_token.OP: TokenType.OPERATOR, + } + + def tokenize(self, expression: str) -> list[Token]: + """ + Converts an expression string into a list of Token objects. + + Args: + expression: The string to tokenize. + + Returns: + A list of Tokens representing the expression. + """ + if not expression.strip(): + return [] + + tokens: list[Token] = [] + try: + # The tokenize module expects a callable that returns strings. + # io.StringIO provides this. + source = io.StringIO(expression) + tok_gen = py_tokenize.generate_tokens(source.readline) + + for tok in tok_gen: + token_type = self._get_token_type(tok) + if token_type: + # The tokenizer gives (line, col) tuples. For a single-line + # entry, we only care about the column. + start_col = tok.start[1] + end_col = tok.end[1] + tokens.append( + Token(token_type, tok.string, start_col, end_col) + ) + except (py_tokenize.TokenError, IndentationError, TypeError): + # If tokenizing fails, it's a syntax error. Return an empty list; + # the validator will catch the error more formally. + return [] + + return tokens + + def _get_token_type(self, tok: py_tokenize.TokenInfo) -> TokenType | None: + """Maps a standard library token to our simplified TokenType.""" + # 1. Explicitly filter out all non-content tokens. + if tok.type in ( + py_token.ENCODING, + py_token.ENDMARKER, + py_token.NEWLINE, + py_token.NL, + py_token.COMMENT, + py_token.INDENT, + py_token.DEDENT, + ): + return None + + # 2. Specific symbols take precedence over generic types. + if tok.string in "()": + return TokenType.PARENTHESIS + if tok.string == ",": + return TokenType.COMMA + + # 3. Fallback to generic type mapping for everything else. + return self._TYPE_MAP.get(tok.type) diff --git a/rayforge/core/expression/validator.py b/rayforge/core/expression/validator.py new file mode 100644 index 000000000..cc4aa3be9 --- /dev/null +++ b/rayforge/core/expression/validator.py @@ -0,0 +1,154 @@ +import ast +from typing import ClassVar + +from .context import ExpressionContext +from .errors import ( + SyntaxErrorInfo, + TypeMismatchInfo, + UnknownVariableInfo, + ValidationResult, +) +from .parser import ExpressionParser + + +class ExpressionValidator: + """ + Performs syntax, semantic, and basic type checking on an expression string. + """ + + def __init__(self): + self._parser = ExpressionParser() + + def validate( + self, expression: str, context: ExpressionContext + ) -> ValidationResult: + """ + Validates an expression against a given context. + + Checks for: + 1. Valid Python syntax. + 2. References to undefined variables or functions. + 3. Basic type mismatches (e.g., adding a string to a number). + + Args: + expression: The expression string to validate. + context: The context containing available symbols. + + Returns: + A ValidationResult object with the outcome. + """ + if not expression.strip(): + return ValidationResult.success() + + # 1. Syntax Check + try: + # Use Python's built-in compile for a more detailed syntax error + compile(expression, "", "eval") + except SyntaxError as e: + return ValidationResult.failure( + SyntaxErrorInfo(e.msg, e.offset or 0) + ) + + # Re-parse to get AST for further checks + ast_node = self._parser.parse(expression) + if not ast_node: + # This case is unlikely if compile() succeeded, but is a safeguard. + return ValidationResult.failure( + SyntaxErrorInfo("Invalid expression", 0) + ) + + # 2. Unknown Variable Check + used_names = self._parser.get_used_variables(ast_node) + for name in used_names: + if not context.is_variable(name) and not context.is_function(name): + return ValidationResult.failure(UnknownVariableInfo(name)) + + # 3. Type Mismatch Check + try: + type_checker = _TypeCheckVisitor(context) + type_checker.visit(ast_node) + except _TypeMismatchError as e: + return ValidationResult.failure( + TypeMismatchInfo(e.op_str, e.left, e.right) + ) + + return ValidationResult.success() + + +# --- Internal Helper for Type Checking --- + + +class _TypeMismatchError(TypeError): + """Custom exception for type checking visitor.""" + + def __init__(self, op_str: str, left: type, right: type): + self.op_str = op_str + self.left = left + self.right = right + super().__init__(f"Type mismatch: {left} {op_str} {right}") + + +class _TypeCheckVisitor(ast.NodeVisitor): + """An AST visitor to perform basic type inference and checking.""" + + # Map AST operators to their string representation + _OP_MAP: ClassVar[dict[type[ast.AST], str]] = { + ast.Add: "+", + ast.Sub: "-", + ast.Mult: "*", + ast.Div: "/", + ast.Pow: "**", + ast.Mod: "%", + } + + def __init__(self, context: ExpressionContext): + self.context = context + + def visit(self, node: ast.AST) -> type: + # Override visit to ensure we return a type + result = super().visit(node) + if not isinstance(result, type): + # Default to float for complex/unhandled types like function calls + return float + return result + + def visit_Expression(self, node: ast.Expression) -> type: + return self.visit(node.body) + + def visit_Constant(self, node: ast.Constant) -> type: + return type(node.value) + + def visit_Name(self, node: ast.Name) -> type: + # Assumes unknown variable check has already passed + var_type = self.context.get_variable_type(node.id) + return var_type or float # Default to float for functions + + def visit_BinOp(self, node: ast.BinOp) -> type: + left_type = self.visit(node.left) + right_type = self.visit(node.right) + + # Allow any numeric operation + numeric_types = (int, float, bool) + is_left_numeric = left_type in numeric_types + is_right_numeric = right_type in numeric_types + + if is_left_numeric and is_right_numeric: + # Promote to float if one operand is a float + return float if float in (left_type, right_type) else int + + # Forbid numeric operations with strings + if (is_left_numeric and right_type is str) or ( + left_type is str and is_right_numeric + ): + op_str = self._OP_MAP.get(type(node.op), "?") + raise _TypeMismatchError(op_str, left_type, right_type) + + # Fallback for other combinations + return float + + def visit_UnaryOp(self, node: ast.UnaryOp) -> type: + return self.visit(node.operand) + + def visit_Call(self, node: ast.Call) -> type: + # We assume all functions return a numeric (float) type for simplicity + return float diff --git a/rayforge/core/geo_helpers.py b/rayforge/core/geo_helpers.py new file mode 100644 index 000000000..9f0f9cd3c --- /dev/null +++ b/rayforge/core/geo_helpers.py @@ -0,0 +1,14 @@ +import cairo +from raygeo.geo import Geometry + + +def geometry_from_cairo_path(path_data: cairo.Path) -> Geometry: + geo = Geometry() + for path_type, points in path_data: + if path_type == cairo.PATH_MOVE_TO: + geo.move_to(points[0], points[1]) + elif path_type == cairo.PATH_LINE_TO: + geo.line_to(points[0], points[1]) + elif path_type == cairo.PATH_CLOSE_PATH: + geo.close_path() + return geo diff --git a/rayforge/core/geometry_provider.py b/rayforge/core/geometry_provider.py new file mode 100644 index 000000000..77cd9e9cf --- /dev/null +++ b/rayforge/core/geometry_provider.py @@ -0,0 +1,70 @@ +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Protocol, + runtime_checkable, +) + +from blinker import Signal + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + from ..image.base_renderer import Renderer + from ..image.structures import FillRenderData + + +@runtime_checkable +class IGeometryProvider(Protocol): + """Protocol for assets that can provide geometry for a Workpiece.""" + + @property + def uid(self) -> str: + """The unique identifier of the provider asset.""" + ... + + @property + def name(self) -> str: + """The user-facing name of the provider asset.""" + ... + + @property + def updated(self) -> "Signal": + """Signal emitted when the provider's geometry changes.""" + ... + + @property + def provider_type_name(self) -> str: + """The type name for geometry provider identification.""" + ... + + @property + def renderer(self) -> Optional["Renderer"]: + """The renderer to use for rendering this provider's geometry.""" + ... + + def get_geometry( + self, + params: dict[str, Any] | None = None, + *, + resolved_text_cache: dict | None = None, + ) -> tuple["Geometry", list["FillRenderData"]]: + """ + Generate geometry with optional parameter overrides. + + Args: + params: Optional dictionary of parameter values to override + the provider's default values. + resolved_text_cache: Optional mutable dict that carries + resolved template text across calls so volatile + expressions like ``uuid4()`` stay consistent. + + Returns: + A tuple of (stroke_geometry, fill_render_data). + """ + ... + + def to_dict(self) -> dict[str, Any]: + """Serialize the provider to a dictionary.""" + ... diff --git a/rayforge/core/group.py b/rayforge/core/group.py new file mode 100644 index 000000000..a8c08adb0 --- /dev/null +++ b/rayforge/core/group.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import cairo +from raygeo.geo import Matrix +from raygeo.geo.types import Rect + +from .item import DocItem +from .workpiece import WorkPiece + +if TYPE_CHECKING: + from .layer import Layer + +logger = logging.getLogger(__name__) + + +@dataclass +class GroupingResult: + """A container for the results of the group creation calculation.""" + + new_group: Group + child_matrices: dict[str, Matrix] + + +class Group(DocItem): + """ + A DocItem that acts as a container for other DocItems (WorkPieces or + other Groups), allowing them to be treated as a single unit for + transformations. + """ + + def __init__(self, name: str = "Group"): + """Initializes a Group instance.""" + super().__init__(name=name) + self.extra: dict[str, Any] = {} + + @property + def layer(self) -> Layer | None: + """Traverses the hierarchy to find the parent Layer.""" + from .layer import Layer # Local import to avoid circular dependency + + ancestor = self.get_ancestor_by_type(Layer) + return ancestor if isinstance(ancestor, Layer) else None + + @property + def all_workpieces(self) -> list[WorkPiece]: + """ + Recursively finds and returns a flattened list of all WorkPiece + objects contained within this layer, including those inside groups. + """ + return self.get_descendants(of_type=WorkPiece) + + @property + def natural_size(self) -> tuple[float, float]: + if not self.children: + return (0.0, 0.0) + bbox = self._calculate_world_bbox(self.children) + if bbox is None: + return (0.0, 0.0) + return (bbox[2], bbox[3]) + + def get_local_bbox(self) -> Rect | None: + return (0.0, 0.0, 1.0, 1.0) + + def render_to_pixels( + self, width: int, height: int + ) -> cairo.ImageSurface | None: + """ + Render all children into a single composite surface. + + Delegates to each child's own render_to_pixels, so nested + groups are handled recursively. + """ + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + ctx.set_source_rgba(0, 0, 0, 0) + ctx.set_operator(cairo.OPERATOR_SOURCE) + ctx.paint() + ctx.set_operator(cairo.OPERATOR_OVER) + + for child in self.children: + T = child.matrix + + corners = [ + T.transform_point(c) for c in [(0, 0), (1, 0), (1, 1), (0, 1)] + ] + xs = [c[0] for c in corners] + ys = [c[1] for c in corners] + apparent_w = max(xs) - min(xs) + apparent_h = max(ys) - min(ys) + + child_pixel_w = max(int(apparent_w * width), 1) + child_pixel_h = max(int(apparent_h * height), 1) + + if not isinstance(child, (Group, WorkPiece)): + continue + + child_surface = child.render_to_pixels( + child_pixel_w, child_pixel_h + ) + if child_surface is None: + continue + + ctx.save() + # Map group (0-1) Y-up to surface pixels Y-down. + ctx.translate(0, height) + ctx.scale(width, -height) + # Apply child-to-group transform. + ctx.transform(cairo.Matrix(*T.for_cairo())) + # Flip Y for the image: image (0,0) is top-left in + # Y-down, but T expects Y-up child coordinates. + ctx.translate(0, 1) + ctx.scale( + 1.0 / child_surface.get_width(), + -1.0 / child_surface.get_height(), + ) + ctx.set_source_surface(child_surface, 0, 0) + ctx.paint() + ctx.restore() + + return surface + + def to_dict(self) -> dict: + """Serializes the Group and its children to a dictionary.""" + result = { + "uid": self.uid, + "type": "group", # Discriminator for deserialization + "name": self.name, + "matrix": self.matrix.to_list(), + "children": [child.to_dict() for child in self.children], + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict) -> Group: + """Deserializes a dictionary into a Group instance.""" + known_keys = {"uid", "type", "name", "matrix", "children"} + extra = {k: v for k, v in data.items() if k not in known_keys} + + new_group = cls(name=data.get("name", "Group")) + new_group.uid = data["uid"] + new_group.matrix = Matrix.from_list(data["matrix"]) + new_group.extra = extra + + for child_data in data.get("children", []): + child_type = child_data.get("type") + child_item = None + + # Whitelist: Only process types that are allowed to be in a group. + if child_type == "group": + child_item = Group.from_dict(child_data) + # Handle WorkPiece and legacy files with no type field. + elif child_type == "workpiece" or child_type is None: + child_item = WorkPiece.from_dict(child_data) + + # Any other type (like 'stockitem') is implicitly ignored. + if child_item: + new_group.add_child(child_item) + + return new_group + + @staticmethod + def _calculate_world_bbox( + items: Sequence[DocItem], + ) -> Rect | None: + """ + Calculates the union of the world-space bounding boxes for a list + of DocItems. + """ + if not items: + return None + + all_corners = [] + for item in items: + unit_corners = [(0, 0), (1, 0), (1, 1), (0, 1)] + world_transform = item.get_world_transform() + all_corners.extend( + [world_transform.transform_point(c) for c in unit_corners] + ) + + min_x = min(p[0] for p in all_corners) + min_y = min(p[1] for p in all_corners) + max_x = max(p[0] for p in all_corners) + max_y = max(p[1] for p in all_corners) + + return min_x, min_y, max_x - min_x, max_y - min_y + + @classmethod + def create_from_items( + cls, items_to_group: list[DocItem], parent: DocItem + ) -> GroupingResult | None: + """ + Factory method to create a new Group sized and positioned to enclose + a list of items. Only groupable items (WorkPiece, Group) are included. + + This is a pure calculation method; it does not modify the + document tree. + + Args: + items_to_group: The items that will be placed into the new group. + parent: The prospective parent of the new group (e.g., a Layer). + + Returns: + A GroupingResult object containing the configured (but not + parented) new group and the calculated local matrices for its + children, or None if no valid items are provided. + """ + # Whitelist the types that are allowed to be grouped. + valid_items_to_group = [ + item + for item in items_to_group + if isinstance(item, (WorkPiece, Group)) + ] + + if not valid_items_to_group: + return None + + # 1. Capture original world transforms and calculate bounding box. + original_world_transforms = { + item.uid: item.get_world_transform() + for item in valid_items_to_group + } + bbox = cls._calculate_world_bbox(valid_items_to_group) + if not bbox: + return None + world_x, world_y, world_w, world_h = bbox + + # 2. Prevent zero-sized groups which are mathematically problematic. + world_w = max(world_w, 1e-9) + world_h = max(world_h, 1e-9) + + # 3. Determine the new group's world transform based on the bbox. + group_world_transform = Matrix.translation( + world_x, world_y + ) @ Matrix.scale(world_w, world_h) + + # 4. Calculate the group's local matrix relative to its future parent. + parent_inv_world = parent.get_world_transform().invert() + group_local_matrix = parent_inv_world @ group_world_transform + + # 5. Calculate the new local matrices for all children relative + # to the group. + group_inv_world = group_world_transform.invert() + new_child_matrices = { + uid: group_inv_world @ world_transform + for uid, world_transform in original_world_transforms.items() + } + + # 6. Create and configure the new group instance. + new_group = cls(name="Group") + new_group.matrix = group_local_matrix + + return GroupingResult( + new_group=new_group, child_matrices=new_child_matrices + ) diff --git a/rayforge/core/hooks.py b/rayforge/core/hooks.py new file mode 100644 index 000000000..16abbab0f --- /dev/null +++ b/rayforge/core/hooks.py @@ -0,0 +1,451 @@ +import pluggy + +hookspec = pluggy.HookspecMarker("rayforge") +hookimpl = pluggy.HookimplMarker("rayforge") + +MINIMUM_API_VERSION = 16 +PLUGIN_API_VERSION = 21 + + +""" +API Changelog +============= + +Version 21 +---------- +Renamed ``WorkOriginElement.set_coordinate_space(space)`` to +``set_axis_direction(x_axis_right, y_axis_down)``. The element no longer +accepts a MachineSpace; it consumes the two display-facing booleans +exposed by ``MachinePanel``. Addons that configure the work-origin symbol +must pass those booleans instead of a coordinate space. + +Version 20 +---------- +Removed the ``transformer_settings_loaded`` hook. Transformer settings +widgets are now registered ahead of time via the new +``register_transformer_widgets`` hook and the global +``transformer_widget_registry`` +(``rayforge.ui_gtk.doceditor.post_processor.registry``); pages look up +widget classes from the registry when building the post-processing UI +instead of constructing them through the hook at page-build time. + +Removed the ``step_settings_loaded`` hook. Step settings page classes +are now registered ahead of time via the new +``register_step_settings_pages`` hook and the global +``step_settings_page_registry`` +(``rayforge.ui_gtk.doceditor.step_settings.page_registry``). The step +settings dialog looks up the page class by the step's assembler name +and builds extra pages from the page class's ``extra_pages`` producer +methods instead of constructing them through the hook at dialog-build +time. + +Version 19 +---------- +The ``rayforge.ui_gtk.shared.unit_spin_row`` module was split into a +package: ``rayforge.ui_gtk.shared.pref_rows``, with one module per +widget (``base``, ``unit_spin_row``, ``length_spin_row``, +``angle_spin_row``, ``speed_spin_row``, ``acceleration_spin_row``). +Addons importing the old module path must update their imports. Added +``LengthChoiceSpinRow``: a length row with a per-row unit dropdown +(defaulting to the user's preferred unit). + +Version 18 +---------- +Added ``register_services`` hook: addons publish services under a +string key via the global ``service_registry``, and other addons +resolve them by key (no cross-package import). Added +``register_settings_pages`` hook so addons can contribute pages to the +main Settings dialog. Addon ``requires`` is now enforced at load time: +dependencies are loaded before dependents, and an addon whose +``requires`` is unsatisfied is skipped. + +Version 17 +---------- +Raygeo 0.15: adaptive_entry, generate_helix_spiral, EntryMethod, +AdaptiveEntryOptions removed (use Workplan). ToroidOptions: renamed +step_distance -> step_over, z -> target_z. Assembler option structs: +renamed radius -> tool_radius, cut_z -> target_z. + +Version 16 +---------- +Raygeo 0.14: CoolantMode::Air split into AirAssistMode and +HeadCoolantMode. Module raygeo.ops.assembly.entry moved to +raygeo.cnc.machining.entry. adaptive_entry/adaptive_wavefronts +return AssemblyResult. Various geo functions renamed with verb +prefixes (any_overlap -> does_any_overlap, point_line_distance +-> get_point_line_distance, etc.). + +Version 15 +---------- +Raygeo 0.6 adds tab operations, merge overlapping lines, overscan, +lead-in/out, concave hull (shrink-wrap), and full-sweep raster scan +mode. Image processing delegated to raygeo.image. + +Version 14 +---------- +New raygeo API replaces old rayforge.core.ops. + +Version 13 +---------- +Ported geometry module from Python to Rust (raygeo). All addons that +import from ``raygeo`` must update to the new API. + +Version 12 +---------- +Renamed ``provides.backend`` key to ``provides.worker`` in addon manifests. +The old ``backend`` key is still accepted for backward compatibility. +Corresponding ``backend.py`` files should be renamed to ``worker.py``. +Removed ``backend_only`` parameter in favor of ``worker_only``. + +Version 11 +---------- +(No manifest changes.) + +Version 10 +---------- +Added ``register_model_libraries`` hook to allow addons to register +3D model libraries. Addons call ``model_manager.add_library`` or +``model_manager.add_library_from_path`` to contribute directories +containing ``.glb`` / ``.gltf`` model files. + +Version 9 +--------- +Added ``main_window_ready`` hook to allow addons to register UI pages, +commands, and other components that require access to the main window. +Added ``register_exporters`` hook to allow addons to register file +exporters. +Added ``register_importers`` hook to allow addons to register file +importers. +Added ``register_renderers`` hook to allow addons to register custom +renderers for their asset types. + +Version 8 +--------- +Added ``register_asset_types`` hook to allow addons to register custom +asset types. Assets are registered via the asset_type_registry and can +be deserialized from document files dynamically. + +Version 7 +--------- +Added ``register_material_libraries`` hook to allow addons to register +material libraries. Addons can return a list of paths to directories +containing material YAML files. + +Version 6 +--------- +Added ``register_transformers`` hook to allow addons to register custom +OpsTransformer classes for post-processing operations. Transformers are +now registered via the transformer_registry instead of introspection. + +Version 5 +--------- +Replaced ``register_step_widgets`` hook with ``step_settings_loaded`` +and ``transformer_settings_loaded`` hooks. Addons now add their widgets +directly to the settings dialog when the hook is called, instead of +registering widget classes in advance. This gives addons full control +over widget instantiation and lifecycle. + +Version 4 +--------- +Consolidated menu and action registration. The ``register_menu_items`` +hook has been removed. Use ``register_actions`` with the action_registry +to register actions with optional menu and toolbar placement. + +The ``register_layout_strategies`` hook now only registers strategy +classes. Layout actions should be registered via ``register_actions``. + +Version 3 +--------- +Added AI provider support to the core API. No changes to hook +specifications - existing addons remain compatible. The core +RayforgeContext now includes AI provider management capabilities. + +Version 2 +--------- +Added ``register_layout_strategies`` hook to allow addons to register +custom layout strategies for the UI. This enables addons to define +how content is arranged and displayed in different contexts. + +Version 1 +------- +Initial plugin API release. Includes core hooks for addon lifecycle, +resource registration, and UI integration: + +- ``rayforge_init``: Called when application context is initialized +- ``on_unload``: Called when addon is disabled or unloaded +- ``register_machines``: Register new machine drivers +- ``register_steps``: Register custom step types +- ``register_producers``: Register custom ops producers (removed v18) +- ``register_step_widgets``: Register step settings widgets (removed v5) +- ``register_menu_items``: Register menu items (removed in v4) +- ``register_commands``: Register editor commands +- ``register_actions``: Register window actions +""" + + +class RayforgeSpecs: + """ + Core hook specifications. + Addons implement these methods to extend functionality. + """ + + @hookspec + def rayforge_init(self, context): + """ + Called when the application context is fully initialized. + Use this for general setup, logging, or UI injection. + + .. versionadded:: 1 + + Args: + context: The global RayforgeContext. + """ + + @hookspec + def on_unload(self): + """ + Called when an addon is being disabled or unloaded. + Use this to clean up resources, close connections, unregister + handlers, etc. + + .. versionadded:: 1 + """ + + @hookspec + def register_machines(self, machine_manager): + """ + Called to allow addons to register new machine drivers. + + .. versionadded:: 1 + + Args: + machine_manager: The application's MachineManager instance. + """ + + @hookspec + def register_steps(self, step_registry): + """ + Called to allow addons to register custom step types. + + .. versionadded:: 1 + + Args: + step_registry: The global StepRegistry instance. + """ + + @hookspec + def register_transformers(self, transformer_registry): + """ + Called to allow addons to register custom ops transformers. + + .. versionadded:: 6 + + Args: + transformer_registry: The global TransformerRegistry instance. + """ + + @hookspec + def register_transformer_widgets(self, transformer_widget_registry): + """ + Called to allow addons to register settings widget classes for + post-processor transformers. + + .. versionadded:: 20 + + Args: + transformer_widget_registry: The global + TransformerWidgetRegistry instance. + """ + + @hookspec + def register_asset_types(self, asset_type_registry): + """ + Called to allow addons to register custom asset types. + + .. versionadded:: 8 + + Args: + asset_type_registry: The global AssetTypeRegistry instance. + """ + + @hookspec + def register_step_settings_pages(self, step_settings_page_registry): + """ + Called to allow addons to register step settings page classes. + + Page classes are keyed by the step's assembler name + (``step.ASSEMBLER_NAME``). A registered page class may declare + extra page producers via its ``extra_pages`` class attribute; + the dialog calls each producer method to build additional + settings pages. + + .. versionadded:: 20 + + Args: + step_settings_page_registry: The global + StepSettingsPageRegistry instance. + """ + + @hookspec + def register_commands(self, command_registry): + """ + Called to allow addons to register editor commands. + + .. versionadded:: 1 + + Args: + command_registry: The global CommandRegistry instance. + """ + + @hookspec + def register_actions(self, action_registry): + """ + Called to allow addons to register window actions. + + .. versionadded:: 1 + .. versionchanged:: 4 + Now receives action_registry instead of window. Use + action_registry.register() with optional menu and toolbar + placement parameters. + + Args: + action_registry: The global ActionRegistry instance. + """ + + @hookspec + def register_layout_strategies(self, layout_registry): + """ + Called to allow addons to register custom layout strategies. + + .. versionadded:: 2 + .. versionchanged:: 4 + Only registers strategy classes. Layout actions should be + registered via the ``register_actions`` hook with menu and + toolbar placement. + + Args: + layout_registry: Registry for layout strategy classes. + """ + + @hookspec + def register_material_libraries(self, library_manager): + """ + Called to allow addons to register material libraries. + + Addons should call ``library_manager.add_library_from_path(path)`` to + register directories containing material YAML files. By default, + registered libraries are read-only. + + .. versionadded:: 7 + + Args: + library_manager: The global LibraryManager instance. + """ + + @hookspec + def register_model_libraries(self, model_manager): + """ + Called to allow addons to register 3D model libraries. + + Addons should call ``model_manager.add_library_from_path(path)`` or + ``model_manager.add_library(library)`` to register directories + containing ``.glb`` / ``.gltf`` model files. By default, + registered libraries are read-only. + + .. versionadded:: 10 + + Args: + model_manager: The global ModelManager instance. + """ + + @hookspec + def register_exporters(self, exporter_registry): + """ + Called to allow addons to register file exporters. + + Addons should call ``exporter_registry.register(exporter_cls)`` to + register exporter classes for their supported file formats. + + .. versionadded:: 9 + + Args: + exporter_registry: The global ExporterRegistry instance. + """ + + @hookspec + def register_importers(self, importer_registry): + """ + Called to allow addons to register file importers. + + Addons should call ``importer_registry.register(importer_cls)`` to + register importer classes for their supported file formats. + + .. versionadded:: 10 + + Args: + importer_registry: The global ImporterRegistry instance. + """ + + @hookspec + def register_renderers(self, renderer_registry): + """ + Called to allow addons to register custom renderers. + + Addons should call ``renderer_registry.register(renderer)`` to + register renderer instances. The renderer's class name is used as + the registry key. + + .. versionadded:: 9 + + Args: + renderer_registry: The global RendererRegistry instance. + """ + + @hookspec + def register_settings_pages(self, settings_page_registry): + """ + Called to allow addons to contribute pages to the Settings dialog. + + Addons call + ``settings_page_registry.register(PageClass, addon_name=...)`` for + each page. A page class is a no-arg widget constructor exposing + ``get_title()`` and ``get_icon_name()`` (e.g. a + ``TrackedPreferencesPage`` subclass). + + .. versionadded:: 18 + + Args: + settings_page_registry: The global SettingsPageRegistry + instance. + """ + + @hookspec + def register_services(self, service_registry): + """ + Called to allow addons to publish services for cross-addon use. + + Addons call + ``service_registry.register(key, service, addon_name=...)``. + Consumers resolve them via the global ``service_registry`` + (``service_registry.get(key)``), avoiding a direct cross-package + import. + + .. versionadded:: 18 + + Args: + service_registry: The global ServiceRegistry instance. + """ + + @hookspec + def main_window_ready(self, main_window): + """ + Called when the main window is fully initialized. + + Addons can use this hook to register custom UI pages, commands, + or other components that require access to the main window. + + .. versionadded:: 9 + + Args: + main_window: The MainWindow instance. + """ diff --git a/rayforge/core/item.py b/rayforge/core/item.py new file mode 100644 index 000000000..0805c3286 --- /dev/null +++ b/rayforge/core/item.py @@ -0,0 +1,892 @@ +from __future__ import annotations + +import logging +import uuid +import weakref +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable +from typing import ( + TYPE_CHECKING, + Any, + TypeVar, + overload, +) + +import numpy as np +from blinker import Signal +from raygeo.geo import Matrix +from raygeo.geo.types import Point, Rect + +if TYPE_CHECKING: + from .asset import IAsset + from .doc import Doc + +logger = logging.getLogger(__name__) + +# For generic type hinting in add_child, etc. +T = TypeVar("T", bound="DocItem") +# For generic type hinting in get_descendants +T_Desc = TypeVar("T_Desc", bound="DocItem") + + +class _RevisionSignal(Signal): + """ + A :class:`~blinker.Signal` subclass that bumps a revision counter on + its owning :class:`DocItem` every time :meth:`send` is invoked. + + The bump happens *before* receivers fire, so any handler that reads + ``owner.geometry_revision`` / ``owner.transform_revision`` sees the + post-bump value. + + The owner is held via a :class:`weakref.ref` so the signal never + keeps a DocItem alive. ``send`` is a no-op for the bump when the + owner has been garbage-collected. + """ + + def __init__( + self, + owner_ref: weakref.ref, + bump: Callable[[DocItem], None], + ): + super().__init__() + self._owner_ref = owner_ref + self._bump = bump + + def send(self, sender: Any = None, *, _async_wrapper=None, **kwargs): + owner = self._owner_ref() + if owner is not None: + self._bump(owner) + return super().send(sender, _async_wrapper=_async_wrapper, **kwargs) + + +class DocItem(ABC): + """ + An abstract base class for any item that can exist in a document's + hierarchy. Implements the Composite design pattern for tree management + and automatic signal bubbling. + """ + + def __init__(self, name: str = ""): + self.uid: str = str(uuid.uuid4()) + self._name: str = name + self._parent: DocItem | None = None + self.children: list[DocItem] = [] + self._matrix: Matrix = Matrix.identity() + + # Monotonic revision counters bumped whenever the corresponding + # signal fires. Callers that cache derived data (e.g. raygeo's + # NodeRequest version_token) read these to detect changes. + # ``geometry_revision`` tracks ``updated`` emissions (geometry, + # step parameters, etc.); ``transform_revision`` tracks + # ``transform_changed`` emissions (matrix edits). + self._geometry_revision: int = 0 + self._transform_revision: int = 0 + + # Signals — wrapped so each ``send`` bumps the matching + # revision counter before notifying receivers. Subclasses and + # external code continue to use ``self.updated.send(self)`` and + # ``self.transform_changed.send(self, ...)`` exactly as before; + # the bump is transparent. + owner_ref = weakref.ref(self) + self.updated = _RevisionSignal( + owner_ref, lambda o: o._bump_geometry_revision() + ) + self.transform_changed = _RevisionSignal( + owner_ref, lambda o: o._bump_transform_revision() + ) + + # Bubbled Signals + # Fired when a descendant is added anywhere in the subtree. + self.descendant_added = Signal() + # Fired when a descendant is removed anywhere in the subtree. + self.descendant_removed = Signal() + # Fired when a descendant's `updated` signal is fired. + self.descendant_updated = Signal() + # Fired when a descendant's `transform_changed` signal is fired. + self.descendant_transform_changed = Signal() + + self._natural_size: tuple[float, float] = (0.0, 0.0) + + # -- Revision counters ------------------------------------------------ + + def _bump_geometry_revision(self) -> None: + """Increment ``geometry_revision`` (called by the signal).""" + self._geometry_revision += 1 + + def _bump_transform_revision(self) -> None: + """Increment ``transform_revision`` (called by the signal).""" + self._transform_revision += 1 + + @property + def geometry_revision(self) -> int: + """ + Monotonic counter bumped every time ``updated`` is sent. + + Starts at ``0`` for a freshly constructed item and increments by + one on each emission. Used by the pipeline to build stable + ``version_token`` values for raygeo :class:`NodeRequest` objects + without hashing geometry payloads. + """ + return self._geometry_revision + + @property + def transform_revision(self) -> int: + """ + Monotonic counter bumped every time ``transform_changed`` is + sent. + + Folded into a workpiece compute token only when the owning step + declares a position-sensitive transformer (e.g. ``CropSpec``); + otherwise the token omits this revision entirely. + """ + return self._transform_revision + + @property + def name(self) -> str: + """The user-facing name of the item.""" + return self._name + + @name.setter + def name(self, new_name: str): + """Sets the item name and sends an update signal if changed.""" + if self._name != new_name: + self._name = new_name + self.updated.send(self) + + def depends_on_asset(self, asset: IAsset) -> bool: + """ + Checks if this item has a direct dependency on the given asset. + Subclasses should override this to check their specific asset links. + By default, items have no asset dependencies. + """ + return False + + @property + def bbox(self) -> Rect: + """ + The world-space bounding box of the item as (x, y, width, height). + """ + x, y = self.pos + w, h = self.size + return x, y, w, h + + @abstractmethod + def to_dict(self) -> dict: + """Serializes the item to a dictionary.""" + raise NotImplementedError + + @classmethod + @abstractmethod + def from_dict(cls, data: dict) -> DocItem: + """Deserializes the item from a dictionary.""" + raise NotImplementedError + + @staticmethod + def create_from_dict(data: dict) -> DocItem: + """ + Factory method that deserializes a dictionary into the appropriate + DocItem subclass based on the 'type' field. + """ + item_type = data.get("type") + + if item_type == "group": + from .group import Group + + return Group.from_dict(data) + elif item_type == "stockitem": + from .stock import StockItem + + return StockItem.from_dict(data) + elif item_type == "workpiece" or item_type is None: + from .workpiece import WorkPiece + + return WorkPiece.from_dict(data) + else: + raise ValueError(f"Unknown item type: {item_type}") + + def duplicate(self) -> DocItem: + """ + Creates a deep copy of this item with new UIDs. + + Subclasses can override this method if they need custom duplication + logic, but the default implementation using serialization should + work for most cases. + """ + item_dict = self.to_dict() + new_item = self.__class__.from_dict(item_dict) + + def assign_new_uids(item: DocItem): + item.uid = str(uuid.uuid4()) + for child in item.children: + assign_new_uids(child) + + assign_new_uids(new_item) + return new_item + + def __iter__(self): + """ + Provides a non-recursive iterator over the item's direct children. + """ + return iter(self.children) + + @property + def parent(self) -> DocItem | None: + """The parent DocItem in the hierarchy.""" + return self._parent + + @parent.setter + def parent(self, new_parent: DocItem | None): + """ + Sets the parent of this item. This is typically managed by the + parent's add/remove_child methods and should not be set directly. + """ + self._parent = new_parent + + @property + def doc(self) -> Doc | None: + """The root Doc object, accessed via the parent hierarchy.""" + if self.parent: + return self.parent.doc + return None + + @property + def pos(self) -> Point: + """ + The position (in mm) of the items's top-left corner in world space. + """ + # The position is the world-space location of the local origin (0,0). + return self.get_world_transform().transform_point((0.0, 0.0)) + + @pos.setter + def pos(self, new_pos_world: Point): + """ + Sets the world-space position of the items's top-left corner + by manipulating the matrix's translation component. + """ + world_transform_old = self.get_world_transform() + current_pos_world = world_transform_old.transform_point((0.0, 0.0)) + delta_x = new_pos_world[0] - current_pos_world[0] + delta_y = new_pos_world[1] - current_pos_world[1] + + if abs(delta_x) < 1e-9 and abs(delta_y) < 1e-9: + return + + # Create the translation in world coordinates + translate_transform_world = Matrix.translation(delta_x, delta_y) + + # Calculate the new desired world transform + world_transform_new = translate_transform_world @ world_transform_old + + # Back-calculate the new local matrix + if self.parent: + parent_world_transform = self.parent.get_world_transform() + try: + parent_world_inv = parent_world_transform.invert() + new_local_matrix = parent_world_inv @ world_transform_new + except np.linalg.LinAlgError: + logger.warning( + "Cannot set pos: parent transform is not invertible." + ) + return + else: + new_local_matrix = world_transform_new + + self.matrix = new_local_matrix + + @property + def size(self) -> tuple[float, float]: + """ + The world-space size (width, height) in mm, as absolute values, + decomposed from the world transformation matrix. + """ + return self.get_world_transform().get_abs_scale() + + def set_size(self, width_mm: float, height_mm: float): + """ + Sets the item size in mm while preserving its world-space center + point. This manipulates the existing matrix. + """ + # Guard against zero dimensions to prevent singular matrices in Cairo + width_mm = max(abs(width_mm), 1e-9) + height_mm = max(abs(height_mm), 1e-9) + + world_transform_old = self.get_world_transform() + current_w, current_h = world_transform_old.get_abs_scale() + + if ( + abs(width_mm - current_w) < 1e-9 + and abs(height_mm - current_h) < 1e-9 + ): + return + + # Decompose the existing world transform to preserve its properties + _, _, angle, sx, sy, skew = world_transform_old.decompose() + + # Preserve any reflection by checking the sign of the original scale + new_sx = width_mm * (1 if sx >= 0 else -1) + new_sy = height_mm * (1 if sy >= 0 else -1) + + # Compose a new world matrix with the new scale, but without its + # translation corrected yet. + # We use (0,0) for translation initially, as we will correct the + # center point manually. + world_transform_new_uncorrected = Matrix.compose( + 0, 0, angle, new_sx, new_sy, skew + ) + + # The old center point that we must maintain + center_world_old = world_transform_old.transform_point((0.5, 0.5)) + + # The center point of our newly composed matrix (before translation) + center_world_new_uncorrected = ( + world_transform_new_uncorrected.transform_point((0.5, 0.5)) + ) + + # Calculate the required translation to move the new center to the old + # center's position. + final_tx = center_world_old[0] - center_world_new_uncorrected[0] + final_ty = center_world_old[1] - center_world_new_uncorrected[1] + + # Create the final desired world matrix + world_transform_new = world_transform_new_uncorrected.set_translation( + final_tx, final_ty + ) + + # Back-calculate the new local matrix + if self.parent: + parent_world_transform = self.parent.get_world_transform() + try: + parent_world_inv = parent_world_transform.invert() + new_local_matrix = parent_world_inv @ world_transform_new + except np.linalg.LinAlgError: + logger.warning( + "Cannot set size: parent transform is not invertible." + ) + return + else: + new_local_matrix = world_transform_new + + self.matrix = new_local_matrix + + @property + def natural_size(self) -> tuple[float, float]: + """ + Returns the natural size (untransformed width and height) of this item. + + For generic items (like Groups), this is the size of the bounding box + that encloses all children in the item's local coordinate space, + calculated at the time of the last structural change (adding or + removing children). + + If an item has no children and provides no intrinsic size (like + WorkPiece or StockItem do), this returns None. + """ + return self._natural_size + + def get_local_bbox(self) -> Rect | None: + """ + Returns the bounding box of the item in its own local coordinate space + (before the local matrix is applied). + + Returns: + (min_x, min_y, width, height) or None + """ + if self.natural_size: + return (0.0, 0.0, self.natural_size[0], self.natural_size[1]) + return None + + def _recalculate_natural_size(self): + """ + Recalculates the natural size based on the current children. + """ + if not self.children: + self._natural_size = (0.0, 0.0) + return + + min_x, min_y = float("inf"), float("inf") + max_x, max_y = float("-inf"), float("-inf") + has_valid_children = False + + for child in self.children: + child_bbox = child.get_local_bbox() + if child_bbox is None: + continue + + bx, by, bw, bh = child_bbox + # The child's local bounds. + # We transform these corners to the parent's (self) local space. + corners = [ + (bx, by), + (bx + bw, by), + (bx + bw, by + bh), + (bx, by + bh), + ] + transformed_corners = [ + child.matrix.transform_point(c) for c in corners + ] + + has_valid_children = True + for x, y in transformed_corners: + min_x = min(min_x, x) + max_x = max(max_x, x) + min_y = min(min_y, y) + max_y = max(max_y, y) + + if has_valid_children: + self._natural_size = (max_x - min_x, max_y - min_y) + else: + self._natural_size = (0.0, 0.0) + + def get_current_aspect_ratio(self) -> float | None: + w, h = self.size + return w / h if h else None + + @property + def angle(self) -> float: + """ + The rotation angle (in degrees) of the item. + This is decomposed from the local transformation matrix. + """ + return self.matrix.get_rotation() + + @angle.setter + def angle(self, new_angle_deg: float): + """ + Sets the local rotation angle to a new value, preserving the item's + world-space center point. + """ + current_angle = self.angle + delta_angle = new_angle_deg - current_angle + + if abs(delta_angle - round(delta_angle / 360.0) * 360.0) < 1e-9: + return + + # Get the current world transform and the world-space center point + # around which the rotation should occur. + world_transform_old = self.get_world_transform() + center_world = world_transform_old.transform_point((0.5, 0.5)) + + # Create a rotation transformation that will be applied in world space + rotate_transform_world = Matrix.rotation( + delta_angle, center=center_world + ) + + # Calculate the new desired world transform by applying the rotation + # to the old one. + world_transform_new = rotate_transform_world @ world_transform_old + + # Now, back-calculate the new local matrix that will result in this + # new world transform. + if self.parent: + parent_world_transform = self.parent.get_world_transform() + try: + parent_world_inv = parent_world_transform.invert() + new_local_matrix = parent_world_inv @ world_transform_new + except np.linalg.LinAlgError: + logger.warning( + "Cannot set angle: parent transform is not invertible." + ) + return + else: + # If there's no parent, the local matrix is the world matrix. + new_local_matrix = world_transform_new + + self.matrix = new_local_matrix + + @property + def shear(self) -> float: + """ + The shear angle (in degrees) of the item. + This is decomposed from the local transformation matrix. + """ + # decompose returns (tx, ty, angle_deg, sx, sy, skew_angle_deg) + return self.matrix.decompose()[5] + + @shear.setter + def shear(self, new_shear_deg: float): + """ + Sets the local shear angle to a new value, preserving the item's + world-space center point. + """ + old_shear_deg = self.shear + if abs(new_shear_deg - old_shear_deg) < 1e-9: + return + + # Get world center before change + world_transform_old = self.get_world_transform() + center_world_old = world_transform_old.transform_point((0.5, 0.5)) + + # Decompose local matrix to get its non-shear components + tx, ty, angle, sx, sy, _ = self.matrix.decompose() + + # Recompose local matrix with the new shear value + new_local_matrix = Matrix.compose(tx, ty, angle, sx, sy, new_shear_deg) + + # Calculate the new world center based on the temporary new matrix + parent_world_transform = ( + self.parent.get_world_transform() + if self.parent + else Matrix.identity() + ) + world_transform_new_uncorrected = ( + parent_world_transform @ new_local_matrix + ) + center_world_new = world_transform_new_uncorrected.transform_point( + (0.5, 0.5) + ) + + # Calculate the world-space correction needed to restore the center + delta_x = center_world_old[0] - center_world_new[0] + delta_y = center_world_old[1] - center_world_new[1] + + # If there's no significant change, just set the matrix + if abs(delta_x) < 1e-9 and abs(delta_y) < 1e-9: + self.matrix = new_local_matrix + return + + # To correct the center point, we apply a world-space translation + # *after* the uncorrected new world transform. Then we back-calculate + # the final local matrix. + translate_transform_world = Matrix.translation(delta_x, delta_y) + world_transform_new_corrected = ( + translate_transform_world @ world_transform_new_uncorrected + ) + + # Back-calculate final local matrix + if self.parent: + try: + parent_world_inv = parent_world_transform.invert() + final_local_matrix = ( + parent_world_inv @ world_transform_new_corrected + ) + except np.linalg.LinAlgError: + logger.warning( + "Cannot set shear: parent transform is not invertible." + ) + return + else: + final_local_matrix = world_transform_new_corrected + + self.matrix = final_local_matrix + + def add_child(self, child: T, index: int | None = None) -> T: + if child in self.children: + return child + + if child.parent: + child.parent.remove_child(child) + + if index is None: + self.children.append(child) + else: + self.children.insert(index, child) + + child.parent = self + self._connect_child_signals(child) + self._recalculate_natural_size() + self.descendant_added.send(self, origin=child, parent_of_origin=self) + return child + + def remove_child(self, child: DocItem): + if child not in self.children: + return + + self.children.remove(child) + child.parent = None + self.descendant_removed.send(self, origin=child, parent_of_origin=self) + self._disconnect_child_signals(child) + self._recalculate_natural_size() + + def add_children( + self, children_to_add: Iterable[DocItem], index: int | None = None + ): + """ + Adds multiple children in a bulk operation to improve performance, + sending a single `updated` signal after completion. It quietly + re-parents the children if they already belong to another parent. + + Args: + children_to_add: An iterable of DocItems to add. + index: The index at which to insert the children. If None, they + are appended. + """ + children_list = list(children_to_add) + if not children_list: + return + + # Quietly detach from any existing parents first. + for child in children_list: + if child.parent: + try: + # Manually remove from old parent's list without signals + child.parent.children.remove(child) + child.parent._disconnect_child_signals(child) + child.parent._recalculate_natural_size() + except (ValueError, AttributeError): + pass # Failsafe if tree is in an inconsistent state + child.parent = None + + # Add to self's children list + if index is None: + self.children.extend(children_list) + else: + self.children[index:index] = children_list + + # Update parent pointers and connect signals + for child in children_list: + child.parent = self + self._connect_child_signals(child) + + self._recalculate_natural_size() + self.updated.send(self) + + def remove_children(self, children_to_remove: Iterable[DocItem]): + """ + Removes multiple children in a bulk operation to improve performance, + sending a single `updated` signal after all are removed. + """ + # Use UIDs for safe comparison in the set + to_remove_uids = {c.uid for c in children_to_remove} + if not to_remove_uids: + return + + removed_items = [c for c in self.children if c.uid in to_remove_uids] + if not removed_items: + return + + # Rebuild the list, excluding the removed items + self.children = [ + c for c in self.children if c.uid not in to_remove_uids + ] + + # Update parent pointers and disconnect signals for removed items + for child in removed_items: + child.parent = None + self._disconnect_child_signals(child) + + self._recalculate_natural_size() + self.updated.send(self) + + def set_children(self, new_children: Iterable[DocItem]): + """ + Correctly updates the list of children by mutating state first, + then sending notifications. + """ + old_children = list(self.children) + new_children_list = list(new_children) + + # 1. Mutate the state immediately. + self.children = new_children_list + + # 2. Calculate differences based on the old and new states. + old_set = set(old_children) + new_set = set(new_children_list) + + # 3. Process removals and notify. + for child in old_set - new_set: + child.parent = None + self.descendant_removed.send( + self, origin=child, parent_of_origin=self + ) + self._disconnect_child_signals(child) + + # 4. Process additions and notify. + for child in new_set - old_set: + if child.parent: + child.parent.remove_child(child) + child.parent = self + self._connect_child_signals(child) + self.descendant_added.send( + self, origin=child, parent_of_origin=self + ) + + self._recalculate_natural_size() + + if old_set == new_set and old_children != new_children_list: + self.updated.send(self) + + def get_depth(self) -> int: + """ + Calculates the depth of this item in the document hierarchy by + counting its DocItem ancestors. + + A direct child has a depth of 1. + An item inside that item would have a depth of 2, and so on. + + Returns: + The integer depth of the item. + """ + depth = 0 + current_item = self + while current_item.parent and isinstance(current_item.parent, DocItem): + depth += 1 + current_item = current_item.parent + return depth + + @overload + def get_descendants(self) -> list[DocItem]: ... + + @overload + def get_descendants(self, of_type: type[T_Desc]) -> list[T_Desc]: ... + + def get_descendants(self, of_type: type[T_Desc] | None = None) -> list: + """ + Recursively finds and returns a flattened list of all descendant + DocItems, optionally filtered by type. + """ + all_descendants: list[DocItem] = [] + for child in self.children: + all_descendants.append(child) + # This recursive call unambiguously matches the first overload. + all_descendants.extend(child.get_descendants()) + + if of_type: + # The list comprehension correctly narrows the type for the return. + return [ + item for item in all_descendants if isinstance(item, of_type) + ] + + return all_descendants + + def get_child_by_uid(self, uid: str) -> DocItem | None: + """ + Finds a direct child of this item by its unique identifier. + + Args: + uid: The unique identifier to search for. + + Returns: + The DocItem if found, otherwise None. + """ + for child in self.children: + if child.uid == uid: + return child + return None + + def find_descendant_by_uid(self, uid: str) -> DocItem | None: + """ + Recursively searches the subtree for a descendant with a matching UID. + + Args: + uid: The unique identifier to search for. + + Returns: + The DocItem if found, otherwise None. + """ + for child in self.children: + if child.uid == uid: + return child + found = child.find_descendant_by_uid(uid) + if found: + return found + return 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.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 + ) + + def _disconnect_child_signals(self, child: DocItem): + child.updated.disconnect(self._on_child_updated) + child.transform_changed.disconnect(self._on_child_transform_changed) + 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 + ) + + def _on_child_updated(self, sender: DocItem): + self.descendant_updated.send( + self, origin=sender, parent_of_origin=self + ) + + def _on_child_transform_changed( + self, sender: DocItem, *, old_matrix: Matrix | None = None + ): + self.descendant_transform_changed.send( + self, origin=sender, parent_of_origin=self, old_matrix=old_matrix + ) + + def _on_descendant_added( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ): + self.descendant_added.send( + self, origin=origin, parent_of_origin=parent_of_origin + ) + + def _on_descendant_removed( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ): + self.descendant_removed.send( + self, origin=origin, parent_of_origin=parent_of_origin + ) + + def _on_descendant_updated( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ): + self.descendant_updated.send( + self, origin=origin, parent_of_origin=parent_of_origin + ) + + def _on_descendant_transform_changed( + self, + sender: DocItem, + *, + origin: DocItem, + parent_of_origin: DocItem, + old_matrix: Matrix | None = None, + ): + self.descendant_transform_changed.send( + self, + origin=origin, + parent_of_origin=parent_of_origin, + old_matrix=old_matrix, + ) + + @property + def matrix(self) -> Matrix: + """The 3x3 local transformation matrix for this item.""" + return self._matrix + + @matrix.setter + def matrix(self, value: Matrix): + if self._matrix == value: + return + old_matrix = self._matrix + self._matrix = value + self.transform_changed.send(self, old_matrix=old_matrix) + + def get_world_transform(self) -> Matrix: + """ + Calculates the cumulative transformation matrix for this item, + which transforms it from its local coordinate space into the + document's world space. + """ + if self.parent: + parent_transform = self.parent.get_world_transform() + return parent_transform @ self.matrix + return self.matrix + + def get_ancestor_by_type(self, ancestor_type: type) -> DocItem | None: + """ + Traverses the parent hierarchy to find the first ancestor of the + specified type. + + Args: + ancestor_type: The class type to search for. + + Returns: + The first ancestor matching the type, or None if not found. + """ + p = self.parent + while p: + if isinstance(p, ancestor_type): + return p + p = p.parent + return None diff --git a/rayforge/core/layer.py b/rayforge/core/layer.py new file mode 100644 index 000000000..31aea0b77 --- /dev/null +++ b/rayforge/core/layer.py @@ -0,0 +1,382 @@ +""" +Defines the Layer class, a central component for organizing and processing +workpieces within a document. +""" + +from __future__ import annotations + +import logging +import math +from collections.abc import Iterable +from gettext import gettext as _ +from typing import ( + Any, + TypeVar, +) + +from blinker import Signal +from raygeo.geo import Matrix + +from .color import COLOR_PALETTE +from .group import Group +from .item import DocItem +from .step import Step +from .workflow import Workflow +from .workpiece import WorkPiece + +logger = logging.getLogger(__name__) + +# For generic type hinting in add_child +T = TypeVar("T", bound="DocItem") + + +class Layer(DocItem): + """ + Represents a group of workpieces processed by a single workflow. + + A Layer acts as a container for `WorkPiece` objects and owns a + `Workflow`. It is a `DocItem` and automatically manages its children + and bubbles up signals. + """ + + DEFAULT_COLOR = COLOR_PALETTE[0] + + def __init__(self, name: str): + """Initializes a Layer instance. + + Args: + name: The user-facing name of the layer. + """ + super().__init__(name=name) + self.visible: bool = True + self.rotary_enabled: bool = False + self.rotary_diameter: float = 25.0 + self.rotary_module_uid: str | None = None + self.color: str = self.DEFAULT_COLOR + self.wcs: str | None = None + + # Signals for notifying other parts of the application of changes. + # This one is special and is bubbled manually. + self.per_step_transformer_changed = Signal() + + # Forward compatibility: store unknown attributes + self.extra: dict[str, Any] = {} + + # A new layer gets a workflow automatically. + workflow = Workflow(_("{name} Workflow").format(name=name)) + self.add_child(workflow) + + def to_dict(self) -> dict: + """Serializes the layer and its children to a dictionary.""" + result = { + "uid": self.uid, + "type": "layer", + "name": self.name, + "matrix": self.matrix.to_list(), + "visible": self.visible, + "rotary_enabled": self.rotary_enabled, + "rotary_diameter": self.rotary_diameter, + "rotary_module_uid": self.rotary_module_uid, + "color": self.color, + "wcs": self.wcs, + "children": [child.to_dict() for child in self.children], + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Layer: + """Deserializes a dictionary into a Layer instance.""" + known_keys = { + "uid", + "type", + "name", + "matrix", + "visible", + "rotary_enabled", + "rotary_diameter", + "rotary_module_uid", + "color", + "wcs", + "children", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + layer = cls(name=data.get("name", "Layer")) + layer.uid = data["uid"] + layer.matrix = Matrix.from_list(data["matrix"]) + layer.visible = data.get("visible", True) + layer.rotary_enabled = data.get("rotary_enabled", False) + layer.rotary_diameter = data.get("rotary_diameter", 25.0) + layer.rotary_module_uid = data.get("rotary_module_uid") + layer.color = data.get("color", cls.DEFAULT_COLOR) + layer.wcs = data.get("wcs") + layer.extra = extra + + children = [] + for child_data in data.get("children", []): + child_type = child_data.get("type") + if child_type == "workflow": + children.append(Workflow.from_dict(child_data)) + elif child_type == "workpiece": + children.append(WorkPiece.from_dict(child_data)) + elif child_type == "group": + children.append(Group.from_dict(child_data)) + layer.set_children(children) + + return layer + + @property + def workpieces(self) -> list[WorkPiece]: + """ + Returns a list of all child items that are WorkPieces. + Note: This only returns direct children. + """ + return [ + child for child in self.children if isinstance(child, WorkPiece) + ] + + @property + def all_workpieces(self) -> list[WorkPiece]: + """ + Recursively finds and returns a flattened list of all WorkPiece + objects contained within this layer, including those inside groups. + """ + return self.get_descendants(of_type=WorkPiece) + + @property + def has_fills(self) -> bool: + """Check if any workpiece in this layer has fill geometry.""" + for wp in self.all_workpieces: + fills = wp.fills + if fills is not None and len(fills) > 0: + return True + return False + + def get_content_items(self) -> list[DocItem]: + """ + Returns a list of user-facing items in this layer (e.g., + WorkPieces, Groups), excluding internal objects like Workflows. + """ + return [ + child for child in self.children if not isinstance(child, Workflow) + ] + + @property + def content_items(self) -> list[DocItem]: + """Property alias for get_content_items().""" + return self.get_content_items() + + @property + def workflow(self) -> Workflow | None: + """Returns the layer's workflow. A layer must have one workflow.""" + for child in self.children: + if isinstance(child, Workflow): + return child + # This state should be unreachable for a standard Layer, but subclasses + # of it may not have a workflow. + return None + + def _on_workflow_post_transformer_changed(self, sender): + """ + Bubbles up the post_transformer_changed signal from the workflow. + """ + self.per_step_transformer_changed.send(self) + + def add_child(self, child: T, index: int | None = None) -> T: + if isinstance(child, Workflow): + child.per_step_transformer_changed.connect( + self._on_workflow_post_transformer_changed + ) + super().add_child(child, index) + return child + + def remove_child(self, child: DocItem): + if isinstance(child, Workflow): + # Check if the workflow actually exists before trying to disconnect + wf = self.workflow + if wf and wf is child: + wf.per_step_transformer_changed.disconnect( + self._on_workflow_post_transformer_changed + ) + super().remove_child(child) + + def set_children(self, new_children: Iterable[DocItem]): + # Disconnect any existing workflow signal handlers + if self.workflow: + self.workflow.per_step_transformer_changed.disconnect( + self._on_workflow_post_transformer_changed + ) + + # Connect to the new ones + for child in new_children: + if isinstance(child, Workflow): + child.per_step_transformer_changed.connect( + self._on_workflow_post_transformer_changed + ) + + super().set_children(new_children) + + @property + def active(self) -> bool: + """ + Returns True if this layer is the currently active layer in the + document. + """ + return self.doc.active_layer is self if self.doc else False + + def set_name(self, name: str): + """Sets the name of the layer. + + Args: + name: The new name for the layer. + """ + if self.name == name: + return + self.name = name + 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. + + Args: + visible: The new visibility state. + """ + if self.visible == visible: + return + self.visible = visible + self.updated.send(self) + + def set_rotary_enabled(self, enabled: bool): + """Enable or disable rotary attachment mode.""" + if self.rotary_enabled == enabled: + return + self.rotary_enabled = enabled + self.updated.send(self) + + def set_rotary_diameter(self, diameter: float): + """Set the diameter of the object on the rotary attachment.""" + if self.rotary_diameter == diameter: + return + self.rotary_diameter = diameter + self.updated.send(self) + + def set_rotary_module_uid(self, uid: str | None): + if self.rotary_module_uid == uid: + return + self.rotary_module_uid = uid + self.updated.send(self) + + def set_color(self, color: str): + if self.color == color: + return + self.color = color + self.updated.send(self) + + def get_subtitle(self, rotary_module_name: str | None = None) -> str: + """Returns a subtitle describing the layer type. + + Args: + rotary_module_name: The name of the selected rotary module, + if any. The caller is responsible for resolving the + layer's ``rotary_module_uid`` to a name. + + Returns: + A human-readable subtitle string (e.g. ``"Flat"`` or + ``"Rotary · Chuck Rotary"``). + """ + if not self.rotary_enabled: + return _("Flat") + if rotary_module_name: + return _("Rotary · {name}").format(name=rotary_module_name) + return _("Rotary") + + def set_wcs(self, wcs: str | None): + if self.wcs == wcs: + return + self.wcs = wcs + self.updated.send(self) + + def get_effective_wcs(self, machine) -> str: + """Return this layer's WCS or the machine's active WCS.""" + return self.wcs if self.wcs else machine.active_wcs + + def mm_to_degrees(self, mm: float) -> float: + """Convert surface mm to degrees for rotary axis.""" + if self.rotary_diameter <= 0: + return 0.0 + circumference = self.rotary_diameter * math.pi + return (mm / circumference) * 360.0 + + def add_workpiece(self, workpiece: WorkPiece): + """Adds a single workpiece to the layer.""" + self.add_child(workpiece) + + def remove_workpiece(self, workpiece: WorkPiece): + """Removes a single workpiece from the layer.""" + self.remove_child(workpiece) + + def set_workpieces(self, workpieces: list[WorkPiece]): + """ + Sets the layer's workpieces to a new list, preserving the + existing workflow and groups. + """ + groups = [c for c in self.children if isinstance(c, Group)] + current_workflow = self.workflow + new_children: list[DocItem] = [] + new_children.extend(workpieces) + new_children.extend(groups) + if current_workflow: + new_children.append(current_workflow) + self.set_children(new_children) + + def reorder_workpieces(self, new_workpiece_order: list[WorkPiece]): + """ + Reorders workpieces while preserving the positions of groups + and the workflow. + """ + wp_iter = iter(new_workpiece_order) + new_children = [] + for child in self.children: + if isinstance(child, WorkPiece): + new_children.append(next(wp_iter)) + else: + new_children.append(child) + self.set_children(new_children) + + def reorder_content_items(self, new_content_order: list[DocItem]): + """ + Reorders all content items (WorkPieces and Groups) while + preserving the workflow's position. + """ + item_iter = iter(new_content_order) + new_children = [] + for child in self.children: + if isinstance(child, Workflow): + new_children.append(child) + else: + new_children.append(next(item_iter)) + self.set_children(new_children) + + def get_renderable_items(self) -> list[tuple[Step, WorkPiece]]: + """ + Gets a list of all visible step/workpiece pairs for rendering. + + Returns: + A list of (Step, WorkPiece) tuples that are currently + visible and have valid geometry for rendering. + """ + if not self.visible or not self.workflow: + return [] + items = [] + # Use the correct recursive method to find all workpieces + for workpiece in self.all_workpieces: + if any(s <= 0 for s in workpiece.size): + continue + for step in self.workflow.steps: + if step.visible: + items.append((step, workpiece)) + return items diff --git a/rayforge/core/library_manager.py b/rayforge/core/library_manager.py new file mode 100644 index 000000000..2a319fb4b --- /dev/null +++ b/rayforge/core/library_manager.py @@ -0,0 +1,405 @@ +"""Library manager for material libraries in Rayforge.""" + +import logging +import shutil +import uuid +from pathlib import Path + +from blinker import Signal + +from .material import Material +from .material_library import MaterialLibrary + +logger = logging.getLogger(__name__) + + +class LibraryManager: + """ + Application-wide manager for material libraries. + + Manages multiple MaterialLibrary instances. User libraries are + stored in the user_dir and are writable. Addon libraries are + registered via add_library() and are typically read-only. + """ + + def __init__(self, user_dir: Path): + """ + Initialize the library manager. + + Args: + user_dir: Directory for user materials (writable) + """ + self.user_dir = user_dir + self.libraries_changed = Signal() + + self._libraries: dict[str, MaterialLibrary] = {} + # Track which addon registered each library (library_id -> addon_name) + self._library_addons: dict[str, str] = {} + + self.user_dir.mkdir(parents=True, exist_ok=True) + + def load_all_libraries(self) -> None: + """Load all user material libraries from user_dir.""" + self._libraries.clear() + + for path in self.user_dir.iterdir(): + if path.is_dir(): + user_library = MaterialLibrary(path, read_only=False) + user_library.load_materials() + lib_id = user_library.library_id + self._libraries[lib_id] = user_library + logger.debug(f"Loaded user library: {lib_id}") + + logger.info(f"Loaded {len(self._libraries)} material libraries") + + def create_user_library(self, display_name: str) -> str | None: + """ + Creates a new user library and returns its ID. + + Args: + display_name: The human-readable name for the new library. + + Returns: + The unique ID of the new library, or None on failure. + """ + if not display_name: + return None + + # Generate a unique directory name + temp_id = str(uuid.uuid4()) + lib_dir = self.user_dir / temp_id + + # Delegate library creation to MaterialLibrary + library = MaterialLibrary.create(lib_dir, display_name) + + if library is not None: + # Use the library's own ID as the key + lib_id = library.library_id + self._libraries[lib_id] = library + self.libraries_changed.send(self) + return lib_id + else: + return None + + def remove_user_library(self, library_id: str) -> bool: + """Removes a writable library by its ID.""" + if library_id not in self._libraries: + logger.error(f"Library with ID '{library_id}' not found.") + return False + + library = self._libraries[library_id] + if library.read_only: + logger.error(f"Cannot remove read-only library '{library_id}'.") + return False + + try: + shutil.rmtree(library._directory) + del self._libraries[library_id] + logger.info(f"Removed library: {library.display_name}") + self.libraries_changed.send(self) + return True + except OSError as e: + logger.error(f"Failed to remove library '{library_id}': {e}") + return False + + def update_library(self, library_id: str) -> bool: + """ + Save library changes to disk by delegating to the library's save + method. + + Args: + library_id: ID of the library to update + + Returns: + True if updated successfully, False otherwise + """ + if library_id not in self._libraries: + logger.error(f"Library with ID '{library_id}' not found.") + return False + + library = self._libraries[library_id] + return library.save() + + def get_library(self, library_id: str) -> MaterialLibrary | None: + """ + Get a library by ID. + + Args: + library_id: ID of the library + + Returns: + MaterialLibrary instance or None if not found + """ + if not self._libraries: + self.load_all_libraries() + + return self._libraries.get(library_id) + + def get_libraries(self) -> list[MaterialLibrary]: + """ + Get all libraries. + + Returns: + List of all MaterialLibrary instances + """ + if not self._libraries: + self.load_all_libraries() + + return list(self._libraries.values()) + + def get_material(self, uid: str) -> Material | None: + """ + Get a material by UID, searching all libraries. + + Args: + uid: Unique identifier of the material + + Returns: + Material instance or None if not found + """ + if not self._libraries: + self.load_all_libraries() + + # Prioritize writable libraries over read-only ones. + sorted_libs = sorted( + self._libraries.values(), + key=lambda lib: ( + lib.read_only, + lib.library_id, + ), + ) + + for library in sorted_libs: + material = library.get_material(uid) + if material: + return material + + return None + + def get_material_or_none(self, uid: str) -> Material | None: + """ + Get a material by UID with graceful fallback. + + This method never raises an exception and always returns + either a Material instance or None. + + Args: + uid: Unique identifier of the material + + Returns: + Material instance or None if not found + """ + try: + return self.get_material(uid) + except OSError as e: + logger.warning(f"Error getting material {uid}: {e}") + return None + + def resolve_material(self, uid: str) -> Material | None: + """ + Resolve a material reference with fallback handling. + + Similar to get_material_or_none but with additional logging + for debugging missing material references. + + Args: + uid: Unique identifier of the material + + Returns: + Material instance or None if not found + """ + material = self.get_material_or_none(uid) + + if material is None and uid: + logger.debug(f"Material reference '{uid}' could not be resolved") + + return material + + def add_material(self, material: Material, library_id: str) -> bool: + """ + Add a material to a library. + + Args: + material: Material to add + library_id: ID of the library to add to. + + Returns: + True if added successfully, False otherwise + """ + if not self._libraries: + self.load_all_libraries() + + if library_id not in self._libraries: + logger.error(f"Library '{library_id}' not found") + return False + + library = self._libraries[library_id] + return library.add_material(material) + + def remove_material(self, uid: str, library_id: str) -> bool: + """ + Remove a material from a library. + + Args: + uid: Unique identifier of the material + library_id: ID of the library to remove from. + + Returns: + True if removed successfully, False otherwise + """ + if not self._libraries: + self.load_all_libraries() + + if library_id not in self._libraries: + logger.error(f"Library '{library_id}' not found") + return False + + library = self._libraries[library_id] + return library.remove_material(uid) + + def get_all_materials(self) -> list[Material]: + """ + Get all materials from all libraries. + + Returns: + List of all materials, with user materials first + """ + if not self._libraries: + self.load_all_libraries() + all_materials = [] + + # Add writable library materials first + for lib in self.get_libraries(): + if not lib.read_only: + all_materials.extend(lib.get_all_materials()) + + # Add materials from read-only libraries + for lib in self.get_libraries(): + if lib.read_only: + all_materials.extend(lib.get_all_materials()) + + return all_materials + + def reload_libraries(self) -> None: + """Reload all libraries from disk.""" + self.load_all_libraries() + self.libraries_changed.send(self) + logger.info("Reloaded all material libraries") + + def get_library_ids(self) -> list[str]: + """ + Get the IDs of all libraries. + + Returns: + List of library IDs + """ + if not self._libraries: + self.load_all_libraries() + + return list(self._libraries.keys()) + + def add_library( + self, + library: MaterialLibrary, + addon_name: str | None = None, + allow_overwrite: bool = False, + ) -> bool: + """ + Add a MaterialLibrary to the manager. + + Args: + library: The MaterialLibrary instance to add + addon_name: Optional addon name for tracking ownership + allow_overwrite: If True, allows replacing an existing library + with the same ID + + Returns: + True if added successfully, False otherwise + """ + lib_id = library.library_id + if lib_id in self._libraries and not allow_overwrite: + logger.error(f"Library with ID '{lib_id}' already exists") + return False + + self._libraries[lib_id] = library + if addon_name: + self._library_addons[lib_id] = addon_name + logger.info(f"Added library: {library.display_name} ({lib_id})") + self.libraries_changed.send(self) + return True + + def add_library_from_path( + self, + path: Path, + read_only: bool = True, + addon_name: str | None = None, + ) -> str | None: + """ + Create and add a MaterialLibrary from a directory path. + + This is a convenience method for addons to register material + libraries from a filesystem path. + + Args: + path: Directory containing material YAML files + read_only: If True (default), the library cannot be modified + addon_name: Optional addon name for tracking ownership + + Returns: + The library ID if added successfully, None otherwise + """ + if not path.exists() or not path.is_dir(): + logger.error( + f"Library path does not exist or is not a directory: {path}" + ) + return None + + library = MaterialLibrary(path, read_only=read_only) + library.load_materials() + + if self.add_library(library, addon_name=addon_name): + return library.library_id + return None + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Remove all libraries registered by a specific addon. + + Args: + addon_name: The name of the addon whose libraries should be removed + + Returns: + The number of libraries removed + """ + lib_ids_to_remove = [ + lib_id + for lib_id, name in self._library_addons.items() + if name == addon_name + ] + + for lib_id in lib_ids_to_remove: + if lib_id in self._libraries: + del self._libraries[lib_id] + del self._library_addons[lib_id] + + if lib_ids_to_remove: + logger.info( + f"Removed {len(lib_ids_to_remove)} libraries from addon " + f"'{addon_name}'" + ) + self.libraries_changed.send(self) + + return len(lib_ids_to_remove) + + def __len__(self) -> int: + """Get the total number of materials across all libraries.""" + return len(self.get_all_materials()) + + def __str__(self) -> str: + """String representation of the library manager.""" + if not self._libraries: + self.load_all_libraries() + return ( + f"LibraryManager(libraries={len(self._libraries)}, " + f"materials={len(self)})" + ) diff --git a/rayforge/core/material.py b/rayforge/core/material.py new file mode 100644 index 000000000..b10db3f06 --- /dev/null +++ b/rayforge/core/material.py @@ -0,0 +1,256 @@ +"""Core material data structures for Rayforge.""" + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +import yaml + +from ..shared.util.localized import LocalizedField + +# Accept both plain strings and LocalizedField as input +LocalizedInput = str | LocalizedField + +logger = logging.getLogger(__name__) + + +@dataclass +class MaterialAppearance: + """Defines the visual properties of a material.""" + + color: str = "#f0f0f0" + pattern: str = "solid" + extra: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "MaterialAppearance": + """Create an instance from a dictionary.""" + known_keys = {"color", "pattern"} + extra = {k: v for k, v in data.items() if k not in known_keys} + + return cls( + color=data.get("color", cls.color), + pattern=data.get("pattern", "solid"), + extra=extra, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the appearance to a dictionary.""" + result = {"color": self.color, "pattern": self.pattern} + result.update(self.extra) + return result + + +@dataclass +class Material: + """ + A pure data class representing a material in Rayforge. + + Materials define the visual and physical properties of stock items + that can be cut or engraved. + + Note: LocalizedField handles all localization transparently. + This class doesn't need to know about context or language. + """ + + uid: str + name: LocalizedInput = field(default_factory=lambda: LocalizedField("")) + description: LocalizedInput = field( + default_factory=lambda: LocalizedField("") + ) + category: LocalizedInput = field( + default_factory=lambda: LocalizedField("") + ) + appearance: MaterialAppearance = field(default_factory=MaterialAppearance) + file_path: Path | None = None + extra: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + """Post-initialization validation and setup.""" + # Convert plain strings to LocalizedField + if not isinstance(self.name, LocalizedField): + self.name = LocalizedField( + str(self.name) if self.name else self.uid + ) + elif not self.name: + self.name = LocalizedField(self.uid) + + if not isinstance(self.description, LocalizedField): + self.description = LocalizedField(str(self.description)) + + if not isinstance(self.category, LocalizedField): + self.category = LocalizedField(str(self.category)) + + @classmethod + def from_file(cls, file_path: Path) -> "Material": + """ + Create a Material instance from a YAML file. + + Args: + file_path: Path to the YAML file containing material data + + Returns: + Material instance with data loaded from the file + + Raises: + FileNotFoundError: If the file doesn't exist + yaml.YAMLError: If the file contains invalid YAML + ValueError: If required fields are missing + """ + if not file_path.exists(): + raise FileNotFoundError(f"Material file not found: {file_path}") + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + raise yaml.YAMLError( + f"Invalid YAML in material file {file_path}: {e}" + ) + + if not isinstance(data, dict): + raise TypeError( + f"Material file {file_path} must contain a dictionary" + ) + + # Extract required UID from filename or data + uid = data.get("uid", file_path.stem) + + # Extract known keys + known_keys = { + "uid", + "name", + "description", + "category", + "appearance", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + # Parse fields - LocalizedField handles both simple and localized + # format + name = LocalizedField.from_yaml(data.get("name", uid)) + description = LocalizedField.from_yaml(data.get("description", "")) + category = LocalizedField.from_yaml(data.get("category", "")) + + material = cls( + uid=uid, + name=name, + description=description, + category=category, + appearance=MaterialAppearance.from_dict( + data.get("appearance", {}) + ), + file_path=file_path, + extra=extra, + ) + + return material + + def to_dict(self) -> dict[str, Any]: + """ + Convert the material to a dictionary representation. + + Returns: + Dictionary containing all material data + """ + result = { + "uid": self.uid, + "name": cast(LocalizedField, self.name).to_yaml(), + "description": cast(LocalizedField, self.description).to_yaml(), + "category": cast(LocalizedField, self.category).to_yaml(), + "appearance": self.appearance.to_dict(), + } + result.update(self.extra) + return result + + def save_to_file(self, file_path: Path | None = None) -> None: + """ + Save the material to a YAML file. + + Args: + file_path: Path to save the file. If None, uses self.file_path + """ + target_path = file_path or self.file_path + if not target_path: + raise ValueError("No file path specified for saving material") + + # Ensure directory exists + target_path.parent.mkdir(parents=True, exist_ok=True) + + data = self.to_dict() + + with open(target_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + self.file_path = target_path + logger.info(f"Saved material '{self.uid}' to {target_path}") + + def get_display_color(self) -> str: + """ + Get the display color for the material. + + Returns: + Hex color string or default if not specified + """ + return self.appearance.color + + def get_display_rgba( + self, alpha: float = 1.0 + ) -> tuple[float, float, float, float]: + """ + Get the display color as RGBA tuple. + + Args: + alpha: Alpha value (0.0 to 1.0) + + Returns: + Tuple of (r, g, b, a) values in 0.0-1.0 range + """ + color_hex = self.appearance.color + color_pattern = r"^#?([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$" + match = re.match(color_pattern, color_hex) + if match: + r, g, b = tuple(int(c, 16) / 255.0 for c in match.groups()) + return (r, g, b, alpha) + else: + # Fallback to default gray if color format is invalid + return (0.5, 0.5, 0.5, alpha) + + def get_pattern(self) -> str: + """ + Get the visual pattern for the material. + + Returns: + Pattern name or 'solid' if not specified + """ + return self.appearance.pattern + + def matches_search(self, query: str) -> bool: + """ + Check if the material matches a search query in any language. + + Args: + query: Search string (case-insensitive) + + Returns: + True if query matches name, description, or category in any + language + """ + return ( + cast(LocalizedField, self.name).matches(query) + or cast(LocalizedField, self.description).matches(query) + or cast(LocalizedField, self.category).matches(query) + ) + + def __str__(self) -> str: + """String representation of the material.""" + return f"Material(uid='{self.uid}', name='{self.name}')" + + def __repr__(self) -> str: + """Detailed string representation of the material.""" + return ( + f"Material(uid='{self.uid}', name='{self.name}', " + f"category='{self.category}', description='{self.description}')" + ) diff --git a/rayforge/core/material_library.py b/rayforge/core/material_library.py new file mode 100644 index 000000000..f606f6b8b --- /dev/null +++ b/rayforge/core/material_library.py @@ -0,0 +1,373 @@ +"""Material library management for Rayforge.""" + +import logging +from pathlib import Path +from typing import Optional + +import yaml + +from .material import Material + +logger = logging.getLogger(__name__) + + +class MaterialLibrary: + """ + Represents a single material library. + + Manages loading materials from a directory and handles read-only + vs writable libraries. + """ + + def __init__(self, directory: Path, read_only: bool = True): + """ + Initialize a material library. + + Args: + directory: Directory containing material files + read_only: If True, the library cannot be modified + """ + self._directory = directory + self._read_only = read_only + self._materials: dict[str, Material] = {} + self._loaded = False + self._display_name: str = "" + self._library_id: str = "" + + @property + def display_name(self) -> str: + """Get the human-readable display name for the library.""" + if not self._loaded: + self.load_materials() + + return self._display_name or self._directory.name + + def set_display_name(self, display_name: str) -> None: + """ + Set the display name for the library. + + This method only updates the in-memory display name. To persist + the change, the library must be saved by calling save(). + + Args: + display_name: New display name for the library + """ + self._display_name = display_name + + @property + def library_id(self) -> str: + """Get the library ID from metadata.""" + if not self._loaded: + self.load_materials() + return self._library_id or self._directory.name + + @property + def read_only(self) -> bool: + """Whether the library is read-only.""" + return self._read_only + + @property + def is_loaded(self) -> bool: + """Check if the library has been loaded.""" + return self._loaded + + @classmethod + def create( + cls, directory: Path, display_name: str + ) -> Optional["MaterialLibrary"]: + """ + Create a new material library with the given display name. + + This class method handles the creation of the directory structure + and metadata file for a new library. + + Args: + directory: Directory where the library should be created + display_name: The human-readable name for the new library + + Returns: + MaterialLibrary instance if created successfully, None otherwise + """ + logger.debug( + f"MaterialLibrary.create called with directory={directory}, " + f"display_name={display_name}" + ) + + if not display_name: + logger.error("Cannot create library with empty display name") + return None + + if directory.exists(): + logger.error(f"Library directory '{directory}' already exists") + return None + + try: + # Create the directory + directory.mkdir(parents=True, exist_ok=True) + logger.debug(f"Created directory: {directory}") + + # Create library with a generated UUID + import uuid + + lib_id = str(uuid.uuid4()) + logger.debug(f"Generated library ID: {lib_id}") + + library = cls(directory, read_only=False) + library._display_name = display_name + library._library_id = lib_id + library._loaded = ( + True # Mark as loaded since we're setting the values directly + ) + + # Save the metadata + save_result = library.save() + logger.debug(f"Library.save() returned: {save_result}") + + if save_result: + logger.info( + f"Created new user library: {display_name} ({lib_id})" + ) + return library + else: + # Clean up directory if save failed + import shutil + + shutil.rmtree(directory) + logger.error("Library save failed, cleaned up directory") + return None + + except OSError as e: + logger.error(f"Failed to create library '{display_name}': {e}") + return None + + def load_materials(self) -> None: + """ + Load all materials from the library directory. + + Scans the directory for .yaml files and loads them as materials. + Invalid files are skipped with warnings. + """ + if self._loaded: + return + + self._materials.clear() + self._display_name = "" + + if not self._directory.exists(): + if not self.read_only: + self._directory.mkdir(parents=True, exist_ok=True) + logger.info( + f"Created material library directory: {self._directory}" + ) + else: + logger.warning( + f"Material library directory not found: {self._directory}" + ) + return + + # Load library metadata first + meta_file = self._directory / "__library__.yaml" + if meta_file.is_file(): + try: + with open(meta_file, "r", encoding="utf-8") as f: + meta_data = yaml.safe_load(f) + if isinstance(meta_data, dict): + self._display_name = meta_data.get("name", "") + self._library_id = meta_data.get("id", "") + except (OSError, yaml.YAMLError) as e: + logger.warning( + f"Could not load library metadata from {meta_file}: {e}" + ) + + # Load all YAML files in the directory + for file_path in self._directory.glob("*.yaml"): + if file_path.name == "__library__.yaml": + continue # Skip metadata file + + try: + material = Material.from_file(file_path) + self._materials[material.uid] = material + logger.debug(f"Loaded material: {material.uid}") + except (OSError, yaml.YAMLError) as e: + logger.warning( + f"Failed to load material from {file_path}: {e}" + ) + + self._loaded = True + logger.info( + f"Loaded {len(self._materials)} materials from " + f"{self._directory.name}" + ) + + def get_material(self, uid: str) -> Material | None: + """ + Get a material by UID. + + Args: + uid: Unique identifier of the material + + Returns: + Material instance or None if not found + """ + if not self._loaded: + self.load_materials() + + return self._materials.get(uid) + + def get_all_materials(self) -> list[Material]: + """ + Get all materials in the library. + + Returns: + List of all materials + """ + if not self._loaded: + self.load_materials() + + return list(self._materials.values()) + + def add_material(self, material: Material) -> bool: + """ + Add a material to the library. + + Args: + material: Material to add + + Returns: + True if added successfully, False if read-only or already exists + """ + if self.read_only: + logger.warning( + f"Cannot add material to read-only library: " + f"{self._directory.name}" + ) + return False + + if material.uid in self._materials: + logger.warning( + f"Material {material.uid} already exists in " + f"{self._directory.name}" + ) + return False + + # Save material to file + file_path = self._directory / f"{material.uid}.yaml" + try: + material.save_to_file(file_path) + self._materials[material.uid] = material + logger.info( + f"Added material {material.uid} to {self._directory.name}" + ) + return True + except OSError as e: + logger.error(f"Failed to save material {material.uid}: {e}") + return False + + def remove_material(self, uid: str) -> bool: + """ + Remove a material from the library. + + Args: + uid: Unique identifier of the material to remove + + Returns: + True if removed successfully, False if read-only or not found + """ + if self.read_only: + logger.warning( + f"Cannot remove material from read-only library: " + f"{self._directory.name}" + ) + return False + + if uid not in self._materials: + logger.warning( + f"Material {uid} not found in {self._directory.name}" + ) + return False + + material = self._materials[uid] + + # Remove file if it exists + if material.file_path and material.file_path.exists(): + try: + material.file_path.unlink() + logger.info(f"Removed material file: {material.file_path}") + except OSError as e: + logger.error(f"Failed to remove material file: {e}") + return False + + # Remove from memory + del self._materials[uid] + logger.info(f"Removed material {uid} from {self._directory.name}") + return True + + def save(self) -> bool: + """ + Save library metadata to disk. + + This method persists the current state of the library to its + metadata file. The library directory itself is never renamed. + + Returns: + True if saved successfully, False otherwise + """ + if self._read_only: + logger.warning( + f"Cannot save read-only library: {self._directory.name}" + ) + return False + + try: + meta_file = self._directory / "__library__.yaml" + + # Read existing metadata if it exists + existing_data = {} + if meta_file.is_file(): + with open(meta_file, "r", encoding="utf-8") as f: + existing_data = yaml.safe_load(f) or {} + + # Update with current library data + existing_data["name"] = self._display_name or self._directory.name + existing_data["id"] = self.library_id + + # Write updated metadata + with open(meta_file, "w", encoding="utf-8") as f: + yaml.dump(existing_data, f, sort_keys=False) + + logger.info(f"Saved library: {self.display_name}") + return True + except OSError as e: + logger.error(f"Failed to save library: {e}") + return False + + def reload(self) -> None: + """Reload all materials from the directory.""" + self._loaded = False + self.load_materials() + + def __len__(self) -> int: + """Get the number of materials in the library.""" + if not self._loaded: + self.load_materials() + return len(self._materials) + + def __contains__(self, uid: str) -> bool: + """Check if a material UID exists in the library.""" + if not self._loaded: + self.load_materials() + return uid in self._materials + + def __iter__(self): + """Iterate over materials in the library.""" + if not self._loaded: + self.load_materials() + return iter(self._materials.values()) + + def __str__(self) -> str: + """String representation of the library.""" + return ( + f"MaterialLibrary(name='{self._directory.name}', " + f"materials={len(self)}, " + f"read_only={self.read_only})" + ) diff --git a/rayforge/core/matrix.py b/rayforge/core/matrix.py new file mode 100644 index 000000000..138718614 --- /dev/null +++ b/rayforge/core/matrix.py @@ -0,0 +1,21 @@ +import numpy as np + + +def euler_rotation_matrix(rx: float, ry: float, rz: float) -> np.ndarray: + """ + Build a 3x3 rotation matrix from Euler angles in degrees. + + The rotation order is X -> Y -> Z (extrinsic Tait-Bryan angles). + """ + ax, ay, az = np.radians(rx), np.radians(ry), np.radians(rz) + cx, sx = np.cos(ax), np.sin(ax) + cy, sy = np.cos(ay), np.sin(ay) + cz, sz = np.cos(az), np.sin(az) + return np.array( + [ + [cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz], + [cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz], + [-sy, sx * cy, cx * cy], + ], + dtype=np.float64, + ) diff --git a/rayforge/core/model.py b/rayforge/core/model.py new file mode 100644 index 000000000..9cf49c445 --- /dev/null +++ b/rayforge/core/model.py @@ -0,0 +1,94 @@ +"""Core 3D model data structures for Rayforge.""" + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class ModelLibrary: + """ + Represents a source of 3D models. + + Each library has a root ``path`` on the filesystem. Model files + (``.glb``, ``.gltf``) are discovered directly in the root + directory. + + Attributes: + library_id: Unique identifier (e.g. "core", "device:K40"). + display_name: Human-readable name shown in the UI. + path: Root directory containing model files. + read_only: Whether the library is read-only. + icon_name: Icon name for the UI row. + """ + + library_id: str + display_name: str + path: Path + read_only: bool = False + icon_name: str = "folder-symbolic" + + +@dataclass +class Model: + """ + A data class representing a 3D model asset in Rayforge. + + Models are referenced by a relative path (e.g. + ``Path("head.glb")``) and resolved by the + ``ModelManager`` by searching registered libraries in order. + """ + + name: str + path: Path + description: str = "" + extra: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_path(cls, path: Path) -> "Model": + """Create a Model from a path, deriving the name from the stem.""" + return cls(name=path.stem, path=path) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Model": + """ + Create a Model instance from a dictionary. + + Args: + data: Dictionary with model data. Must contain at + least ``name`` and ``path``. + + Returns: + A new Model instance. + """ + known_keys = {"name", "path", "description"} + extra = {k: v for k, v in data.items() if k not in known_keys} + + raw_path = data.get("path", "") + return cls( + name=data.get("name", ""), + path=Path(raw_path) if raw_path else Path(), + description=data.get("description", ""), + extra=extra, + ) + + def to_dict(self) -> dict[str, Any]: + """ + Convert the model to a dictionary representation. + + Returns: + Dictionary containing all model data. + """ + result: dict[str, Any] = { + "name": self.name, + "path": str(self.path), + "description": self.description, + } + result.update(self.extra) + return result + + def __str__(self) -> str: + return f"Model(name='{self.name}', path='{self.path}')" diff --git a/rayforge/core/model_manager.py b/rayforge/core/model_manager.py new file mode 100644 index 000000000..76f007887 --- /dev/null +++ b/rayforge/core/model_manager.py @@ -0,0 +1,208 @@ +"""Model manager for 3D model assets in Rayforge.""" + +import importlib.resources +import logging +from gettext import gettext as _ +from pathlib import Path + +from blinker import Signal + +from ..addon_mgr.addon_manager import AddonRegistry +from .model import Model, ModelLibrary + +logger = logging.getLogger(__name__) + +VALID_MODEL_EXTENSIONS = {".glb", ".gltf"} + + +class ModelManager(AddonRegistry): + """ + Application-wide read-only resolver for 3D model assets. + + Maintains an ordered collection of :class:`ModelLibrary` + instances. When resolving a model path the libraries are + searched in registration order, so earlier libraries take + precedence. + + Libraries are registered by the core bundled resources, + device profiles, and addons. There is no user-writable + library — models travel with device profiles. + """ + + def __init__(self): + self.changed = Signal() + self._libraries: dict[str, ModelLibrary] = {} + self._library_addons: dict[str, str | None] = {} + + def get_libraries(self) -> list[ModelLibrary]: + """Return all registered libraries in registration order.""" + return list(self._libraries.values()) + + def add_library( + self, + library: ModelLibrary, + addon_name: str | None = None, + ) -> bool: + """ + Register a model library. + + Args: + library: The library to add. + addon_name: If provided, tracks ownership so the library + can be removed when the addon is disabled. + + Returns: + ``True`` if added, ``False`` if *library_id* already + exists. + """ + if library.library_id in self._libraries: + return False + self._libraries[library.library_id] = library + self._library_addons[library.library_id] = addon_name + return True + + def add_library_from_path( + self, + path: Path, + display_name: str | None = None, + read_only: bool = True, + addon_name: str | None = None, + ) -> str | None: + """ + Create and register a library from a directory path. + + Convenience method for addons. + + Args: + path: Root directory for the library. + display_name: Human-readable name (defaults to directory + name). + read_only: Whether the library is read-only. + addon_name: Owning addon, if any. + + Returns: + The *library_id* on success, ``None`` on failure. + """ + library_id = path.name + library = ModelLibrary( + library_id=library_id, + display_name=display_name or path.name.title(), + path=path, + read_only=read_only, + ) + if self.add_library(library, addon_name=addon_name): + return library.library_id + return None + + def register_bundled_library(self): + """Register the bundled (core) model library if available.""" + core_path = self._get_bundled_path() + if core_path is None: + return + lib = ModelLibrary( + library_id="core", + display_name=_("Core"), + path=core_path, + read_only=True, + icon_name="addon-builtin-symbolic", + ) + self.add_library(lib) + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Remove all libraries registered by the named addon. + + Implements the AddonRegistry protocol. + """ + to_remove = [ + lid + for lid, name in self._library_addons.items() + if name == addon_name + ] + for lid in to_remove: + if lid in self._libraries: + del self._libraries[lid] + del self._library_addons[lid] + if to_remove: + self.changed.send(self) + return len(to_remove) + + def resolve(self, model: Model) -> Path | None: + """ + Resolve a Model to an absolute filesystem path. + + Searches registered libraries in order. Absolute paths are + returned directly if the file exists. + """ + path = model.path + + if path.is_absolute(): + return path if path.is_file() else None + + for lib in self._libraries.values(): + full = lib.path / path + if full.is_file(): + return full + + return None + + def get_models(self, library: ModelLibrary) -> list[Model]: + """ + List model files directly in the library root directory. + + Args: + library: The ModelLibrary to query. + + Returns: + Sorted list of Model instances. + """ + target = library.path + if not target.is_dir(): + return [] + files = sorted( + p + for p in target.iterdir() + if p.is_file() + and not p.name.startswith(".") + and p.suffix.lower() in VALID_MODEL_EXTENSIONS + ) + return [ + Model( + name=f.stem, + path=Path(f.name), + ) + for f in files + ] + + def get_all_models(self) -> list[Model]: + """ + List all models across all libraries. + + Deduplicates by filename — earlier libraries take precedence. + + Returns: + Sorted list of Model instances. + """ + seen_filenames: set = set() + combined: list[Model] = [] + for lib in self._libraries.values(): + models = self.get_models(lib) + for m in models: + if m.path.name not in seen_filenames: + combined.append(m) + seen_filenames.add(m.path.name) + return sorted(combined, key=lambda m: m.name) + + def _get_bundled_path(self) -> Path | None: + try: + from rayforge.resources import models as resource_models + + p = Path(str(importlib.resources.files(resource_models))) + if p.is_dir(): + return p + except (FileNotFoundError, ModuleNotFoundError): + pass + return None + + def __str__(self) -> str: + return f"ModelManager(libraries={len(self._libraries)})" diff --git a/rayforge/core/recipe.py b/rayforge/core/recipe.py new file mode 100644 index 000000000..8abe9d49c --- /dev/null +++ b/rayforge/core/recipe.py @@ -0,0 +1,406 @@ +import logging +import math +import uuid +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +from .step import Step +from .step_registry import step_registry + +if TYPE_CHECKING: + from ..machine.models.machine import Machine + from .stock import StockItem + +logger = logging.getLogger(__name__) + +# Specificity score contributed by the step-type axis when a recipe is +# generic (matches any step type). It must rank as the least specific +# option, so it is larger than any realistic ``len(target_step_types)``. +_GENERIC_STEP_TYPE_SCORE = 1 << 16 + +# Migration shim: maps the legacy ``target_capability_name`` values to +# the list of step class names that declared that capability, captured +# at the time step capabilities were removed. Used only by +# :meth:`Recipe.from_dict` to preserve the targeting of old recipe files. +_LEGACY_CAPABILITY_STEPS: dict[str, list[str]] = { + "CUT": ["ContourStep", "FrameStep", "WavefrontStep", "ShrinkWrapStep"], + "SCORE": ["ContourStep", "FrameStep", "ShrinkWrapStep"], + "ENGRAVE": ["EngraveStep"], + "MATERIAL_TEST": ["MaterialTestStep"], +} + + +@dataclass +class Recipe: + """ + A preset for configuring a step based on context, such as material + and thickness. This is a pure data object. + + A recipe applies to one or more step types (identified by their + class name as registered in ``step_registry``). When + :attr:`target_step_types` is empty the recipe is generic and matches + any step type. + """ + + uid: str = field(default_factory=lambda: str(uuid.uuid4())) + name: str = "New Recipe" + description: str = "" + + # --- Applicability Criteria --- + target_step_types: list[str] = field(default_factory=list) + target_machine_id: str | None = None + material_uid: str | None = None + min_thickness_mm: float | None = None + max_thickness_mm: float | None = None + + # --- Payload --- + # A single dictionary of settings to be applied. + settings: dict[str, Any] = field(default_factory=dict) + + # Post-processor (transformer) settings captured by this recipe. + # Each dict carries ``name``, ``enabled``, ``recipe_apply`` (False = + # "Leave unchanged"), plus the transformer's own params. + transformer_dicts: list[dict[str, Any]] = field(default_factory=list) + + # Forward compatibility: store unknown attributes + extra: dict[str, Any] = field(default_factory=dict) + + def matches_step_settings( + self, + step: "Step", + tolerance=1e-6, + ) -> bool: + """ + Compares this recipe's settings against a Step object's current + settings. Only keys present in the recipe are checked. + """ + for key, recipe_val in self.settings.items(): + if not hasattr(step, key): + return False # Step is missing an attribute the recipe defines + + step_val = getattr(step, key) + + if isinstance(step_val, float) and isinstance(recipe_val, float): + if not math.isclose( + step_val, recipe_val, rel_tol=0, abs_tol=tolerance + ): + return False + elif step_val != recipe_val: + return False + return True + + def matches_step_transformers( + self, + step: "Step", + tolerance: float = 1e-6, + ) -> bool: + """Compare recipe's ``recipe_apply=True`` transformers to the + step's transformers by name + params. + + Only transformer dicts with ``recipe_apply=True`` are checked. + Each is matched against the step's + ``per_workpiece_transformers_dicts`` + + ``per_step_transformers_dicts`` (deduplicated by name). For + each matching transformer name, every key present in the recipe + dict (except ``recipe_apply``) is compared against the step's + dict. Floats use ``math.isclose`` with ``tolerance``. + + Returns ``True`` if the step has a matching transformer for + every recipe entry with ``recipe_apply=True``. + """ + apply_dicts = [ + d for d in self.transformer_dicts if d.get("recipe_apply", True) + ] + if not apply_dicts: + return True + step_dicts = Step._dedupe_transformer_dicts_by_name( + list(step.per_workpiece_transformers_dicts) + + list(step.per_step_transformers_dicts) + ) + for recipe_dict in apply_dicts: + name = recipe_dict.get("name") + if not name: + return False + match = step_dicts.get(name) + if match is None: + return False + for key, recipe_val in recipe_dict.items(): + if key == "recipe_apply": + continue + if key not in match: + return False + step_val = match[key] + if isinstance(step_val, float) and isinstance( + recipe_val, float + ): + if not math.isclose( + step_val, recipe_val, rel_tol=0, abs_tol=tolerance + ): + return False + elif step_val != recipe_val: + return False + return True + + def matches( + self, + stock_items: list["StockItem"], + machine: Optional["Machine"] = None, + step_type: str | None = None, + ) -> bool: + """ + Checks if this recipe is a valid candidate for the given context. + + Args: + stock_items: A list of StockItems. If empty, only generic recipes + (without material/thickness constraints) match. + Returns True if recipe matches ANY item in the list. + machine: An optional machine to filter by. + step_type: An optional step class name (as registered in + ``step_registry``). Only meaningful when + :attr:`target_step_types` is non-empty; a recipe + with an empty ``target_step_types`` matches any + step type. + + Returns: + True if the recipe is a valid match, False otherwise. + """ + # 1. Check step type compatibility + if self.target_step_types and step_type not in self.target_step_types: + # This recipe targets specific step classes. It can only + # match when a step type context is provided and is one of + # the targeted classes. + return False + + # 2. Check machine compatibility + if self.target_machine_id and ( + not machine or machine.id != self.target_machine_id + ): + # This recipe requires a specific machine. + return False + + # A recipe is considered compatible up to this point, so now check + # secondary constraints like laser head. + + # 3. Check head compatibility (if specified in settings) + target_head_uid = self.settings.get("selected_head_uid") + if target_head_uid and ( + not machine + or not any(head.uid == target_head_uid for head in machine.heads) + ): + # This recipe requires a specific head. It can only match if + # a machine context is provided and that machine has the head. + return False + + # 4. If no stock items to check against, only match generic recipes + # (recipes without material/thickness constraints) + if not stock_items: + # If recipe has material constraint, it can't match without stock + if self.material_uid is not None: + return False + # If recipe has thickness constraint, it can't match without stock + return not ( + self.min_thickness_mm is not None + or self.max_thickness_mm is not None + ) + + # 5. Check if recipe matches ANY of the stock items + for stock_item in stock_items: + if self._matches_stock(stock_item): + return True + + return False + + def _matches_stock(self, stock_item: "StockItem") -> bool: + """ + Checks if this recipe matches a single stock item. + """ + # Check material compatibility + if self.material_uid and ( + not stock_item or stock_item.material_uid != self.material_uid + ): + # This recipe requires a specific material. + return False + + # Check thickness compatibility + thickness_mm = stock_item.thickness if stock_item else None + if ( + self.min_thickness_mm is not None + or self.max_thickness_mm is not None + ): + # This recipe requires a specific thickness or range. + if thickness_mm is None: + return False # No thickness provided, cannot match. + if ( + self.min_thickness_mm is not None + and thickness_mm < self.min_thickness_mm + ): + return False + if ( + self.max_thickness_mm is not None + and thickness_mm > self.max_thickness_mm + ): + return False + + # If all checks passed, it's a match. + return True + + def get_specificity_score(self) -> tuple[int, int, int, int, int]: + """ + Calculates a score based on how specific the recipe's criteria are. + A lower score indicates a more specific (and therefore better) match. + The score is a tuple + (machine, head, material, thickness, step_type). + + For the step-type axis, a recipe targeting fewer step types is + more specific than one targeting more; a generic recipe (no + step types) is the least specific. + + Returns: + A tuple representing the specificity score. + """ + # Score 0 for specific, 1 for generic (None or not present) + machine_score = 0 if self.target_machine_id is not None else 1 + head_score = 0 if "selected_head_uid" in self.settings else 1 + material_score = 0 if self.material_uid is not None else 1 + thickness_score = ( + 0 + if self.min_thickness_mm is not None + or self.max_thickness_mm is not None + else 1 + ) + if self.target_step_types: + # Fewer targeted step types = more specific. + step_type_score = len(self.target_step_types) + else: + step_type_score = _GENERIC_STEP_TYPE_SCORE + return ( + machine_score, + head_score, + material_score, + thickness_score, + step_type_score, + ) + + def get_icon_name(self) -> str: + """An icon name representing this recipe's targeted step types. + + When exactly one step type is targeted, that step's icon is + used; otherwise the generic recipe icon. + """ + if len(self.target_step_types) == 1: + step_class = step_registry.get(self.target_step_types[0]) + if step_class is not None: + return step_class.ICON or "recipe-symbolic" + return "recipe-symbolic" + + def get_step_type_label(self) -> str | None: + """A comma-joined label of the targeted step types. + + Returns ``None`` when the recipe is generic (no step types), so + callers can decide their own fallback (e.g. "Any"). The string + may be long; UI labels should ellipsize it. + """ + if not self.target_step_types: + return None + labels = [] + for name in self.target_step_types: + step_class = step_registry.get(name) + if step_class is not None: + labels.append(step_class.TYPELABEL) + else: + labels.append(name) + return ", ".join(labels) + + def to_dict(self) -> dict[str, Any]: + """Serializes the Recipe to a dictionary suitable for YAML.""" + result = asdict(self) + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Recipe": + """Deserializes a Recipe from a dictionary. + + Migrates the legacy ``target_step_type`` (single step class) and + ``target_capability_name`` (operation category) keys into the + current :attr:`target_step_types` list. Legacy capabilities are + expanded to the step class names that declared them via + :data:`_LEGACY_CAPABILITY_STEPS`. + """ + known_keys = { + "uid", + "name", + "description", + "target_step_types", + "target_machine_id", + "material_uid", + "min_thickness_mm", + "max_thickness_mm", + "settings", + "transformer_dicts", + # Legacy targeting keys, consumed by the migration below. + "target_capability_name", + "target_step_type", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + settings = data.get("settings", {}) + # Legacy alias: old recipe files keyed head selection as + # "selected_laser_uid". + if ( + "selected_laser_uid" in settings + and "selected_head_uid" not in settings + ): + settings = dict(settings) + settings["selected_head_uid"] = settings.pop("selected_laser_uid") + + target_step_types = cls._migrate_target_step_types(data) + + transformer_dicts = data.get("transformer_dicts") or [] + if transformer_dicts and not isinstance(transformer_dicts, list): + transformer_dicts = [] + transformer_dicts = [ + dict(d) for d in transformer_dicts if isinstance(d, dict) + ] + + return cls( + uid=data.get("uid", str(uuid.uuid4())), + name=data.get("name", "Unnamed Recipe"), + description=data.get("description", ""), + target_step_types=target_step_types, + target_machine_id=data.get("target_machine_id"), + material_uid=data.get("material_uid"), + min_thickness_mm=data.get("min_thickness_mm"), + max_thickness_mm=data.get("max_thickness_mm"), + settings=settings, + transformer_dicts=transformer_dicts, + extra=extra, + ) + + @staticmethod + def _migrate_target_step_types(data: dict[str, Any]) -> list[str]: + """Resolve ``target_step_types`` from new and legacy keys.""" + if "target_step_types" in data: + return list(data.get("target_step_types") or []) + + step_types: list[str] = [] + + legacy_step_type = data.get("target_step_type") + if legacy_step_type: + step_types.append(legacy_step_type) + + legacy_capability = data.get("target_capability_name") + if legacy_capability: + mapped = _LEGACY_CAPABILITY_STEPS.get(legacy_capability) + if mapped: + for name in mapped: + if name not in step_types: + step_types.append(name) + else: + logger.warning( + "Could not migrate legacy target_capability_name" + " '%s'; no step-type mapping is registered.", + legacy_capability, + ) + + return step_types diff --git a/rayforge/core/recipe_manager.py b/rayforge/core/recipe_manager.py new file mode 100644 index 000000000..6d464f2b1 --- /dev/null +++ b/rayforge/core/recipe_manager.py @@ -0,0 +1,137 @@ +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +import yaml + +from .recipe import Recipe + +if TYPE_CHECKING: + from ..machine.models.machine import Machine + from .stock import StockItem + +logger = logging.getLogger(__name__) + + +class RecipeManager: + """ + Manages loading, saving, and querying Recipe objects from a directory. + """ + + def __init__(self, base_dir: Path): + self.base_dir = base_dir + self.recipes: dict[str, Recipe] = {} + self.base_dir.mkdir(parents=True, exist_ok=True) + self.load() + + def filename_from_id(self, recipe_id: str) -> Path: + """Generates a consistent filename for a given recipe UID.""" + return self.base_dir / f"{recipe_id}.yaml" + + def load(self): + """Loads all recipes from the base directory.""" + self.recipes.clear() + for file in self.base_dir.glob("*.yaml"): + try: + with open(file, "r") as f: + data = yaml.safe_load(f) + if not data: + logger.warning( + f"Skipping empty or invalid recipe {file.name}" + ) + continue + + recipe = Recipe.from_dict(data) + # Ensure UID from file content is used, but fallback + # to filename + recipe.uid = data.get("uid", file.stem) + self.recipes[recipe.uid] = recipe + + except Exception as e: # noqa: BLE001 - arbitrary user YAML file + logger.error(f"Error loading recipe file {file.name}: {e}") + logger.info(f"Loaded {len(self.recipes)} recipes.") + + def save_recipe(self, recipe: Recipe): + """Saves a single recipe to a YAML file.""" + logger.debug(f"Saving recipe {recipe.name} ({recipe.uid})") + recipe_file = self.filename_from_id(recipe.uid) + try: + with open(recipe_file, "w") as f: + data = recipe.to_dict() + yaml.safe_dump(data, f, sort_keys=False) + except (OSError, yaml.YAMLError) as e: + logger.error(f"Failed to save recipe {recipe.uid}: {e}") + + def add_recipe(self, recipe: Recipe): + """Adds a recipe to the manager and saves it.""" + if recipe.uid in self.recipes: + logger.warning( + f"Recipe with UID {recipe.uid} already exists. Overwriting." + ) + self.recipes[recipe.uid] = recipe + self.save_recipe(recipe) + + def delete_recipe(self, recipe_uid: str): + """Deletes a recipe from memory and removes its file.""" + if recipe_uid in self.recipes: + del self.recipes[recipe_uid] + recipe_file = self.filename_from_id(recipe_uid) + if recipe_file.exists(): + try: + recipe_file.unlink() + logger.info(f"Deleted recipe file: {recipe_file}") + except OSError as e: + logger.error( + f"Failed to delete recipe file {recipe_file}: {e}" + ) + + def get_recipe_by_id(self, recipe_id: str) -> Recipe | None: + """Retrieves a recipe by its unique identifier.""" + return self.recipes.get(recipe_id) + + def get_all_recipes(self) -> list[Recipe]: + """Returns a list of all loaded recipes.""" + return list(self.recipes.values()) + + def find_recipes( + self, + stock_items: list["StockItem"], + machine: Optional["Machine"] = None, + step_type: str | None = None, + ) -> list[Recipe]: + """ + Finds matching recipes, sorted from most specific to least specific. + + Args: + stock_items: A list of StockItems. If empty, only generic recipes + (without material/thickness constraints) are returned. + machine: An optional machine context to match against. Can be None. + step_type: An optional step class name (as registered in + ``step_registry``) to match + :attr:`Recipe.target_step_types` against. + + Returns: + A list of Recipe objects, sorted by relevance. + """ + # 1. Filter the recipes using the `matches` method + candidates = [ + r + for r in self.get_all_recipes() + if r.matches(stock_items, machine, step_type=step_type) + ] + + # 2. Sort candidates based on their specificity score and name + candidates.sort( + key=lambda r: (r.get_specificity_score(), r.name.lower()) + ) + + return candidates + + def is_material_in_use(self, material_uid: str) -> bool: + """ + Checks if any recipe in the library references the given material UID. + """ + for recipe in self.recipes.values(): + if recipe.material_uid == material_uid: + return True + return False diff --git a/rayforge/core/registration.py b/rayforge/core/registration.py new file mode 100644 index 000000000..3861dd74d --- /dev/null +++ b/rayforge/core/registration.py @@ -0,0 +1,232 @@ +import importlib +import logging +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class RegistryEntry: + hook_name: str | None + param_name: str + module_path: str + attr_name: str + worker_ok: bool + needs_window: bool + + +REGISTRY_TABLE = [ + RegistryEntry( + "register_steps", + "step_registry", + "rayforge.core.step_registry", + "step_registry", + worker_ok=True, + needs_window=False, + ), + RegistryEntry( + "register_services", + "service_registry", + "rayforge.core.service_registry", + "service_registry", + worker_ok=True, + needs_window=False, + ), + RegistryEntry( + "register_transformers", + "transformer_registry", + "rayforge.pipeline.transformer.registry", + "transformer_registry", + worker_ok=True, + needs_window=False, + ), + RegistryEntry( + "register_layout_strategies", + "layout_registry", + "rayforge.doceditor.layout.registry", + "layout_registry", + worker_ok=True, + needs_window=False, + ), + RegistryEntry( + "register_asset_types", + "asset_type_registry", + "rayforge.core.asset_registry", + "asset_type_registry", + worker_ok=True, + needs_window=False, + ), + RegistryEntry( + "register_renderers", + "renderer_registry", + "rayforge.image", + "renderer_registry", + worker_ok=True, + needs_window=False, + ), + RegistryEntry( + "register_commands", + "command_registry", + "rayforge.doceditor.command_registry", + "command_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + "register_exporters", + "exporter_registry", + "rayforge.image", + "exporter_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + "register_importers", + "importer_registry", + "rayforge.image", + "importer_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + "register_actions", + "action_registry", + "rayforge.ui_gtk.action_registry", + "action_registry", + worker_ok=False, + needs_window=True, + ), + RegistryEntry( + "register_settings_pages", + "settings_page_registry", + "rayforge.ui_gtk.settings.registry", + "settings_page_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + "register_transformer_widgets", + "transformer_widget_registry", + "rayforge.ui_gtk.doceditor.post_processor.registry", + "transformer_widget_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + "register_step_settings_pages", + "step_settings_page_registry", + "rayforge.ui_gtk.doceditor.step_settings.page_registry", + "step_settings_page_registry", + worker_ok=False, + needs_window=False, + ), + # Extension registries have no dedicated hook: addons populate them + # as a side effect of other registration hooks. They are still + # listed here so the addon manager can clean them up on unload. + RegistryEntry( + None, + "action_extension_registry", + "rayforge.ui_gtk.actions", + "action_extension_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + None, + "context_menu_extension_registry", + "rayforge.ui_gtk.canvas2d.context_menu", + "context_menu_extension_registry", + worker_ok=False, + needs_window=False, + ), + RegistryEntry( + None, + "property_provider_registry", + "rayforge.ui_gtk.doceditor.property_providers", + "property_provider_registry", + worker_ok=False, + needs_window=False, + ), +] + +LAZY_MANAGERS = { + "library_manager": ( + "register_material_libraries", + "library_manager", + ), + "model_manager": ( + "register_model_libraries", + "model_manager", + ), +} + + +def _import_registry(entry: RegistryEntry) -> Any: + module = importlib.import_module(entry.module_path) + return getattr(module, entry.attr_name) + + +def get_registries(headless: bool = False) -> dict[str, Any]: + """ + Import and return a dict of all active registries. + + The returned dict maps param_name -> registry instance for all + registries appropriate for the given mode. + """ + result: dict[str, Any] = {} + for entry in REGISTRY_TABLE: + if headless and not entry.worker_ok: + continue + result[entry.param_name] = _import_registry(entry) + return result + + +def call_registration_hooks( + plugin_mgr, + headless: bool = False, + registries: dict[str, Any] | None = None, + window_required: bool = False, +): + """ + Call all appropriate registration hooks on the plugin manager. + + This is the single entry point for registration hook invocation, + used during app startup, addon enable/reload, and worker init. + + Args: + plugin_mgr: The pluggy PluginManager instance. + headless: If True, skip GUI-only registries (worker mode). + registries: Optional dict of pre-loaded registries. Entries not + found here will be imported from their module_path. + window_required: If True, call only hooks that register + actions and other UI elements that depend on the main + window being available (e.g. during addon enable/reload + at runtime). If False, call all other hooks (startup and + worker init). + """ + registries = registries or {} + for entry in REGISTRY_TABLE: + if entry.hook_name is None: + continue + if window_required and not entry.needs_window: + continue + if not window_required and entry.needs_window: + continue + if headless and not entry.worker_ok: + continue + registry = registries.get(entry.param_name) + if registry is None: + try: + registry = _import_registry(entry) + except (ImportError, AttributeError): + continue + getattr(plugin_mgr.hook, entry.hook_name)( + **{entry.param_name: registry} + ) + if not window_required: + for key, (hook_name, param_name) in LAZY_MANAGERS.items(): + registry = registries.get(key) + if registry is not None: + logger.debug(f"Calling {hook_name} hook") + getattr(plugin_mgr.hook, hook_name)(**{param_name: registry}) diff --git a/rayforge/core/service_registry.py b/rayforge/core/service_registry.py new file mode 100644 index 000000000..44b502150 --- /dev/null +++ b/rayforge/core/service_registry.py @@ -0,0 +1,60 @@ +""" +Generic service registry for addon-provided services. + +Addons publish services (callables, instances, classes) under a string +key via the ``register_services`` hook; consumers look them up by key. +This lets one addon expose functionality to another without a direct +cross-package import. + +Implements the :class:`~rayforge.addon_mgr.addon_manager.AddonRegistry` +protocol so a service is removed automatically when its addon unloads. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class ServiceRegistry: + """Maps a string key to a service, tracking the owning addon.""" + + def __init__(self) -> None: + self._services: dict[str, tuple[Any, str]] = {} + + def register(self, key: str, service: Any, addon_name: str = "") -> None: + """ + Register (or replace) a service under ``key``. + + Args: + key: Lookup key, by convention a lowercase noun + (``"tool_manager"``). + service: The service (commonly a zero-arg accessor callable). + addon_name: Canonical name of the contributing addon, used + for cleanup on unload. + """ + self._services[key] = (service, addon_name) + logger.debug(f"Registered service '{key}' for '{addon_name}'") + + def get(self, key: str) -> Any | None: + """Return the service registered under ``key``, or ``None``.""" + entry = self._services.get(key) + return entry[0] if entry is not None else None + + def keys(self) -> list[str]: + """Return all registered service keys.""" + return list(self._services.keys()) + + def unregister_all_from_addon(self, addon_name: str) -> int: + """Remove every service registered by the named addon.""" + before = len(self._services) + self._services = { + k: v for k, v in self._services.items() if v[1] != addon_name + } + removed = before - len(self._services) + if removed: + logger.info(f"Removed {removed} services from '{addon_name}'") + return removed + + +service_registry = ServiceRegistry() diff --git a/rayforge/core/source_asset.py b/rayforge/core/source_asset.py new file mode 100644 index 000000000..1160374f9 --- /dev/null +++ b/rayforge/core/source_asset.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import base64 +import logging +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +import pyvips +from blinker import Signal + +from .asset import IAsset + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from ..image.base_renderer import Renderer + + +@dataclass +class SourceAsset(IAsset): + """ + An immutable data record for a raw imported file and its base render. + This is stored once per file in the document's central asset registry. + """ + + is_addable: ClassVar[bool] = False + asset_type_name: ClassVar[str] = "source" + display_icon_name: ClassVar[str] = "image-x-generic-symbolic" + is_reorderable: ClassVar[bool] = False + is_draggable_to_canvas: ClassVar[bool] = True + type_display_name: ClassVar[str] = _("Source") + can_edit: ClassVar[bool] = False + add_action: ClassVar[str | None] = None + activate_action: ClassVar[str | None] = None + edit_item_action: ClassVar[str | None] = None + + source_file: Path + original_data: bytes = field(repr=False) + renderer: Renderer + base_render_data: bytes | None = field(default=None, repr=False) + thumbnail_data: bytes | None = field(default=None, repr=False) + metadata: dict[str, Any] = field(default_factory=dict) + width_px: int | None = None + height_px: int | None = None + width_mm: float = 0.0 + height_mm: float = 0.0 + _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_MAX_SIZE: ClassVar[int] = 10 + + def __post_init__(self): + self._name = self.source_file.name + + def get_cached_base_image( + self, data_id: int, width: int, height: int + ) -> pyvips.Image | None: + key = (data_id, width, height) + return self._base_image_cache.get(key) + + 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) + + def clear_base_image_cache(self) -> None: + self._base_image_cache.clear() + + @property + def uid(self) -> str: + """The unique identifier of the asset instance.""" + return self._uid + + @property + def updated(self) -> Signal: + return self._updated + + # --- IAsset Protocol Implementation --- + + @property + def name(self) -> str: + """The user-facing name of the asset instance.""" + return self._name + + @name.setter + def name(self, value: str) -> None: + """Sets the asset name. Provided for protocol compatibility.""" + self._name = value + + @property + def hidden(self) -> bool: + """Indicates if this asset should be hidden from the UI.""" + return self._hidden + + @hidden.setter + def hidden(self, value: bool): + """Sets the hidden state.""" + self._hidden = value + + def get_thumbnail(self, size: int) -> bytes | None: + """Returns a PNG thumbnail of the rendered image.""" + try: + if self.thumbnail_data: + return self._scale_png(self.thumbnail_data, size) + return None + except Exception: + logger.exception("Failed to generate source thumbnail") + return None + + def _scale_png(self, png_data: bytes, size: int) -> bytes | None: + image = pyvips.Image.pngload_buffer(png_data) + aspect = image.width / image.height + if aspect > 1: + new_width = size + new_height = int(size / aspect) + else: + new_height = size + new_width = int(size * aspect) + scale = min(new_width / image.width, new_height / image.height) + linear = image.colourspace("scrgb") + resized = linear.resize(scale) + image = resized.colourspace("srgb") + return image.pngsave_buffer() + + def to_dict(self) -> dict[str, Any]: + """Serializes SourceAsset to a dictionary.""" + result = { + "uid": self.uid, + "type": self.asset_type_name, + "name": self.name, + "source_file": str(self.source_file), + "original_data": base64.b64encode(self.original_data).decode( + "utf-8" + ), + "base_render_data": ( + base64.b64encode(self.base_render_data).decode("utf-8") + if self.base_render_data + else None + ), + "thumbnail_data": ( + base64.b64encode(self.thumbnail_data).decode("utf-8") + if self.thumbnail_data + else None + ), + "renderer_name": self.renderer.__class__.__name__, + "metadata": self.metadata, + "width_px": self.width_px, + "height_px": self.height_px, + "width_mm": self.width_mm, + "height_mm": self.height_mm, + "hidden": self._hidden, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SourceAsset: + """Deserializes a dictionary into a SourceAsset instance.""" + from ..image import renderer_registry + from ..image.base_renderer import UnknownRenderer + + known_keys = { + "uid", + "type", + "name", + "source_file", + "original_data", + "base_render_data", + "thumbnail_data", + "renderer_name", + "metadata", + "width_px", + "height_px", + "width_mm", + "height_mm", + "hidden", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + renderer = renderer_registry.get(data["renderer_name"]) + if renderer is None: + logger.warning( + f"Unknown renderer: {data['renderer_name']}. " + f"Using UnknownRenderer as fallback." + ) + renderer = UnknownRenderer() + + original_data = base64.b64decode(data["original_data"]) + base_render_data = ( + base64.b64decode(data["base_render_data"]) + if data.get("base_render_data") + else None + ) + thumbnail_data = ( + base64.b64decode(data["thumbnail_data"]) + if data.get("thumbnail_data") + else None + ) + + instance = cls( + source_file=Path(data["source_file"]), + original_data=original_data, + base_render_data=base_render_data, + thumbnail_data=thumbnail_data, + renderer=renderer, + metadata=data.get("metadata", {}), + width_px=data.get("width_px"), + height_px=data.get("height_px"), + width_mm=data.get("width_mm", 0.0), + height_mm=data.get("height_mm", 0.0), + ) + if "uid" in data: + instance._uid = data["uid"] + if "name" in data: + instance.name = data["name"] + if "hidden" in data: + instance._hidden = data["hidden"] + instance.extra = extra + return instance diff --git a/rayforge/core/source_asset_segment.py b/rayforge/core/source_asset_segment.py new file mode 100644 index 000000000..76fdcd64f --- /dev/null +++ b/rayforge/core/source_asset_segment.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field, replace +from typing import Any + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Rect + +from .vectorization_spec import VectorizationSpec + +# A type alias for a list of serializable modifier configurations. +ImageModifierChain = list[dict[str, Any]] + + +@dataclass +class SourceAssetSegment: + """ + Contains vectors describing the boundaries of a segment in a + SourceAsset, along with a set of instructions for generating those + boundary vectors. + """ + + source_asset_uid: str + vectorization_spec: VectorizationSpec + image_modifier_chain: ImageModifierChain = field(default_factory=list) + layer_id: str | None = None + + # --- Fields for cropped/traced bitmap rendering --- + crop_window_px: Rect | None = None + cropped_width_mm: float | None = None + cropped_height_mm: float | None = None + + # --- Fields for non-destructive vector import --- + pristine_geometry: Geometry | None = None + normalization_matrix: Matrix | None = None + + def to_dict(self) -> dict[str, Any]: + """Serializes the configuration to a dictionary.""" + return { + "source_asset_uid": self.source_asset_uid, + "image_modifier_chain": self.image_modifier_chain, + "vectorization_spec": self.vectorization_spec.to_dict(), + "crop_window_px": self.crop_window_px, + "cropped_width_mm": self.cropped_width_mm, + "cropped_height_mm": self.cropped_height_mm, + "layer_id": self.layer_id, + "pristine_geometry": self.pristine_geometry.to_dict() + if self.pristine_geometry + else None, + "normalization_matrix": self.normalization_matrix.to_list() + if self.normalization_matrix + else None, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SourceAssetSegment: + """Deserializes a dictionary into a SourceAssetSegment instance.""" + # Handle tuple conversion for crop_window_px if it's a list from JSON + crop_window = data.get("crop_window_px") + if isinstance(crop_window, list): + crop_window = tuple(crop_window) + + pristine_geo_data = data.get("pristine_geometry") + pristine_geometry = ( + Geometry.from_dict(pristine_geo_data) + if pristine_geo_data + else None + ) + + norm_matrix_data = data.get("normalization_matrix") + normalization_matrix = ( + Matrix.from_list(norm_matrix_data) if norm_matrix_data else None + ) + + return cls( + source_asset_uid=data["source_asset_uid"], + image_modifier_chain=data.get("image_modifier_chain", []), + vectorization_spec=VectorizationSpec.from_dict( + data["vectorization_spec"] + ), + crop_window_px=crop_window, + cropped_width_mm=data.get("cropped_width_mm"), + cropped_height_mm=data.get("cropped_height_mm"), + layer_id=data.get("layer_id"), + pristine_geometry=pristine_geometry, + normalization_matrix=normalization_matrix, + ) + + def clone_with_geometry( + self, new_y_down_geometry: Geometry + ) -> SourceAssetSegment: + """ + Creates a deep copy of this segment for use in splitting operations. + + The provided `new_y_down_geometry` is assumed to be normalized and + becomes the new pristine shape. The normalization matrix is reset to + identity. This ensures the new workpiece fragment renders correctly. + """ + # Use dataclasses.replace for a shallow copy of scalar fields. + # The new geometry becomes the pristine data, and since it's already + # normalized, the normalization matrix is identity. + new_segment = replace( + self, + pristine_geometry=new_y_down_geometry, + normalization_matrix=Matrix.identity(), + ) + + # Manually deepcopy mutable fields to ensure independence + new_segment.image_modifier_chain = deepcopy(self.image_modifier_chain) + new_segment.vectorization_spec = deepcopy(self.vectorization_spec) + + return new_segment diff --git a/rayforge/core/step.py b/rayforge/core/step.py new file mode 100644 index 000000000..7f0a63edd --- /dev/null +++ b/rayforge/core/step.py @@ -0,0 +1,740 @@ +import logging +from abc import ABC +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Optional, + cast, +) + +from blinker import Signal +from raygeo.cnc.execution.specs import ComputePayload +from raygeo.geo import Matrix +from raygeo.ops import Ops +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.contour import ContourSpec +from raygeo.ops.part import Part +from raygeo.ops.state import CoolantMode + +from ..machine.models.head import Head +from ..machine.models.spindle import SpindleHead +from ..pipeline.transformer.registry import transformer_registry +from .capability import MachineCapability +from .item import DocItem +from .step_registry import step_registry +from .varset import SpeedVar, VarSet + +if TYPE_CHECKING: + from ..context import RayforgeContext + from ..machine.models.machine import Machine + from .layer import Layer + from .workflow import Workflow + from .workpiece import WorkPiece + + +logger = logging.getLogger(__name__) + +_COOLANT_MODE_BY_NAME = { + mode.name: mode + for mode in (CoolantMode.OFF, CoolantMode.FLOOD, CoolantMode.MIST) +} + + +def legacy_producer_params(data: dict[str, Any]) -> dict[str, Any]: + """Return the legacy ``opsproducer_dict.params`` payload, if any. + + Projects saved before the raygeo-pipeline refactor stored each + step's producer configuration under ``opsproducer_dict``. Step + ``from_dict`` implementations consult this payload so the saved + parameters survive loading; current-format top-level keys always + take precedence. + """ + opsproducer = data.get("opsproducer_dict") + if isinstance(opsproducer, dict): + params = opsproducer.get("params") + if isinstance(params, dict): + return params + return {} + + +class Step(DocItem, ABC): + """ + An OpsProducer configuration that operates on WorkPieces. + + A Step is a stateless configuration object that defines a single + operation (e.g., outline, engrave) to be performed. It holds its + configuration as serializable dictionaries. + """ + + HIDDEN: bool = False + ICON: str = "" + REQUIRED_MACHINE_CAPS: ClassVar[frozenset[MachineCapability]] = frozenset() + TYPELABEL: ClassVar[str] = "" + ASSEMBLER_NAME: ClassVar[str] = "" + uses_global_state: ClassVar[bool] = False + + def __init__( + self, + typelabel: str, + name: str | None = None, + ): + super().__init__(name=name or typelabel) + self.typelabel = typelabel + self.visible = True + self.selected_head_uid: str | None = None + self.generated_workpiece_uid: str | None = None + self.applied_recipe_uid: str | None = None + + per_wp_defaults, per_sp_defaults = ( + self.get_default_transformers_dicts() + ) + self.per_workpiece_transformers_dicts: list[dict[str, Any]] = list( + per_wp_defaults + ) + self.per_step_transformers_dicts: list[dict[str, Any]] = list( + per_sp_defaults + ) + + self.pixels_per_mm = 50, 50 + + # Signals for notifying of model changes + self.per_step_transformer_changed = Signal() + self.visibility_changed = Signal() + + # Default machine-dependent values. + self.cut_speed: int = 500 + self.max_cut_speed = 10000 + self.travel_speed: int = 5000 + self.max_travel_speed = 10000 + + # Coolant method used while this step runs. + self.coolant_method: CoolantMode = CoolantMode.OFF + + # Forward compatibility: store unknown attributes + self.extra: dict[str, Any] = {} + + # Set when a step of an unknown type is deserialized, so the + # original type name can be reported and round-tripped. + self._original_step_type: str | None = None + + @classmethod + def recipe_varset(cls) -> VarSet: + """The VarSet used to render this step type's recipe editor. + + The base returns the shared motion vars. Domain bases and + concrete steps extend this (via ``super()`` composition) with + their own attributes. Recipe extraction keys are derived from + this varset via :meth:`recipe_keys`, so the editor and the + extractor agree. + """ + return VarSet( + vars=[ + SpeedVar( + key="cut_speed", + label=_("Cut Speed"), + default=500, + min_val=1, + role="cut", + ), + SpeedVar( + key="travel_speed", + label=_("Travel Speed"), + default=5000, + min_val=1, + role="travel", + ), + ] + ) + + @classmethod + def recipe_keys(cls) -> tuple[str, ...]: + """The step attribute keys eligible for recipe extraction. + + Derived from :meth:`recipe_varset` so the editor and the + extractor always agree on which attributes a step's recipe + carries. Domain bases and concrete steps inherit this through + the same ``super()`` composition as :meth:`recipe_varset`. + """ + return tuple(var.key for var in cls.recipe_varset()) + + @classmethod + def recipe_varset_groups(cls) -> list[tuple[str, VarSet]]: + """Split :meth:`recipe_varset` into named groups for the editor. + + Returns a list of ``(title, varset)`` pairs. The base returns a + single group. Domain bases override this to separate inherited + process settings from step-specific settings (e.g. "Laser" vs + "Step Settings"). + """ + return [(_("Settings"), cls.recipe_varset())] + + @classmethod + def common_recipe_varset_groups( + cls, step_classes: list[type["Step"]] + ) -> list[tuple[str, VarSet]]: + """Settings groups common to all the given step types. + + Used by the recipe editor when a recipe targets more than one + step type: only settings shared by every selected type are + offered. The group structure (titles) of the first given type is + reused, with each group filtered down to the keys present in + every type's :meth:`recipe_varset`. Falls back to the base + :meth:`recipe_varset_groups` when nothing is shared. + """ + if not step_classes: + return cls.recipe_varset_groups() + + common_keys: set[str] | None = None + for step_cls in step_classes: + keys = {var.key for var in step_cls.recipe_varset()} + common_keys = keys if common_keys is None else common_keys & keys + if not common_keys: + break + + if not common_keys: + return cls.recipe_varset_groups() + + reference = step_classes[0] + groups: list[tuple[str, VarSet]] = [] + for title, varset in reference.recipe_varset_groups(): + filtered = [v for v in varset if v.key in common_keys] + if filtered: + groups.append((title, VarSet(vars=filtered))) + return groups or cls.recipe_varset_groups() + + @classmethod + def common_transformer_dicts( + cls, step_classes: list[type["Step"]] + ) -> list[dict[str, Any]]: + """Transformer dicts common to all the given step types. + + Analogous to :meth:`common_recipe_varset_groups`: when a recipe + targets more than one step type, only transformers present in + every type's :meth:`get_default_transformers_dicts` are + offered. Returns a deduplicated list of copies using the first + type's dicts as the structural reference. Empty when no classes + are given. + """ + if not step_classes: + return [] + + common_names: set[str] | None = None + for step_cls in step_classes: + per_wp, per_step = step_cls.get_default_transformers_dicts() + names = { + d.get("name") + for d in list(per_wp) + list(per_step) + if d.get("name") + } + common_names = ( + names if common_names is None else common_names & names + ) + if not common_names: + break + + if not common_names: + return [] + + reference_wp, reference_step = step_classes[ + 0 + ].get_default_transformers_dicts() + result: list[dict[str, Any]] = [] + for t_dict in list(reference_wp) + list(reference_step): + name = t_dict.get("name") + if not name or name not in common_names: + continue + if any(d.get("name") == name for d in result): + continue + result.append(dict(t_dict)) + return result + + @staticmethod + def _dedupe_transformer_dicts_by_name( + dicts: list[dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Return a ``name -> dict`` map, keeping the first occurrence. + + Used to deduplicate a step's combined per-workpiece + per-step + transformer dicts (a single dict can appear in both lists and is + shared by reference). + """ + out: dict[str, dict[str, Any]] = {} + for t_dict in dicts: + name = t_dict.get("name") + if name and name not in out: + out[name] = t_dict + return out + + @classmethod + def create( + cls, + context: "RayforgeContext", + name: str | None = None, + **kwargs, + ) -> "Step": + """ + Factory method to create a fully configured step instance. + + Subclasses must override this to provide default configuration + based on the context (e.g., machine settings). + """ + raise NotImplementedError( + f"{cls.__name__}.create() must be implemented by subclass" + ) + + def get_assembler_kwargs( + self, + machine: "Machine", + workpiece: "WorkPiece", + ) -> dict[str, Any]: + """Build the kwargs dict for :meth:`~.AssemblerRegistry.assemble`.""" + return {} + + def build_compute_payload( + self, + machine: "Machine", + workpiece: "WorkPiece", + ) -> "tuple[Part, ComputePayload]": + """ + Build the raygeo :class:`Part` and :class:`ComputePayload` for + a workpiece compute node of the new intent pipeline. + + The base implementation returns a default payload wrapping a + bare :class:`ContourSpec` assembler and a :class:`Part` + built from the workpiece's vector geometry (or an empty + :class:`Part` when the workpiece has no boundaries). Step + kinds with a real raygeo assembler override this to populate + the assembler spec from their own machine resolution (see + :class:`ContourStep`, :class:`EngraveStep`). + + :param machine: The machine context the step resolves its + process defaults from. + :param workpiece: The workpiece this compute node runs against. + :returns: ``(part, payload)`` for ``StageSpec.Compute``. + """ + part = workpiece.to_part() + if part is None: + part = Part(size_mm=workpiece.size) + return part, ComputePayload(assembler=Assembler(ContourSpec())) + + def assembler_token_params( + self, + machine: "Machine", + workpiece: "WorkPiece", + ) -> dict[str, Any] | None: + """ + Return a JSON-serialisable dict of the assembler spec + parameters that this step resolves for *machine*. + + The value is folded into the workpiece compute token so that + changes to step-specific assembler inputs (e.g. ``cut_side`` + for ContourStep) invalidate the cache even when the generic + step parameters are unchanged. + + The base implementation returns :data:`None`, leaving the + compute token unaffected. Step kinds that wire a real + assembler spec override this (see :class:`ContourStep`). + """ + return None + + def populate_payload(self, payload, machine: "Machine"): + """Set domain-specific fields on the ComputePayload. + + The base stamps the shared motion fields and the resolved head + uid, leaving the process power at its neutral default. Domain + bases override this to add their own process fields (e.g. laser + power) and never read attributes they do not own. + """ + payload.cut_speed = self.cut_speed + head = self.get_selected_head(machine) + payload.head_uid = head.uid if head else None + payload.power = 0.0 + + def get_cache_params(self) -> dict[str, Any]: + """JSON-serialisable step attributes that influence compute output. + + UIDs and cosmetic fields are intentionally omitted so the token + only changes when the actual compute inputs change. Domain bases + extend this with their own process attributes. + """ + return { + "type": type(self).__name__, + "visible": self.visible, + "cut_speed": self.cut_speed, + "max_cut_speed": self.max_cut_speed, + "travel_speed": self.travel_speed, + "max_travel_speed": self.max_travel_speed, + "coolant_method": self.coolant_method.name, + "pixels_per_mm": list(self.pixels_per_mm), + } + + def create_initial_ops(self) -> "Ops": + """Build the initial Ops object with step-wide machine settings. + + The generic step has no process parameters of its own; domain + bases (e.g. :class:`LaserStep`) override this to stamp their + machine settings. + """ + ops = Ops() + if self.coolant_method is not CoolantMode.OFF: + ops.set_coolant(self.coolant_method) + return ops + + def apply_import_settings(self, settings: dict[str, Any]) -> None: + """Apply importer-provided settings that this step owns. + + The settings dict uses the step's own attribute names + (canonicalised by the importer). The base handles the shared + motion settings; domain bases override this to apply their own + process attributes and call ``super()``. + """ + cut_speed = settings.get("cut_speed") + if cut_speed is not None: + self.set_cut_speed(cut_speed) + + def to_dict(self) -> dict: + """Serializes the step and its configuration to a dictionary.""" + step_type = ( + self._original_step_type + if self._original_step_type is not None + else self.__class__.__name__ + ) + result = { + "uid": self.uid, + "type": "step", + "step_type": step_type, + "name": self.name, + "matrix": self.matrix.to_list(), + "typelabel": self.typelabel, + "visible": self.visible, + "selected_head_uid": self.selected_head_uid, + "generated_workpiece_uid": self.generated_workpiece_uid, + "applied_recipe_uid": self.applied_recipe_uid, + "per_workpiece_transformers_dicts": ( + self.per_workpiece_transformers_dicts + ), + "per_step_transformers_dicts": self.per_step_transformers_dicts, + "pixels_per_mm": self.pixels_per_mm, + "cut_speed": self.cut_speed, + "max_cut_speed": self.max_cut_speed, + "travel_speed": self.travel_speed, + "max_travel_speed": self.max_travel_speed, + "coolant_method": self.coolant_method.name, + "children": [child.to_dict() for child in self.children], + } + result.update(self.extra) + return result + + @classmethod + def _serialized_keys(cls) -> frozenset[str]: + """Keys this class handles in ``to_dict``/``from_dict``. + + Used solely for ``extra``-dict filtering: unknown keys from + newer file versions are preserved in ``extra`` rather than + silently dropped. Subclasses that serialize additional keys + extend this via ``super()`` composition in the MRO. + """ + return frozenset( + { + "uid", + "type", + "step_type", + "name", + "matrix", + "typelabel", + "visible", + "selected_laser_uid", + "selected_head_uid", + "generated_workpiece_uid", + "applied_recipe_uid", + "modifiers_dicts", + "per_workpiece_transformers_dicts", + "per_step_transformers_dicts", + "pixels_per_mm", + "cut_speed", + "max_cut_speed", + "travel_speed", + "max_travel_speed", + "coolant_method", + "children", + } + ) + + @classmethod + def get_default_transformers_dicts(cls) -> tuple[list, list]: + """ + Returns default transformer configurations for this step type. + + Returns: + A tuple of (per_workpiece_transformers_dicts, + per_step_transformers_dicts) for new steps of this type. + Subclasses should override this to provide their defaults. + """ + return [], [] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Step": + """Deserializes a Step instance from a dictionary.""" + extra = { + k: v for k, v in data.items() if k not in cls._serialized_keys() + } + + step_type_name = data.get("step_type") + if step_type_name: + step_class = step_registry.get(step_type_name) + else: + step_class = None + + if step_class is None: + typelabel = data.get("typelabel") + if typelabel: + step_class = step_registry.get_by_typelabel(typelabel) + + if step_class is not None and step_class is not cls: + return step_class.from_dict(data) + + if step_class is None: + step_class = cls + # Preserve the original step type name so a missing step + # can be reported and round-tripped when the addon + # providing it is not installed. + original_step_type = step_type_name + else: + original_step_type = None + + step = step_class(typelabel=data["typelabel"], name=data.get("name")) + if original_step_type: + step._original_step_type = original_step_type + step.uid = data["uid"] + step.matrix = Matrix.from_list(data["matrix"]) + step.visible = data["visible"] + step.selected_head_uid = data.get( + "selected_head_uid", data.get("selected_laser_uid") + ) + step.generated_workpiece_uid = data.get("generated_workpiece_uid") + step.applied_recipe_uid = data.get("applied_recipe_uid") + + default_per_wp, default_per_step = ( + step_class.get_default_transformers_dicts() + ) + step.per_workpiece_transformers_dicts = Step._merge_transformer_dicts( + data.get("per_workpiece_transformers_dicts", []), + default_per_wp, + ) + step.per_step_transformers_dicts = Step._merge_transformer_dicts( + data.get("per_step_transformers_dicts", []), + default_per_step, + ) + + # Share dict references for transformers that appear in both lists + step._unify_shared_transformers() + + step.pixels_per_mm = data.get("pixels_per_mm", (100, 100)) + step.max_cut_speed = data.get("max_cut_speed", step.max_cut_speed) + step.max_travel_speed = data.get( + "max_travel_speed", step.max_travel_speed + ) + step.cut_speed = data.get("cut_speed", step.cut_speed) + step.travel_speed = data.get("travel_speed", step.travel_speed) + raw_coolant = data.get("coolant_method", CoolantMode.OFF.name) + step.coolant_method = _COOLANT_MODE_BY_NAME.get( + raw_coolant, CoolantMode.OFF + ) + step.extra = extra + return step + + @staticmethod + def _merge_transformer_dicts( + loaded: list[dict[str, Any]], defaults: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """ + Merges loaded transformer dicts with defaults. + + Adds any transformers from defaults that are not present in loaded, + preserving order where new transformers appear in the defaults list. + """ + loaded_names = {t.get("name") for t in loaded if t.get("name")} + result = list(loaded) + for default_t in defaults: + name = default_t.get("name") + if name and name not in loaded_names: + result.append(default_t) + return result + + def _unify_shared_transformers(self): + """ + Ensures transformers that appear in both lists share the same dict. + + Some transformers (like Optimize) are intended to be the same instance + in both per_workpiece and per_step lists. This method detects such + cases and unifies them to share the same dict reference. + """ + per_wp_names = { + t.get("name"): t + for t in self.per_workpiece_transformers_dicts + if t.get("name") + } + for i, t in enumerate(self.per_step_transformers_dicts): + name = t.get("name") + if name and name in per_wp_names: + self.per_step_transformers_dicts[i] = per_wp_names[name] + + @property + def original_step_type(self) -> str | None: + """ + The step type name stored in the source document. + + When a step's class is not registered (e.g. because the addon + providing it is not installed), ``from_dict`` preserves the + original ``step_type`` so the missing feature can be reported + and round-tripped. Registered steps return ``None``. + """ + return self._original_step_type + + @property + def layer(self) -> Optional["Layer"]: + """Returns the parent layer, if it exists.""" + # Local import to prevent circular dependency at module load time + from .layer import Layer + + workflow = self.workflow + if not workflow: + return None + + layer = workflow.parent + return layer if isinstance(layer, Layer) else None + + @property + def workflow(self) -> Optional["Workflow"]: + """Returns the parent workflow, if it exists.""" + # Local import to prevent circular dependency at module load time + from .workflow import Workflow + + if self.parent and isinstance(self.parent, Workflow): + return cast(Workflow, self.parent) + return None + + @property + def show_general_settings(self) -> bool: + """ + Returns whether general settings (power, speed, air assist) should be + shown in the settings dialog. Override in subclasses to hide these + settings when they don't apply. + """ + return True + + def get_selected_head(self, machine: "Machine") -> Head | None: + """ + Resolves and returns the selected head for this step, or None + if the machine has no heads. Falls back to the first head on + the machine if the selection is invalid or not set. + """ + if self.selected_head_uid: + for head in machine.heads: + if head.uid == self.selected_head_uid: + return head + # Fallback + if machine.heads: + return machine.heads[0] + return None + + def set_selected_head_uid(self, uid: str | None): + """ + Sets the UID of the head to be used by this step. + """ + if self.selected_head_uid != uid: + self.selected_head_uid = uid + 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) + + def set_visible(self, visible: bool): + if self.visible != visible: + self.visible = visible + self.visibility_changed.send(self) + self.updated.send(self) + + def set_cut_speed(self, speed: int): + if self.cut_speed != speed: + self.cut_speed = int(speed) + self.updated.send(self) + + def set_travel_speed(self, speed: int): + if self.travel_speed != speed: + self.travel_speed = int(speed) + self.updated.send(self) + + def set_coolant_method(self, mode: CoolantMode): + """Sets the coolant method used while this step runs.""" + if self.coolant_method is not mode: + self.coolant_method = mode + self.updated.send(self) + + def get_unsupported_coolant_methods( + self, machine: "Machine" + ) -> tuple[CoolantMode, ...]: + """Coolant methods this step uses that the machine's selected + head does not support. + + ``CoolantMode.OFF`` is always supported, so it is never + reported. Non-spindle heads (e.g. laser heads) have no coolant + methods, so nothing is reported for them either. + """ + if self.coolant_method is CoolantMode.OFF: + return () + head = self.get_selected_head(machine) + if not isinstance(head, SpindleHead): + return () + if self.coolant_method in head.cooling_methods: + return () + return (self.coolant_method,) + + def get_operation_mode_short(self) -> str | None: + return None + + def get_operation_color(self, head) -> str | None: + """Return the color used to represent this step's operation for + the given head, or None when the step has no color. + + Domain bases override this (e.g. laser steps return the head's + raster or cut color). + """ + return None + + def get_summary(self) -> str: + """Return a short human-readable summary for the UI. + + The generic step has no process parameters of its own, so it + falls back to the type label. Domain bases (e.g. + :class:`LaserStep`) override this to describe their process. + """ + return self.typelabel + + def dump(self, indent: int = 0): + print(" " * indent, self.name) + + def is_position_sensitive(self) -> bool: + """ + Returns True if workpiece position changes may affect the output. + + This is true when per-workpiece transformers are configured that + depend on the workpiece's world position (e.g., crop-to-stock). + """ + for t_dict in self.per_workpiece_transformers_dicts: + if not t_dict.get("enabled", True): + continue + name = t_dict.get("name") + if not name or not isinstance(name, str): + continue + transformer_cls = transformer_registry.get(name) + if transformer_cls is None: + continue + if transformer_cls.POSITION_SENSITIVE: + return True + return False diff --git a/rayforge/core/step_registry.py b/rayforge/core/step_registry.py new file mode 100644 index 000000000..806805f10 --- /dev/null +++ b/rayforge/core/step_registry.py @@ -0,0 +1,170 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .capability import MachineCapability + from .step import Step + + +class StepRegistry: + """ + Registry for Step classes. + + Allows explicit registration of step types for lookup by name. + Supports polymorphic deserialization and provides access to + step factory methods for UI menus. + """ + + def __init__(self): + self._steps: dict[str, type[Step]] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, step_class: type["Step"], addon_name: str | None = None + ) -> None: + """ + Register a step class. + + Args: + step_class: The Step subclass to register. + The class name is used as the registry key. + addon_name: Optional name of the addon registering this step. + Used for cleanup when addon is unloaded. + """ + name = step_class.__name__ + self._steps[name] = step_class + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(name) + + def unregister(self, name: str) -> bool: + """ + Unregister a step class by name. + + Args: + name: The class name of the step to unregister. + + Returns: + True if the step was unregistered, False if not found. + """ + if name in self._steps: + del self._steps[name] + for items in self._addon_items.values(): + items.discard(name) + return True + return False + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all steps registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of steps unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for name in items: + if name in self._steps: + del self._steps[name] + count += 1 + return count + + def get(self, name: str) -> type["Step"] | None: + """ + Look up a step class by name. + + Args: + name: The class name of the step. + + Returns: + The step class, or None if not found. + """ + return self._steps.get(name) + + def progress_label(self, assembler_name: str) -> str | None: + """ + Look up the UI label for a raygeo assembler progress name. + + The label is the ``TYPELABEL`` of the registered step whose + ``ASSEMBLER_NAME`` matches. + + Args: + assembler_name: A raygeo assembler name (the prefix of the + ``"{name}: assemble"`` batch progress message). + + Returns: + The UI label, or None when no registered step declares that + assembler name. + """ + for step_class in self._steps.values(): + if step_class.ASSEMBLER_NAME != assembler_name: + continue + if step_class.TYPELABEL: + return step_class.TYPELABEL + return None + + def get_by_typelabel(self, typelabel: str) -> type["Step"] | None: + """ + Look up a step class by its TYPELABEL attribute. + + This is useful for backward compatibility with older project files + that only stored typelabel but not step_type. + + Args: + typelabel: The TYPELABEL of the step. + + Returns: + The step class, or None if not found. + """ + for step_class in self._steps.values(): + class_typelabel = getattr(step_class, "TYPELABEL", None) + if class_typelabel == typelabel: + return step_class + return None + + def get_factories( + self, + machine_caps: frozenset["MachineCapability"] | None = None, + ) -> list[Callable]: + """ + Return all registered step factory methods. + + Args: + machine_caps: Optional set of machine capabilities. When + given, only steps whose REQUIRED_MACHINE_CAPS are a + subset of the machine capabilities are included. + When None, no filtering is applied. + + Returns: + List of callable `create` class methods from registered + step classes, excluding hidden steps. + """ + factories: list[Callable] = [] + for cls in self._steps.values(): + if cls.HIDDEN: + continue + if ( + machine_caps is not None + and not cls.REQUIRED_MACHINE_CAPS.issubset(machine_caps) + ): + continue + factories.append(cls.create) + return factories + + def all_steps(self) -> dict[str, type["Step"]]: + """ + Return a copy of all registered steps. + + Returns: + Dictionary mapping step names to classes. + """ + return self._steps.copy() + + +step_registry = StepRegistry() diff --git a/rayforge/core/stock.py b/rayforge/core/stock.py new file mode 100644 index 000000000..dda053b42 --- /dev/null +++ b/rayforge/core/stock.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, cast + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Rect + +from .item import DocItem + +if TYPE_CHECKING: + from .asset import IAsset + from .material import Material + from .stock_asset import StockAsset + +logger = logging.getLogger(__name__) + + +class StockItem(DocItem): + """ + Represents an instance of a stock material in the document. + + It is a first-class document item that can be transformed. It references + a StockAsset for its defining properties like geometry and material. + """ + + def __init__(self, stock_asset_uid: str, name: str = "Stock"): + super().__init__(name=name) + self.stock_asset_uid: str = stock_asset_uid + self.visible: bool = True + self.extra: dict[str, Any] = {} + + def depends_on_asset(self, asset: IAsset) -> bool: + """Checks if this stock item is an instance of the given asset.""" + return self.stock_asset_uid == asset.uid + + @property + def stock_asset(self) -> StockAsset | None: + """Retrieves the StockAsset this item is an instance of.""" + doc = self.doc + if doc: + return cast( + "StockAsset | None", + doc.get_asset_by_uid(self.stock_asset_uid), + ) + return None + + @property + def natural_size(self) -> tuple[float, float]: + """ + Returns the natural size of the stock item, defined by its + referenced StockAsset's geometry bounding box. + """ + asset = self.stock_asset + if asset: + return asset.get_natural_size() + return (1.0, 1.0) # Fallback + + def get_local_bbox(self) -> Rect | None: + """ + StockItems are geometrically defined as a unit square (0,0,1,1) that is + scaled by their matrix, keeping mathematical consistency with + WorkPieces. + """ + return (0.0, 0.0, 1.0, 1.0) + + def to_dict(self) -> dict[str, Any]: + """Serializes the StockItem to a dictionary.""" + result = { + "uid": self.uid, + "type": "stockitem", # Discriminator for deserialization + "name": self.name, + "matrix": self.matrix.to_list(), + "stock_asset_uid": self.stock_asset_uid, + "visible": self.visible, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> StockItem: + """ + Deserializes a dictionary into a StockItem instance. + Assumes the new format with 'stock_asset_uid'. Legacy file handling + is performed in Doc.from_dict. + """ + known_keys = { + "uid", + "type", + "name", + "matrix", + "stock_asset_uid", + "visible", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + new_item = cls( + name=data.get("name", "Stock"), + stock_asset_uid=data["stock_asset_uid"], + ) + new_item.uid = data["uid"] + new_item.matrix = Matrix.from_list(data["matrix"]) + new_item.visible = data.get("visible", True) + new_item.extra = extra + + return new_item + + def duplicate(self) -> StockItem: + """ + Creates a deep copy of this StockItem with a new UID. + + This also creates a duplicate of the associated StockAsset, + ensuring the new item is completely independent. + """ + from .stock_asset import StockAsset + + old_asset = self.stock_asset + if old_asset: + asset_dict = old_asset.to_dict() + new_asset = StockAsset.from_dict(asset_dict) + new_asset.uid = str(__import__("uuid").uuid4()) + new_asset.name = _("{name} (copy)").format(name=old_asset.name) + else: + new_asset = None + + item_dict = self.to_dict() + new_item = StockItem.from_dict(item_dict) + new_item.uid = str(__import__("uuid").uuid4()) + new_item.name = _("{name} (copy)").format(name=self.name) + + if new_asset: + new_item.stock_asset_uid = new_asset.uid + if self.doc: + self.doc.add_asset(new_asset, silent=True) + + 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) + + # --- Delegated Properties for backward compatibility and convenience --- + + @property + def thickness(self) -> float | None: + """Delegates thickness access to the StockAsset.""" + asset = self.stock_asset + return asset.thickness if asset else None + + @property + def material_uid(self) -> str | None: + """Delegates material_uid access to the StockAsset.""" + asset = self.stock_asset + return asset.material_uid if asset else None + + @property + def geometry(self) -> Geometry: + """Delegates geometry access to the StockAsset.""" + asset = self.stock_asset + return asset.geometry if asset else Geometry() + + def get_world_geometry(self) -> Geometry: + """ + Returns the geometry transformed to world space. + """ + geo = self.geometry + if geo.is_empty(): + return geo + + # Normalize the geometry to a 1x1 unit box at the origin (0,0). + # This matches the DocItem architecture where local matrix + # scale == physical size. + min_x, min_y, max_x, max_y = geo.rect() + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + + norm_matrix = Matrix.scale( + 1.0 / width, 1.0 / height + ) @ Matrix.translation(-min_x, -min_y) + + world_geo = geo.copy() + world_transform = self.get_world_transform() + + # Combine normalizations with world transform before applying + final_transform = world_transform @ norm_matrix + world_geo.transform(final_transform) + + rect = world_geo.rect() + logger.debug( + "Stock.get_world_geometry: uid=%s, local_geo.rect=%s, " + "world_transform scale=(%.2f, %.2f), world_rect=%s", + self.uid, + geo.rect() if geo else "None", + world_transform.get_scale()[0] if world_transform else 0, + world_transform.get_scale()[1] if world_transform else 0, + rect, + ) + return world_geo + + def get_world_rect_geometry(self) -> Geometry: + """ + Returns a rectangle in world space based on the stock's dimensions. + + Creates a 1x1 unit rectangle and applies the world transform to + properly handle rotation, shear, scale, and translation. + """ + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(1, 0) + geo.line_to(1, 1) + geo.line_to(0, 1) + geo.close_path() + + world_transform = self.get_world_transform() + geo.transform(world_transform) + + rect = geo.rect() + logger.debug( + "Stock.get_world_rect_geometry: uid=%s, " + "world_transform scale=(%.2f, %.2f), world_rect=%s", + self.uid, + world_transform.get_scale()[0] if world_transform else 0, + world_transform.get_scale()[1] if world_transform else 0, + rect, + ) + return geo + + @property + def display_icon_name(self) -> str: + """Delegates display_icon_name access to the StockAsset.""" + asset = self.stock_asset + return asset.display_icon_name if asset else "error-symbolic" + + def get_default_size( + self, _bounds_width: float = 0, _bounds_height: float = 0 + ) -> tuple[float, float]: + """Delegates size calculation to the StockAsset.""" + asset = self.stock_asset + if asset: + return asset.get_natural_size() + return 1.0, 1.0 + + @property + def material(self) -> Material | None: + """ + Gets the Material object for this stock item via its StockAsset. + + Returns: + Material instance or None if not set or not found + """ + asset = self.stock_asset + return asset.material if asset else None + + def set_visible(self, visible: bool): + """Sets the visibility of the stock item.""" + if self.visible == visible: + return + self.visible = visible + self.updated.send(self) + + def get_natural_aspect_ratio(self) -> float | None: + """ + Returns the aspect ratio of the stock's geometry bounding box + from its StockAsset. + """ + asset = self.stock_asset + if not asset or asset.geometry.is_empty(): + return None + w, h = asset.get_natural_size() + return w / h if h > 1e-9 else None + + def get_current_aspect_ratio(self) -> float | None: + """ + Returns the aspect ratio of the stock's current world-space size. + """ + w, h = self.size + return w / h if h > 1e-9 else None diff --git a/rayforge/core/stock_asset.py b/rayforge/core/stock_asset.py new file mode 100644 index 000000000..8d2e4b686 --- /dev/null +++ b/rayforge/core/stock_asset.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import logging +import uuid +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, ClassVar + +from blinker import Signal +from raygeo.geo import Geometry + +from ..context import get_context +from ..image.geo_renderer import render_geometry_to_png +from .asset import IAsset + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from .material import Material + + +class StockAsset(IAsset): + """ + Represents a stock material definition in the asset library. + + This is not a DocItem and does not exist in the document hierarchy. + It defines the properties of a stock that can be instanced as a StockItem. + """ + + is_addable: ClassVar[bool] = True + asset_type_name: ClassVar[str] = "stock" + display_icon_name: ClassVar[str] = "stock-symbolic" + is_reorderable: ClassVar[bool] = True + is_draggable_to_canvas: ClassVar[bool] = True + type_display_name: ClassVar[str] = _("Stock Material") + can_edit: ClassVar[bool] = True + add_action: ClassVar[str | None] = "add-stock" + activate_action: ClassVar[str | None] = "activate-stock" + edit_item_action: ClassVar[str | None] = "edit-stock-item" + + def __init__(self, name: str = "Stock", geometry: Geometry | None = None): + self._uid: str = str(uuid.uuid4()) + self._name: str = name + self.geometry: Geometry = ( + geometry if geometry is not None else Geometry() + ) + self.thickness: float | None = None + self.material_uid: str | None = None + self._hidden: bool = False + self._updated = Signal() + self.extra: dict[str, Any] = {} + + @property + def uid(self) -> str: + """The unique identifier of the asset instance.""" + return self._uid + + @uid.setter + def uid(self, value: str) -> None: + """Set the unique identifier. Used for deserialization.""" + self._uid = value + + @property + def updated(self) -> Signal: + """Signal emitted when the stock asset changes.""" + return self._updated + + @property + def name(self) -> str: + """The user-facing name of the asset.""" + return self._name + + @name.setter + def name(self, value: str): + """Sets the asset name and sends an update signal if changed.""" + if self._name != value: + self._name = value + self._updated.send(self) + + def to_dict(self) -> dict[str, Any]: + """Serializes the StockAsset to a dictionary.""" + result = { + "uid": self.uid, + "type": self.asset_type_name, + "name": self.name, + "geometry": self.geometry.to_dict(), + "thickness": self.thickness, + "material_uid": self.material_uid, + "hidden": self._hidden, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> StockAsset: + """Deserializes a dictionary into a StockAsset instance.""" + known_keys = { + "uid", + "type", + "name", + "geometry", + "thickness", + "material_uid", + "hidden", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + geometry = ( + Geometry.from_dict(data["geometry"]) + if data.get("geometry") + else None + ) + asset = cls(name=data.get("name", "Stock"), geometry=geometry) + asset.uid = data["uid"] + asset.thickness = data.get("thickness") + asset.material_uid = data.get("material_uid") + asset._hidden = data.get("hidden", False) + asset.extra = extra + return asset + + def set_thickness(self, value: float | None): + """Setter method for use with undo commands.""" + if self.thickness != value: + self.thickness = value + self._updated.send(self) + + @property + def material(self) -> Material | None: + """ + Gets the Material object for this stock asset. + + Returns: + Material instance or None if not set or not found + """ + if not self.material_uid: + return None + + context = get_context() + material_mgr = context.material_mgr + return material_mgr.get_material_or_none(self.material_uid) + + def set_material(self, material_uid: str): + """ + Setter method for use with undo commands. + + Args: + material_uid: The UID of the material to set + """ + if self.material_uid != material_uid: + self.material_uid = material_uid + self._updated.send(self) + + def get_natural_size(self) -> tuple[float, float]: + """ + Returns the natural size of the stock's geometry bounding box. + """ + if self.geometry.is_empty(): + return 1.0, 1.0 # Fallback for empty geometry + min_x, min_y, max_x, max_y = self.geometry.rect() + width = max_x - min_x + height = max_y - min_y + return width, height + + @property + def hidden(self) -> bool: + """Indicates if this asset should be hidden from the UI.""" + return self._hidden + + @hidden.setter + def hidden(self, value: bool): + """Sets the hidden state and sends an update signal if changed.""" + if self._hidden != value: + self._hidden = value + self._updated.send(self) + + def set_hidden(self, value: bool): + """Setter method for use with undo commands.""" + self.hidden = value + + def get_thumbnail(self, size: int) -> bytes | None: + """Returns a PNG thumbnail of the stock geometry.""" + try: + return render_geometry_to_png(self.geometry, size) + except Exception: + logger.exception("Failed to generate stock thumbnail") + return None diff --git a/rayforge/core/tab.py b/rayforge/core/tab.py new file mode 100644 index 000000000..d325da298 --- /dev/null +++ b/rayforge/core/tab.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + + +@dataclass +class Tab: + """Represents a single tab on a workpiece's geometry.""" + + width: float # The length of the tab along the path in mm + segment_index: int # The index of the Command in Geometry.commands + pos: float # Normalized position (0.0 to 1.0) along that segment + uid: str = field(default_factory=lambda: str(uuid.uuid4())) diff --git a/rayforge/core/undo/__init__.py b/rayforge/core/undo/__init__.py new file mode 100644 index 000000000..e4a9d2d28 --- /dev/null +++ b/rayforge/core/undo/__init__.py @@ -0,0 +1,25 @@ +""" +Undo/Redo Framework Module + +This package provides a transactional undo/redo history manager based on the +Command pattern. +""" + +from .command import Command +from .composite_cmd import CompositeCommand +from .dict_cmd import DictItemCommand +from .history import HistoryManager +from .list_cmd import ListItemCommand, ReorderListCommand +from .property_cmd import ChangePropertyCommand +from .setter_cmd import SetterCommand + +__all__ = [ + "ChangePropertyCommand", + "Command", + "CompositeCommand", + "DictItemCommand", + "HistoryManager", + "ListItemCommand", + "ReorderListCommand", + "SetterCommand", +] diff --git a/rayforge/core/undo/command.py b/rayforge/core/undo/command.py new file mode 100644 index 000000000..8988d487f --- /dev/null +++ b/rayforge/core/undo/command.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from collections.abc import Callable + + +class Command(ABC): + """ + Abstract base class for an undoable action. + """ + + def __init__( + self, + name: str | None = None, + on_change_callback: Callable[[], None] | None = None, + ): + self.name = name + self.on_change_callback = on_change_callback + self.timestamp: float = time.time() + + @abstractmethod + def execute(self) -> None: + raise NotImplementedError + + @abstractmethod + def undo(self) -> None: + raise NotImplementedError + + def can_coalesce_with(self, next_command: Command) -> bool: + """ + Checks if the 'next_command' can be merged into this one without + modifying the state of either command. + + Args: + next_command: The incoming command to check. + + Returns: True if merging is possible, False otherwise. + """ + return False + + def coalesce_with(self, next_command: Command) -> bool: + """ + Attempts to merge the 'next_command' into this one. If successful, + this command's state is updated with the newer command's state, + and it returns True. Otherwise, it returns False. + + Args: + next_command: The incoming command to potentially merge. + + Returns: + True if merging was successful, False otherwise. + """ + return False + + def should_skip_undo(self) -> bool: + """ + Checks if this command should be skipped from being added to the + undo stack. This is useful for no-op operations that don't change + the state. + + Returns: + True if the command should be skipped, False otherwise. + """ + return False diff --git a/rayforge/core/undo/composite_cmd.py b/rayforge/core/undo/composite_cmd.py new file mode 100644 index 000000000..fa59b6deb --- /dev/null +++ b/rayforge/core/undo/composite_cmd.py @@ -0,0 +1,62 @@ +from collections.abc import Callable + +from .command import Command + + +class CompositeCommand(Command): + """ + A command that groups several other commands into a single transaction. + """ + + def __init__( + self, + commands: list[Command], + name: str, + on_change_callback: Callable[[], None] | None = None, + ): + super().__init__(name, on_change_callback) + self.commands = commands + + def execute(self) -> None: + """Executes all child commands in order.""" + for cmd in self.commands: + cmd.execute() + if self.on_change_callback: + self.on_change_callback() + + def undo(self) -> None: + """Undoes all child commands in reverse order.""" + for cmd in reversed(self.commands): + cmd.undo() + if self.on_change_callback: + self.on_change_callback() + + def can_coalesce_with(self, next_command: Command) -> bool: + if not isinstance(next_command, CompositeCommand): + return False + + # Both composites must have the same number of child commands. + if len(self.commands) != len(next_command.commands): + return False + + # Check if every child command can coalesce with its counterpart. + for i, cmd in enumerate(self.commands): + if not cmd.can_coalesce_with(next_command.commands[i]): + return False + + return True + + def coalesce_with(self, next_command: Command) -> bool: + """ + Merges another CompositeCommand if all their respective child + commands can be coalesced. + """ + if not self.can_coalesce_with(next_command): + return False + + # Since we know it's possible, now perform the merge. + for i, cmd in enumerate(self.commands): + cmd.coalesce_with(next_command.commands[i]) # type: ignore + + self.timestamp = next_command.timestamp + return True diff --git a/rayforge/core/undo/dict_cmd.py b/rayforge/core/undo/dict_cmd.py new file mode 100644 index 000000000..98ac02584 --- /dev/null +++ b/rayforge/core/undo/dict_cmd.py @@ -0,0 +1,80 @@ +""" +Provides a command for changing a value within a dictionary. +""" + +from collections.abc import Callable +from typing import Any, cast + +from .command import Command + + +class DictItemCommand(Command): + """ + An undoable command that changes a value for a specific key in a + dictionary. + """ + + def __init__( + self, + target_dict: dict[str, Any], + key: str, + new_value: Any, + name: str, + on_change_callback: Callable[[], Any] | None = None, + ): + """ + Initializes the command. + + Args: + target_dict: The dictionary to modify. + key: The key whose value will be changed. + new_value: The new value to set for the key. + name: The user-facing name for this command. + on_change_callback: An optional function to call after the + dictionary is modified. + """ + super().__init__(name, on_change_callback) + self.target_dict = target_dict + self.key = key + self.new_value = new_value + self.key_existed = self.key in self.target_dict + if self.key_existed: + self.old_value = self.target_dict.get(self.key) + else: + self.old_value = None + + def execute(self) -> None: + """Sets the new value in the dictionary.""" + self.target_dict[self.key] = self.new_value + if self.on_change_callback: + self.on_change_callback() + + def undo(self) -> None: + """Restores the old value in the dictionary.""" + if self.key_existed: + self.target_dict[self.key] = self.old_value + else: + if self.key in self.target_dict: + del self.target_dict[self.key] + + if self.on_change_callback: + self.on_change_callback() + + def can_coalesce_with(self, next_command: Command) -> bool: + return ( + isinstance(next_command, DictItemCommand) + and self.target_dict is next_command.target_dict + and self.key == next_command.key + ) + + def coalesce_with(self, next_command: Command) -> bool: + """ + Merges another DictItemCommand if it affects the same dictionary key. + """ + if not self.can_coalesce_with(next_command): + return False + + # mypy check for next_command type is done in can_coalesce_with + self.new_value = cast(DictItemCommand, next_command).new_value + self.timestamp = next_command.timestamp + return True diff --git a/rayforge/core/undo/history.py b/rayforge/core/undo/history.py new file mode 100644 index 000000000..3a6a15033 --- /dev/null +++ b/rayforge/core/undo/history.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager + +from blinker import Signal + +from .command import Command +from .composite_cmd import CompositeCommand + +logger = logging.getLogger(__name__) + +# Maximum time in seconds between two commands to be considered for coalescing. +COALESCE_THRESHOLD = 0.5 + + +class _TransactionContextProxy: + """ + A helper object yielded by the HistoryManager's transaction context + manager. + It proxies execute/add calls to the manager, ensuring they are handled + within the current transaction. + """ + + def __init__(self, manager: HistoryManager): + self._manager = manager + + def set_label(self, name: str) -> None: + """Sets the display name for the transaction (e.g., for the UI).""" + self._manager.transaction_name = name + + def execute(self, command: Command) -> None: + """Executes a command and adds it to the transaction.""" + self._manager.execute(command) + + def add(self, command: Command) -> None: + """Adds a command that has already been executed to the transaction.""" + self._manager.add(command) + + +class HistoryManager: + """ + Manages the undo/redo history using a transactional command pattern. + Supports both explicit transactions (for multi-part actions) and + automatic coalescing (for rapid, identical actions). + """ + + def __init__(self): + self.undo_stack: list[Command] = [] + self.redo_stack: list[Command] = [] + self.changed = Signal() + + # State for explicit, manual transactions + self.in_transaction: bool = False + self.transaction_commands: list[Command] = [] + self.transaction_name: str = "" + + # Track a checkpoint: None means the current state is at the + # checkpoint (no changes since checkpoint was set). + # A Command reference means that command and all commands below it + # in the undo stack represent the checkpointed state. + self._checkpoint: Command | None = None + + def execute(self, command: Command): + """ + Executes a command and adds it to the history, possibly coalescing + it with the previous command. + """ + command.execute() + self.add(command) + + def add(self, command: Command): + """ + Adds a command that has already been executed to the history, + possibly coalescing it with the previous command. + """ + if self.in_transaction: + self.transaction_commands.append(command) + return + self._add_to_history(command) + + def _add_to_history(self, command: Command): + """ + Adds a command to the undo stack, handling the coalescing logic. + This is the single entry point for a command to be placed on the + undo stack. + """ + if command.should_skip_undo(): + return + + last_command = self.undo_stack[-1] if self.undo_stack else None + + if last_command: + time_delta = command.timestamp - last_command.timestamp + # Try to coalesce if the new command is similar and recent. + if time_delta < COALESCE_THRESHOLD and last_command.coalesce_with( + command + ): + # The last command was successfully updated. + self.changed.send(self, command=last_command) + return + + # If we couldn't coalesce, add the new command to the stack. + self.undo_stack.append(command) + self.redo_stack.clear() + self.changed.send(self, command=command) + + @contextmanager + def transaction( + self, name: str = "Transaction" + ) -> Iterator[_TransactionContextProxy]: + """ + Provides a context manager for grouping commands into a single + transaction. + + If the transaction completes successfully, the commands are grouped + into a single history entry. If only one command is executed, it is + "unwrapped" and added directly. Otherwise, commands are bundled into + a CompositeCommand. + The transaction's name will be applied to the final command. + + If an exception occurs, all commands executed within the transaction + are undone, and the transaction is aborted. + + Usage: + with history_manager.transaction("My Changes") as t: + # t.set_label("A better name") is also possible + t.execute(SetterCommand(...)) + """ + self.begin_transaction(name) + try: + yield _TransactionContextProxy(self) + self.end_transaction() + except Exception: + # An exception occurred. Undo any commands that were executed. + for cmd in reversed(self.transaction_commands): + try: + cmd.undo() + except Exception: + # Best effort: log this secondary error. For now, we + # continue. + logger.exception("Secondary error during undo") + self.abort_transaction() + # The state has changed due to the undos, so we signal. + self.changed.send(self, command=None) + raise # Re-raise the original exception + + def begin_transaction(self, name: str = "Transaction"): + """ + Starts an explicit transaction. All subsequent commands executed will + be grouped together until end_transaction() is called. + """ + if self.in_transaction: + # Nested transactions are not supported; raise an error to prevent + # unexpected behavior. + raise RuntimeError( + "Cannot start a new transaction while another is already" + " active." + ) + + self.in_transaction = True + self.transaction_commands = [] + self.transaction_name = name + + def end_transaction(self): + """ + Ends the current transaction, creates a CompositeCommand, and adds + it to the history, allowing it to be coalesced. + """ + if not self.in_transaction: + return + + self.in_transaction = False + if not self.transaction_commands: + return + + # If only one command is in the transaction, it gets "unwrapped". + # Otherwise, they are bundled into a CompositeCommand. + final_command = self._coalesce_commands(self.transaction_commands) + + if final_command: + final_command.name = self.transaction_name + # Add the composite/unwrapped command to history via the proper + # channel. + self._add_to_history(final_command) + + def abort_transaction(self): + """ + Aborts the current transaction, discarding any commands that were + added since it began. NOTE: This does not undo the commands itself, + as that is handled by the context manager's exception block. + """ + if not self.in_transaction: + return + self.in_transaction = False + self.transaction_commands = [] + self.transaction_name = "" + + def _coalesce_commands(self, commands: list[Command]) -> Command | None: + """ + Internal helper to optimize a list of commands from an explicit + transaction. If there's only one command, it returns it directly. + Otherwise, it wraps them in a CompositeCommand. + """ + if not commands: + return None + if len(commands) == 1: + return commands[0] + + # Unlike automatic coalescing, here we group different commands + # into a single CompositeCommand. + return CompositeCommand(commands, self.transaction_name) + + def undo(self): + """Undoes the last action.""" + if not self.can_undo(): + return + command = self.undo_stack.pop() + command.undo() + self.redo_stack.append(command) + self.changed.send(self, command=command) + + def redo(self): + """Redoes the last undone action.""" + if not self.can_redo(): + return + command = self.redo_stack.pop() + command.execute() + self.undo_stack.append(command) + self.changed.send(self, command=command) + + def undo_to(self, target_command: Command): + """Undoes all actions up to and including the target command.""" + while self.can_undo(): + command_to_undo = self.undo_stack[-1] + self.undo() + if command_to_undo is target_command: + break + + def redo_to(self, target_command: Command): + """Redoes all actions up to and including the target command.""" + while self.can_redo(): + command_to_redo = self.redo_stack[-1] + self.redo() + if command_to_redo is target_command: + break + + def can_undo(self) -> bool: + """Returns True if there are actions to undo.""" + return bool(self.undo_stack) + + def can_redo(self) -> bool: + """Returns True if there are actions to redo.""" + return bool(self.redo_stack) + + def clear(self): + """Clears all undo and redo history.""" + self.undo_stack.clear() + self.redo_stack.clear() + self.in_transaction = False + self.transaction_commands.clear() + self._checkpoint = None + self.changed.send(self, command=None) + + def set_checkpoint(self): + """ + Marks the current state as a checkpoint. + The checkpoint is used to track whether the current state + matches the checkpointed state. + """ + if self.undo_stack: + self._checkpoint = self.undo_stack[-1] + else: + self._checkpoint = None + + def is_at_checkpoint(self) -> bool: + """ + Returns True if the current state matches the checkpointed state. + + The state is considered at the checkpoint if: + - There is no checkpoint set (None) AND undo stack is empty, + meaning we're at the initial state, or + - The top of the undo stack is the checkpoint command + + This allows the caller to determine if undoing/redoing has brought + the state back to the checkpointed position. + """ + if self._checkpoint is None: + return not self.undo_stack + + if not self.undo_stack: + return False + + return self.undo_stack[-1] is self._checkpoint + + def clear_checkpoint(self): + """Clears the checkpoint, treating current state as checkpointed.""" + if self.undo_stack: + self._checkpoint = self.undo_stack[-1] + else: + self._checkpoint = None diff --git a/rayforge/core/undo/list_cmd.py b/rayforge/core/undo/list_cmd.py new file mode 100644 index 000000000..407c95e91 --- /dev/null +++ b/rayforge/core/undo/list_cmd.py @@ -0,0 +1,74 @@ +from collections.abc import Callable +from typing import Any + +from .command import Command + + +class ListItemCommand(Command): + """A command for adding or removing an item from a list-like container.""" + + def __init__( + self, + owner_obj: Any, + item: Any, + undo_command: str, + redo_command: str, + on_change_callback: Callable[[], None] | None = None, + name: str | None = None, + ): + super().__init__(name, on_change_callback) + self.owner_obj = owner_obj + self.item = item + self.redo_command = getattr(owner_obj, redo_command) + self.undo_command = getattr(owner_obj, undo_command) + + def execute(self) -> None: + """Executes the redo action.""" + self.redo_command(self.item) + if self.on_change_callback: + self.on_change_callback() + + def undo(self) -> None: + """Executes the undo action.""" + self.undo_command(self.item) + if self.on_change_callback: + self.on_change_callback() + + +class ReorderListCommand(Command): + """A command to handle the reordering of a list.""" + + def __init__( + self, + target_obj: Any, + list_property_name: str, + new_list: list[Any], + setter_method_name: str | None = None, + on_change_callback: Callable[[], None] | None = None, + name: str | None = None, + ): + super().__init__(name, on_change_callback) + self.target_obj = target_obj + self.list_property_name = list_property_name + self.new_list = list(new_list) + self.setter_method_name = setter_method_name + self.old_list = list(getattr(target_obj, list_property_name)) + + def _set_list(self, new_order: list[Any]): + if self.setter_method_name: + setter_func = getattr(self.target_obj, self.setter_method_name) + setter_func(new_order) + else: + setattr(self.target_obj, self.list_property_name, new_order) + + def execute(self) -> None: + """Applies the new order to the list.""" + self._set_list(self.new_list) + if self.on_change_callback: + self.on_change_callback() + + def undo(self) -> None: + """Restores the original order of the list.""" + self._set_list(self.old_list) + if self.on_change_callback: + self.on_change_callback() diff --git a/rayforge/core/undo/property_cmd.py b/rayforge/core/undo/property_cmd.py new file mode 100644 index 000000000..736336504 --- /dev/null +++ b/rayforge/core/undo/property_cmd.py @@ -0,0 +1,71 @@ +from collections.abc import Callable +from typing import Any + +from .command import Command + +_sentinel = object() + + +class ChangePropertyCommand(Command): + """A command to change a single property on an object.""" + + def __init__( + self, + target: Any, + property_name: str, + new_value: Any, + old_value: Any = _sentinel, + setter_method_name: str | None = None, + on_change_callback: Callable[[], None] | None = None, + name: str | None = None, + ): + super().__init__(name, on_change_callback) + self.target = target + self.property_name = property_name + self.new_value = new_value + self.setter_method_name = setter_method_name + if old_value is _sentinel: + # If old_value is not provided, fetch it from the object. + # This maintains backward compatibility for cases where the + # command is created before the action is executed. + self.old_value = getattr(self.target, self.property_name) + else: + # If old_value is provided, use it. This is for cases where + # the action has already been executed. + self.old_value = old_value + + def _set_property(self, value: Any) -> None: + if self.setter_method_name: + setter_func = getattr(self.target, self.setter_method_name) + setter_func(value) + else: + setattr(self.target, self.property_name, value) + + def execute(self) -> None: + self._set_property(self.new_value) + if self.on_change_callback: + self.on_change_callback() + + def undo(self) -> None: + self._set_property(self.old_value) + if self.on_change_callback: + self.on_change_callback() + + def can_coalesce_with(self, next_command: Command) -> bool: + return ( + isinstance(next_command, ChangePropertyCommand) + and self.target is next_command.target + and self.property_name == next_command.property_name + ) + + def coalesce_with(self, next_command: Command) -> bool: + """ + Merges another ChangePropertyCommand if it affects the same + property. + """ + if not self.can_coalesce_with(next_command): + return False + + self.new_value = next_command.new_value # type: ignore + self.timestamp = next_command.timestamp + return True diff --git a/rayforge/core/undo/setter_cmd.py b/rayforge/core/undo/setter_cmd.py new file mode 100644 index 000000000..ae11f3905 --- /dev/null +++ b/rayforge/core/undo/setter_cmd.py @@ -0,0 +1,59 @@ +from collections.abc import Callable +from typing import Any + +from .command import Command + + +class SetterCommand(Command): + """ + A generic command to call a setter method with arbitrary arguments. + """ + + def __init__( + self, + target: Any, + setter_method_name: str, + new_args: tuple[Any, ...], + old_args: tuple[Any, ...], + on_change_callback: Callable[[], None] | None = None, + name: str | None = None, + ): + super().__init__(name, on_change_callback) + self.target = target + self.setter_method_name = setter_method_name + self.setter_method = getattr(self.target, self.setter_method_name) + self.new_args = new_args + self.old_args = old_args + + def execute(self) -> None: + """Executes the setter with the new arguments.""" + self.setter_method(*self.new_args) + if self.on_change_callback: + self.on_change_callback() + + def undo(self) -> None: + """Executes the setter with the old arguments to revert.""" + self.setter_method(*self.old_args) + if self.on_change_callback: + self.on_change_callback() + + def can_coalesce_with(self, next_command: Command) -> bool: + return ( + isinstance(next_command, SetterCommand) + and self.target is next_command.target + and self.setter_method_name == next_command.setter_method_name + ) + + def coalesce_with(self, next_command: Command) -> bool: + """ + Merges another SetterCommand if it affects the same object and + method. + """ + if not self.can_coalesce_with(next_command): + return False + + # The new arguments become the value from the incoming command. + self.new_args = next_command.new_args # type: ignore + # The timestamp is updated to the newer command's time. + self.timestamp = next_command.timestamp + return True diff --git a/rayforge/core/varset/__init__.py b/rayforge/core/varset/__init__.py new file mode 100644 index 000000000..b52d02e2c --- /dev/null +++ b/rayforge/core/varset/__init__.py @@ -0,0 +1,42 @@ +from .appkeyvar import AppKeyVar +from .baudratevar import BaudrateVar +from .boolvar import BoolVar +from .choicevar import ChoiceVar +from .floatvar import FloatVar, SliderFloatVar +from .hostnamevar import HostnameVar +from .intvar import IntVar +from .labeledchoicevar import LabeledChoiceVar +from .lengthvar import LengthVar +from .oauthvar import OAuthFlowVar +from .portvar import PortVar +from .serialportvar import SerialPortVar +from .speedvar import SpeedVar +from .textareavar import TextAreaVar +from .urlvar import UrlVar, WebsocketUrlVar +from .var import ValidationError, Var, get_editable_var_types +from .varset import VarSet, merge_varsets + +__all__ = [ + "AppKeyVar", + "BaudrateVar", + "BoolVar", + "ChoiceVar", + "FloatVar", + "HostnameVar", + "IntVar", + "LabeledChoiceVar", + "LengthVar", + "OAuthFlowVar", + "PortVar", + "SerialPortVar", + "SliderFloatVar", + "SpeedVar", + "TextAreaVar", + "UrlVar", + "ValidationError", + "Var", + "VarSet", + "WebsocketUrlVar", + "get_editable_var_types", + "merge_varsets", +] diff --git a/rayforge/core/varset/appkeyvar.py b/rayforge/core/varset/appkeyvar.py new file mode 100644 index 000000000..99e29793e --- /dev/null +++ b/rayforge/core/varset/appkeyvar.py @@ -0,0 +1,96 @@ +import json +from typing import Any + +from .var import Var + + +class AppKeyVar(Var[str]): + """ + A Var for obtaining an API key from a device that supports a + decision-based key approval flow. + + The value is a JSON string containing the key data, or "" if + not yet obtained. Users can also enter a key manually. + + URL config fields support ``{key}`` templates that reference + sibling Vars in the same VarSet, resolved at request time. + """ + + display_name = "Application Key" + + def __init__( + self, + key: str, + label: str, + app_name: str, + probe_url: str | None = None, + request_url: str | None = None, + poll_url: str | None = None, + description: str | None = None, + default: str | None = None, + value: str | None = None, + ): + self.app_name = app_name + self.probe_url = probe_url + self.request_url = request_url + self.poll_url = poll_url + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default or "", + value=value, + ) + + def get_api_key(self) -> str | None: + val = self.value + if not val: + return None + try: + tokens = json.loads(val) + if isinstance(tokens, dict): + return tokens.get("api_key") + return str(tokens) + except (json.JSONDecodeError, TypeError): + return val.strip() if val else None + + def has_key(self) -> bool: + return bool(self.get_api_key()) + + def resolve_config( + self, + overrides: dict[str, str] | None = None, + ) -> dict[str, Any]: + sibling_values: dict[str, Any] = {} + if self._varset is not None: + sibling_values = self._varset.get_values() + + merged: dict[str, Any] = { + "app_name": self.app_name, + "probe_url": self.probe_url, + "request_url": self.request_url, + "poll_url": self.poll_url, + } + if overrides: + for k, v in overrides.items(): + if v and v.strip(): + merged[k] = v + + def _resolve(template): + if template is None: + return None + return template.format(**sibling_values) + + merged["probe_url"] = _resolve(merged["probe_url"]) + merged["request_url"] = _resolve(merged["request_url"]) + merged["poll_url"] = _resolve(merged["poll_url"]) + return merged + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data["app_name"] = self.app_name + data["probe_url"] = self.probe_url + data["request_url"] = self.request_url + data["poll_url"] = self.poll_url + return data diff --git a/rayforge/core/varset/baudratevar.py b/rayforge/core/varset/baudratevar.py new file mode 100644 index 000000000..a386e4290 --- /dev/null +++ b/rayforge/core/varset/baudratevar.py @@ -0,0 +1,64 @@ +from gettext import gettext as _ +from typing import Any + +from .intvar import IntVar, ValidationError + +STANDARD_BAUD_RATES: list[int] = [ + 9600, + 19200, + 38400, + 57600, + 115200, + 230400, + 460800, + 921600, + 1000000, + 1843200, +] + + +def validate_baud_rate(rate: int | None, choices: list[int]): + """Raises ValidationError if the baud rate is not in the choices list.""" + if rate is None: + raise ValidationError(_("Baud rate cannot be empty.")) + if rate not in choices: + raise ValidationError( + _("'{rate}' is not a standard baud rate.").format(rate=rate) + ) + + +class BaudrateVar(IntVar): + """A Var subclass for serial port baud rates, for use with a dropdown.""" + + display_name = _("Baud Rate") + + def __init__( + self, + key: str, + label: str = _("Baud Rate"), + description: str | None = _("Connection speed in bits per second"), + default: int | None = 115200, + value: int | None = None, + min_val: int | None = None, + max_val: int | None = None, + choices: list[int] | None = None, + ): + self.choices: list[int] = ( + choices if choices is not None else list(STANDARD_BAUD_RATES) + ) + super().__init__( + key=key, + label=label, + description=description, + default=default, + value=value, + # Provide sensible, non-None bounds for serialization + min_val=300, + max_val=4000000, + validator=lambda v: validate_baud_rate(v, self.choices), + ) + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data["choices"] = self.choices + return data diff --git a/rayforge/core/varset/boolvar.py b/rayforge/core/varset/boolvar.py new file mode 100644 index 000000000..1ca1c3d03 --- /dev/null +++ b/rayforge/core/varset/boolvar.py @@ -0,0 +1,36 @@ +from gettext import gettext as _ + +from .var import Var + + +class BoolVar(Var[bool]): + """A variable that represents a boolean value.""" + + display_name = _("Boolean (Switch)") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: bool | None = None, + value: bool | None = None, + ): + """ + Initializes a new BoolVar instance. + + Args: + key: The unique machine-readable identifier. + label: The human-readable name for the UI. + description: A longer, human-readable description. + default: The default value. + value: The initial value. If provided, it overrides the default. + """ + super().__init__( + key=key, + label=label, + var_type=bool, + description=description, + default=default, + value=value, + ) diff --git a/rayforge/core/varset/choicevar.py b/rayforge/core/varset/choicevar.py new file mode 100644 index 000000000..2bb63d4d3 --- /dev/null +++ b/rayforge/core/varset/choicevar.py @@ -0,0 +1,78 @@ +from gettext import gettext as _ +from typing import Any + +from .var import Var + + +class ChoiceVar(Var[str]): + """ + A variable that represents a choice from a predefined list of strings. + """ + + display_name = _("Choice") + + def __init__( + self, + key: str, + label: str, + choices: list[str], + description: str | None = None, + default: str | None = None, + value: str | None = None, + allow_none: bool = True, + null_label: str | None = None, + ): + """ + Initializes a new ChoiceVar instance. + + Args: + key: The unique machine-readable identifier. + label: The human-readable name for the UI. + choices: A list of string options for the user to choose from. + description: A longer, human-readable description. + default: The default value. Must be one of the choices. + value: The initial value. If provided, it overrides the default. + allow_none: Whether to include a "None Selected" option in UI. + null_label: Overrides the default "None Selected" option label + (e.g. "Standard" for a protocol variant whose unset value + means "use the standard/default option"). + """ + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default, + value=value, + ) + self.choices = choices + self.allow_none = allow_none + self.null_label = null_label + + # Validator to ensure the value is always one of the allowed choices. + def _choice_validator(val: str | None): + if val is not None and val not in self.choices: + raise ValueError( + f"Value '{val}' is not a valid choice for '{self.key}'" + ) + + self.validator = _choice_validator + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data.update({"choices": self.choices}) + return data + + def get_display_for_value(self, value: str | None) -> str | None: + """ + For simple ChoiceVar, the display value is the same as the stored + value. Subclasses can override this for mapping. + """ + return value + + def get_value_for_display(self, display: str | None) -> str | None: + """ + For simple ChoiceVar, the stored value is the same as the display + value. Subclasses can override this for mapping. + """ + return display diff --git a/rayforge/core/varset/floatvar.py b/rayforge/core/varset/floatvar.py new file mode 100644 index 000000000..fc77f3aed --- /dev/null +++ b/rayforge/core/varset/floatvar.py @@ -0,0 +1,107 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import Any + +from .var import ValidationError, Var + + +class FloatVar(Var[float]): + """A Var subclass for float values with optional bounds.""" + + display_name = _("Floating Point") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: float | None = None, + value: float | None = None, + min_val: float | None = None, + max_val: float | None = None, + extra_validator: Callable[[float], None] | None = None, + ): + self.min_val = min_val + self.max_val = max_val + + def validator(v: float | None): + # A None value is valid for an unset optional field. + if v is None: + return + + if self.min_val is not None and v < self.min_val: + raise ValidationError( + _("Value must be at least {min_val}.").format( + min_val=self.min_val + ) + ) + if self.max_val is not None and v > self.max_val: + raise ValidationError( + _("Value must be at most {max_val}.").format( + max_val=self.max_val + ) + ) + if extra_validator: + extra_validator(v) + + super().__init__( + key=key, + label=label, + var_type=float, + description=description, + default=default, + value=value, + validator=validator, + ) + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data.update({"min_val": self.min_val, "max_val": self.max_val}) + return data + + +class SliderFloatVar(FloatVar): + """ + A FloatVar subclass that hints to the UI that it should be represented + by a slider rather than a spinbox. + The value is typically expected to be in a normalized 0.0-1.0 range, + which the UI will display as 0-100. + """ + + display_name = _("Slider (0-100%)") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: float | None = None, + value: float | None = None, + min_val: float | None = None, + max_val: float | None = None, + extra_validator: Callable[[float], None] | None = None, + show_value: bool = True, + format_suffix: str | None = None, + ): + self.show_value = show_value + self.format_suffix = format_suffix + super().__init__( + key=key, + label=label, + description=description, + default=default, + value=value, + min_val=min_val, + max_val=max_val, + extra_validator=extra_validator, + ) + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data.update( + { + "show_value": self.show_value, + "format_suffix": self.format_suffix, + } + ) + return data diff --git a/rayforge/core/varset/hostnamevar.py b/rayforge/core/varset/hostnamevar.py new file mode 100644 index 000000000..4b51d740b --- /dev/null +++ b/rayforge/core/varset/hostnamevar.py @@ -0,0 +1,67 @@ +import ipaddress +from collections.abc import Callable +from gettext import gettext as _ + +from .var import ValidationError, Var + + +def is_valid_hostname_or_ip(s: str) -> bool: + if not isinstance(s, str): + return False + try: + ipaddress.ip_address(s) + return True + except ValueError: + pass + if len(s) <= 2 or len(s) > 253 or s.endswith("."): + return False + labels = s.split(".") + if ( + len(labels) == 4 + and any(label.isdigit() for label in labels) + and not all(label.isdigit() for label in labels) + ): + return False + if s.replace(".", "").isdigit(): + return False + for label in labels: + if not (1 <= len(label) <= 63): + return False + if label.startswith("-") or label.endswith("-"): + return False + if not all(c.isalnum() or c == "-" for c in label): + return False + return True + + +def hostname_validator(hostname: str | None): + """Raises ValidationError if the string is not a valid hostname/IP.""" + if not hostname: + raise ValidationError(_("Hostname or IP address cannot be empty.")) + if not is_valid_hostname_or_ip(hostname): + raise ValidationError(_("Invalid hostname or IP address format.")) + + +class HostnameVar(Var[str]): + """A Var subclass for hostnames or IP addresses.""" + + display_name = _("Hostname / IP") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: str | None = None, + value: str | None = None, + validator: Callable[[str | None], None] | None = hostname_validator, + ): + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default, + value=value, + validator=validator, + ) diff --git a/rayforge/core/varset/intvar.py b/rayforge/core/varset/intvar.py new file mode 100644 index 000000000..8ebdc4cfa --- /dev/null +++ b/rayforge/core/varset/intvar.py @@ -0,0 +1,56 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import Any + +from .var import ValidationError, Var + + +class IntVar(Var[int]): + """A Var subclass for integer values with optional bounds.""" + + display_name = _("Integer") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: int | None = None, + value: int | None = None, + min_val: int | None = None, + max_val: int | None = None, + validator: Callable[[int | None], None] | None = None, + ): + self.min_val = min_val + self.max_val = max_val + + def thevalidator(v: int | None): + if self.min_val is not None and v is not None and v < self.min_val: + raise ValidationError( + _("Value must be at least {min_val}.").format( + min_val=self.min_val + ) + ) + if self.max_val is not None and v is not None and v > self.max_val: + raise ValidationError( + _("Value must be at most {max_val}.").format( + max_val=self.max_val + ) + ) + if validator: + validator(v) + + super().__init__( + key=key, + label=label, + var_type=int, + description=description, + default=default, + value=value, + validator=thevalidator, + ) + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data.update({"min_val": self.min_val, "max_val": self.max_val}) + return data diff --git a/rayforge/core/varset/labeledchoicevar.py b/rayforge/core/varset/labeledchoicevar.py new file mode 100644 index 000000000..6ba9ce556 --- /dev/null +++ b/rayforge/core/varset/labeledchoicevar.py @@ -0,0 +1,59 @@ +from gettext import gettext as _ + +from .choicevar import ChoiceVar + + +class LabeledChoiceVar(ChoiceVar): + """A :class:`ChoiceVar` that shows human-readable labels while + storing machine-readable values. + + The ``choices`` are given as ``(label, value)`` pairs. The UI + dropdown shows the labels; the stored value is the corresponding + value. This is used for enum-backed recipe settings (e.g. + ``CutSide``) so the editor displays "Centerline" rather than + "CENTERLINE". + """ + + display_name = _("Choice (Labeled)") + + def __init__( + self, + key: str, + label: str, + choices: list[tuple[str, str]], + description: str | None = None, + default: str | None = None, + value: str | None = None, + allow_none: bool = True, + ): + self._label_to_value = {lbl: val for lbl, val in choices} + self._value_to_label = {val: lbl for lbl, val in choices} + display_choices = [lbl for lbl, _ in choices] + super().__init__( + key=key, + label=label, + choices=display_choices, + description=description, + default=default, + value=value, + allow_none=allow_none, + ) + valid_values = list(self._value_to_label) + + def _labeled_validator(val: str | None): + if val is not None and val not in valid_values: + raise ValueError( + f"Value '{val}' is not a valid choice for '{self.key}'" + ) + + self.validator = _labeled_validator + + def get_display_for_value(self, value: str | None) -> str | None: + if value is None: + return None + return self._value_to_label.get(value, value) + + def get_value_for_display(self, display: str | None) -> str | None: + if display is None: + return None + return self._label_to_value.get(display, display) diff --git a/rayforge/core/varset/lengthvar.py b/rayforge/core/varset/lengthvar.py new file mode 100644 index 000000000..3b751f5b2 --- /dev/null +++ b/rayforge/core/varset/lengthvar.py @@ -0,0 +1,34 @@ +from collections.abc import Callable + +from .floatvar import FloatVar + + +class LengthVar(FloatVar): + """ + A FloatVar representing a length value (e.g. offset, overcut). + + Values are always stored in base units (mm). Hints the UI to apply + unit conversion via LengthSpinRow. + """ + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: float | None = None, + value: float | None = None, + min_val: float | None = None, + max_val: float | None = None, + extra_validator: Callable[[float], None] | None = None, + ): + super().__init__( + key=key, + label=label, + description=description, + default=default, + value=value, + min_val=min_val, + max_val=max_val, + extra_validator=extra_validator, + ) diff --git a/rayforge/core/varset/oauthvar.py b/rayforge/core/varset/oauthvar.py new file mode 100644 index 000000000..16ec1996c --- /dev/null +++ b/rayforge/core/varset/oauthvar.py @@ -0,0 +1,144 @@ +import json +from datetime import datetime, timezone +from typing import Any + +from .var import Var + + +class OAuthFlowVar(Var[str]): + """ + A Var that represents an OAuth 2.0 Authorization Code flow. + + The value is a JSON string containing token data, or "" if not + yet authenticated. + + URL config fields support ``{key}`` templates that reference + sibling Vars in the same VarSet, resolved at flow-start time. + If a field is ``None`` the adapter shows an entry row so the + user can provide it manually. + """ + + display_name = "OAuth Authentication" + + def __init__( + self, + key: str, + label: str, + authorize_url: str | None = None, + token_url: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + scopes: list[str] | None = None, + redirect_port: int = 8765, + description: str | None = None, + default: str | None = None, + value: str | None = None, + ): + self.authorize_url = authorize_url + self.token_url = token_url + self.client_id = client_id + self.client_secret = client_secret + self.scopes = scopes or [] + self.redirect_port = redirect_port + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default or "", + value=value, + ) + + def get_tokens(self) -> dict[str, Any] | None: + """Return parsed token dict, or None if not authenticated.""" + val = self.value + if not val: + return None + try: + return json.loads(val) + except (json.JSONDecodeError, TypeError): + return None + + def is_authenticated(self) -> bool: + tokens = self.get_tokens() + if not tokens: + return False + if not tokens.get("access_token"): + return False + return not self._is_expired(tokens) + + def is_expired(self) -> bool: + tokens = self.get_tokens() + if not tokens: + return False + return self._is_expired(tokens) + + def get_refresh_token(self) -> str | None: + tokens = self.get_tokens() + if tokens: + return tokens.get("refresh_token") + return None + + @staticmethod + def _is_expired(tokens: dict[str, Any]) -> bool: + expires_at_str = tokens.get("expires_at") + if not expires_at_str: + return False + try: + expires_at = datetime.fromisoformat(expires_at_str) + return datetime.now(tz=timezone.utc) >= expires_at + except (ValueError, TypeError): + return False + + def resolve_config( + self, + overrides: dict[str, str] | None = None, + ) -> dict[str, Any]: + """ + Build a resolved config dict with all ``{key}`` placeholders + substituted from sibling Var values. + + *overrides* is an optional dict (typically from user-provided + entry rows in the adapter) that takes precedence over the + var's own fields. + """ + sibling_values: dict[str, Any] = {} + if self._varset is not None: + sibling_values = self._varset.get_values() + + merged: dict[str, Any] = { + "authorize_url": self.authorize_url, + "token_url": self.token_url, + "client_id": self.client_id, + "client_secret": self.client_secret, + "scopes": self.scopes, + "redirect_port": self.redirect_port, + } + if overrides: + for k, v in overrides.items(): + if v and v.strip(): + merged[k] = v + + def _resolve(template): + if template is None: + return None + try: + return template.format(**sibling_values) + except (KeyError, IndexError): + return template + + merged["authorize_url"] = _resolve(merged["authorize_url"]) + merged["token_url"] = _resolve(merged["token_url"]) + merged["client_id"] = _resolve(merged["client_id"]) + merged["client_secret"] = _resolve(merged["client_secret"]) + return merged + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + data = super().to_dict(include_value=include_value) + data["authorize_url"] = self.authorize_url + data["token_url"] = self.token_url + data["client_id"] = self.client_id + data["client_secret"] = self.client_secret + data["scopes"] = self.scopes + data["redirect_port"] = self.redirect_port + return data diff --git a/rayforge/core/varset/portvar.py b/rayforge/core/varset/portvar.py new file mode 100644 index 000000000..f9e178c7c --- /dev/null +++ b/rayforge/core/varset/portvar.py @@ -0,0 +1,38 @@ +from gettext import gettext as _ + +from .intvar import IntVar, ValidationError + + +def port_validator(port: int | None): + """Raises ValidationError if port is not a valid network port.""" + if port is None: + raise ValidationError(_("Port cannot be empty.")) + if not isinstance(port, int): + raise ValidationError(_("Port must be a number.")) + # The range check (1-65535) is handled by IntVar's validator logic + # because we pass min_val and max_val to its constructor. + + +class PortVar(IntVar): + """A Var subclass for network port numbers.""" + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: int | None = None, + value: int | None = None, + min_val: int | None = None, + max_val: int | None = None, + ): + super().__init__( + key=key, + label=label, + description=description, + default=default, + value=value, + min_val=1, + max_val=65535, + validator=port_validator, + ) diff --git a/rayforge/core/varset/serialportvar.py b/rayforge/core/varset/serialportvar.py new file mode 100644 index 000000000..065c0ee91 --- /dev/null +++ b/rayforge/core/varset/serialportvar.py @@ -0,0 +1,33 @@ +from gettext import gettext as _ + +from .var import ValidationError, Var + + +def serial_port_validator(port: str | None): + """Raises ValidationError if the serial port is not specified.""" + if not port: + raise ValidationError(_("Serial port cannot be empty.")) + + +class SerialPortVar(Var[str]): + """A Var subclass for serial port names.""" + + display_name = _("Serial Port") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: str | None = None, + value: str | None = None, + ): + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default, + value=value, + validator=serial_port_validator, + ) diff --git a/rayforge/core/varset/speedvar.py b/rayforge/core/varset/speedvar.py new file mode 100644 index 000000000..1cd0700b9 --- /dev/null +++ b/rayforge/core/varset/speedvar.py @@ -0,0 +1,35 @@ +from collections.abc import Callable + +from .intvar import IntVar + + +class SpeedVar(IntVar): + """ + An IntVar representing a speed value (e.g. cut speed, travel speed). + + Hints the UI to apply unit conversion via SpeedSpinRow. + """ + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: int | None = None, + value: int | None = None, + min_val: int | None = None, + max_val: int | None = None, + role: str = "cut", + validator: Callable[[int | None], None] | None = None, + ): + self.role = role + super().__init__( + key=key, + label=label, + description=description, + default=default, + value=value, + min_val=min_val, + max_val=max_val, + validator=validator, + ) diff --git a/rayforge/core/varset/textareavar.py b/rayforge/core/varset/textareavar.py new file mode 100644 index 000000000..cfdd719f8 --- /dev/null +++ b/rayforge/core/varset/textareavar.py @@ -0,0 +1,30 @@ +from gettext import gettext as _ + +from .var import Var + + +class TextAreaVar(Var[str]): + """ + A Var subclass for multi-line string values that hints to the UI + that it should be represented by a text area (Gtk.TextView) rather than + a single-line entry. + """ + + display_name = _("Text (Multi-Line)") + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: str | None = None, + value: str | None = None, + ): + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default, + value=value, + ) diff --git a/rayforge/core/varset/urlvar.py b/rayforge/core/varset/urlvar.py new file mode 100644 index 000000000..2b04235ce --- /dev/null +++ b/rayforge/core/varset/urlvar.py @@ -0,0 +1,91 @@ +from gettext import gettext as _ +from urllib.parse import urlparse + +from .var import ValidationError, Var + + +def url_validator( + url: str | None, allowed_schemes: tuple[str, ...] | None = None +): + """ + Raises ValidationError if the string is not a valid URL. + + Args: + url: The URL string to validate. + allowed_schemes: Optional tuple of allowed schemes + (e.g., ('http', 'https')). If None, any scheme is allowed. + """ + if not url: + raise ValidationError(_("URL cannot be empty.")) + try: + parsed = urlparse(url) + if not parsed.scheme: + raise ValidationError( + _("URL must include a scheme (e.g., 'http://').") + ) + if not parsed.netloc: + raise ValidationError(_("URL must include a hostname.")) + if allowed_schemes and parsed.scheme not in allowed_schemes: + schemes_str = ", ".join(f"'{s}://'" for s in allowed_schemes) + raise ValidationError( + _("URL scheme must be one of: {schemes}.").format( + schemes=schemes_str + ) + ) + except ValidationError: + raise + except (ValueError, TypeError) as e: + raise ValidationError(_("Invalid URL: {error}").format(error=str(e))) + + +class UrlVar(Var[str]): + """A Var subclass for generic URLs.""" + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: str | None = None, + value: str | None = None, + allowed_schemes: tuple[str, ...] | None = None, + ): + self.allowed_schemes = allowed_schemes + + def validator(url: str | None): + url_validator(url, allowed_schemes=allowed_schemes) + + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default, + value=value, + validator=validator, + ) + + +class WebsocketUrlVar(Var[str]): + """A Var subclass specifically for WebSocket URLs (ws:// or wss://).""" + + def __init__( + self, + key: str, + label: str, + description: str | None = None, + default: str | None = None, + value: str | None = None, + ): + def validator(url: str | None): + url_validator(url, allowed_schemes=("ws", "wss")) + + super().__init__( + key=key, + label=label, + var_type=str, + description=description, + default=default, + value=value, + validator=validator, + ) diff --git a/rayforge/core/varset/var.py b/rayforge/core/varset/var.py new file mode 100644 index 000000000..ca49458e5 --- /dev/null +++ b/rayforge/core/varset/var.py @@ -0,0 +1,266 @@ +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + TypeVar, +) + +from blinker import Signal + +if TYPE_CHECKING: + from .varset import VarSet + + +T = TypeVar("T") + + +class ValidationError(ValueError): + """Custom exception for validation failures in Var.""" + + +class Var(Generic[T]): + """ + Represents a single typed variable with metadata for UI generation, + validation, and data handling. + """ + + _registry: ClassVar[dict[str, type["Var"]]] = {} + + display_name: str | None = None + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + Var._registry[cls.__name__] = cls + + def __init__( + self, + key: str, + label: str, + var_type: type[T], + description: str | None = None, + default: T | None = None, + value: T | None = None, + validator: Callable[[T | None], None] | None = None, + ): + """ + Initializes a new Var instance. + + Args: + key: The unique machine-readable identifier for the variable. + label: The human-readable name for the variable (e.g., for UI). + var_type: The expected Python type of the variable's value. + description: A longer, human-readable description. + default: The default value. + value: The initial value. If provided, it overrides the default. + validator: An optional callable that raises an exception if a new + value is invalid. + """ + self._key = key + self._label = label + self.var_type = var_type + self._description = description + self._default = default + self.validator = validator + self._value: T | None = None + self._varset: VarSet | None = None + + # Signal sent when the Var's value or default value changes. + self.value_changed = Signal() + # Signal sent when the Var's definition (key, label, etc.) changes. + self.definition_changed = Signal() + + # Set initial explicit value ONLY if provided. + if value is not None: + self.value = value # Use the public setter + + @property + def key(self) -> str: + """The unique machine-readable identifier for the variable.""" + return self._key + + @key.setter + def key(self, new_key: str): + if self._key != new_key: + self._key = new_key + self.definition_changed.send(self, property="key") + + @property + def label(self) -> str: + """The human-readable name for the variable (e.g., for UI).""" + return self._label + + @label.setter + def label(self, new_label: str): + if self._label != new_label: + self._label = new_label + self.definition_changed.send(self, property="label") + + @property + def description(self) -> str | None: + """A longer, human-readable description.""" + return self._description + + @description.setter + def description(self, new_description: str | None): + if self._description != new_description: + self._description = new_description + self.definition_changed.send(self, property="description") + + def validate(self) -> None: + """ + Runs the validator on the current effective value. + + Raises: + ValidationError: If validation fails. + """ + if self.validator: + try: + # The validator checks the effective value. + self.validator(self.value) + except ValidationError: + raise + except Exception as e: + raise ValidationError( + f"Validation failed for key '{self.key}' with value " + f"'{self.value}': {e}" + ) from e + + @property + def default(self) -> T | None: + """The default value of the variable.""" + return self._default + + @default.setter + def default(self, new_default: T | None): + """ + Sets the default value, triggering updates if effective value changes. + """ + old_effective_value = self.value + old_default = self._default + + if old_default == new_default: + return # Nothing to do + + self._default = new_default + new_effective_value = self.value + + # A change in default is always a change in definition. + self.definition_changed.send(self, property="default") + + # If the effective value was also changed, send that signal too. + if old_effective_value != new_effective_value: + self.value_changed.send( + self, + new_value=new_effective_value, + old_value=old_effective_value, + ) + + @property + def raw_value(self) -> T | None: + """The explicitly set value, or None if the default is being used.""" + return self._value + + @property + def value(self) -> T | None: + """ + The effective value of the variable (returns explicit value if set, + otherwise default). + """ + if self._value is not None: + return self._value + return self.default + + @value.setter + def value(self, new_value: T | None): + """ + Sets the explicit override value for the variable. + """ + old_effective_value = self.value + coerced_value: T | None + + # 1. Coerce value if not None + if new_value is None: + coerced_value = None + else: + try: + if self.var_type is int: + coerced_value = int(float(new_value)) # type: ignore + elif self.var_type is bool: + if isinstance(new_value, str): + val_lower = new_value.lower() + if val_lower in ("true", "1", "on", "yes"): + coerced_value = True # type: ignore + elif val_lower in ("false", "0", "off", "no"): + coerced_value = False # type: ignore + else: + raise ValueError( + f"Cannot convert string '{new_value}' to bool." + ) + else: + coerced_value = bool(new_value) # type: ignore + else: + coerced_value = self.var_type(new_value) # type: ignore + except (ValueError, TypeError) as e: + raise TypeError( + f"Value '{new_value}' for key '{self.key}' cannot be " + f"coerced to type {self.var_type.__name__}" + ) from e + + # 2. Assign the coerced value to the explicit storage. + self._value = coerced_value + + # 3. Emit signal if the *effective* value changed. + new_effective_value = self.value + if old_effective_value != new_effective_value: + self.value_changed.send( + self, + new_value=new_effective_value, + old_value=old_effective_value, + ) + + def to_dict(self, include_value: bool = False) -> dict[str, Any]: + """ + Serializes the Var's definition to a dictionary. + + Args: + include_value: If True, the current value of the Var is included + in the output. Defaults to False. + """ + data = { + "class": self.__class__.__name__, + "key": self.key, + "label": self.label, + "description": self.description, + "default": self.default, + } + if self.__class__ is Var: + vtype = self.var_type + data["var_type"] = f"{vtype.__module__}.{vtype.__qualname__}" + if include_value: + # Always serialize the effective value + data["value"] = self.value + return data + + def __repr__(self) -> str: + return ( + f"Var(key='{self.key}', value={self.value}, " + f"type={self.var_type.__name__})" + ) + + +Var.display_name = _("Text (Single Line)") +Var._registry["Var"] = Var + + +def get_editable_var_types() -> list: + return sorted( + [ + (cls.display_name, cls) + for cls in Var._registry.values() + if cls.display_name is not None + ], + key=lambda t: t[0], + ) diff --git a/rayforge/core/varset/varset.py b/rayforge/core/varset/varset.py new file mode 100644 index 000000000..c9b367de4 --- /dev/null +++ b/rayforge/core/varset/varset.py @@ -0,0 +1,294 @@ +import importlib +import logging +from collections.abc import Iterator, KeysView +from typing import Any + +from blinker import Signal + +from .var import Var + +logger = logging.getLogger(__name__) + + +class VarSet: + """ + A collection of Var objects, representing a logical group of settings or + parameters. This class is observable via blinker signals. + """ + + def __init__( + self, + vars: list[Var] | None = None, + title: str | None = None, + description: str | None = None, + ): + """ + Initializes a new VarSet. + + Args: + vars: An optional list of Var objects to populate the set with. + title: An optional title for the group of variables. + description: An optional description for the group. + """ + self.title = title + self.description = description + self._vars: dict[str, Var] = {} + self._order: list[str] = [] # Explicit order tracking + self.extra: dict[str, Any] = {} + + self.var_added = Signal() + self.var_removed = Signal() + self.cleared = Signal() + self.var_value_changed = Signal() + self.var_definition_changed = Signal() + + if vars: + for var in vars: + self.add(var) + + def _on_child_var_changed(self, var: Var, **kwargs): + """Handler for bubbling up value changes from contained Vars.""" + logger.debug( + f"Signal bubble-up: var_value_changed for var '{var.key}' " + f"to '{kwargs.get('new_value')}'" + ) + self.var_value_changed.send(self, var=var, **kwargs) + + def _on_child_var_definition_changed(self, var: Var, **kwargs): + """ + Handler for bubbling up definition changes from contained Vars. + """ + if kwargs.get("property") == "key": + old_key = None + for k, v in self._vars.items(): + if v is var: + old_key = k + break + + if old_key is not None and old_key != var.key: + logger.debug( + f"Resyncing VarSet dictionary for key rename: " + f"'{old_key}' -> '{var.key}'" + ) + # Update the dictionary key + self._vars[var.key] = self._vars.pop(old_key) + # Update the explicit order list + try: + idx = self._order.index(old_key) + self._order[idx] = var.key + except ValueError: + # Should not happen if state is consistent + pass + + logger.debug( + f"Signal bubble-up: var_definition_changed for var '{var.key}' " + f"(prop: {kwargs.get('property')})" + ) + self.var_definition_changed.send(self, var=var, **kwargs) + + @staticmethod + def _create_var_from_dict(data: dict[str, Any]) -> Var: + """ + Internal factory to instantiate a Var subclass from its serialized + definition. + """ + data_copy = data.copy() + class_name = data_copy.pop("class", None) + if not class_name: + raise ValueError( + "Var definition dictionary is missing 'class' key." + ) + VarClass = Var._registry.get(class_name) + if VarClass is None: + raise ValueError( + f"Unknown Var class '{class_name}' in definition." + ) + if VarClass is Var: + type_path = data_copy.pop("var_type", "builtins.str") + module_name, qualname = type_path.rsplit(".", 1) + module = importlib.import_module(module_name) + data_copy["var_type"] = getattr(module, qualname) + return VarClass(**data_copy) + + @property + def vars(self) -> list[Var]: + """Returns the list of Var objects in the set in order.""" + return [self._vars[key] for key in self._order] + + def add(self, var: Var): + """Adds a Var to the set. Raises KeyError if the key exists.""" + if var.key in self._vars: + raise KeyError( + f"Var with key '{var.key}' already exists in this VarSet." + ) + self._vars[var.key] = var + self._order.append(var.key) + var._varset = self + + # Connect directly to the var's instance signal. + # weak=False ensures the bound method is not garbage collected + # prematurely. We are responsible for disconnecting it manually. + var.value_changed.connect(self._on_child_var_changed, weak=False) + var.definition_changed.connect( + self._on_child_var_definition_changed, weak=False + ) + logger.debug(f"Emitting signal: var_added for var '{var.key}'") + self.var_added.send(self, var=var) + + def remove(self, key: str) -> Var | None: + """Removes a Var from the set by its key and returns it.""" + var = self._vars.pop(key, None) + if var: + if key in self._order: + self._order.remove(key) + var._varset = None + # Disconnect from the specific instance signal. + var.value_changed.disconnect(self._on_child_var_changed) + var.definition_changed.disconnect( + self._on_child_var_definition_changed + ) + logger.debug(f"Emitting signal: var_removed for var '{var.key}'") + self.var_removed.send(self, var=var) + return var + + def get(self, key: str) -> Var | None: + """Gets a Var by its key, or None if not found.""" + return self._vars.get(key) + + def move_var(self, key: str, new_index: int): + """ + Moves the variable with the given key to a new index in the list. + """ + if key not in self._order: + return + + # Clamp index + new_index = max(new_index, 0) + if new_index >= len(self._order): + new_index = len(self._order) - 1 + + current_index = self._order.index(key) + if current_index == new_index: + return + + self._order.pop(current_index) + self._order.insert(new_index, key) + + def to_dict( + self, include_value: bool = False, include_metadata: bool = True + ) -> dict[str, Any]: + """ + Serializes the VarSet's definition to a dictionary. + + Args: + include_value: If True, include the current value of each Var. + include_metadata: If True, include the VarSet's title and + description. + """ + data: dict[str, Any] = { + "vars": [ + self._vars[key].to_dict(include_value=include_value) + for key in self._order + ], + } + if include_metadata: + data["title"] = self.title + data["description"] = self.description + data.update(self.extra) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "VarSet": + """Deserializes a dictionary into a full VarSet instance.""" + known_keys = {"vars", "title", "description"} + extra = {k: v for k, v in data.items() if k not in known_keys} + + new_set = cls( + title=data.get("title"), description=data.get("description") + ) + var_definitions = data.get("vars", []) + for var_data in var_definitions: + try: + new_var = cls._create_var_from_dict(var_data) + new_set.add(new_var) + except Exception as e: # noqa: BLE001 - arbitrary deserialized var + logger.warning("Could not deserialize var: %s", e) + new_set.extra = extra + return new_set + + def __getitem__(self, key: str) -> Var: + """Gets a Var by its key. Raises KeyError if not found.""" + return self._vars[key] + + def __setitem__(self, key: str, value: Any): + """Sets the value of an existing Var by its key.""" + if key not in self._vars: + raise KeyError( + f"No Var with key '{key}' in this VarSet. " + "Use add() to add a new Var." + ) + self._vars[key].value = value + + def __iter__(self) -> Iterator[Var]: + """Iterates over the Var objects in insertion/defined order.""" + for key in self._order: + yield self._vars[key] + + def __len__(self) -> int: + """Returns the number of Var objects in the set.""" + return len(self._vars) + + def keys(self) -> KeysView[str]: + """Returns a view of the Var keys.""" + return self._vars.keys() + + def get_values(self) -> dict[str, Any]: + """Returns a dictionary of all keys and their current values.""" + return {key: var.value for key, var in self._vars.items()} + + def set_values(self, values: dict[str, Any]): + """ + Sets the values for multiple Vars from a dictionary. + Ignores keys that are not in the VarSet. + """ + for key, value in values.items(): + if key in self._vars: + self[key] = value + + def clear(self): + """Removes all Var objects from the set.""" + for var in list(self._vars.values()): + var.value_changed.disconnect(self._on_child_var_changed) + var.definition_changed.disconnect( + self._on_child_var_definition_changed + ) + var._varset = None + self._vars.clear() + self._order.clear() + logger.debug("Emitting signal: cleared") + self.cleared.send(self) + + def validate(self): + """ + Validates all Var objects in the set. + Raises: ValidationError on the first validation failure. + """ + for var in self: + var.validate() + + def __repr__(self) -> str: + return f"VarSet(title='{self.title}', count={len(self)})" + + +def merge_varsets(*varsets: VarSet) -> VarSet: + """ + Merge multiple VarSets into a single VarSet. + + Vars are collected by key, with later VarSets overriding earlier + ones for shared keys. The merged VarSet carries no title. + """ + merged: dict[str, Var] = {} + for vs in varsets: + for var in vs: + merged[var.key] = var + return VarSet(vars=list(merged.values())) diff --git a/rayforge/core/vectorization_spec.py b/rayforge/core/vectorization_spec.py new file mode 100644 index 000000000..96291d108 --- /dev/null +++ b/rayforge/core/vectorization_spec.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from raygeo.svg.color import ColorAttr + + +class LayerImportMode(Enum): + """Determines how imported layers are mapped into the document.""" + + MAP_TO_EXISTING = "map_to_existing" + NEW_LAYERS = "new_layers" + FLATTEN = "flatten" + + +class LayerSource(Enum): + """Determines how layers are identified in a vector source file.""" + + SVG_LAYERS = "svg_layers" + COLORS = "colors" + + +@dataclass +class VectorizationSpec(ABC): + """Base class for defining how vectors are generated.""" + + ppi: float = 96.0 + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + """Serializes the specification to a dictionary.""" + raise NotImplementedError + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> VectorizationSpec: + """Factory to create a VectorizationSpec instance from a dictionary.""" + spec_type = data.get("type") + if not spec_type: + raise ValueError("VectorizationSpec dict must have a 'type' key.") + + if spec_type == "TraceSpec": + return TraceSpec.from_dict(data) + elif spec_type == "PassthroughSpec": + return PassthroughSpec.from_dict(data) + elif spec_type == "ProceduralSpec": + return ProceduralSpec.from_dict(data) + else: + raise ValueError(f"Unknown VectorizationSpec type: {spec_type}") + + +@dataclass +class TraceSpec(VectorizationSpec): + """Specifies that vectors should be generated by tracing a bitmap.""" + + threshold: float = 0.5 + auto_threshold: bool = True + invert: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "type": "TraceSpec", + "threshold": self.threshold, + "auto_threshold": self.auto_threshold, + "invert": self.invert, + "ppi": self.ppi, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TraceSpec: + return cls( + threshold=data.get("threshold", 0.5), + auto_threshold=data.get("auto_threshold", True), + invert=data.get("invert", False), + ppi=data.get("ppi", 96.0), + ) + + +@dataclass +class PassthroughSpec(VectorizationSpec): + """ + Specifies that vectors should be parsed directly from a vector source. + """ + + active_layer_ids: list[str] | None = None + layer_import_mode: LayerImportMode = LayerImportMode.MAP_TO_EXISTING + layer_source: LayerSource = LayerSource.SVG_LAYERS + color_attr: ColorAttr = ColorAttr.ANY + trim_padding: float = 0.01 + + def to_dict(self) -> dict[str, Any]: + return { + "type": "PassthroughSpec", + "active_layer_ids": self.active_layer_ids, + "layer_import_mode": self.layer_import_mode.value, + "layer_source": self.layer_source.value, + "color_attr": self.color_attr.value, + "trim_padding": self.trim_padding, + "ppi": self.ppi, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PassthroughSpec: + mode_str = data.get("layer_import_mode") + if mode_str: + mode = LayerImportMode(mode_str) + elif "create_new_layers" in data: + mode = ( + LayerImportMode.NEW_LAYERS + if data["create_new_layers"] + else LayerImportMode.FLATTEN + ) + else: + mode = LayerImportMode.MAP_TO_EXISTING + source_str = data.get("layer_source") + layer_source = ( + LayerSource(source_str) if source_str else LayerSource.SVG_LAYERS + ) + return cls( + active_layer_ids=data.get("active_layer_ids"), + layer_import_mode=mode, + layer_source=layer_source, + color_attr=color_attr_from_value(data.get("color_attr")), + trim_padding=data.get("trim_padding", 0.01), + ppi=data.get("ppi", 96.0), + ) + + +def color_attr_from_value(value: Any) -> ColorAttr: + """Maps a serialized color-attribute string back to a ColorAttr.""" + mapping = { + "fill": ColorAttr.FILL, + "stroke": ColorAttr.STROKE, + "fill_else_stroke": ColorAttr.FILL_ELSE_STROKE, + "any": ColorAttr.ANY, + } + try: + return mapping[value] + except (KeyError, TypeError): + return ColorAttr.ANY + + +@dataclass +class ProceduralSpec(VectorizationSpec): + """Specifies that vectors are generated by a procedural function.""" + + def to_dict(self) -> dict[str, Any]: + return {"type": "ProceduralSpec", "ppi": self.ppi} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ProceduralSpec: + return cls(ppi=data.get("ppi", 96.0)) diff --git a/rayforge/core/workflow.py b/rayforge/core/workflow.py new file mode 100644 index 000000000..5ec4269d5 --- /dev/null +++ b/rayforge/core/workflow.py @@ -0,0 +1,143 @@ +""" +Defines the Workflow class, which holds an ordered sequence of Steps. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from typing import Any, TypeVar + +from blinker import Signal +from raygeo.geo import Matrix + +from .item import DocItem +from .step import Step + +logger = logging.getLogger(__name__) + +# For generic type hinting in add_child +T = TypeVar("T", bound="DocItem") + + +class Workflow(DocItem): + """ + An ordered sequence of Steps that defines a manufacturing process. + + Each Layer owns a Workflow. The Workflow holds a list of Step + objects, which are applied in order to the workpieces in the layer to + generate machine operations. It automatically bubbles signals from its + child steps. + """ + + def __init__(self, name: str): + """ + Initializes the Workflow. + + Args: + name: The user-facing name for the work plan. + """ + super().__init__(name=name) + self.per_step_transformer_changed = Signal() + + # Forward compatibility: store unknown attributes + self.extra: dict[str, Any] = {} + + def to_dict(self) -> dict: + """Serializes the workflow and its children to a dictionary.""" + result = { + "uid": self.uid, + "type": "workflow", + "name": self.name, + "matrix": self.matrix.to_list(), + "children": [child.to_dict() for child in self.children], + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Workflow: + """Deserializes a dictionary into a Workflow instance.""" + known_keys = { + "uid", + "type", + "name", + "matrix", + "children", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + workflow = cls(name=data.get("name", "Workflow")) + workflow.uid = data["uid"] + workflow.matrix = Matrix.from_list(data["matrix"]) + workflow.extra = extra + + steps = [ + Step.from_dict(d) + for d in data.get("children", []) + if d.get("type") == "step" + ] + workflow.set_children(steps) + return workflow + + @property + def steps(self) -> list[Step]: + """Returns a list of all child items that are Steps.""" + return [child for child in self.children if isinstance(child, Step)] + + def __iter__(self): + """Allows iteration over the work steps.""" + return iter(self.steps) + + def _on_per_step_transformer_changed(self, step: Step): + """ + Handles changes to per-step transformers from a child step and + bubbles the signal up. + """ + self.per_step_transformer_changed.send(self) + + def add_child(self, child: T, index: int | None = None) -> T: + if isinstance(child, Step): + child.per_step_transformer_changed.connect( + self._on_per_step_transformer_changed + ) + super().add_child(child, index) + return child + + def remove_child(self, child: DocItem): + if isinstance(child, Step): + child.per_step_transformer_changed.disconnect( + self._on_per_step_transformer_changed + ) + super().remove_child(child) + + def set_children(self, new_children: Iterable[DocItem]): + old_steps = self.steps + for step in old_steps: + step.per_step_transformer_changed.disconnect( + self._on_per_step_transformer_changed + ) + + new_steps = [c for c in new_children if isinstance(c, Step)] + for step in new_steps: + step.per_step_transformer_changed.connect( + self._on_per_step_transformer_changed + ) + + super().set_children(new_children) + + def add_step(self, step: Step): + """Adds a step to the end of the work plan.""" + self.add_child(step) + + def remove_step(self, step: Step): + """Removes a step from the work plan.""" + self.remove_child(step) + + def set_steps(self, steps: list[Step]): + """Replaces the entire list of steps with a new one.""" + self.set_children(steps) + + def has_steps(self) -> bool: + """Checks if the work plan contains any steps.""" + return len(self.steps) > 0 diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py new file mode 100644 index 000000000..e3640c6f9 --- /dev/null +++ b/rayforge/core/workpiece.py @@ -0,0 +1,1573 @@ +from __future__ import annotations + +import logging +import math +import warnings +from collections.abc import Generator +from copy import deepcopy +from dataclasses import asdict +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + NamedTuple, + cast, +) + +import cairo +import numpy as np + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Point, Rect +from raygeo.ops.part import Part + +from ..context import get_context +from .asset_registry import asset_type_registry +from .geometry_provider import IGeometryProvider +from .item import DocItem +from .source_asset_segment import SourceAssetSegment +from .tab import Tab +from .vectorization_spec import TraceSpec + +if TYPE_CHECKING: + from ..image.base_renderer import Renderer, RenderSpecification + from ..image.structures import FillRenderData + from .asset import IAsset + from .layer import Layer + from .source_asset import SourceAsset + + +logger = logging.getLogger(__name__) + +CAIRO_MAX_DIMENSION = 16384 + + +class RenderContext(NamedTuple): + """Encapsulates the resources required for rendering.""" + + data: bytes | Any + original_data: bytes | None + renderer: Renderer + source_pixel_dims: tuple[int, int] | None + metadata: dict[str, Any] + boundaries: Geometry | None + fills: list[FillRenderData] | None + + +class WorkPiece(DocItem): + """ + Represents a real-world workpiece. It is a lightweight data container, + holding its transformation matrix and a link to its source and shape + definition. + """ + + def __init__( + self, + name: str, + source_segment: SourceAssetSegment | None = None, + ): + super().__init__(name=name) + self._source_segment = source_segment + self._boundaries_cache: Geometry | None = None + self._fills_cache: list[FillRenderData] | None = None + + # Natural (untransformed) dimensions of the workpiece content. + self.natural_width_mm: float = 0.0 + self.natural_height_mm: float = 0.0 + + # An optional override for the workpiece geometry. If set, this takes + # precedence over the source_segment's geometry. It allows for + # non-destructive editing (like splitting) without modifying the + # shared source segment. + 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] = {} + + # Transient attributes for deserialized instances in subprocesses + self._data: bytes | None = None + self._original_data: bytes | None = None + self._renderer: Renderer | None = None + self._transient_source_px_dims: tuple[int, int] | None = None + + self.geometry_provider_uid: str | None = None + self._geometry_provider_params: dict[str, Any] = {} + self._transient_geometry_provider: IGeometryProvider | None = None + self._geometry_provider_connection: Any | None = None + + self._resolved_text_cache: dict = {} + + self.source_asset_uid: str | None = None + + self._tabs: list[Tab] = [] + self._tabs_enabled: bool = True + + # Transient cache for UI view artifacts (Cairo surfaces, etc.) + # This persists across view element destruction/creation + # (e.g. Grouping) but is not serialized to disk. + self._view_cache: dict[str, Any] = {} + + # Forward compatibility: store unknown attributes + self.extra: dict[str, Any] = {} + + def depends_on_asset(self, asset: IAsset) -> bool: + """ + Checks if this workpiece depends on the given asset, either through + its geometry provider or its source file. + """ + if ( + self.geometry_provider_uid + and self.geometry_provider_uid == asset.uid + ): + return True + return bool( + self.source_segment + and self.source_segment.source_asset_uid == asset.uid + ) + + @classmethod + def from_geometry_provider(cls, provider: IGeometryProvider) -> WorkPiece: + """ + Factory method to create a WorkPiece from an IGeometryProvider. + + This method generates geometry from the provider to determine + its natural dimensions and initialize the WorkPiece's transformation + matrix correctly before it is added to the document. + """ + geometry = None + fill_data = [] + min_x, min_y = 0.0, 0.0 + + instance_cache: dict = {} + + try: + geometry, fill_data = provider.get_geometry( + resolved_text_cache=instance_cache + ) + + geometry.upgrade_to_scalable() + for fd in fill_data: + fd.geometry.upgrade_to_scalable() + + if not geometry.is_empty(): + min_x, min_y, max_x, max_y = geometry.rect() + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + else: + width, height = 0.0, 0.0 + except Exception as e: # noqa: BLE001 - plugin provider boundary + logger.warning( + f"Failed to calculate initial geometry for provider " + f"{provider.provider_type_name}: {e}" + ) + width, height = 0.0, 0.0 + + geometry = Geometry() + fill_data = [] + + # 2. Create the instance + instance = cls( + name=provider.name or provider.provider_type_name.capitalize() + ) + instance.geometry_provider_uid = provider.uid + instance.natural_width_mm = width + instance.natural_height_mm = height + + # 3. Set the transformation matrix scale to match natural size. + if width > 1e-6 and height > 1e-6: + instance.set_size(width, height) + + # 4. Pre-populate caches to avoid immediate re-solve on first render. + # We perform the same normalization here that the boundaries property + # does. + if geometry and not geometry.is_empty(): + norm_matrix = Matrix.scale( + 1.0 / width, 1.0 / height + ) @ Matrix.translation(-min_x, -min_y) + geometry.transform(norm_matrix) + for fd in fill_data: + fd.geometry.transform(norm_matrix) + + # Cache the results (even if empty) to ensure fast rendering + instance._boundaries_cache = geometry + instance._fills_cache = fill_data + instance._resolved_text_cache = instance_cache + + return instance + + @property + def source_segment(self) -> SourceAssetSegment | None: + """The source data definition for this workpiece.""" + return self._source_segment + + @source_segment.setter + def source_segment(self, new_segment: SourceAssetSegment | None): + """ + Sets a new source segment, clearing caches and signaling an update. + This is the correct way to modify a workpiece's source data. + """ + if self._source_segment != new_segment: + self._source_segment = new_segment + # Invalidate all cached data that depends on the source. + self.clear_render_cache() + # Signal that the workpiece's content has changed. This is crucial + # for triggering the pipeline to re-process the geometry. + self.updated.send(self) + + @property + def natural_size(self) -> tuple[float, float]: + """ + Returns the natural (untransformed) size of the content in mm. + This is the authoritative source for the workpiece's intrinsic size. + """ + return (self.natural_width_mm, self.natural_height_mm) + + def get_local_bbox(self) -> Rect | None: + """ + WorkPieces are geometrically defined as a unit square (0,0,1,1) that is + scaled by their matrix. + """ + return (0.0, 0.0, 1.0, 1.0) + + def clear_render_cache(self): + """ + Invalidates and clears all cached renders for this workpiece. + Should be called if the underlying data or geometry changes. + """ + logger.debug( + f"WP {self.uid[:8]}: Clearing all caches (render and boundaries)." + ) + self._render_cache.clear() + self._boundaries_cache = None + self._fills_cache = None + + @property + def source(self) -> SourceAsset | None: + """ + Convenience property to retrieve the full SourceAsset object from the + document's central registry. + """ + if self.doc and self.source_segment: + return self.doc.get_source_asset_by_uid( + self.source_segment.source_asset_uid + ) + return None + + @property + def original_data(self) -> bytes | None: + """ + Retrieves the original, unmodified data from the source asset. + """ + # Prioritize transient data for isolated/subprocess instances + if self._original_data is not None: + return self._original_data + source = self.source + return source.original_data if source else None + + @property + def data(self) -> bytes | None: + """ + Retrieves the appropriate source data for rendering. + + This property intelligently selects the correct data: + 1. Prioritizes transient data for isolated/subprocess instances. + 2. Prioritizes the `base_render_data` (e.g., a trimmed SVG or + cropped PDF) if it exists, as this is what the workpiece's size + is based on. + 3. Falls back to the `original_data` if no specific render data + is available. + """ + # Prioritize transient data for isolated/subprocess instances + if self._data is not None: + return self._data + source = self.source + if not source: + return None + + # Prioritize the processed render data if it exists. + if source.base_render_data is not None: + return source.base_render_data + + # Fall back to the original data. + return source.original_data + + @property + def source_file(self) -> Path | None: + """ + Retrieves the source file path from the linked SourceAsset. + + For workpieces with a source_segment, this retrieves the source file + from the segment's SourceAsset. For sketches and other workpieces + without a source_segment, this retrieves the source file from the + directly linked SourceAsset via source_asset_uid. + """ + source = self.source + if source: + return source.source_file + if self.source_asset_uid and self.doc: + asset = self.doc.get_source_asset_by_uid(self.source_asset_uid) + return asset.source_file if asset else None + return None + + @property + def _active_renderer(self) -> Renderer | None: + """Retrieves the renderer (internal use).""" + if self._renderer is not None: + return self._renderer + + # If we have a geometry provider, use its renderer. + provider = self.get_geometry_provider() + if provider: + return provider.renderer + + source = self.source + return source.renderer if source else None + + @property + def boundaries(self) -> Geometry | None: + """ + The normalized vector geometry defining the workpiece shape. + + This `Geometry` object represents the workpiece's intrinsic shape, + normalized to fit within a 1x1 unit reference box. This separation of + intrinsic shape from its world transformation is crucial for + preventing rendering and processing errors. + + If `_edited_boundaries` is set (e.g. from a split operation), it is + returned. Otherwise, the geometry from the `source_segment` is used. + + The local coordinate space of this normalized geometry has the + following properties: + + - **Coordinate System**: Y-up, where (0, 0) is the bottom-left corner. + - **Reference Size**: The geometry is scaled to fit within a box + that is 1 unit wide by 1 unit tall. + - **Origin (0,0)**: The anchor point is the bottom-left corner of the + geometry's bounding box. + - **Transformation**: The vector data itself is static. All physical + sizing, positioning, and rotation are handled by applying the + `WorkPiece.matrix` to this normalized shape. + """ + logger.debug("boundaries called") + if self._edited_boundaries is not None: + return self._edited_boundaries + + if self._boundaries_cache is not None: + logger.debug("Cache hit: boundaries present") + return self._boundaries_cache + logger.debug("Cache miss: boundaries not present") + + # --- GeometryProvider-based Geometry Generation --- + if self.geometry_provider_uid: + provider = self.get_geometry_provider() + if not provider: + return None + + logger.debug( + f"WP {self.uid[:8]}: Getting geometry with " + f"params: {self._geometry_provider_params}" + ) + unnormalized_geo, unnormalized_fills = provider.get_geometry( + self._geometry_provider_params, + resolved_text_cache=self._resolved_text_cache, + ) + + # Upgrade all generated geometry to be fully scalable + unnormalized_geo.upgrade_to_scalable() + for fill_data in unnormalized_fills: + fill_data.geometry.upgrade_to_scalable() + + # Cache the geometry even if it is empty, to prevent + # re-solving on every render frame. + if unnormalized_geo.is_empty(): + self._boundaries_cache = unnormalized_geo + self._fills_cache = unnormalized_fills + return self._boundaries_cache + + # Normalize the geometry to a 0-1 box (Y-Up) based on + # the boundaries (strokes). + min_x, min_y, max_x, max_y = unnormalized_geo.rect() + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + + # Detect natural size change and update metadata + old_w = self.natural_width_mm + old_h = self.natural_height_mm + + if abs(width - old_w) > 1e-5 or abs(height - old_h) > 1e-5: + # Update natural dimensions to match the actual geometry. + self.natural_width_mm = width + self.natural_height_mm = height + logger.debug( + f"WP {self.uid[:8]}: Natural size changed to " + f"{width:.2f}x{height:.2f}" + ) + + norm_matrix = Matrix.scale( + 1.0 / width, 1.0 / height + ) @ Matrix.translation(-min_x, -min_y) + + # Apply same normalization to both strokes and fills + unnormalized_geo.transform(norm_matrix) + for fill_data in unnormalized_fills: + fill_data.geometry.transform(norm_matrix) + + self._boundaries_cache = unnormalized_geo + self._fills_cache = unnormalized_fills + return self._boundaries_cache + + # --- SourceAssetSegment-based Geometry --- + if not self.source_segment: + logger.warning( + f"WP {self.uid[:8]}: Cannot get boundaries, no source_segment." + ) + return None + + # The authoritative path for vector imports + if ( + self.source_segment.pristine_geometry + and self.source_segment.normalization_matrix is not None + ): + # Path for UI rendering: normalize the pristine data + norm_geo = self.source_segment.pristine_geometry.copy() + norm_matrix = self.source_segment.normalization_matrix + norm_geo.transform(norm_matrix) + self._boundaries_cache = norm_geo + return self._boundaries_cache + + # If there's no pristine geometry, there's nothing to show. + logger.debug( + f"WP {self.uid[:8]}: No pristine geometry available in segment." + ) + return None + + @property + def fills(self) -> list[FillRenderData] | None: + """ + The fill geometry data for this workpiece, if any. + + Returns a list of FillRenderData objects for geometry-provider-based + workpieces (e.g., sketches), or None for workpieces where fills are + not tracked (images, imported SVGs). + + Accessing this property triggers boundary computation, which also + populates the fills cache. + """ + _ = self.boundaries # trigger boundary computation + return self._fills_cache + + @property + def world_space_boundaries(self) -> Geometry | None: + """ + The geometry scaled to world-space dimensions (millimeters). + + This transforms the normalized `boundaries` geometry back to + actual physical dimensions using natural_width_mm and + natural_height_mm. + + Returns None if boundaries is None or empty. + """ + geo = self.boundaries + if geo is None or geo.is_empty(): + return None + scaled = geo.copy() + w = self.natural_width_mm or 1.0 + h = self.natural_height_mm or 1.0 + scale_matrix = Matrix.scale(w, h) + scaled.transform(scale_matrix) + return scaled + + def to_part(self) -> Part | None: + """ + Create a :class:`raygeo.Part` from this workpiece's geometry and size. + + The resulting ``Part`` carries the workpiece's vector geometry scaled + to physical millimetre dimensions, ready for use with any raygeo + assembler (``profile_inner_part``, ``Workplan.from_part``, …). + + Disjoint pockets in the geometry are exposed as separate faces via + ``Part.from_geometry_multi_face``; a single-contour workpiece yields + the single default face ``""``. + + Returns ``None`` if the workpiece has no boundaries. + """ + boundaries = self.boundaries + if boundaries is None or boundaries.is_empty(): + return None + w, h = self.size + geo = boundaries.copy() + if w > 0 and h > 0: + geo.transform(Matrix.scale(w, h)) + return Part.from_geometry_multi_face(geometry=geo, size_mm=(w, h)) + + @property + def _boundaries_y_down(self) -> Geometry | None: + """ + Internal helper to get the Y-DOWN normalized geometry for use in + image masking, which operates in a Y-down pixel space. + """ + # This property *always* derives the Y-down geometry from the + # canonical Y-up `boundaries` property. + y_up_geo = self.boundaries + if not y_up_geo: + return None + + y_down_geo = y_up_geo.copy() + # Flip Y-up (0,0 at bottom) to Y-down (0,0 at top) in a 0-1 box + flip_matrix = Matrix.translation(0, 1) @ Matrix.scale(1, -1) + y_down_geo.transform(flip_matrix) + return y_down_geo + + @property + def tabs(self) -> list[Tab]: + """The list of Tab objects for this workpiece.""" + return self._tabs + + @tabs.setter + def tabs(self, new_tabs: list[Tab]): + if self._tabs != new_tabs: + self._tabs = new_tabs + self.updated.send(self) + + @property + def tabs_enabled(self) -> bool: + return self._tabs_enabled + + @tabs_enabled.setter + def tabs_enabled(self, new_value: bool): + if self._tabs_enabled != new_value: + self._tabs_enabled = new_value + self.updated.send(self) + + @property + def layer(self) -> Layer | None: + """Traverses the hierarchy to find the parent Layer.""" + from .layer import Layer # Local import to avoid circular dependency + + ancestor = self.get_ancestor_by_type(Layer) + return ancestor if isinstance(ancestor, Layer) else None + + def in_world(self) -> WorkPiece: + """ + Returns a new, unparented WorkPiece instance whose local + transformation matrix is the world transformation matrix of this one. + This effectively "bakes" the parent transformations into the object, + making it suitable for serialization or use in contexts without a + document hierarchy. It also hydrates the instance with the necessary + data for rendering in isolated environments like subprocesses. + """ + # Create a new instance to avoid side effects with signals, + # parents, etc. + world_wp = WorkPiece(self.name, deepcopy(self.source_segment)) + world_wp.uid = self.uid + world_wp.matrix = self.get_world_transform() + world_wp.tabs = deepcopy(self.tabs) + world_wp.tabs_enabled = self.tabs_enabled + world_wp.geometry_provider_uid = self.geometry_provider_uid + world_wp.geometry_provider_params = deepcopy( + self._geometry_provider_params + ) + world_wp.source_asset_uid = self.source_asset_uid + world_wp._resolved_text_cache = dict(self._resolved_text_cache) + + # Ensure any edited boundaries are carried over. + if self._edited_boundaries is not None: + world_wp._edited_boundaries = self._edited_boundaries.copy() + + # Hydrate with data and renderer for use in isolated contexts + # like subprocesses where the document link is lost. + source = self.source + if source: + world_wp._data = self.data + world_wp._original_data = self.original_data + world_wp._renderer = source.renderer + if source.width_px is not None and source.height_px is not None: + world_wp._transient_source_px_dims = ( + source.width_px, + source.height_px, + ) + + # Hydrate the transient geometry provider if it exists + if self.geometry_provider_uid and self.doc: + provider = self.doc.get_asset_by_uid(self.geometry_provider_uid) + if provider: + provider_dict = provider.to_dict() + provider_type = provider_dict.get("type") + if provider_type: + provider_cls = asset_type_registry.get(provider_type) + if provider_cls: + hydrated = provider_cls.from_dict(provider_dict) + if isinstance(hydrated, IGeometryProvider): + world_wp._transient_geometry_provider = hydrated + + return world_wp + + def get_geometry_provider(self) -> IGeometryProvider | None: + """ + Retrieves the geometry provider for this workpiece, if applicable. + Prioritizes the transient provider (subprocesses), then checks + the document registry. + Returns None if the provider is missing or is an UnknownAsset. + """ + if self._transient_geometry_provider: + return self._transient_geometry_provider + if self.geometry_provider_uid and self.doc: + provider = self.doc.get_asset_by_uid(self.geometry_provider_uid) + if not isinstance(provider, IGeometryProvider): + return None + if provider and self._geometry_provider_connection is None: + self._subscribe_to_geometry_provider(provider) + return provider + return None + + def _subscribe_to_geometry_provider( + self, provider: IGeometryProvider + ) -> None: + """ + Subscribe to geometry provider updates. + When the provider changes, we clear caches and emit updated. + """ + self._geometry_provider_connection = provider.updated.connect( + self._on_geometry_provider_updated + ) + + def _on_geometry_provider_updated(self, sender, **kwargs) -> None: + """Handler for when the geometry provider changes.""" + self.clear_render_cache() + self._boundaries_cache = None + self._fills_cache = None + self._resolved_text_cache = {} + self.updated.send(self) + + def _resolve_render_context(self) -> RenderContext | None: + """ + Resolves the data, renderer, and metadata needed for rendering. + Unifies logic for transient (subprocess) vs. managed (document) states. + """ + # Calling self.boundaries here ensures that the geometry is computed + # and cached, which populates both _boundaries_cache and _fills_cache. + boundaries = self.boundaries + fills = self._fills_cache + + # For geometry providers, the "data" is not needed, as the geometry + # is generated by the `boundaries` property. We pass empty bytes. + if self.geometry_provider_uid: + provider = self.get_geometry_provider() + renderer = provider.renderer if provider else None + if not renderer: + return None + + return RenderContext( + data=b"", + original_data=None, + renderer=renderer, + source_pixel_dims=None, + metadata={"is_vector": True}, + boundaries=boundaries, + fills=fills, + ) + + # --- Fallback to standard SourceAsset logic --- + + # 1. Renderer + renderer = self._active_renderer + if not renderer: + return None + + # 2. Data + data_to_render = self.data + if data_to_render is None: + return None + + # 3. Source Pixel Dimensions + source_px_dims = self._transient_source_px_dims + if ( + not source_px_dims + and self.source + and ( + self.source.width_px is not None + and self.source.height_px is not None + ) + ): + source_px_dims = ( + self.source.width_px, + self.source.height_px, + ) + + # 4. Original Data (for cropping) + original_data = self.original_data + + # 5. Metadata + metadata = self.source.metadata if self.source else {} + + return RenderContext( + data=data_to_render, + original_data=original_data, + renderer=renderer, + source_pixel_dims=source_px_dims, + metadata=metadata, + boundaries=boundaries, + fills=fills, + ) + + def _process_rendered_image_from_spec( + self, + image: pyvips.Image, + spec: RenderSpecification, + target_size: tuple[int, int], + source_px_dims: tuple[int, int] | None, + ) -> pyvips.Image | None: + """ + Applies post-processing based on a RenderSpecification. + """ + from ..image import util + + processed_image = image + target_w, target_h = target_size + + # 1. Apply Crop + if ( + spec.crop_rect + and self.source_segment + and self.source_segment.crop_window_px + ): + # Re-calculate the crop rect based on the actual rendered image + # dimensions to handle any rounding from the renderer. + crop_x, crop_y, crop_w, crop_h = map( + float, self.source_segment.crop_window_px + ) + actual_w = processed_image.width + actual_h = processed_image.height + + scale_x = 1.0 + scale_y = 1.0 + if source_px_dims and source_px_dims[0] > 0: + scale_x = actual_w / source_px_dims[0] + if source_px_dims and source_px_dims[1] > 0: + scale_y = actual_h / source_px_dims[1] + + scaled_x = int(crop_x * scale_x) + scaled_y = int(crop_y * scale_y) + scaled_w = int(crop_w * scale_x) + scaled_h = int(crop_h * scale_y) + + processed_image = util.safe_crop( + processed_image, scaled_x, scaled_y, scaled_w, scaled_h + ) + if not processed_image: + return None + + # 2. Apply Mask + if spec.apply_mask: + mask_geo = self._boundaries_y_down + if mask_geo and not mask_geo.is_empty(): + processed_image = util.apply_mask_to_vips_image( + processed_image, mask_geo + ) + if not processed_image: + return None + + # 3. Apply Inversion for traced imports + if self.source_segment and self.source_segment.vectorization_spec: + vspec = self.source_segment.vectorization_spec + if isinstance(vspec, TraceSpec) and vspec.invert: + bands = processed_image.bands + if bands == 2: + background = [255] + else: + background = [255, 255, 255] + processed_image = processed_image.flatten( + background=background + ).invert() + + # 4. Final Resize Check + if ( + ( + processed_image.width != target_w + or processed_image.height != target_h + ) + and processed_image.width > 0 + and processed_image.height > 0 + ): + h_scale = target_w / processed_image.width + v_scale = target_h / processed_image.height + processed_image = util.resize_linear( + processed_image, h_scale, vscale=v_scale + ) + + return processed_image + + def get_vips_image(self, width: int, height: int) -> pyvips.Image | None: + """ + The central hub for rendering a 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] + + # 1. Resolve Context + ctx = self._resolve_render_context() + if not ctx: + logger.warning( + f"WP {self.uid[:8]}: Could not resolve render context." + ) + return None + + # 2. Compute Render Specification from Renderer + spec = ctx.renderer.compute_render_spec( + self.source_segment, (width, height), ctx + ) + + # 3. Render (with SourceAsset-level cache for shared base images) + source = self.source + raw_image = None + + if source is not None and not spec.kwargs: + raw_image = source.get_cached_base_image( + id(spec.data), spec.width, spec.height + ) + + if raw_image is None: + raw_image = ctx.renderer.render_base_image( + spec.data, spec.width, spec.height, **spec.kwargs + ) + if not raw_image: + logger.warning(f"WP {self.uid[:8]}: Renderer returned None.") + return None + if source is not None and not spec.kwargs: + source.cache_base_image( + id(spec.data), spec.width, spec.height, raw_image + ) + + # 4. Process (Crop/Mask/Resize) based on the spec + final_image = self._process_rendered_image_from_spec( + raw_image, spec, (width, height), ctx.source_pixel_dims + ) + + if final_image: + self._render_cache[key] = final_image + + return final_image + + def get_local_size(self) -> tuple[float, float]: + """ + The local-space size (width, height) in mm, as absolute values, + decomposed from the local transformation matrix. This is used for + determining rasterization resolution. + """ + return self.matrix.get_abs_scale() + + def to_dict(self) -> dict[str, Any]: + """ + Serializes the WorkPiece state to a dictionary. Includes transient + data if it has been hydrated. + """ + state = { + "uid": self.uid, + "type": "workpiece", + "name": self.name, + "matrix": self._matrix.to_list(), + "width_mm": self.natural_width_mm, + "height_mm": self.natural_height_mm, + "tabs": [asdict(t) for t in self._tabs], + "tabs_enabled": self._tabs_enabled, + "source_segment": ( + self.source_segment.to_dict() if self.source_segment else None + ), + "edited_boundaries": ( + self._edited_boundaries.to_dict() + if self._edited_boundaries is not None + else None + ), + "geometry_provider_uid": self.geometry_provider_uid, + "geometry_provider_params": self._geometry_provider_params, + "source_asset_uid": self.source_asset_uid, + } + if self._resolved_text_cache: + cache = { + str(k): v + for k, v in self._resolved_text_cache.items() + if v is not None + } + if cache: + state["resolved_text_cache"] = cache + if self._data is not None: + state["data"] = self._data + if self._original_data is not None: + state["original_data"] = self._original_data + if self._renderer is not None: + state["renderer_name"] = self._renderer.__class__.__name__ + if self._transient_source_px_dims is not None: + state["source_px_dims"] = self._transient_source_px_dims + if self._transient_geometry_provider is not None: + state["transient_geometry_provider"] = ( + self._transient_geometry_provider.to_dict() + ) + # Include unknown attributes for forward compatibility + state.update(self.extra) + return state + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> WorkPiece: + """ + Restores a WorkPiece instance from a dictionary. + """ + known_keys = { + "uid", + "type", + "name", + "matrix", + "width_mm", + "height_mm", + "tabs", + "tabs_enabled", + "source_segment", + "edited_boundaries", + "geometry_provider_uid", + "geometry_provider_params", + "source_asset_uid", + "resolved_text_cache", + "data", + "original_data", + "source_px_dims", + "renderer_name", + "transient_geometry_provider", + "_geometry_provider_params", + # Legacy keys for backward compatibility + "sketch_uid", + "sketch_params", + "transient_sketch_definition", + "_sketch_params", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + config_data = data.get("source_segment") + source_segment = ( + SourceAssetSegment.from_dict(config_data) if config_data else None + ) + + wp = cls( + name=data["name"], + source_segment=source_segment, + ) + wp.uid = data["uid"] + wp.matrix = Matrix.from_list(data["matrix"]) + wp.natural_width_mm = data.get("width_mm", 0.0) + wp.natural_height_mm = data.get("height_mm", 0.0) + + loaded_tabs = [Tab(**t_data) for t_data in data.get("tabs", [])] + wp.tabs = loaded_tabs + wp.tabs_enabled = data.get("tabs_enabled", True) + + if data.get("edited_boundaries"): + wp._edited_boundaries = Geometry.from_dict( + data["edited_boundaries"] + ) + + wp.geometry_provider_uid = data.get( + "geometry_provider_uid" + ) or data.get("sketch_uid") + wp._geometry_provider_params = data.get( + "geometry_provider_params", {} + ) or data.get("sketch_params", {}) + wp.source_asset_uid = data.get("source_asset_uid") + raw_cache = data.get("resolved_text_cache") + if raw_cache: + wp._resolved_text_cache = {int(k): v for k, v in raw_cache.items()} + + # Hydrate with transient data if provided for subprocesses + if "data" in data: + wp._data = data["data"] + if "original_data" in data: + wp._original_data = data["original_data"] + if "source_px_dims" in data: + wp._transient_source_px_dims = tuple(data["source_px_dims"]) + if "renderer_name" in data: + renderer_name = data["renderer_name"] + from ..image import renderer_registry + + renderer = renderer_registry.get(renderer_name) + if renderer: + wp._renderer = renderer + + transient_data = data.get("transient_geometry_provider") or data.get( + "transient_sketch_definition" + ) + if transient_data: + provider_type = transient_data.get("type") + if provider_type: + provider_cls = asset_type_registry.get(provider_type) + if provider_cls: + hydrated = provider_cls.from_dict(transient_data) + if isinstance(hydrated, IGeometryProvider): + wp._transient_geometry_provider = hydrated + + wp.extra = extra + + return wp + + @property + def geometry_provider_params(self) -> dict[str, Any]: + """Get the geometry provider parameters for this workpiece.""" + return self._geometry_provider_params + + @geometry_provider_params.setter + def geometry_provider_params(self, new_params: dict[str, Any]): + """ + Set the geometry provider parameters and trigger regeneration if + needed. + """ + if self._geometry_provider_params != new_params: + self._geometry_provider_params = new_params + if self.geometry_provider_uid: + # Regenerate the internal geometry and natural size + self.regenerate_from_geometry_provider() + + def get_natural_aspect_ratio(self) -> float | None: + size = self.natural_size + if size: + w, h = size + if w and h and h > 0: + return w / h + return None + + def set_pos(self, x_mm: float, y_mm: float): + """Legacy method, use property `pos` instead.""" + self.pos = (x_mm, y_mm) + + def set_angle(self, angle: float): + """Legacy method, use property `angle` instead.""" + self.angle = angle + + def get_default_size( + self, bounds_width: float, bounds_height: float + ) -> tuple[float, float]: + """Calculates a sensible default size based on the content's aspect + ratio and the provided container bounds.""" + size = self.natural_size + if size and size[0] > 0 and size[1] > 0: + return cast(tuple[float, float], size) + + aspect = self.get_natural_aspect_ratio() + if aspect is None: + return bounds_width, bounds_height + + width_mm = bounds_width + height_mm = width_mm / aspect + if height_mm > bounds_height: + height_mm = bounds_height + width_mm = height_mm * aspect + + return width_mm, height_mm + + def render_to_pixels( + self, width: int, height: int + ) -> cairo.ImageSurface | None: + from ..image import util + + # This now uses the central hub for rendering. + final_image = self.get_vips_image(width, height) + if not final_image: + return None + normalized_image = util.normalize_to_rgba(final_image) + if not normalized_image: + return None + return util.vips_rgba_to_cairo_surface(normalized_image) + + def render_for_ops( + self, + pixels_per_mm_x: float, + pixels_per_mm_y: float, + ) -> cairo.ImageSurface | None: + """Renders to a pixel surface at the workpiece's current size. + Returns None if size is not valid.""" + # Use the final world-space size for rendering resolution. + current_size = self.size + if not current_size or current_size[0] <= 0 or current_size[1] <= 0: + return None + + width_mm, height_mm = current_size + target_width_px = int(width_mm * pixels_per_mm_x) + target_height_px = int(height_mm * pixels_per_mm_y) + + return self.render_to_pixels(target_width_px, target_height_px) + + def _calculate_chunk_layout( + self, + real_width: int, + real_height: int, + max_chunk_width: int | None, + max_chunk_height: int | None, + max_memory_size: int | None, + ) -> tuple[int, int, int, int]: + bytes_per_pixel = 4 + effective_max_width = min( + max_chunk_width + if max_chunk_width is not None + else CAIRO_MAX_DIMENSION, + CAIRO_MAX_DIMENSION, + ) + chunk_width = min(real_width, effective_max_width) + possible_heights = [] + effective_max_height = min( + max_chunk_height + if max_chunk_height is not None + else CAIRO_MAX_DIMENSION, + CAIRO_MAX_DIMENSION, + ) + possible_heights.append(effective_max_height) + if max_memory_size is not None and chunk_width > 0: + height_from_mem = math.floor( + max_memory_size / (chunk_width * bytes_per_pixel) + ) + possible_heights.append(height_from_mem) + chunk_height = min(real_height, *possible_heights) + chunk_width = max(1, chunk_width) + chunk_height = max(1, chunk_height) + cols = math.ceil(real_width / chunk_width) + rows = math.ceil(real_height / chunk_height) + return chunk_width, cols, chunk_height, rows + + def render_chunk( + self, + pixels_per_mm_x: float, + pixels_per_mm_y: float, + max_chunk_width: int | None = None, + max_chunk_height: int | None = None, + max_memory_size: int | None = None, + ) -> Generator[tuple[cairo.ImageSurface, tuple[float, float]], None, None]: + """Renders in chunks at the workpiece's current size. + Yields nothing if size is not valid.""" + from ..image import util + + # Use the final world-space size for rendering resolution. + current_size = self.size + if not current_size or current_size[0] <= 0 or current_size[1] <= 0: + return + + width_px = current_size[0] * pixels_per_mm_x + height_px = current_size[1] * pixels_per_mm_y + + if all( + arg is None + for arg in [max_chunk_width, max_chunk_height, max_memory_size] + ): + raise ValueError( + "At least one of max_chunk_width, max_chunk_height, " + "or max_memory_size must be provided." + ) + + vips_image = self.get_vips_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 + + real_width = cast(int, vips_image.width) + real_height = cast(int, vips_image.height) + if not real_width or not real_height: + return + + chunk_width, cols, chunk_height, rows = self._calculate_chunk_layout( + real_width, + real_height, + max_chunk_width, + max_chunk_height, + max_memory_size, + ) + + overlap_x, overlap_y = 1, 0 # Default overlap values + + for row in range(rows): + for col in range(cols): + left = col * chunk_width + top = row * chunk_height + width = min(chunk_width + overlap_x, real_width - left) + height = min(chunk_height + overlap_y, real_height - top) + + if width <= 0 or height <= 0: + continue + + chunk: pyvips.Image = vips_image.crop(left, top, width, height) + + normalized_chunk = util.normalize_to_rgba(chunk) + if not normalized_chunk: + logger.warning( + f"Could not normalize chunk at ({left},{top})" + ) + continue + + surface = util.vips_rgba_to_cairo_surface(normalized_chunk) + yield surface, (left, top) + + def get_geometry_world_bbox( + self, + ) -> Rect | None: + """ + Calculates the bounding box of the workpiece's geometry in world + coordinates. + + This is achieved by creating a temporary copy of the geometry, + transforming it by the workpiece's world matrix, and then calculating + the bounding box of the transformed shape. + + Returns: + A tuple (min_x, min_y, max_x, max_y) representing the bounding + box, or None if the workpiece has no vector geometry. + """ + boundaries = self.boundaries + if boundaries is None or boundaries.is_empty(): + return None + + # Create a copy to avoid modifying the original normalized vectors + world_geometry = boundaries.copy() + + # Apply the full world transformation + world_matrix = self.get_world_transform() + world_geometry.transform(world_matrix) + + # Return the bounding box of the transformed geometry + return world_geometry.rect() + + def get_world_geometry(self) -> Geometry | None: + """ + Returns the final, world-space geometry of the workpiece in + millimeters. + This is the definitive geometry for pipeline processing, composing all + transformations (normalization, local, and parent) before applying + them to the pristine source geometry. + """ + # --- Path for direct vector imports (SVG, DXF, etc.) --- + if ( + self.source_segment + and self.source_segment.pristine_geometry + and self.source_segment.normalization_matrix is not None + ): + pristine_geo = self.source_segment.pristine_geometry.copy() + norm_matrix = self.source_segment.normalization_matrix + world_transform = self.get_world_transform() + + # The key insight: compose all matrices BEFORE transforming + # geometry + final_transform = world_transform @ norm_matrix + pristine_geo.transform(final_transform) + return pristine_geo + + # --- Path for generated geometry (e.g., sketches) --- + # This path uses the `boundaries` property which returns a + # normalized 1x1 geometry. + boundaries = self.boundaries + if boundaries and not boundaries.is_empty(): + world_geo = boundaries.copy() + world_transform = self.get_world_transform() + world_geo.transform(world_transform) + return world_geo + + return None + + def get_tab_direction(self, tab: Tab) -> tuple[float, float] | None: + """ + Calculates the "outside" direction vector for a given tab in world + coordinates. + + The direction is a normalized 2D vector representing the outward + normal of the geometry at the tab's location, transformed by the + workpiece's rotation and scaling. + + Args: + tab: The Tab object for which to find the direction. + + Returns: + A tuple (dx, dy) representing the direction vector, or None if + the workpiece has no vector data or the path is open. + """ + boundaries = self.boundaries + if boundaries is None: + return None + + # 1. Get the normal vector in the geometry's local space. + local_normal = boundaries.get_outward_normal_at( + tab.segment_index, tab.pos + ) + if local_normal is None: + return None + + # For non-uniform scaling, the normal must be transformed by the + # inverse transpose of the world matrix to remain perpendicular. + world_matrix_3x3 = self.get_world_transform().to_numpy() + try: + # Get the top-left 2x2 part for the normal transformation + m_2x2 = world_matrix_3x3[:2, :2] + m_inv_T = np.linalg.inv(m_2x2).T + transformed_vector = m_inv_T @ np.array(local_normal) + except np.linalg.LinAlgError: + # Fallback for non-invertible matrices (e.g., zero scale) + return self.get_world_transform().transform_vector(local_normal) + + tx, ty = transformed_vector + norm = math.sqrt(tx**2 + ty**2) + if norm < 1e-9: + return (1.0, 0.0) # Fallback + + return (tx / norm, ty / norm) + + def dump(self, indent=0): + source_file = self.source_file + renderer = self._active_renderer + renderer_name = renderer.__class__.__name__ if renderer else "None" + print(" " * indent, source_file, renderer_name) + + @property + def pos_machine(self) -> Point | None: + """ + Gets the workpiece's anchor position in the machine's native + coordinate system. + """ + if not self.pos or not self.size: + return None + + context = get_context() + if not context.config or not context.machine: + return None + + machine = context.machine + model_x, model_y = self.pos + width, height = self.size + mach_w, mach_h = machine.axis_extents + + # Calculate Machine X + machine_x = ( + (mach_w - model_x - width) if machine.x_axis_right else model_x + ) + + # Calculate Machine Y + machine_y = ( + (mach_h - model_y - height) if machine.y_axis_down else model_y + ) + + return machine_x, machine_y + + @pos_machine.setter + def pos_machine(self, pos: Point): + """ + Sets the workpiece's position from the machine's native + coordinate system. + """ + if not pos or not self.size: + return + + context = get_context() + if not context.config or not context.machine: + return + + machine = context.machine + machine_x, machine_y = pos + width, height = self.size + mach_w, mach_h = machine.axis_extents + + # Handle X Axis + if machine.x_axis_right: + model_x = mach_w - machine_x - width + else: + model_x = machine_x + + # Handle Y Axis + if machine.y_axis_down: + model_y = mach_h - machine_y - height + else: + model_y = machine_y + + self.pos = (model_x, model_y) + + def apply_split(self, fragments: list[Geometry]) -> list[WorkPiece]: + """ + Creates new WorkPiece instances from a list of normalized geometry + fragments. Each fragment represents a subset of this workpiece's + current geometry. + + The new workpieces inherit the source segment but override their + geometry with the specific fragment. + + Args: + fragments: A list of Geometry objects. Each must be a subset of + self.boundaries, defined in the same 0-1 Y-up + normalized coordinate space. + + Returns: + A list of new WorkPiece instances. + """ + if not fragments or len(fragments) <= 1: + return [] + + new_workpieces = [] + original_matrix = self.matrix + source = self.source + + # Get current physical dimensions to filter noise. + phys_w, phys_h = self.size + + for frag_geo in fragments: + # 1. Calculate bounding box of the fragment in the local 0-1 space. + min_x, min_y, max_x, max_y = frag_geo.rect() + w = max(max_x - min_x, 1e-9) + h = max(max_y - min_y, 1e-9) + + # 2. Filter out noise / dust. + if (w * phys_w < 0.1) and (h * phys_h < 0.1): + continue + + # 3. Normalize the fragment geometry to its own 1x1 box. + # This becomes the new canonical shape for this piece. + normalized_frag = frag_geo.copy() + norm_matrix = Matrix.scale(1.0 / w, 1.0 / h) @ Matrix.translation( + -min_x, -min_y + ) + normalized_frag.transform(norm_matrix) + + # 4. Create a lightweight copy of the segment + # containing only metadata. + # This avoids the expensive deepcopy of the large + # pristine_geometry. + new_segment = None + if self.source_segment: + # Manually construct a new segment instead of deepcopying. + # We explicitly set pristine_geometry to None because the new + # workpiece will use _edited_boundaries for its shape. + mtx = self.source_segment.normalization_matrix + new_segment = SourceAssetSegment( + source_asset_uid=self.source_segment.source_asset_uid, + vectorization_spec=self.source_segment.vectorization_spec, + layer_id=self.source_segment.layer_id, + pristine_geometry=None, # Prevents massive array copy + normalization_matrix=mtx, + crop_window_px=self.source_segment.crop_window_px, + ) + + new_wp = WorkPiece(self.name, new_segment) + new_wp.tabs_enabled = self.tabs_enabled + + # 5. Set the edited_boundaries override. This gives the workpiece + # its final, correct, Y-Up geometry directly. + new_wp._edited_boundaries = normalized_frag + + # 6. Update the new segment's crop window to match the fragment. + # This ensures the renderer draws the correct background portion. + if new_segment and source and self.source_segment: + parent_crop = self.source_segment.crop_window_px + pc_x, pc_y, pc_w, pc_h = 0, 0, 0, 0 + + if parent_crop: + pc_x, pc_y, pc_w, pc_h = parent_crop + elif source.width_px and source.height_px: + pc_x, pc_y, pc_w, pc_h = ( + 0, + 0, + source.width_px, + source.height_px, + ) + + # Calculate new crop window relative to the parent's. + # The geometry is Y-Up (0 at bottom), but the pixel crop window + # is Y-Down (0 at top), so we must invert the Y calculation. + new_crop_x_px = pc_x + (min_x * pc_w) + new_crop_y_px = pc_y + ((1 - max_y) * pc_h) + new_crop_w_px = w * pc_w + new_crop_h_px = h * pc_h + + new_segment.crop_window_px = ( + new_crop_x_px, + new_crop_y_px, + new_crop_w_px, + new_crop_h_px, + ) + new_segment.cropped_width_mm = w * phys_w + new_segment.cropped_height_mm = h * phys_h + + # 7. Set natural size and calculate the matrix for the new piece. + new_wp.natural_width_mm = w * phys_w + new_wp.natural_height_mm = h * phys_h + + # The new matrix must position and scale the new 1x1 workpiece + # to match where the fragment was in the original object. + offset_matrix = original_matrix @ Matrix.translation(min_x, min_y) + final_matrix = offset_matrix @ Matrix.scale(w, h) + + new_wp.matrix = final_matrix + new_workpieces.append(new_wp) + + return new_workpieces + + def regenerate_from_geometry_provider(self) -> None: + """ + Regenerates the workpiece from its geometry provider. + + This method: + 1. Fetches the geometry provider. + 2. Gets geometry with instance-specific parameter overrides. + 3. Calculates the new natural size from the geometry. + 4. Updates the instance's `natural_width/height_mm`. + 5. It resizes the on-canvas item. + 6. Invalidates caches and signals the UI to redraw. + """ + if not self.geometry_provider_uid: + logger.warning( + f"WP {self.uid[:8]}: No geometry_provider_uid to " + f"regenerate from" + ) + return + + provider = self.get_geometry_provider() + if not provider: + logger.warning( + f"WP {self.uid[:8]}: Could not find geometry provider " + f"{self.geometry_provider_uid}" + ) + return + + logger.debug( + f"WP {self.uid[:8]}: Regenerating from geometry provider " + f"{self.geometry_provider_uid[:8]}" + ) + + # Get geometry with current parameter overrides. + variable_overrides = self._geometry_provider_params or {} + logger.debug( + f"WP {self.uid[:8]}: Getting geometry with params: " + f"{variable_overrides}" + ) + self._resolved_text_cache = {} + geometry, _ = provider.get_geometry( + params=variable_overrides, + resolved_text_cache=self._resolved_text_cache, + ) + + if geometry.is_empty(): + logger.warning( + f"WP {self.uid[:8]}: Geometry is empty. " + "Natural size not updated." + ) + else: + # Calculate bounding box in mm + min_x, min_y, max_x, max_y = geometry.rect() + width = max(max_x - min_x, 1e-9) # Prevent zero size + height = max(max_y - min_y, 1e-9) # Prevent zero size + + self.natural_width_mm = width + self.natural_height_mm = height + + logger.debug( + f"WP {self.uid[:8]}: New natural size: " + f"{width:.2f}x{height:.2f}mm" + ) + # Update the workpiece's actual size to match its new natural size. + self.set_size(width, height) + + # Invalidate the geometry cache to force regeneration on next render + self.clear_render_cache() + + # Send updated signal to trigger UI updates + self.updated.send(self) diff --git a/rayforge/debug.py b/rayforge/debug.py new file mode 100644 index 000000000..145c196dd --- /dev/null +++ b/rayforge/debug.py @@ -0,0 +1,146 @@ +import json +import logging +import shutil +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional + +import yaml + +from . import const + +if TYPE_CHECKING: + from .doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class DebugDumpManager: + """ + Orchestrates the creation of comprehensive debug dump files using the + new logging system. + """ + + def create_dump_archive( + self, editor: Optional["DocEditor"] = None + ) -> Path | None: + """ + Gathers all debug information, writes it to a temporary directory, + and creates a ZIP archive. + + If editor is given, the current project is serialized and included + in the archive (regardless of whether it has been saved to disk). + """ + from . import __version__ + from .config import LOG_DIR + from .context import get_context + from .ui_gtk.about import get_dependency_info + + logger.info("Creating debug dump archive...") + try: + context = get_context() + config = context.config + machine_mgr = context.machine_mgr + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + + # 1. Copy the latest session log file + session_logs = sorted( + LOG_DIR.glob("session-*.log"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if session_logs: + latest_log = session_logs[0] + shutil.copy(latest_log, tmp_path / latest_log.name) + else: + logger.warning( + "No session log file found to include in dump." + ) + + # 2. Write system info to system_info.txt + dep_info = get_dependency_info() + with open(tmp_path / "system_info.txt", "w") as f: + f.write( + f"## {const.APP_NAME} {__version__ or 'Unknown'}\n\n" + ) + for category, deps in dep_info.items(): + f.write(f"### {category}\n") + f.writelines(f"{name}: {ver}\n" for name, ver in deps) + f.write("\n") + + # 3. Write configs to YAML files + if config and config.machine: + with open(tmp_path / "active_machine.yaml", "w") as f: + yaml.safe_dump(config.machine.to_dict(), f) + with open(tmp_path / "app_config.yaml", "w") as f: + yaml.safe_dump(config.to_dict(), f) + + all_machines_dict: dict[str, dict[str, Any]] = { + machine_id: machine.to_dict() + for machine_id, machine in machine_mgr.machines.items() + } + with open(tmp_path / "all_machines.yaml", "w") as f: + yaml.safe_dump(all_machines_dict, f) + + # 4. Write custom dialects + custom_dialects = [ + d.to_dict() + for d in context.dialect_mgr.get_all() + if d.is_custom + ] + if custom_dialects: + with open(tmp_path / "custom_dialects.yaml", "w") as f: + yaml.safe_dump(custom_dialects, f) + + # 5. Copy addons.yaml if it exists + addon_config_file = context.addon_config.config_file + if addon_config_file.exists(): + shutil.copy(addon_config_file, tmp_path / "addons.yaml") + + # 6. Serialize and include project if requested + if editor is not None: + doc_dict = editor.doc.to_dict() + json_bytes = json.dumps(doc_dict, indent=2).encode("utf-8") + project_file = tmp_path / "project.ryp" + with zipfile.ZipFile( + project_file, + "w", + compression=zipfile.ZIP_DEFLATED, + ) as zf: + zf.writestr("project.json", json_bytes) + + # 7. Create ZIP archive + timestamp_str = datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%d_%H-%M-%S" + ) + archive_name = f"rayforge_debug_{timestamp_str}" + # Use a system-wide temp dir for the final archive to ensure + # it survives the 'with' block of the temporary directory. + final_archive_base = Path(tempfile.gettempdir()) / archive_name + + shutil.make_archive( + str(final_archive_base), "zip", root_dir=tmpdir + ) + archive_path = final_archive_base.with_suffix(".zip") + logger.info(f"Debug dump archive created at {archive_path}") + return archive_path + + except Exception: + logger.exception("Failed to create debug dump archive") + return None + + @staticmethod + def save_archive_to(archive_path: Path, destination: Path): + """ + Moves a previously created dump archive to the given destination. + Cleans up the temporary archive regardless of success. + """ + try: + shutil.move(str(archive_path), str(destination)) + finally: + if archive_path.exists(): + archive_path.unlink() diff --git a/tests/opstransformer/arcwelder/__init__.py b/rayforge/doceditor/__init__.py similarity index 100% rename from tests/opstransformer/arcwelder/__init__.py rename to rayforge/doceditor/__init__.py diff --git a/rayforge/doceditor/array/__init__.py b/rayforge/doceditor/array/__init__.py new file mode 100644 index 000000000..f16e7d006 --- /dev/null +++ b/rayforge/doceditor/array/__init__.py @@ -0,0 +1,45 @@ +""" +Array / Pattern tool strategies and parameters. + +This package is purely geometric: it computes world-space transformation +deltas for array arrangements. Document mutation lives in +:class:`rayforge.doceditor.array_cmd.ArrayCmd`. +""" + +from raygeo.geo.types import Rect + +from .base import ArrayStrategy +from .circular import CircularArrayStrategy +from .grid import GridArrayStrategy +from .params import ( + ArrayMode, + ArrayParams, + CircularArrayParams, + GridArrayParams, + PointRotationParams, + SpacingMode, +) +from .point_rotation import PointRotationStrategy + +__all__ = [ + "ArrayMode", + "ArrayParams", + "ArrayStrategy", + "CircularArrayParams", + "CircularArrayStrategy", + "GridArrayParams", + "GridArrayStrategy", + "PointRotationParams", + "PointRotationStrategy", + "SpacingMode", + "make_array_strategy", +] + + +def make_array_strategy(unit_bbox: Rect, params: ArrayParams) -> ArrayStrategy: + """Factory that builds the strategy for an :class:`ArrayParams`.""" + if params.mode == ArrayMode.POINT_ROTATION: + return PointRotationStrategy(unit_bbox, params.point_rotation) + if params.mode == ArrayMode.CIRCULAR: + return CircularArrayStrategy(unit_bbox, params.circular) + return GridArrayStrategy(unit_bbox, params.grid) diff --git a/rayforge/doceditor/array/base.py b/rayforge/doceditor/array/base.py new file mode 100644 index 000000000..ccdef68db --- /dev/null +++ b/rayforge/doceditor/array/base.py @@ -0,0 +1,104 @@ +""" +Array strategies: pure-geometry calculators that turn array parameters +into a list of world-space transformation matrices (one per array +instance). + +A strategy performs no document mutation. The first instance in the +returned list is always the identity matrix, representing the original +selection that stays in place. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from raygeo.geo import Matrix +from raygeo.geo.types import Rect + + +class ArrayStrategy(ABC): + """Computes world-space delta matrices for an array arrangement. + + Each delta, when applied to every item of the source selection, + places a copy at one array instance. Instance 0 is the identity + (the original selection). + + The *anchor* is a relative point ``(u, v)`` within the selection's + collective bounding box (the unit square ``[0, 1] × [0, 1]``). + The default ``(0.5, 0.5)`` is the centre; subclasses may override + ``_default_anchor`` for strategy-specific behaviour (e.g. the grid + uses the origin corner ``(0, 0)``). + When the selection is moved, the bbox changes and the anchor + follows automatically. + """ + + def __init__( + self, + unit_bbox: Rect, + anchor: tuple[float, float] | None = None, + ): + """ + Args: + unit_bbox: The collective world-space bounding box + ``(min_x, min_y, max_x, max_y)`` of the source + selection, treated as one rigid unit. + anchor: Optional custom anchor as a *local* ``(u, v)`` + pair inside the bbox's unit square. ``(0, 0)`` is the + bbox origin corner, ``(1, 1)`` the far corner. When + ``None`` the strategy-specific default is used. + """ + self.unit_bbox: Rect = unit_bbox + self._custom_anchor: tuple[float, float] | None = anchor + + @property + def anchor(self) -> tuple[float, float]: + """The effective LOCAL anchor ``(u, v)`` for this strategy.""" + if self._custom_anchor is not None: + return self._custom_anchor + return self._default_anchor + + @property + def anchor_world(self) -> tuple[float, float]: + """The anchor evaluated in world coordinates for the current + ``unit_bbox``.""" + u, v = self.anchor + min_x, min_y, max_x, max_y = self.unit_bbox + w = max_x - min_x + h = max_y - min_y + return (min_x + u * w, min_y + v * h) + + @property + def _default_anchor(self) -> tuple[float, float]: + """Default anchor — the bbox centre ``(0.5, 0.5)``.""" + return (0.5, 0.5) + + @property + def _unit_center(self) -> tuple[float, float]: + min_x, min_y, max_x, max_y = self.unit_bbox + return ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + + @property + def _unit_size(self) -> tuple[float, float]: + min_x, min_y, max_x, max_y = self.unit_bbox + return (max_x - min_x, max_y - min_y) + + @abstractmethod + def calculate_placements(self) -> list[Matrix]: + """Return one world-space delta Matrix per array instance.""" + raise NotImplementedError + + @staticmethod + def distribute_angles(count: int, total_angle_deg: float) -> list[float]: + """Distributes ``count`` angular offsets over ``total_angle_deg``. + + The step is always ``total / count`` so that every offset is + strictly less than ``total_angle_deg`` and no two copies ever + coincide with the original position (offset 0). The final copy + sits at ``(count - 1) * total / count``. + """ + if count <= 1: + return [0.0] + if count == 0: + return [] + step = total_angle_deg / count + return [i * step for i in range(count)] diff --git a/rayforge/doceditor/array/circular.py b/rayforge/doceditor/array/circular.py new file mode 100644 index 000000000..02a597cb2 --- /dev/null +++ b/rayforge/doceditor/array/circular.py @@ -0,0 +1,65 @@ +"""Circular array strategy.""" + +from __future__ import annotations + +import math + +from raygeo.geo import Matrix + +from .base import ArrayStrategy +from .params import CircularArrayParams + + +class CircularArrayStrategy(ArrayStrategy): + """Places copies along a circular arc around a center. + + Copies orbit ``center_mm`` at ``radius_mm``. With ``rotate_copies`` + each copy is also spun by its angular offset around the selection's + own center. Instance 0 is the identity (the original stays in + place). + """ + + def __init__( + self, + unit_bbox, + params: CircularArrayParams, + ): + super().__init__(unit_bbox) + self.params = params + + def calculate_placements(self) -> list[Matrix]: + p = self.params + count = max(1, int(p.count)) + + cx, cy = p.center_mm + ux, uy = self.anchor_world # workpiece position on the circle + radius = p.radius_mm + + # Base angle of the original selection relative to the center. + base_angle = math.atan2(uy - cy, ux - cx) + offsets = self.distribute_angles(count, p.total_angle_deg) + + placements: list[Matrix] = [] + for i, ang_offset in enumerate(offsets): + # Instance 0 is always the identity: the original selection + # stays in place as the anchor of the array. + if i == 0: + placements.append(Matrix.identity()) + continue + + angle = base_angle + math.radians(ang_offset) + new_center_x = cx + radius * math.cos(angle) + new_center_y = cy + radius * math.sin(angle) + + dx = new_center_x - ux + dy = new_center_y - uy + delta = Matrix.translation(dx, dy) + + if p.rotate_copies and abs(ang_offset) > 1e-9: + # Spin the copy in place around the selection's own + # center by the angular offset. + spin = Matrix.rotation(ang_offset, center=(ux, uy)) + delta = delta @ spin + + placements.append(delta) + return placements diff --git a/rayforge/doceditor/array/grid.py b/rayforge/doceditor/array/grid.py new file mode 100644 index 000000000..88f89b9f8 --- /dev/null +++ b/rayforge/doceditor/array/grid.py @@ -0,0 +1,60 @@ +"""Grid (rows x columns) array strategy.""" + +from __future__ import annotations + +from raygeo.geo import Matrix + +from .base import ArrayStrategy +from .params import GridArrayParams, SpacingMode + + +class GridArrayStrategy(ArrayStrategy): + """Arranges copies in a regular 2D grid. + + The anchor defaults to the bounding-box origin ``(min_x, min_y)`` + which preserves the current behaviour: cell ``(0, 0)`` is identity + (the original selection), and subsequent cells extend in the grid + pattern relative to that corner. + """ + + def __init__( + self, + unit_bbox, + params: GridArrayParams, + anchor=None, + ): + super().__init__(unit_bbox, anchor) + self.params = params + + @property + def _default_anchor(self) -> tuple[float, float]: + """Grid's default anchor is the bbox origin corner.""" + return (0.0, 0.0) + + def _resolve_pitch(self) -> tuple[float, float]: + """Returns the (x, y) center-to-center pitch.""" + p = self.params + if p.spacing_mode == SpacingMode.GAP: + unit_w, unit_h = self._unit_size + pitch_x = unit_w + p.col_spacing_mm + pitch_y = unit_h + p.row_spacing_mm + else: + pitch_x = p.col_spacing_mm + pitch_y = p.row_spacing_mm + return pitch_x, pitch_y + + def calculate_placements(self) -> list[Matrix]: + p = self.params + rows = max(1, int(p.rows)) + cols = max(1, int(p.cols)) + pitch_x, pitch_y = self._resolve_pitch() + ax, ay = self.anchor_world + ox, oy = self.unit_bbox[0], self.unit_bbox[1] + + placements: list[Matrix] = [] + for row in range(rows): + for col in range(cols): + dx = (ax - ox) + col * pitch_x + dy = (ay - oy) - row * pitch_y + placements.append(Matrix.translation(dx, dy)) + return placements diff --git a/rayforge/doceditor/array/params.py b/rayforge/doceditor/array/params.py new file mode 100644 index 000000000..8b7d639e8 --- /dev/null +++ b/rayforge/doceditor/array/params.py @@ -0,0 +1,175 @@ +""" +Parameter and data-class definitions for the Array / Pattern tool. + +An "array" duplicates a multi-layer selection of items into a regular +pattern. Three arrangements are supported: + +* GRID - copies laid out in a rows x columns grid. +* POINT_ROTATION - copies rotated in place around the selection's + own center. +* CIRCULAR - copies placed along a circular arc around a center. + +Each duplicate preserves the layer membership of its source item. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class ArrayMode(Enum): + """The geometric arrangement of the array.""" + + GRID = "grid" + POINT_ROTATION = "point_rotation" + CIRCULAR = "circular" + + +class SpacingMode(Enum): + """How the distance between adjacent copies is interpreted. + + DISPLACEMENT: center-to-center distance (independent of item size). + GAP: edge-to-edge distance; the pitch is computed from the + selection's collective bounding box plus the gap. + """ + + DISPLACEMENT = "displacement" + GAP = "gap" + + +@dataclass +class GridArrayParams: + """Parameters for a rows x columns grid array.""" + + rows: int = 2 + cols: int = 2 + spacing_mode: SpacingMode = SpacingMode.GAP + # Horizontal distance between adjacent columns. + col_spacing_mm: float = 1.0 + # Vertical distance between adjacent rows. + row_spacing_mm: float = 1.0 + + def to_dict(self) -> dict[str, Any]: + return { + "rows": self.rows, + "cols": self.cols, + "spacing_mode": self.spacing_mode.value, + "col_spacing_mm": self.col_spacing_mm, + "row_spacing_mm": self.row_spacing_mm, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> GridArrayParams: + try: + spacing = SpacingMode(data.get("spacing_mode", "gap")) + except ValueError: + spacing = SpacingMode.GAP + return cls( + rows=int(data.get("rows", 2)), + cols=int(data.get("cols", 2)), + spacing_mode=spacing, + col_spacing_mm=float(data.get("col_spacing_mm", 1.0)), + row_spacing_mm=float(data.get("row_spacing_mm", 1.0)), + ) + + +@dataclass +class PointRotationParams: + """Parameters for a point-rotation array. + + Copies are rotated in place around the selection's own center and + therefore share the same position; only their orientation differs. + """ + + count: int = 6 + total_angle_deg: float = 360.0 + + def to_dict(self) -> dict[str, Any]: + return { + "count": self.count, + "total_angle_deg": self.total_angle_deg, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PointRotationParams: + return cls( + count=int(data.get("count", 6)), + total_angle_deg=float(data.get("total_angle_deg", 360.0)), + ) + + +@dataclass +class CircularArrayParams: + """Parameters for a circular array. + + Copies are placed along a circular arc around ``center_mm`` at + ``radius_mm``. The default radius is auto-computed from the center + and the selection's center when the dialog opens; the guide circle + is always drawn at this radius. With ``rotate_copies`` each copy is + also spun by its angular offset around the selection's own center. + """ + + count: int = 6 + total_angle_deg: float = 360.0 + center_mm: tuple[float, float] = (0.0, 0.0) + radius_mm: float = 10.0 + rotate_copies: bool = True + + def to_dict(self) -> dict[str, Any]: + return { + "count": self.count, + "total_angle_deg": self.total_angle_deg, + "center_mm": list(self.center_mm), + "radius_mm": self.radius_mm, + "rotate_copies": self.rotate_copies, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CircularArrayParams: + center = data.get("center_mm", [0.0, 0.0]) + return cls( + count=int(data.get("count", 6)), + total_angle_deg=float(data.get("total_angle_deg", 360.0)), + center_mm=(float(center[0]), float(center[1])), + radius_mm=float(data.get("radius_mm", 10.0)), + rotate_copies=bool(data.get("rotate_copies", True)), + ) + + +@dataclass +class ArrayParams: + """Top-level parameters for an array operation.""" + + mode: ArrayMode = ArrayMode.GRID + grid: GridArrayParams = field(default_factory=GridArrayParams) + point_rotation: PointRotationParams = field( + default_factory=PointRotationParams + ) + circular: CircularArrayParams = field(default_factory=CircularArrayParams) + + def to_dict(self) -> dict[str, Any]: + return { + "mode": self.mode.value, + "grid": self.grid.to_dict(), + "point_rotation": self.point_rotation.to_dict(), + "circular": self.circular.to_dict(), + } + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> ArrayParams: + if not data: + return cls() + try: + mode = ArrayMode(data.get("mode", "grid")) + except ValueError: + mode = ArrayMode.GRID + return cls( + mode=mode, + grid=GridArrayParams.from_dict(data.get("grid", {})), + point_rotation=PointRotationParams.from_dict( + data.get("point_rotation", {}) + ), + circular=CircularArrayParams.from_dict(data.get("circular", {})), + ) diff --git a/rayforge/doceditor/array/point_rotation.py b/rayforge/doceditor/array/point_rotation.py new file mode 100644 index 000000000..b6afdca09 --- /dev/null +++ b/rayforge/doceditor/array/point_rotation.py @@ -0,0 +1,40 @@ +"""Point-rotation array strategy.""" + +from __future__ import annotations + +from raygeo.geo import Matrix + +from .base import ArrayStrategy +from .params import PointRotationParams + + +class PointRotationStrategy(ArrayStrategy): + """Rotates copies in place around the selection's own center. + + Each copy is the selection rotated by its angular offset around the + unit center, so all copies share the same position and differ only + in orientation. Instance 0 is the identity (the original). + """ + + def __init__( + self, + unit_bbox, + params: PointRotationParams, + ): + super().__init__(unit_bbox) + self.params = params + + def calculate_placements(self) -> list[Matrix]: + p = self.params + count = max(1, int(p.count)) + ax, ay = self.anchor_world + offsets = self.distribute_angles(count, p.total_angle_deg) + + placements: list[Matrix] = [] + for i, ang_offset in enumerate(offsets): + # Instance 0 is always the identity: the original stays put. + if i == 0: + placements.append(Matrix.identity()) + continue + placements.append(Matrix.rotation(ang_offset, center=(ax, ay))) + return placements diff --git a/rayforge/doceditor/array_cmd.py b/rayforge/doceditor/array_cmd.py new file mode 100644 index 000000000..5c3cbeb18 --- /dev/null +++ b/rayforge/doceditor/array_cmd.py @@ -0,0 +1,185 @@ +""" +ArrayCmd: the document command handler for the Array / Pattern tool. + +It duplicates a (possibly multi-layer) selection of items into a +regular pattern. Each duplicate is placed on the same layer as its +source item, preserving the document's layer structure. +""" + +from __future__ import annotations + +import logging +import math +from collections.abc import Sequence +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Matrix +from raygeo.geo.types import Rect + +from ..core.group import Group +from ..core.item import DocItem +from ..core.undo import ListItemCommand +from ..core.workpiece import WorkPiece +from .array import ArrayParams, make_array_strategy +from .layout.base import LayoutStrategy +from .transform_cmd import TransformCmd + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class ArrayCmd: + """Handles undoable creation of item arrays.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + # ------------------------------------------------------------------ + # Pure helpers (no mutation) - shared with the live preview. + # ------------------------------------------------------------------ + @staticmethod + def _get_top_level_items( + items: Sequence[DocItem], + ) -> list[DocItem]: + """Returns only the top-level items from a selection. + + If an item and one of its ancestors are both selected, only the + ancestor is arrayed, so descendants are not duplicated twice. + """ + if not items: + return [] + item_set = set(items) + top_level: list[DocItem] = [] + for item in items: + ancestor = item.parent + while ancestor: + if ancestor in item_set: + break + ancestor = ancestor.parent + else: + top_level.append(item) + return top_level + + @staticmethod + def _compute_unit_bbox(items: Sequence[DocItem]) -> Rect | None: + """Collective world-space bbox of the selection as one unit.""" + min_x = min_y = math.inf + max_x = max_y = -math.inf + for item in items: + bbox = LayoutStrategy._get_item_world_bbox(item) + if not bbox: + continue + min_x = min(min_x, bbox[0]) + min_y = min(min_y, bbox[1]) + max_x = max(max_x, bbox[2]) + max_y = max(max_y, bbox[3]) + if math.isinf(min_x): + return None + return (min_x, min_y, max_x, max_y) + + def compute_plan( + self, + source_items: Sequence[DocItem], + params: ArrayParams, + ) -> list[Matrix]: + """Returns the list of world-space delta matrices for the array. + + This is pure: it neither reads nor mutates document state beyond + the source items' current transforms. Instance 0 is the identity + (the original selection). Used by the live preview. + """ + top_level = self._get_top_level_items(source_items) + unit_bbox = self._compute_unit_bbox(top_level) + if unit_bbox is None: + return [] + strategy = make_array_strategy(unit_bbox, params) + return strategy.calculate_placements() + + def get_selection_bbox( + self, source_items: Sequence[DocItem] + ) -> Rect | None: + """Returns the collective world bbox of the selection, or None. + + Convenience wrapper for the live preview. + """ + return self._compute_unit_bbox(self._get_top_level_items(source_items)) + + # ------------------------------------------------------------------ + # Commit path (undoable mutation). + # ------------------------------------------------------------------ + def create_array( + self, + source_items: Sequence[DocItem], + params: ArrayParams, + ) -> list[DocItem]: + """Duplicates the selection into the array in one transaction. + + The original selection is kept in place as the first (identity) + instance; one copy per remaining instance is created, each on + the same layer as its source item. + + Returns the newly created top-level items. + """ + top_level = self._get_top_level_items(source_items) + if not top_level: + return [] + + deltas = self.compute_plan(top_level, params) + if len(deltas) <= 1: + return [] + + history = self._editor.history_manager + created: list[DocItem] = [] + + with history.transaction(_("Create Array")) as t: + for delta in deltas: + if _is_identity(delta): + # The original selection occupies this cell. + continue + for item in top_level: + layer = _get_item_layer(item) + if layer is None: + logger.warning( + "Item '%s' has no layer; skipping.", item.name + ) + continue + copy = self._make_copy_at_delta(item, delta) + cmd = ListItemCommand( + owner_obj=layer, + item=copy, + undo_command="remove_child", + redo_command="add_child", + name=_("Create array copy"), + ) + t.execute(cmd) + created.append(copy) + return created + + @staticmethod + def _make_copy_at_delta(item: DocItem, delta_world: Matrix) -> DocItem: + """Duplicates ``item`` and applies a world-space delta to the copy. + + The copy keeps the source item's parent layer, so the world + delta is converted back into the copy's local matrix relative + to that unchanged parent. + """ + copy = item.duplicate() + old_world = item.get_world_transform() + new_world = delta_world @ old_world + copy.matrix = TransformCmd._world_to_local_matrix(item, new_world) + return copy + + +def _is_identity(matrix: Matrix) -> bool: + """True if the matrix has no practical effect.""" + return matrix.is_identity() + + +def _get_item_layer(item: DocItem): + """Returns the layer owning ``item`` (WorkPiece or Group), or None.""" + if isinstance(item, (WorkPiece, Group)): + return item.layer + return None diff --git a/rayforge/doceditor/asset_cmd.py b/rayforge/doceditor/asset_cmd.py new file mode 100644 index 000000000..0b5bce844 --- /dev/null +++ b/rayforge/doceditor/asset_cmd.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from ..core.asset import IAsset +from ..core.asset_registry import asset_type_registry +from ..core.stock_asset import StockAsset +from ..core.undo import ChangePropertyCommand, Command, ListItemCommand + +if TYPE_CHECKING: + from ..core.doc import Doc + from ..core.geometry_provider import IGeometryProvider + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class UpdateAssetCommand(Command): + """ + A command that updates an asset in the document's registry. + + For assets that are also IGeometryProvider, it recalculates geometry + and resizes all WorkPiece instances that depend on this asset. + """ + + def __init__( + self, + doc: Doc, + asset_uid: str, + new_data: dict[str, Any], + name: str = _("Update Asset"), + ): + super().__init__(name) + self.doc = doc + self.asset_uid = asset_uid + + # --- Store old state for undo --- + old_asset = doc.get_asset_by_uid(asset_uid) + if not old_asset: + raise ValueError(f"Asset with UID {asset_uid} not found.") + self.old_data = old_asset.to_dict() + self.new_data = new_data + self.asset_type_name = old_asset.asset_type_name + + # Store old matrices of all affected workpieces for a perfect undo + # (only relevant for geometry providers) + self.old_matrices = { + wp.uid: wp.matrix.copy() + for wp in doc.all_workpieces + if wp.geometry_provider_uid == asset_uid + } + + def _apply_state(self, data: dict[str, Any]): + """Helper to apply an asset dictionary to the document state.""" + # 1. Deserialize and update the asset in the document registry + asset_class = asset_type_registry.get(self.asset_type_name) + if not asset_class: + raise TypeError(f"Unknown asset type '{self.asset_type_name}'") + asset_instance = asset_class.from_dict(data) + self.doc.assets[self.asset_uid] = asset_instance + + # 2. If this is a geometry provider, update dependent workpieces + from ..core.geometry_provider import IGeometryProvider + + if isinstance(asset_instance, IGeometryProvider): + self._update_dependent_workpieces(asset_instance) + + # 3. Send a general doc update signal for pipeline, etc. + self.doc.updated.send(self.doc) + + def _update_dependent_workpieces( + self, provider: IGeometryProvider + ) -> None: + """Update all workpieces that depend on this geometry provider.""" + for workpiece in self.doc.all_workpieces: + if workpiece.geometry_provider_uid != self.asset_uid: + continue + + params = workpiece.geometry_provider_params or {} + geometry, _ = provider.get_geometry(params=params) + + if geometry.is_empty(): + new_width = 0.0 + new_height = 0.0 + else: + min_x, min_y, max_x, max_y = geometry.rect() + new_width = max(max_x - min_x, 1e-9) + new_height = max(max_y - min_y, 1e-9) + + # Update the workpiece's own dimension attributes + workpiece.natural_width_mm = new_width + workpiece.natural_height_mm = new_height + + # This resizes the workpiece's matrix while preserving its + # center + workpiece.set_size(new_width, new_height) + + # This clears _boundaries_cache and _render_cache + workpiece.clear_render_cache() + + # Signal for UI to redraw this specific workpiece + workpiece.updated.send(workpiece) + + def execute(self): + logger.debug( + f"Executing UpdateAssetCommand for asset {self.asset_uid}" + ) + self._apply_state(self.new_data) + + def undo(self): + logger.debug(f"Undoing UpdateAssetCommand for asset {self.asset_uid}") + # Re-apply the old asset data, which will call set_size + self._apply_state(self.old_data) + + # `set_size` preserves center, which might not be what we want for + # undo. + # To guarantee a perfect undo, explicitly restore original matrices. + for wp in self.doc.all_workpieces: + if wp.uid in self.old_matrices: + wp.matrix = self.old_matrices[wp.uid].copy() + + +class AssetCmd: + """Handles commands related to document assets.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + @property + def doc(self): + return self._editor.doc + + def rename_asset(self, asset: IAsset, new_name: str): + """ + Renames an asset and any dependent items in a single transaction. + For example, renaming a StockAsset also renames its StockItems. + """ + if not new_name.strip() or new_name == asset.name: + return + + with self.doc.history_manager.transaction(_("Rename Asset")) as t: + # 1. Rename the asset definition itself. The property setter will + # trigger the necessary signals. + t.execute( + ChangePropertyCommand( + target=asset, + property_name="name", + new_value=new_name, + ) + ) + + # 2. Find and rename dependent DocItems in an agnostic way. + for item in self.doc.get_descendants(): + if item.depends_on_asset(asset): + t.execute( + ChangePropertyCommand( + target=item, + property_name="name", + new_value=new_name, + ) + ) + + def delete_asset(self, asset_to_delete: IAsset): + """ + Deletes an asset and all document items that depend on it in a single + undoable transaction. + """ + logger.debug( + "delete_asset called: name=%s, uid=%s", + asset_to_delete.name, + asset_to_delete.uid, + ) + history = self.doc.history_manager + dependent_items = [] + + # 1. Find all DocItems that depend on this asset, agnostically. + for item in self.doc.get_descendants(): + if item.depends_on_asset(asset_to_delete): + dependent_items.append(item) + + # 2. Create a single transaction to remove everything. + tx_name = _("Delete Asset '{name}'").format(name=asset_to_delete.name) + with history.transaction(tx_name) as t: + # First, remove the dependent DocItems from the document tree. + for item in dependent_items: + if not item.parent: + continue + t.execute( + ListItemCommand( + owner_obj=item.parent, + item=item, + undo_command="add_child", + redo_command="remove_child", + name=_("Remove dependent item"), + ) + ) + + # Finally, remove the asset definition itself. + t.execute( + ListItemCommand( + owner_obj=self.doc, + item=asset_to_delete, + undo_command="add_asset", + redo_command="remove_asset", + name=_("Remove asset definition"), + ) + ) + + def toggle_asset_visibility(self, asset: IAsset): + """ + Toggles the visibility of an asset and all its dependent items + with an undoable command. + """ + new_hidden = not asset.hidden + + with self.doc.history_manager.transaction( + _("Toggle Asset Visibility") + ) as t: + # Toggle the asset itself + t.execute( + ChangePropertyCommand( + target=asset, + property_name="hidden", + new_value=new_hidden, + setter_method_name="set_hidden", + ) + ) + # For StockAsset, also update all StockItem instances + if isinstance(asset, StockAsset): + for item in self.doc.stock_items: + if item.stock_asset_uid == asset.uid: + t.execute( + ChangePropertyCommand( + target=item, + property_name="visible", + new_value=not new_hidden, + setter_method_name="set_visible", + ) + ) diff --git a/rayforge/doceditor/command_registry.py b/rayforge/doceditor/command_registry.py new file mode 100644 index 000000000..5eef94ece --- /dev/null +++ b/rayforge/doceditor/command_registry.py @@ -0,0 +1,93 @@ +class CommandRegistry: + """ + Registry for editor command classes. + + Allows addons to register command handlers that extend the + DocEditor functionality. Commands are registered by name and + instantiated with the editor instance. + """ + + def __init__(self): + self._command_classes: dict[str, type] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, + command_name: str, + command_class: type, + addon_name: str | None = None, + ) -> None: + """ + Register a command class. + + Args: + command_name: The name to use for this command. + command_class: The command class (takes DocEditor in __init__). + addon_name: Optional name of addon registering this command. + """ + self._command_classes[command_name] = command_class + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(command_name) + + def unregister(self, command_name: str) -> bool: + """ + Unregister a command class by name. + + Args: + command_name: The name of command to unregister. + + Returns: + True if command was unregistered, False if not found. + """ + if command_name in self._command_classes: + del self._command_classes[command_name] + for items in self._addon_items.values(): + items.discard(command_name) + return True + return False + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all commands registered by a specific addon. + + Args: + addon_name: The name of addon. + + Returns: + The number of commands unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for command_name in items: + if command_name in self._command_classes: + del self._command_classes[command_name] + count += 1 + return count + + def get(self, command_name: str) -> type | None: + """ + Look up a command class by name. + + Args: + command_name: The name of command. + + Returns: + The command class, or None if not found. + """ + return self._command_classes.get(command_name) + + def all_commands(self) -> dict[str, type]: + """ + Return a copy of all registered command classes. + + Returns: + Dictionary mapping command names to command classes. + """ + return self._command_classes.copy() + + +command_registry = CommandRegistry() diff --git a/rayforge/doceditor/edit_cmd.py b/rayforge/doceditor/edit_cmd.py new file mode 100644 index 000000000..42e7111bf --- /dev/null +++ b/rayforge/doceditor/edit_cmd.py @@ -0,0 +1,467 @@ +import logging +import uuid +from collections.abc import Sequence +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Optional, + cast, +) + +from raygeo.geo import Geometry, Move + +from ..core.item import DocItem +from ..core.stock import StockItem +from ..core.undo import ( + ChangePropertyCommand, + ListItemCommand, + ReorderListCommand, +) +from ..core.workflow import Workflow +from ..core.workpiece import WorkPiece + +if TYPE_CHECKING: + from ..core.asset import IAsset + from ..core.geometry_provider import IGeometryProvider + from ..core.layer import Layer + from ..core.source_asset import SourceAsset + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class EditCmd: + """Handles clipboard, duplication, and removal of document items.""" + + def __init__(self, editor: "DocEditor"): + self._editor = editor + # Module-level state for the clipboard is now instance state + self._clipboard_snapshot: list[dict] = [] + self._paste_counter = 0 + self._paste_increment_mm: tuple[float, float] = (10.0, -10.0) + + def can_paste(self) -> bool: + """Checks if there is anything on the clipboard to paste.""" + return len(self._clipboard_snapshot) > 0 + + def _get_top_level_items( + self, all_items: Sequence[DocItem] + ) -> list[DocItem]: + """From a list of items, returns only the top-level ones.""" + if not all_items: + return [] + + item_set = set(all_items) + top_level = [] + for item in all_items: + has_selected_ancestor = False + parent = item.parent + while parent: + if parent in item_set: + has_selected_ancestor = True + break + parent = parent.parent + if not has_selected_ancestor: + top_level.append(item) + return top_level + + def copy_items(self, items: list[DocItem]): + """ + Snapshots the current state of the selected items for the clipboard + and resets the paste sequence. It only copies the top-level items + from the selection to avoid redundancy. + """ + if not items: + return + + top_level_items = self._get_top_level_items(items) + + self._clipboard_snapshot = [item.to_dict() for item in top_level_items] + self._paste_counter = 1 # For a copy, the next paste should be offset. + logger.debug( + f"Copied {len(self._clipboard_snapshot)} top-level items. " + "Paste counter set to 1." + ) + + def cut_items(self, items: list[DocItem]): + """ + Copies the selected items to the clipboard and then removes them + from the document in a single undoable transaction. + """ + if not items: + return + + self.copy_items(items) + # For a cut, the next paste should be at the original location. + self._paste_counter = 0 + + self.remove_items(items, "Cut item(s)") + + def paste_items(self) -> list[DocItem]: + """ + Pastes a new set of items from the clipboard snapshot. It creates new + unique IDs for all pasted items and their children, and applies a + cumulative offset for each subsequent paste. + + Returns: + A list of the newly created top-level items. + """ + if not self.can_paste(): + return [] + + history = self._editor.history_manager + newly_pasted_items = [] + + target_layer = self._editor.doc.active_layer + + with history.transaction(_("Paste item(s)")) as t: + offset_x = self._paste_increment_mm[0] * self._paste_counter + offset_y = self._paste_increment_mm[1] * self._paste_counter + + for item_dict in self._clipboard_snapshot: + # Recreate item from dictionary using factory method + new_item = DocItem.create_from_dict(item_dict) + + # Assign new UIDs to the pasted item and all its children + # recursively + def assign_new_uids(item: DocItem): + item.uid = str(uuid.uuid4()) + for child in item.children: + assign_new_uids(child) + + assign_new_uids(new_item) + newly_pasted_items.append(new_item) + + # Apply offset to the top-level pasted item's position + original_pos = new_item.pos + new_item.pos = ( + original_pos[0] + offset_x, + original_pos[1] + offset_y, + ) + + command = ListItemCommand( + owner_obj=target_layer, + item=new_item, + undo_command="remove_child", + redo_command="add_child", + name=_("Paste item"), + ) + t.execute(command) + + # Increment counter for the *next* paste + self._paste_counter += 1 + + return newly_pasted_items + + def duplicate_items(self, items: list[DocItem]) -> list[DocItem]: + """ + Creates an exact copy of the selected items in the same location. + This operation is a single undoable transaction. + + Returns: + A list of the newly created top-level items. + """ + if not items: + return [] + + history = self._editor.history_manager + newly_duplicated_items = [] + + target_layer = self._editor.doc.active_layer + + top_level_items = self._get_top_level_items(items) + + with history.transaction(_("Duplicate item(s)")) as t: + for item in top_level_items: + new_item = item.duplicate() + newly_duplicated_items.append(new_item) + + if isinstance(new_item, StockItem): + owner = self._editor.doc + else: + owner = target_layer + + command = ListItemCommand( + owner_obj=owner, + item=new_item, + undo_command="remove_child", + redo_command="add_child", + name=_("Duplicate item"), + ) + t.execute(command) + + return newly_duplicated_items + + def add_items( + self, + items: list[DocItem], + source_assets: list["SourceAsset"] | None = None, + assets: list["IAsset"] | None = None, + name: str = "Add item(s)", + ) -> list[DocItem]: + """ + Adds a list of items and their associated source assets to the + document. + """ + if not items: + return [] + + history = self._editor.history_manager + target_layer = self._editor.doc.active_layer + + with history.transaction(_(name)) as t: + # Add source assets. This is not currently undoable in this simple + # command, but matches the import logic. + if source_assets: + for asset in source_assets: + self._editor.doc.add_asset(asset) + + # Register assets. + if assets: + for asset in assets: + self._editor.doc.add_asset(asset) + + for item in items: + command = ListItemCommand( + owner_obj=target_layer, + item=item, + undo_command="remove_child", + redo_command="add_child", + name=_("Add item"), + ) + t.execute(command) + return items + + def remove_items( + self, + items: list[DocItem], + transaction_name: str = "Remove item(s)", + ): + """Removes a list of items from the document.""" + if not items: + return + + history = self._editor.history_manager + top_level_items = self._get_top_level_items(items) + + with history.transaction(_(transaction_name)) as t: + for item in top_level_items: + if not item.parent: + logger.warning( + f"Attempted to remove item '{item.name}' which " + "has no parent." + ) + continue + + command = ListItemCommand( + owner_obj=item.parent, + item=item, + undo_command="add_child", + redo_command="remove_child", + name=_("Remove item"), + ) + t.execute(command) + + def rename_item(self, item: DocItem, new_name: str): + """Renames a document item with an undoable command.""" + new_name = new_name.strip() + if not new_name or new_name == item.name: + return + command = ChangePropertyCommand( + target=item, + property_name="name", + new_value=new_name, + name=_("Rename item"), + ) + self._editor.history_manager.execute(command) + + def clear_all_items(self): + """ + Removes all workpieces and groups from all layers in the document in a + single undoable transaction. + """ + doc = self._editor.doc + if not doc.has_workpiece(): + return + + with doc.history_manager.transaction(_("Remove all workpieces")) as t: + for layer in doc.layers: + # A layer is considered "not empty" if it has any children + # besides its mandatory workflow. + if any( + not isinstance(child, Workflow) for child in layer.children + ): + command = ReorderListCommand( + target_obj=layer, + list_property_name="children", + new_list=[layer.workflow], + setter_method_name="set_children", + name=_("Clear Layer Items"), + ) + t.execute(command) + + def reset_paste_counter(self): + """ + Resets the paste counter. This is typically called when the context + changes, such as selecting a new layer, to ensure the next paste + operation does not continue an offset chain from a previous context. + The next paste will be "in place". + """ + if self._paste_counter != 0: + logger.debug("Paste counter reset to 0 due to context change.") + self._paste_counter = 0 + + def delete_contours( + self, + workpiece: WorkPiece, + indices_to_remove: set[int], + ): + """Removes selected contours from a workpiece's boundaries. + + This sets the ``_edited_boundaries`` on the workpiece to a geometry + that excludes the contours at the given indices, in an undoable + transaction. + + Args: + workpiece: The workpiece to modify. + indices_to_remove: Set of contour indices to remove. + """ + if not indices_to_remove: + return + + boundaries = workpiece.boundaries + if boundaries is None or boundaries.is_empty(): + return + + contours = boundaries.split_into_contours() + if not contours: + return + + remaining = [ + c for i, c in enumerate(contours) if i not in indices_to_remove + ] + + new_geo = Geometry() + for contour in remaining: + new_geo.extend(contour) + + old_value = workpiece._edited_boundaries + + def _on_changed(): + workpiece.clear_render_cache() + workpiece.updated.send(workpiece) + + history = self._editor.history_manager + with history.transaction(_("Delete contour(s)")) as t: + cmd = ChangePropertyCommand( + target=workpiece, + property_name="_edited_boundaries", + new_value=new_geo, + old_value=old_value, + on_change_callback=_on_changed, + name=_("Delete contour(s)"), + ) + t.execute(cmd) + + def delete_segments( + self, + workpiece: WorkPiece, + segment_indices: set[int], + ): + """Removes selected segments from a workpiece's boundaries. + + Each segment index refers to a command in the geometry. The MOVE + commands that precede removed segments are also removed to keep + the path well-formed. + + Args: + workpiece: The workpiece to modify. + segment_indices: Set of command indices to remove. + """ + if not segment_indices: + return + + boundaries = workpiece.boundaries + if boundaries is None or boundaries.is_empty(): + return + + to_remove = set(segment_indices) + data = boundaries.data + for idx in segment_indices: + if idx > 0 and isinstance(data[idx - 1], Move): + to_remove.add(idx - 1) + + keep = set(range(len(data))) - to_remove + new_geo = boundaries.filter(keep) + + old_value = workpiece._edited_boundaries + + def _on_changed(): + workpiece.clear_render_cache() + workpiece.updated.send(workpiece) + + history = self._editor.history_manager + with history.transaction(_("Delete segment(s)")) as t: + cmd = ChangePropertyCommand( + target=workpiece, + property_name="_edited_boundaries", + new_value=new_geo, + old_value=old_value, + on_change_callback=_on_changed, + name=_("Delete segment(s)"), + ) + t.execute(cmd) + + def add_geometry_provider_instance( + self, + provider_uid: str, + position_mm: tuple[float, float], + target_layer: Optional["Layer"] = None, + ) -> WorkPiece: + """ + Creates a new WorkPiece instance from a geometry provider. + + Args: + provider_uid: The UID of the geometry provider to instantiate + position_mm: The (x, y) position in mm where to place the instance + target_layer: The layer to add the instance to. If None, uses + the active layer. + + Returns: + The newly created WorkPiece instance + """ + history = self._editor.history_manager + target_layer = target_layer or self._editor.doc.active_layer + + provider = cast( + Optional["IGeometryProvider"], + self._editor.doc.get_asset_by_uid(provider_uid), + ) + if not provider: + raise ValueError( + f"Geometry provider with UID {provider_uid} not found." + ) + + # Create new WorkPiece using the factory method which handles + # correct sizing and initialization. + new_workpiece = WorkPiece.from_geometry_provider(provider) + + width, height = new_workpiece.natural_size + new_workpiece.pos = ( + position_mm[0] - width / 2, + position_mm[1] - height / 2, + ) + + with history.transaction( + _("Add {} Instance").format(provider.name) + ) as t: + command = ListItemCommand( + owner_obj=target_layer, + item=new_workpiece, + undo_command="remove_child", + redo_command="add_child", + name=_("Add {} Instance").format(provider.name), + ) + t.execute(command) + + return new_workpiece diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py new file mode 100644 index 000000000..7f7e1ddc6 --- /dev/null +++ b/rayforge/doceditor/editor.py @@ -0,0 +1,636 @@ +from __future__ import annotations + +import asyncio +import logging +import threading +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from blinker import Signal + +from ..core.asset import UnknownAsset +from ..core.doc import Doc +from ..core.layer import Layer +from ..core.vectorization_spec import VectorizationSpec +from ..pipeline.artifact import JobArtifact +from ..pipeline.artifact.handle import BaseArtifactHandle +from ..pipeline.pipeline import Pipeline +from ..pipeline.view import ViewManager +from .array_cmd import ArrayCmd +from .asset_cmd import AssetCmd +from .command_registry import command_registry +from .edit_cmd import EditCmd +from .file_cmd import FileCmd +from .group_cmd import GroupCmd +from .layer_cmd import LayerCmd +from .layout_cmd import LayoutCmd +from .split_cmd import SplitCmd +from .step_cmd import StepCmd +from .stock_cmd import StockCmd +from .tab_cmd import TabCmd +from .transform_cmd import TransformCmd + +if TYPE_CHECKING: + from ..context import RayforgeContext + from ..core.tab import Tab + from ..core.undo import HistoryManager + from ..core.workpiece import WorkPiece + from ..shared.tasker.manager import TaskManager + + +logger = logging.getLogger(__name__) + + +class DocEditor: + """ + The central, non-UI controller for document state and operations. + + This class owns the core data models (Doc, Pipeline) and provides a + structured API for all document manipulations, which are organized into + namespaced command handlers. It is instantiated with its dependencies + (task_manager, config_manager) to be a self-contained unit. + """ + + def __init__( + self, + task_manager: TaskManager, + context: RayforgeContext, + doc: Doc | None = None, + ): + """ + Initializes the DocEditor. + + Args: + task_manager: The application's TaskManager instance. + config_manager: The application's ConfigManager instance. + doc: An optional existing Doc object. If None, a new one is + created. + """ + self.context = context + self.task_manager = task_manager + self._config_manager = context.config_mgr + self.doc = doc or Doc() + + if doc is None and context.machine: + self.doc.active_layer.set_rotary_enabled( + context.machine.rotary_enabled_default + ) + default_rm = context.machine.get_default_rotary_module() + if default_rm: + self.doc.active_layer.set_rotary_diameter( + default_rm.default_diameter + ) + self.doc.active_layer.set_rotary_module_uid(default_rm.uid) + self.pipeline = Pipeline( + self.doc, + self.task_manager, + context.artifact_store, + context.machine, + cache_budget_bytes=context.config.cache_budget_bytes, + ) + self.view_manager = ViewManager( + self.pipeline, + context.artifact_store, + context.machine, + ) + self.history_manager: HistoryManager = self.doc.history_manager + + # A set to track temporary artifacts (e.g., for job previews) + # that don't live in the Pipeline cache. + self._transient_artifact_handles: set[BaseArtifactHandle] = set() + + # Track the number of active background tasks initiated by editor + # commands + self._busy_task_count: int = 0 + + # Track file path and saved state for the document + self._file_path: Path | None = None + self._is_saved: bool = True + + # Signals for monitoring document processing state + self.processing_state_changed = Signal() + self.document_settled = Signal() # Fires when processing finishes + self.notification_requested = Signal() # For UI feedback + self.assembly_warnings = Signal() # Non-fatal assembler warnings + self.saved_state_changed = Signal() # Fires when saved state changes + self.document_changed = Signal() # Fires when a new document is set + self.pipeline.processing_state_changed.connect( + self._on_processing_state_changed + ) + self.pipeline.pipeline_error.connect(self._on_pipeline_error) + self.pipeline.assembly_warnings.connect(self._on_assembly_warnings) + + # Connect to history manager to track undo/redo for saved state + self.history_manager.changed.connect(self._on_history_changed) + + context.addon_mgr.addon_state_changed.connect( + self._on_addon_state_changed + ) + + if context.machine: + context.machine.changed.connect(self._on_machine_changed) + self.configure_machine() + + context.config.changed.connect(self._on_config_changed) + self.pipeline.auto_pipeline = context.config.auto_pipeline + + # Keep the baseline machine config in sync with the document state. + doc = self.doc + doc.descendant_updated.connect(self._on_doc_changed) + doc.descendant_added.connect(self._on_doc_changed) + doc.descendant_removed.connect(self._on_doc_changed) + doc.active_layer_changed.connect(self._on_doc_changed) + + # Instantiate and link command handlers, passing dependencies. + self.asset = AssetCmd(self) + self.array = ArrayCmd(self) + self.edit = EditCmd(self) + self.file = FileCmd(self, self.task_manager) + self.group = GroupCmd(self, self.task_manager) + self.layer = LayerCmd(self) + self.layout = LayoutCmd(self, self.task_manager) + self.split = SplitCmd(self) + self.stock = StockCmd(self) + self.step = StepCmd(self) + self.tab = TabCmd(self) + self.transform = TransformCmd(self) + + # Instantiate addon-registered commands. + for name, cmd_class in command_registry.all_commands().items(): + setattr(self, name, cmd_class(self)) + + def cleanup(self): + """ + Shuts down owned long-running services, like the Pipeline, to + ensure cleanup of resources (e.g., shared memory). + """ + if self.context.machine: + self.context.machine.changed.disconnect(self._on_machine_changed) + + logger.info( + f"Releasing {len(self._transient_artifact_handles)} " + "transient job artifacts..." + ) + + if self.pipeline: + store = self.pipeline.artifact_store + for handle in list(self._transient_artifact_handles): + store.release(handle) + + self._transient_artifact_handles.clear() + + self.view_manager.shutdown() + self.pipeline.shutdown() + + def _on_machine_changed(self, sender, **kwargs): + machine = self.context.machine + if not machine: + return + default_rm = machine.get_default_rotary_module() + for layer in self.doc.layers: + if not layer.rotary_enabled: + continue + if ( + layer.rotary_module_uid is not None + and machine.get_rotary_module_by_uid(layer.rotary_module_uid) + ): + continue + if default_rm: + layer.set_rotary_module_uid(default_rm.uid) + layer.set_rotary_diameter(default_rm.default_diameter) + else: + layer.set_rotary_module_uid(None) + self.configure_machine() + + def configure_machine(self): + """ + Configures the machine for the resting document state. + + Uses the first layer of the document as the baseline so that the + scene renders a sensible view before a job is compiled. The + OpPlayer overrides this per playback position when a job exists. + """ + machine = self.context.machine + if not machine: + return + first_layer = self.doc.layers[0] if self.doc.layers else None + machine.configure_for_layer(first_layer) + + def _on_doc_changed(self, sender, **kwargs): + """Re-assert the baseline machine config on document changes.""" + self.configure_machine() + + def _on_config_changed(self, sender, **kwargs): + config = self.context.config + self.pipeline.auto_pipeline = config.auto_pipeline + self.pipeline.set_cache_budget_bytes(config.cache_budget_bytes) + + new_machine = config.machine + if new_machine and new_machine is not self.pipeline.machine: + if self.context.machine: + self.context.machine.changed.disconnect( + self._on_machine_changed + ) + self.pipeline.set_machine(new_machine) + new_machine.changed.connect(self._on_machine_changed) + self._on_machine_changed(self) + + def add_tab_from_context(self, context: dict[str, Any]): + """ + Public handler for the 'add_tab' action, using context from the UI. + """ + workpiece: WorkPiece = context["workpiece"] + location: dict[str, Any] = context["location"] + segment_index = location["segment_index"] + pos = location["pos"] + + self.tab.add_single_tab( + workpiece=workpiece, segment_index=segment_index, pos=pos + ) + + def remove_tab_from_context(self, context: dict[str, Any]): + """ + Public handler for the 'remove_tab' action, using context from the UI. + """ + workpiece: WorkPiece = context["workpiece"] + tab_to_remove: Tab = context["tab_data"] + + self.tab.remove_single_tab( + workpiece=workpiece, tab_to_remove=tab_to_remove + ) + + @property + def machine_dimensions(self) -> tuple[float, float] | None: + """Returns the configured machine's axis extents, or None.""" + config = self.context.config + if config and config.machine: + return config.machine.axis_extents + return None + + @property + def default_workpiece_layer(self) -> Layer: + """ + Determines the most appropriate layer for adding new workpieces. + """ + return self.doc.active_layer + + async def wait_until_settled(self, timeout: float = 10.0) -> None: + """ + Waits until the internal Pipeline has finished all background + processing and the document state is stable. + """ + if not self.is_processing: + return + + settled_future = asyncio.get_running_loop().create_future() + + def on_settled(sender, is_processing: bool): + if not is_processing and not settled_future.done(): + settled_future.set_result(True) + + self.processing_state_changed.connect(on_settled) + try: + if not self.is_processing and not settled_future.done(): + settled_future.set_result(True) + await asyncio.wait_for(settled_future, timeout) + finally: + self.processing_state_changed.disconnect(on_settled) + + def wait_until_settled_sync(self, timeout: float = 10.0) -> bool: + """ + Synchronous version of wait_until_settled for use in scripts. + + Uses a polling loop to detect when is_processing becomes False, + with the processing_state_changed signal as an early wakeup + hint. This avoids relying solely on signal delivery, which can + be missed when the pipeline transitions busy→idle faster than + _check_and_update_processing_state runs. + + Returns True if settled within timeout, False otherwise. + """ + settled_event = threading.Event() + + def on_settled(sender, is_processing: bool): + if not is_processing: + settled_event.set() + + self.processing_state_changed.connect(on_settled) + try: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not self.is_processing: + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + break + settled_event.wait(timeout=min(remaining, 0.2)) + settled_event.clear() + return not self.is_processing + finally: + self.processing_state_changed.disconnect(on_settled) + + def _on_addon_state_changed(self, sender, addon_name: str): + """ + Refresh the document after an addon is reloaded. + + Re-serializes and deserializes the document to ensure all + producer/widget instances use fresh class references from + the reloaded addon. + """ + logger.info( + f"Refreshing document after addon '{addon_name}' state change" + ) + doc_data = self.doc.to_dict() + new_doc = Doc.from_dict(doc_data) + self.set_doc(new_doc) + + unknown_assets = [ + asset + for asset in new_doc.get_all_assets() + if isinstance(asset, UnknownAsset) + ] + if unknown_assets: + from gettext import gettext as _ + + self.notification_requested.send( + self, + message=_( + "{count} asset(s) require disabled addon '{addon}'" + ).format(count=len(unknown_assets), addon=addon_name), + persistent=True, + ) + + async def import_file_from_path( + self, + filename: Path, + mime_type: str | None, + vectorization_spec: VectorizationSpec | None, + ) -> None: + """ + Imports a file from the specified path and waits for the operation + to complete. + """ + # Step 1: Run the importer + import_result = await self.file._load_file_async( + filename, mime_type, vectorization_spec + ) + if not import_result or not import_result.payload: + logger.warning( + f"Test import of {filename.name} produced no items." + ) + return + + # Step 2: Run the finalizer on the main thread. + self.file._finalize_import_on_main_thread( + import_result.payload, filename, position_mm=None + ) + + async def export_gcode_to_path(self, output_path: Path) -> None: + """ + Exports the current document to a G-code file at the specified path + and waits for the operation to complete. This awaitable version is + useful for tests. + """ + export_future = asyncio.get_running_loop().create_future() + artifact_store = self.pipeline.artifact_store + + def _on_export_assembly_done( + handle: BaseArtifactHandle | None, + error: Exception | None, + ): + try: + if error: + export_future.set_exception(error) + return + + with artifact_store.checkout_handle(handle) as artifact: + if not artifact: + raise ValueError( + "Assembly process returned no artifact." + ) + assert isinstance(artifact, JobArtifact) + if artifact.machine_code is None: + raise ValueError( + "Final artifact is missing G-code data." + ) + + output_path.write_text( + artifact.machine_code, encoding="utf-8" + ) + + logger.info(f"Test export successful to {output_path}") + export_future.set_result(True) + + except Exception as e: # noqa: BLE001 - forward to future + if not export_future.done(): + export_future.set_exception(e) + + # Call the non-blocking method and provide our callback to bridge it + self.file.assemble_job_in_background( + when_done=_on_export_assembly_done + ) + await export_future + + def set_doc(self, new_doc: Doc): + """ + Assigns a new document to editor, re-initializing the core + components like the Pipeline. + """ + old_history_manager = self.history_manager + self.pipeline.processing_state_changed.disconnect( + self._on_processing_state_changed + ) + + logger.debug("DocEditor is setting a new document.") + old_doc = self.doc + if old_doc is not new_doc: + old_doc.descendant_updated.disconnect(self._on_doc_changed) + old_doc.descendant_added.disconnect(self._on_doc_changed) + old_doc.descendant_removed.disconnect(self._on_doc_changed) + old_doc.active_layer_changed.disconnect(self._on_doc_changed) + self.doc = new_doc + self._reconcile_step_heads() + self._reconcile_rotary_modules() + self.history_manager = self.doc.history_manager + # The Pipeline's setter handles cleanup and reconnection + self.pipeline.doc = new_doc + + self.pipeline.processing_state_changed.connect( + self._on_processing_state_changed + ) + + # Keep the baseline machine config in sync with the new document. + new_doc.descendant_updated.connect(self._on_doc_changed) + new_doc.descendant_added.connect(self._on_doc_changed) + new_doc.descendant_removed.connect(self._on_doc_changed) + new_doc.active_layer_changed.connect(self._on_doc_changed) + self.configure_machine() + + # Reconnect to new history manager + if old_history_manager is not self.history_manager: + old_history_manager.changed.disconnect(self._on_history_changed) + self.history_manager.changed.connect(self._on_history_changed) + + # Notify listeners that document has changed + self.document_changed.send(self) + + # Mark document as unsaved when setting a new doc + # (unless called from load_project_from_path which will mark as saved) + self.mark_as_unsaved() + + def _reconcile_step_heads(self): + """ + Reconciles step head UIDs with the current machine. + + For each step, checks if its selected_head_uid exists in the + machine's heads. If not, updates it to the default head. + This handles the case where a project file references a head + that doesn't exist in the current machine profile. + """ + machine = self.context.machine + if not machine or not machine.heads: + return + + valid_head_uids = {head.uid for head in machine.heads} + default_head_uid = machine.heads[0].uid + + for layer in self.doc.layers: + if not layer.workflow: + continue + for step in layer.workflow.steps: + if step.selected_head_uid not in valid_head_uids: + logger.info( + f"Step '{step.name}' references non-existent head " + f"'{step.selected_head_uid}', " + f"resetting to default '{default_head_uid}'" + ) + step.selected_head_uid = default_head_uid + + def _reconcile_rotary_modules(self): + """ + Reconcile layer rotary_module_uid with the current machine. + + For each layer, checks if its rotary_module_uid references a + module that exists on the machine. If not, updates it to the + default module. This handles the case where a project file + references a module that doesn't exist in the current machine. + """ + machine = self.context.machine + if not machine: + return + + default_rm = machine.get_default_rotary_module() + for layer in self.doc.layers: + if not layer.rotary_enabled: + continue + if ( + layer.rotary_module_uid is not None + and machine.get_rotary_module_by_uid(layer.rotary_module_uid) + ): + continue + if default_rm: + logger.info( + "Layer '%s' has no valid rotary module, assigning default", + layer.name, + ) + layer.set_rotary_module_uid(default_rm.uid) + layer.set_rotary_diameter(default_rm.default_diameter) + else: + layer.set_rotary_module_uid(None) + + @property + def is_processing(self) -> bool: + """Returns True if the document is currently generating operations.""" + # The editor is busy if the pipeline is active OR if there are + # outstanding background tasks (like grouping calculations) + # running. + return self.pipeline.is_busy or self._busy_task_count > 0 + + def notify_task_started(self): + """ + Notifies the editor that a background task (e.g. calculation) has + started. + This prevents wait_until_settled from returning prematurely. + """ + was_processing = self.is_processing + self._busy_task_count += 1 + + # If we transitioned from idle to busy, emit the signal. + if not was_processing: + self.processing_state_changed.send(self, is_processing=True) + + def notify_task_ended(self): + """ + Notifies the editor that a background task has ended. + """ + if self._busy_task_count > 0: + self._busy_task_count -= 1 + + # If we transitioned from busy to idle, emit the signals. + if not self.is_processing: + self.processing_state_changed.send(self, is_processing=False) + self.document_settled.send(self) + + def _on_processing_state_changed(self, sender, is_processing: bool): + """Proxies the signal from the Pipeline.""" + # Use the effective state (pipeline + tasks) rather than just + # pipeline state + effective_state = self.is_processing + self.processing_state_changed.send(self, is_processing=effective_state) + if not effective_state: + self.document_settled.send(self) + + def _on_pipeline_error(self, sender, *, message: str) -> None: + """Show a UI notification on pipeline execution errors.""" + self.notification_requested.send(self, message=message) + + def _on_assembly_warnings(self, sender, *, warnings) -> None: + """Translate non-fatal assembler warnings and show them as toasts.""" + from rayforge.pipeline.assembly_warnings import ( + translate_assembly_warning, + ) + + for w in warnings: + message = translate_assembly_warning(w) + self.notification_requested.send(self, message=message) + + def _on_history_changed(self, sender, command): + """ + Handles history manager changes (undo/redo/new commands). + Updates saved state based on checkpoint position. + """ + new_is_saved = self.history_manager.is_at_checkpoint() + if self._is_saved != new_is_saved: + self._is_saved = new_is_saved + self.saved_state_changed.send(self) + + @property + def file_path(self) -> Path | None: + """Returns the current file path of the document.""" + return self._file_path + + @property + def is_saved(self) -> bool: + """Returns True if the document has no unsaved changes.""" + return self._is_saved + + def set_file_path(self, path: Path | None): + """Sets the file path for the document.""" + self._file_path = path + self.saved_state_changed.send(self) + + def mark_as_saved(self): + """Marks the document as saved.""" + self.history_manager.set_checkpoint() + new_is_saved = self.history_manager.is_at_checkpoint() + if self._is_saved != new_is_saved: + self._is_saved = new_is_saved + self.saved_state_changed.send(self) + + def mark_as_unsaved(self): + """Marks the document as having unsaved changes.""" + self.history_manager.clear_checkpoint() + if self._is_saved: + self._is_saved = False + self.saved_state_changed.send(self) diff --git a/rayforge/doceditor/file_cmd.py b/rayforge/doceditor/file_cmd.py new file mode 100644 index 000000000..78856650c --- /dev/null +++ b/rayforge/doceditor/file_cmd.py @@ -0,0 +1,1359 @@ +import asyncio +import json +import logging +import mimetypes +import warnings +import zipfile +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum, auto +from gettext import gettext as _ +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Optional, + cast, +) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Point, Rect +from raygeo.ops.state import CoolantMode + +from ..context import get_context +from ..core.doc import Doc +from ..core.item import DocItem +from ..core.layer import Layer +from ..core.source_asset import SourceAsset +from ..core.undo import ChangePropertyCommand, ListItemCommand +from ..core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + TraceSpec, + VectorizationSpec, +) +from ..core.workpiece import WorkPiece +from ..image import ( + Importer, + ImporterFeature, + ImportManifest, + exporter_registry, + importer_registry, +) +from ..image.base_exporter import Exporter +from ..image.dxf.exporter import GeometryDxfExporter +from ..image.structures import ImportPayload, ImportResult, ParsingResult +from ..image.svg.exporter import GeometrySvgExporter +from ..pipeline.artifact import JobArtifact +from ..pipeline.artifact.handle import BaseArtifactHandle +from .layout.align import PositionAtStrategy + +if TYPE_CHECKING: + from ..core.asset import IAsset + from ..doceditor.editor import DocEditor + from ..machine.models.machine import Machine + from ..shared.tasker.manager import TaskManager + + +logger = logging.getLogger(__name__) + + +_COOLANT_MODE_LABELS = { + CoolantMode.FLOOD: _("Flood"), + CoolantMode.MIST: _("Mist"), +} + + +def _unsupported_coolant_labels( + doc: "Doc", machine: Optional["Machine"] +) -> list[str]: + """Human-readable labels of coolant methods used by the doc's steps + that the current machine does not support.""" + if machine is None: + return [] + unsupported: set[CoolantMode] = set() + for layer in doc.layers: + if not layer.workflow: + continue + for step in layer.workflow.steps: + unsupported.update(step.get_unsupported_coolant_methods(machine)) + ordered = sorted(unsupported, key=lambda m: m.value) + return [_COOLANT_MODE_LABELS[m] for m in ordered] + + +@dataclass +class PreviewResult: + """ + Result of a preview generation operation. + Contains the rendered image bytes, the document items to display, and the + parsing context needed for correct rendering. + """ + + image_bytes: bytes + payload: ImportPayload | None + parse_result: ParsingResult | None # Context for rendering + aspect_ratio: float = 1.0 + warnings: list[str] = field(default_factory=list) + content_bounds: Rect | None = None + + +class ImportAction(Enum): + """Determines the workflow required to import a specific file.""" + + DIRECT_LOAD = auto() + INTERACTIVE_CONFIG = auto() + UNSUPPORTED = auto() + + +class FileCmd: + """Handles file import and export operations.""" + + def __init__( + self, + editor: "DocEditor", + task_manager: "TaskManager", + ): + self._editor = editor + self._task_manager = task_manager + + def get_importer_info( + self, file_path: Path, mime_type: str | None + ) -> tuple[type[Importer] | None, set[ImporterFeature]]: + """ + Finds the importer for a file and returns its class and feature set. + """ + if not mime_type: + mime_type, _ = mimetypes.guess_type(file_path) + + importer_cls = None + if mime_type: + importer_cls = importer_registry.get_by_mime_type(mime_type) + + if not importer_cls and file_path.suffix: + importer_cls = importer_registry.get_by_extension( + file_path.suffix.lower() + ) + + if importer_cls: + return importer_cls, importer_cls.features + return None, set() + + def analyze_import_target( + self, file_path: Path, mime_type: str | None = None + ) -> ImportAction: + """ + Analyzes a file path (and optional mime type) to determine how it + should be imported. + """ + importer_cls, features = self.get_importer_info(file_path, mime_type) + + if not importer_cls: + return ImportAction.UNSUPPORTED + + # Any format that can be traced OR has selectable layers needs an + # interactive dialog. + if ( + ImporterFeature.BITMAP_TRACING in features + or ImporterFeature.LAYER_SELECTION in features + ): + return ImportAction.INTERACTIVE_CONFIG + + return ImportAction.DIRECT_LOAD + + def scan_import_file( + self, file_bytes: bytes, file_path: Path, mime_type: str + ) -> ImportManifest: + """ + Lightweight scan of a file to extract metadata without full processing. + """ + importer_cls, _ = self.get_importer_info(file_path, mime_type) + + if not importer_cls: + logger.warning( + f"No importer found for mime type '{mime_type}' or " + f"extension '{file_path.suffix}' during scan." + ) + return ImportManifest( + title=file_path.name, + warnings=[f"Unsupported file type: {file_path.suffix}"], + ) + + try: + importer_instance = importer_cls( + data=file_bytes, source_file=file_path + ) + manifest = importer_instance.scan() + return manifest + except Exception: + logger.exception( + f"Error scanning file {file_path.name} with " + f"{importer_cls.__name__}" + ) + return ImportManifest( + title=file_path.name, + warnings=[ + "An unexpected error occurred during file analysis." + ], + ) + + async def generate_preview( + self, + file_bytes: bytes, + filename: str, + mime_type: str, + spec: VectorizationSpec, + preview_size_px: int, + ) -> PreviewResult | None: + """ + Generates a preview image and vector payload for the import dialog. + Runs the heavy image processing in a background thread. + """ + return await asyncio.to_thread( + self._generate_preview_impl, + file_bytes, + filename, + mime_type, + spec, + preview_size_px, + ) + + def _generate_preview_impl( + self, + file_bytes: bytes, + filename: str, + mime_type: str, + spec: VectorizationSpec, + preview_size_px: int, + ) -> PreviewResult | None: + """Blocking implementation of preview generation.""" + importer_cls, _ = self.get_importer_info(Path(filename), mime_type) + if not importer_cls: + return None + + try: + importer = importer_cls( + data=file_bytes, source_file=Path(filename) + ) + import_result = importer.get_doc_items(spec) + + if not import_result: + return None + + # Even if no items were created, we might still be able to show a + # preview of the source asset (e.g., an empty DXF). + if not import_result.payload or not import_result.payload.items: + logger.warning( + f"Import of '{filename}' produced no document items, " + "but attempting to generate a preview." + ) + + return self._generate_rich_preview_result( + import_result, file_bytes, spec, preview_size_px + ) + + except Exception: + logger.exception("Failed to generate import preview") + return None + + def _generate_rich_preview_result( + self, + import_result: ImportResult, + original_file_bytes: bytes, + spec: VectorizationSpec, + preview_size_px: int, + ) -> PreviewResult | None: + """ + Generates the final PreviewResult from a rich ImportResult. + This is the new central logic for creating preview bitmaps. + """ + payload = import_result.payload + parse_result = import_result.parse_result + + if not payload or not parse_result: + return None + + renderer = payload.source.renderer + if not renderer: + return None + + # 1. Generate high-res base image for the background by delegating + # to the source's specialized renderer. + vips_image = None + content_bounds = None + target_dim = 2048 # Target for the longest edge of the hi-res preview + + _, _, w_native, h_native = parse_result.document_bounds + if w_native <= 1e-9 or h_native <= 1e-9: + # If there's no page size, we can't render a background. + # This is not necessarily an error; a file might have vector + # content but no defined canvas. + pass + else: + aspect = w_native / h_native + if aspect >= 1.0: + render_width = target_dim + render_height = max(1, int(target_dim / aspect)) + else: + render_height = target_dim + render_width = max(1, int(target_dim * aspect)) + + vips_image = renderer.render_preview_image( + import_result, render_width, render_height + ) + + # 2. Calculate content bounds for vector overlays from the + # intermediate vectorization result. + if import_result.vectorization_result: + all_geos = Geometry() + for geo in ( + import_result.vectorization_result.geometries_by_layer.values() + ): + if geo: + all_geos.extend(geo) + + if not all_geos.is_empty(): + min_x, min_y, max_x, max_y = all_geos.rect() + content_bounds = (min_x, min_y, max_x, max_y) + + # 3. Create a thumbnail for the UI. + if not vips_image: + # If background rendering failed or was skipped, but we have + # vectors, create a blank image to render the vectors on. + if payload and payload.items: + vips_image = pyvips.Image.black( + preview_size_px, preview_size_px + ) + else: + return None # No background and no items, nothing to show. + + aspect_ratio = ( + vips_image.width / vips_image.height if vips_image.height else 1.0 + ) + preview_vips = vips_image.thumbnail_image( + preview_size_px, height=preview_size_px, size="both" + ) + + if isinstance(spec, TraceSpec) and spec.invert: + bands = preview_vips.bands + if bands == 2: + background = [255] + elif bands == 4: + background = [255, 255, 255] + else: + background = [255, 255, 255] + preview_vips = preview_vips.flatten(background=background).invert() + + png_bytes = preview_vips.pngsave_buffer() + + return PreviewResult( + image_bytes=png_bytes, + payload=payload, + parse_result=parse_result, + aspect_ratio=aspect_ratio, + content_bounds=content_bounds, + ) + + def _extract_first_workpiece( + self, items: list[DocItem] + ) -> WorkPiece | None: + """Recursively extract the first WorkPiece from a list of items.""" + for item in items: + if isinstance(item, WorkPiece): + return item + if hasattr(item, "children"): + res = self._extract_first_workpiece(item.children) + if res: + return res + return None + + async def _load_file_async( + self, + filename: Path, + mime_type: str | None, + vectorization_spec: VectorizationSpec | None, + ) -> ImportResult | None: + """ + Runs the blocking import function in a background thread and returns + the resulting rich ImportResult. + """ + importer_cls, _ = self.get_importer_info(filename, mime_type) + if not importer_cls: + return None + + file_data = filename.read_bytes() + importer = importer_cls(file_data, source_file=filename) + return await asyncio.to_thread( + importer.get_doc_items, vectorization_spec + ) + + def _get_positionable_content(self, items: list[DocItem]) -> list[DocItem]: + """ + Extracts the actual content (WorkPieces, Groups) from a list of + imported items, looking inside any top-level Layer containers. + """ + content = [] + for item in items: + if isinstance(item, Layer): + content.extend(item.get_content_items()) + else: + content.append(item) + return content + + def _position_newly_imported_items( + self, + items: list[DocItem], + position_mm: Point | None, + ): + """ + Applies transformations to newly imported items, either positioning + them at a specific point or fitting and centering them. + This method modifies the items' matrices in-place. + """ + logger.debug( + f"_position_newly_imported_items: position_mm={position_mm}, " + f"items={len(items)}" + ) + + # Get the actual content to be transformed, looking inside layers. + content_to_transform = self._get_positionable_content(items) + if not content_to_transform: + return + + scale_factor = self._scale_to_fit_if_oversized(content_to_transform) + + if position_mm: + # Note: PositionAtStrategy needs the top-level items to calculate + # the current group position correctly. + strategy = PositionAtStrategy(items=items, position_mm=position_mm) + deltas = strategy.calculate_deltas() + if deltas: + # All items get the same delta matrix to move the group + delta_matrix = next(iter(deltas.values())) + # Apply the delta to the actual content, not the containers. + for item in content_to_transform: + item.matrix = delta_matrix @ item.matrix + + target_x, target_y = position_mm + logger.info( + f"Positioned {len(content_to_transform)} imported " + f"item(s) at ({target_x:.2f}, {target_y:.2f}) mm" + ) + else: + self._position_at_reference_origin(content_to_transform) + + if scale_factor < 1.0: + self._show_scale_down_notification( + content_to_transform, scale_factor + ) + + @staticmethod + def _unwrap_item(item: DocItem) -> list[DocItem]: + """Extract content items from a Layer, or return the item itself.""" + if isinstance(item, Layer): + return item.get_content_items() + return [item] + + def _resolve_destinations( + self, + items: list[DocItem], + mode: LayerImportMode, + target_layer: Layer | None = None, + ) -> list[tuple[DocItem, DocItem]]: + """ + Resolve each item to a (owner, item) pair based on the import mode. + Returns a flat list of (destination_owner, item_to_add) tuples. + """ + target_layer = cast( + Layer, + target_layer or self._editor.default_workpiece_layer, + ) + doc = self._editor.doc + pairs: list[tuple[DocItem, DocItem]] = [] + + if mode == LayerImportMode.MAP_TO_EXISTING: + existing = doc.layers + for idx, item in enumerate(items): + if idx < len(existing): + dest = existing[idx] + for child in self._unwrap_item(item): + pairs.append((dest, child)) + elif isinstance(item, Layer): + pairs.append((doc, item)) + else: + pairs.append((target_layer, item)) + elif mode == LayerImportMode.NEW_LAYERS: + for item in items: + if isinstance(item, Layer): + pairs.append((doc, item)) + else: + pairs.append((target_layer, item)) + else: + for item in items: + for child in self._unwrap_item(item): + pairs.append((target_layer, child)) + + return pairs + + def _plan_layer_renames( + self, + items: list[DocItem], + mode: LayerImportMode, + existing_layers: list[Layer], + ) -> list[tuple[Layer, str]]: + """ + Plan renames of default-named destination layers that receive + content from imported layers. + + Only applies in ``MAP_TO_EXISTING`` mode: an existing layer is + renamed to the imported layer name if it still carries an + auto-generated default name. + + Returns a list of (layer, new_name) pairs. + """ + if mode != LayerImportMode.MAP_TO_EXISTING: + return [] + renames: list[tuple[Layer, str]] = [] + for idx, item in enumerate(items): + if idx >= len(existing_layers): + break + dest = existing_layers[idx] + if ( + isinstance(item, Layer) + and Doc.is_default_layer_name(dest.name) + and item.name != dest.name + ): + renames.append((dest, item.name)) + return renames + + @staticmethod + def _layer_import_mode( + vectorization_spec: VectorizationSpec | None, + ) -> LayerImportMode: + """Returns the layer import mode of the given spec.""" + mode = LayerImportMode.NEW_LAYERS + if isinstance(vectorization_spec, PassthroughSpec): + mode = vectorization_spec.layer_import_mode + return mode + + def _commit_items( + self, + items: list[DocItem], + mode: LayerImportMode, + cmd_name: str, + target_layer: Layer | None = None, + ) -> list[Layer]: + """ + Adds the imported items to the document model using the history + manager, resolving destinations and planning layer renames. + + Returns the list of destination layers that received items. + """ + pairs = self._resolve_destinations(items, mode, target_layer) + renames = self._plan_layer_renames( + items, mode, self._editor.doc.layers + ) + + with self._editor.history_manager.transaction(cmd_name) as t: + for owner, item in pairs: + t.execute( + ListItemCommand( + owner_obj=owner, + item=item, + undo_command="remove_child", + redo_command="add_child", + ) + ) + for layer, new_name in renames: + t.execute( + ChangePropertyCommand( + target=layer, + property_name="name", + new_value=new_name, + setter_method_name="set_name", + name=_("Rename layer"), + ) + ) + + dest_layers = [] + seen = set() + for owner, item in pairs: + if isinstance(owner, Layer) and owner.uid not in seen: + dest_layers.append(owner) + seen.add(owner.uid) + elif isinstance(item, Layer) and item.uid not in seen: + dest_layers.append(item) + seen.add(item.uid) + return dest_layers + + def _commit_items_to_document( + self, + items: list[DocItem], + source: SourceAsset | None, + filename: Path, + assets: list["IAsset"] | None = None, + vectorization_spec: VectorizationSpec | None = None, + ) -> list[Layer]: + """ + Adds the imported items and their source to the document model using + the history manager. + + Returns the list of destination layers that received items. + """ + if source: + self._editor.doc.add_asset(source) + + if assets: + for asset in assets: + self._editor.doc.add_asset(asset) + + cmd_name = _("Import {filename}").format(filename=filename.name) + mode = self._layer_import_mode(vectorization_spec) + return self._commit_items(items, mode, cmd_name) + + def _finalize_import_on_main_thread( + self, + payload: ImportPayload, + filename: Path, + position_mm: Point | None, + vectorization_spec: VectorizationSpec | None = None, + ): + """ + Performs the final steps of an import on the main thread. + This includes positioning items (which may send UI notifications) and + committing them to the document (which fires signals that update UI). + """ + item_info = ( + f"{len(payload.items)} items" + if payload and payload.items + else "0 items" + ) + logger.debug(f"Item_info: {item_info} position_mm: {position_mm}") + # 1. Position the new items. This is now safe as it runs on the main + # thread, so any notifications it sends are valid. + self._position_newly_imported_items(payload.items, position_mm) + + # 2. Add the positioned items to the document model. This is also + # safe now as all subsequent signal handling will be on the + # main thread. + dest_layers = self._commit_items_to_document( + payload.items, + payload.source, + filename, + payload.assets, + vectorization_spec, + ) + + # 3. Add default steps to the destination layers. + if dest_layers: + self._editor.step.add_default_steps_for_layers(dest_layers) + + def load_file_from_path( + self, + filename: Path, + mime_type: str | None, + vectorization_spec: VectorizationSpec | None, + position_mm: Point | None = None, + ): + """ + Public, synchronous method to launch a file import in the background. + This is the clean entry point for the UI. + + Args: + filename: Path to the file to import + mime_type: MIME type of the file + vectorization_spec: Configuration for vectorization + (None for direct vector import) + position_mm: Optional (x, y) tuple in world coordinates (mm) + to center the imported item. + If None, items are centered on the workspace. + """ + logger.debug( + f"Loading file: {filename} " + f"vectorization_spec: {vectorization_spec} " + f"position_mm: {position_mm}" + ) + + # This wrapper adapts our clean async method to the TaskManager, + # which expects a coroutine that accepts a 'ctx' argument. + async def wrapper(ctx, fn, mt, vec_spec, pos_mm): + try: + # Update task message for UI feedback + ctx.set_message( + _("Importing {filename}...").format(filename=filename.name) + ) + + # 1. Run blocking I/O and CPU work in a background thread. + import_result = await self._load_file_async(fn, mt, vec_spec) + + # 2. Validate the result. + if not import_result or not import_result.payload: + if mt and mt.startswith("image/"): + msg = _( + "Failed to import {filename}. The image file " + "may be corrupted or in an unsupported format." + ).format(filename=fn.name) + else: + msg = _( + "Import failed: No items were created " + "from {filename}" + ).format(filename=fn.name) + logger.warning( + f"Importer created no items for '{fn.name}' " + f"(MIME: {mt})" + ) + # Schedule the error notification on the main thread. + self._task_manager.schedule_on_main_thread( + self._editor.notification_requested.send, + self, + message=msg, + ) + ctx.set_message(_("Import failed.")) + return + + # 3. Schedule finalization on main thread and wait for it to + # signal completion back to this (background) thread. + loop = asyncio.get_running_loop() + main_thread_done = loop.create_future() + + def finalizer_and_callback(): + """Wraps finalizer to signal future on completion/error.""" + try: + assert import_result.payload, "Missing import payload" + self._finalize_import_on_main_thread( + import_result.payload, fn, pos_mm, vec_spec + ) + if not main_thread_done.done(): + loop.call_soon_threadsafe( + main_thread_done.set_result, True + ) + except Exception as e: + logger.exception( + "Failed import finalization on main thread." + ) + if not main_thread_done.done(): + loop.call_soon_threadsafe( + main_thread_done.set_exception, e + ) + + self._task_manager.schedule_on_main_thread( + finalizer_and_callback + ) + + # Wait here until the main thread signals completion or error. + await main_thread_done + + ctx.set_message(_("Import complete!")) + except Exception as e: + # This will catch failures from the importer or the finalizer. + ctx.set_message(_("Import failed.")) + logger.error( + f"Import task for {fn.name} failed in wrapper.", + exc_info=e, + ) + # Re-raise to ensure the task manager marks the task as failed. + raise + + self._task_manager.add_coroutine( + wrapper, + filename, + mime_type, + vectorization_spec, + position_mm, + key=f"import-{filename}", + ) + + def execute_batch_import( + self, + files: list[Path], + spec: VectorizationSpec, + pos: Point | None, + ): + """ + Imports multiple files using the same vectorization settings. + This spawns individual import tasks for each file. + """ + for file_path in files: + # We assume files are valid if passed here, or guess mime type + # individually + mime_type, _ = mimetypes.guess_type(file_path) + self.load_file_from_path(file_path, mime_type, spec, pos) + + def _calculate_items_bbox( + self, + items: list[DocItem], + ) -> Rect | None: + """ + Calculates the world-space bounding box that encloses a list of + DocItems by taking the union of their individual bboxes. + This is more robust than item.bbox for un-parented items. + """ + if not items: + return None + + all_rects = [] + for item in items: + # FIX: Use the item's matrix directly. This is robust for + # items not yet in the document tree, as their matrix IS their + # world transform at this point. + item_transform = item.matrix + item_bbox_local = item.get_local_bbox() + + if item_bbox_local: + # Transform the four corners of the local bounding box + corners = [ + (item_bbox_local[0], item_bbox_local[1]), + ( + item_bbox_local[0] + item_bbox_local[2], + item_bbox_local[1], + ), + ( + item_bbox_local[0] + item_bbox_local[2], + item_bbox_local[1] + item_bbox_local[3], + ), + ( + item_bbox_local[0], + item_bbox_local[1] + item_bbox_local[3], + ), + ] + world_corners = [ + item_transform.transform_point(p) for p in corners + ] + + min_x = min(p[0] for p in world_corners) + min_y = min(p[1] for p in world_corners) + max_x = max(p[0] for p in world_corners) + max_y = max(p[1] for p in world_corners) + all_rects.append((min_x, min_y, max_x - min_x, max_y - min_y)) + + if not all_rects: + return None + + # Calculate the union of all collected rectangles + min_x, min_y, w, h = all_rects[0] + max_x = min_x + w + max_y = min_y + h + + for x, y, w, h in all_rects[1:]: + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x + w) + max_y = max(max_y, y + h) + + return min_x, min_y, max_x - min_x, max_y - min_y + + def _scale_to_fit_if_oversized(self, items: list[DocItem]) -> float: + """ + Scales items to fit within machine work area if they are too + large, preserving aspect ratio. + + Returns the scale factor applied (1.0 if no scaling was needed). + """ + config = get_context().config + if not config or not config.machine: + logger.warning( + "Cannot fit/position imported items: " + "machine dimensions unknown." + ) + return 1.0 + + # We must operate on the actual content (WorkPieces, Groups), not the + # top-level containers (Layers). + content_items = self._get_positionable_content(items) + if not content_items: + logger.warning("No positionable content found to fit/position.") + return 1.0 + + # Calculate the bounding box of the actual content. + bbox = self._calculate_items_bbox(content_items) + if not bbox: + logger.warning( + "Cannot fit/position imported items: no bounding box." + ) + return 1.0 + + bbox_x, bbox_y, bbox_w, bbox_h = bbox + area_x, area_y, area_w, area_h = config.machine.work_area + logger.debug( + f"_fit_and_position_at_reference_origin: bbox=({bbox_x:.2f}, " + f"{bbox_y:.2f}, {bbox_w:.2f}, {bbox_h:.2f}), " + f"work_area=" + f"({area_x:.2f}, {area_y:.2f}, {area_w:.2f}, {area_h:.2f})" + ) + + # Scale to fit if necessary, preserving aspect ratio + scale_factor = 1.0 + if bbox_w > area_w or bbox_h > area_h: + scale_w = area_w / bbox_w if bbox_w > 1e-9 else 1.0 + scale_h = area_h / bbox_h if bbox_h > 1e-9 else 1.0 + scale_factor = min(scale_w, scale_h) + + if scale_factor < 1.0: + # The pivot for scaling should be the center of the bounding box + bbox_center_x = bbox_x + bbox_w / 2 + bbox_center_y = bbox_y + bbox_h / 2 + + # The transformation is: T(pivot) @ S(scale) @ T(-pivot) + t_to_origin = Matrix.translation(-bbox_center_x, -bbox_center_y) + s = Matrix.scale(scale_factor, scale_factor) + t_back = Matrix.translation(bbox_center_x, bbox_center_y) + transform_matrix = t_back @ s @ t_to_origin + + # Apply the group transform to each piece of content. + for item in content_items: + item.matrix = transform_matrix @ item.matrix + + return scale_factor + + def _position_at_reference_origin(self, items: list[DocItem]): + """ + Positions items at the reference origin. + + The caller is responsible for calling _scale_to_fit_if_oversized() + before this method. + """ + config = get_context().config + if not config or not config.machine: + logger.warning( + "Cannot fit/position imported items: " + "machine dimensions unknown." + ) + return + + content_items = self._get_positionable_content(items) + if not content_items: + return + + bbox = self._calculate_items_bbox(content_items) + if not bbox: + return # Should not happen, but for safety + bbox_x, bbox_y, bbox_w, bbox_h = bbox + + machine = config.machine + # Position at reference origin + # The reference origin is where the user expects (0,0) to be. + # The panel gives us the reference origin in world coords; we use + # world_position_from_origin to handle origin corner adjustment. + ref_x, ref_y = machine.panel.reference_position_world + target_x, target_y = machine.panel.world_position_from_origin( + ref_x, ref_y, (bbox_w, bbox_h) + ) + + # Calculate translation to move bbox top-left to the target position + delta_x = target_x - bbox_x + delta_y = target_y - bbox_y + + # Apply the same translation to all top-level imported items + if abs(delta_x) > 1e-9 or abs(delta_y) > 1e-9: + translation_matrix = Matrix.translation(delta_x, delta_y) + # Apply the group transform to each piece of content. + for item in content_items: + item.matrix = translation_matrix @ item.matrix + + def _show_scale_down_notification( + self, content_items: list[DocItem], scale_factor: float + ): + """ + Shows a persistent notification that the imported item was scaled + down, with an undo action that reverts the scaling. + """ + # Notification with Undo logic + # We define this after centering so the callback can handle the + # final position correctly. + + def _undo_scaling_callback(): + """ + Reverts the auto-scaling applied during import. + It scales the items back up around their CURRENT center. + """ + # Use the content items for calculation and transformation + current_bbox = self._calculate_items_bbox(content_items) + if not current_bbox: + return + + cur_x, cur_y, cur_w, cur_h = current_bbox + cur_cx = cur_x + cur_w / 2 + cur_cy = cur_y + cur_h / 2 + + inv_scale = 1.0 / scale_factor + + # Create a matrix that scales by 1/factor around the current + # center + undo_matrix = Matrix.scale( + inv_scale, inv_scale, center=(cur_cx, cur_cy) + ) + + changes = [] + for item in content_items: + current = item.matrix + new_m = undo_matrix @ current + changes.append((item, current, new_m)) + + self._editor.transform.create_transform_transaction(changes) + + msg = _( + "⚠️ Imported item was larger than the work area and has been " + "scaled down to fit." + ) + logger.info(msg) + self._editor.notification_requested.send( + self, + message=msg, + persistent=True, + action_label=_("Reset"), + action_callback=_undo_scaling_callback, + ) + + def assemble_job_in_background( + self, + when_done: Callable[ + [BaseArtifactHandle | None, Exception | None], None + ], + ): + """ + Asynchronously runs the full job assembly in a background process. + This method is non-blocking and returns immediately. + + Args: + when_done: A callback executed upon completion. It receives + an ArtifactHandle on success, or (None, error) on + failure. + """ + self._editor.pipeline.generate_job_artifact(when_done=when_done) + + def export_gcode_to_path(self, file_path: Path): + """ + Asynchronously generates and exports G-code to a specific path. + This is a non-blocking, fire-and-forget method for the UI. + """ + artifact_store = self._editor.pipeline.artifact_store + + def _on_export_assembly_done( + handle: BaseArtifactHandle | None, + error: Exception | None, + ): + try: + if error: + raise error + + with artifact_store.checkout_handle(handle) as artifact: + if not artifact: + raise ValueError( + "Assembly process returned no artifact." + ) + if not isinstance(artifact, JobArtifact): + raise TypeError("Expected a JobArtifact for export.") + if artifact.machine_code is None: + raise ValueError( + "Final artifact is missing G-code data." + ) + + file_path.write_text( + artifact.machine_code, encoding="utf-8" + ) + + logger.info(f"Successfully exported G-code to {file_path}") + msg = _("Export successful: {name}").format( + name=file_path.name + ) + self._editor.notification_requested.send(self, message=msg) + + except Exception as e: + logger.error( + f"G-code export to {file_path} failed.", exc_info=e + ) + self._editor.notification_requested.send( + self, message=_("Export failed: {error}").format(error=e) + ) + + self.assemble_job_in_background(when_done=_on_export_assembly_done) + + def export_object_to_path(self, file_path: Path, workpiece: WorkPiece): + """ + Exports a workpiece to a file. + + Supports multiple formats based on file extension: + - .rfs: Rayforge Sketch (parametric, sketch-based only) + - .svg: SVG format + - .dxf: DXF format + + This is a synchronous method for the UI. + """ + ext = file_path.suffix.lower() + if ext == ".rfs": + return self._export_sketch_to_rfs(file_path, workpiece) + + geo = workpiece.get_world_geometry() + if geo is None or geo.is_empty(): + raise ValueError( + "Cannot export: The selected item has no geometry." + ) + + if ext == ".svg": + exporter = GeometrySvgExporter(geo) + elif ext == ".dxf": + exporter = GeometryDxfExporter(geo) + else: + raise ValueError(f"Unsupported export format: {ext}") + + return self._do_export(file_path, exporter) + + def _export_sketch_to_rfs( + self, file_path: Path, workpiece: WorkPiece + ) -> bool: + """Export a sketch-based workpiece to RFS format.""" + exporter_cls = exporter_registry.get_by_extension(file_path.suffix) + if not exporter_cls: + raise ValueError( + f"No exporter registered for extension {file_path.suffix}" + ) + exporter = cast(type[Exporter], exporter_cls)(workpiece) + return self._do_export(file_path, exporter) + + def _do_export(self, file_path: Path, exporter) -> bool: + """Execute the export and handle notifications.""" + try: + data = exporter.export() + file_path.write_bytes(data) + logger.info(f"Successfully exported object to {file_path}") + msg = _("Object exported successfully.") + self._editor.notification_requested.send(self, message=msg) + return True + except Exception as e: + logger.error(f"Failed to export object to {file_path}", exc_info=e) + self._editor.notification_requested.send( + self, + message=_("Failed to export object: {error}").format( + error=str(e) + ), + ) + return False + + def export_document_to_path(self, file_path: Path) -> bool: + """ + Exports all workpieces in the document to a file. + + Supports multiple formats based on file extension: + - .svg: SVG format + - .dxf: DXF format + + This is a synchronous method for the UI. + """ + geometries = [] + for wp in self._editor.doc.get_descendants(WorkPiece): + geo = wp.get_world_geometry() + if geo is not None and not geo.is_empty(): + geometries.append(geo) + + if not geometries: + self._editor.notification_requested.send( + self, + message=_("Cannot export: Document has no geometry."), + ) + return False + + ext = file_path.suffix.lower() + if ext == ".svg": + from ..image.svg.exporter import MultiGeometrySvgExporter + + exporter = MultiGeometrySvgExporter(geometries) + elif ext == ".dxf": + from ..image.dxf.exporter import MultiGeometryDxfExporter + + exporter = MultiGeometryDxfExporter(geometries) + else: + raise ValueError(f"Unsupported export format: {ext}") + + try: + data = exporter.export() + file_path.write_bytes(data) + logger.info(f"Successfully exported document to {file_path}") + msg = _("Document exported successfully.") + self._editor.notification_requested.send(self, message=msg) + return True + except Exception as e: + logger.error( + f"Failed to export document to {file_path}", exc_info=e + ) + self._editor.notification_requested.send( + self, + message=_("Failed to export document: {error}").format( + error=str(e) + ), + ) + return False + + def save_project_to_path(self, file_path: Path): + """ + Saves the current document to a .ryp project file. + This is a synchronous method for the UI. + """ + try: + doc_dict = self._editor.doc.to_dict() + json_bytes = json.dumps(doc_dict, indent=2).encode("utf-8") + with zipfile.ZipFile( + file_path, "w", compression=zipfile.ZIP_DEFLATED + ) as zf: + zf.writestr("project.json", json_bytes) + self._editor.set_file_path(file_path) + self._editor.mark_as_saved() + logger.info(f"Successfully saved project to {file_path}") + msg = _("Project saved: {name}").format(name=file_path.name) + self._editor.notification_requested.send(self, message=msg) + return True + except Exception as e: + logger.error(f"Failed to save project to {file_path}", exc_info=e) + self._editor.notification_requested.send( + self, message=_("Save failed: {error}").format(error=str(e)) + ) + return False + + @staticmethod + def _read_project_content(file_path: Path) -> str: + if zipfile.is_zipfile(file_path): + with zipfile.ZipFile(file_path, "r") as zf: + return zf.read("project.json").decode("utf-8") + return file_path.read_text(encoding="utf-8") + + def load_project_from_path(self, file_path: Path): + """ + Loads a .ryp project file and replaces the current document. + This is a synchronous method for the UI. + """ + try: + if not file_path.exists(): + msg = _("File not found: {name}").format(name=file_path.name) + self._editor.notification_requested.send(self, message=msg) + return False + + file_content = self._read_project_content(file_path) + doc_dict = json.loads(file_content) + + from ..core.asset import UnknownAsset + from ..core.doc import Doc + + new_doc = Doc.from_dict(doc_dict) + + self._editor.set_doc(new_doc) + self._editor.set_file_path(file_path) + self._editor.mark_as_saved() + self._editor.doc.updated.send(self._editor.doc) + + labels = _unsupported_coolant_labels( + new_doc, self._editor.context.machine + ) + if labels: + self._editor.notification_requested.send( + self, + message=_( + "This project uses cooling methods not supported by " + "the current machine: {methods}" + ).format(methods=", ".join(labels)), + persistent=True, + ) + + unknown_assets = [ + asset + for asset in new_doc.get_all_assets() + if isinstance(asset, UnknownAsset) + ] + if unknown_assets: + self._editor.notification_requested.send( + self, + message=_( + "{count} asset(s) require disabled addon(s)" + ).format(count=len(unknown_assets)), + persistent=True, + ) + + logger.info(f"Successfully loaded project from {file_path}") + return True + except json.JSONDecodeError as e: + logger.error( + f"Failed to parse project file {file_path}: {e}", + exc_info=e, + ) + self._editor.notification_requested.send( + self, message=_("Invalid project file format") + ) + return False + except Exception as e: + logger.error( + f"Failed to load project from {file_path}", exc_info=e + ) + self._editor.notification_requested.send( + self, message=_("Load failed: {error}").format(error=str(e)) + ) + return False + + def reimport_from_source_asset( + self, + source_asset: SourceAsset, + vectorization_spec: VectorizationSpec, + position_mm: Point | None = None, + target_layer: Layer | None = None, + ) -> ImportResult | None: + """ + Re-run the import pipeline for an existing SourceAsset, producing + fresh (or additional) workpieces from the original data. + + Unlike the normal import path, no new SourceAsset is added to the + document -- the existing one is reused. + """ + meta = source_asset.metadata + importer_cls_name = meta.get("_importer_class") + if not importer_cls_name: + logger.warning( + "Cannot reimport: SourceAsset has no _importer_class metadata" + ) + return None + importer_cls = importer_registry.get_by_name(importer_cls_name) + if not importer_cls: + logger.warning( + f"Cannot reimport: importer '{importer_cls_name}' not " + f"registered" + ) + return None + + importer = importer_cls( + data=source_asset.original_data, + source_file=source_asset.source_file or Path("Untitled"), + ) + import_result = importer.get_doc_items_for_reimport( + source_asset, vectorization_spec + ) + + if not import_result or not import_result.payload: + return import_result + + self._finalize_reimport( + import_result.payload.items, + position_mm, + vectorization_spec, + target_layer, + ) + return import_result + + def _finalize_reimport( + self, + items: list[DocItem], + position_mm: Point | None, + vectorization_spec: VectorizationSpec | None = None, + target_layer: Layer | None = None, + ): + """ + Commit reimported items to the document. + + Unlike _finalize_import_on_main_thread, this does NOT add a new + SourceAsset -- the existing one is reused. + """ + self._position_newly_imported_items(items, position_mm) + + mode = self._layer_import_mode(vectorization_spec) + dest_layers = self._commit_items( + items, mode, _("Re-Import"), target_layer=target_layer + ) + if dest_layers: + self._editor.step.add_default_steps_for_layers(dest_layers) diff --git a/rayforge/doceditor/group_cmd.py b/rayforge/doceditor/group_cmd.py new file mode 100644 index 000000000..fa7ad3e5b --- /dev/null +++ b/rayforge/doceditor/group_cmd.py @@ -0,0 +1,352 @@ +import asyncio +import logging +from collections import defaultdict +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Matrix + +from ..core.group import Group, GroupingResult +from ..core.item import DocItem +from ..core.layer import Layer +from ..core.undo.command import Command + +if TYPE_CHECKING: + from ..pipeline.pipeline import Pipeline + from ..shared.tasker.context import ExecutionContext + from ..shared.tasker.manager import TaskManager + from ..shared.tasker.task import Task + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class _CreateGroupCommand(Command): + """An undoable command to group a list of DocItems into a new Group.""" + + def __init__( + self, + layer: Layer, + items_to_group: list[DocItem], + pipeline: "Pipeline", + name: str = "Group Items", + precalculated_result: GroupingResult | None = None, + ): + super().__init__(name) + self.layer = layer + self.items_to_group = list(items_to_group) + self.pipeline = pipeline + self.new_group: Group | None = None + self._original_parents: dict[str, DocItem] = { + item.uid: item.parent + for item in self.items_to_group + if item.parent + } + self._original_matrices: dict[str, Matrix] = { + item.uid: item.matrix.copy() for item in self.items_to_group + } + self._precalculated_result = precalculated_result + + def execute(self) -> None: + """Performs the grouping operation.""" + # The result must be pre-calculated by the background task. + if not self._precalculated_result: + return + + result = self._precalculated_result + + # Pause the generator to prevent it from reacting to the storm of + # add/remove signals during the model mutation. + with self.pipeline.paused(): + self.new_group = result.new_group + self.layer.add_child(self.new_group) + + # --- Bulk Reparenting to Prevent Signal Storm --- + # 1. Group items by their original parent. + items_by_parent = defaultdict(list) + for item in self.items_to_group: + if item.parent: + items_by_parent[item.parent].append(item) + + # 2. Remove items from old parents in batches. + for parent, items in items_by_parent.items(): + parent.remove_children(items) + + # 3. Add all items to the new group in one batch. + self.new_group.add_children(self.items_to_group) + + # 4. Set final local matrices *after* reparenting is complete. + for item in self.items_to_group: + item.matrix = result.child_matrices[item.uid] + + def undo(self) -> None: + """Reverts the grouping operation.""" + if not self.new_group: + return + + with self.pipeline.paused(): + # --- Bulk Reparenting for Undo --- + # 1. Remove all children from the group in one batch. + self.new_group.remove_children(self.items_to_group) + + # 2. Group items by their original parent for re-adding. + items_by_original_parent = defaultdict(list) + for item in self.items_to_group: + original_parent = self._original_parents.get(item.uid) + if original_parent: + items_by_original_parent[original_parent].append(item) + + # 3. Re-add items to their original parents in batches and + # restore their original matrices. + for parent, items in items_by_original_parent.items(): + parent.add_children(items) + for item in self.items_to_group: + item.matrix = self._original_matrices[item.uid] + + # 5. Finally, remove the now-empty group. + self.layer.remove_child(self.new_group) + + +class _UngroupCommand(Command): + """An undoable command to dissolve one or more Groups.""" + + def __init__( + self, + groups_to_ungroup: list[Group], + pipeline: "Pipeline", + name: str = "Ungroup Items", + precalculated_matrices: dict[str, dict[str, Matrix]] | None = None, + ): + super().__init__(name) + self.groups_to_ungroup = list(groups_to_ungroup) + self.pipeline = pipeline + self._precalculated_matrices = precalculated_matrices + self._undo_data = [] + for group in self.groups_to_ungroup: + if group.parent: + self._undo_data.append( + { + "group_uid": group.uid, + "group_matrix": group.matrix.copy(), + "parent": group.parent, + "group_index": group.parent.children.index(group), + "children": list(group.children), + "child_matrices": { + c.uid: c.matrix.copy() for c in group.children + }, + } + ) + + @staticmethod + def _calculate_ungroup_transforms( + group: Group, parent_inv_world: Matrix + ) -> dict[str, Matrix]: + """ + Calculates the new local matrices for a group's children using a + pre-calculated parent inverse transform. + """ + group_world_transform = group.get_world_transform() + new_child_matrices = {} + for child in group.children: + child_world_transform = group_world_transform @ child.matrix + new_child_matrices[child.uid] = ( + parent_inv_world @ child_world_transform + ) + return new_child_matrices + + def execute(self) -> None: + """Performs the ungrouping operation.""" + with self.pipeline.paused(): + for group in self.groups_to_ungroup: + parent = group.parent + if not parent: + continue + + try: + group_index = parent.children.index(group) + except ValueError: + continue # Should not happen + + children_to_move = list(group.children) + + if self._precalculated_matrices: + if group.uid not in self._precalculated_matrices: + continue + new_child_matrices = self._precalculated_matrices[ + group.uid + ] + else: + # This path is for safety but should not be used by the + # async command. + parent_inv = parent.get_world_transform().invert() + new_child_matrices = ( + _UngroupCommand._calculate_ungroup_transforms( + group, parent_inv + ) + ) + + # Set new matrices before reparenting + for child in children_to_move: + child.matrix = new_child_matrices[child.uid] + + parent.remove_child(group) + parent.add_children(children_to_move, index=group_index) + + def undo(self) -> None: + """Reverts the ungrouping by re-creating the original groups.""" + with self.pipeline.paused(): + for data in reversed(self._undo_data): + parent = data["parent"] + group_index = data["group_index"] + children = data["children"] + group = next( + ( + g + for g in self.groups_to_ungroup + if g.uid == data["group_uid"] + ), + None, + ) + if not group: + continue + + # Restore matrices first + group.matrix = data["group_matrix"] + for child in children: + child.matrix = data["child_matrices"][child.uid] + + # Move children from parent back into the group + parent.remove_children(children) + group.set_children(children) # Fast, as group starts empty + + # Add group back to its parent and restore its matrix + parent.add_child(group, index=group_index) + + +class GroupCmd: + """Handles grouping and ungrouping of document items.""" + + def __init__(self, editor: "DocEditor", task_manager: "TaskManager"): + self._editor = editor + self._task_manager = task_manager + + def group_items(self, layer: Layer, items_to_group: list[DocItem]): + """ + Creates and executes an undoable command to group items. This operation + runs as a background task. + """ + if not items_to_group: + return + + # Notify editor that we are starting a background task + self._editor.notify_task_started() + + async def group_coro(context: "ExecutionContext"): + context.set_message(_("Grouping items...")) + context.flush() + await asyncio.sleep(0.01) + + result = Group.create_from_items(items_to_group, layer) + + context.set_progress(1.0) + return result + + def when_done(task: "Task"): + try: + if task.get_status() != "completed": + logger.error( + "Group task did not complete successfully. Status: %s", + task.get_status(), + ) + return + + result: GroupingResult | None = task.result() + if not result: + return + + command = _CreateGroupCommand( + layer=layer, + items_to_group=items_to_group, + pipeline=self._editor.pipeline, + precalculated_result=result, + ) + self._editor.history_manager.execute(command) + finally: + # Always notify editor when done, even on failure + self._editor.notify_task_ended() + + self._task_manager.add_coroutine( + group_coro, + when_done=when_done, + key="group-items", + ) + + def ungroup_items(self, groups_to_ungroup: list[Group]): + """ + Creates and executes an undoable command to ungroup items. This + operation runs as a background task. + """ + if not groups_to_ungroup: + return + + # Notify editor that we are starting a background task + self._editor.notify_task_started() + + def do_calculation_sync() -> dict[str, dict[str, Matrix]]: + results = {} + parent_inverses: dict[str, Matrix] = {} + for group in groups_to_ungroup: + if group.parent and group.parent.uid not in parent_inverses: + parent_inverses[group.parent.uid] = ( + group.parent.get_world_transform().invert() + ) + + for group in groups_to_ungroup: + if group.parent: + parent_inv = parent_inverses[group.parent.uid] + new_matrices = ( + _UngroupCommand._calculate_ungroup_transforms( + group, parent_inv + ) + ) + results[group.uid] = new_matrices + return results + + async def ungroup_coro(context: "ExecutionContext"): + context.set_message(_("Ungrouping items...")) + context.flush() + await asyncio.sleep(0.01) + + calculated_matrices = do_calculation_sync() + + context.set_progress(1.0) + return calculated_matrices + + def when_done(task: "Task"): + try: + if task.get_status() != "completed": + logger.error( + "Ungroup task did not complete successfully. " + f"Status: {task.get_status()}", + ) + return + + calculated_matrices = task.result() + if not calculated_matrices: + return + + command = _UngroupCommand( + groups_to_ungroup=groups_to_ungroup, + pipeline=self._editor.pipeline, + precalculated_matrices=calculated_matrices, + ) + self._editor.history_manager.execute(command) + finally: + # Always notify editor when done, even on failure + self._editor.notify_task_ended() + + self._task_manager.add_coroutine( + ungroup_coro, + when_done=when_done, + key="ungroup-items", + ) diff --git a/rayforge/doceditor/layer_cmd.py b/rayforge/doceditor/layer_cmd.py new file mode 100644 index 000000000..61a8ce8d6 --- /dev/null +++ b/rayforge/doceditor/layer_cmd.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..core.color import pick_unused_color +from ..core.group import Group +from ..core.item import DocItem +from ..core.layer import Layer +from ..core.undo import ( + ChangePropertyCommand, + Command, +) +from ..core.undo.list_cmd import ReorderListCommand +from ..core.workpiece import WorkPiece + +if TYPE_CHECKING: + from ..ui_gtk.canvas2d.surface import WorkSurface + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class MoveWorkpiecesLayerCommand(Command): + """ + An undoable command to move one or more workpieces to a different layer. + """ + + def __init__( + self, + workpieces: list[WorkPiece], + new_layer: Layer, + old_layer: Layer, + name: str | None = None, + ): + super().__init__(name) + self.workpieces = workpieces + self.new_layer = new_layer + self.old_layer = old_layer + if not name: + self.name = _("Move to another layer") + + def _move(self, from_layer: Layer, to_layer: Layer): + """The core logic for moving workpieces, model-only.""" + # The UI will react to the model changes automatically through signals. + # The DocItem.add_child() method handles removing the child from its + # previous parent. + for wp in self.workpieces: + to_layer.add_child(wp) + + def execute(self): + """Executes the command, moving workpieces to the new layer.""" + self._move(self.old_layer, self.new_layer) + + def undo(self): + """Undoes the command, moving workpieces back to the old layer.""" + self._move(self.new_layer, self.old_layer) + + +class MoveItemsLayerCommand(Command): + """ + An undoable command to move one or more DocItems (workpieces or groups) + to a different layer. + """ + + def __init__( + self, + items: list[DocItem], + new_layer: Layer, + old_layer: Layer, + name: str | None = None, + ): + super().__init__(name) + self.items = items + self.new_layer = new_layer + self.old_layer = old_layer + if not name: + self.name = _("Move to another layer") + + def _move(self, from_layer: Layer, to_layer: Layer): + for item in self.items: + to_layer.add_child(item) + + def execute(self): + self._move(self.old_layer, self.new_layer) + + def undo(self): + self._move(self.new_layer, self.old_layer) + + +class AddLayerAndSetActiveCommand(Command): + """ + An undoable command to add a new layer and set it as the active layer. + """ + + def __init__( + self, + editor: DocEditor, + new_layer: Layer | None = None, + name: str = "Add layer", + ): + super().__init__(name=name) + self._editor = editor + self.new_layer = new_layer or self._create_default_layer() + self._old_active_layer: Layer | None = None + + def _create_default_layer(self) -> Layer: + """Creates a new layer with a default, unique name and color.""" + # Find a unique default name for the new layer + base_name = _("Layer") + existing_names = {layer.name for layer in self._editor.doc.layers} + highest_num = 0 + for name in existing_names: + if name.startswith(base_name): + try: + num_part = name[len(base_name) :].strip() + if num_part.isdigit(): + highest_num = max(highest_num, int(num_part)) + except ValueError: + continue # Ignore names that don't parse correctly + + new_name = f"{base_name} {highest_num + 1}" + layer = Layer(name=new_name) + + used = {layer.color for layer in self._editor.doc.layers} + layer.set_color(pick_unused_color(used)) + + return layer + + def execute(self): + """Adds the layer and makes it active.""" + self._old_active_layer = self._editor.doc.active_layer + new_list = self._editor.doc.layers + [self.new_layer] + cmd = ReorderListCommand( + target_obj=self._editor.doc, + list_property_name="layers", + new_list=new_list, + setter_method_name="set_layers", + ) + cmd.execute() + self._editor.doc.active_layer = self.new_layer + + def undo(self): + """Removes the layer and restores the previous active layer.""" + new_list = [ + g for g in self._editor.doc.layers if g is not self.new_layer + ] + cmd = ReorderListCommand( + target_obj=self._editor.doc, + list_property_name="layers", + new_list=new_list, + setter_method_name="set_layers", + ) + cmd.execute() + if self._old_active_layer in self._editor.doc.layers: + self._editor.doc.active_layer = self._old_active_layer + + +class LayerCmd: + """Handles commands related to layer manipulation.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def move_workpieces_to_layer( + self, workpieces: list[WorkPiece], target_layer: Layer + ): + """ + Creates an undoable command to move workpieces to a specific layer. + + Args: + workpieces: The workpieces to move. + target_layer: The layer to move them to. + """ + if not workpieces: + return + source_layer = workpieces[0].layer + if not source_layer or source_layer is target_layer: + return + cmd = MoveWorkpiecesLayerCommand( + workpieces, target_layer, source_layer + ) + self._editor.history_manager.execute(cmd) + + def move_selected_to_adjacent_layer( + self, surface: WorkSurface, direction: int + ): + """ + Creates an undoable command to move selected workpieces to the + next or previous valid (non-stock) layer, preserving the selection. + + Args: + surface: The WorkSurface instance containing the selection. + direction: 1 for the next layer (down), -1 for the previous (up). + """ + selected_wps = surface.get_selected_workpieces() + if not selected_wps: + return + + doc = self._editor.doc + workpiece_layers = list(doc.layers) + + if len(workpiece_layers) <= 1: + # Not enough valid layers to move between. + return + + # Assume all selected workpieces are on the same layer, which is a + # reasonable constraint for this operation. + current_layer = selected_wps[0].layer + if not current_layer: + return + + try: + # Find the index of the current layer within the *filtered* list. + current_index = workpiece_layers.index(current_layer) + + # Wrap around the filtered layer list. + new_index = ( + current_index + direction + len(workpiece_layers) + ) % len(workpiece_layers) + new_layer = workpiece_layers[new_index] + + # 1. Create the model-only command. + cmd = MoveWorkpiecesLayerCommand( + selected_wps, new_layer, current_layer + ) + + # 2. Execute the command. The history manager updates the model, + # which triggers signals that cause the UI to destructively + # rebuild the moved elements in a new layer element. + self._editor.history_manager.execute(cmd) + + # 3. After the model and UI have been updated, explicitly + # re-apply the selection to the newly created UI elements by + # telling the surface to select the same model objects again. + surface.select_items(selected_wps) + + except ValueError: + # This can happen if the current layer is not in the filtered list, + # which would be an inconsistent state, but we should handle it. + logger.warning( + f"Layer '{current_layer.name}' not found in document's " + "workpiece layer list." + ) + + def add_layer_and_set_active(self, new_layer: Layer | None = None): + """Adds a new layer to the document and sets it as the active layer.""" + cmd = AddLayerAndSetActiveCommand(self._editor, new_layer) + self._editor.history_manager.execute(cmd) + + def rename_layer(self, layer: Layer, new_name: str): + """Renames a layer with an undoable command.""" + if new_name == layer.name: + return + cmd = ChangePropertyCommand( + target=layer, + property_name="name", + new_value=new_name, + setter_method_name="set_name", + name=_("Rename layer"), + ) + self._editor.history_manager.execute(cmd) + + def set_layer_visibility(self, layer: Layer, visible: bool): + """Sets the visibility of a layer with an undoable command.""" + if visible == layer.visible: + return + cmd = ChangePropertyCommand( + target=layer, + property_name="visible", + new_value=visible, + setter_method_name="set_visible", + name=_("Toggle layer visibility"), + ) + self._editor.history_manager.execute(cmd) + + def set_active_layer(self, layer: Layer): + """Sets the active layer.""" + if self._editor.doc.active_layer is layer: + return + old_layer = self._editor.doc.active_layer + cmd = ChangePropertyCommand( + target=self._editor.doc, + property_name="active_layer", + new_value=layer, + old_value=old_layer, + name=_("Set active layer"), + ) + self._editor.history_manager.execute(cmd) + + def delete_layer(self, layer: Layer): + """Deletes a layer with an undoable command.""" + new_list = [g for g in self._editor.doc.layers if g is not layer] + cmd = ReorderListCommand( + target_obj=self._editor.doc, + list_property_name="layers", + new_list=new_list, + setter_method_name="set_layers", + name=_("Remove layer '{name}'").format(name=layer.name), + ) + self._editor.history_manager.execute(cmd) + + def reorder_layers(self, new_order: list[Layer]): + """Reorders layers with an undoable command.""" + cmd = ReorderListCommand( + target_obj=self._editor.doc, + list_property_name="layers", + new_list=new_order, + setter_method_name="set_layers", + ) + self._editor.history_manager.execute(cmd) + + def reorder_workpieces(self, layer: Layer, new_order: list[WorkPiece]): + """Reorders workpieces within a layer with an undoable command.""" + cmd = ReorderListCommand( + target_obj=layer, + list_property_name="workpieces", + new_list=new_order, + setter_method_name="reorder_workpieces", + name=_("Reorder workpieces"), + ) + self._editor.history_manager.execute(cmd) + + def move_items_to_layer(self, items: list[DocItem], target_layer: Layer): + """Creates an undoable command to move items to a specific layer.""" + if not items: + return + + by_layer: dict[Layer, list[DocItem]] = {} + for item in items: + if isinstance(item, (WorkPiece, Group)): + layer = item.layer + else: + continue + if not layer or layer is target_layer: + continue + by_layer.setdefault(layer, []).append(item) + + if not by_layer: + return + + history = self._editor.history_manager + with history.transaction(_("Move to another layer")) as t: + for source_layer, layer_items in by_layer.items(): + cmd = MoveItemsLayerCommand( + layer_items, target_layer, source_layer + ) + t.execute(cmd) + + def reorder_content_items(self, layer: Layer, new_order: list[DocItem]): + """Reorders content items within a layer with an undoable command.""" + cmd = ReorderListCommand( + target_obj=layer, + list_property_name="content_items", + new_list=new_order, + setter_method_name="reorder_content_items", + name=_("Reorder items"), + ) + self._editor.history_manager.execute(cmd) diff --git a/rayforge/doceditor/layout/__init__.py b/rayforge/doceditor/layout/__init__.py new file mode 100644 index 000000000..eb6886d53 --- /dev/null +++ b/rayforge/doceditor/layout/__init__.py @@ -0,0 +1,26 @@ +from .align import ( + BboxAlignBottomStrategy, + BboxAlignCenterStrategy, + BboxAlignLeftStrategy, + BboxAlignMiddleStrategy, + BboxAlignRightStrategy, + BboxAlignTopStrategy, + PositionAtStrategy, +) +from .auto import PixelPerfectLayoutStrategy +from .base import LayoutStrategy +from .spread import SpreadHorizontallyStrategy, SpreadVerticallyStrategy + +__all__ = [ + "BboxAlignBottomStrategy", + "BboxAlignCenterStrategy", + "BboxAlignLeftStrategy", + "BboxAlignMiddleStrategy", + "BboxAlignRightStrategy", + "BboxAlignTopStrategy", + "LayoutStrategy", + "PixelPerfectLayoutStrategy", + "PositionAtStrategy", + "SpreadHorizontallyStrategy", + "SpreadVerticallyStrategy", +] diff --git a/rayforge/doceditor/layout/align.py b/rayforge/doceditor/layout/align.py new file mode 100644 index 000000000..7b15a15e6 --- /dev/null +++ b/rayforge/doceditor/layout/align.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from raygeo.geo import Matrix + +from .base import LayoutStrategy + +if TYPE_CHECKING: + from ...core.item import DocItem + from ...shared.tasker.context import ExecutionContext + + +class BboxAlignLeftStrategy(LayoutStrategy): + """Aligns the left edges of the selection's bounding boxes.""" + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + target_x: float + if len(self.items) == 1: + # For a single item, align to the world origin's left edge. + target_x = 0.0 + else: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + target_x = bbox[0] # Align to selection's left edge + + deltas = {} + for wp in self.items: + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + continue + delta_x = target_x - wp_bbox[0] + if abs(delta_x) > 1e-6: + deltas[wp] = Matrix.translation(delta_x, 0) + return deltas + + +class BboxAlignCenterStrategy(LayoutStrategy): + """Horizontally centers the selection's bounding boxes.""" + + def __init__( + self, + items: Sequence[DocItem], + surface_width_mm: float | None = None, + ): + super().__init__(items) + self.surface_width_mm = surface_width_mm + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + target_center_x: float + if len(self.items) == 1 and self.surface_width_mm is not None: + target_center_x = self.surface_width_mm / 2 + else: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + target_center_x = bbox[0] + (bbox[2] - bbox[0]) / 2 + + deltas = {} + for wp in self.items: + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + continue + wp_center_x = wp_bbox[0] + (wp_bbox[2] - wp_bbox[0]) / 2 + delta_x = target_center_x - wp_center_x + if abs(delta_x) > 1e-6: + deltas[wp] = Matrix.translation(delta_x, 0) + return deltas + + +class BboxAlignRightStrategy(LayoutStrategy): + """Aligns the right edges of the selection's bounding boxes.""" + + def __init__( + self, + items: Sequence[DocItem], + surface_width_mm: float | None = None, + ): + super().__init__(items) + self.surface_width_mm = surface_width_mm + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + target_x: float + if len(self.items) == 1 and self.surface_width_mm is not None: + target_x = self.surface_width_mm + else: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + target_x = bbox[2] # Right edge of collective box + + deltas = {} + for wp in self.items: + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + continue + delta_x = target_x - wp_bbox[2] + if abs(delta_x) > 1e-6: + deltas[wp] = Matrix.translation(delta_x, 0) + return deltas + + +class BboxAlignTopStrategy(LayoutStrategy): + """Aligns the top edges of the selection's bounding boxes.""" + + def __init__( + self, + items: Sequence[DocItem], + surface_height_mm: float | None = None, + ): + super().__init__(items) + self.surface_height_mm = surface_height_mm + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + target_y: float + if len(self.items) == 1 and self.surface_height_mm is not None: + target_y = self.surface_height_mm + else: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + target_y = bbox[3] # Top edge of collective box + + deltas = {} + for wp in self.items: + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + continue + delta_y = target_y - wp_bbox[3] + if abs(delta_y) > 1e-6: + deltas[wp] = Matrix.translation(0, delta_y) + return deltas + + +class BboxAlignMiddleStrategy(LayoutStrategy): + """Vertically centers the selection's bounding boxes.""" + + def __init__( + self, + items: Sequence[DocItem], + surface_height_mm: float | None = None, + ): + super().__init__(items) + self.surface_height_mm = surface_height_mm + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + target_center_y: float + if len(self.items) == 1 and self.surface_height_mm is not None: + target_center_y = self.surface_height_mm / 2 + else: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + target_center_y = bbox[1] + (bbox[3] - bbox[1]) / 2 + + deltas = {} + for wp in self.items: + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + continue + wp_center_y = wp_bbox[1] + (wp_bbox[3] - wp_bbox[1]) / 2 + delta_y = target_center_y - wp_center_y + if abs(delta_y) > 1e-6: + deltas[wp] = Matrix.translation(0, delta_y) + return deltas + + +class BboxAlignBottomStrategy(LayoutStrategy): + """Aligns the bottom edges of the selection's bounding boxes.""" + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + target_y: float + if len(self.items) == 1: + target_y = 0.0 + else: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + target_y = bbox[1] # Bottom edge of collective box + + deltas = {} + for wp in self.items: + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + continue + delta_y = target_y - wp_bbox[1] + if abs(delta_y) > 1e-6: + deltas[wp] = Matrix.translation(0, delta_y) + return deltas + + +class PositionAtStrategy(LayoutStrategy): + """ + Positions the center of the selection's bounding box at a specific point. + """ + + def __init__( + self, + items: Sequence[DocItem], + position_mm: tuple[float, float], + ): + super().__init__(items) + self.position_mm = position_mm + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + bbox = self._get_selection_world_bbox() + if not bbox: + return {} + + min_x, min_y, max_x, max_y = bbox + target_x, target_y = self.position_mm + + # Calculate current center of the bounding box + current_center_x = min_x + (max_x - min_x) / 2 + current_center_y = min_y + (max_y - min_y) / 2 + + # Calculate translation to move center to target position + delta_x = target_x - current_center_x + delta_y = target_y - current_center_y + + deltas = {} + if abs(delta_x) > 1e-6 or abs(delta_y) > 1e-6: + translation_matrix = Matrix.translation(delta_x, delta_y) + # All items get the same matrix to move them as a group + for item in self.items: + deltas[item] = translation_matrix + return deltas diff --git a/rayforge/doceditor/layout/auto.py b/rayforge/doceditor/layout/auto.py new file mode 100644 index 000000000..858d72775 --- /dev/null +++ b/rayforge/doceditor/layout/auto.py @@ -0,0 +1,883 @@ +""" +Implements a pixel-based layout strategy for dense packing of workpieces. +""" + +from __future__ import annotations + +import logging +import math +from collections.abc import Sequence +from dataclasses import dataclass +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, +) + +import cairo +import numpy as np +from raygeo.geo import Matrix +from raygeo.geo.types import Point, Rect +from scipy.ndimage import binary_dilation +from scipy.signal import fftconvolve + +from ...context import get_context +from ...core.group import Group +from ...core.item import DocItem +from ...core.stock import StockItem +from ...core.workpiece import WorkPiece +from ...image.geo_renderer import geometry_to_cairo +from .base import LayoutStrategy + +if TYPE_CHECKING: + from ...shared.tasker.context import ExecutionContext + + +logger = logging.getLogger(__name__) + + +@dataclass +class WorkpieceVariant: + """Represents a pre-rendered, rotated version of a DocItem.""" + + item: DocItem # The original DocItem (WorkPiece or Group) + mask: np.ndarray # Dilated mask for collision detection + local_bbox: Rect # Bbox in local coords + angle_offset: int # Rotation applied to create this variant + unrotated_size_mm: tuple[float, float] # The size of source shape + + +@dataclass +class PlacedItem: + """Represents a workpiece variant placed on the packing canvas.""" + + variant: WorkpieceVariant + position_px: tuple[int, int] # (y, x) position on canvas + + +class PixelPerfectLayoutStrategy(LayoutStrategy): + """ + Arranges workpieces for maximum density using their rendered shapes. + + This strategy operates in three main phases: + 1. **Preparation**: Each workpiece is rendered into a pixel mask for + each allowed rotation. A margin is added by dilating the mask. + 2. **Packing**: The masks are placed one-by-one onto a large virtual + canvas using a greedy first-fit algorithm. The goal is to keep + the total bounding box of all placed items as small as possible. + 3. **Transformation**: The final pixel positions are translated back + into world-coordinate transformation matrices for each workpiece. + """ + + def __init__( + self, + items: Sequence[DocItem], + margin_mm: float = 0.5, + resolution_px_per_mm: float = 8.0, + allow_rotation: bool = True, + **kwargs, + ): + """ + Initializes pixel-perfect layout strategy. + + Args: + items: The list of DocItems to arrange. + margin_mm: The safety margin to add around each workpiece. + resolution_px_per_mm: The resolution for rendering shapes. + Higher values lead to more accurate but slower packing. + allow_rotation: Whether to allow 90-degree rotations. + """ + super().__init__(items, **kwargs) + self.margin_mm = margin_mm + self.resolution = resolution_px_per_mm + self.allow_rotation = allow_rotation + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + """ + Calculates the transform for each workpiece for a dense layout. The + final arrangement is centered relative to the center of the initial + selection's bounding box. + """ + if not self.items: + return {} + + logger.info("Starting pixel-perfect layout...") + + if context: + context.set_message("Preparing workpiece variants...") + + prepared_items, _total_area = self._prepare_variants() + if not prepared_items: + self.unplaced_items = list(self.items) + return {} + + if context: + context.set_progress(0.1) + + # Stock-aware Logic + stock_item: StockItem | None = None + stock_bbox = None + doc = self.items[0].doc + if doc: + visible_stocks = [s for s in doc.stock_items if s.visible] + if visible_stocks: + stock_item = visible_stocks[0] + + placements: list[PlacedItem] = [] + group_offset = (0.0, 0.0) + canvas_h_mm = 0.0 + canvas_h_px = 0 + actual_canvas_h_px = 0 + actual_canvas_h_mm = 0.0 + + # Use stock as boundary if it exists, otherwise use whole surface. + if stock_item: + logger.info("Stock item found, using it as layout boundary.") + if context: + context.set_message("Using stock as boundary...") + + stock_bbox = self._get_item_world_bbox(stock_item) + if stock_bbox: + canvas_origin_world = (stock_bbox[0], stock_bbox[1]) + canvas_w_mm = stock_bbox[2] - stock_bbox[0] + canvas_h_mm = stock_bbox[3] - stock_bbox[1] + canvas_w_px = round(canvas_w_mm * self.resolution) + canvas_h_px = round(canvas_h_mm * self.resolution) + + allowed_area_mask = self._render_stock_to_mask( + stock_item, canvas_w_px, canvas_h_px, canvas_origin_world + ) + canvas = np.logical_not(allowed_area_mask) + group_offset = canvas_origin_world + actual_canvas_h_px = canvas.shape[0] + actual_canvas_h_mm = canvas_h_mm + + placements, self.unplaced_items = self._pack_items( + prepared_items, canvas, context + ) + else: + logger.warning("Could not get stock bbox, falling back.") + + if not stock_item or stock_bbox is None: + # Use whole surface (machine work area) as boundary + logger.info("Using whole surface as layout boundary.") + if context: + context.set_message("Using whole surface as boundary...") + + # Get machine work area + wa_w, wa_h = 200.0, 200.0 # Fallback + machine = get_context().machine + if machine: + work_area = machine.work_area + wa_w, wa_h = work_area[2], work_area[3] + ref_x, ref_y = machine.panel.reference_position_world + canvas_origin_world = machine.panel.world_position_from_origin( + ref_x, ref_y, (wa_w, wa_h) + ) + else: + canvas_origin_world = (0.0, 0.0) + canvas_w_mm, canvas_h_mm = wa_w, wa_h + canvas_w_px = round(canvas_w_mm * self.resolution) + canvas_h_px = round(canvas_h_mm * self.resolution) + + # Create a full mask for the entire machine surface + allowed_area_mask = np.ones((canvas_h_px, canvas_w_px), dtype=bool) + # Initialize the canvas with all areas marked as valid + canvas = np.zeros((canvas_h_px, canvas_w_px), dtype=bool) + group_offset = canvas_origin_world + actual_canvas_h_px = canvas.shape[0] + actual_canvas_h_mm = canvas_h_mm + + placements, self.unplaced_items = self._pack_items( + prepared_items, canvas, context + ) + + if self.unplaced_items: + item_names = ", ".join(item.name for item in self.unplaced_items) + message = _( + "Could not fit the following items: {item_names}" + ).format(item_names=item_names) + self.error_reported.send(self, message=message) + + if context: + context.set_progress(0.9) + context.set_message("Calculating final positions...") + + # 5. Compute the final transformation deltas for successfully + # placed items. + deltas = self._compute_deltas_from_placements( + placements, group_offset, actual_canvas_h_px, actual_canvas_h_mm + ) + + # 6. If any items were unplaced and stock exists, move them + # outside the stock area. + if self.unplaced_items and stock_item and stock_bbox: + logger.info( + f"Moving {len(self.unplaced_items)} unplaced items " + "outside stock area." + ) + # Calculate a collective bounding box for all unplaced items + unplaced_bboxes = [ + self._get_item_world_bbox(item) for item in self.unplaced_items + ] + valid_bboxes = [b for b in unplaced_bboxes if b] + if valid_bboxes: + min_x = min(b[0] for b in valid_bboxes) + min_y = min(b[1] for b in valid_bboxes) + max_x = max(b[2] for b in valid_bboxes) + max_y = max(b[3] for b in valid_bboxes) + unplaced_coll_bbox = (min_x, min_y, max_x, max_y) + + # Determine the target position for the collective bbox's + # top-left corner. + target_x = stock_bbox[2] + self.margin_mm * 4 + target_y = stock_bbox[3] + + # Calculate a single (dx, dy) offset for the whole group + dx = target_x - unplaced_coll_bbox[0] + dy = target_y - unplaced_coll_bbox[3] + + for item in self.unplaced_items: + # Reset rotation and apply to collective translation + old_world_transform = item.get_world_transform() + tx_old, ty_old = old_world_transform.decompose()[:2] + + final_x = tx_old + dx + final_y = ty_old + dy + + T = Matrix.translation(final_x, final_y) + scale_w, scale_h = old_world_transform.get_abs_scale() + S = Matrix.scale(scale_w, scale_h) + final_matrix = T @ S # Rotation is reset to identity + + # Calculate the local delta to achieve this + old_local_matrix = item.matrix + if old_local_matrix.has_zero_scale(): + continue + old_local_inv = old_local_matrix.invert() + parent_inv = Matrix.identity() + if item.parent: + parent_tfm = item.parent.get_world_transform() + if not parent_tfm.has_zero_scale(): + parent_inv = parent_tfm.invert() + + delta = parent_inv @ final_matrix @ old_local_inv + deltas[item] = delta + + logger.info("Pixel-perfect layout complete.") + return deltas + + def _render_stock_to_mask( + self, + stock_item: StockItem, + width_px: int, + height_px: int, + canvas_origin_world: Point, + ) -> np.ndarray: + """ + Renders the stock's transformed geometry to a boolean mask that + defines the valid area for packing. + + Args: + stock_item: The stock item to render. + width_px: The width of the target canvas in pixels. + height_px: The height of the target canvas in pixels. + canvas_origin_world: The world coordinates of the canvas origin. + + Returns: + A 2D boolean numpy array where True represents a valid area. + """ + # 1. Get the transform that maps the stock's local geometry space to + # world, then to the canvas's local pixel space. + world_geo = stock_item.get_world_geometry() + if world_geo.is_empty(): + world_geo = stock_item.get_world_rect_geometry() + if world_geo.is_empty(): + return np.zeros((height_px, width_px), dtype=bool) + + logger.debug( + f"Stock mask: canvas_origin={canvas_origin_world}, " + f"geo_rect={world_geo.rect()}" + ) + + translation_to_canvas = Matrix.translation( + -canvas_origin_world[0], -canvas_origin_world[1] + ) + + # 2. Apply this transform to a copy of the geometry. + geometry_for_render = world_geo.copy() + geometry_for_render.transform(translation_to_canvas) + + # 3. Render the transformed geometry onto a cairo surface. + surface = cairo.ImageSurface(cairo.FORMAT_A8, width_px, height_px) + ctx = cairo.Context(surface) + ctx.set_source_rgb(1, 1, 1) # Use white for valid area + ctx.scale(self.resolution, self.resolution) # Scale context to mm + + # Draw path from geometry data. + geometry_to_cairo(geometry_for_render, ctx) + ctx.fill() + + # 4. Extract the pixel data into a NumPy array. + buf = surface.get_data() + mask = np.frombuffer(buf, dtype=np.uint8).reshape( + (height_px, surface.get_stride()) + ) + # We flip Y-axis (np.flipud) because Cairo's origin is top-left, + # while our application's world space is bottom-left. + return np.flipud(mask[:, :width_px] > 0) + + def _prepare_variants( + self, + ) -> tuple[list[list[WorkpieceVariant]], int]: + """ + Generates rotated and dilated masks for all DocItems. + + Returns: + A tuple containing: + - A list of item groups, where each group is a list of + variants (rotations) for a single workpiece, sorted by size. + - The total pixel area of all dilated masks. + """ + groups = [] + total_area_px = 0 + rotations = [0, 90, 180, 270] if self.allow_rotation else [0] + margin_px = int(self.margin_mm * self.resolution) + + for item in self.items: + variants = [] + for angle in rotations: + render = self._render_and_mask(item, angle) + if not (render and np.sum(render[0]) > 0): + continue + + mask, local_bbox, unrotated_size = render + + if margin_px > 0: + # Pad the mask array to create physical space for the + # margin. The dilated mask will be larger than the + # original mask. + padded_mask = np.pad( + mask, + pad_width=margin_px, + mode="constant", + constant_values=False, + ) + # Dilate the padded mask. Using iterations is an efficient + # way to expand the shape by `margin_px` pixels. + # The default 3x3 cross-shaped structure is used. + dilated_mask = binary_dilation( + padded_mask, iterations=margin_px + ) + else: + dilated_mask = mask + + variants.append( + WorkpieceVariant( + item, dilated_mask, local_bbox, angle, unrotated_size + ) + ) + total_area_px += np.sum(dilated_mask) + + if variants: + groups.append(variants) + + # Sort workpieces by the max dimension of their first variant's mask + # (heuristic for placing largest items first). + groups.sort(key=lambda v_group: -max(v_group[0].mask.shape)) + return groups, int(total_area_px) + + def _create_packing_canvas( + self, total_area_px: int, items: list[list[WorkpieceVariant]] + ) -> np.ndarray: + """ + Creates a boolean numpy array to serve as the packing surface. + + Args: + total_area_px: The sum of the pixel areas of all items. + items: The prepared workpiece variants. + + Returns: + A 2D boolean numpy array initialized to False. + """ + # Estimate canvas side length with a 50% buffer for inefficiency. + canvas_side = math.ceil(math.sqrt(total_area_px * 1.5)) + # Ensure canvas is at least as large as the largest item. + max_dim = max(items[0][0].mask.shape) if items else 0 + canvas_h = canvas_w = max(canvas_side, max_dim) + 1 + return np.full((canvas_h, canvas_w), False, dtype=bool) + + def _pack_items( + self, + item_groups: list[list[WorkpieceVariant]], + canvas: np.ndarray, + context: ExecutionContext | None = None, + ) -> tuple[list[PlacedItem], list[DocItem]]: + """ + Places workpiece variants onto the canvas greedily. + + Args: + item_groups: A list of variant lists, one for each workpiece. + canvas: The 2D numpy array to pack items onto. + context: The execution context for reporting progress. + + Returns: + A tuple containing: + - A list of final `PlacedItem` instances. + - A list of `DocItem`s that could not be placed. + """ + placements: list[PlacedItem] = [] + placed_bounds_px: list[tuple[int, int, int, int]] = [] + # Create a dictionary of all items to be placed, for easy removal. + item_dict = {group[0].item.uid: group[0].item for group in item_groups} + total_items = len(item_groups) + + for i, variants in enumerate(item_groups): + item_obj = variants[0].item + logger.debug(f"Placing item: {item_obj.name}") + + placement = self._find_best_placement( + variants, canvas, placed_bounds_px + ) + + if placement: + item, pos = placement.variant, placement.position_px + y_px, x_px = pos + h_px, w_px = item.mask.shape + + canvas[y_px : y_px + h_px, x_px : x_px + w_px] |= item.mask + placed_bounds_px.append((x_px, y_px, x_px + w_px, y_px + h_px)) + placements.append(placement) + # Remove successfully placed item from the dictionary. + del item_dict[item_obj.uid] + + if context: + # Calculate progress within 0.1 to 0.9 range allocated + # for the packing phase (an 80% span). + pack_progress = (i + 1) / total_items + total_progress = 0.1 + (pack_progress * 0.8) + context.set_progress(total_progress) + context.set_message( + f"Packing item {i + 1} of {total_items}..." + ) + else: + logger.warning(f"Could not place item {item_obj.name}.") + + # Any items remaining in the dictionary are the ones that failed. + unplaced_items = list(item_dict.values()) + return placements, unplaced_items + + @staticmethod + def _get_placement_bounds( + placement: PlacedItem, + ) -> tuple[int, int, int, int]: + """Calculates the (x0, y0, x1, y1) bounds of a placed item.""" + y_px, x_px = placement.position_px + h_px, w_px = placement.variant.mask.shape + return (x_px, y_px, x_px + w_px, y_px + h_px) + + def _find_best_placement( + self, + variants: list[WorkpieceVariant], + canvas: np.ndarray, + placed_bounds: list[tuple[int, int, int, int]], + ) -> PlacedItem | None: + """ + Finds the best rotation and position for an item. + + The "best" placement is the one that results in the smallest + overall bounding box for all items placed so far. + + Args: + variants: A list of possible rotations for a workpiece. + canvas: The packing canvas. + placed_bounds: A list of bounding boxes for already-placed items. + + Returns: + The best `PlacedItem` if a fit is found, otherwise None. + """ + best_fit: dict | None = None + best_score = float("inf") + + for variant in variants: + pos_px = self._find_first_fit(canvas, variant.mask) + if not pos_px: + continue + + # Score the placement by the area of the new total bounding box. + score = self._calculate_placement_score( + pos_px, variant.mask.shape, placed_bounds + ) + + if score < best_score: + best_score = score + best_fit = {"pos": pos_px, "variant": variant} + + if best_fit: + logger.debug( + f" - Best fit: offset {best_fit['variant'].angle_offset}°, " + f"pos {best_fit['pos']}, score {best_score:.0f}" + ) + return PlacedItem( + variant=best_fit["variant"], position_px=best_fit["pos"] + ) + return None + + @staticmethod + def _calculate_placement_score( + pos_px: tuple[int, int], + mask_shape: tuple[int, int], + placed_bounds: list[tuple[int, int, int, int]], + ) -> float: + """ + Calculates the area of the bounding box of a potential placement. + + Args: + pos_px: The (y, x) position of the new item's top-left corner. + mask_shape: The (h, w) shape of the new item's mask. + placed_bounds: Bboxes of items already on the canvas, as + (x0, y0, x1, y1) tuples. + + Returns: + The total area of the new combined bounding box. + """ + y_px, x_px = pos_px + h_px, w_px = mask_shape + temp_bounds = placed_bounds + [(x_px, y_px, x_px + w_px, y_px + h_px)] + min_x = min(b[0] for b in temp_bounds) + min_y = min(b[1] for b in temp_bounds) + max_x = max(b[2] for b in temp_bounds) + max_y = max(b[3] for b in temp_bounds) + return (max_x - min_x) * (max_y - min_y) + + def _compute_deltas_from_placements( + self, + placements: list[PlacedItem], + group_offset: Point, + canvas_h_px: int, + canvas_h_mm: float, + ) -> dict[DocItem, Matrix]: + """ + Converts a list of pixel placements into transform deltas. + + Args: + placements: The list of `PlacedItem`s. + group_offset: The (x, y) world coordinate of the packing origin. + canvas_h_px: The canvas height in pixels. + canvas_h_mm: The canvas height in mm. + + Returns: + A dictionary mapping each DocItem to its required delta matrix. + """ + deltas: dict[DocItem, Matrix] = {} + if not placements: + return deltas + for item in placements: + doc_item, delta = self._create_delta_for_placement( + item, group_offset, canvas_h_px, canvas_h_mm + ) + deltas[doc_item] = delta + return deltas + + def _create_delta_for_placement( + self, + item: PlacedItem, + group_offset: Point, + canvas_h_px: int, + canvas_h_mm: float, + ) -> tuple[DocItem, Matrix]: + """ + Calculates the final matrix and delta for a single placed item. + + Args: + item: The `PlacedItem` to process. + group_offset: The (x, y) world coordinate of the packing origin. + canvas_h_px: The canvas height in pixels. + canvas_h_mm: The canvas height in mm. + + Returns: + A tuple of (DocItem, delta_Matrix). + """ + doc_item = item.variant.item + y_px, x_px = item.position_px + margin_px = int(self.margin_mm * self.resolution) + group_offset_x, group_offset_y = group_offset + + # 1. Calculate the final position of the rotated bbox corner in world + # space + total_margin_px = 2 * margin_px + true_x_px = x_px + total_margin_px + true_y_px = y_px + total_margin_px + + px_to_mm_y = canvas_h_mm / canvas_h_px if canvas_h_px > 0 else 0.0 + flipped_y_mm = (canvas_h_px - 1 - true_y_px) * px_to_mm_y + + if isinstance(doc_item, Group): + # A Group is a rigid body. Calculate a pure + # rotation/translation delta to move it from its old state to + # the new packed state without altering its internal scale/shear. + W_old = doc_item.get_world_transform() + _, _, angle_old, _, _, _ = W_old.decompose() + old_bbox = self._get_item_world_bbox(doc_item) + if not old_bbox: + return doc_item, Matrix.identity() + C_old = ( + (old_bbox[0] + old_bbox[2]) / 2, + (old_bbox[1] + old_bbox[3]) / 2, + ) + + target_angle = item.variant.angle_offset + angle_delta = target_angle - angle_old + + rotated_bbox = item.variant.local_bbox + w_mm = rotated_bbox[2] - rotated_bbox[0] + h_mm = rotated_bbox[3] - rotated_bbox[1] + C_new = ( + group_offset_x + + (true_x_px + w_mm * self.resolution / 2) / self.resolution, + group_offset_y + flipped_y_mm - h_mm / 2, + ) + + # Create a world-space delta transform: translate, then rotate + delta_T = Matrix.translation( + C_new[0] - C_old[0], C_new[1] - C_old[1] + ) + delta_R = Matrix.rotation(angle_delta, center=C_old) + delta_world = delta_T @ delta_R + + final_matrix = delta_world @ W_old + else: + # For a WorkPiece, reconstruct its transform from scratch. + packed_x = group_offset_x + (true_x_px / self.resolution) + packed_y = group_offset_y + flipped_y_mm + bbox_off_x = item.variant.local_bbox[0] + bbox_off_y = item.variant.local_bbox[3] + final_x = packed_x - bbox_off_x + final_y = packed_y - bbox_off_y + T = Matrix.translation(final_x, final_y) + target_angle = item.variant.angle_offset + w_mm, h_mm = item.variant.unrotated_size_mm + S = Matrix.scale(w_mm, h_mm) + center_for_rot = (w_mm / 2, h_mm / 2) + R = Matrix.rotation(target_angle, center=center_for_rot) + final_matrix = T @ R @ S + + # 6. Calculate the delta required to achieve this new world matrix. + # W_new = P @ (Delta @ L_old) => Delta = P_inv @ W_new @ L_old_inv + old_local_matrix = doc_item.matrix + if old_local_matrix.has_zero_scale(): + logger.warning(f"Item {doc_item.name} has zero scale, skipping.") + return doc_item, Matrix.identity() + old_local_inv = old_local_matrix.invert() + + parent_inv = Matrix.identity() + if doc_item.parent: + parent_world_transform = doc_item.parent.get_world_transform() + if not parent_world_transform.has_zero_scale(): + parent_inv = parent_world_transform.invert() + + delta = parent_inv @ final_matrix @ old_local_inv + return doc_item, delta + + def _render_and_mask( + self, item: DocItem, angle_offset: int + ) -> tuple[np.ndarray, Rect, tuple[float, float]] | None: + """ + Renders a DocItem to a pixel mask at a specific orientation. + + Returns a tuple: (mask, local_bbox_of_rotated_shape, + unrotated_shape_size). + """ + source_surface: cairo.ImageSurface | None = None + unrotated_w_mm, unrotated_h_mm = 0.0, 0.0 + + if isinstance(item, WorkPiece): + unrotated_w_mm, unrotated_h_mm = ( + item.get_world_transform().get_abs_scale() + ) + if unrotated_w_mm <= 0 or unrotated_h_mm <= 0: + return None + # Use the item's own render method which now delegates to the hub + source_surface = item.render_to_pixels( + width=int(unrotated_w_mm * self.resolution), + height=int(unrotated_h_mm * self.resolution), + ) + elif isinstance(item, Group): + # For a group, render its contents based on its world AABB. + bbox = self._get_item_world_bbox(item) + if not bbox: + return None + min_x_world, min_y_world, max_x_world, max_y_world = bbox + unrotated_w_mm = max_x_world - min_x_world + unrotated_h_mm = max_y_world - min_y_world + + if unrotated_w_mm <= 0 or unrotated_h_mm <= 0: + return None + + width_px = int(unrotated_w_mm * self.resolution) + height_px = int(unrotated_h_mm * self.resolution) + source_surface = cairo.ImageSurface( + cairo.FORMAT_A8, width_px, height_px + ) + ctx = cairo.Context(source_surface) + + for wp in item.get_descendants(of_type=WorkPiece): + ctx.save() + wp_w, wp_h = wp.get_world_transform().get_abs_scale() + if wp_w <= 0 or wp_h <= 0: + ctx.restore() + continue + + wp_surf = wp.render_to_pixels( + width=int(wp_w * self.resolution), + height=int(wp_h * self.resolution), + ) + if not wp_surf: + ctx.restore() + continue + + # Get the workpiece's world transform. + world_transform = wp.get_world_transform() + + # Robustly get child's world center and map it to the + # Y-down group canvas to create an accurate snapshot. + wp_bbox = self._get_item_world_bbox(wp) + if not wp_bbox: + ctx.restore() + continue + wp_center_x = (wp_bbox[0] + wp_bbox[2]) / 2 + wp_center_y = (wp_bbox[1] + wp_bbox[3]) / 2 + + x_pos_px = (wp_center_x - min_x_world) * self.resolution + y_pos_px = (max_y_world - wp_center_y) * self.resolution + + _, _, angle, _, _, _ = world_transform.decompose() + + # Standard translate-rotate-translate pattern around the center + ctx.translate(x_pos_px, y_pos_px) + ctx.rotate( + -math.radians(angle) + ) # Negate for Cairo's clockwise + ctx.translate( + -wp_surf.get_width() / 2, -wp_surf.get_height() / 2 + ) + + ctx.set_source_surface(wp_surf, 0, 0) + ctx.paint() + ctx.restore() + + if not source_surface: + return None + + # The rest of the logic rotates this source surface + transform = Matrix.rotation( + angle_offset, center=(unrotated_w_mm / 2, unrotated_h_mm / 2) + ) + corners = [ + (0, 0), + (unrotated_w_mm, 0), + (unrotated_w_mm, unrotated_h_mm), + (0, unrotated_h_mm), + ] + world_corners = [transform.transform_point(p) for p in corners] + min_x, min_y = ( + min(p[0] for p in world_corners), + min(p[1] for p in world_corners), + ) + max_x, max_y = ( + max(p[0] for p in world_corners), + max(p[1] for p in world_corners), + ) + local_bbox = (min_x, min_y, max_x, max_y) + + width_mm, height_mm = max_x - min_x, max_y - min_y + if width_mm <= 0 or height_mm <= 0: + return None + width_px, height_px = ( + round(width_mm * self.resolution), + round(height_mm * self.resolution), + ) + if not source_surface: + return None + + # 3. Create a destination surface and draw the rotated source onto it. + final_surface = cairo.ImageSurface( + cairo.FORMAT_A8, width_px, height_px + ) + ctx = cairo.Context(final_surface) + src_w, src_h = source_surface.get_width(), source_surface.get_height() + + # Center the rotated image via translate-rotate-translate. + ctx.translate(width_px / 2, height_px / 2) + ctx.rotate(-math.radians(angle_offset)) + ctx.translate(-src_w / 2, -src_h / 2) + ctx.set_source_surface(source_surface, 0, 0) + ctx.paint() + + # 4. Extract the mask data from the cairo surface into a numpy array. + buf = final_surface.get_data() + mask = np.frombuffer(buf, dtype=np.uint8).reshape( + (height_px, final_surface.get_stride()) + ) + # We only care about the actual width, not the stride. + mask = mask[:, :width_px] > 0 + return np.flipud(mask), local_bbox, (unrotated_w_mm, unrotated_h_mm) + + @staticmethod + def _find_first_fit( + canvas: np.ndarray, item_mask: np.ndarray + ) -> tuple[int, int] | None: + """ + Finds the first top-left position where an item fits on the canvas. + + This method uses FFT-based convolution to quickly find all + collision-free locations, then returns the first one (top-most, then + left-most). This is a significant optimization over a naive + pixel-by-pixel scan, especially on large canvases. + + Args: + canvas: The boolean 2D array representing occupied space. + item_mask: The boolean 2D array of the item to place. + + Returns: + A tuple (y, x) of the top-left corner for placement, or None + if no fit is found. + """ + canvas_h, canvas_w = canvas.shape + item_h, item_w = item_mask.shape + + if item_h > canvas_h or item_w > canvas_w: + return None + + # The core of the check is a 2D cross-correlation: + # result(y, x) = sum(canvas[y:y+h, x:x+w] * item_mask) + # We look for a (y,x) where the result is 0. + # fftconvolve computes convolution, which is correlation with a + # flipped kernel. + # We use floating point numbers for fftconvolve performance. + canvas_f = canvas.astype(np.float32) + # The kernel must be flipped for cross-correlation. + item_mask_f = np.flip(item_mask.astype(np.float32)) + + # `mode='valid'` ensures the output size is correct for checking + # every possible top-left placement. The result is a map where each + # pixel value is the sum of products of overlapping areas. + collision_map = fftconvolve(canvas_f, item_mask_f, mode="valid") + + # Due to floating point inaccuracies, results may not be exactly zero. + # We round to the nearest integer to check for collisions. A collision + # exists if the sum of overlapping pixels is > 0. + collision_map_int = np.round(collision_map).astype(np.int32) + + # Find the coordinates of the first zero (no collision). + # np.argwhere finds all non-zero elements. We want the first zero. + potential_fits = np.argwhere(collision_map_int == 0) + + if potential_fits.size > 0: + # np.argwhere returns results sorted first by row, then by column, + # so the first result is the top-most, left-most fit. + y, x = potential_fits[0] + return int(y), int(x) + + return None diff --git a/rayforge/doceditor/layout/base.py b/rayforge/doceditor/layout/base.py new file mode 100644 index 000000000..962fb3e2c --- /dev/null +++ b/rayforge/doceditor/layout/base.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from blinker import Signal +from raygeo.geo import Matrix +from raygeo.geo.types import Rect + +from ...core.group import Group +from ...core.item import DocItem +from ...core.layer import Layer +from ...core.stock import StockItem +from ...core.workpiece import WorkPiece + +if TYPE_CHECKING: + from ...shared.tasker.context import ExecutionContext + from ...shared.tasker.manager import TaskManager + + +class LayoutStrategy(ABC): + """ + Abstract base class for alignment and distribution strategies. + + Each strategy calculates the necessary transformation deltas to apply + to a list of DocItems to achieve a specific layout. + """ + + def __init__(self, items: Sequence[DocItem], **kwargs): + if not items: + raise ValueError("LayoutStrategy requires at least one item.") + # Filter out items that are descendants of other items in the selection + # to avoid applying transformations multiple times up the hierarchy. + self.items = self._filter_descendants(list(items)) + if not self.items: + raise ValueError( + "LayoutStrategy requires at least one item after filtering." + ) + self.error_reported = Signal() + + @staticmethod + def _filter_descendants(items: Sequence[DocItem]) -> list[DocItem]: + """ + Given a list of DocItems, returns a new list containing only the + top-level items from the original list. If an item is a descendant + of another item in the list, it is excluded. + """ + # Create a set of all items for efficient lookup. + item_set = set(items) + top_level_items = [] + + for item in items: + is_descendant = False + p = item.parent + while p: + if p in item_set: + is_descendant = True + break + p = p.parent + if not is_descendant: + top_level_items.append(item) + return top_level_items + + @staticmethod + def _get_item_world_bbox( + item: DocItem, + ) -> Rect | None: + """ + Calculates the axis-aligned bounding box (min_x, min_y, max_x, max_y) + of a single DocItem (WorkPiece, Group, or StockItem) in world (mm) + coordinates. + """ + + items_to_measure = [] + if isinstance(item, WorkPiece): + items_to_measure.append(item) + elif isinstance(item, (Group, Layer)): + items_to_measure.extend(item.get_descendants(of_type=WorkPiece)) + elif isinstance(item, StockItem): + items_to_measure.append(item) + else: + return None + + if not items_to_measure: + return None + + all_corners = [] + for sub_item in items_to_measure: + transform = sub_item.get_world_transform() + # Each workpiece's local geometry is a 1x1 unit square + local_corners = [(0, 0), (1, 0), (1, 1), (0, 1)] + all_corners.extend( + [transform.transform_point(p) for p in local_corners] + ) + + if not all_corners: + return None + + min_x = min(p[0] for p in all_corners) + min_y = min(p[1] for p in all_corners) + max_x = max(p[0] for p in all_corners) + max_y = max(p[1] for p in all_corners) + return (min_x, min_y, max_x, max_y) + + def _get_selection_world_bbox( + self, + ) -> Rect | None: + """ + Calculates the collective world-space bounding box for all + items. Returns (min_x, min_y, max_x, max_y). + """ + overall_min_x, overall_max_x = float("inf"), float("-inf") + overall_min_y, overall_max_y = float("inf"), float("-inf") + + for item in self.items: + bbox = self._get_item_world_bbox(item) + if not bbox: + continue + min_x, min_y, max_x, max_y = bbox + overall_min_x = min(overall_min_x, min_x) + overall_max_x = max(overall_max_x, max_x) + overall_min_y = min(overall_min_y, min_y) + overall_max_y = max(overall_max_y, max_y) + + if math.isinf(overall_min_x): + return None + return (overall_min_x, overall_min_y, overall_max_x, overall_max_y) + + @abstractmethod + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + """ + Calculates the required delta transformation matrix for each + item. + + Returns: + A dictionary mapping each DocItem to a delta Matrix that, + when pre-multiplied with the item's current matrix, will + move it to the target position. + """ + + async def calculate_deltas_async( + self, + context: ExecutionContext | None = None, + task_manager: TaskManager | None = None, + ) -> dict[DocItem, Matrix]: + """ + Asynchronous version of calculate_deltas. + + Default implementation raises NotImplementedError. Subclasses can + override this to provide async implementations. + + Returns: + A dictionary mapping each DocItem to a delta Matrix. + """ + raise NotImplementedError( + "This layout strategy does not support async calculation" + ) diff --git a/rayforge/doceditor/layout/registry.py b/rayforge/doceditor/layout/registry.py new file mode 100644 index 000000000..972a5ef13 --- /dev/null +++ b/rayforge/doceditor/layout/registry.py @@ -0,0 +1,143 @@ +from typing import TYPE_CHECKING + +from blinker import Signal + +if TYPE_CHECKING: + from .base import LayoutStrategy + + +class LayoutStrategyRegistry: + """ + Registry for layout strategy classes. + + Allows addons to register custom layout strategies. UI metadata + (labels, shortcuts, menu/toolbar placement) should be registered + via the ActionRegistry. + """ + + def __init__(self): + self._strategies: dict[str, type[LayoutStrategy]] = {} + self._addon_items: dict[str, set[str]] = {} + self.changed = Signal() + + def register( + self, + strategy_class: type["LayoutStrategy"], + name: str, + addon_name: str | None = None, + ) -> None: + """ + Register a layout strategy class. + + Args: + strategy_class: The LayoutStrategy subclass to register. + name: Unique name for this strategy. + addon_name: Optional name of the addon registering this strategy. + """ + if name in self._strategies and addon_name: + old_info = self._strategies.get(name) + if old_info and name in self._addon_items: + for addon in list(self._addon_items.keys()): + if name in self._addon_items.get(addon, set()): + self._addon_items[addon].discard(name) + + self._strategies[name] = strategy_class + + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(name) + + self.changed.send(self) + + def unregister(self, name: str) -> bool: + """ + Unregister a layout strategy by name. + + Args: + name: The name of the strategy to unregister. + + Returns: + True if the strategy was unregistered, False if not found. + """ + if name not in self._strategies: + return False + + for addon_name in list(self._addon_items.keys()): + self._addon_items[addon_name].discard(name) + + del self._strategies[name] + self.changed.send(self) + return True + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all strategies registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of strategies unregistered. + """ + if addon_name not in self._addon_items: + return 0 + + names = self._addon_items.pop(addon_name) + count = 0 + for name in names: + if name in self._strategies: + del self._strategies[name] + count += 1 + if count > 0: + self.changed.send(self) + return count + + def get(self, name: str) -> type["LayoutStrategy"] | None: + """ + Look up a strategy class by name. + + Args: + name: The name of the strategy. + + Returns: + The strategy class, or None if not found. + """ + return self._strategies.get(name) + + def list_all(self) -> list[type["LayoutStrategy"]]: + """ + Return a list of all registered strategy classes. + + Returns: + List of LayoutStrategy subclasses. + """ + return list(self._strategies.values()) + + def list_names(self) -> list[str]: + """ + Return a list of all registered strategy names. + + Returns: + List of strategy names. + """ + return list(self._strategies.keys()) + + +layout_registry = LayoutStrategyRegistry() + + +def register_builtin_layout_strategies(): + """ + Register built-in layout strategies. + + This function should be called during application initialization + before addons register their own strategies. + """ + from .auto import PixelPerfectLayoutStrategy + + layout_registry.register( + PixelPerfectLayoutStrategy, + name="pixel-perfect", + addon_name="core", + ) diff --git a/rayforge/doceditor/layout/spread.py b/rayforge/doceditor/layout/spread.py new file mode 100644 index 000000000..5a92a7977 --- /dev/null +++ b/rayforge/doceditor/layout/spread.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from raygeo.geo import Matrix + +from .base import LayoutStrategy + +if TYPE_CHECKING: + from ...core.item import DocItem + from ...shared.tasker.context import ExecutionContext + + +class SpreadHorizontallyStrategy(LayoutStrategy): + """Distributes items evenly in the horizontal direction.""" + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + if len(self.items) < 3: + return {} + + wps_with_bboxes = [] + for wp in self.items: + bbox = self._get_item_world_bbox(wp) + if bbox: + wps_with_bboxes.append((wp, bbox)) + + if len(wps_with_bboxes) < 3: + return {} + + # Sort by the center x of the bounding box + wps_with_bboxes.sort(key=lambda item: (item[1][0] + item[1][2]) / 2) + + leftmost_bbox = wps_with_bboxes[0][1] + rightmost_bbox = wps_with_bboxes[-1][1] + + total_span = rightmost_bbox[2] - leftmost_bbox[0] + total_items_width = sum( + bbox[2] - bbox[0] for _, bbox in wps_with_bboxes + ) + total_gap_space = total_span - total_items_width + gap_size = total_gap_space / (len(wps_with_bboxes) - 1) + + deltas = {} + current_x = leftmost_bbox[2] + for wp, bbox in wps_with_bboxes[1:-1]: + target_min_x = current_x + gap_size + delta_x = target_min_x - bbox[0] + if abs(delta_x) > 1e-6: + deltas[wp] = Matrix.translation(delta_x, 0) + + item_width = bbox[2] - bbox[0] + current_x = target_min_x + item_width + + return deltas + + +class SpreadVerticallyStrategy(LayoutStrategy): + """Distributes items evenly in the vertical direction.""" + + def calculate_deltas( + self, context: ExecutionContext | None = None + ) -> dict[DocItem, Matrix]: + if len(self.items) < 3: + return {} + + wps_with_bboxes = [] + for wp in self.items: + bbox = self._get_item_world_bbox(wp) + if bbox: + wps_with_bboxes.append((wp, bbox)) + + if len(wps_with_bboxes) < 3: + return {} + + # Sort by the center y of the bounding box + wps_with_bboxes.sort(key=lambda item: (item[1][1] + item[1][3]) / 2) + + bottommost_bbox = wps_with_bboxes[0][1] + topmost_bbox = wps_with_bboxes[-1][1] + + total_span = topmost_bbox[3] - bottommost_bbox[1] + total_items_height = sum( + bbox[3] - bbox[1] for _, bbox in wps_with_bboxes + ) + total_gap_space = total_span - total_items_height + gap_size = total_gap_space / (len(wps_with_bboxes) - 1) + + deltas = {} + current_y = bottommost_bbox[3] + for wp, bbox in wps_with_bboxes[1:-1]: + target_min_y = current_y + gap_size + delta_y = target_min_y - bbox[1] + if abs(delta_y) > 1e-6: + deltas[wp] = Matrix.translation(0, delta_y) + + item_height = bbox[3] - bbox[1] + current_y = target_min_y + item_height + + return deltas diff --git a/rayforge/doceditor/layout_cmd.py b/rayforge/doceditor/layout_cmd.py new file mode 100644 index 000000000..d72f4190a --- /dev/null +++ b/rayforge/doceditor/layout_cmd.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..core.item import DocItem +from ..core.undo import ChangePropertyCommand +from ..usage import get_usage_tracker +from .layout import ( + BboxAlignBottomStrategy, + BboxAlignCenterStrategy, + BboxAlignLeftStrategy, + BboxAlignMiddleStrategy, + BboxAlignRightStrategy, + BboxAlignTopStrategy, + LayoutStrategy, + PixelPerfectLayoutStrategy, + PositionAtStrategy, + SpreadHorizontallyStrategy, + SpreadVerticallyStrategy, +) + +if TYPE_CHECKING: + from ..shared.tasker.manager import TaskManager + from ..shared.tasker.task import Task + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class LayoutCmd: + """Handles alignment, distribution, and automatic layout of items.""" + + def __init__(self, editor: DocEditor, task_manager: TaskManager): + self._editor = editor + self._task_manager = task_manager + + def execute_layout( + self, + strategy: LayoutStrategy, + transaction_name: str, + use_async: bool = False, + ): + """ + Execute a layout strategy with undo/redo support. + + Configures and launches a background layout task. The actual model + mutation happens in the `when_done` callback, which is guaranteed + to run on the main GTK thread. + + Args: + strategy: The layout strategy to execute. + transaction_name: Name for the undo transaction. + use_async: If True, use async calculation for the strategy. + """ + slug = transaction_name.lower().replace(" ", "-") + get_usage_tracker().track_page_view( + f"/doc/layout/{slug}", transaction_name + ) + + # Define the handler that will receive error signals from the strategy. + def on_error_reported(sender, message: str): + """ + Receives an error message from the strategy (from a background + thread) and safely schedules a UI notification on the main thread. + """ + # Wrap the call in a lambda to ensure the keyword argument is + # passed correctly by GLib.idle_add. + self._task_manager.schedule_on_main_thread( + self._editor.notification_requested.send, self, message=message + ) + + # Connect the handler before running the task. + strategy.error_reported.connect(on_error_reported) + + def when_done(task: Task): + """ + This callback runs on the main thread after the task finishes. + It disconnects the signal handler and safely applies the + calculated changes to the document. + """ + # Disconnect the handler to prevent potential memory leaks. + strategy.error_reported.disconnect(on_error_reported) + + if task.get_status() != "completed": + logger.error( + "Layout task '%s' did not complete successfully. " + "Status: %s", + transaction_name, + task.get_status(), + ) + return + + # The result of the task is the dictionary of transformation + # deltas. + deltas = task.result() + + if not deltas: + return # No changes to apply + + with self._editor.history_manager.transaction( + transaction_name + ) as t: + for item, delta_matrix in deltas.items(): + old_matrix = item.matrix.copy() + new_matrix = delta_matrix @ old_matrix + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + # This simple coroutine just runs the calculation in the background + # and returns the result. + async def layout_coro(context): + if use_async: + return await strategy.calculate_deltas_async( + context, self._task_manager + ) + return strategy.calculate_deltas(context) + + # Launch the coroutine and attach the main-thread callback. + self._task_manager.add_coroutine( + layout_coro, + when_done=when_done, + key=f"layout-{transaction_name}", # key to prevent concurrent runs + ) + + def center_horizontally( + self, selected_items: list[DocItem], surface_width_mm: float + ): + """Action handler for centering selected items horizontally.""" + if not selected_items: + return + + strategy = BboxAlignCenterStrategy( + selected_items, surface_width_mm=surface_width_mm + ) + self.execute_layout(strategy, _("Center Horizontally")) + + def center_vertically( + self, selected_items: list[DocItem], surface_height_mm: float + ): + """Action handler for centering selected items vertically.""" + if not selected_items: + return + + strategy = BboxAlignMiddleStrategy( + selected_items, surface_height_mm=surface_height_mm + ) + self.execute_layout(strategy, _("Center Vertically")) + + def align_left(self, selected_items: list[DocItem]): + """Action handler for aligning selected items to the left.""" + if not selected_items: + return + + strategy = BboxAlignLeftStrategy(selected_items) + self.execute_layout(strategy, _("Align Left")) + + def align_right( + self, selected_items: list[DocItem], surface_width_mm: float + ): + """Action handler for aligning selected items to the right.""" + if not selected_items: + return + + strategy = BboxAlignRightStrategy( + selected_items, surface_width_mm=surface_width_mm + ) + self.execute_layout(strategy, _("Align Right")) + + def align_top( + self, selected_items: list[DocItem], surface_height_mm: float + ): + """Action handler for aligning selected items to the top.""" + if not selected_items: + return + + strategy = BboxAlignTopStrategy( + selected_items, surface_height_mm=surface_height_mm + ) + self.execute_layout(strategy, _("Align Top")) + + def align_bottom(self, selected_items: list[DocItem]): + """Action handler for aligning selected items to the bottom.""" + if not selected_items: + return + + strategy = BboxAlignBottomStrategy(selected_items) + self.execute_layout(strategy, _("Align Bottom")) + + def spread_horizontally(self, selected_items: list[DocItem]): + """Action handler for spreading selected items horizontally.""" + if not selected_items: + return + + strategy = SpreadHorizontallyStrategy(selected_items) + self.execute_layout(strategy, _("Spread Horizontally")) + + def spread_vertically(self, selected_items: list[DocItem]): + """Action handler for spreading selected items vertically.""" + if not selected_items: + return + + strategy = SpreadVerticallyStrategy(selected_items) + self.execute_layout(strategy, _("Spread Vertically")) + + def position_at( + self, selected_items: list[DocItem], position_mm: tuple[float, float] + ): + """Action handler for positioning the selection's center at a point.""" + if not selected_items: + return + + strategy = PositionAtStrategy( + items=selected_items, position_mm=position_mm + ) + self.execute_layout(strategy, _("Position at Point")) + + def layout_pixel_perfect(self, selected_items: list[DocItem]): + """Action handler for the pixel-perfect packing layout.""" + items_to_layout = self.get_items_to_layout(selected_items) + + if not items_to_layout: + return + + strategy = PixelPerfectLayoutStrategy( + items=items_to_layout, + margin_mm=0.5, + resolution_px_per_mm=8.0, + allow_rotation=True, + ) + self.execute_layout(strategy, _("Auto Layout")) + + def get_items_to_layout( + self, selected_items: list[DocItem] + ) -> list[DocItem]: + """Determine items to layout based on selection context.""" + if not selected_items: + # If nothing is selected, get all top-level content items from + # the current active layer only. + items_to_layout = [] + active_layer = self._editor.doc.active_layer + if active_layer: + items_to_layout.extend(active_layer.get_content_items()) + else: + # For any selection, only pack the top-level selected items. + # E.g., if a group and its child are both selected, only pack the + # group. + items_to_layout = [] + selected_set = set(selected_items) + for item in selected_items: + has_selected_ancestor = False + p = item.parent + while p: + if p in selected_set: + has_selected_ancestor = True + break + p = p.parent + if not has_selected_ancestor: + items_to_layout.append(item) + + return items_to_layout diff --git a/rayforge/doceditor/split_cmd.py b/rayforge/doceditor/split_cmd.py new file mode 100644 index 000000000..12f08883e --- /dev/null +++ b/rayforge/doceditor/split_cmd.py @@ -0,0 +1,125 @@ +import logging +import uuid +from abc import ABC, abstractmethod +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Geometry + +from ..core.item import DocItem +from ..core.undo import ListItemCommand +from ..core.workpiece import WorkPiece + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class SplitStrategy(ABC): + """ + Abstract base class for strategies that determine how to split a + WorkPiece's geometry into multiple fragments. + """ + + @abstractmethod + def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: + """ + Calculates the geometric fragments for the split operation. + + Args: + workpiece: The WorkPiece to split. + + Returns: + A list of Geometry objects. Each geometry should represent a + fragment in the same normalized coordinate space (0-1 box, Y-up) + as the original workpiece's boundaries. + """ + + +class ConnectivitySplitStrategy(SplitStrategy): + """ + Splits a workpiece by separating disjoint vector components (islands). + This is the standard "Split" behavior for vector shapes. + """ + + def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: + if not workpiece.boundaries or workpiece.boundaries.is_empty(): + return [] + return workpiece.boundaries.split_into_components() + + +class SplitCmd: + """Handles splitting of document items.""" + + def __init__(self, editor: "DocEditor"): + self._editor = editor + + def split_items( + self, + items: list[WorkPiece], + strategy: SplitStrategy | None = None, + ) -> list[DocItem]: + """ + Splits the provided items into multiple fragments based on the given + strategy. Replaces the original items with the new fragments in the + document. + + Args: + items: The list of items to split. + strategy: The strategy to use for calculating fragments. + Defaults to splitting disjoint components. + + Returns: + A list of the newly created items. + """ + if strategy is None: + strategy = ConnectivitySplitStrategy() + if not items: + return [] + + history = self._editor.history_manager + newly_created_items = [] + + with history.transaction(_("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. + parent = item.parent + if not isinstance(item, WorkPiece) or not parent: + continue + + fragments = strategy.calculate_fragments(item) + new_pieces = item.apply_split(fragments) + + # If splitting didn't produce multiple pieces, do nothing for + # this item. + if len(new_pieces) <= 1: + continue + + # Remove the original + remove_cmd = ListItemCommand( + owner_obj=parent, + item=item, + undo_command="add_child", + redo_command="remove_child", + name=_("Remove original item"), + ) + t.execute(remove_cmd) + + # Add the new pieces + for piece in new_pieces: + # Assign a unique ID to each new piece + piece.uid = str(uuid.uuid4()) + + add_cmd = ListItemCommand( + owner_obj=parent, + item=new_pieces, + undo_command="remove_children", + redo_command="add_children", + name=_("Add split fragments"), + ) + t.execute(add_cmd) + newly_created_items.extend(new_pieces) + + return newly_created_items diff --git a/rayforge/doceditor/step_cmd.py b/rayforge/doceditor/step_cmd.py new file mode 100644 index 000000000..48c139d0b --- /dev/null +++ b/rayforge/doceditor/step_cmd.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from ..core.color_preset import get_color_preset_mgr +from ..core.step_registry import step_registry +from ..core.undo import ChangePropertyCommand, DictItemCommand +from ..core.vectorization_spec import LayerSource, PassthroughSpec + +if TYPE_CHECKING: + from ..core.recipe import Recipe + from ..core.step import Step + from .editor import DocEditor + + +logger = logging.getLogger(__name__) + + +class StepCmd: + """Handles commands related to step settings.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + self._doc = editor.doc + self._context = editor.context + + def set_step_param( + self, + target_dict: dict[str, Any], + key: str, + new_value: Any, + name: str, + on_change_callback: Any = None, + ): + """ + Sets a parameter in a step's dictionary with an undoable command. + + Args: + target_dict: The dictionary to modify. + key: The key of the parameter to set. + new_value: The new value for the parameter. + name: The name of the command for the undo stack. + on_change_callback: A callback to execute after the command. + """ + # Check if the value is a float and compare with a tolerance + if isinstance(new_value, float): + old_value = target_dict.get(key) + if old_value is None: + pass + elif ( + isinstance(old_value, (int, float)) + and abs(new_value - old_value) < 1e-6 + ): + return + elif new_value == target_dict.get(key): + return + + command = DictItemCommand( + target_dict=target_dict, + key=key, + new_value=new_value, + name=name, + on_change_callback=on_change_callback, + ) + self._editor.history_manager.execute(command) + + def apply_best_recipe_to_step(self, step: Step): + """ + Finds the best matching recipe for a given step and applies its + settings. This modifies the step object directly and is not undoable + by itself; it should be called before the step is added to the + document via an undoable command. + """ + # Get the stock items from the document + stock_items = self._doc.stock_items + machine = self._context.machine + + # Query the RecipeManager for the best match for this step type. + matching_recipes: list = [] + recipe_mgr = self._context.recipe_mgr + if recipe_mgr is not None: + matching_recipes = recipe_mgr.find_recipes( + stock_items=stock_items, + machine=machine, + step_type=type(step).__name__, + ) + + # If matching_recipes is not empty, apply the best one + if matching_recipes: + best_recipe = matching_recipes[0] + logger.info( + f"Applying best recipe '{best_recipe.name}' to new step." + ) + # Apply the settings to the step object + for key, value in best_recipe.settings.items(): + if hasattr(step, key): + setattr(step, key, value) + + # Apply transformer settings directly to the freshly-created + # step. Per-workpiece and per-step dicts are mutated in place. + self._apply_recipe_transformers_to_step(step, best_recipe) + + # Store a reference to the applied recipe + step.applied_recipe_uid = best_recipe.uid + + @staticmethod + def _apply_recipe_transformers_to_step(step: Step, recipe: Recipe) -> None: + """Apply a recipe's transformer settings to a fresh step. + + Direct mutation of the step's per-workpiece and per-step + transformer dicts, used by the auto-apply path. For each recipe + transformer dict with ``recipe_apply=True``, update the step's + matching dict (by name) with ``enabled`` and the transformer's + params. + """ + step_dicts_by_name: dict[str, dict] = {} + for d in list(step.per_workpiece_transformers_dicts) + list( + step.per_step_transformers_dicts + ): + name = d.get("name") + if name and name not in step_dicts_by_name: + step_dicts_by_name[name] = d + + for recipe_dict in recipe.transformer_dicts or []: + if not recipe_dict.get("recipe_apply", True): + continue + name = recipe_dict.get("name") + if not name: + continue + step_dict = step_dicts_by_name.get(name) + if step_dict is None: + continue + for key, value in recipe_dict.items(): + if key in ("name", "recipe_apply"): + continue + step_dict[key] = value + + def rename_step(self, step: Step, new_name: str): + """Renames a step with an undoable command.""" + if new_name == step.name: + return + cmd = ChangePropertyCommand( + target=step, + property_name="name", + new_value=new_name, + setter_method_name="set_name", + name=_("Rename step"), + ) + self._editor.history_manager.execute(cmd) + + def initialize_default_steps(self): + """ + Adds a default Contour step to the first layer if it has no steps. + + Called on application startup or when a new empty document is created. + """ + doc = self._doc + if not doc.layers: + return + + first_layer = doc.layers[0] + workflow = first_layer.workflow + if not workflow or workflow.has_steps(): + return + + contour_cls = step_registry.get("ContourStep") + if not contour_cls: + return + + step = contour_cls.create(self._context) + self.apply_best_recipe_to_step(step) + workflow.add_step(step) + logger.info( + f"Added default '{step.typelabel}' step to " + f"layer '{first_layer.name}'." + ) + + def add_default_steps_for_layers(self, layers): + """ + Adds default steps to newly imported layers. + + For each layer: + - If it was imported from a color source whose color matches a + color rule, a step of the rule's step type is created. If that + step type is not registered (e.g. its addon was uninstalled), + it falls back to the default behavior below and logs a + warning. + - If workpieces have fills: add Contour + Engrave steps + - If workpieces have only unfilled vectors: add Contour only + """ + contour_cls = step_registry.get("ContourStep") + engrave_cls = step_registry.get("EngraveStep") + + for layer in layers: + workflow = layer.workflow + if not workflow or workflow.has_steps(): + continue + + rule_cls = self._step_class_from_color_rule(layer) + if rule_cls is not None: + step = rule_cls.create(self._context) + self.apply_best_recipe_to_step(step) + workflow.add_step(step) + logger.info( + f"Added default '{step.typelabel}' step to " + f"layer '{layer.name}' (color rule)." + ) + continue + + if contour_cls: + step = contour_cls.create(self._context) + self.apply_best_recipe_to_step(step) + workflow.add_step(step) + logger.info( + f"Added default '{step.typelabel}' step to " + f"layer '{layer.name}'." + ) + + if layer.has_fills and engrave_cls: + step = engrave_cls.create(self._context) + self.apply_best_recipe_to_step(step) + workflow.add_step(step) + logger.info( + f"Added default '{step.typelabel}' step to " + f"layer '{layer.name}' (has fills)." + ) + + def _step_class_from_color_rule(self, layer) -> type[Step] | None: + """ + Resolve the step class a color rule maps to for a layer. + + Inspects each workpiece's source segment: for color-source + imports the segment's ``layer_id`` is the resolved SVG color, so + the rule applies regardless of whether the workpiece was placed + on a fresh layer (which carries the color) or an existing one + (which does not). Returns the step class of the first matching + rule, or ``None`` when no rule applies. If a rule matches but + its step type is no longer registered, a warning is logged and + ``None`` is returned so the caller falls back to the default + behavior. + """ + for workpiece in layer.all_workpieces: + segment = workpiece.source_segment + if segment is None: + continue + spec = segment.vectorization_spec + if not ( + isinstance(spec, PassthroughSpec) + and spec.layer_source == LayerSource.COLORS + ): + continue + color = segment.layer_id + if not color: + continue + preset = get_color_preset_mgr().get_preset(color) + if preset is None: + logger.debug( + f"No color rule for layer '{layer.name}' (color {color})." + ) + continue + cls = step_registry.get(preset.step_type) + if cls is None: + logger.warning( + f"Step type '{preset.step_type}' from a color rule " + f"is not registered; falling back to default steps " + f"for layer '{layer.name}'." + ) + return None + return cls + return None diff --git a/rayforge/doceditor/stock_cmd.py b/rayforge/doceditor/stock_cmd.py new file mode 100644 index 000000000..97e86ffe8 --- /dev/null +++ b/rayforge/doceditor/stock_cmd.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Geometry, Matrix + +from ..core.stock import StockItem +from ..core.stock_asset import StockAsset +from ..core.undo import ChangePropertyCommand, Command +from ..core.workpiece import WorkPiece +from ..usage import get_usage_tracker + +if TYPE_CHECKING: + from ..core.doc import Doc + from ..core.item import DocItem + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class _AddStockCommand(Command): + """ + A private command to handle the creation of a StockAsset and StockItem. + """ + + def __init__( + self, + doc: Doc, + name: str, + geometry: Geometry, + pos: tuple[float, float], + ): + super().__init__(name=_("Add Stock")) + self.doc = doc + self.asset = StockAsset(name=name, geometry=geometry) + self.item = StockItem(stock_asset_uid=self.asset.uid, name=name) + w, h = self.asset.get_natural_size() + self.item.matrix = Matrix.scale(w, h) + self.item.pos = pos + self.asset_uid = self.asset.uid + + def execute(self): + self.do() + + def do(self): + self.doc.add_asset(self.asset, silent=True) + self.doc.add_child(self.item) + + def undo(self): + self.doc.remove_child(self.item) + self.doc.remove_asset_by_uid(self.asset_uid) + + +class RemoveStockAssetCommand(Command): + """Command to remove a StockAsset from the document.""" + + def __init__(self, doc: Doc, asset_uid: str): + super().__init__(name=_("Remove Stock Asset")) + self.doc = doc + self.asset_uid = asset_uid + self._removed_asset = None + + def execute(self): + self._removed_asset = self.doc.get_asset_by_uid(self.asset_uid) + if self._removed_asset: + self.doc.remove_asset_by_uid(self.asset_uid) + + def undo(self): + if self._removed_asset: + self.doc.add_asset(self._removed_asset, silent=True) + + +class ConvertToStockCommand(Command): + """ + Command to convert a WorkPiece to a StockItem with its own StockAsset. + """ + + def __init__(self, doc: Doc, workpiece: WorkPiece): + super().__init__(name=_("Convert to Stock")) + self.doc = doc + self.workpiece = workpiece + self.original_parent: DocItem | None = workpiece.parent + self.original_index = 0 + + geometry = workpiece.get_world_geometry() + if geometry is None: + geometry = Geometry() + + self.asset = StockAsset(name=workpiece.name, geometry=geometry) + self.stock_item = StockItem( + stock_asset_uid=self.asset.uid, name=workpiece.name + ) + w, h = self.asset.get_natural_size() + if w > 0 and h > 0: + self.stock_item.matrix = Matrix.scale(w, h) + self.stock_item.pos = workpiece.pos + self.stock_item.angle = workpiece.angle + self.asset_uid = self.asset.uid + + def execute(self): + if self.original_parent: + children = list(self.original_parent.children) + self.original_index = children.index(self.workpiece) + self.original_parent.remove_child(self.workpiece) + + self.doc.add_asset(self.asset, silent=True) + self.doc.add_child(self.stock_item) + + def undo(self): + self.doc.remove_child(self.stock_item) + self.doc.remove_asset_by_uid(self.asset_uid) + + if self.original_parent: + self.original_parent.add_child( + self.workpiece, index=self.original_index + ) + + +class StockCmd: + """Handles commands related to stock material.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def add_stock(self): + """ + Adds a new StockAsset and a linking StockItem to the document. + This is a single undoable operation. + """ + doc = self._editor.doc + machine = self._editor.context.config.machine + if machine: + __, __, wa_w, wa_h = machine.work_area + ref_x, ref_y = machine.panel.reference_position_world + stock_x = ref_x + stock_y = ref_y + stock_w = wa_w * 0.8 + stock_h = wa_h * 0.8 + stock_x, stock_y = machine.panel.world_position_from_origin( + ref_x, ref_y, (stock_w, stock_h) + ) + logger.debug( + "Calculated stock position (%.2f, %.2f)", stock_x, stock_y + ) + else: + stock_x, stock_y, stock_w, stock_h = 0, 0, 200.0, 200.0 + + default_geometry = Geometry() + default_geometry.move_to(0, 0) + default_geometry.line_to(stock_w, 0) + default_geometry.line_to(stock_w, stock_h) + default_geometry.line_to(0, stock_h) + default_geometry.close_path() + + stock_count = len(doc.stock_assets) + 1 + stock_name = _("Stock {count}").format(count=stock_count) + + command = _AddStockCommand( + doc, stock_name, default_geometry, (stock_x, stock_y) + ) + doc.history_manager.execute(command) + get_usage_tracker().track_page_view( + "/doc/add-asset/stock", "Add Stock Asset" + ) + + def toggle_stock_visibility(self, stock_item: StockItem): + """ + Toggles the visibility of a StockItem with an undoable command. + """ + new_visibility = not stock_item.visible + command = ChangePropertyCommand( + target=stock_item, + property_name="visible", + new_value=new_visibility, + setter_method_name="set_visible", + name=_("Toggle stock visibility"), + ) + self._editor.doc.history_manager.execute(command) + + def rename_stock_asset(self, stock_asset: StockAsset, new_name: str): + """ + Renames a StockAsset with an undoable command. It also finds and + renames all StockItem instances that use this asset. + """ + if new_name == stock_asset.name: + return + + with self._editor.doc.history_manager.transaction( + _("Rename Stock Asset") + ) as t: + # Command to rename the asset definition + t.execute( + ChangePropertyCommand( + target=stock_asset, + property_name="name", + new_value=new_name, + setter_method_name="set_name", + ) + ) + # Find and rename all instances + for item in self._editor.doc.stock_items: + if item.stock_asset_uid == stock_asset.uid: + t.execute( + ChangePropertyCommand( + target=item, + property_name="name", + new_value=new_name, + setter_method_name="set_name", + ) + ) + + def set_stock_thickness(self, stock_item: StockItem, new_thickness: float): + """ + Sets the thickness of a StockAsset with an undoable command. + """ + stock_asset = stock_item.stock_asset + if not stock_asset or new_thickness == stock_asset.thickness: + return + + command = ChangePropertyCommand( + target=stock_asset, + property_name="thickness", + new_value=new_thickness, + setter_method_name="set_thickness", + name=_("Change stock thickness"), + ) + self._editor.doc.history_manager.execute(command) + stock_item.updated.send(stock_item) + + def set_stock_material(self, stock_item: StockItem, new_material_uid: str): + """ + Sets the material of a StockAsset with an undoable command. + """ + stock_asset = stock_item.stock_asset + if not stock_asset or new_material_uid == stock_asset.material_uid: + return + + command = ChangePropertyCommand( + target=stock_asset, + property_name="material_uid", + new_value=new_material_uid, + setter_method_name="set_material", + name=_("Change stock material"), + ) + self._editor.doc.history_manager.execute(command) + stock_item.updated.send(stock_item) + + def convert_to_stock(self, workpiece: WorkPiece) -> StockItem: + """ + Converts a WorkPiece to a StockItem with its own StockAsset. + This is a single undoable operation. + """ + command = ConvertToStockCommand(self._editor.doc, workpiece) + self._editor.doc.history_manager.execute(command) + return command.stock_item diff --git a/rayforge/doceditor/tab_cmd.py b/rayforge/doceditor/tab_cmd.py new file mode 100644 index 000000000..10b86ee40 --- /dev/null +++ b/rayforge/doceditor/tab_cmd.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import logging +from copy import deepcopy +from dataclasses import replace +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Geometry + +from ..core.tab import Tab +from ..core.undo import Command +from ..core.workpiece import WorkPiece +from ..usage import get_usage_tracker + +if TYPE_CHECKING: + from ..doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class SetWorkpieceTabsCommand(Command): + """An undoable command that sets the list of tabs for a workpiece.""" + + def __init__( + self, + editor: DocEditor, + workpiece: WorkPiece, + new_tabs: list[Tab], + name: str = "Set Tabs", + ): + super().__init__(name=name) + self.editor = editor + self.workpiece_uid = workpiece.uid + self.new_tabs = new_tabs + self.old_tabs = deepcopy(workpiece.tabs) + + def _get_workpiece(self) -> WorkPiece | None: + """Helper to find the model object from the stored UID.""" + workpiece = self.editor.doc.find_descendant_by_uid(self.workpiece_uid) + if isinstance(workpiece, WorkPiece): + return workpiece + logger.error("Could not find target WorkPiece for command.") + return None + + def execute(self) -> None: + """Applies the new list of tabs.""" + workpiece = self._get_workpiece() + if workpiece: + workpiece.tabs = self.new_tabs + + def undo(self) -> None: + """Reverts to the previous list of tabs.""" + workpiece = self._get_workpiece() + if workpiece: + workpiece.tabs = self.old_tabs + + +class TabCmd: + """Handles commands related to creating and managing workpiece tabs.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def _calculate_equidistant_tabs( + self, geometry: Geometry, count: int, width: float + ) -> list[Tab]: + """Calculates positions for a number of equally spaced tabs.""" + if count <= 0: + return [] + + total_length = geometry.distance() + if total_length == 0: + return [] + + spacing = total_length / count + targets = [(i + 0.5) * spacing for i in range(count)] + positions = geometry.get_positions_at_distances(targets) + return [ + Tab(width=width, segment_index=si, pos=min(1.0, max(0.0, t))) + for si, t, _ in positions + ] + + def _calculate_cardinal_tabs( + self, geometry: Geometry, width: float + ) -> list[Tab]: + """Calculates positions for 4 tabs at the cardinal points.""" + if geometry.is_empty(): + return [] + + # 1. Get bounding box of the geometry + min_x, min_y, max_x, max_y = geometry.rect() + width_bbox = max_x - min_x + height_bbox = max_y - min_y + + if width_bbox < 1e-6 or height_bbox < 1e-6: + return [] + + # 2. Define the 4 cardinal points on the bounding box + mid_x = min_x + width_bbox / 2 + mid_y = min_y + height_bbox / 2 + cardinal_points = [ + (mid_x, max_y), # North + (mid_x, min_y), # South + (max_x, mid_y), # East + (min_x, mid_y), # West + ] + + # 3. For each point, find the closest location on the geometry path + tabs: list[Tab] = [] + for x, y in cardinal_points: + closest = geometry.find_closest_point(x, y) + if closest: + segment_index, t, _ = closest + tabs.append( + Tab( + width=width, + segment_index=segment_index, + pos=min(1.0, max(0.0, t)), + ) + ) + + # 4. Deduplicate tabs that might land on the same spot (e.g., corners) + unique_tabs: list[Tab] = [] + seen: set[tuple[int, int]] = set() + for tab in tabs: + # Round `t` to avoid floating point inaccuracies causing missed + # duplicates + key = (tab.segment_index, round(tab.pos * 1e5)) + if key not in seen: + unique_tabs.append(tab) + seen.add(key) + + return unique_tabs + + def add_tabs( + self, + workpiece: WorkPiece, + count: int, + width: float, + strategy: str = "equidistant", + ): + """ + Creates and applies tabs to a workpiece. This is an undoable action. + + Args: + workpiece: The WorkPiece to add tabs to. + count: The number of tabs to add. + width: The width of each tab in millimeters. + strategy: The placement strategy (currently only 'equidistant'). + """ + if not workpiece.boundaries: + logger.warning( + f"Cannot add tabs to workpiece '{workpiece.name}' " + "because it has no vector geometry." + ) + return + + if strategy == "equidistant": + new_tabs = self._calculate_equidistant_tabs( + workpiece.boundaries, count, width + ) + else: + raise NotImplementedError( + f"Tabbing strategy '{strategy}' not implemented." + ) + + cmd = SetWorkpieceTabsCommand( + editor=self._editor, + workpiece=workpiece, + new_tabs=new_tabs, + name=_("Add Tabs"), + ) + self._editor.history_manager.execute(cmd) + get_usage_tracker().track_page_view( + "/doc/add-tabs/equidistant", "Add Equidistant Tabs" + ) + + def add_cardinal_tabs(self, workpiece: WorkPiece, width: float): + """ + Creates and applies 4 tabs to a workpiece at the cardinal points. This + is an undoable action. + + Args: + workpiece: The WorkPiece to add tabs to. + width: The width of each tab in millimeters. + """ + if not workpiece.boundaries: + logger.warning( + f"Cannot add tabs to workpiece '{workpiece.name}' " + "because it has no vector geometry." + ) + return + + new_tabs = self._calculate_cardinal_tabs(workpiece.boundaries, width) + + cmd = SetWorkpieceTabsCommand( + editor=self._editor, + workpiece=workpiece, + new_tabs=new_tabs, + name=_("Add Cardinal Tabs"), + ) + self._editor.history_manager.execute(cmd) + get_usage_tracker().track_page_view( + "/doc/add-tabs/cardinal", "Add Cardinal Tabs" + ) + + def add_single_tab( + self, + workpiece: WorkPiece, + segment_index: int, + pos: float, + width: float = 2.0, + length: float = 1.0, + ): + """Adds a single new tab to a workpiece. Undoable.""" + new_tab = Tab(width=width, segment_index=segment_index, pos=pos) + + # Create a new list with the added tab + new_tabs_list = deepcopy(workpiece.tabs) + new_tabs_list.append(new_tab) + + cmd = SetWorkpieceTabsCommand( + editor=self._editor, + workpiece=workpiece, + new_tabs=new_tabs_list, + name=_("Add Tab"), + ) + self._editor.history_manager.execute(cmd) + + def remove_single_tab(self, workpiece: WorkPiece, tab_to_remove: Tab): + """Removes a single tab from a workpiece. Undoable.""" + new_tabs_list = [ + t for t in workpiece.tabs if t.uid != tab_to_remove.uid + ] + + cmd = SetWorkpieceTabsCommand( + editor=self._editor, + workpiece=workpiece, + new_tabs=new_tabs_list, + name=_("Remove Tab"), + ) + self._editor.history_manager.execute(cmd) + + def clear_tabs(self, workpiece: WorkPiece): + """Removes all tabs from a workpiece.""" + cmd = SetWorkpieceTabsCommand( + editor=self._editor, + workpiece=workpiece, + new_tabs=[], + name=_("Clear Tabs"), + ) + self._editor.history_manager.execute(cmd) + + def set_workpiece_tabs_enabled(self, workpiece: WorkPiece, enabled: bool): + """Enables or disables tabs for a workpiece.""" + if workpiece.tabs_enabled == enabled: + return + + old_value = workpiece.tabs_enabled + workpiece.tabs_enabled = enabled + + # This is a simple property change, so we can use a generic command + from ..core.undo import ChangePropertyCommand + + cmd = ChangePropertyCommand( + target=workpiece, + property_name="tabs_enabled", + new_value=enabled, + old_value=old_value, + name=_("Toggle Tabs"), + ) + self._editor.history_manager.execute(cmd) + + def set_workpiece_tab_width(self, workpiece: WorkPiece, width: float): + """Sets the width of all tabs on a workpiece.""" + if not workpiece.tabs: + return + + old_tabs = deepcopy(workpiece.tabs) + # Check if any change is actually needed to avoid empty undo commands + if all(tab.width == width for tab in old_tabs): + return + + new_tabs = [replace(tab, width=width) for tab in old_tabs] + + cmd = SetWorkpieceTabsCommand( + editor=self._editor, + workpiece=workpiece, + new_tabs=new_tabs, + name=_("Change Tab Width"), + ) + self._editor.history_manager.execute(cmd) diff --git a/rayforge/doceditor/transform_cmd.py b/rayforge/doceditor/transform_cmd.py new file mode 100644 index 000000000..b49f2cbf9 --- /dev/null +++ b/rayforge/doceditor/transform_cmd.py @@ -0,0 +1,671 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Matrix + +from ..context import get_context +from ..core.item import DocItem +from ..core.undo import ChangePropertyCommand + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class TransformCmd: + """Handles undoable transformations of document items.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def create_transform_transaction( + self, + changes: list[tuple[DocItem, Matrix, Matrix]], + ): + """ + Creates a single, undoable transaction for a list of matrix changes + that have already been calculated. + + Args: + changes: A list of tuples, where each tuple contains + (DocItem_to_change, old_matrix, new_matrix). + """ + history_manager = self._editor.history_manager + if not changes: + return + + logger.debug( + f"Creating transform transaction for {len(changes)} item(s)." + ) + + with history_manager.transaction(_("Transform item(s)")) as t: + for doc_item, old_matrix, new_matrix in changes: + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=doc_item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + @staticmethod + def group_bbox_world( + items: list[DocItem], + ) -> tuple[float, float, float, float]: + """Returns ``(min_x, min_y, max_x, max_y)`` of the items' combined + axis-aligned bounding box in world space. + + A DocItem's local coordinate space is the unit square + ``[0, 1] x [0, 1]``; the item's world transform encodes its size + as a scale factor, so we sample the four unit-square corners and + transform them into world space. + """ + min_x, max_x = float("inf"), float("-inf") + min_y, max_y = float("inf"), float("-inf") + for item in items: + world_transform = item.get_world_transform() + for lx, ly in [(0, 0), (1, 0), (1, 1), (0, 1)]: + wx, wy = world_transform.transform_point((lx, ly)) + min_x, max_x = min(min_x, wx), max(max_x, wx) + min_y, max_y = min(min_y, wy), max(max_y, wy) + return min_x, min_y, max_x, max_y + + @classmethod + def group_center_world(cls, items: list[DocItem]) -> tuple[float, float]: + """World-space centre of the items' combined bounding box.""" + min_x, min_y, max_x, max_y = cls.group_bbox_world(items) + return (min_x + max_x) / 2.0, (min_y + max_y) / 2.0 + + @staticmethod + def _world_to_local_matrix( + item: DocItem, world_transform: Matrix + ) -> Matrix: + """Convert a world-space transform back to the item's local matrix + by cancelling out the parent's world transform.""" + if item.parent: + parent_world = item.parent.get_world_transform() + try: + return parent_world.invert() @ world_transform + except ValueError: + return item.matrix.copy() + return world_transform + + def nudge_items( + self, + items: list[DocItem], + dx_mm: float, + dy_mm: float, + ): + """ + Moves a list of DocItems by a given delta in world coordinates, + creating a single undoable transaction for the operation. + + Args: + items: The list of DocItems to move. + dx_mm: The distance to move along the X-axis in millimeters. + dy_mm: The distance to move along the Y-axis in millimeters. + """ + history_manager = self._editor.history_manager + if not items or (dx_mm == 0.0 and dy_mm == 0.0): + return + + with history_manager.transaction(_("Move item(s)")) as t: + for item in items: + old_matrix = item.matrix.copy() + # Nudge must be pre-multiplied to apply the translation in + # world space, not local space. + delta = Matrix.translation(dx_mm, dy_mm) + new_matrix = delta @ old_matrix + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def flip_horizontal(self, items: list[DocItem]): + """ + Flips a list of DocItems horizontally (mirrors along the Y-axis), + creating a single undoable transaction for the operation. + + Args: + items: The list of DocItems to flip horizontally. + """ + history_manager = self._editor.history_manager + if not items: + return + + with history_manager.transaction(_("Flip Horizontal")) as t: + for item in items: + old_matrix = item.matrix.copy() + # Get the world center of the item before transformation + # This ensures we always flip around the same point + world_center = item.get_world_transform().transform_point( + (0.5, 0.5) + ) + + # Create a flip matrix (scale by -1 on X-axis) around world + # center + flip_matrix = Matrix.flip_horizontal(center=world_center) + new_matrix = flip_matrix @ old_matrix + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def flip_vertical(self, items: list[DocItem]): + """ + Flips a list of DocItems vertically (mirrors along the X-axis), + creating a single undoable transaction for the operation. + + Args: + items: The list of DocItems to flip vertically. + """ + history_manager = self._editor.history_manager + if not items: + return + + with history_manager.transaction(_("Flip Vertical")) as t: + for item in items: + old_matrix = item.matrix.copy() + # Get the world center of the item before transformation + # This ensures we always flip around the same point + world_center = item.get_world_transform().transform_point( + (0.5, 0.5) + ) + + # Create a flip matrix (scale by -1 on Y-axis) around world + # center + flip_matrix = Matrix.flip_vertical(center=world_center) + new_matrix = flip_matrix @ old_matrix + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_position(self, items: list[DocItem], x: float, y: float): + """ + Sets the position of **every** item individually using machine + coordinates. Each item's top-left corner is moved to the world + position derived from ``(x, y)`` and that item's own size, so + items with different sizes land at different world positions. + + Args: + items: List of items to move. + x: Target X position in machine coordinates. + y: Target Y position in machine coordinates. + """ + history_manager = self._editor.history_manager + if not items: + return + + machine = get_context().machine + + with history_manager.transaction(_("Move item(s)")) as t: + for item in items: + old_matrix = item.matrix.copy() + + # Convert target Machine Coordinate to World Coordinate + # We need the item's size for correct conversion if origin is + # right/top. + size_world = item.size + + if machine: + x_world, y_world = machine.panel.machine_item_to_world( + (x, y), size_world + ) + else: + # Fallback to direct mapping if no machine context + x_world, y_world = x, y + + current_pos = item.pos + dx = x_world - current_pos[0] + dy = y_world - current_pos[1] + + # Apply translation to matrix + new_matrix = Matrix.translation(dx, dy) @ old_matrix + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_angle(self, items: list[DocItem], angle: float): + """Sets **every** item's local rotation angle to *angle* degrees, + preserving each item's own world-space center.""" + history_manager = self._editor.history_manager + if not items: + return + + with history_manager.transaction(_("Change item angle")) as t: + for item in items: + old_matrix = item.matrix.copy() + item.angle = angle + new_matrix = item.matrix.copy() + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_shear(self, items: list[DocItem], shear: float): + """Sets **every** item's local shear angle to *shear* degrees, + preserving each item's own world-space center.""" + history_manager = self._editor.history_manager + if not items: + return + + with history_manager.transaction(_("Change item shear")) as t: + for item in items: + old_matrix = item.matrix.copy() + item.shear = shear + new_matrix = item.matrix.copy() + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_size( + self, + items: list[DocItem], + width: float | None = None, + height: float | None = None, + fixed_ratio: bool = False, + sizes: list[tuple[float, float]] | None = None, + ): + """Sets the size of each item individually. + + Args: + items: The list of DocItems to resize. + width: The target width. Ignored if ``sizes`` is provided. + height: The target height. Ignored if ``sizes`` is provided. + fixed_ratio: If True, calculates the missing dimension based on + aspect ratio if one dimension is None. + sizes: A list of ``(width, height)`` tuples, one per item. + If provided this takes precedence over ``width``/``height``. + """ + history_manager = self._editor.history_manager + if not items: + return + + if sizes is not None and len(sizes) != len(items): + logger.error( + "Length of sizes list must match length of items list." + ) + return + + def _calculate_missing_dim( + item: DocItem, w: float | None, h: float | None + ) -> tuple[float, float]: + """Calculates final width and height handling aspect ratio.""" + current_w, current_h = item.size + final_w = w if w is not None else current_w + final_h = h if h is not None else current_h + + if fixed_ratio: + aspect_ratio = item.get_current_aspect_ratio() + if aspect_ratio: + if w is not None and h is None: + final_h = final_w / aspect_ratio + elif h is not None and w is None: + final_w = final_h * aspect_ratio + + return final_w, final_h + + with history_manager.transaction(_("Resize item(s)")) as t: + for i, item in enumerate(items): + old_matrix = item.matrix.copy() + + if sizes is not None: + new_width, new_height = sizes[i] + else: + new_width, new_height = _calculate_missing_dim( + item, width, height + ) + + # The set_size method will rebuild the matrix, + # preserving pos/angle + item.set_size(new_width, new_height) + new_matrix = item.matrix.copy() + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_position_group(self, items: list[DocItem], x: float, y: float): + """Moves the selection so its **combined bounding box** reaches + the given target in machine coordinates. + + The ``(x, y)`` target is the desired machine-space position of the + bounding-box corner that the machine origin refers to (top-left, + top-right, bottom-left or bottom-right). The conversion uses the + combined size of all items, so the whole group lands exactly at + the target. + """ + history_manager = self._editor.history_manager + if not items: + return + + bbox_min_x, bbox_min_y, bbox_max_x, bbox_max_y = self.group_bbox_world( + items + ) + group_size = ( + bbox_max_x - bbox_min_x, + bbox_max_y - bbox_min_y, + ) + + machine = get_context().machine + if machine: + target_world = machine.panel.machine_item_to_world( + (x, y), group_size + ) + else: + target_world = (x, y) + + dx = target_world[0] - bbox_min_x + dy = target_world[1] - bbox_min_y + + if abs(dx) < 1e-9 and abs(dy) < 1e-9: + return + + with history_manager.transaction(_("Move item(s)")) as t: + for item in items: + old_matrix = item.matrix.copy() + new_matrix = Matrix.translation(dx, dy) @ old_matrix + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_angle_group(self, items: list[DocItem], angle: float): + """Rotates the whole selection so the anchor item (``items[0]``) + reaches *angle* degrees. + + The rotation is applied as a world-space delta around the group's + bounding-box centre. Every item receives the same delta, so + relative positions are preserved and the group rotates as a whole. + """ + history_manager = self._editor.history_manager + if not items: + return + + anchor = items[0] + delta_angle = angle - anchor.angle + if abs(delta_angle - round(delta_angle / 360.0) * 360.0) < 1e-9: + return + + center = self.group_center_world(items) + + with history_manager.transaction(_("Change item angle")) as t: + for item in items: + old_matrix = item.matrix.copy() + world_transform_old = item.get_world_transform() + rotate_transform_world = Matrix.rotation( + delta_angle, center=center + ) + world_transform_new = ( + rotate_transform_world @ world_transform_old + ) + + new_matrix = self._world_to_local_matrix( + item, world_transform_new + ) + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_shear_group(self, items: list[DocItem], shear: float): + """Shears the whole selection so the anchor item (``items[0]``) + reaches *shear* degrees. + + The shear delta is applied to each item's local matrix rather than + as a world-space shear around the group centre. This ensures the + operation is stateless and idempotent: shear does not commute with + translation, so composing world-space deltas would accumulate + decomposed drift between pieces and prevent reset from converging. + Every item's local shear changes by the same delta so they all + display the same value. + """ + history_manager = self._editor.history_manager + if not items: + return + + anchor = items[0] + delta_deg = shear - anchor.shear + if abs(delta_deg) < 1e-9: + return + + with history_manager.transaction(_("Change item shear")) as t: + for item in items: + target_shear = item.shear + delta_deg + old_matrix = item.matrix.copy() + item.shear = target_shear + new_matrix = item.matrix.copy() + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def set_size_group( + self, + items: list[DocItem], + width: float | None = None, + height: float | None = None, + fixed_ratio: bool = False, + ): + """Resizes the whole selection uniformly so the combined bounding + box reaches the given *width* and *height* in world space. + + Each item is scaled around the group's centre by the same + (scale_x, scale_y) factor, preserving item-to-item offsets. + + When only one dimension is provided and *fixed_ratio* is ``True``, + the missing dimension is calculated from the group's current aspect + ratio. + """ + history_manager = self._editor.history_manager + if not items: + return + + bbox_min_x, bbox_min_y, bbox_max_x, bbox_max_y = self.group_bbox_world( + items + ) + cur_w = bbox_max_x - bbox_min_x + cur_h = bbox_max_y - bbox_min_y + + if cur_w < 1e-9 or cur_h < 1e-9: + return + + if width is None and height is None: + return + + final_w: float = cur_w + final_h: float = cur_h + + if width is not None: + final_w = width + if height is not None: + final_h = height + + if fixed_ratio: + if width is not None and height is None: + final_h = final_w * cur_h / cur_w + elif height is not None and width is None: + final_w = final_h * cur_w / cur_h + + scale_x = final_w / cur_w + scale_y = final_h / cur_h + + center = ( + (bbox_min_x + bbox_max_x) / 2.0, + (bbox_min_y + bbox_max_y) / 2.0, + ) + + with history_manager.transaction(_("Resize item(s)")) as t: + for item in items: + old_matrix = item.matrix.copy() + world_old = item.get_world_transform() + + # Build world-space scale around centre + scale_matrix = ( + Matrix.identity() + .post_translate(center[0], center[1]) + .post_scale(scale_x, scale_y) + .post_translate(-center[0], -center[1]) + ) + world_new = scale_matrix @ world_old + new_matrix = self._world_to_local_matrix(item, world_new) + + if old_matrix.is_close(new_matrix): + continue + + cmd = ChangePropertyCommand( + target=item, + property_name="matrix", + new_value=new_matrix, + old_value=old_matrix, + ) + t.execute(cmd) + + def reset_position(self, items: list[DocItem]): + """Moves every item's top-left corner to machine (0, 0).""" + self.set_position(items, 0.0, 0.0) + + def reset_position_group(self, items: list[DocItem]): + """Moves the selection's bounding box so its origin-corner sits + at machine (0, 0).""" + self.set_position_group(items, 0.0, 0.0) + + def reset_angle(self, items: list[DocItem]): + """Sets every item's angle to 0°.""" + self.set_angle(items, 0.0) + + def reset_angle_group(self, items: list[DocItem]): + """Resets the group's rotation to 0°.""" + self.set_angle_group(items, 0.0) + + def reset_shear(self, items: list[DocItem]): + """Sets every item's shear to 0°.""" + self.set_shear(items, 0.0) + + def reset_shear_group(self, items: list[DocItem]): + """Resets the group's shear to 0°.""" + self.set_shear_group(items, 0.0) + + @classmethod + def get_position_group( + cls, items: list[DocItem] + ) -> tuple[float, float] | None: + """Machine-coordinate position of the group's bounding-box + origin-corner (the corner the machine origin refers to). + + Returns ``None`` when the list is empty. + """ + if not items: + return None + machine = get_context().machine + min_x, min_y, max_x, max_y = cls.group_bbox_world(items) + gw, gh = max_x - min_x, max_y - min_y + if machine: + return machine.panel.world_item_to_machine( + (min_x, min_y), (gw, gh) + ) + return (min_x, min_y) + + @classmethod + def get_size_group( + cls, items: list[DocItem] + ) -> tuple[float, float] | None: + """World-space (width, height) of the group bounding box.""" + if not items: + return None + min_x, min_y, max_x, max_y = cls.group_bbox_world(items) + return (max_x - min_x, max_y - min_y) + + @classmethod + def get_angle_group(cls, items: list[DocItem]) -> float | None: + """Angle (degrees) of the anchor item, representing the group.""" + if not items: + return None + return items[0].angle + + @classmethod + def get_shear_group(cls, items: list[DocItem]) -> float | None: + """Shear (degrees) of the anchor item, representing the group.""" + if not items: + return None + return items[0].shear diff --git a/rayforge/driver/__init__.py b/rayforge/driver/__init__.py deleted file mode 100644 index a51effb8a..000000000 --- a/rayforge/driver/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# flake8: noqa:F401 -import inspect -from .driver import Driver -from .dummy import NoDeviceDriver -from .grbl import GrblDriver - -def isdriver(obj): - return (inspect.isclass(obj) - and issubclass(obj, Driver) - and not obj is Driver) - -drivers = [obj for obj in list(locals().values()) - if isdriver(obj)] - -driver_by_classname = dict([(o.__name__, o) for o in drivers]) - -def get_driver_cls(classname: str, default=NoDeviceDriver): - return driver_by_classname.get(classname, default) - -def get_driver(classname: str, default=NoDeviceDriver): - return get_driver_cls(classname, default)() - -def get_params(driver_cls): - signature = inspect.signature(driver_cls.setup) - return signature.parameters.items() diff --git a/rayforge/driver/driver.py b/rayforge/driver/driver.py deleted file mode 100644 index d0883ef1c..000000000 --- a/rayforge/driver/driver.py +++ /dev/null @@ -1,212 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional -from blinker import Signal -from gi.repository import GLib -from dataclasses import dataclass -from enum import Enum, auto -from ..transport import TransportStatus -from ..models.ops import Ops -from ..models.machine import Machine - - -class DeviceStatus(Enum): - UNKNOWN = auto() - IDLE = auto() - RUN = auto() - HOLD = auto() - JOG = auto() - ALARM = auto() - DOOR = auto() - CHECK = auto() - HOME = auto() - SLEEP = auto() - TOOL = auto() - QUEUE = auto() - LOCK = auto() - UNLOCK = auto() - CYCLE = auto() - TEST = auto() - - -@dataclass -class DeviceState: - status: int = DeviceStatus.UNKNOWN - machine_pos: tuple[float, float, float] = None, None, None # x, y, z in mm - work_pos: tuple[float, float, float] = None, None, None # x, y, z in mm - feed_rate: int = None - - -def _falsify(func, *args, **kwargs): - """ - Wrapper for GLib.idle_add, as function must return False, otherwise it - is automatically rescheduled into the event loop. - """ - func(*args, **kwargs) - return False - - -class Driver(ABC): - """ - Abstract base class for all drivers. - All drivers must provide the following methods: - - setup() - cleanup() - connect() - run() - move_to() - - All drivers provide the following signals: - log_received: for log messages - state_changed: emitted when the DeviceState changes - command_status_changed: to monitor a command that was sent - connection_status_changed: signals connectivity changes - - Subclasses of driver MUST NOT emit these signals directly; - the should instead call self._log, self,_on_state_changed, etc. - """ - label = None - subtitle = None - - def __init__(self): - self.log_received = Signal() - self.state_changed = Signal() - self.command_status_changed = Signal() - self.connection_status_changed = Signal() - self.did_setup = False - self.state = DeviceState() - - def setup(self): - """ - The type annotations of this method are used to generate a UI - for the user! So if your driver requires any UI parameters, - you should overload this function to ensure that a UI for the - parameters is generated. - - The method will be invoked once the user has provided the arguments - in the UI. - """ - self.did_setup = True - - async def cleanup(self): - self.did_setup = False - - @abstractmethod - async def connect(self) -> None: - """ - Establishes the connection and maintains it. i.e. auto reconnect. - On errors or lost connection it should continue trying. - """ - pass - - @abstractmethod - async def run(self, ops: Ops, machine: Machine) -> None: - """ - Converts the given Ops into commands for the machine, and executes - them. - """ - pass - - @abstractmethod - async def set_hold(self, hold: bool = True) -> None: - """ - Sends a command to put the currently executing program on hold. - If hold is False, sends the command to remove the hold. - """ - pass - - @abstractmethod - async def cancel(self) -> None: - """ - Sends a command to cancel the currently executing program. - """ - pass - - @abstractmethod - async def home(self) -> None: - """ - Sends a command to home machine. - """ - pass - - @abstractmethod - async def move_to(self, pos_x: float, pos_y: float) -> None: - """ - Moves to the given position. Values are given mm. - """ - pass - - def _log(self, message: str): - GLib.idle_add(lambda: _falsify( - self.log_received.send, - self, - message=message - )) - - def _on_state_changed(self): - GLib.idle_add(lambda: _falsify( - self.state_changed.send, - self, - state=self.state - )) - - def _on_command_status_changed(self, - status: TransportStatus, - message: Optional[str] = None): - GLib.idle_add(lambda: _falsify( - self.command_status_changed.send, - self, - status=status, - message=message - )) - - def _on_connection_status_changed(self, - status: TransportStatus, - message: Optional[str] = None): - GLib.idle_add(lambda: _falsify( - self.connection_status_changed.send, - self, - status=status, - message=message - )) - - -class DriverManager: - def __init__(self): - self.driver = None - self.changed = Signal() - - async def _assign_driver(self, driver, **args): - self.driver = driver - self._on_driver_changed() - self.driver.setup(**args) - await self.driver.connect() - - async def _reconfigure_driver(self, **args): - await self.driver.cleanup() - self._on_driver_changed() - self.driver.setup(**args) - await self.driver.connect() - - async def _switch_driver(self, driver, **args): - await self.driver.cleanup() - del self.driver - await self._assign_driver(driver, **args) - - def _on_driver_changed(self): - GLib.idle_add(lambda: _falsify( - self.changed.send, - self, - driver=self.driver - )) - - async def select_by_cls(self, driver_cls, **args): - if self.driver and self.driver.__class__ == driver_cls: - await self._reconfigure_driver(**args) - elif self.driver: - await self._switch_driver(driver_cls(), **args) - else: - await self._assign_driver(driver_cls(), **args) - - -driver_mgr = DriverManager() diff --git a/rayforge/driver/dummy.py b/rayforge/driver/dummy.py deleted file mode 100644 index c7fdb5d71..000000000 --- a/rayforge/driver/dummy.py +++ /dev/null @@ -1,28 +0,0 @@ -from .driver import Driver -from ..models.ops import Ops - - -class NoDeviceDriver(Driver): - """ - A dummy driver that is used if the user has no machine. - """ - label = 'No driver' - subtitle = 'No connection' - - async def connect(self) -> None: - pass - - async def run(self, ops: Ops) -> None: - pass - - async def set_hold(self, hold: bool = True) -> None: - pass - - async def cancel(self) -> None: - pass - - async def home(self) -> None: - pass - - async def move_to(self, pos_x, pos_y) -> None: - pass diff --git a/rayforge/driver/grbl.py b/rayforge/driver/grbl.py deleted file mode 100644 index 7552fa43f..000000000 --- a/rayforge/driver/grbl.py +++ /dev/null @@ -1,280 +0,0 @@ -import re -import asyncio -import aiohttp -from copy import copy -from typing import Optional -from ..transport import HttpTransport, WebSocketTransport, TransportStatus -from ..opsencoder.gcode import GcodeEncoder -from ..models.ops import Ops -from ..models.machine import Machine -from .driver import Driver, DeviceStatus - - -hw_info_url = '/command?plain=%5BESP420%5D&PAGEID=' -fw_info_url = '/command?plain=%5BESP800%5D&PAGEID=' -eeprom_info_url = '/command?plain=%5BESP400%5D&PAGEID=' -command_url = '/command?commandText={command}&PAGEID=' -upload_url = '/upload' -upload_list_url = '/upload?path=/&PAGEID=0' -execute_url = '/command?commandText=%5BESP220%5D/{filename}' -status_url = command_url.format(command='?') - -pos_re = re.compile(r':(\d+\.\d+),(\d+\.\d+),(\d+\.\d+)') -fs_re = re.compile(r'FS:(\d+),(\d+)') - - -def _parse_pos_triplet(pos, default=None): - match = pos_re.search(pos) - if not match or match.lastindex != 3: - return default - return [float(i) for i in match.groups()] - - -class GrblDriver(Driver): - """ - Handles GRBL based devices via HTTP+WebSocket - """ - label = "GRBL" - subtitle = 'Send GRBL-compatible Gcode via network connection' - - def __init__(self): - super().__init__() - self.encoder = GcodeEncoder() - self.http = None - self.websocket = None - self.keep_running = False - self._connection_task: Optional[asyncio.Task] = None - - def setup(self, host: str): - assert not self.did_setup - super().setup() - - # Initialize transports - self.http_base = f'http://{host}' - self.http = HttpTransport( - f'{self.http_base}{status_url}', - receive_interval=.5 - ) - self.http.received.connect(self.on_http_data_received) - self.http.status_changed.connect(self.on_http_status_changed) - - self.websocket = WebSocketTransport( - f'ws://{host}:81/', - self.http_base - ) - self.websocket.received.connect(self.on_websocket_data_received) - self.websocket.status_changed.connect(self.on_websocket_status_changed) - - async def cleanup(self): - self.keep_running = False - if self._connection_task: - self._connection_task.cancel() - if self.websocket: - await self.websocket.disconnect() - self.websocket.received.disconnect(self.on_websocket_data_received) - self.websocket.status_changed.disconnect( - self.on_websocket_status_changed - ) - self.websocket = None - if self.http: - await self.http.disconnect() - self.http.received.disconnect(self.on_http_data_received) - self.http.status_changed.disconnect(self.on_http_status_changed) - self.http = None - await super().cleanup() - - async def _get_hardware_info(self): - async with aiohttp.ClientSession() as session: - async with session.get( - f"{self.http_base}{hw_info_url}" - ) as response: - data = await response.text() - return data - - async def _get_firmware_info(self): - async with aiohttp.ClientSession() as session: - async with session.get( - f"{self.http_base}{fw_info_url}" - ) as response: - data = await response.text() - return data - - async def _get_eeprom_info(self): - async with aiohttp.ClientSession() as session: - async with session.get( - f"{self.http_base}{eeprom_info_url}" - ) as response: - data = await response.text() - return data - - async def _send_command(self, command): - async with aiohttp.ClientSession() as session: - url = command_url.format(command=command) - async with session.get( - f"{self.http_base}{url}" - ) as response: - data = await response.text() - return data - - async def _upload(self, gcode, filename): - form = aiohttp.FormData([]) - form.add_field('path', '/') - form.add_field(f'/{filename}S', str(len(gcode))) - form.add_field('myfile[]', gcode, filename=filename) - async with aiohttp.ClientSession() as session: - async with session.post( - f"{self.http_base}{upload_url}", - data=form - ) as response: - data = await response.text() - return data - - async def _execute(self, filename): - async with aiohttp.ClientSession() as session: - url = execute_url.format(filename=filename) - async with session.get(f"{self.http_base}{url}") as response: - data = await response.text() - await session.close() - return data - - async def connect(self): - self.keep_running = True - self._connection_task = asyncio.create_task(self._connection_loop()) - - async def _connection_loop(self) -> None: - while self.keep_running: - self._on_connection_status_changed(TransportStatus.CONNECTING) - try: - hw_info = await self._get_hardware_info() - self._log(hw_info) - fw_info = await self._get_firmware_info() - self._log(fw_info) - eeprom_info = await self._get_eeprom_info() - self._log(eeprom_info) - - async with asyncio.TaskGroup() as tg: - tg.create_task(self.http.connect()) - tg.create_task(self.websocket.connect()) - except Exception as e: - self._on_connection_status_changed( - TransportStatus.ERROR, - str(e) - ) - finally: - if self.websocket: - await self.websocket.disconnect() - if self.http: - await self.http.disconnect() - - self._on_connection_status_changed(TransportStatus.SLEEPING) - await asyncio.sleep(5) - - async def run(self, ops: Ops, machine: Machine) -> None: - gcode = self.encoder.encode(ops, machine) - - try: - await self._upload(gcode, 'rayforge.gcode') - await self._execute('rayforge.gcode') - except Exception as e: - self._on_connection_status_changed( - TransportStatus.ERROR, - str(e) - ) - raise - - async def set_hold(self, hold: bool = True) -> None: - if hold: - await self._send_command('!') - else: - await self._send_command('~') - - async def cancel(self) -> None: - await self._send_command('%18') - - async def home(self) -> None: - await self._send_command('$H') - - async def move_to(self, pos_x, pos_y) -> None: - cmd = f"$J=G90 G21 F1500 X{float(pos_x)} Y{float(pos_y)}" - await self._send_command(cmd) - - def on_http_data_received(self, sender, data: bytes): - pass - - def on_http_status_changed(self, - sender, - status: TransportStatus, - message: Optional[str] = None): - self._on_command_status_changed(status, message) - - def _parse_state(self, state_str): - """ - Example state_str: - Run|MPos:10.0,20.0,0.0|WPos:10.0,20.0,0.0|W0:10.0,20.0,0.0|FS:1500,0 - - - First field is always the status. - - MPos is position in machine coords. - - WPos is position in work coords. - - No idea what W0 is. - - FS: tuple of feed rate and spindle speed - - Also note that not always all fields are included, and sometimes - others not listed here appear. - """ - # Split out the status. - try: - status, *attribs = state_str.split('|') - status = status.split(':')[0] - except ValueError: - return - - state = copy(self.state) - try: - state.status = DeviceStatus[status.upper()] - except KeyError: - self.log_received.send( - self, - message=f"device sent an unupported status: {status}" - ) - - # Parse the substrings. - for attrib in attribs: - if attrib.startswith('MPos:'): - state.machine_pos = _parse_pos_triplet( - attrib, - state.machine_pos - ) - - elif attrib.startswith('WPos:'): - state.work_pos = _parse_pos_triplet(attrib, state.work_pos) - - elif attrib.startswith('FS:'): - try: - match = fs_re.match(attrib) - fs = [int(i) for i in match.groups()] - state.feed_rate = int(fs[0]) - # We ignore fs[1] (="spindle speed") - except (ValueError, IndexError): - pass - - else: - pass # Ignore everything else - - return state - - def on_websocket_data_received(self, sender, data: bytes): - data = data.decode('utf-8') - for line in data.splitlines(): - self._log(line) - if not line.startswith('<') or not line.endswith('>'): - continue - state = self._parse_state(line[1:-1]) - if state != self.state: - self.state = state - self._on_state_changed() - - def on_websocket_status_changed(self, - sender, - status: TransportStatus, - message: Optional[str] = None): - self._on_connection_status_changed(status, message) diff --git a/rayforge/image/__init__.py b/rayforge/image/__init__.py new file mode 100644 index 000000000..def13cfd8 --- /dev/null +++ b/rayforge/image/__init__.py @@ -0,0 +1,272 @@ +import inspect +import logging +import mimetypes +from pathlib import Path + +from ..core.item import DocItem +from ..core.source_asset import SourceAsset +from ..core.vectorization_spec import PassthroughSpec, VectorizationSpec +from ..core.workpiece import WorkPiece +from .base_exporter import BaseExporter +from .base_importer import ( + Importer, + ImporterFeature, +) +from .base_renderer import 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 .jpg.importer import JpgImporter +from .jpg.renderer import JPG_RENDERER +from .lightburn.importer import LightBurnImporter +from .lightburn.renderer import LIGHTBURN_RENDERER +from .material_test_grid_renderer import MaterialTestRenderer +from .ops_renderer import OPS_RENDERER +from .pdf.importer import PdfImporter +from .pdf.renderer import PDF_RENDERER +from .png.importer import PngImporter +from .png.renderer import PNG_RENDERER +from .procedural.renderer import PROCEDURAL_RENDERER +from .registry import ( + exporter_registry, + importer_registry, + renderer_registry, +) +from .ruida.importer import RuidaImporter +from .ruida.renderer import RUIDA_RENDERER +from .structures import ( + ImportManifest, + ImportPayload, + ImportResult, + LayerInfo, + ParsingResult, +) +from .svg.exporter import GeometrySvgExporter +from .svg.importer import SvgImporter +from .svg.renderer import SVG_RENDERER + +logger = logging.getLogger(__name__) + + +def isimporter(obj): + return ( + inspect.isclass(obj) + and issubclass(obj, Importer) + and obj is not Importer + ) + + +for name, obj in list(locals().items()): + if isimporter(obj): + importer_registry.register(obj) + + +def isexporter(obj): + return ( + inspect.isclass(obj) + and issubclass(obj, BaseExporter) + and obj is not BaseExporter + ) + + +for name, obj in list(locals().items()): + if isexporter(obj): + exporter_registry.register(obj) + + +def _hydrate_workpieces_for_preview( + items: list["DocItem"], source: "SourceAsset" +): + """ + Recursively finds all WorkPieces in a list of items and attaches the + transient renderer and data required for previews. + """ + for item in items: + if isinstance(item, WorkPiece): + item._renderer = source.renderer + # Set the transient data, preferring the processed version + # (e.g., cropped SVG) if it exists. + item._data = source.base_render_data or source.original_data + + # Recurse into children of containers (like Groups) + if hasattr(item, "children") and item.children: + _hydrate_workpieces_for_preview(item.children, source) + + +def import_file_from_bytes( + file_data: bytes, + source_file_name: str, + mime_type: str, + vectorization_spec: VectorizationSpec | None = None, +) -> ImportPayload | None: + """ + Imports a file from raw byte data. Used for previews and in-memory + operations where a file path is not available or desirable. + + Args: + file_data: The raw bytes of the file. + source_file_name: The original name of the file (for context). + mime_type: The MIME type to determine the importer. + vectorization_spec: An optional VectorizationSpec for vectorization. + + Returns: + An ImportPayload or None on failure. + """ + logger.debug( + f"import_file_from_bytes: file_data_len={len(file_data)}, " + f"source_file_name={source_file_name}, mime_type={mime_type}" + ) + importer_class = importer_registry.get_by_mime_type(mime_type) + if not importer_class: + logger.error(f"No importer found for MIME type: {mime_type}") + return None + + try: + source_file = Path(source_file_name) + importer = importer_class(file_data, source_file=source_file) + + # If no spec is given (e.g., initial preview), default to Passthrough + spec_to_use = vectorization_spec or PassthroughSpec() + + import_result = importer.get_doc_items(spec_to_use) + + if not import_result: + return None + + payload = import_result.payload + # Hydrate the temporary WorkPiece(s) with a direct renderer AND data + # link so they can be rendered without being part of a full document. + if payload and payload.source: + _hydrate_workpieces_for_preview(payload.items, payload.source) + + return payload + except Exception as e: + logger.error( + f"Importer {importer_class.__name__} " + f"failed for {source_file_name}", + exc_info=e, + ) + return None + + +def import_file( + source: Path | bytes, + mime_type: str | None = None, + vectorization_spec: VectorizationSpec | None = None, +) -> ImportPayload | None: + """ + A high-level convenience function to import a file from a path or raw + data. It automatically determines the correct importer to use. + + The importer is chosen based on this priority: + 1. The provided `mime_type` override. + 2. The MIME type guessed from the filename (if `source` is a Path). + 3. The file extension (if `source` is a Path). + + Args: + source: The pathlib.Path to the file or the raw bytes data. + mime_type: An optional MIME type to force a specific importer. + vectorization_spec: An optional VectorizationSpec for vectorization. + + Returns: + An ImportPayload containing the source and doc items, or None if + the import fails or no suitable importer is found. + """ + # If source is a path and no override is given, guess the MIME type. + if isinstance(source, Path) and not mime_type: + mime_type, _ = mimetypes.guess_type(source) + + # 1. Determine importer class + importer_class: type[Importer] | None = None + if mime_type: + importer_class = importer_registry.get_by_mime_type(mime_type) + + if not importer_class and isinstance(source, Path): + file_extension = source.suffix.lower() + if file_extension: + importer_class = importer_registry.get_by_extension(file_extension) + + if not importer_class: + logger.error(f"No importer found for source: {source}") + return None + + # 2. Prepare data and source path + if isinstance(source, Path): + source_file = source + try: + file_data = source.read_bytes() + except OSError as e: + logger.error(f"Could not read file {source}: {e}") + return None + else: # is bytes + source_file = Path("Untitled") + file_data = source + + logger.debug( + f"import_file: file_data_len={len(file_data)}, " + f"source_file={source_file}, mime_type={mime_type}" + ) + + # 3. Execute importer + try: + importer = importer_class(file_data, source_file=source_file) + import_result = importer.get_doc_items(vectorization_spec) + # Unpack the result to return only the payload, maintaining the API + return import_result.payload if import_result else None + except Exception as e: + logger.error( + f"Importer {importer_class.__name__} failed for {source_file}", + exc_info=e, + ) + return None + + +_RENDERERS = [ + BMP_RENDERER, + DXF_RENDERER, + LIGHTBURN_RENDERER, + PROCEDURAL_RENDERER, + JPG_RENDERER, + MaterialTestRenderer(), + OPS_RENDERER, + PNG_RENDERER, + PDF_RENDERER, + RUIDA_RENDERER, + SVG_RENDERER, +] + +for renderer in _RENDERERS: + renderer_registry.register(renderer) + + +def get_renderer_for_asset(asset_type: str) -> Renderer | None: + """Get the renderer for an asset type.""" + return renderer_registry.get(asset_type) + + +__all__ = [ + "BmpImporter", + "DxfImporter", + "GeometryDxfExporter", + "GeometrySvgExporter", + "ImportManifest", + "ImportPayload", + "ImportResult", + "ImporterFeature", + "JpgImporter", + "LayerInfo", + "LightBurnImporter", + "ParsingResult", + "PdfImporter", + "PngImporter", + "RuidaImporter", + "SvgImporter", + "exporter_registry", + "get_renderer_for_asset", + "import_file", + "import_file_from_bytes", + "importer_registry", + "renderer_registry", +] diff --git a/rayforge/image/assembler.py b/rayforge/image/assembler.py new file mode 100644 index 000000000..220ffce5d --- /dev/null +++ b/rayforge/image/assembler.py @@ -0,0 +1,245 @@ +import logging +from typing import Any + +from raygeo.geo import Geometry +from raygeo.geo.types import Rect + +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.step_registry import step_registry +from ..core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + VectorizationSpec, +) +from ..core.workpiece import WorkPiece +from .structures import LayoutItem + +logger = logging.getLogger(__name__) + + +class ItemAssembler: + """ + Phase 5: Object Assembly. + + Factory that instantiates Rayforge domain objects (WorkPieces, Layers) + based on the LayoutPlan calculated by the NormalizationEngine. + + Coordinate System Contract: + -------------------------- + Input (LayoutItem): + - crop_window: Native Coordinates (file-specific units) + - normalization_matrix: Transforms Native -> Normalized (0-1, Y-Up) + - world_matrix: Transforms Normalized (0-1, Y-Up) -> World (mm, Y-Up) + + Output (DocItems): + - WorkPieces and Layers are positioned in World Coordinates (mm, Y-Up) + - The transformation matrices are applied to the WorkPiece.matrix + - Origin (0,0) is at the bottom-left of the workpiece + + Frame of Reference: + ------------------ + - crop_window is absolute in the document's native coordinate space + - The world_matrix positions the workpiece in the world coordinate system + - All output DocItems are ready for insertion into the document + + Error Handling: + --------------- + This class does not collect errors. It assumes valid input from the + NormalizationEngine. Invalid inputs may produce undefined results. + """ + + def create_items( + self, + source_asset: SourceAsset, + layout_plan: list[LayoutItem], + spec: VectorizationSpec, + source_name: str, + geometries: dict[str | None, Geometry], + document_bounds: Rect | None = None, + ) -> list[DocItem]: + """ + Creates DocItems from the layout plan. + + Instantiates WorkPieces and Layers based on the LayoutItem + configurations calculated by the NormalizationEngine. + + Coordinate System: + ------------------ + Input: + - layout_plan: LayoutItems with transformation matrices + - geometries: Geometry in Native Coordinates + - document_bounds: Optional bounds in Native Coordinates + + Output: + - DocItems (WorkPieces or Layers) positioned in World Coordinates + (mm, Y-Up) with transformation matrices applied + + Args: + source_asset: The SourceAsset representing the imported file. + layout_plan: List of LayoutItem configurations from + NormalizationEngine. Each contains transformation + matrices and crop windows. + spec: VectorizationSpec describing the vectorization approach. + Determines whether to create Layers for split items. + source_name: Base name for the resulting DocItems. + geometries: Dict of Geometry objects keyed by layer ID. + Geometry is in Native Coordinates. + document_bounds: Optional document bounds in Native Coordinates. + Used for debugging and reference. + + Returns: + List of DocItems (WorkPieces or Layers) ready for insertion + into the document. May be empty if layout_plan is empty. + + Frame of Reference: + ------------------ + - crop_window in LayoutItem is absolute in native coordinate space + - For raster sources: crop_window contains pixel coordinates + - For vector sources (SVG): crop_window contains native user-units + - The WorkPiece/Renderer logic handles the distinction + + Error Handling: + --------------- + This method assumes valid input from the NormalizationEngine. + Invalid inputs may produce undefined results. + """ + if not layout_plan: + return [] + + # If we have multiple items, we generally wrap them in Layers (if + # requested by spec) or return a list of WorkPieces. + items: list[DocItem] = [] + + logger.debug(f"ItemAssembler: document_bounds={document_bounds}") + + for item in layout_plan: + # 1. Create the Segment + # This links the WorkPiece to the specific subset of the source + # file + geo: Geometry | None = None + if item.layer_id is not None: + # Split strategy: get geometry for the specific layer + geo = geometries.get(item.layer_id) + else: + # Merge strategy: combine all available geometries + if geometries: + merged_geo = Geometry() + for g in geometries.values(): + if g and not g.is_empty(): + merged_geo.extend(g) + if not merged_geo.is_empty(): + geo = merged_geo + + # The `item.crop_window` is in absolute native coordinates. + # For rendering trimmed vector files (like SVG), the renderer + # needs an absolute viewBox to render the correct portion of the + # source file. We pass this through directly. + # NOTE: For raster sources, this field contains pixel coordinates. + # For vector sources (SVG), it contains native user-units. The + # respective WorkPiece/Renderer logic must handle this distinction. + logger.debug( + f"ItemAssembler: item.crop_window={item.crop_window}, " + f"layer_id={item.layer_id}" + ) + + segment = SourceAssetSegment( + source_asset_uid=source_asset.uid, + vectorization_spec=spec, + layer_id=item.layer_id, + pristine_geometry=geo, + normalization_matrix=item.normalization_matrix, + crop_window_px=item.crop_window, + ) + + # Note: We should probably store physical dimensions on the segment + # for split/crop reference, calculated from the world matrix scale. + w_mm, h_mm = item.world_matrix.get_abs_scale() + segment.cropped_width_mm = w_mm + segment.cropped_height_mm = h_mm + + # 2. Create the WorkPiece + # Prioritize human-readable name from layout item, fallback to ID, + # then to the overall source name. + name = ( + item.layer_name + if item.layer_name + else (item.layer_id if item.layer_id else source_name) + ) + wp = WorkPiece(name=name, source_segment=segment) + + # 3. Apply Physical Transforms + wp.matrix = item.world_matrix + wp.natural_width_mm = w_mm + wp.natural_height_mm = h_mm + + # 4. Wrap in Layer if splitting is active and meaningful + # Hack: Sketches (layer_id="__default__") should never be wrapped + # in a layer + if ( + item.layer_id + and item.layer_id != "__default__" + and isinstance(spec, PassthroughSpec) + and spec.layer_import_mode != LayerImportMode.FLATTEN + ): + layer = Layer(name=name) + if item.color: + layer.set_color(item.color) + layer.add_child(wp) + # If the importer provided settings, pre-populate a step + # on the layer's workflow so that add_default_steps_for_layers + # will skip it (workflow.has_steps() -> True). + if item.settings: + self._apply_settings(layer, item.settings) + items.append(layer) + else: + items.append(wp) + + return items + + @staticmethod + def _apply_settings(layer: Layer, settings: dict[str, Any]) -> None: + """Create a configured step on the layer from importer settings. + + Layers tagged ``_is_image_layer`` (e.g. LightBurn + ``CutSetting_Img``) get an ``EngraveStep``; everything else gets + a ``ContourStep``. + + The settings dict uses the step's own attribute names + (canonicalised by the importer); the step applies the domain + settings it owns via :meth:`Step.apply_import_settings`. Only + the structural ``passes`` value (a transformer config) is + handled here. + """ + is_image = bool(settings.get("_is_image_layer")) + if is_image: + step_name, typelabel = "EngraveStep", "Engrave" + else: + step_name, typelabel = "ContourStep", "Contour" + cls = step_registry.get(step_name) + if cls is None: + return + + try: + step = cls(typelabel=typelabel, name=layer.name) + per_wp, per_step = cls.get_default_transformers_dicts() + step.per_workpiece_transformers_dicts = per_wp + step.per_step_transformers_dicts = per_step + except Exception: + logger.exception("Failed to set up step from settings") + return + + step.apply_import_settings(settings) + + passes = settings.get("passes") + if passes is not None: + for t in step.per_step_transformers_dicts: + if t.get("name") == "MultiPassTransformer": + t["passes"] = passes + break + + workflow = layer.workflow + if workflow is not None: + workflow.add_step(step) diff --git a/rayforge/image/base_exporter.py b/rayforge/image/base_exporter.py new file mode 100644 index 000000000..6026491dd --- /dev/null +++ b/rayforge/image/base_exporter.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..core.item import DocItem + + +class BaseExporter(ABC): + """ + Abstract base class for exporters that work with Geometry objects. + """ + + label: str + extensions: tuple[str, ...] + mime_types: tuple[str, ...] + + @abstractmethod + def export(self) -> bytes: + """ + Performs the export operation. + + Returns: + The exported data as a bytes object. + """ + raise NotImplementedError + + +class Exporter(BaseExporter): + """ + An abstract base class that defines the interface for all exporters. + An exporter takes a DocItem and converts it to a specific file format + represented as bytes. + """ + + label: str + extensions: tuple[str, ...] + mime_types: tuple[str, ...] + + def __init__(self, doc_item: DocItem): + """ + Initializes the exporter with the document item to be exported. + + Args: + doc_item: The DocItem instance to export. + """ + self.doc_item = doc_item diff --git a/rayforge/image/base_importer.py b/rayforge/image/base_importer.py new file mode 100644 index 000000000..daf92c6b8 --- /dev/null +++ b/rayforge/image/base_importer.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import enum +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +import pyvips + +from ..core.vectorization_spec import PassthroughSpec, TraceSpec +from .assembler import ItemAssembler +from .engine import NormalizationEngine + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from ..core.source_asset import SourceAsset + from ..core.vectorization_spec import VectorizationSpec + from .structures import ( + ImportManifest, + ImportPayload, + ImportResult, + ParsingResult, + VectorizationResult, + ) + + +class ImporterFeature(enum.Flag): + """ + Defines the capabilities of an Importer class. + """ + + NONE = 0 + BITMAP_TRACING = enum.auto() + DIRECT_VECTOR = enum.auto() + LAYER_SELECTION = enum.auto() + COLOR_LAYERS = enum.auto() + PROCEDURAL_GENERATION = enum.auto() + + +class Importer(ABC): + """ + An abstract base class that defines the interface for all importers. + + An Importer acts as a factory, taking raw file data and producing a + self-contained `ImportResult`. This result contains the `ImportPayload` + (the `SourceAsset` and `DocItem`s) and the `ParsingResult` (geometric + facts used for contextual rendering). + + Five-Phase Import Pipeline: + --------------------------- + 1. **Scan (Phase 1)**: Extract metadata without full processing. + Returns ImportManifest with layer info, natural size, warnings/errors. + + 2. **Parse (Phase 2)**: Extract geometric facts from the file. + Returns ParsingResult with bounds, coordinate system info, layers. + + 3. **Vectorize (Phase 3)**: Convert parsed data to vector geometry. + Returns VectorizationResult with Geometry objects per layer. + + 4. **Layout (Phase 4)**: Calculate transformations for positioning. + NormalizationEngine produces LayoutItem with transformation matrices. + + 5. **Assemble (Phase 5)**: Create final DocItems. + ItemAssembler produces WorkPieces and Layers ready for insertion. + + Coordinate System Contract: + -------------------------- + Importers must handle coordinate systems correctly: + + **Native Coordinates (Input/Output of parse/vectorize):** + - File-specific coordinate system (SVG user units, DXF units, pixels) + - Y-axis orientation varies by format + - All bounds are absolute within the document's coordinate space + - Units are converted to mm via native_unit_to_mm factor + + **World Coordinates (Final output):** + - Physical world coordinates in millimeters (mm) + - Y-axis points UP (Y-Up convention) + - Origin (0,0) is at the bottom-left of the workpiece + - All positions are absolute in the world coordinate system + + **Y-Down vs Y-Up:** + - Y-Down formats (SVG, images): origin at top-left, Y increases downward + - Y-Up formats (DXF): origin at bottom-left, Y increases upward + - Importers must set is_y_down flag correctly in ParsingResult + - NormalizationEngine handles Y-inversion for Y-Down sources + + Frame of Reference: + ------------------ + - document_bounds are absolute in the document's native coordinate space + - For Y-Down: origin is at top-left + - For Y-Up: origin is at bottom-left + - untrimmed_document_bounds provides reference for Y-inversion + - world_frame_of_reference provides stable world coordinate frame + + Architectural Contract: + ----------------------- + To prevent "double transformation" bugs, all importers MUST follow a + strict separation of concerns between an object's intrinsic shape and + its physical transformation in the document. + + 1. **Generate Normalized Vectors**: The vector geometry created by the + importer should represent the object's SHAPE, normalized to a standard + unit size (e.g., fitting within a 1x1 box) while preserving the + original aspect ratio. + + 2. **Assign to WorkPiece**: This normalized `Geometry` is assigned to + `WorkPiece.boundaries`. At this point, the `WorkPiece`'s transformation + matrix should be the identity matrix (scale=1). + + 3. **Apply Physical Size via Matrix**: The importer then determines the + object's intended physical size in millimeters and calls + `WorkPiece.set_size()`. This method correctly applies the physical + dimensions by modifying the `WorkPiece.matrix`, scaling the + normalized vectors to their final size. + + This ensures that the scale is applied only once, through the matrix, + and that `WorkPiece.boundaries` remains a pure representation of shape. + + Error Handling Rules: + -------------------- + **scan() and parse() methods:** + - These methods COLLECT errors via add_error() and add_warning() + - They must NEVER raise exceptions for expected error conditions + - Errors are stored in self._errors and self._warnings lists + - ImportManifest and ParsingResult include errors/warnings fields + - The presence of errors does not prevent returning results + + **Other public methods (vectorize, create_source_asset, get_doc_items):** + - These methods MAY raise exceptions for unexpected conditions + - They should assume valid input from earlier phases + - Errors during these phases are also collected via add_error() + - The get_doc_items() template method handles error collection + + **General rules:** + - Use add_warning() for non-critical issues that don't prevent import + - Use add_error() for problems that may affect the result quality + - Always return a result object (even if partial) rather than None + - The ImportResult wrapper contains all collected errors/warnings + """ + + label: str + mime_types: tuple[str, ...] + extensions: tuple[str, ...] + + # The base set of features is empty. Subclasses MUST override this. + features: ClassVar[set[ImporterFeature]] = set() + + def __init__(self, data: bytes, source_file: Path | None = None): + """ + The constructor that all subclasses must implement. + """ + self.raw_data = data + self.source_file = source_file or Path("Untitled") + self._warnings: list[str] = [] + self._errors: list[str] = [] + self._vectorization_spec: VectorizationSpec | None = None + + def add_warning(self, message: str) -> None: + """Records a warning message to be displayed to the user.""" + self._warnings.append(message) + + def add_error(self, message: str) -> None: + """Records an error message to be displayed to the user.""" + self._errors.append(message) + + @abstractmethod + def scan(self) -> ImportManifest: + """ + Phase 1: Lightweight file scan. + + Extracts metadata and structural information without full processing. + This method should be fast and avoid heavy computation like pixel + processing or full geometry conversion. + + Coordinate System: + ------------------ + natural_size_mm in returned ImportManifest should be in World + Coordinates (mm, Y-Up) representing the document's physical size. + + Error Handling: + --------------- + This method COLLECTS errors via add_error() and add_warning(). + It must NEVER raise exceptions for expected error conditions. + Errors are included in the returned ImportManifest. + The presence of errors does not prevent returning a result. + + Returns: + ImportManifest describing the file's contents, including layers, + natural size, and any warnings/errors encountered. + """ + raise NotImplementedError + + @abstractmethod + def parse(self) -> ParsingResult | None: + """ + Phase 2: Parse raw data into geometric facts. + + Extracts geometric information from the file including bounds, + coordinate system details, and layer information. + + Coordinate System: + ------------------ + Returned ParsingResult must have: + - document_bounds: Native Coordinates (file-specific units) + - is_y_down: True for Y-Down (SVG, images), False for Y-Up (DXF) + - native_unit_to_mm: Conversion factor to millimeters + - world_frame_of_reference: World Coordinates (mm, Y-Up) + - layers: List of LayerGeometry with content_bounds in Native Coords + + Frame of Reference: + ------------------ + - document_bounds are absolute in the document's native coordinate + space + - For Y-Down formats: origin at top-left + - For Y-Up formats: origin at bottom-left + - untrimmed_document_bounds provides reference for Y-inversion + + Error Handling: + --------------- + This method COLLECTS errors via add_error() and add_warning(). + It must NEVER raise exceptions for expected error conditions. + Errors are stored in self._errors and self._warnings lists. + The presence of errors does not prevent returning a result. + + Returns: + ParsingResult containing geometric facts about the file, + or None if parsing fails completely. + """ + raise NotImplementedError + + @abstractmethod + def vectorize( + self, parse_result: ParsingResult, spec: VectorizationSpec + ) -> VectorizationResult: + """ + Phase 3: Convert parsed data to vector geometry. + + Converts the parsed data into vector Geometry objects according to the + VectorizationSpec. + + Coordinate System: + ------------------ + Returned VectorizationResult geometries_by_layer must be in Native + Coordinates (file-specific units) as specified in parse_result. + The NormalizationEngine will handle conversion to World Coordinates. + + Args: + parse_result: The ParsingResult from the parse() method. + Contains coordinate system metadata (is_y_down, + native_unit_to_mm, etc.). + spec: The VectorizationSpec describing how to vectorize. + Either PassthroughSpec (direct vector) or TraceSpec + (bitmap tracing). + + Returns: + VectorizationResult containing the vectorized geometry per layer. + The source_parse_result must reference the input parse_result. + + Error Handling: + --------------- + This method MAY raise exceptions for unexpected conditions. + It should assume valid input from earlier phases. + Errors can also be collected via add_error() for reporting. + """ + raise NotImplementedError + + @abstractmethod + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset representing the imported file. + + The SourceAsset provides access to the original file data for + rendering and reference purposes. + + Args: + parse_result: The ParsingResult from the parse() method. + Contains document bounds and coordinate system info. + + Returns: + A SourceAsset for the imported file. The asset should store + raw file data and any metadata needed for rendering. + + Error Handling: + --------------- + This method MAY raise exceptions for unexpected conditions. + It should assume valid input from earlier phases. + """ + raise NotImplementedError + + def _stamp_importer_identity(self, source_asset: SourceAsset) -> None: + source_asset.metadata["_importer_class"] = type(self).__name__ + if self.mime_types: + source_asset.metadata["_importer_mime"] = self.mime_types[0] + + @staticmethod + def _render_thumbnail_from_vips(image, size: int = 256) -> bytes | None: + if image is None: + return None + try: + thumb = image.thumbnail_image(size, height=size, size="both") + return thumb.pngsave_buffer() + except pyvips.Error: + return None + + @staticmethod + def _render_thumbnail_from_renderer( + renderer, data: bytes | None, size: int = 256 + ) -> bytes | None: + if not data: + return None + try: + image = renderer.render_base_image(data, size, size) + if image: + return image.pngsave_buffer() + except Exception: + logger.debug("Failed to render thumbnail", exc_info=True) + return None + + def _resolve_default_spec(self) -> VectorizationSpec: + if ImporterFeature.DIRECT_VECTOR in self.features: + return PassthroughSpec() + elif ImporterFeature.BITMAP_TRACING in self.features: + return TraceSpec(threshold=1.0, auto_threshold=False) + else: + return PassthroughSpec() + + def _run_pipeline( + self, + vectorization_spec: VectorizationSpec, + source_asset: SourceAsset, + parse_result: ParsingResult | None = None, + ) -> ImportResult: + """ + Shared phases 2–5 of the import pipeline. + + Args: + vectorization_spec: Resolved spec (never None). + source_asset: The asset to reference in assembled items. + parse_result: Optional pre-computed parse result. When + omitted (or None) parse() is called automatically. + """ + from .structures import ( + ImportPayload, + ImportResult, + VectorizationResult, + ) + + self._vectorization_spec = vectorization_spec + + # Phase 2: Parse (unless caller already did) + if parse_result is None: + parse_result = self.parse() + if not parse_result: + return ImportResult( + payload=None, + parse_result=None, + warnings=self._warnings, + errors=self._errors, + ) + + # Phase 3: Vectorize + spec = self._vectorization_spec + + if not parse_result.layers and isinstance(spec, TraceSpec): + return ImportResult( + payload=ImportPayload(source=source_asset, items=[]), + parse_result=parse_result, + vectorization_result=VectorizationResult( + geometries_by_layer={}, + source_parse_result=parse_result, + ), + warnings=self._warnings, + errors=self._errors, + ) + + vec_result = self.vectorize(parse_result, spec) + + # Phase 4: Layout + engine = NormalizationEngine() + plan = engine.calculate_layout(vec_result, spec) + + if not plan: + return ImportResult( + payload=ImportPayload(source=source_asset, items=[]), + parse_result=parse_result, + vectorization_result=vec_result, + warnings=self._warnings, + errors=self._errors, + ) + + # Phase 5: Assemble + assembler = ItemAssembler() + items = assembler.create_items( + source_asset=source_asset, + layout_plan=plan, + spec=spec, + source_name=self.source_file.stem, + geometries=vec_result.geometries_by_layer, + document_bounds=vec_result.source_parse_result.document_bounds, + ) + + payload = ImportPayload(source_asset, items) + final_payload = self._post_process_payload(payload) + + return ImportResult( + payload=final_payload, + parse_result=vec_result.source_parse_result, + vectorization_result=vec_result, + warnings=self._warnings, + errors=self._errors, + ) + + def get_doc_items( + self, vectorization_spec: VectorizationSpec | None = None + ) -> ImportResult | None: + """ + Template method that orchestrates the full five-phase import pipeline. + + This method coordinates the complete import process: + 1. Parse (Phase 2) + 2. Create Source Asset + 3. Vectorize (Phase 3) + 4. Layout (Phase 4) + 5. Assemble (Phase 5) + + Coordinate System: + ------------------ + The returned ImportResult contains: + - payload: DocItems in World Coordinates (mm, Y-Up) + - parse_result: Native and World Coordinates + - vectorization_result: Native Coordinates + + Args: + vectorization_spec: Optional VectorizationSpec. If None, a smart + default is chosen based on importer features + (PassthroughSpec for direct vector, + TraceSpec for bitmap tracing). + + Returns: + ImportResult containing the final payload and all intermediate + results. May contain partial results and errors if some phases + failed. Returns None only in exceptional cases. + + Error Handling: + --------------- + This method collects errors from all phases and returns them in the + ImportResult. It handles failures gracefully, returning partial + results where possible. + """ + spec = vectorization_spec or self._resolve_default_spec() + self._vectorization_spec = spec + + # Phase 2: Parse (once, reused below) + parse_result = self.parse() + if not parse_result: + from .structures import ImportResult + + return ImportResult( + payload=None, + parse_result=None, + warnings=self._warnings, + errors=self._errors, + ) + + # Phase 2b: Create Source Asset + source_asset = self.create_source_asset(parse_result) + self._stamp_importer_identity(source_asset) + + return self._run_pipeline(spec, source_asset, parse_result) + + def _post_process_payload(self, payload: ImportPayload) -> ImportPayload: + """ + An optional hook for subclasses to modify the final payload after + assembly. This is useful for importers that need to add extra data + or links, like the SketchImporter. + """ + return payload + + def get_doc_items_for_reimport( + self, + existing_source_asset: SourceAsset, + vectorization_spec: VectorizationSpec, + ) -> ImportResult | None: + """ + Re-run the import pipeline using an existing SourceAsset. + + Skips source-asset creation (Phase 2b) and reuses the existing + SourceAsset so the UID stays stable. The new segments will + reference the same asset UID. + """ + return self._run_pipeline(vectorization_spec, existing_source_asset) diff --git a/rayforge/image/base_renderer.py b/rayforge/image/base_renderer.py new file mode 100644 index 000000000..c504238b6 --- /dev/null +++ b/rayforge/image/base_renderer.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import logging +import warnings +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from ..core.vectorization_spec import TraceSpec + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + from ..core.source_asset_segment import SourceAssetSegment + from ..core.workpiece import RenderContext + from ..image.structures import ImportResult + + +logger = logging.getLogger(__name__) + + +@dataclass +class RenderSpecification: + """Instructions from a Renderer on how to execute a render job.""" + + width: int + height: int + data: bytes + kwargs: dict[str, Any] = field(default_factory=dict) + crop_rect: tuple[int, int, int, int] | None = None + apply_mask: bool = True + + +class Renderer(ABC): + """ + An abstract base class for any object that can render raw data to a + pixel image. Renderers are stateless singletons. + """ + + def compute_render_spec( + self, + segment: SourceAssetSegment | None, + target_size: tuple[int, int], + source_context: RenderContext, + ) -> RenderSpecification: + """ + Calculates the strategy for rendering. Subclasses will override this. + The default implementation is a simple pass-through. + """ + return RenderSpecification( + width=target_size[0], + height=target_size[1], + data=source_context.data, + apply_mask=True, + ) + + @abstractmethod + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + """ + Renders raw data into a pyvips Image of the specified dimensions. + This method performs the raw format conversion (e.g. SVG->Bitmap, + PDF->Bitmap) but does NOT handle cropping, masking, or high-level + caching, which are handled by the WorkPiece. + + Args: + data: The raw bytes to render. + width: The target pixel width. + height: The target pixel height. + **kwargs: Optional format-specific arguments (e.g. 'boundaries' + for vector renderers). + + Returns: + A pyvips.Image, or None if rendering fails. + """ + raise NotImplementedError + + def render_preview_image( + self, + import_result: ImportResult, + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """ + Generates a high-resolution preview image from a full ImportResult. + This allows a renderer to use context from parsing and vectorization + to create the most accurate background image for the import dialog. + + The base implementation is a fallback for simple raster renderers. + + Args: + import_result: The complete result of the import operation. + target_width: The target pixel width for the preview. + target_height: The target pixel height for the preview. + + Returns: + A pyvips.Image, or None if rendering fails. + """ + if not import_result.payload: + return None + + source = import_result.payload.source + # For previews, always prefer the pre-processed (e.g., trimmed) data. + data_to_render = source.base_render_data or source.original_data + if not data_to_render: + return None + + # Delegate directly to render_base_image, which is expected to handle + # rendering to the target dimensions. This is more efficient than + # loading a full-res image and then thumbnailing. + return self.render_base_image( + data=data_to_render, width=target_width, height=target_height + ) + + +class RasterRenderer(Renderer): + """ + A base renderer for raster formats that handles the complex logic for + high-resolution rendering of cropped segments. + """ + + def compute_render_spec( + self, + segment: SourceAssetSegment | None, + target_size: tuple[int, int], + source_context: RenderContext, + ) -> RenderSpecification: + """ + Calculates the render specification for a raster source. If the + source is cropped, it computes the upscaled render dimensions and + crop rectangle necessary to produce a sharp final image. + """ + target_width, target_height = target_size + source_px_dims = source_context.source_pixel_dims + original_data = source_context.original_data + + # A traced item is treated as a raster for cropping purposes. + is_vector = segment is not None and not isinstance( + segment.vectorization_spec, TraceSpec + ) + + # This logic applies only to non-vector sources that are cropped and + # for which we have the original, uncropped data and dimensions. + if ( + segment + and segment.crop_window_px is not None + and not is_vector + and original_data + and source_px_dims + ): + source_w, source_h = source_px_dims + crop_x_f, crop_y_f, crop_w_f, crop_h_f = segment.crop_window_px + crop_w, crop_h = float(crop_w_f), float(crop_h_f) + + if crop_w > 0 and crop_h > 0: + # Upscale the full original image so the crop area matches the + # target pixel dimensions. + scale_x = target_width / crop_w + scale_y = target_height / crop_h + render_width = max(1, int(source_w * scale_x)) + render_height = max(1, int(source_h * scale_y)) + + # Calculate the crop rectangle in the upscaled image's coords. + scaled_x = int(crop_x_f * scale_x) + scaled_y = int(crop_y_f * scale_y) + scaled_w = int(crop_w * scale_x) + scaled_h = int(crop_h * scale_y) + crop_rect = (scaled_x, scaled_y, scaled_w, scaled_h) + + return RenderSpecification( + width=render_width, + height=render_height, + data=original_data, # Use original for full render + crop_rect=crop_rect, + apply_mask=True, + ) + + # Fallback for non-cropped images or if required data is missing. + # This is a standard, direct render. + return RenderSpecification( + width=target_width, + height=target_height, + data=source_context.data, + apply_mask=True, + ) + + +class UnknownRenderer(Renderer): + """ + A placeholder renderer used when the actual renderer is not available + (e.g., when its addon is disabled). + """ + + def compute_render_spec( + self, + segment: SourceAssetSegment | None, + target_size: tuple[int, int], + source_context: RenderContext, + ) -> RenderSpecification: + """ + Always returns minimal render spec to avoid crashes. + """ + return RenderSpecification( + width=target_size[0], + height=target_size[1], + data=b"", # Empty data since we can't render + apply_mask=False, + ) + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + """ + Always returns None since we can't render without the actual renderer. + """ + logger.warning( + f"Attempted to render with UnknownRenderer " + f"(width={width}, height={height}), " + f"renderer likely from disabled addon" + ) + return None + + def render_preview_image( + self, + import_result: ImportResult, + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """ + Always returns None since we can't render without the actual renderer. + """ + return None diff --git a/rayforge/image/bmp/__init__.py b/rayforge/image/bmp/__init__.py new file mode 100644 index 000000000..456d7eb93 --- /dev/null +++ b/rayforge/image/bmp/__init__.py @@ -0,0 +1,3 @@ +from .importer import BmpImporter + +__all__ = ["BmpImporter"] diff --git a/rayforge/image/bmp/importer.py b/rayforge/image/bmp/importer.py new file mode 100644 index 000000000..d280b9bb7 --- /dev/null +++ b/rayforge/image/bmp/importer.py @@ -0,0 +1,199 @@ +import logging +import struct +import warnings +from gettext import gettext as _ +from pathlib import Path +from typing import ClassVar + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + try: + import pyvips + except ImportError: + raise ImportError("The BMP importer requires the pyvips library.") + +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 .parser import parse_bmp +from .renderer import BMP_RENDERER + +logger = logging.getLogger(__name__) + + +class BmpImporter(Importer): + label = "BMP files" + mime_types = ("image/bmp",) + extensions = (".bmp",) + 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 scan(self) -> ImportManifest: + """ + Scans the BMP header to extract dimensions and calculate physical size. + """ + fname = self.source_file.name + try: + parsed_data = parse_bmp(self.raw_data) + if not parsed_data: + self.add_error( + _("Could not parse BMP header in {}").format(fname) + ) + return ImportManifest(title=fname, errors=self._errors) + + _ignored, width, height, dpi_x, dpi_y = parsed_data + dpi_x = dpi_x or 96.0 + dpi_y = dpi_y or 96.0 + + width_mm = float(width) * (25.4 / dpi_x) + height_mm = float(height) * (25.4 / dpi_y) + + return ImportManifest( + title=self.source_file.name, + natural_size_mm=(width_mm, height_mm), + warnings=self._warnings, + errors=self._errors, + ) + except (struct.error, ValueError, IndexError) as e: + logger.warning(f"BMP scan failed for {fname}: {e}") + self.add_error(_("Failed to scan BMP file: {}").format(e)) + return ImportManifest(title=fname, errors=self._errors) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset for BMP import. + """ + _ignored1, _ignored2, w_px, h_px = parse_result.document_bounds + width_mm = w_px * parse_result.native_unit_to_mm + height_mm = h_px * parse_result.native_unit_to_mm + + return SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=BMP_RENDERER, + thumbnail_data=self._render_thumbnail_from_vips(self._image), + width_px=int(w_px), + height_px=int(h_px), + width_mm=width_mm, + height_mm=height_mm, + ) + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + assert self._image is not None, "parse() must be called first" + if not isinstance(spec, TraceSpec): + raise TypeError("BmpImporter only supports TraceSpec") + + surface = util.vips_rgba_to_cairo_surface(self._image) + geometries_list = trace_surface(surface, spec) + merged_geometry = Geometry() + for geo in geometries_list: + merged_geometry.extend(geo) + # For now, all traced geometry goes into a single "layer" + return VectorizationResult( + geometries_by_layer={None: merged_geometry}, + source_parse_result=parse_result, + ) + + def parse(self) -> ParsingResult | None: + """ + Phase 1: Parsing. + + Parses the BMP file and returns a ParsingResult containing geometric + facts about the image in its native coordinate system (pixels). The + parsed pyvips.Image is stored in self._image. + """ + parsed_data = parse_bmp(self.raw_data) + if not parsed_data: + self._image = None + self.add_error(_("Invalid or unsupported BMP data.")) + return None + + rgba_bytes, width, height, dpi_x, dpi_y = parsed_data + dpi_x = dpi_x or 96.0 + dpi_y = dpi_y or 96.0 + + try: + image = pyvips.Image.new_from_memory( + rgba_bytes, width, height, 4, "uchar" + ) + image = image.copy( + interpretation=pyvips.Interpretation.SRGB, + xres=dpi_x / 25.4, + yres=dpi_y / 25.4, + ) + self._image = image + except pyvips.Error as e: + logger.error( + "Failed to create pyvips image from parsed BMP data: %s", e + ) + self._image = None + self.add_error(_("Image processing failed: {}").format(e)) + return None + + # Calculate unit conversion (pixels to mm) + native_unit_to_mm = 25.4 / dpi_x + + # Page bounds are the full image dimensions + document_bounds = (0.0, 0.0, float(width), float(height)) + + # World frame is Y-Up, so y-origin is 0. BMPs have no untrimmed bounds. + 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, + ) + + # Create temporary result to calculate background transform + 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 + ) + + # BMP is a single-layer format, use a default layer ID + layer_id = "__default__" + + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=native_unit_to_mm, + is_y_down=True, + layers=[ + LayerGeometry( + layer_id=layer_id, + name=layer_id, + content_bounds=document_bounds, + ) + ], + world_frame_of_reference=world_frame, + background_world_transform=bg_item.world_matrix, + ) diff --git a/rayforge/image/bmp/parser.py b/rayforge/image/bmp/parser.py new file mode 100644 index 000000000..9f5279bb3 --- /dev/null +++ b/rayforge/image/bmp/parser.py @@ -0,0 +1,463 @@ +import logging +import struct + +logger = logging.getLogger(__name__) + +# Supported DIB header types +_BITMAPINFOHEADER_SIZE = 40 +_BITMAPCOREHEADER_SIZE = 12 +_BITMAPV5HEADER_SIZE = 124 + +# Supported compression types +_COMPRESSION_NONE = 0 +_COMPRESSION_BITFIELDS = 3 + +# Supported bit depths +_SUPPORTED_BPP = 1, 8, 24, 32 + + +def parse_bmp(data: bytes) -> tuple[bytes, int, int, float, float] | None: + """ + Parse a BMP file and extract image data and metadata. + + Supports uncompressed 1-bit, 8-bit, 24-bit, and 32-bit BMPs. Handles + BITMAPINFOHEADER, BITMAPV5HEADER, and the older BITMAPCOREHEADER formats. + + Args: + data: Raw bytes of the BMP file. + + Returns: + A tuple containing (RGBA pixel buffer, width, height, dpi_x, dpi_y) + or None if parsing fails. + """ + if not is_valid_bmp_signature(data): + logger.error("Not a BMP file (missing 'BM' magic bytes).") + return None + + try: + pixel_data_start = parse_file_header(data) + if pixel_data_start is None: + return None + + header_info = parse_dib_header(data) + if header_info is None: + return None + + ( + width, + height, + bits_per_pixel, + compression, + dpi_x, + dpi_y, + is_top_down, + ) = header_info + + if not _validate_format(bits_per_pixel, compression): + return None + + dib_header_size = struct.unpack(" bool: + """ + Check if the provided data starts with the BMP signature 'BM'. + + Args: + data: The byte data of the file. + + Returns: + True if the signature is valid, False otherwise. + """ + return len(data) >= 2 and data[:2] == b"BM" + + +def parse_file_header(data: bytes) -> int | None: + """ + Parse the 14-byte BMP file header to find the pixel data offset. + + Args: + data: The byte data of the BMP file. + + Returns: + The integer offset where the pixel data begins, or None on failure. + """ + if len(data) < 14: + logger.error("Incomplete file header.") + return None + + try: + # bfOffBits is the 4 bytes at offset 10 + (pixel_data_offset,) = struct.unpack(" tuple[int, int, int, int, float, float, bool] | None: + """ + Parse the DIB (Device-Independent Bitmap) header. + + This function identifies and parses BITMAPINFOHEADER (40 bytes), + BITMAPV5HEADER (124 bytes), or an older BITMAPCOREHEADER (12 bytes) + to extract image metadata. + + Args: + data: The byte data of the BMP file. + + Returns: + A tuple containing + (width, height, bpp, compression, dpi_x, dpi_y, is_top_down), + or None on failure. + """ + if len(data) < 18: + logger.error("Incomplete DIB header size field.") + return None + + dib_header_size = struct.unpack(" tuple[int, int, int, int, float, float, bool] | None: + """Parse a BITMAPINFOHEADER (40 bytes).""" + if len(data) < 54: + logger.error("Incomplete BITMAPINFOHEADER.") + return None + + info = struct.unpack(" 0 else 96.0 + dpi_y = ppm_y * 0.0254 if ppm_y > 0 else 96.0 + is_top_down = raw_height < 0 + + logger.debug( + f"INFOHEADER width={width} height={height} bpp={bits_per_pixel} " + f"compression={compression} is_top_down={is_top_down}" + ) + return ( + width, + height, + bits_per_pixel, + compression, + dpi_x, + dpi_y, + is_top_down, + ) + + +def _parse_v5_header( + data: bytes, +) -> tuple[int, int, int, int, float, float, bool] | None: + """ + Parse a BITMAPV5HEADER (124 bytes). + + The V5 header is a superset of the V4 and INFO headers. The first 40 + bytes are identical to BITMAPINFOHEADER, so we can reuse its parsing logic. + """ + if len(data) < 138: # 14 (file) + 124 (v5 header) + logger.error("Incomplete BITMAPV5HEADER.") + return None + + logger.debug("Parsing BITMAPV5HEADER by reusing INFOHEADER logic.") + return _parse_info_header(data) + + +def _parse_core_header( + data: bytes, +) -> tuple[int, int, int, int, float, float, bool] | None: + """Parse a BITMAPCOREHEADER (12 bytes).""" + if len(data) < 26: + logger.error("Incomplete BITMAPCOREHEADER.") + return None + + width, height, _, bits_per_pixel = struct.unpack(" bool: + """Validate that the BMP format is supported.""" + # Allow BI_RGB (0) and BI_BITFIELDS (3), which is used for uncompressed + # 32bpp images. + if compression not in (_COMPRESSION_NONE, _COMPRESSION_BITFIELDS): + logger.error(f"Unsupported compression type: {compression}") + return False + + if bits_per_pixel not in _SUPPORTED_BPP: + logger.error( + f"Unsupported bpp: {bits_per_pixel}. " + f"Only {_SUPPORTED_BPP} are supported." + ) + return False + + return True + + +def _parse_info_palette( + data: bytes, palette_offset: int, num_colors: int +) -> list[tuple[int, int, int, int]] | None: + """Parse a BMP palette with 4-byte RGBQUAD entries.""" + palette_size = num_colors * 4 + if len(data) < palette_offset + palette_size: + logger.error( + f"Palette bytes (RGBQUAD) not present at offset {palette_offset}." + ) + return None + + palette = [] + for i in range(0, palette_size, 4): + b, g, r, _ = data[palette_offset + i : palette_offset + i + 4] + palette.append((r, g, b, 255)) + + logger.debug(f"Palette entries (INFO) read for {num_colors} colors.") + return palette + + +def _parse_core_palette( + data: bytes, palette_offset: int, num_colors: int +) -> list[tuple[int, int, int, int]] | None: + """Parse a BMP palette with 3-byte RGBTRIPLE entries.""" + palette_size = num_colors * 3 + if len(data) < palette_offset + palette_size: + logger.error( + f"Palette bytes (RGBTRIPLE) not present at " + f"offset {palette_offset}." + ) + return None + + palette = [] + for i in range(0, palette_size, 3): + b, g, r = data[palette_offset + i : palette_offset + i + 3] + palette.append((r, g, b, 255)) # Convert to RGBA + + logger.debug(f"Palette entries (CORE) read for {num_colors} colors.") + return palette + + +def _parse_paletted_data( + data: bytes, + width: int, + height: int, + pixel_data_start: int, + is_top_down: bool, + dib_header_size: int, + bits_per_pixel: int, +) -> bytearray | None: + """Helper for parsing 1-bit and 8-bit paletted data.""" + is_core_header = dib_header_size == _BITMAPCOREHEADER_SIZE + palette_offset = 14 + dib_header_size + max_colors = 2**bits_per_pixel + + if is_core_header: + num_colors = max_colors + palette = _parse_core_palette(data, palette_offset, num_colors) + else: + colors_used_offset = 14 + 32 # Offset of biClrUsed field + colors_used = struct.unpack( + " 0 else max_colors + palette = _parse_info_palette(data, palette_offset, num_colors) + + if palette is None: + return None + + if bits_per_pixel == 1: + row_bytes = (width + 7) // 8 + process_row_func = _process_1bit_row + else: # bits_per_pixel == 8 + row_bytes = width + process_row_func = _process_8bit_row + + row_size_padded = (row_bytes + 3) & ~3 + logger.debug( + f"bpp={bits_per_pixel} " + f"row_bytes={row_bytes} " + f"row_size_padded={row_size_padded}" + ) + + rgba_buffer = bytearray(width * height * 4) + for y in range(height): + row_offset = _get_row_offset( + y, height, row_size_padded, pixel_data_start, is_top_down + ) + + slice_end = row_offset + row_size_padded + if slice_end > len(data): + logger.error( + f"Row {y} slice end ({slice_end}) exceeds data length " + f"({len(data)}). File is likely truncated." + ) + return None + + row_data = data[row_offset:slice_end] + process_row_func(row_data, width, y, palette, rgba_buffer) + + return rgba_buffer + + +def _parse_rgb_data( + data: bytes, + width: int, + height: int, + bits_per_pixel: int, + pixel_data_start: int, + is_top_down: bool, +) -> bytearray | None: + """Parse 24-bit or 32-bit RGB(A) BMP data.""" + bytes_per_pixel = bits_per_pixel // 8 + row_size_padded = (width * bytes_per_pixel + 3) & ~3 + logger.debug( + f"{bits_per_pixel}-bit image, row size padded={row_size_padded}" + ) + + rgba_buffer = bytearray(width * height * 4) + for y in range(height): + row_offset = _get_row_offset( + y, height, row_size_padded, pixel_data_start, is_top_down + ) + + slice_end = row_offset + row_size_padded + if slice_end > len(data): + logger.error( + f"Row {y} slice end ({slice_end}) exceeds data length " + f"({len(data)}). File is likely truncated." + ) + return None + + row_data = data[row_offset:slice_end] + _process_rgb_row(row_data, width, bytes_per_pixel, y, rgba_buffer) + + return rgba_buffer + + +def _get_row_offset( + y: int, height: int, row_size: int, data_start: int, is_top_down: bool +) -> int: + """Calculate the byte offset for a specific row.""" + if is_top_down: + return data_start + y * row_size + else: + return data_start + (height - 1 - y) * row_size + + +def _process_1bit_row( + row_data: bytes, + width: int, + y: int, + palette: list[tuple[int, int, int, int]], + rgba_buffer: bytearray, +): + """Process a single row of 1-bit monochrome data.""" + dest_row_start = y * width * 4 + for x in range(width): + byte_val = row_data[x // 8] + bit = (byte_val >> (7 - (x % 8))) & 1 + r, g, b, a = palette[bit] + rgba_buffer[dest_row_start + x * 4 : dest_row_start + x * 4 + 4] = ( + r, + g, + b, + a, + ) + + +def _process_8bit_row( + row_data: bytes, + width: int, + y: int, + palette: list[tuple[int, int, int, int]], + rgba_buffer: bytearray, +): + """Process a single row of 8-bit paletted data.""" + dest_row_start = y * width * 4 + for x in range(width): + palette_index = row_data[x] + r, g, b, a = palette[palette_index] + rgba_buffer[dest_row_start + x * 4 : dest_row_start + x * 4 + 4] = ( + r, + g, + b, + a, + ) + + +def _process_rgb_row( + row_data: bytes, + width: int, + bytes_per_pixel: int, + y: int, + rgba_buffer: bytearray, +): + """Process a single row of 24-bit or 32-bit RGB data.""" + dest_row_start = y * width * 4 + for x in range(width): + src_idx = x * bytes_per_pixel + dest_idx = dest_row_start + x * 4 + b, g, r = row_data[src_idx : src_idx + 3] + a = row_data[src_idx + 3] if bytes_per_pixel == 4 else 255 + rgba_buffer[dest_idx : dest_idx + 4] = (r, g, b, a) diff --git a/rayforge/image/bmp/renderer.py b/rayforge/image/bmp/renderer.py new file mode 100644 index 000000000..34258e000 --- /dev/null +++ b/rayforge/image/bmp/renderer.py @@ -0,0 +1,35 @@ +import warnings + +from ..base_renderer import RasterRenderer +from .parser import parse_bmp + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + + +class BmpRenderer(RasterRenderer): + """Renders BMP data.""" + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + if not data: + return None + parsed_data = parse_bmp(data) + if not parsed_data: + return None + rgba_bytes, img_width, img_height, _, _ = parsed_data + try: + return pyvips.Image.new_from_memory( + rgba_bytes, img_width, img_height, 4, "uchar" + ) + except pyvips.Error: + return None + + +BMP_RENDERER = BmpRenderer() diff --git a/rayforge/image/dither.py b/rayforge/image/dither.py new file mode 100644 index 000000000..2b4e20230 --- /dev/null +++ b/rayforge/image/dither.py @@ -0,0 +1,88 @@ +"""Dithering algorithms for converting grayscale images to binary.""" + +from enum import Enum +from gettext import gettext as _ + +import numpy as np +from raygeo.image.convert import rgba_to_grayscale +from raygeo.image.dither import ( + apply_bayer_dither, + apply_floyd_steinberg_dither, + apply_minimum_run_length, +) + + +class DitherAlgorithm(Enum): + FLOYD_STEINBERG = "floyd_steinberg" + BAYER2 = "bayer2" + BAYER4 = "bayer4" + BAYER8 = "bayer8" + + @property + def display_name(self) -> str: + names = { + self.FLOYD_STEINBERG: _("Floyd Steinberg"), + self.BAYER2: _("Bayer 2"), + self.BAYER4: _("Bayer 4"), + self.BAYER8: _("Bayer 8"), + } + return names[self] + + +BAYER_MATRICES = { + DitherAlgorithm.BAYER2: np.array([[0, 2], [3, 1]], dtype=np.float32), + DitherAlgorithm.BAYER4: np.array( + [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], + dtype=np.float32, + ), + DitherAlgorithm.BAYER8: np.array( + [ + [0, 32, 8, 40, 2, 34, 10, 42], + [48, 16, 56, 24, 50, 18, 58, 26], + [12, 44, 4, 36, 14, 46, 6, 38], + [60, 28, 52, 20, 62, 30, 54, 22], + [3, 35, 11, 43, 1, 33, 9, 41], + [51, 19, 59, 27, 49, 17, 57, 25], + [15, 47, 7, 39, 13, 45, 5, 37], + [63, 31, 55, 23, 61, 29, 53, 21], + ], + dtype=np.float32, + ), +} + + +def surface_to_dithered_array( + surface, + dither_algorithm: DitherAlgorithm, + invert: bool, + min_feature_px: int = 1, +) -> np.ndarray: + """ + Convert Cairo surface to dithered binary array. + + Args: + surface: Cairo surface in ARGB32 format. + dither_algorithm: The dithering algorithm to use. + invert: If True, invert the output (engrave light areas). + min_feature_px: Minimum feature size in pixels. + + Returns: + Binary image where 1 represents areas to engrave. + """ + width = surface.get_width() + height = surface.get_height() + stride_px = surface.get_stride() // 4 + buf = np.frombuffer(surface.get_data(), dtype=np.uint8).copy() + + grayscale, _ = rgba_to_grayscale(buf, width, height, stride_px) + + if dither_algorithm == DitherAlgorithm.FLOYD_STEINBERG: + 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 bw_image diff --git a/rayforge/image/dxf/__init__.py b/rayforge/image/dxf/__init__.py new file mode 100644 index 000000000..01020c9fb --- /dev/null +++ b/rayforge/image/dxf/__init__.py @@ -0,0 +1,3 @@ +from .importer import DxfImporter + +__all__ = ["DxfImporter"] diff --git a/rayforge/image/dxf/exporter.py b/rayforge/image/dxf/exporter.py new file mode 100644 index 000000000..cc8f9f2aa --- /dev/null +++ b/rayforge/image/dxf/exporter.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import io +import math +from gettext import gettext as _ + +import ezdxf +from raygeo.geo import Arc, Bezier, Geometry, Line, Move +from raygeo.geo.shape.arc import get_arc_angles +from raygeo.geo.shape.bezier import linearize_bezier_segment + +from ..base_exporter import BaseExporter + + +class GeometryDxfExporter(BaseExporter): + """ + Exports a Geometry object to DXF format. + """ + + label = _("DXF (CAD Exchange Format)") + extensions = (".dxf",) + mime_types = ("image/vnd.dxf",) + + def __init__(self, geometry: Geometry): + self.geometry = geometry + + def export(self) -> bytes: + if self.geometry.is_empty(): + raise ValueError("Cannot export: The geometry is empty.") + + return self._geometry_to_dxf(self.geometry) + + def _geometry_to_dxf(self, geometry: Geometry) -> bytes: + doc = ezdxf.new() # type: ignore[attr-defined] + doc.header["$INSUNITS"] = 4 # Millimeters + msp = doc.modelspace() + self._add_geometry_to_msp(geometry, msp) + + output = io.StringIO() + doc.write(output) + return output.getvalue().encode("utf-8") + + def _add_geometry_to_msp(self, geometry: Geometry, msp) -> None: + """Add geometry entities to a DXF modelspace.""" + last_x = 0.0 + last_y = 0.0 + poly_points: list[tuple] = [] + + def flush_polyline(): + nonlocal poly_points + if len(poly_points) >= 2: + msp.add_lwpolyline(poly_points) + poly_points = [] + + for cmd in geometry.data: + x = cmd.end[0] + y = cmd.end[1] + + if isinstance(cmd, Move): + flush_polyline() + elif isinstance(cmd, Line): + if not poly_points: + poly_points = [(last_x, last_y)] + poly_points.append((x, y)) + elif isinstance(cmd, Arc): + flush_polyline() + + i = cmd.center_offset[0] + j = cmd.center_offset[1] + cw = cmd.clockwise + + center_x = last_x + i + center_y = last_y + j + radius = math.hypot(i, j) + if radius < 1e-9: + radius = 0.001 + + start_angle, end_angle, _ = get_arc_angles( + (last_x, last_y), (x, y), (center_x, center_y), cw + ) + start_angle = math.degrees(start_angle) + end_angle = math.degrees(end_angle) + + if cw: + start_angle, end_angle = end_angle, start_angle + if end_angle <= start_angle: + end_angle += 360 + + msp.add_arc( + center=(center_x, center_y), + radius=radius, + start_angle=start_angle, + end_angle=end_angle, + ) + elif isinstance(cmd, Bezier): + flush_polyline() + + c1x = cmd.control1[0] + c1y = cmd.control1[1] + c2x = cmd.control2[0] + c2y = cmd.control2[1] + + points = linearize_bezier_segment( + (last_x, last_y, 0.0), + (c1x, c1y, 0.0), + (c2x, c2y, 0.0), + (x, y, 0.0), + tolerance=0.1, + ) + if points: + fit_points = [(p[0], p[1]) for p in points] + msp.add_spline_control_frame( + fit_points=fit_points, degree=3 + ) + + last_x = x + last_y = y + flush_polyline() + + +class MultiGeometryDxfExporter(BaseExporter): + """ + Exports multiple Geometry objects to a single DXF file. + """ + + label = _("DXF (CAD Exchange Format)") + extensions = (".dxf",) + mime_types = ("image/vnd.dxf",) + + def __init__(self, geometries: list[Geometry]): + self.geometries = geometries + + def export(self) -> bytes: + non_empty = [g for g in self.geometries if not g.is_empty()] + if not non_empty: + raise ValueError("Cannot export: All geometries are empty.") + + doc = ezdxf.new() # type: ignore[attr-defined] + doc.header["$INSUNITS"] = 4 # Millimeters + msp = doc.modelspace() + + single_exporter = GeometryDxfExporter(Geometry()) + for geo in non_empty: + single_exporter._add_geometry_to_msp(geo, msp) + + output = io.StringIO() + doc.write(output) + return output.getvalue().encode("utf-8") diff --git a/rayforge/image/dxf/importer.py b/rayforge/image/dxf/importer.py new file mode 100644 index 000000000..311f18c4b --- /dev/null +++ b/rayforge/image/dxf/importer.py @@ -0,0 +1,560 @@ +import io +import logging +import math +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import replace +from gettext import gettext as _ +from pathlib import Path +from typing import ClassVar + +import ezdxf +import ezdxf.math +from ezdxf import bbox +from ezdxf.addons import text2path +from ezdxf.lldxf.const import DXFStructureError +from ezdxf.path import Command +from raygeo.geo import Geometry +from raygeo.geo.types import Rect + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + VectorizationSpec, +) +from ...image.geo_renderer import render_geometry_to_png +from ..base_importer import ( + Importer, + ImporterFeature, +) +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + LayerGeometry, + LayerInfo, + ParsingResult, + VectorizationResult, +) +from .renderer import DXF_RENDERER + +logger = logging.getLogger(__name__) + +units_to_mm = { + 0: 1.0, + 1: 25.4, + 2: 304.8, + 4: 1.0, + 5: 10.0, + 6: 1000.0, + 8: 0.0254, + 9: 0.0254, + 10: 914.4, +} + + +class DxfImporter(Importer): + label = "DXF files (2D)" + mime_types = ("image/vnd.dxf",) + extensions = (".dxf",) + 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._dxf_doc: ezdxf.document.Drawing | None = None # type: ignore + self._geometries_by_layer: dict[str | None, Geometry] = {} + + def scan(self) -> ImportManifest: + try: + data_str = self.raw_data.decode("utf-8", errors="replace") + normalized_str = data_str.replace("\r\n", "\n") + doc = ezdxf.read(io.StringIO(normalized_str)) # type: ignore + except DXFStructureError as e: + logger.warning(f"DXF scan failed: {e}") + self.add_error(_("DXF file structure is invalid: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + except Exception as e: + logger.exception("DXF scan error") + self.add_error( + _("Unexpected error while scanning DXF: {}").format(e) + ) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + # Count entities per layer to detect empty layers + counts: defaultdict[str, int] = defaultdict(int) + if doc.modelspace(): + for e in doc.modelspace(): + counts[e.dxf.layer] += 1 + + manifest_data = self._get_layer_manifest(doc) + layers = [] + for m in manifest_data: + lid = m["id"] + count = counts.get(lid, 0) + layers.append( + LayerInfo(id=lid, name=m["name"], feature_count=count) + ) + + bounds = self._get_bounds_mm(doc) + size_mm = (bounds[2], bounds[3]) if bounds else None + + return ImportManifest( + title=self.source_file.name, + layers=layers, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + _, _, w, h = parse_result.document_bounds + width_mm = w * parse_result.native_unit_to_mm + height_mm = h * parse_result.native_unit_to_mm + + thumbnail_data = self._render_thumbnail() + + source = SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=DXF_RENDERER, + metadata={"is_vector": True}, + thumbnail_data=thumbnail_data, + width_mm=width_mm, + height_mm=height_mm, + ) + return source + + def _render_thumbnail(self, size: int = 256) -> bytes | None: + merged = Geometry() + for geo in self._geometries_by_layer.values(): + if geo: + merged.extend(geo) + if merged.is_empty(): + return None + return render_geometry_to_png( + merged, + size, + line_width=2.0, + color=(0.2, 0.2, 0.2, 1.0), + ) + + def _merged_geometry(self) -> Geometry | None: + if not self._geometries_by_layer: + return None + merged = Geometry() + for geo in self._geometries_by_layer.values(): + if geo: + merged.extend(geo) + return merged if not merged.is_empty() else None + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + """ + Prepares the final vector geometry based on the user's specification. + This method is "spec-aware" and handles the merging of geometries + if requested. + """ + split_layers = False + active_layers_set = None + if isinstance(spec, PassthroughSpec): + split_layers = spec.layer_import_mode != LayerImportMode.FLATTEN + if spec.active_layer_ids: + active_layers_set = set(spec.active_layer_ids) + + # Filter geometries based on the active layers in the spec + geometries_to_process: dict[str | None, Geometry] + if active_layers_set: + geometries_to_process = { + layer_id: geo + for layer_id, geo in self._geometries_by_layer.items() + if layer_id in active_layers_set + } + else: + geometries_to_process = self._geometries_by_layer + + final_geometries: dict[str | None, Geometry] + if split_layers: + # For a "split" strategy, return the dictionary of individual + # layer geometries. + final_geometries = geometries_to_process + else: + # For a "merge" strategy, combine all active geometries into a + # single Geometry object under the `None` key. + merged_geo = Geometry() + for geo in geometries_to_process.values(): + merged_geo.extend(geo) + final_geometries = {None: merged_geo} + + # Hack: Updating the parsing result bounds if layers were filtered + # is a violation of the pipelines sequential nature. Ideally this + # updated window would be communicated back to the layout engine in a + # cleaner way. However, for now this ensures that the preview renderer + # and layout engine remain in sync when layers are filtered out. + + # If we have filtered layers, we must recalculate the bounds in the + # parsing result. Otherwise, the preview renderer (which renders based + # on the filtered geometry) and the layout engine (which uses the + # original whole-doc parsing result for the background image) will + # disagree, causing the vector overlay to drift from the image. + final_parse_result = parse_result + if active_layers_set: + union_bounds = self._calculate_geometry_union( + final_geometries.values() + ) + if union_bounds: + final_parse_result = self._update_parse_result_bounds( + parse_result, union_bounds + ) + + return VectorizationResult( + geometries_by_layer=final_geometries, + source_parse_result=final_parse_result, + ) + + def _calculate_geometry_union( + self, geometries: Iterable[Geometry] + ) -> Rect | None: + """Calculates the bounding box of a collection of geometries.""" + min_x, min_y, max_x, max_y = ( + float("inf"), + float("inf"), + float("-inf"), + float("-inf"), + ) + has_content = False + + for geo in geometries: + if not geo or geo.is_empty(): + continue + gx1, gy1, gx2, gy2 = geo.rect() + min_x = min(min_x, gx1) + min_y = min(min_y, gy1) + max_x = max(max_x, gx2) + max_y = max(max_y, gy2) + has_content = True + + if not has_content: + return None + + return (min_x, min_y, max_x - min_x, max_y - min_y) + + def _update_parse_result_bounds( + self, + original: ParsingResult, + new_bounds: Rect, + ) -> ParsingResult: + """ + Creates a new ParsingResult with updated bounds and transform matrices + to reflect a subset of the original document. + """ + x, y, w, h = new_bounds + scale = original.native_unit_to_mm + + # Recalculate world frame of reference (mm) + new_world_frame = (x * scale, y * scale, w * scale, h * scale) + + # Create a temp result to allow the Engine to calculate the matrix + # mapping the new bounds to the new world frame. + # Note: We must preserve is_y_down=False for DXF (Y-Up). + temp_result = replace( + original, + document_bounds=new_bounds, + world_frame_of_reference=new_world_frame, + ) + + bg_item = NormalizationEngine.calculate_layout_item( + new_bounds, temp_result + ) + + return replace( + original, + document_bounds=new_bounds, + world_frame_of_reference=new_world_frame, + background_world_transform=bg_item.world_matrix, + ) + + def _get_layer_manifest(self, doc) -> list[dict[str, str]]: + return [ + {"id": layer.dxf.name, "name": layer.dxf.name} + for layer in doc.layers + if layer.dxf.name.lower() != "defpoints" + ] + + def parse(self) -> ParsingResult | None: + try: + data_str = self.raw_data.decode("utf-8", errors="replace") + normalized_str = data_str.replace("\r\n", "\n") + doc = ezdxf.read(io.StringIO(normalized_str)) # type: ignore + self._dxf_doc = doc + except DXFStructureError as e: + self._dxf_doc = None + self.add_error(_("DXF file is corrupt or invalid: {}").format(e)) + return None + + # 1. Bounds + doc_bounds = self._get_bounds_native(self._dxf_doc) + if not doc_bounds: + doc_bounds = (0.0, 0.0, 0.0, 0.0) + + # 2. Extract with Flattening & Sorting + geometries_by_layer = self._extract_geometries(self._dxf_doc) + self._geometries_by_layer = geometries_by_layer + + # 3. Consolidate (Adaptive Tolerance) + w, h = doc_bounds[2], doc_bounds[3] + diag = math.hypot(w, h) + adaptive_tolerance = max(0.01, diag / 20000.0) + + for layer_name, geo in geometries_by_layer.items(): + if geo and not geo.is_empty(): + geo.close_gaps(tolerance=adaptive_tolerance) + + # 4. Create temporary result to calculate transforms + native_unit_to_mm = self._get_scale_to_mm(self._dxf_doc) + temp_result = ParsingResult( + document_bounds=doc_bounds, + native_unit_to_mm=native_unit_to_mm, + is_y_down=False, + layers=[], + # Dummy values, will be replaced + world_frame_of_reference=(0, 0, 0, 0), + background_world_transform=None, # type: ignore + ) + + # 5. Calculate authoritative frames using centralized logic + x, y, w, h = doc_bounds + world_frame = ( + x * native_unit_to_mm, + y * native_unit_to_mm, + w * native_unit_to_mm, + h * native_unit_to_mm, + ) + bg_layout_item = NormalizationEngine.calculate_layout_item( + doc_bounds, temp_result + ) + + # 6. Final Result + result = ParsingResult( + document_bounds=doc_bounds, + native_unit_to_mm=native_unit_to_mm, + is_y_down=False, + layers=[], + world_frame_of_reference=world_frame, + background_world_transform=bg_layout_item.world_matrix, + ) + + for layer_name, geo in geometries_by_layer.items(): + if layer_name is None or geo.is_empty(): + continue + + min_x, min_y, max_x, max_y = geo.rect() + w = max_x - min_x + h = max_y - min_y + + result.layers.append( + LayerGeometry( + layer_id=layer_name, + name=layer_name, + content_bounds=(min_x, min_y, w, h), + ) + ) + + return result + + def _extract_geometries(self, doc) -> dict[str | None, Geometry]: + """ + Recursively extracts, flattens, sorts, and consumes DXF entities. + Sorting is critical for creating continuous paths from scrambled + modelspace entities. + """ + raw_paths_by_layer: defaultdict[str, list] = defaultdict(list) + + def process_entity(entity, parent_transform: ezdxf.math.Matrix44): + # Transform Composition + if hasattr(entity, "matrix44"): + transform = parent_transform @ entity.matrix44() + else: + transform = parent_transform + + if entity.dxftype() == "INSERT": + # Recurse into blocks (Flattening) + block_name = entity.dxf.name + if block_name in doc.blocks: + block_def = doc.blocks.get(block_name) + for sub_entity in block_def: + process_entity(sub_entity, transform) + return + + layer_name = entity.dxf.layer + if layer_name.lower() == "defpoints": + return + + try: + # Convert entities to ezdxf Paths + if entity.dxftype() in ("TEXT", "MTEXT"): + paths = text2path.make_paths_from_entity(entity) + for path in paths: + raw_paths_by_layer[layer_name].append( + path.transform(transform) + ) + else: + path = ezdxf.path.make_path(entity) # type: ignore + raw_paths_by_layer[layer_name].append( + path.transform(transform) + ) + except Exception: + logger.debug( + "Failed to convert DXF entity %s", + entity.dxftype(), + exc_info=True, + ) + + # 1. Collect all paths (Disordered) + identity = ezdxf.math.Matrix44() + for entity in doc.modelspace(): + process_entity(entity, identity) + + # 2. Sort/Chain paths and Consume + final_geometries = {} + for layer_name, paths in raw_paths_by_layer.items(): + if not paths: + continue + + # Sort paths so end[i] approx== start[i+1] + sorted_paths = self._chain_paths(paths) + + geo = Geometry() + for path in sorted_paths: + self._consume_native_path(geo, path) + + if not geo.is_empty(): + final_geometries[layer_name] = geo + + return final_geometries + + def _chain_paths(self, paths: list) -> list: + """ + Greedy sorting of paths to restore continuity. + Groups paths that share endpoints into contiguous chains. + """ + if not paths: + return [] + + # Index start points for O(1) lookups + # Precision: 3 decimal places ensures visual continuity matches + start_map: defaultdict[tuple[int, int], list[int]] = defaultdict(list) + + for i, p in enumerate(paths): + s = p.start + key = (int(s.x * 1000), int(s.y * 1000)) + start_map[key].append(i) + + ordered = [] + visited = [False] * len(paths) + + for i in range(len(paths)): + if visited[i]: + continue + + # Start a new chain + current_idx = i + while True: + visited[current_idx] = True + p = paths[current_idx] + ordered.append(p) + + # Look for a path starting where this one ends + e = p.end + end_key = (int(e.x * 1000), int(e.y * 1000)) + + candidates = start_map.get(end_key) + next_idx = -1 + + if candidates: + for c_idx in candidates: + if not visited[c_idx]: + next_idx = c_idx + break + + if next_idx != -1: + current_idx = next_idx + else: + # Chain broken + break + + return ordered + + def _consume_native_path(self, geo: Geometry, path): + if not path: + return + + start = path.start + + # Check continuity with the *internal* geometry cursor + is_continuous = False + if not geo.is_empty(): + lx, ly, lz = geo.get_last_point() + if (lx - start.x) ** 2 + (ly - start.y) ** 2 + ( + lz - start.z + ) ** 2 < 1e-8: + is_continuous = True + + if not is_continuous: + geo.move_to(start.x, start.y, start.z) + + for cmd in path.commands(): + end = cmd.end + if cmd.type == Command.LINE_TO: + geo.line_to(end.x, end.y, end.z) + elif cmd.type == Command.CURVE4_TO: + c1, c2 = cmd.ctrl1, cmd.ctrl2 + geo.bezier_to(end.x, end.y, c1.x, c1.y, c2.x, c2.y, z=end.z) + elif cmd.type == Command.CURVE3_TO: + start_x, start_y, _ = geo.get_last_point() + ctrl = cmd.ctrl + c1x = start_x + (2 / 3) * (ctrl.x - start_x) + c1y = start_y + (2 / 3) * (ctrl.y - start_y) + c2x = end.x + (2 / 3) * (ctrl.x - end.x) + c2y = end.y + (2 / 3) * (ctrl.y - end.y) + geo.bezier_to(end.x, end.y, c1x, c1y, c2x, c2y, z=end.z) + elif cmd.type == Command.MOVE_TO: + # Check internal continuity of the path object itself + cx, cy, cz = geo.get_last_point() + if (cx - end.x) ** 2 + (cy - end.y) ** 2 + ( + cz - end.z + ) ** 2 > 1e-8: + geo.move_to(end.x, end.y, end.z) + + def _get_scale_to_mm(self, doc, default: float = 1.0) -> float: + insunits = doc.header.get("$INSUNITS", 0) + return units_to_mm.get(insunits, default) or default + + def _get_bounds_native(self, doc): + entity_bbox = bbox.extents(doc.modelspace(), fast=True) + if not entity_bbox.has_data: + return None + min_p, max_p = entity_bbox.extmin, entity_bbox.extmax + return (min_p.x, min_p.y, (max_p.x - min_p.x), (max_p.y - min_p.y)) + + def _get_bounds_mm(self, doc): + entity_bbox = bbox.extents(doc.modelspace(), fast=True) + if not entity_bbox.has_data: + return None + min_p, max_p = entity_bbox.extmin, entity_bbox.extmax + scale = self._get_scale_to_mm(doc) + return ( + min_p.x * scale, + min_p.y * scale, + (max_p.x - min_p.x) * scale, + (max_p.y - min_p.y) * scale, + ) diff --git a/rayforge/image/dxf/renderer.py b/rayforge/image/dxf/renderer.py new file mode 100644 index 000000000..70e21c1a1 --- /dev/null +++ b/rayforge/image/dxf/renderer.py @@ -0,0 +1,165 @@ +import logging +import warnings +from typing import TYPE_CHECKING, Optional + +from raygeo.geo import Geometry + +from ..base_renderer import Renderer, RenderSpecification +from ..ops_renderer import OPS_RENDERER + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + from ...core.source_asset_segment import SourceAssetSegment + from ...core.workpiece import RenderContext + from ...image.structures import ImportResult + +logger = logging.getLogger(__name__) + + +class DxfRenderer(Renderer): + """ + A renderer for DXF workpieces. Uses OpsRenderer for vector outlines + and overlays solid fills if present. + """ + + def compute_render_spec( + self, + segment: Optional["SourceAssetSegment"], + target_size: tuple[int, int], + source_context: "RenderContext", + ) -> "RenderSpecification": + """ + Specifies that 'boundaries' and 'source_metadata' are required for + rendering DXF files. + """ + kwargs = { + "boundaries": source_context.boundaries, + "source_metadata": source_context.metadata, + } + return RenderSpecification( + width=target_size[0], + height=target_size[1], + data=source_context.data, + kwargs=kwargs, + apply_mask=False, + ) + + def render_preview_image( + self, + import_result: "ImportResult", + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """Generates a preview by rendering the vectorized geometry.""" + vec_result = import_result.vectorization_result + if not vec_result: + return None + + if not import_result.payload: + return None + + all_geos = Geometry() + for geo in vec_result.geometries_by_layer.values(): + if geo: + all_geos.extend(geo) + + if all_geos.is_empty(): + return None + + return self.render_base_image( + data=import_result.payload.source.original_data, + width=target_width, + height=target_height, + boundaries=all_geos, + source_metadata=import_result.payload.source.metadata, + ) + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + boundaries = kwargs.get("boundaries") + if not boundaries or boundaries.is_empty(): + logger.warning( + "DxfRenderer: No boundaries provided, cannot render." + ) + return None + + # 1. Render vector outlines using OpsRenderer + logger.debug("DxfRenderer: Rendering vector outlines...") + surface = OPS_RENDERER._render_to_cairo_surface( + boundaries, width, height + ) + if not surface: + logger.error("DxfRenderer: Failed to render vector outlines.") + return None + + # 2. Draw solids if present + source_metadata = kwargs.get("source_metadata") + + if source_metadata: + solids = source_metadata.get("solids", []) + if solids: + import cairo + + logger.debug( + f"DxfRenderer: Rendering {len(solids)} solid fills..." + ) + ctx = cairo.Context(surface) + + # Get the transformation info from the vector rendering step + geo_min_x, geo_min_y, geo_max_x, geo_max_y = boundaries.rect() + geo_width = geo_max_x - geo_min_x + geo_height = geo_max_y - geo_min_y + + if geo_width > 1e-9 and geo_height > 1e-9: + scale_x = width / geo_width + scale_y = height / geo_height + + # Apply the same Y-flipping transform as OpsRenderer + ctx.save() + ctx.translate(-geo_min_x * scale_x, geo_max_y * scale_y) + ctx.scale(scale_x, -scale_y) + + ctx.set_source_rgb(0, 0, 0) # Black fill + for i, solid_points in enumerate(solids): + if len(solid_points) < 3: + logger.warning( + f"Skipping degenerate solid #{i} " + f"with < 3 points." + ) + continue + + p_start = solid_points[0] + ctx.move_to(p_start[0], p_start[1]) + for x, y in solid_points[1:]: + ctx.line_to(x, y) + ctx.close_path() + ctx.fill() + ctx.restore() + else: + logger.warning( + "Cannot render solids because geometry has zero size." + ) + + # 3. Convert Cairo surface to PyVips Image + h, w = surface.get_height(), surface.get_width() + vips_image = pyvips.Image.new_from_memory( + surface.get_data(), w, h, 4, "uchar" + ) + b, g, r, a = ( + vips_image[0], + vips_image[1], + vips_image[2], + vips_image[3], + ) + return r.bandjoin([g, b, a]) + + +DXF_RENDERER = DxfRenderer() diff --git a/rayforge/image/engine.py b/rayforge/image/engine.py new file mode 100644 index 000000000..994053246 --- /dev/null +++ b/rayforge/image/engine.py @@ -0,0 +1,327 @@ +import logging +from typing import Any + +from raygeo.geo import Matrix +from raygeo.geo.types import Rect + +from ..core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + TraceSpec, + VectorizationSpec, +) +from .structures import ( + LayerGeometry, + LayoutItem, + ParsingResult, + VectorizationResult, +) + +logger = logging.getLogger(__name__) + + +class NormalizationEngine: + """ + Phase 4: Layout Engine. + + Pure logic component that calculates how to map Native Coordinates + (ParsingResult) to Rayforge World Coordinates (LayoutPlan) based on + user intent (VectorizationSpec). + + Coordinate System Contract: + -------------------------- + All inputs and outputs follow a strict coordinate system convention: + + **Native Coordinates (Input):** + - File-specific coordinate system (e.g., SVG user units, DXF units) + - Y-axis orientation varies by format (is_y_down flag indicates this) + - Bounds are always absolute within the document's coordinate space + - Units are converted to mm via native_unit_to_mm factor + + **World Coordinates (Output):** + - Physical world coordinates in millimeters (mm) + - Y-axis points UP (Y-Up convention) + - Origin (0,0) is at the bottom-left of the workpiece + - All positions are absolute in the world coordinate system + + **Normalized Coordinates (Intermediate):** + - Unit square from (0,0) to (1,1) + - Y-axis points UP (Y-Up convention) + - Used as intermediate representation between native and world + + Frame of Reference: + ------------------ + - Native bounds are relative to the document's origin + - For Y-Down formats (SVG, images): origin is at top-left + - For Y-Up formats (DXF): origin is at bottom-left + - World positions preserve the original document's spatial relationships + - untrimmed_document_bounds provides reference for Y-inversion + + Error Handling: + --------------- + This class does not collect errors. It assumes valid input and handles + edge cases gracefully (e.g., degenerate bounds by normalizing to minimum + size). Invalid inputs may produce undefined results. + """ + + @staticmethod + def calculate_layout_item( + bounds: Rect, + parse_result: ParsingResult, + layer_id: str | None = None, + layer_name: str | None = None, + settings: dict[str, Any] | None = None, + color: str | None = None, + ) -> LayoutItem: + """ + Generates the transformation matrices for a specific bounding box. + + This is the central layout algorithm that maps Native Coordinates to + World Coordinates through Normalized Coordinates. + + Args: + bounds: Bounding box (x, y, width, height) in Native Coordinates. + These are absolute coordinates within the document's native + coordinate system. + parse_result: ParsingResult containing coordinate system metadata. + layer_id: Optional layer identifier for this item. + layer_name: Optional human-readable layer name. + + Returns: + LayoutItem containing: + - normalization_matrix: Native -> Unit Square (0-1, Y-Up) + - world_matrix: Unit Square (0-1, Y-Up) -> World (mm, Y-Up) + - crop_window: The bounds in Native Coordinates + + Coordinate Transformations: + --------------------------- + 1. Normalization Matrix: + - Scales content to fit within unit square (0-1) + - Translates content to origin if geometry_is_relative_to_bounds + - Flips Y-axis if is_y_down to ensure Y-Up output + + 2. World Matrix: + - Scales unit square to physical dimensions (mm) + - Translates to correct world position based on Y-inversion + - For Y-Down: position measured from bottom of reference frame + - For Y-Up: position measured from origin directly + """ + bx, by, bw, bh = bounds + + # Protect against degenerate bounds + if bw <= 0: + bw = 1.0 + if bh <= 0: + bh = 1.0 + + # 1. Normalization Matrix: Native -> Unit Square (0-1, Y-Up) + scale_matrix = Matrix.scale(1.0 / bw, 1.0 / bh) + if parse_result.geometry_is_relative_to_bounds: + # Geometry is already at its local origin (0,0) due to trimming. + # No translation needed for normalization. + norm_matrix = scale_matrix + else: + # Geometry is in global coords. Translate it to its origin first. + norm_matrix = scale_matrix @ Matrix.translation(-bx, -by) + + # The contract is that the normalization_matrix MUST produce a Y-UP, + # 0-1 coordinate space for the WorkPiece. + if parse_result.is_y_down: + # Source (SVG, PNG) is Y-Down. We need to flip it to become Y-Up. + flip_matrix = Matrix.translation(0, 1) @ Matrix.scale(1, -1) + norm_matrix = flip_matrix @ norm_matrix + + # 2. World Matrix: Unit Square (0-1, Y-Up) -> Physical World (mm, Y-Up) + width_mm = bw * parse_result.native_unit_to_mm + height_mm = bh * parse_result.native_unit_to_mm + + pos_x_mm = bx * parse_result.native_unit_to_mm + + # The frame of reference for Y-inversion is the original, + # untrimmed page. + ref_bounds = ( + parse_result.untrimmed_document_bounds + or parse_result.document_bounds + ) + _ref_x_native, ref_y_native, _ref_w_native, ref_h_native = ref_bounds + + if parse_result.is_y_down: + # Native is Y-Down (0 at top). We invert relative to the full page. + # The bottom of the content in native coords is by + bh. + # The bottom of the reference frame in native coords is + # ref_y + ref_h + dist_from_bottom_native = (ref_y_native + ref_h_native) - (by + bh) + pos_y_mm = dist_from_bottom_native * parse_result.native_unit_to_mm + else: + # Native is Y-Up (DXF). Origin is already at the bottom. + pos_y_mm = by * parse_result.native_unit_to_mm + + world_matrix = Matrix.translation(pos_x_mm, pos_y_mm) @ Matrix.scale( + width_mm, height_mm + ) + + return LayoutItem( + layer_id=layer_id, + layer_name=layer_name, + world_matrix=world_matrix, + normalization_matrix=norm_matrix, + crop_window=bounds, + settings=settings, + color=color, + ) + + def calculate_layout( + self, + vec_result: VectorizationResult, + spec: VectorizationSpec | None, + ) -> list[LayoutItem]: + """ + Calculates the layout plan for creating WorkPieces. + + Determines how vector geometry should be positioned and sized in the + world based on the VectorizationSpec. + + Args: + vec_result: VectorizationResult containing vectorized geometry + and source parse metadata. + spec: Optional VectorizationSpec specifying layout strategy. + Defaults to PassthroughSpec if None. + + Returns: + List of LayoutItem objects, each representing one WorkPiece + configuration. May be empty if no valid geometry exists. + + Layout Strategies: + ------------------ + For TraceSpec: + - Single WorkPiece sized to document_bounds + - Uses the bitmap's coordinate system as reference + - Fallback to union of geometry bounds if document invalid + + For PassthroughSpec: + - If create_new_layers: One WorkPiece per layer, sized to content + - Otherwise: Single WorkPiece sized to union of all layer bounds + - Respects active_layer_ids filter if specified + - Falls back to document_bounds if no layers or geometry + """ + result = vec_result.source_parse_result + spec = spec or PassthroughSpec() + + if isinstance(spec, TraceSpec): + # Check if we have any valid geometries + has_valid_geo = any( + geo and not geo.is_empty() + for geo in vec_result.geometries_by_layer.values() + ) + if not has_valid_geo: + return [] + + # For traced results, the coordinate system and overall bounds are + # defined by the bitmap that was rendered for tracing. This is + # described in the source_parse_result. The actual vector geometry + # is just content within that frame. Using the document_bounds + # ensures the final workpiece size matches the background image + # size. + bounds_to_use = result.document_bounds + + # Fallback in case the document bounds are invalid + if bounds_to_use[2] <= 1e-6 or bounds_to_use[3] <= 1e-6: + all_rects = [] + for geo in vec_result.geometries_by_layer.values(): + if geo and not geo.is_empty(): + min_x, min_y, max_x, max_y = geo.rect() + all_rects.append( + (min_x, min_y, max_x - min_x, max_y - min_y) + ) + if not all_rects: + # No geometry and no valid page bounds, return empty plan + return [] + bounds_to_use = self._calculate_union_rect(all_rects) + + return [ + self.calculate_layout_item( + bounds_to_use, result, layer_id=None, layer_name=None + ) + ] + + # For direct vector imports (PassthroughSpec), bounds from parse phase + # are authoritative. + split_layers = False + active_layers = None + if isinstance(spec, PassthroughSpec): + split_layers = spec.layer_import_mode != LayerImportMode.FLATTEN + if spec.active_layer_ids: + active_layers = set(spec.active_layer_ids) + + # Filter relevant layers + target_layers: list[LayerGeometry] = result.layers + if active_layers: + target_layers = [ + geo for geo in result.layers if geo.layer_id in active_layers + ] + + if not target_layers: + # Fallback for empty files or no matching layers: use page bounds. + # But if page bounds are effectively zero-sized, return empty plan. + _bx, _by, bw, bh = result.document_bounds + if bw <= 1e-6 or bh <= 1e-6: + return [] + return [ + self.calculate_layout_item( + result.document_bounds, + result, + layer_id=None, + layer_name=None, + ) + ] + + if split_layers: + # Strategy: Each layer gets its own workpiece, sized to its + # content and positioned correctly in the world. + plan = [] + for layer in target_layers: + settings = vec_result.layer_settings.get(layer.layer_id) + plan.append( + self.calculate_layout_item( + layer.content_bounds, + result, + layer_id=layer.layer_id, + layer_name=layer.name, + settings=settings, + color=layer.color, + ) + ) + return plan + else: + # Strategy: Merged (Union Rect) + # Calculate union of all content bounds + union_rect = self._calculate_union_rect( + [geo.content_bounds for geo in target_layers] + ) + + # If union is zero/invalid (e.g. empty layers), fallback to page + if union_rect[2] <= 0 or union_rect[3] <= 0: + union_rect = result.document_bounds + + return [ + self.calculate_layout_item( + union_rect, result, layer_id=None, layer_name=None + ) + ] + + def _calculate_union_rect(self, rects: list[Rect]) -> Rect: + if not rects: + return (0.0, 0.0, 0.0, 0.0) + + min_x = rects[0][0] + min_y = rects[0][1] + max_x = min_x + rects[0][2] + max_y = min_y + rects[0][3] + + for x, y, w, h in rects[1:]: + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x + w) + max_y = max(max_y, y + h) + + return (min_x, min_y, max_x - min_x, max_y - min_y) diff --git a/rayforge/image/geo_renderer.py b/rayforge/image/geo_renderer.py new file mode 100644 index 000000000..a75a727cb --- /dev/null +++ b/rayforge/image/geo_renderer.py @@ -0,0 +1,123 @@ +"""Geometry rendering utilities using Cairo.""" + +import math + +import cairo +from raygeo.geo import Arc, Bezier, Geometry, Line, Move + + +def geometry_to_cairo( + geometry: Geometry, + ctx: cairo.Context, +) -> None: + """ + Render a Geometry object to a Cairo context. + + Args: + geometry: The geometry to render. + ctx: The Cairo context to draw to. + """ + last_point = (0.0, 0.0) + + for cmd in geometry.iter_typed_commands(): + end = (cmd.end[0], cmd.end[1]) + + if isinstance(cmd, Move): + ctx.move_to(end[0], end[1]) + elif isinstance(cmd, Line): + ctx.line_to(end[0], end[1]) + elif isinstance(cmd, Arc): + cx = last_point[0] + cmd.center_offset[0] + cy = last_point[1] + cmd.center_offset[1] + radius = math.hypot(cmd.center_offset[0], cmd.center_offset[1]) + + start_angle = math.atan2( + -cmd.center_offset[1], -cmd.center_offset[0] + ) + end_angle = math.atan2(end[1] - cy, end[0] - cx) + + clockwise = cmd.clockwise + if radius > 1e-9 and abs(end_angle - start_angle) < 1e-9: + mid = start_angle + math.pi + if clockwise: + ctx.arc_negative(cx, cy, radius, start_angle, mid) + ctx.arc_negative(cx, cy, radius, mid, start_angle) + else: + ctx.arc(cx, cy, radius, start_angle, mid) + ctx.arc(cx, cy, radius, mid, start_angle) + elif clockwise: + ctx.arc_negative(cx, cy, radius, start_angle, end_angle) + else: + ctx.arc(cx, cy, radius, start_angle, end_angle) + elif isinstance(cmd, Bezier): + ctx.curve_to( + cmd.control1[0], + cmd.control1[1], + cmd.control2[0], + cmd.control2[1], + end[0], + end[1], + ) + + last_point = end + + +def render_geometry_to_png( + geometry: Geometry, + size: int, + line_width: float | None = None, + color: tuple[float, float, float, float] | None = None, +) -> bytes | None: + """ + Render a geometry to PNG bytes fitting within a square of ``size`` pixels. + + Returns None if the geometry is empty. + + Args: + geometry: The geometry to render. + size: The square size of the output image in pixels. + line_width: Optional line width. Defaults to max(1.0/scale, 0.5). + color: Optional RGBA color tuple. Defaults to (0.55, 0.55, 0.55, 1.0). + + Returns: + PNG bytes or None if geometry is empty. + """ + if geometry.is_empty(): + return None + + x1, y1, x2, y2 = geometry.rect() + gw = x2 - x1 + gh = y2 - y1 + if gw < 1e-9 or gh < 1e-9: + return None + + padding = 4 + available = size - 2 * padding + scale = min(available / gw, available / gh) + + surface = cairo.ImageSurface(cairo.Format.ARGB32, size, size) + ctx = cairo.Context(surface) + + ctx.set_source_rgba(0, 0, 0, 0) + ctx.set_operator(cairo.Operator.SOURCE) + ctx.paint() + ctx.set_operator(cairo.Operator.OVER) + + ctx.translate(size / 2, size / 2) + ctx.scale(scale, -scale) + ctx.translate(-(x1 + gw / 2), -(y1 + gh / 2)) + + rgba = color or (0.55, 0.55, 0.55, 1.0) + ctx.set_source_rgba(*rgba) + width = line_width if line_width is not None else max(1.0 / scale, 0.5) + ctx.set_line_width(width) + geometry_to_cairo(geometry, ctx) + ctx.stroke() + + surface.flush() + + import io + + buf = io.BytesIO() + surface.write_to_png(buf) + return buf.getvalue() diff --git a/rayforge/image/hull.py b/rayforge/image/hull.py new file mode 100644 index 000000000..b39fb3dfe --- /dev/null +++ b/rayforge/image/hull.py @@ -0,0 +1,102 @@ +import numpy as np +from raygeo.geo import Geometry +from raygeo.geo.algo import hull as _hull + + +def _transform_geometry( + geo: Geometry, + scale_x: float, + scale_y: float, + height_px: int, + border_size: int, +) -> Geometry: + """ + Transform a Geometry's vertex coordinates from pixel space to + millimeter space, applying scaling, Y-axis inversion, and border + offset. + """ + data = geo.data + if len(data) < 2: + return geo + + new_geo = Geometry() + for i, cmd in enumerate(data[:-1]): + px = cmd.end[0] - border_size + py = height_px - (cmd.end[1] - border_size) + x = px / scale_x + y = py / scale_y + if i == 0: + new_geo.move_to(x, y) + else: + new_geo.line_to(x, y) + new_geo.close_path() + return new_geo + + +def get_enclosing_hull( + boolean_image: np.ndarray, + scale_x: float, + scale_y: float, + height_px: int, + border_size: int, +) -> Geometry | None: + """ + Calculates a single convex hull that encompasses all content in the image. + + Delegates to the raygeo Rust backend for contour tracing and convex hull + computation, then transforms pixel coordinates to millimeter space. + """ + geo = _hull.get_enclosing_hull(boolean_image) + if geo is None: + return None + return _transform_geometry(geo, scale_x, scale_y, height_px, border_size) + + +def get_hulls_from_image( + boolean_image: np.ndarray, + scale_x: float, + scale_y: float, + height_px: int, + border_size: int, +) -> list[Geometry]: + """ + Finds all distinct contours in a boolean image, calculates the convex + hull for each, and returns them as a list of Geometry objects. + + Args: + boolean_image: The clean boolean image containing only major shapes. + scale_x: Pixels per millimeter (X). + scale_y: Pixels per millimeter (Y). + height_px: Original height of the source surface in pixels. + border_size: The pixel border size added during pre-processing. + + Returns: + A list of Geometry objects, each representing a convex hull. + """ + geometries = _hull.get_hulls_from_image(boolean_image) + return [ + _transform_geometry(geo, scale_x, scale_y, height_px, border_size) + for geo in geometries + ] + + +def get_concave_hull( + boolean_image: np.ndarray, + scale_x: float, + scale_y: float, + height_px: int, + border_size: int, + gravity: float = 0.1, +) -> Geometry | None: + """ + Calculates a smooth, constrained concave hull that "shrink-wraps" the + content geometrically, mimicking a physical rubber band using Bézier + curves. + + Delegates to the raygeo Rust backend for the full algorithm, then + transforms pixel coordinates to millimeter space. + """ + geo = _hull.get_concave_hull(boolean_image, gravity) + if geo is None: + return None + return _transform_geometry(geo, scale_x, scale_y, height_px, border_size) diff --git a/rayforge/image/jpg/__init__.py b/rayforge/image/jpg/__init__.py new file mode 100644 index 000000000..131667364 --- /dev/null +++ b/rayforge/image/jpg/__init__.py @@ -0,0 +1,3 @@ +from .importer import JpgImporter + +__all__ = ["JpgImporter"] diff --git a/rayforge/image/jpg/importer.py b/rayforge/image/jpg/importer.py new file mode 100644 index 000000000..077b1d0e6 --- /dev/null +++ b/rayforge/image/jpg/importer.py @@ -0,0 +1,181 @@ +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 JPG_RENDERER + +logger = logging.getLogger(__name__) + + +class JpgImporter(Importer): + label = "JPEG files" + mime_types = ("image/jpeg",) + extensions = (".jpg", ".jpeg") + 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 scan(self) -> ImportManifest: + """ + Scans the JPEG to extract physical dimensions from its metadata. + """ + try: + image = pyvips.Image.jpegload_buffer( + self.raw_data, access=pyvips.Access.SEQUENTIAL + ) + size_mm = util.get_physical_size_mm(image) + return ImportManifest( + title=self.source_file.name, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + ) + except pyvips.Error as e: + logger.warning( + f"JPEG scan failed for {self.source_file.name}: {e}" + ) + self.add_error(_("Failed to scan JPEG file: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset for JPEG import. + """ + metadata = util.extract_vips_metadata(self._image) + metadata["image_format"] = "JPEG" + _, _, w_px, h_px = parse_result.document_bounds + width_mm = w_px * parse_result.native_unit_to_mm + height_mm = h_px * parse_result.native_unit_to_mm + + return SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=JPG_RENDERER, + metadata=metadata, + thumbnail_data=self._render_thumbnail_from_vips(self._image), + width_px=int(w_px), + height_px=int(h_px), + width_mm=width_mm, + height_mm=height_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("JpgImporter only supports TraceSpec") + + normalized_image = util.normalize_to_rgba(self._image) + if not normalized_image: + logger.error("Failed to normalize image to RGBA format.") + 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) + geometries = trace_surface(surface, spec) + merged_geo = Geometry() + for geo in geometries: + merged_geo.extend(geo) + + return VectorizationResult( + geometries_by_layer={None: merged_geo}, + source_parse_result=parse_result, + ) + + def parse(self) -> ParsingResult | None: + """Phase 2: Parse the JPG into a vips image and extract facts.""" + try: + image = pyvips.Image.jpegload_buffer( + self.raw_data, access=pyvips.Access.RANDOM + ) + except pyvips.Error as e: + logger.exception("pyvips failed to load JPEG buffer") + self.add_error(_("Image load failed: {}").format(e)) + self._image = None + return None + + self._image = image + + # Extract geometric facts + width_px = float(image.width) + height_px = float(image.height) + document_bounds = (0.0, 0.0, width_px, height_px) + + # VIPS stores resolution as pixels per millimeter in xres/yres + if image.xres > 0: + native_unit_to_mm = 1.0 / image.xres + else: + # Fallback to a standard screen DPI if metadata is missing + default_dpi = 96.0 + native_unit_to_mm = 25.4 / default_dpi + + # World frame is Y-Up, so y-origin is 0. + 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, + ) + + # Create temporary result to calculate background transform + 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, + ) diff --git a/rayforge/image/jpg/renderer.py b/rayforge/image/jpg/renderer.py new file mode 100644 index 000000000..097cc7367 --- /dev/null +++ b/rayforge/image/jpg/renderer.py @@ -0,0 +1,28 @@ +import warnings + +from ..base_renderer import RasterRenderer + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + + +class JpgRenderer(RasterRenderer): + """Renders JPEG data.""" + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + if not data: + return None + try: + return pyvips.Image.jpegload_buffer(data) + except pyvips.Error: + return None + + +JPG_RENDERER = JpgRenderer() diff --git a/rayforge/image/lightburn/__init__.py b/rayforge/image/lightburn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/image/lightburn/importer.py b/rayforge/image/lightburn/importer.py new file mode 100644 index 000000000..d464053a0 --- /dev/null +++ b/rayforge/image/lightburn/importer.py @@ -0,0 +1,786 @@ +from __future__ import annotations + +import base64 +import binascii +import logging +import math +import re +import warnings +from dataclasses import dataclass +from gettext import gettext as _ +from pathlib import Path +from typing import Any, ClassVar +from xml.etree import ElementTree as ET + +from raygeo.geo import Geometry + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from raygeo.geo import Matrix + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import ( + TraceSpec, + VectorizationSpec, +) +from ...image import util +from ...image.geo_renderer import render_geometry_to_png +from ...image.tracing import trace_surface +from ..base_importer import ( + Importer, + ImporterFeature, +) +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + LayerGeometry, + LayerInfo, + ParsingResult, + VectorizationResult, +) +from .renderer import LIGHTBURN_RENDERER + +logger = logging.getLogger(__name__) + + +_XFORM_RE = re.compile(r"\s+") +_VERTLIST_RE = re.compile( + r"V\s*" + r"(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s+" + r"(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)" + r"\s*" + r"((?:c0[xXyY]-?(?:\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)?" + r"|c1[xXyY]-?(?:\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)?)*)" +) +_CONTROL_PT_RE = re.compile( + r"(c0[xXyY]|c1[xXyY])(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)" +) +_PRIMLIST_ITEM_RE = re.compile(r"([A-Za-z])\s*(-?\d+(?:\s+-?\d+)*)?") + + +def _parse_xform(text: str) -> Matrix: + parts = _XFORM_RE.split(text.strip()) + if len(parts) != 6: + logger.warning("Invalid XForm string: %s, using identity", text) + return Matrix() + try: + a, b, c, d, tx, ty = (float(v) for v in parts) + except ValueError: + logger.warning("Invalid XForm values: %s, using identity", text) + return Matrix() + return Matrix( + [ + [a, c, tx], + [b, d, ty], + [0, 0, 1], + ] + ) + + +def _parse_verts(text: str) -> list[dict[str, float]]: + verts: list[dict[str, float]] = [] + for match in _VERTLIST_RE.finditer(text): + x = float(match.group(1)) + y = float(match.group(2)) + v: dict[str, float] = {"x": x, "y": y} + cp_str = match.group(3) + for cp_match in _CONTROL_PT_RE.finditer(cp_str): + key = cp_match.group(1).lower() + value = float(cp_match.group(2)) + v[key] = value + verts.append(v) + return verts + + +def _parse_prims(text: str) -> list[tuple[str, int, int]]: + prims: list[tuple[str, int, int]] = [] + for match in _PRIMLIST_ITEM_RE.finditer(text): + prim_type = match.group(1) + args_str = match.group(2) + if prim_type == "L" and args_str: + parts = [int(x) for x in args_str.split()] + if len(parts) >= 2: + prims.append(("L", parts[0], parts[1])) + elif prim_type == "B" and args_str: + parts = [int(x) for x in args_str.split()] + if len(parts) >= 2: + prims.append(("B", parts[0], parts[1])) + return prims + + +def _apply_xform_to_geo(geo: Geometry, xform: Matrix) -> Geometry: + if xform.is_identity(): + return geo + geo = geo.copy() + geo.transform(xform) + return geo + + +def _build_rect(w: float, h: float, cr: float) -> Geometry: + geo = Geometry() + if w <= 0 or h <= 0: + return geo + hw, hh = w / 2.0, h / 2.0 + + if cr <= 0: + geo.move_to(-hw, -hh) + geo.line_to(hw, -hh) + geo.line_to(hw, hh) + geo.line_to(-hw, hh) + geo.close_path() + else: + cr = min(cr, hw, hh) + segments = 8 + pts = _rounded_rect_points(-hw, -hh, w, h, cr, segments) + geo.move_to(pts[0][0], pts[0][1]) + for px, py in pts[1:]: + geo.line_to(px, py) + geo.close_path() + return geo + + +def _rounded_rect_points( + x: float, y: float, w: float, h: float, r: float, seg: int +) -> list[tuple[float, float]]: + pts: list[tuple[float, float]] = [] + for i in range(seg + 1): + a = (math.pi / 2) * (i / seg) + pts.append((x + r - r * math.cos(a), y + r - r * math.sin(a))) + for i in range(seg + 1): + a = (math.pi / 2) * (i / seg) + pts.append((x + w - r + r * math.sin(a), y + r - r * math.cos(a))) + for i in range(seg + 1): + a = (math.pi / 2) * (i / seg) + pts.append((x + w - r + r * math.cos(a), y + h - r + r * math.sin(a))) + for i in range(seg + 1): + a = (math.pi / 2) * (i / seg) + pts.append((x + r - r * math.sin(a), y + h - r + r * math.cos(a))) + return pts + + +def _build_ellipse(rx: float, ry: float) -> Geometry: + geo = Geometry() + if rx <= 0 or ry <= 0: + return geo + + n_segments = 32 + pts: list[tuple[float, float]] = [] + for i in range(n_segments): + a = 2 * math.pi * i / n_segments + pts.append((rx * math.cos(a), ry * math.sin(a))) + + if pts: + geo.move_to(pts[0][0], pts[0][1]) + for px, py in pts[1:]: + geo.line_to(px, py) + geo.close_path() + + return geo + + +def _build_path_from_verts_and_prims( + verts: list[dict[str, float]], + prims: list[tuple[str, int, int]], + prim_list_raw: str, +) -> Geometry: + geo = Geometry() + if not verts: + return geo + + if prim_list_raw.strip() == "LineClosed" or not prims: + if len(verts) == 1: + geo.move_to(verts[0]["x"], verts[0]["y"]) + geo.close_path() + elif len(verts) > 1: + geo.move_to(verts[0]["x"], verts[0]["y"]) + for v in verts[1:]: + geo.line_to(v["x"], v["y"]) + geo.close_path() + return geo + + for prim_type, si, ei in prims: + if si < 0 or si >= len(verts) or ei < 0 or ei >= len(verts): + logger.warning("Path primitive index out of range: %d, %d", si, ei) + continue + sv = verts[si] + ev = verts[ei] + sx, sy = sv["x"], sv["y"] + ex, ey = ev["x"], ev["y"] + + need_move = geo.is_empty() + if not need_move: + lx, ly, _ = geo.get_last_point() + if abs(lx - sx) > 1e-8 or abs(ly - sy) > 1e-8: + need_move = True + + if need_move: + geo.move_to(sx, sy) + + if prim_type == "L": + geo.line_to(ex, ey) + elif prim_type == "B": + c0x = sv.get("c0x") + c0y = sv.get("c0y") + c1x = ev.get("c1x") + c1y = ev.get("c1y") + if ( + c0x is not None + and c0y is not None + and c1x is not None + and c1y is not None + ): + geo.bezier_to(ex, ey, c0x, c0y, c1x, c1y) + else: + geo.line_to(ex, ey) + + return geo + + +def _build_path_text(shape_elem: ET.Element) -> Geometry | None: + backup_path = shape_elem.find("BackupPath") + if backup_path is None: + return None + bp_shape = backup_path.find("Shape") + if bp_shape is None: + return None + if bp_shape.get("Type") != "Path": + return None + vert_list_el = bp_shape.find("VertList") + prim_list_el = bp_shape.find("PrimList") + if vert_list_el is None or vert_list_el.text is None: + return None + verts = _parse_verts(vert_list_el.text) + prim_list_raw = ( + prim_list_el.text + if prim_list_el is not None and prim_list_el.text + else "" + ) + prims = _parse_prims(prim_list_raw) + return _build_path_from_verts_and_prims(verts, prims, prim_list_raw) + + +@dataclass +class BitmapInfo: + cut_index: int + xform: Matrix + width: float + height: float + png_data: bytes + + +def _shape_to_geometry( + shape_elem: ET.Element, + cut_settings: dict[int, dict[str, Any]], + bitmaps: list[BitmapInfo] | None = None, +) -> tuple[int, Geometry] | None: + shape_type = shape_elem.get("Type") + cut_index = int(shape_elem.get("CutIndex", "0")) + + xform_el = shape_elem.find("XForm") + xform = Matrix() + if xform_el is not None and xform_el.text: + xform = _parse_xform(xform_el.text) + + geo: Geometry | None = None + + if shape_type == "Rect": + w = float(shape_elem.get("W", "0")) + h = float(shape_elem.get("H", "0")) + cr = float(shape_elem.get("Cr", "0")) + if w > 0 and h > 0: + geo = _build_rect(w, h, cr) + + elif shape_type == "Ellipse": + rx = float(shape_elem.get("Rx", "0")) + ry = float(shape_elem.get("Ry", "0")) + if rx > 0 and ry > 0: + geo = _build_ellipse(rx, ry) + + elif shape_type == "Path": + vert_list_el = shape_elem.find("VertList") + prim_list_el = shape_elem.find("PrimList") + if vert_list_el is not None and vert_list_el.text is not None: + verts = _parse_verts(vert_list_el.text) + prim_list_raw = ( + prim_list_el.text + if prim_list_el is not None and prim_list_el.text + else "" + ) + prims = _parse_prims(prim_list_raw) + geo = _build_path_from_verts_and_prims(verts, prims, prim_list_raw) + + elif shape_type == "Text": + has_backup = shape_elem.get("HasBackupPath", "0") == "1" + if has_backup: + geo = _build_path_text(shape_elem) + + elif shape_type == "Group": + children_elem = shape_elem.find("Children") + if children_elem is not None: + combined = Geometry() + for child in children_elem.findall("Shape"): + child_result = _shape_to_geometry(child, cut_settings, bitmaps) + if child_result is not None: + _child_cut_idx, child_geo = child_result + child_geo = _apply_xform_to_geo(child_geo, xform) + combined.extend(child_geo) + if not combined.is_empty(): + return (cut_index, combined) + return None + + elif shape_type == "Bitmap": + w = float(shape_elem.get("W", "0")) + h = float(shape_elem.get("H", "0")) + data_b64 = shape_elem.get("Data", "") + if w > 0 and h > 0 and data_b64: + try: + png_bytes = base64.b64decode(data_b64) + except (binascii.Error, ValueError): + logger.warning("Failed to decode Bitmap data") + return None + if bitmaps is not None: + bitmaps.append( + BitmapInfo( + cut_index=cut_index, + xform=xform, + width=w, + height=h, + png_data=png_bytes, + ) + ) + geo = _build_rect(w, h, cr=0) + else: + return None + + if geo is None or geo.is_empty(): + return None + + geo = _apply_xform_to_geo(geo, xform) + return (cut_index, geo) + + +def _build_step_config( + cs: dict[str, Any], +) -> dict[str, Any] | None: + """Translate LightBurn cut settings to generic step configuration.""" + config: dict[str, Any] = {} + max_power = cs.get("maxPower") + if max_power is not None: + config["power"] = float(max_power) / 100.0 + min_power = cs.get("minPower") + if min_power is not None: + config["min_power_level"] = float(min_power) / 100.0 + speed = cs.get("speed") + if speed is not None: + config["cut_speed"] = round(float(speed) * 60.0) + kerf = cs.get("kerf") + if kerf is not None: + # LightBurn's kerf is the full beam width; rayforge's offset_mm + # is the displacement applied to the path (half the width), so + # halve the imported value to preserve behaviour. + config["offset_mm"] = float(kerf) / 2.0 + dot_width = cs.get("dotWidth") + if dot_width is not None: + # LightBurn's dotWidth is the total amount shortened per run; + # raygeo's dot_width_correction_mm is applied at each end, so + # halve the imported value to preserve behaviour. + config["dot_width_correction_mm"] = float(dot_width) / 2.0 + interval = cs.get("interval") + if interval is not None: + config["line_interval_mm"] = float(interval) + angle = cs.get("angle") + if angle is not None: + config["scan_angle"] = float(angle) + num_passes = cs.get("numPasses") + if num_passes is not None: + config["passes"] = int(num_passes) + return config or None + + +class LightBurnImporter(Importer): + label = "LightBurn project files" + mime_types = ("application/x-lightburn",) + extensions = (".lbrn", ".lbrn2") + features: ClassVar[set[ImporterFeature]] = { + ImporterFeature.DIRECT_VECTOR, + ImporterFeature.LAYER_SELECTION, + ImporterFeature.BITMAP_TRACING, + } + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self._geometries_by_layer: dict[str, Geometry] = {} + self._cut_settings: dict[int, dict[str, Any]] = {} + self._cut_setting_kinds: dict[int, str] = {} + self._project_title: str = "" + self._bitmaps: list[BitmapInfo] = [] + + def scan(self) -> ImportManifest: + try: + root = ET.fromstring(self.raw_data) + except ET.ParseError as e: + logger.warning("LightBurn scan failed: %s", e) + self.add_error(_("LightBurn file is invalid XML: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + project = root.find("LightBurnProject") + if project is None: + project = root + + title = project.get("AppVersion", self.source_file.stem) + layers: list[LayerInfo] = [] + cut_settings = self._parse_cut_settings(project) + + for cs in cut_settings.values(): + name = cs.get("name", f"Layer {cs['index']}") + layers.append( + LayerInfo( + id=str(cs["index"]), + name=name, + feature_count=cs.get("shape_count", 0), + ) + ) + + return ImportManifest( + title=title, + layers=layers, + natural_size_mm=None, + warnings=self._warnings, + errors=self._errors, + ) + + def _parse_cut_settings( + self, project: ET.Element + ) -> dict[int, dict[str, Any]]: + cut_settings: dict[int, dict[str, Any]] = {} + for cs_elem in list(project.findall("CutSetting")) + list( + project.findall("CutSetting_Img") + ): + index_el = cs_elem.find("index") + if index_el is None: + continue + idx = int(index_el.get("Value", "0")) + params: dict[str, Any] = {"index": idx} + for child in cs_elem: + tag = child.tag + val = child.get("Value") + if val is not None: + try: + if "." in val or "e" in val.lower(): + params[tag] = float(val) + else: + params[tag] = int(val) + except ValueError: + params[tag] = val + else: + params[tag] = child.text or "" + cut_settings[idx] = params + self._cut_setting_kinds[idx] = ( + "image" if cs_elem.tag == "CutSetting_Img" else "cut" + ) + return cut_settings + + def _render_bitmaps_to_svg(self) -> bytes | None: + if not self._bitmaps: + return None + + min_x = float("inf") + min_y = float("inf") + max_x = float("-inf") + max_y = float("-inf") + image_tags: list[str] = [] + + for bm in self._bitmaps: + hw, hh = bm.width / 2.0, bm.height / 2.0 + a, b, c, d, tx, ty = bm.xform.for_cairo() + data_url = "data:image/png;base64," + base64.b64encode( + bm.png_data + ).decode("ascii") + transform = f"matrix({a} {-b} {c} {-d} {tx} {-ty})" + + corners_lx = [-hw, hw, hw, -hw] + corners_ly = [-hh, -hh, hh, hh] + for lx, ly in zip(corners_lx, corners_ly): + sx = a * lx + c * ly + tx + sy = -(b * lx + d * ly + ty) + min_x = min(min_x, sx) + min_y = min(min_y, sy) + max_x = max(max_x, sx) + max_y = max(max_y, sy) + + image_tags.append( + f'' + ) + + if min_x == float("inf"): + return None + + vw = max(max_x - min_x, 1.0) + vh = max(max_y - min_y, 1.0) + svg_parts = [ + ( + '' + ) + ] + svg_parts.extend(image_tags) + svg_parts.append("") + return "".join(svg_parts).encode("utf-8") + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + _, _, w, h = parse_result.document_bounds + + merged = Geometry() + for geo in self._geometries_by_layer.values(): + if geo: + merged.extend(geo) + thumbnail_data = ( + render_geometry_to_png( + merged, + 256, + line_width=2.0, + color=(0.2, 0.2, 0.2, 1.0), + ) + if not merged.is_empty() + else None + ) + + # Store LightBurn cut settings in source asset metadata so they + # survive project save/load and can be re-applied on re-import. + cut_settings_by_name: dict[str, dict[str, Any]] = {} + for idx, cs in self._cut_settings.items(): + name = cs.get("name", str(idx)) + cut_settings_by_name[name] = dict(cs) + + source = SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=LIGHTBURN_RENDERER, + thumbnail_data=thumbnail_data, + width_mm=w, + height_mm=h, + ) + source.metadata["lightburn_cut_settings"] = cut_settings_by_name + + if self._bitmaps: + svg_data = self._render_bitmaps_to_svg() + if svg_data: + source.base_render_data = svg_data + + return source + + def _trace_bitmaps( + self, + parse_result: ParsingResult, + spec: TraceSpec, + ) -> dict[str | None, Geometry]: + geometries_by_layer: dict[str | None, Geometry] = {} + for bm in self._bitmaps: + layer_id = str(bm.cut_index) + try: + img = pyvips.Image.pngload_buffer( + bm.png_data, access=pyvips.Access.SEQUENTIAL + ) + except pyvips.Error: + logger.warning("Failed to load bitmap for tracing") + continue + + normalized = util.normalize_to_rgba(img) + if normalized is None: + continue + + surface = util.vips_rgba_to_cairo_surface(normalized) + traced_geom_list = trace_surface(surface, spec) + + traced_geo = Geometry() + for g in traced_geom_list: + traced_geo.extend(g) + + if traced_geo.is_empty(): + continue + + traced_geo = _apply_xform_to_geo(traced_geo, bm.xform) + if layer_id not in geometries_by_layer: + geometries_by_layer[layer_id] = Geometry() + geometries_by_layer[layer_id].extend(traced_geo) + + return geometries_by_layer + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + from ...core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + ) + + if isinstance(spec, TraceSpec): + traced = self._trace_bitmaps(parse_result, spec) + merged = Geometry() + for geo in traced.values(): + merged.extend(geo) + return VectorizationResult( + geometries_by_layer=traced or {None: merged}, + source_parse_result=parse_result, + ) + + split_layers = False + active_layers_set = None + if isinstance(spec, PassthroughSpec): + split_layers = spec.layer_import_mode != LayerImportMode.FLATTEN + if spec.active_layer_ids: + active_layers_set = set(spec.active_layer_ids) + + geometries: dict[str | None, Geometry] + if active_layers_set: + geometries = { + layer_id: geo + for layer_id, geo in self._geometries_by_layer.items() + if layer_id in active_layers_set + } + else: + g: dict[str | None, Geometry] = {} + for k, v in self._geometries_by_layer.items(): + g[k] = v + geometries = g + + merged_geo = Geometry() + for geo in geometries.values(): + merged_geo.extend(geo) + + if split_layers: + final_geometries: dict[str | None, Geometry] = geometries or { + None: merged_geo + } + else: + final_geometries = {None: merged_geo} + + # Build per-layer settings from cut settings for the assembler. + layer_settings: dict[str | None, dict[str, Any]] = {} + for layer_id in final_geometries: + if layer_id is None: + continue + try: + layer_idx = int(layer_id) + except (ValueError, TypeError): + continue + cs = self._cut_settings.get(layer_idx) + if cs is None: + continue + config = _build_step_config(cs) + if not config: + continue + if self._cut_setting_kinds.get(layer_idx) == "image": + config["_is_image_layer"] = True + # EngraveStep modulates between min_power_level / + # max_power_level; LightBurn's power is the raster + # ceiling, so mirror it into max_power_level as well. + if "power" in config: + config["max_power_level"] = config["power"] + layer_settings[layer_id] = config + + return VectorizationResult( + geometries_by_layer=final_geometries, + source_parse_result=parse_result, + layer_settings=layer_settings, + ) + + def parse(self) -> ParsingResult | None: + try: + root = ET.fromstring(self.raw_data) + except ET.ParseError as e: + self.add_error( + _("LightBurn file is corrupt or invalid: {}").format(e) + ) + return None + + project = root.find("LightBurnProject") + if project is None: + project = root + + self._cut_settings = self._parse_cut_settings(project) + self._bitmaps = [] + + geometries_by_layer: dict[str, Geometry] = {} + shape_count_by_layer: dict[int, int] = {} + + for shape_elem in project.findall("Shape"): + result = _shape_to_geometry( + shape_elem, self._cut_settings, self._bitmaps + ) + if result is not None: + cut_idx, geo = result + layer_id = str(cut_idx) + if layer_id not in geometries_by_layer: + geometries_by_layer[layer_id] = Geometry() + geometries_by_layer[layer_id].extend(geo) + shape_count_by_layer[cut_idx] = ( + shape_count_by_layer.get(cut_idx, 0) + 1 + ) + + self._geometries_by_layer = geometries_by_layer + + for idx, cs in self._cut_settings.items(): + cs["shape_count"] = shape_count_by_layer.get(idx, 0) + + all_geo = Geometry() + for geo in geometries_by_layer.values(): + if geo: + all_geo.extend(geo) + + if all_geo.is_empty(): + document_bounds = (0.0, 0.0, 0.0, 0.0) + else: + min_x, min_y, max_x, max_y = all_geo.rect() + w = max(max_x - min_x, 1e-9) + h = max(max_y - min_y, 1e-9) + document_bounds = (min_x, min_y, w, h) + + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[], + world_frame_of_reference=document_bounds, + background_world_transform=None, # type: ignore + ) + + bg_item = NormalizationEngine.calculate_layout_item( + document_bounds, temp_result + ) + + layer_geometries: list[LayerGeometry] = [] + for layer_id, geo in geometries_by_layer.items(): + if geo.is_empty(): + continue + min_x, min_y, max_x, max_y = geo.rect() + w = max(max_x - min_x, 1e-9) + h = max(max_y - min_y, 1e-9) + cs = self._cut_settings.get(int(layer_id), {}) + name = cs.get("name", layer_id) + layer_geometries.append( + LayerGeometry( + layer_id=layer_id, + name=str(name), + content_bounds=(min_x, min_y, w, h), + ) + ) + + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=layer_geometries, + world_frame_of_reference=document_bounds, + background_world_transform=bg_item.world_matrix, + ) diff --git a/rayforge/image/lightburn/renderer.py b/rayforge/image/lightburn/renderer.py new file mode 100644 index 000000000..8ea1a256d --- /dev/null +++ b/rayforge/image/lightburn/renderer.py @@ -0,0 +1,118 @@ +import logging +import warnings +from typing import TYPE_CHECKING, Optional +from xml.etree import ElementTree as ET + +from ..base_renderer import Renderer, RenderSpecification +from ..ops_renderer import OPS_RENDERER +from ..svg.svg_fallback import ( + SVG_LOAD_AVAILABLE, + cairo_surface_to_vips, + render_svg_to_cairo, +) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + from ...core.source_asset_segment import SourceAssetSegment + from ...core.workpiece import RenderContext + from ...image.structures import ImportResult + +logger = logging.getLogger(__name__) + + +class LightBurnRenderer(Renderer): + def compute_render_spec( + self, + segment: Optional["SourceAssetSegment"], + target_size: tuple[int, int], + source_context: "RenderContext", + ) -> "RenderSpecification": + kwargs = { + "boundaries": source_context.boundaries, + } + return RenderSpecification( + width=target_size[0], + height=target_size[1], + data=source_context.data, + kwargs=kwargs, + apply_mask=False, + ) + + def render_preview_image( + self, + import_result: "ImportResult", + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + if not import_result.payload: + return None + + source = import_result.payload.source + data_to_render = source.base_render_data or source.original_data + if not data_to_render: + return None + + return self.render_base_image( + data=data_to_render, width=target_width, height=target_height + ) + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + if data and data.startswith(b" pyvips.Image | None: + try: + root = ET.fromstring(svg_data) + except ET.ParseError: + return None + root.set("width", f"{width}px") + root.set("height", f"{height}px") + root.set("preserveAspectRatio", "xMidYMid meet") + svg_bytes = ET.tostring(root) + try: + if SVG_LOAD_AVAILABLE: + return pyvips.Image.svgload_buffer(svg_bytes) + surface = render_svg_to_cairo(svg_bytes, width, height) + if surface: + return cairo_surface_to_vips(surface) + except pyvips.Error as e: + logger.warning(f"Failed to render SVG image: {e}") + return None + return None + + +LIGHTBURN_RENDERER = LightBurnRenderer() diff --git a/rayforge/image/material_test_grid_renderer.py b/rayforge/image/material_test_grid_renderer.py new file mode 100644 index 000000000..d3a47d8bb --- /dev/null +++ b/rayforge/image/material_test_grid_renderer.py @@ -0,0 +1,211 @@ +""" +Material Test Renderer + +Renders a preview visualization of a material test grid for display on the +canvas. The actual ops generation is handled by MaterialTestGridProducer. +""" + +from __future__ import annotations + +import json +import logging +import warnings +from typing import Any + +import cairo + +from .base_renderer import Renderer + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + + +logger = logging.getLogger(__name__) + + +class MaterialTestRenderer(Renderer): + """Renders material test grid previews.""" + + def _get_params_from_data( + self, data: bytes | None + ) -> dict[str, Any] | None: + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error(f"Failed to decode material test parameters: {e}") + return None + + def _draw_grid(self, ctx: cairo.Context, params: dict[str, Any]): + cols, rows = ( + int(params["grid_dimensions"][0]), + int(params["grid_dimensions"][1]), + ) + shape_size = params["shape_size"] + spacing = params["spacing"] + speed_range = params["speed_range"] + power_range = params["power_range"] + test_type = params.get("test_type", "Cut") + grid_mode = params.get("grid_mode", "Power vs Speed") + passes_range = params.get("passes_range", (1, 5)) + + min_speed, max_speed = speed_range + min_power, max_power = power_range + min_passes, max_passes = int(passes_range[0]), int(passes_range[1]) + + if grid_mode == "Power vs Passes": + col_range = (min_power, max_power) + row_range = (float(min_passes), float(max_passes)) + elif grid_mode == "Speed vs Passes": + col_range = (min_speed, max_speed) + row_range = (float(min_passes), float(max_passes)) + else: + col_range = (min_power, max_power) + row_range = (min_speed, max_speed) + + col_step = ( + (col_range[1] - col_range[0]) / (cols - 1) if cols > 1 else 0 + ) + row_step = ( + (row_range[1] - row_range[0]) / (rows - 1) if rows > 1 else 0 + ) + + col_span = col_range[1] - col_range[0] + row_span = row_range[1] - row_range[0] + + for r in range(rows): + for c in range(cols): + col_val = col_range[0] + c * col_step + row_val = row_range[0] + r * row_step + + if grid_mode == "Power vs Passes": + power_factor = ( + (col_val - col_range[0]) / col_span + if col_span > 0 + else 0 + ) + passes_factor = ( + (row_val - row_range[0]) / row_span + if row_span > 0 + else 0 + ) + intensity = (power_factor + passes_factor) / 2.0 + elif grid_mode == "Speed vs Passes": + speed_factor = ( + 1.0 - (col_val - col_range[0]) / col_span + if col_span > 0 + else 0 + ) + passes_factor = ( + (row_val - row_range[0]) / row_span + if row_span > 0 + else 0 + ) + intensity = (speed_factor + passes_factor) / 2.0 + else: + speed_factor = ( + 1.0 - (row_val - row_range[0]) / row_span + if row_span > 0 + else 0 + ) + power_factor = ( + (col_val - col_range[0]) / col_span + if col_span > 0 + else 0 + ) + intensity = (speed_factor + power_factor) / 2.0 + + # Gradient from light gray (0.9) to dark gray (0.3) + gray = 0.9 - (intensity * 0.6) + + x = c * (shape_size + spacing) + y = r * (shape_size + spacing) + + if test_type == "Engrave": + # Fill cell with horizontal lines for engrave mode + ctx.set_source_rgb(gray, gray, gray) + ctx.rectangle(x, y, shape_size, shape_size) + ctx.fill() + + # Draw horizontal raster lines + ctx.set_source_rgb(0.5, 0.5, 0.5) + ctx.set_line_width(0.1) + line_spacing = shape_size / 10 # ~10 lines per box + for i in range(11): + y_line = y + (i * line_spacing) + ctx.move_to(x, y_line) + ctx.line_to(x + shape_size, y_line) + ctx.stroke() + else: + # Cut mode: just a light fill + ctx.set_source_rgb(0.95, 0.95, 0.95) + ctx.rectangle(x, y, shape_size, shape_size) + ctx.fill() + + # Border for both modes + ctx.set_source_rgb(0.3, 0.3, 0.3) + ctx.set_line_width(0.2) + ctx.rectangle(x, y, shape_size, shape_size) + ctx.stroke() + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + params = self._get_params_from_data(data) + if not params: + return None + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + ctx.set_source_rgb(1, 1, 1) + ctx.paint() + + cols, rows = ( + int(params["grid_dimensions"][0]), + int(params["grid_dimensions"][1]), + ) + shape_size = params["shape_size"] + spacing = params["spacing"] + + grid_width = cols * (shape_size + spacing) - spacing + grid_height = rows * (shape_size + spacing) - spacing + + # Add margins for labels if enabled (using shared layout) + include_labels = params.get("include_labels", True) + offset_x = 0 + offset_y = 0 + total_width = grid_width + total_height = grid_height + + if include_labels: + base_margin = min(shape_size * 1.5, 15.0) + total_width = grid_width + base_margin + total_height = grid_height + base_margin + offset_x = base_margin + offset_y = base_margin + + scale_x = width / total_width if total_width > 0 else 1 + scale_y = height / total_height if total_height > 0 else 1 + + ctx.scale(scale_x, -scale_y) + ctx.translate(offset_x, -total_height + offset_y) + + self._draw_grid(ctx, params) + + h, w = surface.get_height(), surface.get_width() + vips_image = pyvips.Image.new_from_memory( + surface.get_data(), w, h, 4, "uchar" + ) + b, g, r, a = ( + vips_image[0], + vips_image[1], + vips_image[2], + vips_image[3], + ) + return r.bandjoin([g, b, a]) diff --git a/rayforge/image/ops_renderer.py b/rayforge/image/ops_renderer.py new file mode 100644 index 000000000..f891a3556 --- /dev/null +++ b/rayforge/image/ops_renderer.py @@ -0,0 +1,178 @@ +import logging +from typing import TYPE_CHECKING, Optional + +import cairo + +if TYPE_CHECKING: + from ..core.source_asset_segment import SourceAssetSegment + from ..core.workpiece import RenderContext + +import warnings + +from raygeo.geo import Geometry + +from .base_renderer import Renderer, RenderSpecification +from .geo_renderer import geometry_to_cairo + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +# Cairo has a hard limit on surface dimensions, often 32767. +# We use a slightly more conservative value to be safe. +CAIRO_MAX_DIMENSION = 16384 + +logger = logging.getLogger(__name__) + + +class OpsRenderer(Renderer): + """ + Renders vector geometry (Geometry) to an image. + """ + + def compute_render_spec( + self, + segment: Optional["SourceAssetSegment"], + target_size: tuple[int, int], + source_context: "RenderContext", + ) -> "RenderSpecification": + """ + Specifies that the 'boundaries' geometry from the context is required + for rendering. + """ + kwargs = {"boundaries": source_context.boundaries} + return RenderSpecification( + width=target_size[0], + height=target_size[1], + data=source_context.data, + kwargs=kwargs, + apply_mask=False, # Vector renderers don't need a post-mask + ) + + def _render_to_cairo_surface( + self, boundaries: Geometry, width: int, height: int + ) -> cairo.ImageSurface | None: + """Internal helper for renderer reuse.""" + render_width, render_height = width, height + if render_width <= 0 or render_height <= 0: + logger.warning( + f"OpsRenderer received invalid dimensions: {width}x{height}. " + "Cannot render." + ) + return None + + logger.debug( + f"OpsRenderer: Rendering to Cairo surface of " + f"{render_width}x{render_height} px." + ) + + # Downscale if requested size exceeds Cairo's limit + if ( + render_width > CAIRO_MAX_DIMENSION + or render_height > CAIRO_MAX_DIMENSION + ): + scale_factor = 1.0 + if render_width > CAIRO_MAX_DIMENSION: + scale_factor = CAIRO_MAX_DIMENSION / render_width + if render_height > CAIRO_MAX_DIMENSION: + scale_factor = min( + scale_factor, CAIRO_MAX_DIMENSION / render_height + ) + render_width = max(1, int(render_width * scale_factor)) + render_height = max(1, int(render_height * scale_factor)) + logger.warning( + "Requested render size exceeds Cairo limit. " + f"Downscaling to {render_width}x{render_height}." + ) + + surface = cairo.ImageSurface( + cairo.FORMAT_ARGB32, render_width, render_height + ) + ctx = cairo.Context(surface) + ctx.set_source_rgba(0, 0, 0, 0) # Transparent background + ctx.paint() + + # Calculate scaling to fit the workpiece's local geometry into + # the surface + geo_min_x, geo_min_y, geo_max_x, geo_max_y = boundaries.rect() + geo_width = geo_max_x - geo_min_x + geo_height = geo_max_y - geo_min_y + + if geo_width <= 1e-9 or geo_height <= 1e-9: + logger.warning( + "Geometry has zero size. Returning transparent surface." + ) + return surface # Return transparent surface if no size + + scale_x = render_width / geo_width + scale_y = render_height / geo_height + + # Render directly from Geometry to support Beziers and avoid + # intermediate linearization in Ops. + ctx.save() + + # Transform Logic: + # Map Geometry box (min_x, min_y, max_x, max_y) to Surface + # (0, 0, w, h). + # Surface (0,0) is Top-Left. + # Geometry is Cartesian (Y-Up). + # PixelX = (GeoX - min_x) * scale_x + # PixelY = (max_y - GeoY) * scale_y + # + # Translate(-min_x*scale_x, max_y*scale_y) -> Scale(scale_x, -scale_y) + ctx.translate(-geo_min_x * scale_x, geo_max_y * scale_y) + ctx.scale(scale_x, -scale_y) + + # Set style + ctx.set_source_rgb(0, 0, 0) # Black lines + + # Try to use hairlines for crisp rendering independent of scale + lw = 1.5 / max(scale_x, scale_y) + ctx.set_line_width(lw) + logger.debug(f"Using fallback line width: {lw}") + + ctx.set_line_cap(cairo.LINE_CAP_SQUARE) + + geometry_to_cairo(boundaries, ctx) + ctx.stroke() + logger.debug("Stroked geometry path to Cairo context.") + + ctx.restore() + + return surface + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + boundaries = kwargs.get("boundaries") + if not boundaries or boundaries.is_empty(): + logger.warning( + "OpsRenderer: No boundaries provided or boundaries are empty." + ) + return None + + surface = self._render_to_cairo_surface(boundaries, width, height) + if not surface: + logger.warning( + "OpsRenderer: Failed to render boundaries to Cairo surface." + ) + return None + + h, w = surface.get_height(), surface.get_width() + vips_image = pyvips.Image.new_from_memory( + surface.get_data(), w, h, 4, "uchar" + ) + b, g, r, a = ( + vips_image[0], + vips_image[1], + vips_image[2], + vips_image[3], + ) + return r.bandjoin([g, b, a]).copy(interpretation="srgb") + + +OPS_RENDERER = OpsRenderer() diff --git a/rayforge/image/pdf/__init__.py b/rayforge/image/pdf/__init__.py new file mode 100644 index 000000000..d066d6af3 --- /dev/null +++ b/rayforge/image/pdf/__init__.py @@ -0,0 +1,5 @@ +from .importer import PdfImporter +from .pdf_trace import PdfTraceImporter +from .pdf_vector import PdfVectorImporter + +__all__ = ["PdfImporter", "PdfTraceImporter", "PdfVectorImporter"] diff --git a/rayforge/image/pdf/importer.py b/rayforge/image/pdf/importer.py new file mode 100644 index 000000000..1871b77c3 --- /dev/null +++ b/rayforge/image/pdf/importer.py @@ -0,0 +1,108 @@ +import logging +from pathlib import Path +from typing import ClassVar + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import ( + PassthroughSpec, + TraceSpec, + VectorizationSpec, +) +from ..base_importer import Importer, ImporterFeature +from ..structures import ( + ImportManifest, + ImportResult, + ParsingResult, + VectorizationResult, +) +from .pdf_trace import PdfTraceImporter +from .pdf_vector import PdfVectorImporter + +logger = logging.getLogger(__name__) + + +class PdfImporter(Importer): + """ + A Facade importer for PDF files. + + Routes the import request to either the Vector strategy (for direct + path extraction) or the Trace strategy (for rendering and tracing), + depending on the provided VectorizationSpec. + """ + + label = "PDF files" + mime_types = ("application/pdf",) + extensions = (".pdf",) + features: ClassVar[set[ImporterFeature]] = { + ImporterFeature.DIRECT_VECTOR, + ImporterFeature.BITMAP_TRACING, + } + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + + def scan(self) -> ImportManifest: + return PdfVectorImporter(self.raw_data, self.source_file).scan() + + def get_doc_items( + self, vectorization_spec: VectorizationSpec | None = None + ) -> ImportResult | None: + spec_to_use = vectorization_spec + if spec_to_use is None: + spec_to_use = PassthroughSpec() + + if isinstance(spec_to_use, TraceSpec): + logger.debug("PdfImporter: Delegating to PdfTraceImporter.") + delegate = PdfTraceImporter(self.raw_data, self.source_file) + else: + logger.debug("PdfImporter: Delegating to PdfVectorImporter.") + delegate = PdfVectorImporter(self.raw_data, self.source_file) + + import_result = delegate.get_doc_items(spec_to_use) + + if ( + import_result + and import_result.payload + and import_result.payload.source + ): + self._stamp_importer_identity(import_result.payload.source) + + if import_result: + import_result.warnings.extend(self._warnings) + import_result.errors.extend(self._errors) + + return import_result + + def parse(self) -> ParsingResult | None: + raise NotImplementedError( + "PdfImporter is a facade; parse is delegated via get_doc_items" + ) + + def vectorize( + self, parse_result: ParsingResult, spec: VectorizationSpec + ) -> VectorizationResult: + raise NotImplementedError( + "PdfImporter is a facade; vectorize is delegated via get_doc_items" + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + raise NotImplementedError( + "PdfImporter is a facade; create_source_asset is delegated" + ) + + def get_doc_items_for_reimport( + self, + existing_source_asset: SourceAsset, + vectorization_spec: VectorizationSpec, + ) -> ImportResult | None: + if isinstance(vectorization_spec, TraceSpec): + delegate = PdfTraceImporter(self.raw_data, self.source_file) + else: + delegate = PdfVectorImporter(self.raw_data, self.source_file) + result = delegate.get_doc_items_for_reimport( + existing_source_asset, vectorization_spec + ) + if result: + result.warnings.extend(self._warnings) + result.errors.extend(self._errors) + return result diff --git a/rayforge/image/pdf/pdf_trace.py b/rayforge/image/pdf/pdf_trace.py new file mode 100644 index 000000000..96c53048e --- /dev/null +++ b/rayforge/image/pdf/pdf_trace.py @@ -0,0 +1,235 @@ +import io +import logging +import warnings +from gettext import gettext as _ +from pathlib import Path +from typing import ClassVar + +from pypdf import PdfReader +from pypdf.errors import PdfReadError + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from raygeo.geo import Geometry, Matrix + +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 ..util import to_mm +from .renderer import PDF_RENDERER + +logger = logging.getLogger(__name__) + + +class PdfTraceImporter(Importer): + """ + Imports vector data from PDF files by rasterizing and tracing. + + This importer renders the PDF to a high-resolution bitmap, finds the + content bounds, crops the PDF to that area, and then traces the result + to generate vector geometry. + """ + + label = "PDF (Trace Strategy)" + mime_types = () + extensions = () + features: ClassVar[set[ImporterFeature]] = {ImporterFeature.BITMAP_TRACING} + _TRACE_PPM = 24.0 + _MAX_RENDER_DIM = 16384 + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self._image: pyvips.Image | None = None + + def scan(self) -> ImportManifest: + try: + reader = PdfReader(io.BytesIO(self.raw_data)) + if not reader.pages: + self.add_error(_("PDF file contains no pages.")) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + media_box = reader.pages[0].mediabox + width_pt = float(media_box.width) + height_pt = float(media_box.height) + size_mm = (to_mm(width_pt, "pt"), to_mm(height_pt, "pt")) + title = reader.metadata.title if reader.metadata else None + return ImportManifest( + title=title or self.source_file.name, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + ) + except PdfReadError as e: + logger.warning(f"PDF scan failed for {self.source_file.name}: {e}") + self.add_error(_("Could not read PDF: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + except Exception as e: + logger.exception( + f"Unexpected error during PDF scan for {self.source_file.name}" + ) + self.add_error( + _("Unexpected error while scanning PDF: {}").format(e) + ) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + assert self._image is not None, "parse() must have been called first" + + _, _, w_px, h_px = parse_result.document_bounds + width_mm = w_px * parse_result.native_unit_to_mm + height_mm = h_px * parse_result.native_unit_to_mm + + source = SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=PDF_RENDERER, + thumbnail_data=self._render_thumbnail_from_vips(self._image), + width_px=int(w_px), + height_px=int(h_px), + width_mm=width_mm, + height_mm=height_mm, + ) + + source.base_render_data = self._image.pngsave_buffer() + return source + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + assert self._image is not None, "parse() must be called first" + if not isinstance(spec, TraceSpec): + raise TypeError("PdfTraceImporter only supports TraceSpec") + + norm_image = util.normalize_to_rgba(self._image) + if not norm_image: + logger.error("Failed to normalize PDF image for tracing.") + self.add_error(_("Failed to process PDF image data.")) + return VectorizationResult( + geometries_by_layer={}, source_parse_result=parse_result + ) + + surface = util.vips_rgba_to_cairo_surface(norm_image) + geometries = trace_surface(surface, spec) + merged_geo = Geometry() + for geo in geometries: + merged_geo.extend(geo) + + return VectorizationResult( + geometries_by_layer={None: merged_geo}, + source_parse_result=parse_result, + ) + + def parse(self) -> ParsingResult | None: + try: + reader = PdfReader(io.BytesIO(self.raw_data)) + media_box = reader.pages[0].mediabox + width_pt = float(media_box.width) + height_pt = float(media_box.height) + size_mm = (to_mm(width_pt, "pt"), to_mm(height_pt, "pt")) + except (PdfReadError, ValueError, TypeError, KeyError) as e: + logger.error(f"Failed to read PDF size: {e}") + self.add_error( + _("Failed to read PDF page dimensions: {}").format(e) + ) + self._image = None + return None + + w_mm, h_mm = size_mm + if w_mm <= 0 or h_mm <= 0: + self._image = None + self.add_error(_("PDF page has zero dimensions")) + return None + + render_w_px, render_h_px = self._calculate_render_resolution( + w_mm, h_mm + ) + dpi = float(render_w_px / w_mm) * 25.4 + + vips_image = PDF_RENDERER.render_base_image( + self.raw_data, width=render_w_px, height=render_h_px + ) + if not vips_image: + logger.error("Failed to render PDF to an image for processing") + self.add_error(_("Failed to rasterize PDF")) + self._image = None + return None + + px_per_mm = dpi / 25.4 + self._image = vips_image.copy(xres=px_per_mm, yres=px_per_mm) + + document_bounds = (0.0, 0.0, float(render_w_px), float(render_h_px)) + native_unit_to_mm = 1.0 / px_per_mm + + 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=Matrix(), + ) + + 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, + ) + + def _calculate_render_resolution( + self, w_mm: float, h_mm: float + ) -> tuple[int, int]: + if w_mm <= 0 or h_mm <= 0: + return 1, 1 + + ideal_w = w_mm * self._TRACE_PPM + ideal_h = h_mm * self._TRACE_PPM + + scale = 1.0 + if ideal_w > self._MAX_RENDER_DIM: + scale = self._MAX_RENDER_DIM / ideal_w + if ideal_h > self._MAX_RENDER_DIM: + scale = min(scale, self._MAX_RENDER_DIM / ideal_h) + + final_w = int(ideal_w * scale) + final_h = int(ideal_h * scale) + + return max(1, final_w), max(1, final_h) diff --git a/rayforge/image/pdf/pdf_vector.py b/rayforge/image/pdf/pdf_vector.py new file mode 100644 index 000000000..0cdd56829 --- /dev/null +++ b/rayforge/image/pdf/pdf_vector.py @@ -0,0 +1,710 @@ +from __future__ import annotations + +import logging +import math +import re +from gettext import gettext as _ +from pathlib import Path +from typing import Any, ClassVar, cast + +try: + import pymupdf +except ImportError: + import fitz as pymupdf + +from raygeo.geo import Geometry, Matrix + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + VectorizationSpec, +) +from ..base_importer import Importer, ImporterFeature +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + LayerGeometry, + LayerInfo, + ParsingResult, + VectorizationResult, +) +from ..util import to_mm +from .renderer import PDF_RENDERER + +logger = logging.getLogger(__name__) + +PT_TO_MM = 25.4 / 72.0 + + +class PdfVectorImporter(Importer): + """ + Imports vector data directly from PDF files using pymupdf. + + Extracts vector paths (lines, curves, shapes) from PDF pages without + rasterization, preserving the original vector geometry. + """ + + label = "PDF (Vector Strategy)" + mime_types = () + extensions = () + 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._doc: pymupdf.Document | None = None + self._page: pymupdf.Page | None = None + self._page_width_pt: float = 0.0 + self._page_height_pt: float = 0.0 + self._geometries_by_layer: dict[str | None, Geometry] = {} + + def scan(self) -> ImportManifest: + try: + doc = pymupdf.open(stream=self.raw_data, filetype="pdf") + if doc.page_count == 0: + doc.close() + self.add_error(_("PDF file contains no pages.")) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + page = doc[0] + mediabox = page.mediabox + width_pt = float(mediabox.width) + height_pt = float(mediabox.height) + size_mm = (to_mm(width_pt, "pt"), to_mm(height_pt, "pt")) + + title = (doc.metadata or {}).get("title") or self.source_file.name + + ocgs = doc.get_ocgs() + layers = [] + if ocgs: + for ocg_id, ocg_info in ocgs.items(): + layer_name = ocg_info.get("name", str(ocg_id)) + layers.append( + LayerInfo( + id=layer_name, + name=layer_name, + default_active=ocg_info.get("on", True), + ) + ) + + doc.close() + + return ImportManifest( + title=title, + layers=layers, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + ) + except (pymupdf.FileDataError, ValueError, RuntimeError) as e: + logger.warning(f"PDF scan failed for {self.source_file.name}: {e}") + self.add_error(_("Could not read PDF: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + def parse(self) -> ParsingResult | None: + try: + self._doc = pymupdf.open(stream=self.raw_data, filetype="pdf") + if self._doc.page_count == 0: + self.add_error(_("PDF file contains no pages.")) + self._close_document() + return None + + self._page = self._doc[0] + mediabox = self._page.mediabox + self._page_width_pt = float(mediabox.width) + self._page_height_pt = float(mediabox.height) + + if self._page_width_pt <= 0 or self._page_height_pt <= 0: + self.add_error(_("PDF page has zero dimensions")) + self._close_document() + return None + + document_bounds = ( + 0.0, + 0.0, + self._page_width_pt, + self._page_height_pt, + ) + + native_unit_to_mm = PT_TO_MM + 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=Matrix(), + ) + + bg_item = NormalizationEngine.calculate_layout_item( + document_bounds, temp_result + ) + + geometries = self._extract_page_geometry() + self._geometries_by_layer = geometries + + if not geometries or all( + g.is_empty() for g in geometries.values() + ): + self.add_warning(_("PDF contains no vector geometry.")) + + layer_geometries: list[LayerGeometry] = [] + for layer_id, geo in geometries.items(): + if not geo.is_empty(): + layer_geometries.append( + LayerGeometry( + layer_id=layer_id or "__default__", + name=layer_id or "__default__", + content_bounds=document_bounds, + ) + ) + + if not layer_geometries: + layer_geometries.append( + LayerGeometry( + layer_id="__default__", + name="__default__", + content_bounds=document_bounds, + ) + ) + + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=native_unit_to_mm, + is_y_down=True, + layers=layer_geometries, + world_frame_of_reference=world_frame, + background_world_transform=bg_item.world_matrix, + ) + + except Exception as e: + logger.exception("Failed to parse PDF") + self.add_error(_("Failed to parse PDF: {}").format(e)) + self._close_document() + return None + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + if not isinstance(spec, PassthroughSpec): + spec = PassthroughSpec() + + if self._page is None: + logger.error("vectorize() called before parse()") + return VectorizationResult( + geometries_by_layer={}, source_parse_result=parse_result + ) + + split_layers = False + active_layers_set = None + if isinstance(spec, PassthroughSpec): + split_layers = spec.layer_import_mode != LayerImportMode.FLATTEN + if spec.active_layer_ids: + active_layers_set = set(spec.active_layer_ids) + + geometries: dict[str | None, Geometry] = self._geometries_by_layer + if not geometries: + geometries = {None: Geometry()} + + geometries_to_process: dict[str | None, Geometry] + if active_layers_set: + geometries_to_process = { + layer_id: geo + for layer_id, geo in geometries.items() + if layer_id in active_layers_set + } + else: + geometries_to_process = geometries + + final_geometries: dict[str | None, Geometry] + if split_layers: + final_geometries = {} + for layer_id, geo in geometries_to_process.items(): + final_layer_id = layer_id or "__default__" + final_geometries[final_layer_id] = geo + else: + merged_geo = Geometry() + for geo in geometries_to_process.values(): + merged_geo.extend(geo) + final_geometries = {"__default__": merged_geo} + + self._close_document() + + return VectorizationResult( + geometries_by_layer=final_geometries, + source_parse_result=parse_result, + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + width_mm = self._page_width_pt * parse_result.native_unit_to_mm + height_mm = self._page_height_pt * parse_result.native_unit_to_mm + + source = SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=PDF_RENDERER, + thumbnail_data=self._render_thumbnail_from_renderer( + PDF_RENDERER, self.raw_data + ), + width_px=int(self._page_width_pt), + height_px=int(self._page_height_pt), + width_mm=width_mm, + height_mm=height_mm, + ) + + return source + + def _close_document(self) -> None: + if self._doc is not None: + try: + self._doc.close() + except Exception: + logger.debug("Failed to close PDF document", exc_info=True) + self._doc = None + self._page = None + + def _extract_page_geometry(self) -> dict[str | None, Geometry]: + if self._page is None: + return {None: Geometry()} + + geometries_by_layer: dict[str | None, Geometry] = {} + + try: + drawings = self._page.get_drawings() + for drawing in drawings: + layer_name: str | None = drawing.get("layer") + if layer_name not in geometries_by_layer: + geometries_by_layer[layer_name] = Geometry() + self._add_drawing_to_geometry( + drawing, geometries_by_layer[layer_name] + ) + except (RuntimeError, ValueError, TypeError) as e: + logger.warning(f"Failed to extract drawings: {e}") + + if not geometries_by_layer: + geometries_by_layer[None] = Geometry() + + return geometries_by_layer + + def _add_drawing_to_geometry( + self, drawing: dict[str, Any], geometry: Geometry + ) -> None: + items = cast(list[tuple], drawing.get("items", [])) + if not items: + return + + dashes_str = cast(str, drawing.get("dashes", "")) + pattern, phase = self._parse_dash_pattern(dashes_str) + if pattern: + items = self._expand_dashed_items(items, pattern, phase) + + first_cmd = items[0][0] if items else None + if first_cmd != "m": + start_pt = self._get_start_point(items[0]) + if start_pt is not None: + geometry.move_to(float(start_pt.x), float(start_pt.y)) + + last_end_pt: pymupdf.Point | None = None + for item in items: + if not isinstance(item, tuple) or len(item) < 1: + continue + + cmd = item[0] + + if cmd == "m": + if len(item) >= 2 and isinstance(item[1], pymupdf.Point): + pt = item[1] + geometry.move_to(float(pt.x), float(pt.y)) + last_end_pt = pt + + elif cmd == "l": + if len(item) >= 3 and isinstance(item[2], pymupdf.Point): + start_pt = item[1] + end_pt = item[2] + if last_end_pt is not None and self._points_differ( + start_pt, last_end_pt + ): + geometry.move_to(float(start_pt.x), float(start_pt.y)) + geometry.line_to(float(end_pt.x), float(end_pt.y)) + last_end_pt = end_pt + + elif cmd == "c": + if len(item) >= 5 and all( + isinstance(item[i], pymupdf.Point) for i in range(1, 5) + ): + start_pt = item[1] + c1 = item[2] + c2 = item[3] + end_pt = item[4] + if last_end_pt is not None and self._points_differ( + start_pt, last_end_pt + ): + geometry.move_to(float(start_pt.x), float(start_pt.y)) + geometry.bezier_to( + float(end_pt.x), + float(end_pt.y), + float(c1.x), + float(c1.y), + float(c2.x), + float(c2.y), + ) + last_end_pt = end_pt + + elif cmd == "h": + geometry.close_path() + + elif cmd == "re": + if len(item) >= 2: + rect = item[1] + if hasattr(rect, "x0") and hasattr(rect, "y0"): + x = float(rect.x0) + y = float(rect.y0) + w = float(rect.width) + h = float(rect.height) + self._add_rect_to_geometry(geometry, x, y, w, h) + last_end_pt = None + + elif cmd == "q" or cmd == "Q": + pass + + def _points_differ( + self, p1: pymupdf.Point, p2: pymupdf.Point, tolerance: float = 0.01 + ) -> bool: + return ( + abs(float(p1.x) - float(p2.x)) > tolerance + or abs(float(p1.y) - float(p2.y)) > tolerance + ) + + def _get_start_point(self, item: tuple) -> pymupdf.Point | None: + cmd = item[0] if item else None + if ( + cmd == "l" + and len(item) >= 2 + and isinstance(item[1], pymupdf.Point) + ): + return item[1] + if ( + cmd == "c" + and len(item) >= 2 + and isinstance(item[1], pymupdf.Point) + ): + return item[1] + return None + + def _add_rect_to_geometry( + self, geometry: Geometry, x: float, y: float, w: float, h: float + ) -> None: + geometry.move_to(x, y) + geometry.line_to(x + w, y) + geometry.line_to(x + w, y + h) + geometry.line_to(x, y + h) + geometry.close_path() + + def _parse_dash_pattern( + self, dashes_str: str + ) -> tuple[list[float], float]: + if not dashes_str or dashes_str == "[] 0": + return [], 0.0 + match = re.match(r"\[\s*([\d.\s]+)\s*\]\s*(\d+\.?\d*)", dashes_str) + if not match: + return [], 0.0 + pattern = [float(x) for x in match.group(1).split()] + phase = float(match.group(2)) + return pattern, phase + + def _expand_dashed_items( + self, items: list[tuple], pattern: list[float], phase: float + ) -> list[tuple]: + if not pattern: + return items + expanded = [] + dash_pos = phase + pattern_idx = 0 + for item in items: + cmd = item[0] + if cmd == "m": + expanded.append(item) + dash_pos = phase + pattern_idx = 0 + elif cmd == "l" and len(item) >= 3: + start_pt, end_pt = item[1], item[2] + segments = self._dash_line( + float(start_pt.x), + float(start_pt.y), + float(end_pt.x), + float(end_pt.y), + pattern, + dash_pos, + pattern_idx, + ) + expanded.extend(segments) + length = math.hypot( + float(end_pt.x) - float(start_pt.x), + float(end_pt.y) - float(start_pt.y), + ) + dash_pos, pattern_idx = self._advance_dash( + length, pattern, dash_pos, pattern_idx + ) + elif cmd == "c" and len(item) >= 5: + start_pt, c1, c2, end_pt = item[1], item[2], item[3], item[4] + segments = self._dash_bezier( + float(start_pt.x), + float(start_pt.y), + float(c1.x), + float(c1.y), + float(c2.x), + float(c2.y), + float(end_pt.x), + float(end_pt.y), + pattern, + dash_pos, + pattern_idx, + ) + expanded.extend(segments) + length = self._bezier_arc_length( + float(start_pt.x), + float(start_pt.y), + float(c1.x), + float(c1.y), + float(c2.x), + float(c2.y), + float(end_pt.x), + float(end_pt.y), + ) + dash_pos, pattern_idx = self._advance_dash( + length, pattern, dash_pos, pattern_idx + ) + elif cmd == "h" or cmd == "re" and len(item) >= 5: + expanded.append(item) + return expanded + + def _dash_line( + self, + x1: float, + y1: float, + x2: float, + y2: float, + pattern: list[float], + dash_pos: float, + pattern_idx: int, + ) -> list[tuple]: + segments = [] + length = math.hypot(x2 - x1, y2 - y1) + if length < 0.001: + return segments + dx, dy = (x2 - x1) / length, (y2 - y1) / length + pos = 0.0 + remaining = pattern[pattern_idx] - dash_pos + is_on = (pattern_idx % 2) == 0 + while pos < length: + if remaining <= 1e-6: + pattern_idx = (pattern_idx + 1) % len(pattern) + is_on = not is_on + remaining = pattern[pattern_idx] + segment_len = min(remaining, length - pos) + if is_on: + sx1 = x1 + dx * pos + sy1 = y1 + dy * pos + sx2 = x1 + dx * (pos + segment_len) + sy2 = y1 + dy * (pos + segment_len) + if not segments: + segments.append(("m", pymupdf.Point(sx1, sy1))) + else: + last = segments[-1] + if last[0] == "l": + last_end = last[2] + if ( + abs(float(last_end.x) - sx1) > 0.01 + or abs(float(last_end.y) - sy1) > 0.01 + ): + segments.append(("m", pymupdf.Point(sx1, sy1))) + else: + segments.append(("m", pymupdf.Point(sx1, sy1))) + segments.append( + ("l", pymupdf.Point(sx1, sy1), pymupdf.Point(sx2, sy2)) + ) + pos += segment_len + remaining -= segment_len + return segments + + def _advance_dash( + self, + length: float, + pattern: list[float], + dash_pos: float, + pattern_idx: int, + ) -> tuple[float, int]: + if not pattern: + return 0.0, 0 + total_pos = dash_pos + length + while total_pos >= pattern[pattern_idx]: + total_pos -= pattern[pattern_idx] + pattern_idx = (pattern_idx + 1) % len(pattern) + return total_pos, pattern_idx + + def _bezier_arc_length( + self, + x0: float, + y0: float, + x1: float, + y1: float, + x2: float, + y2: float, + x3: float, + y3: float, + steps: int = 20, + ) -> float: + length = 0.0 + prev_x, prev_y = x0, y0 + for i in range(1, steps + 1): + t = i / steps + t2, t3 = t * t, t * t * t + mt, mt2, mt3 = 1 - t, (1 - t) ** 2, (1 - t) ** 3 + px = mt3 * x0 + 3 * mt2 * t * x1 + 3 * mt * t2 * x2 + t3 * x3 + py = mt3 * y0 + 3 * mt2 * t * y1 + 3 * mt * t2 * y2 + t3 * y3 + length += math.hypot(px - prev_x, py - prev_y) + prev_x, prev_y = px, py + return length + + def _bezier_point( + self, + t: float, + x0: float, + y0: float, + x1: float, + y1: float, + x2: float, + y2: float, + x3: float, + y3: float, + ) -> tuple[float, float]: + t2, t3 = t * t, t * t * t + mt, mt2, mt3 = 1 - t, (1 - t) ** 2, (1 - t) ** 3 + px = mt3 * x0 + 3 * mt2 * t * x1 + 3 * mt * t2 * x2 + t3 * x3 + py = mt3 * y0 + 3 * mt2 * t * y1 + 3 * mt * t2 * y2 + t3 * y3 + return px, py + + def _dash_bezier( + self, + x0: float, + y0: float, + x1: float, + y1: float, + x2: float, + y2: float, + x3: float, + y3: float, + pattern: list[float], + dash_pos: float, + pattern_idx: int, + ) -> list[tuple]: + segments = [] + total_length = self._bezier_arc_length(x0, y0, x1, y1, x2, y2, x3, y3) + if total_length < 0.001: + return segments + steps = max(20, int(total_length / 5)) + arc_lengths = [0.0] + prev_x, prev_y = x0, y0 + for i in range(1, steps + 1): + t = i / steps + px, py = self._bezier_point(t, x0, y0, x1, y1, x2, y2, x3, y3) + arc_lengths.append( + arc_lengths[-1] + math.hypot(px - prev_x, py - prev_y) + ) + prev_x, prev_y = px, py + + def t_for_arc_len(target_len: float) -> float: + for i in range(1, len(arc_lengths)): + if arc_lengths[i] >= target_len: + frac = (target_len - arc_lengths[i - 1]) / ( + arc_lengths[i] - arc_lengths[i - 1] + ) + return (i - 1 + frac) / steps + return 1.0 + + pos = 0.0 + remaining = pattern[pattern_idx] - dash_pos + is_on = (pattern_idx % 2) == 0 + while pos < total_length: + if remaining <= 1e-6: + pattern_idx = (pattern_idx + 1) % len(pattern) + is_on = not is_on + remaining = pattern[pattern_idx] + segment_len = min(remaining, total_length - pos) + if is_on: + t_start = t_for_arc_len(pos) + t_end = t_for_arc_len(pos + segment_len) + sx, sy = self._bezier_point( + t_start, x0, y0, x1, y1, x2, y2, x3, y3 + ) + if not segments: + segments.append(("m", pymupdf.Point(sx, sy))) + else: + last = segments[-1] + if last[0] in ("l", "c"): + last_end = last[2] if last[0] == "l" else last[4] + if ( + abs(float(last_end.x) - sx) > 0.01 + or abs(float(last_end.y) - sy) > 0.01 + ): + segments.append(("m", pymupdf.Point(sx, sy))) + else: + segments.append(("m", pymupdf.Point(sx, sy))) + if t_end - t_start >= 0.001: + ex, ey = self._bezier_point( + t_end, x0, y0, x1, y1, x2, y2, x3, y3 + ) + c1x, c1y = self._bezier_point( + t_start + (t_end - t_start) / 3, + x0, + y0, + x1, + y1, + x2, + y2, + x3, + y3, + ) + c2x, c2y = self._bezier_point( + t_start + 2 * (t_end - t_start) / 3, + x0, + y0, + x1, + y1, + x2, + y2, + x3, + y3, + ) + segments.append( + ( + "c", + pymupdf.Point(sx, sy), + pymupdf.Point(c1x, c1y), + pymupdf.Point(c2x, c2y), + pymupdf.Point(ex, ey), + ) + ) + pos += segment_len + remaining -= segment_len + return segments + + def __del__(self): + self._close_document() diff --git a/rayforge/image/pdf/renderer.py b/rayforge/image/pdf/renderer.py new file mode 100644 index 000000000..04cefa488 --- /dev/null +++ b/rayforge/image/pdf/renderer.py @@ -0,0 +1,177 @@ +import io +import logging +import warnings +from typing import TYPE_CHECKING, Optional + +from pypdf import PdfReader +from pypdf.errors import PdfReadError + +from ..base_renderer import RasterRenderer, RenderSpecification + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + from ...core.source_asset_segment import SourceAssetSegment + from ...core.workpiece import RenderContext + from ...image.structures import ImportResult + +logger = logging.getLogger(__name__) + + +class PdfRenderer(RasterRenderer): + """Renders PDF data.""" + + def compute_render_spec( + self, + segment: Optional["SourceAssetSegment"], + target_size: tuple[int, int], + source_context: "RenderContext", + ) -> "RenderSpecification": + target_width, target_height = target_size + original_data = source_context.original_data + source_px_dims = source_context.source_pixel_dims + + if ( + segment + and segment.crop_window_px is not None + and original_data + and source_px_dims + ): + source_w, source_h = source_px_dims + crop_x_f, crop_y_f, crop_w_f, crop_h_f = segment.crop_window_px + crop_w, crop_h = float(crop_w_f), float(crop_h_f) + + if crop_w > 0 and crop_h > 0: + scale_x = target_width / crop_w + scale_y = target_height / crop_h + render_width = max(1, int(source_w * scale_x)) + render_height = max(1, int(source_h * scale_y)) + + scaled_x = int(crop_x_f * scale_x) + scaled_y = int(crop_y_f * scale_y) + scaled_w = int(crop_w * scale_x) + scaled_h = int(crop_h * scale_y) + crop_rect = (scaled_x, scaled_y, scaled_w, scaled_h) + + return RenderSpecification( + width=render_width, + height=render_height, + data=original_data, + crop_rect=crop_rect, + apply_mask=False, + ) + + return RenderSpecification( + width=target_width, + height=target_height, + data=source_context.data, + apply_mask=False, + ) + + def render_preview_image( + self, + import_result: "ImportResult", + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """ + Generates a preview image from a PDF import. + + This method has special handling for PDFs. The PdfImporter pre-renders + the PDF to a PNG and stores it in `base_render_data` as an + optimization. This method will use that cached PNG if available, + bypassing the expensive PDF rendering. + """ + if not import_result.payload: + return None + + source = import_result.payload.source + if source.base_render_data: + try: + # The importer has cached a pre-rendered PNG. Load it directly. + image = pyvips.Image.new_from_buffer( + source.base_render_data, "" + ) + # Scale it to the final preview size. + return image.thumbnail_image( + target_width, height=target_height, size="both" + ) + except pyvips.Error as e: + logger.warning( + f"Failed to load cached preview image from " + f"base_render_data: {e}" + ) + # Fall through to rendering the original PDF. + + # Fallback: If no cached data, render the original PDF from scratch. + return super().render_preview_image( + import_result, target_width, target_height + ) + + def _get_page_points(self, data: bytes) -> tuple[float, float] | None: + try: + reader = PdfReader(io.BytesIO(data)) + mb = reader.pages[0].mediabox + w, h = float(mb.width), float(mb.height) + if w > 0 and h > 0: + return w, h + except (PdfReadError, IndexError, AttributeError, ValueError) as e: + logger.warning( + "Failed to read PDF page dimensions via pypdf: %s", e + ) + + try: + probe = pyvips.Image.pdfload_buffer(data, dpi=72) + w, h = float(probe.width), float(probe.height) + if w > 0 and h > 0: + return w, h + except pyvips.Error as e: + logger.warning( + "Failed to read PDF page dimensions via pyvips: %s", e + ) + + return None + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + if not data: + return None + + # Check if data is pre-rendered PNG (from trace import) + if data[:4] == b"\x89PNG": + try: + image = pyvips.Image.new_from_buffer(data, "") + return image.thumbnail_image(width, height=height, size="both") + except pyvips.Error as e: + logger.warning(f"Failed to load PNG data: {e}") + return None + + # For PDFs, we must determine a DPI to request from the loader + # to achieve the target pixel dimensions. + page_pts = self._get_page_points(data) + if page_pts and width > 0 and height > 0: + w_pt, h_pt = page_pts + dpi = max((width / w_pt) * 72.0, (height / h_pt) * 72.0) + else: + dpi = 300.0 + + try: + image = pyvips.Image.pdfload_buffer(data, dpi=dpi) + if not isinstance(image, pyvips.Image) or image.width == 0: + return None + return image + except pyvips.Error: + logger.warning( + "Failed to render PDF data to vips image.", exc_info=True + ) + return None + + +PDF_RENDERER = PdfRenderer() diff --git a/rayforge/image/png/__init__.py b/rayforge/image/png/__init__.py new file mode 100644 index 000000000..0c333e773 --- /dev/null +++ b/rayforge/image/png/__init__.py @@ -0,0 +1,3 @@ +from .importer import PngImporter + +__all__ = ["PngImporter"] diff --git a/rayforge/image/png/importer.py b/rayforge/image/png/importer.py new file mode 100644 index 000000000..54da22169 --- /dev/null +++ b/rayforge/image/png/importer.py @@ -0,0 +1,176 @@ +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 PNG_RENDERER + +logger = logging.getLogger(__name__) + + +class PngImporter(Importer): + label = "PNG files" + mime_types = ("image/png",) + extensions = (".png",) + 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 scan(self) -> ImportManifest: + """ + Scans the PNG to extract physical dimensions from its metadata. + """ + try: + image = pyvips.Image.pngload_buffer( + self.raw_data, access=pyvips.Access.SEQUENTIAL + ) + size_mm = util.get_physical_size_mm(image) + return ImportManifest( + title=self.source_file.name, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + ) + except pyvips.Error as e: + logger.warning(f"PNG scan failed for {self.source_file.name}: {e}") + self.add_error(_("Failed to scan PNG file: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset for PNG import. + """ + metadata = util.extract_vips_metadata(self._image) + metadata["image_format"] = "PNG" + _ignored1, _ignored2, w_px, h_px = parse_result.document_bounds + width_mm = w_px * parse_result.native_unit_to_mm + height_mm = h_px * parse_result.native_unit_to_mm + + return SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=PNG_RENDERER, + metadata=metadata, + thumbnail_data=self._render_thumbnail_from_vips(self._image), + width_px=int(w_px), + height_px=int(h_px), + width_mm=width_mm, + height_mm=height_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("PngImporter only supports TraceSpec") + + normalized_image = util.normalize_to_rgba(self._image) + if not normalized_image: + logger.error("Failed to normalize image to RGBA format.") + 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) + geometries = trace_surface(surface, spec) + merged_geo = Geometry() + for geo in geometries: + merged_geo.extend(geo) + + return VectorizationResult( + geometries_by_layer={None: merged_geo}, + source_parse_result=parse_result, + ) + + def parse(self) -> ParsingResult | None: + """Phase 2: Parse the PNG into a vips image and extract facts.""" + try: + image = pyvips.Image.pngload_buffer( + self.raw_data, access=pyvips.Access.RANDOM + ) + except pyvips.Error as e: + logger.exception("pyvips failed to load PNG buffer") + self.add_error(_("Image load failed: {}").format(e)) + self._image = None + return None + + self._image = image + + # Extract geometric facts + width_px = float(image.width) + height_px = float(image.height) + document_bounds = (0.0, 0.0, width_px, height_px) + + if image.xres > 0: + native_unit_to_mm = 1.0 / image.xres + else: + default_dpi = 96.0 + native_unit_to_mm = 25.4 / default_dpi + + 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, + ) + + # Create temporary result to calculate background transform + 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, + ) diff --git a/rayforge/image/png/renderer.py b/rayforge/image/png/renderer.py new file mode 100644 index 000000000..f15ebdc50 --- /dev/null +++ b/rayforge/image/png/renderer.py @@ -0,0 +1,30 @@ +import warnings + +from ..base_renderer import RasterRenderer + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + + +class PngRenderer(RasterRenderer): + """Renders PNG data.""" + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + if not data: + return None + try: + return pyvips.Image.pngload_buffer( + data, access=pyvips.Access.RANDOM + ) + except pyvips.Error: + return None + + +PNG_RENDERER = PngRenderer() diff --git a/rayforge/image/procedural/__init__.py b/rayforge/image/procedural/__init__.py new file mode 100644 index 000000000..96108d5d0 --- /dev/null +++ b/rayforge/image/procedural/__init__.py @@ -0,0 +1,8 @@ +from .importer import ProceduralImporter +from .renderer import PROCEDURAL_RENDERER, ProceduralRenderer + +__all__ = [ + "PROCEDURAL_RENDERER", + "ProceduralImporter", + "ProceduralRenderer", +] diff --git a/rayforge/image/procedural/importer.py b/rayforge/image/procedural/importer.py new file mode 100644 index 000000000..da2e41947 --- /dev/null +++ b/rayforge/image/procedural/importer.py @@ -0,0 +1,211 @@ +import importlib +import json +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 ...core.vectorization_spec import ProceduralSpec, VectorizationSpec +from ...core.workpiece import WorkPiece +from ..base_importer import ( + Importer, + ImporterFeature, +) +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + ImportPayload, + LayerGeometry, + ParsingResult, + VectorizationResult, +) +from .renderer import PROCEDURAL_RENDERER + +logger = logging.getLogger(__name__) + + +class ProceduralImporter(Importer): + """ + A factory for creating procedural WorkPieces. + + Unlike file-based importers that parse existing data, this importer is + instantiated programmatically with the "recipe" for creating content. + It generates the SourceAsset and WorkPiece on the fly. + """ + + features: ClassVar[set[ImporterFeature]] = { + ImporterFeature.PROCEDURAL_GENERATION + } + label = "Procedural" + mime_types: tuple[str, ...] = () + extensions: tuple[str, ...] = () + + def __init__( + self, + *, + drawing_function_path: str, + size_function_path: str, + params: dict, + name: str, + ): + """ + Initializes the importer with the recipe for procedural content. + + Args: + drawing_function_path: Fully-qualified path to the drawing + function. + size_function_path: Fully-qualified path to the size calculation + function. + params: Dictionary of geometric parameters for the functions. + name: The name for the generated WorkPiece and source file. + """ + self.drawing_function_path = drawing_function_path + self.size_function_path = size_function_path + self.params = params + self.name = name + + # Create the recipe data that will be stored in the SourceAsset. + recipe_dict = { + "drawing_function_path": self.drawing_function_path, + "size_function_path": self.size_function_path, + "params": self.params, + } + recipe_data = json.dumps(recipe_dict).encode("utf-8") + + # Initialize the base class. The recipe data serves as "raw_data". + super().__init__(data=recipe_data, source_file=Path(f"[{self.name}]")) + + def scan(self) -> ImportManifest: + """ + Calculates the size of the procedural item from its recipe. + """ + try: + module_path, func_name = self.size_function_path.rsplit(".", 1) + module = importlib.import_module(module_path) + size_func = getattr(module, func_name) + size_mm = size_func(self.params) + return ImportManifest( + title=self.name, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + ) + except (ImportError, AttributeError, ValueError) as e: + logger.exception("Failed to calculate procedural size") + self.add_error(_("Failed to calculate parameters: {}").format(e)) + return ImportManifest(title=self.name, errors=self._errors) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset for Procedural import. + """ + _, _, w, h = parse_result.document_bounds + # For procedural, native units are 1:1 with mm (scale=1.0) + width_mm = w + height_mm = h + + return SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=PROCEDURAL_RENDERER, + thumbnail_data=self._render_thumbnail_from_renderer( + PROCEDURAL_RENDERER, self.raw_data + ), + width_mm=width_mm, + height_mm=height_mm, + ) + + def _post_process_payload(self, payload) -> "ImportPayload": + """ + Overrides the base importer hook to fix WorkPiece names. + The source_file has brackets like "[Name]" but the WorkPiece + should use the clean name without brackets. + """ + for item in payload.items: + if isinstance(item, WorkPiece): + item.name = self.name + + return payload + + def parse(self) -> ParsingResult | None: + """ + Phase 2: "Parse" the procedural parameters to determine geometric + properties. + """ + try: + module_path, func_name = self.size_function_path.rsplit(".", 1) + module = importlib.import_module(module_path) + size_func = getattr(module, func_name) + width_mm, height_mm = size_func(self.params) + except (ImportError, AttributeError, ValueError) as e: + logger.exception("Failed to load procedural size function") + self.add_error(_("Failed to execute generator: {}").format(e)) + return None + + # Define the native coordinate system as 1 unit = 1 mm. + # This preserves the aspect ratio in the parsing result. + document_bounds = (0.0, 0.0, float(width_mm), float(height_mm)) + x, _y, w, h = document_bounds + + # World frame is Y-Up and already in mm. + world_frame = (x, 0.0, w, h) + + # Create temporary result to calculate background transform + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + 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 + ) + + layer_id = "__default__" + + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, # 1 native unit = 1 mm + is_y_down=True, # Standardize on Y-down for generated content + layers=[ + LayerGeometry( + layer_id=layer_id, + name=layer_id, + content_bounds=document_bounds, + ) + ], + world_frame_of_reference=world_frame, + background_world_transform=bg_item.world_matrix, + ) + + def vectorize( + self, parse_result: ParsingResult, spec: VectorizationSpec + ) -> VectorizationResult: + """ + Phase 3: Generate the pristine geometry. + We create a rectangle matching the calculated dimensions. + """ + if not isinstance(spec, ProceduralSpec): + raise TypeError("ProceduralImporter only supports ProceduralSpec.") + _, _, w, h = parse_result.document_bounds + + frame_geo = Geometry() + frame_geo.move_to(0, 0) + frame_geo.line_to(w, 0) + frame_geo.line_to(w, h) + frame_geo.line_to(0, h) + frame_geo.close_path() + + # Retrieve the layer ID (we know there is one) + layer_id = parse_result.layers[0].layer_id + + return VectorizationResult( + geometries_by_layer={layer_id: frame_geo}, + source_parse_result=parse_result, + ) diff --git a/rayforge/image/procedural/renderer.py b/rayforge/image/procedural/renderer.py new file mode 100644 index 000000000..d190f7cba --- /dev/null +++ b/rayforge/image/procedural/renderer.py @@ -0,0 +1,115 @@ +import importlib +import json +import logging +import warnings +from collections.abc import Callable +from typing import TYPE_CHECKING + +import cairo + +from ..base_renderer import Renderer + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + from ...image.structures import ImportResult + +logger = logging.getLogger(__name__) + + +class ProceduralRenderer(Renderer): + """ + Renders procedural content by dispatching to a drawing function. + + This renderer is a generic execution engine. It reads a "recipe" from + the WorkPiece's SourceAsset data. The recipe is a JSON object that + specifies a path to a drawing function and the geometric parameters to + pass to it. This allows for creating resolution-independent content + without hardcoding rendering logic for each procedural type. + """ + + def _get_recipe_and_func_internal( + self, source_original_data: bytes | None, func_key: str + ) -> tuple[dict | None, dict | None, Callable | None]: + """Helper to deserialize the recipe and import a function.""" + if not source_original_data: + logger.warning("Procedural source has no original_data.") + return None, None, None + + try: + recipe = json.loads(source_original_data) + params = recipe.get("params", {}) + func_path = recipe.get(func_key) + + if not func_path: + logger.error(f"Recipe missing required key: '{func_key}'") + return None, None, None + + module_path, func_name = func_path.rsplit(".", 1) + module = importlib.import_module(module_path) + func = getattr(module, func_name) + return recipe, params, func + + except ( + json.JSONDecodeError, + KeyError, + ImportError, + AttributeError, + ): + logger.exception("Failed to load procedural function") + return None, None, None + + def render_preview_image( + self, + import_result: "ImportResult", + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """Renders the procedural recipe at the target preview dimensions.""" + if not import_result.payload: + return None + + return self.render_base_image( + data=import_result.payload.source.original_data, + width=target_width, + height=target_height, + ) + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + _, params, draw_func = self._get_recipe_and_func_internal( + data, "drawing_function_path" + ) + if not draw_func or params is None: + return None + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + + try: + draw_func(ctx, width, height, params) + except Exception: + logger.exception("Error executing procedural drawing function") + return None + + h, w = surface.get_height(), surface.get_width() + vips_image = pyvips.Image.new_from_memory( + surface.get_data(), w, h, 4, "uchar" + ) + b, g, r, a = ( + vips_image[0], + vips_image[1], + vips_image[2], + vips_image[3], + ) + return r.bandjoin([g, b, a]) + + +PROCEDURAL_RENDERER = ProceduralRenderer() diff --git a/rayforge/image/registry.py b/rayforge/image/registry.py new file mode 100644 index 000000000..206fde3bf --- /dev/null +++ b/rayforge/image/registry.py @@ -0,0 +1,307 @@ +import logging +import mimetypes +from dataclasses import dataclass +from pathlib import Path + +from .base_exporter import BaseExporter +from .base_importer import Importer, ImporterFeature +from .base_renderer import Renderer + + +@dataclass(frozen=True) +class FileFilter: + label: str + extensions: tuple[str, ...] + mime_types: tuple[str, ...] + + +logger = logging.getLogger(__name__) + + +class ImporterRegistry: + """Registry for file importers.""" + + def __init__(self): + self._importers_by_ext: dict[str, type[Importer]] = {} + self._importers_by_mime: dict[str, type[Importer]] = {} + self._importers_by_name: dict[str, type[Importer]] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, + importer_cls: type[Importer], + addon_name: str | None = None, + ) -> None: + """Register an importer for its extensions and MIME types.""" + name = importer_cls.__name__ + self._importers_by_name[name] = importer_cls + for ext in importer_cls.extensions: + self._importers_by_ext[ext] = importer_cls + + for mime_type in importer_cls.mime_types: + self._importers_by_mime[mime_type] = importer_cls + + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(name) + + addon_str = f" from {addon_name}" if addon_name else "" + logger.debug(f"Registered importer {importer_cls.__name__}{addon_str}") + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all importers registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of importers unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for name in items: + if name in self._importers_by_name: + del self._importers_by_name[name] + count += 1 + for ext in list(self._importers_by_ext.keys()): + if self._importers_by_ext[ext].__name__ in items: + del self._importers_by_ext[ext] + for mime in list(self._importers_by_mime.keys()): + if self._importers_by_mime[mime].__name__ in items: + del self._importers_by_mime[mime] + return count + + def get_by_extension(self, file_ext: str) -> type[Importer] | None: + """Get importer class for a file extension.""" + return self._importers_by_ext.get(file_ext) + + def get_by_mime_type(self, mime_type: str) -> type[Importer] | None: + """Get importer class for a MIME type.""" + return self._importers_by_mime.get(mime_type) + + def get_supported_extensions(self) -> list[str]: + """Get all supported file extensions.""" + return list(self._importers_by_ext.keys()) + + def get_by_name(self, name: str) -> type[Importer] | None: + """Get importer class by its class name.""" + return self._importers_by_name.get(name) + + def get_for_file(self, file_path: Path) -> type[Importer] | None: + """Get the appropriate importer for a file path.""" + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type: + importer_cls = self.get_by_mime_type(mime_type) + if importer_cls: + return importer_cls + file_ext = file_path.suffix.lower() + return self.get_by_extension(file_ext) + + def get_all_filters(self) -> list[FileFilter]: + """ + Get all supported import filters. + + Returns: + A list of FileFilter objects. + """ + seen_exts = set() + filters = [] + for importer_cls in set(self._importers_by_ext.values()): + if importer_cls.extensions[0] not in seen_exts: + seen_exts.update(importer_cls.extensions) + filters.append( + FileFilter( + label=importer_cls.label, + extensions=importer_cls.extensions, + mime_types=importer_cls.mime_types, + ) + ) + return filters + + def get_all(self) -> list[type[Importer]]: + """ + Get all registered importer classes. + + Returns: + A list of unique importer classes. + """ + return list(self._importers_by_name.values()) + + def by_feature(self, feature: ImporterFeature) -> list[type[Importer]]: + """ + Get all importer classes that support a specific feature. + + Args: + feature: The ImporterFeature to filter by. + + Returns: + A list of importer classes that support the feature. + """ + return [ + imp + for imp in self._importers_by_name.values() + if feature in imp.features + ] + + def mime_types_by_feature(self, feature: ImporterFeature) -> set[str]: + """ + Get all MIME types supported by importers with a specific feature. + + Args: + feature: The ImporterFeature to filter by. + + Returns: + A set of MIME type strings. + """ + return { + mime + for imp in self._importers_by_name.values() + if feature in imp.features + for mime in imp.mime_types + } + + +class RendererRegistry: + """Registry for asset renderers.""" + + def __init__(self): + self._renderers: dict[str, Renderer] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, + renderer: Renderer, + addon_name: str | None = None, + ) -> None: + """Register a renderer for an asset type.""" + key = renderer.__class__.__name__ + self._renderers[key] = renderer + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(key) + addon_str = f" from {addon_name}" if addon_name else "" + logger.debug(f"Registered renderer for {key}{addon_str}") + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all renderers registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of renderers unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for key in items: + if key in self._renderers: + del self._renderers[key] + count += 1 + return count + + def get(self, asset_type: str) -> Renderer | None: + """Get renderer for an asset type.""" + return self._renderers.get(asset_type) + + def all(self) -> dict[str, Renderer]: + """Return all registered renderers.""" + return self._renderers.copy() + + def get_by_name(self, name: str) -> Renderer | None: + """Get renderer by its class name.""" + return self._renderers.get(name) + + +class ExporterRegistry: + """Registry for file exporters.""" + + def __init__(self): + self._exporters_by_ext: dict[str, type[BaseExporter]] = {} + self._exporters_by_mime: dict[str, type[BaseExporter]] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, + exporter_cls: type[BaseExporter], + addon_name: str | None = None, + ) -> None: + """Register an exporter for its extensions and MIME types.""" + name = exporter_cls.__name__ + for ext in exporter_cls.extensions: + self._exporters_by_ext[ext] = exporter_cls + + for mime_type in exporter_cls.mime_types: + self._exporters_by_mime[mime_type] = exporter_cls + + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(name) + + addon_str = f" from {addon_name}" if addon_name else "" + logger.debug(f"Registered exporter {exporter_cls.__name__}{addon_str}") + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all exporters registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of exporters unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for ext in list(self._exporters_by_ext.keys()): + if self._exporters_by_ext[ext].__name__ in items: + del self._exporters_by_ext[ext] + count += 1 + for mime in list(self._exporters_by_mime.keys()): + if self._exporters_by_mime[mime].__name__ in items: + del self._exporters_by_mime[mime] + return count + + def get_by_extension(self, file_ext: str) -> type[BaseExporter] | None: + """Get exporter class for a file extension.""" + return self._exporters_by_ext.get(file_ext) + + def get_by_mime_type(self, mime_type: str) -> type[BaseExporter] | None: + """Get exporter class for a MIME type.""" + return self._exporters_by_mime.get(mime_type) + + def get_all_filters(self) -> list[FileFilter]: + """ + Get all supported export filters. + + Returns: + A list of FileFilter objects. + """ + seen_exts = set() + filters = [] + for exporter_cls in set(self._exporters_by_ext.values()): + if exporter_cls.extensions[0] not in seen_exts: + seen_exts.update(exporter_cls.extensions) + filters.append( + FileFilter( + label=exporter_cls.label, + extensions=exporter_cls.extensions, + mime_types=exporter_cls.mime_types, + ) + ) + return filters + + +importer_registry = ImporterRegistry() +renderer_registry = RendererRegistry() +exporter_registry = ExporterRegistry() diff --git a/rayforge/image/ruida/__init__.py b/rayforge/image/ruida/__init__.py new file mode 100644 index 000000000..e34030d02 --- /dev/null +++ b/rayforge/image/ruida/__init__.py @@ -0,0 +1,5 @@ +from .importer import RuidaImporter + +__all__ = [ + "RuidaImporter", +] diff --git a/rayforge/image/ruida/importer.py b/rayforge/image/ruida/importer.py new file mode 100644 index 000000000..4a913048c --- /dev/null +++ b/rayforge/image/ruida/importer.py @@ -0,0 +1,248 @@ +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 ...core.vectorization_spec import VectorizationSpec +from ...image.geo_renderer import render_geometry_to_png +from ..base_importer import ( + Importer, + ImporterFeature, +) +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + LayerGeometry, + ParsingResult, + VectorizationResult, +) +from .job import RuidaJob +from .parser import RuidaParseError, RuidaParser +from .renderer import RUIDA_RENDERER + +logger = logging.getLogger(__name__) + + +class RuidaImporter(Importer): + label = "Ruida files" + mime_types = ("application/x-rd-file", "application/octet-stream") + extensions = (".rd",) + features: ClassVar[set[ImporterFeature]] = {ImporterFeature.DIRECT_VECTOR} + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self._job: RuidaJob | None = None + self._geometries_by_layer: dict[str | None, Geometry] = {} + + def scan(self) -> ImportManifest: + """ + Scans the Ruida file to determine its overall dimensions. + """ + try: + job = self._get_job() + if not job.commands: + self.add_error(_("File contains no vector commands.")) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + min_x, min_y, max_x, max_y = job.get_extents() + width_mm = max_x - min_x + height_mm = max_y - min_y + return ImportManifest( + title=self.source_file.name, + natural_size_mm=(width_mm, height_mm), + warnings=self._warnings, + errors=self._errors, + ) + except RuidaParseError as e: + logger.warning( + f"Ruida scan failed for {self.source_file.name}: {e}" + ) + self.add_error(_("Ruida file is invalid: {}").format(e)) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + except Exception as e: + logger.exception( + f"Unexpected error during Ruida scan for " + f"{self.source_file.name}" + ) + self.add_error( + _("Unexpected error while scanning Ruida file: {}").format(e) + ) + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + + def _get_job(self) -> RuidaJob: + """Parses the Ruida data into a job object.""" + parser = RuidaParser(self.raw_data) + return parser.parse() + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """ + Creates a SourceAsset for Ruida import. + """ + _, _, w, h = parse_result.document_bounds + + merged = Geometry() + for geo in self._geometries_by_layer.values(): + if geo: + merged.extend(geo) + thumbnail_data = ( + render_geometry_to_png( + merged, + 256, + line_width=2.0, + color=(0.2, 0.2, 0.2, 1.0), + ) + if not merged.is_empty() + else None + ) + + source = SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=RUIDA_RENDERER, + thumbnail_data=thumbnail_data, + width_mm=w, + height_mm=h, + ) + return source + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + """ + Phase 3: Package parsed data for the layout engine. + Since Ruida files are always a single merged entity, we package the + geometry under the `__default__` key (matching parse()) so the + assembler can find it when splitting layers is active. + """ + # A Ruida file is conceptually a single "layer" or entity. + # We merge all geometries into one entry for the layout engine. + merged_geo = Geometry() + for geo in self._geometries_by_layer.values(): + merged_geo.extend(geo) + + # Key must match the layer_id declared in parse() ("__default__") + # so that ItemAssembler can find it when layout items request that + # layer. + geometries_for_layout: dict[str | None, Geometry] = { + "__default__": merged_geo + } + + return VectorizationResult( + geometries_by_layer=geometries_for_layout, + source_parse_result=parse_result, + ) + + def parse(self) -> ParsingResult | None: + """Phase 2: Parse Ruida file into geometric facts.""" + try: + job = self._get_job() + self._job = job + except RuidaParseError as e: + logger.error("Ruida file parse failed: %s", e) + self.add_error(_("Failed to parse Ruida commands: {}").format(e)) + self._job = None + return None + + pristine_geo = self._get_geometry(job) + pristine_geo.close_gaps() + + if not job.commands or pristine_geo.is_empty(): + # Return empty but valid structures + empty_result = ParsingResult( + document_bounds=(0, 0, 0, 0), + native_unit_to_mm=1.0, + is_y_down=False, + layers=[], + world_frame_of_reference=(0.0, 0.0, 0.0, 0.0), + background_world_transform=None, # type: ignore + ) + # Create a dummy transform for safety + bg_item = NormalizationEngine.calculate_layout_item( + (0, 0, 0, 0), empty_result + ) + empty_result.background_world_transform = bg_item.world_matrix + + self._geometries_by_layer: dict[str | None, Geometry] = { + "__default__": pristine_geo + } + return empty_result + + min_x, min_y, max_x, max_y = job.get_extents() + width_mm = max_x - min_x + height_mm = max_y - min_y + + # Use a virtual layer ID for consistency with other importers + layer_id = "__default__" + document_bounds = (min_x, min_y, width_mm, height_mm) + + # Create temporary result to calculate background transform + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[], + world_frame_of_reference=document_bounds, + background_world_transform=None, # type: ignore + ) + + bg_item = NormalizationEngine.calculate_layout_item( + document_bounds, temp_result + ) + + parse_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[ + LayerGeometry( + layer_id=layer_id, + name=layer_id, + content_bounds=document_bounds, + ) + ], + world_frame_of_reference=document_bounds, + background_world_transform=bg_item.world_matrix, + ) + self._geometries_by_layer = {layer_id: pristine_geo} + return parse_result + + def _get_geometry(self, job: RuidaJob) -> Geometry: + """ + Returns the parsed vector geometry. The coordinate system is + canonical (Y-up, origin at bottom-left of content). + """ + geo = Geometry() + if not job.commands: + return geo + + _min_x, min_y, _max_x, max_y = job.get_extents() + y_flip_val = max_y + min_y + + for cmd in job.commands: + # Check the command type first, then safely access params. + if cmd.command_type in ("Move_Abs", "Cut_Abs"): + # Ensure params are valid before unpacking. + if not cmd.params or len(cmd.params) != 2: + logger.warning( + f"Skipping Ruida command with invalid params: {cmd}" + ) + continue + + x, y = cmd.params + flipped_y = y_flip_val - y + if cmd.command_type == "Move_Abs": + geo.move_to(x, flipped_y) + elif cmd.command_type == "Cut_Abs": + geo.line_to(x, flipped_y) + return geo diff --git a/rayforge/image/ruida/job.py b/rayforge/image/ruida/job.py new file mode 100644 index 000000000..326e1f7aa --- /dev/null +++ b/rayforge/image/ruida/job.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass, field +from typing import Any + +from raygeo.geo.types import Rect + + +@dataclass +class RuidaLayer: + """ + Defines the parameters for a single Ruida 'color' or 'layer'. + These settings are applied to all subsequent geometric commands + associated with this layer's color_index. + """ + + color_index: int + speed: float # in mm/s + power: float # as a percentage (0-100) + air_assist: bool = False + frequency: int = 0 + # Ruida has min/max power for cornering, but we'll start simple. + + +@dataclass +class RuidaGeoCommand: + """ + Represents a single geometric or state command tagged with a layer index. + """ + + command_type: str # e.g., 'Move_Abs', 'Cut_Abs', 'End' + params: list[Any] = field(default_factory=list) + # The layer these command parameters belong to. + color_index: int = 0 + + +@dataclass +class RuidaJob: + """ + The complete logical representation of a job for a Ruida controller. + This object is the bridge between the low-level binary file format + and the application's internal models. + """ + + # A map of color indexes to their layer parameter definitions. + layers: dict[int, RuidaLayer] = field(default_factory=dict) + + # An ordered list of commands to be executed. + commands: list[RuidaGeoCommand] = field(default_factory=list) + + def get_extents(self) -> Rect: + """ + Calculates the bounding box (min_x, min_y, max_x, max_y) in mm + of all geometric commands in the job. + """ + points = [] + for cmd in self.commands: + if cmd.command_type in ("Move_Abs", "Cut_Abs") and cmd.params: + points.append(cmd.params) + + if not points: + return 0.0, 0.0, 0.0, 0.0 + + min_x = min(p[0] for p in points) + min_y = min(p[1] for p in points) + max_x = max(p[0] for p in points) + max_y = max(p[1] for p in points) + + return min_x, min_y, max_x, max_y diff --git a/rayforge/image/ruida/parser.py b/rayforge/image/ruida/parser.py new file mode 100644 index 000000000..23ce36578 --- /dev/null +++ b/rayforge/image/ruida/parser.py @@ -0,0 +1,201 @@ +import struct +from collections.abc import Callable + +from ...machine.driver.ruida.ruida_util import ( + UM_PER_MM, + decode14, + decode35, + decode_abs_coords, + decode_rel_coords, + unswizzle_byte, +) +from .job import RuidaGeoCommand, RuidaJob, RuidaLayer + +# A type alias for a command handler, defined at the module level for +# correct type checking. A handler is a tuple of: +# (payload_length, handler_function). +HandlerType = tuple[int, Callable[[RuidaJob, bytes], None]] + + +class RuidaParseError(Exception): + """Custom exception for errors during Ruida file parsing.""" + + +class RuidaParser: + """ + Parses a Ruida .rd file content into a structured RuidaJob object. + It handles the proprietary unscrambling and decodes the binary + command stream. + """ + + def __init__(self, data: bytes): + """ + Initializes the parser with the raw .rd file data. + + Args: + data: The byte content of the .rd file. + """ + if data.startswith(b"RDWORKV"): + # Standard .rd files have a 10-byte header to skip. + raw_data = data[10:] + else: + raw_data = data + + self.data = bytes([unswizzle_byte(b, magic=0x88) for b in raw_data]) + self.index = 0 + self.current_color = 0 + self.x, self.y = 0.0, 0.0 + + # The command table maps a command byte to either a handler + # or a nested dictionary of sub-command bytes to handlers. + self.COMMAND_TABLE: dict[int, HandlerType | dict[int, HandlerType]] = ( + self._build_command_table() + ) + + def parse(self) -> RuidaJob: + """ + Parses the entire data buffer and returns a complete RuidaJob. + """ + job = RuidaJob() + while self.index < len(self.data): + self._process_one_command(job) + return job + + def _process_one_command(self, job: RuidaJob) -> None: + """ + Reads, decodes, and handles a single command from the data stream. + """ + command_byte = self.data[self.index] + handler_entry = self.COMMAND_TABLE.get(command_byte) + + if handler_entry is None: + self.index += 1 + return + + self.index += 1 + handler = None + length = 0 + + if isinstance(handler_entry, dict): + if self.index >= len(self.data): + raise RuidaParseError( + f"Unexpected end of file after command " + f"0x{command_byte:02X}." + ) + subcommand_byte = self.data[self.index] + found_handler = handler_entry.get(subcommand_byte) + if found_handler: + self.index += 1 + length, handler = found_handler + else: + length, handler = handler_entry + + if handler: + if self.index + length > len(self.data): + raise RuidaParseError( + f"Incomplete payload for command 0x{command_byte:02X}. " + f"Expected {length} bytes, " + f"found {len(self.data) - self.index}." + ) + payload = self.data[self.index : self.index + length] + self.index += length + handler(job, payload) + + def _handle_set_color(self, job: RuidaJob, payload: bytes) -> None: + self.current_color = payload[0] + + def _handle_set_speed(self, job: RuidaJob, payload: bytes) -> None: + color_index = payload[0] + speed = struct.unpack(" None: + color_index = payload[0] + # Power is a short, scaled by 10 + power = struct.unpack(" None: + color_index = payload[0] + frequency = decode35(payload[2:7]) + self._ensure_layer(job, color_index).frequency = frequency + + def _handle_move_abs(self, job: RuidaJob, payload: bytes) -> None: + self.x, self.y = decode_abs_coords(payload) + cmd = RuidaGeoCommand("Move_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_cut_abs(self, job: RuidaJob, payload: bytes) -> None: + self.x, self.y = decode_abs_coords(payload) + cmd = RuidaGeoCommand("Cut_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_move_rel_xy(self, job: RuidaJob, payload: bytes) -> None: + dx, dy = decode_rel_coords(payload) + self.x += dx + self.y += dy + cmd = RuidaGeoCommand("Move_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_cut_rel_xy(self, job: RuidaJob, payload: bytes) -> None: + dx, dy = decode_rel_coords(payload) + self.x += dx + self.y += dy + cmd = RuidaGeoCommand("Cut_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_move_rel_x(self, job: RuidaJob, payload: bytes) -> None: + dx = decode14(payload) / UM_PER_MM + self.x += dx + cmd = RuidaGeoCommand("Move_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_cut_rel_x(self, job: RuidaJob, payload: bytes) -> None: + dx = decode14(payload) / UM_PER_MM + self.x += dx + cmd = RuidaGeoCommand("Cut_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_move_rel_y(self, job: RuidaJob, payload: bytes) -> None: + dy = decode14(payload) / UM_PER_MM + self.y += dy + cmd = RuidaGeoCommand("Move_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_cut_rel_y(self, job: RuidaJob, payload: bytes) -> None: + dy = decode14(payload) / UM_PER_MM + self.y += dy + cmd = RuidaGeoCommand("Cut_Abs", [self.x, self.y], self.current_color) + job.commands.append(cmd) + + def _handle_end(self, job: RuidaJob, payload: bytes) -> None: + job.commands.append(RuidaGeoCommand("End")) + + def _build_command_table(self): + """Constructs the mapping from command bytes to handlers.""" + return { + 0x88: (10, self._handle_move_abs), + 0x89: (4, self._handle_move_rel_xy), + 0x8A: (2, self._handle_move_rel_x), + 0x8B: (2, self._handle_move_rel_y), + 0xA8: (10, self._handle_cut_abs), + 0xA9: (4, self._handle_cut_rel_xy), + 0xAA: (2, self._handle_cut_rel_x), + 0xAB: (2, self._handle_cut_rel_y), + 0xD7: (0, self._handle_end), + # Nested commands + 0xCA: {0x06: (5, self._handle_set_color)}, + 0xC9: {0x04: (5, self._handle_set_speed)}, + 0xC6: { + 0x32: (3, self._handle_set_power), + 0x60: (7, self._handle_set_frequency), + }, + } + + def _ensure_layer(self, job: RuidaJob, color: int) -> RuidaLayer: + """ + Gets the layer for a given color, creating it if it doesn't exist. + """ + if color not in job.layers: + job.layers[color] = RuidaLayer(color_index=color, speed=0, power=0) + return job.layers[color] diff --git a/rayforge/image/ruida/renderer.py b/rayforge/image/ruida/renderer.py new file mode 100644 index 000000000..57f9823db --- /dev/null +++ b/rayforge/image/ruida/renderer.py @@ -0,0 +1,13 @@ +from ..dxf.renderer import DxfRenderer + + +class RuidaRenderer(DxfRenderer): + """ + A renderer for Ruida workpieces. Inherits vector rendering logic from + DxfRenderer. + """ + + +# The RUIDA importer produces vector geometry, so it uses a renderer that +# can handle it. We create this alias for consistency and future extension. +RUIDA_RENDERER = RuidaRenderer() diff --git a/rayforge/image/structures.py b/rayforge/image/structures.py new file mode 100644 index 000000000..a2f6150b0 --- /dev/null +++ b/rayforge/image/structures.py @@ -0,0 +1,378 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Rect + +from ..core.color import ColorRGBA + +if TYPE_CHECKING: + from ..core.asset import IAsset + from ..core.item import DocItem + from ..core.source_asset import SourceAsset + + +class FillStyle(Enum): + """Fill rendering style.""" + + SOLID = "solid" + LINEAR_GRADIENT = "linear_gradient" + RADIAL_GRADIENT = "radial_gradient" + + +@dataclass +class FillRenderData: + """Data needed to render a fill region.""" + + geometry: Geometry + style: FillStyle + color: ColorRGBA + gradient_stops: list[tuple[float, ColorRGBA]] | None = None + gradient_angle: float = 0.0 + + +@dataclass +class LayerInfo: + """ + A lightweight descriptor for a single layer discovered in a file scan. + + This class is part of Phase 1 (Scan) of the import pipeline. It provides + metadata about layers without requiring full parsing of the file content. + + Attributes: + id: Unique identifier for the layer within the file. + name: Human-readable name for the layer. + color: Optional RGB color tuple (0-1 range) for display purposes. + default_active: Whether this layer should be active by default. + feature_count: Optional count of geometric features in this layer. + """ + + id: str + name: str + color: tuple[float, float, float] | None = None + default_active: bool = True + feature_count: int | None = None + + +@dataclass +class ImportManifest: + """ + The result of Phase 1 (Scan) of the import pipeline. + + Describes the file's contents and structure without performing a full + import. This is used for UI previews and layer selection dialogs. + + Coordinate System: + ------------------ + natural_size_mm: Physical dimensions in millimeters (mm, Y-Up). + This represents the natural size of the document as + defined by the source format. + + Attributes: + layers: List of LayerInfo objects describing each layer. + natural_size_mm: Optional (width, height) in mm for the document. + title: Optional title or name for the document. + warnings: List of non-critical warnings discovered during scan. + errors: List of errors discovered during scan. + + Error Handling: + --------------- + Scan errors are collected here but do not prevent the scan from + returning. The presence of errors indicates the file may have issues + but some information was still extracted. + """ + + layers: list[LayerInfo] = field(default_factory=list) + color_layers: list[LayerInfo] = field(default_factory=list) + natural_size_mm: tuple[float, float] | None = None + title: str | None = None + warnings: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + is_unitless: bool = False + + +@dataclass +class LayerGeometry: + """ + Describes the geometric properties of a specific layer within a parsed + file. + + Coordinate System: + ------------------ + All coordinates are in the file's Native Coordinate System: + - For SVG: user units as defined by the viewBox + - For DXF: drawing units + - For images: pixels + Y-axis orientation depends on source format (see ParsingResult.is_y_down) + + Frame of Reference: + ------------------ + content_bounds are absolute coordinates within the document's native + coordinate space. They represent the tight bounding box of all content + on this layer. + + Attributes: + layer_id: Unique identifier for this layer. + name: Human-readable name for this layer. + content_bounds: Tight bounding box (x, y, width, height) in Native + Coordinates. For raster images, this is the non- + transparent pixel area. For vector files, this is + the geometric bounding box. + color: Optional hex color string (e.g. "#ff0000") associated with + this layer. Used by color-based layer sources. + """ + + layer_id: str + name: str + content_bounds: Rect + color: str | None = None + + +@dataclass +class ParsingResult: + """ + The result of Phase 2 (Parse) of the import pipeline. + + Contains pure geometric facts about the file without any layout decisions. + + Coordinate System: + ------------------ + document_bounds: Native Coordinates (file-specific units) + - For SVG: user units as defined by the viewBox + - For DXF: drawing units + - For images: pixels + Y-axis orientation is specified by is_y_down flag. + + world_frame_of_reference: World Coordinates (mm, Y-Up) + - Physical world coordinates in millimeters + - Y-axis points UP (Y-Up convention) + - Origin (0,0) is at the bottom-left + - Used as stable reference for UI previews + + Frame of Reference: + ------------------ + - document_bounds are absolute within the document's native space + - untrimmed_document_bounds provides reference for Y-inversion + - world_frame_of_reference provides the stable world coordinate frame + + Attributes: + document_bounds: Total canvas/page size (x, y, width, height) in + Native Coordinates. For trimmed files, this is the + trimmed viewbox. All coordinates are absolute. + native_unit_to_mm: Multiplier to convert Native units to mm. + e.g., for 96 DPI SVG, this is 25.4 / 96. + is_y_down: True if native Y points down (SVG, images), False if Y + points up (DXF). + layers: List of LayerGeometry describing each layer's geometry. + world_frame_of_reference: Authoritative frame (x, y, w, h) in World + Coordinates (mm, Y-Up) encompassing the + entire import operation. + background_world_transform: Matrix for positioning background image + within world_frame_of_reference. + untrimmed_document_bounds: Optional bounds of original untrimmed + page. Used as reference for positioning + trimmed content. + geometry_is_relative_to_bounds: True if geometry is relative to + content_bounds origin (trimmed + SVGs). False if in global native + coords (DXF). + is_cropped_to_content: True if document_bounds represent a cropped-to- + content view and workpiece should be sized to + these bounds, not untrimmed bounds. + + Error Handling: + --------------- + This structure represents parsing results. Errors during parsing are + collected by the Importer and returned in ImportResult, not here. + """ + + document_bounds: Rect + native_unit_to_mm: float + is_y_down: bool + layers: list[LayerGeometry] + world_frame_of_reference: Rect + background_world_transform: Matrix + untrimmed_document_bounds: Rect | None = None + geometry_is_relative_to_bounds: bool = False + is_cropped_to_content: bool = False + + +@dataclass +class VectorizationResult: + """ + The result of Phase 3 (Vectorize) of the import pipeline. + + Contains the final vector geometry that will be used for layout and + assembly. + + Coordinate System: + ------------------ + geometries_by_layer: Vector geometry in Native Coordinates + - The geometry is still in the file's native coordinate system + - It will be normalized by the NormalizationEngine during layout + - Y-axis orientation matches the source format + + Frame of Reference: + ------------------ + - Geometry coordinates are absolute within the document's native space + - source_parse_result provides the context for coordinate transformations + + Attributes: + geometries_by_layer: Final vector geometry for each layer. Keys are + layer IDs (None for single-layer content). + source_parse_result: Reference to original ParsingResult for context + (e.g., page bounds, coordinate system info). + fills_by_layer: Optional fill geometry per layer, primarily used by + the Sketch importer. + layer_settings: Per-layer settings (e.g. power, speed) produced by + the importer during vectorization. Layer IDs match + keys in geometries_by_layer. The assembler applies + these when creating Layer objects. + + Error Handling: + --------------- + This structure represents vectorization results. Errors during + vectorization are collected by the Importer and returned in ImportResult. + """ + + geometries_by_layer: dict[str | None, Geometry] + source_parse_result: ParsingResult + fills_by_layer: dict[str | None, list[FillRenderData]] = field( + default_factory=dict + ) + layer_settings: dict[str | None, dict[str, Any]] = field( + default_factory=dict + ) + + +@dataclass +class LayoutItem: + """ + A single instruction for Phase 5 (Assemble). + + Represents one resulting WorkPiece configuration as calculated by the + NormalizationEngine. + + Coordinate System: + ------------------ + world_matrix: Transforms from Normalized (0-1, Y-Up) to World (mm, Y-Up) + - Input: Unit square coordinates (0,0) to (1,1), Y-Up + - Output: Physical world coordinates in millimeters, Y-Up + - Origin (0,0) is at the bottom-left of the workpiece + + normalization_matrix: Transforms from Native to Normalized (0-1, Y-Up) + - Input: Native Coordinates (file-specific) + - Output: Unit square coordinates (0,1), Y-Up + - Handles Y-axis inversion for Y-Down sources + + crop_window: Native Coordinates (file-specific units) + - Absolute coordinates within the original document + - Used to specify which portion of the source to use + + Frame of Reference: + ------------------ + - crop_window is absolute in the document's native coordinate space + - world_matrix positions the workpiece in the world coordinate system + - normalization_matrix handles the coordinate system conversion + + Attributes: + layer_id: Optional ID of the layer(s) this item represents. + layer_name: Optional human-readable name for the layer(s). + color: Optional hex color string (e.g. "#ff0000") for the resulting + document layer. + world_matrix: Matrix transforming normalized (0-1) geometry to final + World position/scale (mm, Y-Up). + normalization_matrix: Matrix transforming Native Coordinates to Unit + Square (0-1, Y-Up). + crop_window: Subset of original file (x, y, w, h) in Native Coords. + Used for cropping images or limiting vector scope. + """ + + layer_id: str | None + layer_name: str | None + world_matrix: Matrix + normalization_matrix: Matrix + crop_window: Rect + settings: dict[str, Any] | None = None + color: str | None = None + + +@dataclass +class ImportPayload: + """ + A container for the complete result of Phase 5 (Assemble). + + This is the final output of the import pipeline, containing a + self-contained package ready for integration into a document. + + Coordinate System: + ------------------ + All DocItems in this payload are already positioned in World + Coordinates (mm, Y-Up) with their transformation matrices applied. + + Attributes: + source: The SourceAsset representing the imported file. + items: List of DocItems (WorkPieces or Layers) ready for insertion. + assets: Optional list of IAsset objects (e.g., Sketches) for special + importers that need to create reusable asset definitions. + + Error Handling: + --------------- + This structure represents successful import results. Errors during + import are handled in ImportResult, not here. + """ + + source: "SourceAsset" + items: list["DocItem"] + assets: list["IAsset"] = field(default_factory=list) + + +@dataclass +class ImportResult: + """ + The complete result of the five-phase import pipeline. + + Contains both the final payload and intermediate results for contextual + use (like previews). + + Coordinate System: + ------------------ + - payload: Contains DocItems in World Coordinates (mm, Y-Up) + - parse_result: Contains Native Coordinates and World Coordinates + - vectorization_result: Contains Native Coordinates + + Frame of Reference: + ------------------ + - Each result maintains its own coordinate system as documented in + the respective class docstrings + - The world_frame_of_reference in parse_result provides the stable + world coordinate frame for UI purposes + + Attributes: + payload: Optional ImportPayload containing the final DocItems. + May be None if import failed completely. + parse_result: Optional ParsingResult with geometric facts. + May be None if parsing failed. + vectorization_result: Optional VectorizationResult with vector + geometry. May be None if vectorization was + not performed or failed. + warnings: List of non-critical warnings collected during import. + These do not prevent the import from returning results. + errors: List of errors collected during import. The presence of + errors indicates the import may have partial or no results. + + Error Handling: + --------------- + This is the primary container for error reporting. Warnings indicate + non-critical issues that were handled. Errors indicate problems that + may have prevented complete import. The presence of errors does not + necessarily mean the import failed completely - partial results may + still be available in payload or intermediate results. + """ + + payload: ImportPayload | None + parse_result: ParsingResult | None + vectorization_result: VectorizationResult | None = None + warnings: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) diff --git a/rayforge/image/svg/__init__.py b/rayforge/image/svg/__init__.py new file mode 100644 index 000000000..0c296ae6b --- /dev/null +++ b/rayforge/image/svg/__init__.py @@ -0,0 +1,3 @@ +from .importer import SvgImporter + +__all__ = ["SvgImporter"] diff --git a/rayforge/image/svg/exporter.py b/rayforge/image/svg/exporter.py new file mode 100644 index 000000000..fc9679c76 --- /dev/null +++ b/rayforge/image/svg/exporter.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import math +from gettext import gettext as _ + +from raygeo.geo import Arc, Bezier, Geometry, Line, Move +from raygeo.geo.shape.arc import get_arc_angles +from raygeo.geo.shape.rect import get_combined_rect + +from ..base_exporter import BaseExporter + + +class GeometrySvgExporter(BaseExporter): + """ + Exports a Geometry object to SVG format. + """ + + label = _("SVG (Scalable Vector Graphics)") + extensions = (".svg",) + mime_types = ("image/svg+xml",) + + def __init__(self, geometry: Geometry): + self.geometry = geometry + + def export(self) -> bytes: + if self.geometry.is_empty(): + raise ValueError("Cannot export: The geometry is empty.") + + min_x, min_y, max_x, max_y = self.geometry.rect() + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + + svg_content = self._geometry_to_svg( + self.geometry, min_x, min_y, width, height + ) + return svg_content.encode("utf-8") + + def _geometry_to_svg( + self, + geometry: Geometry, + min_x: float, + min_y: float, + width: float, + height: float, + ) -> str: + path_data = self._geometry_to_svg_path(geometry, min_x, min_y) + + padding = 1.0 + svg_width = width + 2 * padding + svg_height = height + 2 * padding + + vb = ( + f'viewBox="{-padding} {-padding} {svg_width:.3f} {svg_height:.3f}"' + ) + svg_parts = [ + ( + f'' + ) + ] + + if path_data: + svg_parts.append( + f'' + ) + + svg_parts.append("") + return "\n".join(svg_parts) + + def _geometry_to_svg_path( + self, + geometry: Geometry, + min_x: float, + min_y: float, + max_y: float | None = None, + ) -> str: + path_data: list[str] = [] + if max_y is None: + _, _, _, max_y = geometry.rect() + + def transform(x: float, y: float) -> tuple: + tx = x - min_x + assert max_y is not None + ty = max_y - y + return tx, ty + + last_x = 0.0 + last_y = 0.0 + + for cmd in geometry.data: + x = cmd.end[0] + y = cmd.end[1] + + if isinstance(cmd, Move): + tx, ty = transform(x, y) + path_data.append(f"M {tx:.6f} {ty:.6f}") + elif isinstance(cmd, Line): + tx, ty = transform(x, y) + path_data.append(f"L {tx:.6f} {ty:.6f}") + elif isinstance(cmd, Arc): + i = cmd.center_offset[0] + j = cmd.center_offset[1] + cw = cmd.clockwise + + radius = math.hypot(i, j) + large_arc = self._compute_large_arc_flag( + last_x, last_y, x, y, i, j, cw + ) + sweep = 1 if cw else 0 + + tx, ty = transform(x, y) + path_data.append( + f"A {radius:.6f} {radius:.6f} 0 {large_arc} {sweep} " + f"{tx:.6f} {ty:.6f}" + ) + elif isinstance(cmd, Bezier): + c1x = cmd.control1[0] + c1y = cmd.control1[1] + c2x = cmd.control2[0] + c2y = cmd.control2[1] + + tx, ty = transform(x, y) + c1tx, c1ty = transform(c1x, c1y) + c2tx, c2ty = transform(c2x, c2y) + path_data.append( + f"C {c1tx:.6f} {c1ty:.6f} " + f"{c2tx:.6f} {c2ty:.6f} " + f"{tx:.6f} {ty:.6f}" + ) + + last_x = x + last_y = y + + return " ".join(path_data) + + def _compute_large_arc_flag( + self, + x1: float, + y1: float, + x2: float, + y2: float, + i: float, + j: float, + cw: bool, + ) -> int: + _, _, sweep = get_arc_angles((x1, y1), (x2, y2), (x1 + i, y1 + j), cw) + return 1 if abs(sweep) > math.pi else 0 + + +class MultiGeometrySvgExporter(BaseExporter): + """ + Exports multiple Geometry objects to a single SVG file. + """ + + label = _("SVG (Scalable Vector Graphics)") + extensions = (".svg",) + mime_types = ("image/svg+xml",) + + def __init__(self, geometries: list[Geometry]): + self.geometries = geometries + + def export(self) -> bytes: + non_empty = [g for g in self.geometries if not g.is_empty()] + if not non_empty: + raise ValueError("Cannot export: All geometries are empty.") + + min_x, min_y, max_x, max_y = get_combined_rect(non_empty) + + width = max(max_x - min_x, 1e-9) + height = max(max_y - min_y, 1e-9) + + svg_content = self._geometries_to_svg( + non_empty, min_x, min_y, width, height + ) + return svg_content.encode("utf-8") + + def _geometries_to_svg( + self, + geometries: list[Geometry], + min_x: float, + min_y: float, + width: float, + height: float, + ) -> str: + padding = 1.0 + svg_width = width + 2 * padding + svg_height = height + 2 * padding + max_y = min_y + height + + vb = ( + f'viewBox="{-padding} {-padding} {svg_width:.3f} {svg_height:.3f}"' + ) + svg_parts = [ + ( + f'' + ) + ] + + single_exporter = GeometrySvgExporter(Geometry()) + for geo in geometries: + path_data = single_exporter._geometry_to_svg_path( + geo, min_x, min_y, max_y + ) + if path_data: + svg_parts.append( + f'' + ) + + svg_parts.append("") + return "\n".join(svg_parts) diff --git a/rayforge/image/svg/importer.py b/rayforge/image/svg/importer.py new file mode 100644 index 000000000..85e631cf4 --- /dev/null +++ b/rayforge/image/svg/importer.py @@ -0,0 +1,208 @@ +import logging +from typing import ClassVar + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import ( + LayerImportMode, + LayerSource, + PassthroughSpec, + TraceSpec, + VectorizationSpec, +) +from ..base_importer import ( + Importer, + ImporterFeature, +) +from ..structures import ( + ImportManifest, + ImportResult, + ParsingResult, + VectorizationResult, +) +from .svg_trace import SvgTraceImporter +from .svg_vector import SvgVectorImporter + +logger = logging.getLogger(__name__) + + +class SvgImporter(Importer): + """ + A Facade importer for SVG files. + + It routes the import request to either the Vector strategy (for path + extraction) or the Trace strategy (for rendering and tracing bitmaps), + depending on the provided VectorizationSpec. + """ + + label = "SVG files" + mime_types = ("image/svg+xml",) + extensions = (".svg",) + features: ClassVar[set[ImporterFeature]] = { + ImporterFeature.DIRECT_VECTOR, + ImporterFeature.BITMAP_TRACING, + ImporterFeature.LAYER_SELECTION, + ImporterFeature.COLOR_LAYERS, + } + + def scan(self) -> ImportManifest: + # Use Vector importer for scanning as it's lightweight/standard + return SvgVectorImporter(self.raw_data, self.source_file).scan() + + def get_doc_items( + self, vectorization_spec: VectorizationSpec | None = None + ) -> ImportResult | None: + """ + Delegates the full import process to the appropriate strategy. + """ + spec_to_use = vectorization_spec + # If no spec is provided, default to the vector strategy. + if spec_to_use is None: + spec_to_use = PassthroughSpec() + + if isinstance(spec_to_use, TraceSpec): + logger.debug("SvgImporter: Delegating to SvgTraceImporter.") + delegate = SvgTraceImporter(self.raw_data, self.source_file) + else: + # This is the direct vector import path. + # If no layers are specified (e.g. from CLI), assume the user wants + # all layers imported into the current document layer. + if ( + isinstance(spec_to_use, PassthroughSpec) + and not spec_to_use.active_layer_ids + ): + logger.debug( + "Empty PassthroughSpec detected in facade. " + "Scanning for all available layers." + ) + manifest = self.scan() + if spec_to_use.layer_source == LayerSource.COLORS: + all_layer_ids = [ + layer.id for layer in manifest.color_layers + ] + else: + all_layer_ids = [layer.id for layer in manifest.layers] + if all_layer_ids: + logger.debug( + f"Populating spec with all layers: {all_layer_ids}" + ) + # Create a new spec object that matches the UI's default. + # This ensures the "merge" strategy is used in the engine + # unless the caller explicitly chose another mode. + layer_import_mode = spec_to_use.layer_import_mode + if layer_import_mode == LayerImportMode.MAP_TO_EXISTING: + layer_import_mode = LayerImportMode.FLATTEN + spec_to_use = PassthroughSpec( + active_layer_ids=all_layer_ids, + layer_import_mode=layer_import_mode, + layer_source=spec_to_use.layer_source, + color_attr=spec_to_use.color_attr, + ) + + logger.debug("SvgImporter: Delegating to SvgVectorImporter.") + delegate = SvgVectorImporter(self.raw_data, self.source_file) + + import_result = delegate.get_doc_items(spec_to_use) + + if ( + import_result + and import_result.payload + and import_result.payload.source + ): + self._stamp_importer_identity(import_result.payload.source) + + # --- DIAGNOSTIC LOGGING --- + if ( + import_result + and import_result.payload + and import_result.payload.items + ): + from ...core.layer import Layer + from ...core.workpiece import WorkPiece + + def count_workpieces(items): + count = 0 + for item in items: + if isinstance(item, WorkPiece): + count += 1 + elif isinstance(item, Layer): + count += count_workpieces(item.children) + return count + + def check_for_geometry(items): + for item in items: + if isinstance(item, WorkPiece): + if ( + item.source_segment + and item.source_segment.pristine_geometry + ): + return True + elif isinstance(item, Layer) and check_for_geometry( + item.children + ): + return True + return False + + item_count = len(import_result.payload.items) + wp_count = count_workpieces(import_result.payload.items) + + has_geo_in_segment = check_for_geometry( + import_result.payload.items + ) + + item_info = ( + f"{item_count} total items ({wp_count} WorkPieces). " + f"Pristine geometry in segment: {has_geo_in_segment}" + ) + elif import_result: + item_info = "0 items." + else: + item_info = "None (import failed)." + + logger.debug(f"SvgImporter delegate returned result with: {item_info}") + # --- END DIAGNOSTIC --- + + # If we have a result, ensure the facade's errors (if any were + # collected before delegation) are merged, though usually facade + # does little before delegation. + if import_result: + import_result.warnings.extend(self._warnings) + import_result.errors.extend(self._errors) + + return import_result + + # These abstract methods must be implemented to satisfy the ABC contract, + # but get_doc_items bypasses them in this facade. + + def parse(self) -> ParsingResult | None: + raise NotImplementedError( + "SvgImporter is a facade; parse is delegated via get_doc_items" + ) + + def vectorize( + self, parse_result: ParsingResult, spec: VectorizationSpec + ) -> VectorizationResult: + raise NotImplementedError( + "SvgImporter is a facade; vectorize is delegated via get_doc_items" + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + raise NotImplementedError( + "SvgImporter is a facade; create_source_asset is delegated" + ) + + def get_doc_items_for_reimport( + self, + existing_source_asset: SourceAsset, + vectorization_spec: VectorizationSpec, + ) -> ImportResult | None: + if isinstance(vectorization_spec, TraceSpec): + delegate = SvgTraceImporter(self.raw_data, self.source_file) + else: + delegate = SvgVectorImporter(self.raw_data, self.source_file) + result = delegate.get_doc_items_for_reimport( + existing_source_asset, vectorization_spec + ) + if result: + result.warnings.extend(self._warnings) + result.errors.extend(self._errors) + return result diff --git a/rayforge/image/svg/renderer.py b/rayforge/image/svg/renderer.py new file mode 100644 index 000000000..ceeb5b254 --- /dev/null +++ b/rayforge/image/svg/renderer.py @@ -0,0 +1,177 @@ +import logging +import warnings +from typing import TYPE_CHECKING, Optional +from xml.etree import ElementTree as ET + +from raygeo.geo.types import Rect +from raygeo.svg import filter_svg_by_color +from raygeo.svg.color import ColorAttr + +from ...core.vectorization_spec import LayerSource, PassthroughSpec, TraceSpec +from ..base_renderer import Renderer, RenderSpecification +from .svg_fallback import ( + SVG_LOAD_AVAILABLE, + cairo_surface_to_vips, + render_svg_to_cairo, +) +from .svgutil import filter_svg_layers + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from ...core.source_asset_segment import SourceAssetSegment + from ...core.workpiece import RenderContext + from ...image.structures import ImportResult + + +class SvgRenderer(Renderer): + """Renders SVG data.""" + + def compute_render_spec( + self, + segment: Optional["SourceAssetSegment"], + target_size: tuple[int, int], + source_context: "RenderContext", + ) -> "RenderSpecification": + """ + Calculates the render specification for an SVG source. This method + populates the kwargs with `viewbox` or `visible_layer_ids` as needed. + """ + kwargs = {} + target_width, target_height = target_size + + # A segment is required for any special SVG handling. + if segment: + # Handle layer visibility. Color-layer segments use a color key + # (e.g. "#ff0000") as their layer id, which does not correspond + # to any top-level group, so the render is filtered by color + # instead of by group membership. + if segment.layer_id: + spec = segment.vectorization_spec + if ( + isinstance(spec, PassthroughSpec) + and spec.layer_source == LayerSource.COLORS + ): + kwargs["color_key"] = segment.layer_id + kwargs["color_attr"] = spec.color_attr + else: + kwargs["visible_layer_ids"] = [segment.layer_id] + + # Handle viewbox cropping for direct vector imports + if segment.crop_window_px: + # The upscale-then-crop logic of rasters does not apply to + # vectors. Instead, we pass a `viewbox` to the renderer, but + # ONLY if it's a direct vector import (PassthroughSpec). If + # it's a TraceSpec, we must render the full SVG as a bitmap, + # so we don't pass a viewbox. + is_vector = not isinstance( + segment.vectorization_spec, TraceSpec + ) + if is_vector: + kwargs["viewbox"] = segment.crop_window_px + + return RenderSpecification( + width=target_width, + height=target_height, + data=source_context.data, + kwargs=kwargs, + # Vector renders from SVG are pre-masked by their nature; + # applying a secondary mask based on potentially open-path + # geometry is incorrect and would hide the content. + apply_mask=False, + ) + + def render_preview_image( + self, + import_result: "ImportResult", + target_width: int, + target_height: int, + ) -> pyvips.Image | None: + """Renders the SVG source data at the target preview dimensions.""" + if not import_result.payload: + return None + + source = import_result.payload.source + # For previews, use the pre-trimmed data if available. + data_to_render = source.base_render_data or source.original_data + if not data_to_render: + return None + + # render_base_image correctly handles setting the width/height on the + # SVG data before passing it to the vips loader. + return self.render_base_image( + data=data_to_render, width=target_width, height=target_height + ) + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + visible_layer_ids: list[str] | None = None, + viewbox: Rect | None = None, + **kwargs, + ) -> pyvips.Image | None: + """ + Renders raw SVG data to a pyvips Image by setting its pixel dimensions. + Expects data to be pre-trimmed for content. + Can optionally filter by layer IDs if 'visible_layer_ids' is passed. + Can optionally override the viewBox if 'viewbox' is passed + (x, y, w, h). + """ + if not data: + return None + + render_data = data + if visible_layer_ids: + render_data = filter_svg_layers(data, visible_layer_ids) + elif kwargs.get("color_key"): + render_data = filter_svg_by_color( + data.decode("utf-8", errors="replace"), + kwargs["color_key"], + kwargs.get("color_attr") or ColorAttr.ANY, + ).encode("utf-8") + + if not render_data: + return None + + try: + # Modify SVG dimensions for the loader to render at target size + root = ET.fromstring(render_data) + root.set("width", f"{width}px") + root.set("height", f"{height}px") + root.set("preserveAspectRatio", "none") + + # Allow overriding the viewBox (used for rendering split/cropped + # vector segments) + if viewbox: + vb_x, vb_y, vb_w, vb_h = viewbox + root.set("viewBox", f"{vb_x} {vb_y} {vb_w} {vb_h}") + + # This causes the content to stretch to fill the width/height + # instead of scaling proportionally. This is REQUIRED for tracing + # non-uniformly scaled objects correctly. + root.set("style", "overflow: visible") + + svg_bytes = ET.tostring(root) + + if SVG_LOAD_AVAILABLE: + image = pyvips.Image.svgload_buffer(svg_bytes) + else: + surface = render_svg_to_cairo(svg_bytes, width, height) + if not surface: + return None + image = cairo_surface_to_vips(surface) + if not image: + return None + + return image + except (pyvips.Error, ET.ParseError, ValueError, TypeError): + return None + + +SVG_RENDERER = SvgRenderer() diff --git a/rayforge/image/svg/svg_base.py b/rayforge/image/svg/svg_base.py new file mode 100644 index 000000000..b08ae30f3 --- /dev/null +++ b/rayforge/image/svg/svg_base.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Rect +from raygeo.svg import svg_string_to_geometry +from raygeo.svg.metadata import extract_svg_metadata + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import PassthroughSpec +from ..base_importer import ( + Importer, +) +from ..structures import ( + ImportManifest, + LayerInfo, + ParsingResult, +) +from .renderer import SVG_RENDERER +from .svgutil import ( + PPI, + extract_color_manifest, + extract_layer_manifest, + get_natural_size, + hex_color_to_rgb, + is_unitless_svg, +) + +logger = logging.getLogger(__name__) + + +class SvgImporterBase(Importer): + """ + Base class for SVG importers containing shared logic for: + - Scanning metadata + - Analytical trimming + - Parsing SVG dimensions and units + - Converting SVG paths to Geometry (for bounds/trimming) + """ + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self.trimmed_data: bytes | None = None + + def _get_ppi(self) -> float: + if self._vectorization_spec and hasattr( + self._vectorization_spec, "ppi" + ): + return self._vectorization_spec.ppi + return PPI + + def scan(self) -> ImportManifest: + """Shared scan logic.""" + layers = [] + color_layers = [] + size_mm = None + try: + # Check for basic XML validity first to ensure we can catch + # malformed files and warn the user. + try: + ET.fromstring(self.raw_data) + except ET.ParseError as e: + raise ET.ParseError(f"XML Parse Error: {e}") + + size_mm = get_natural_size(self.raw_data) + layer_data = extract_layer_manifest(self.raw_data) + layers = [ + LayerInfo( + id=layer["id"], + name=layer["name"], + feature_count=layer.get("count"), + ) + for layer in layer_data + ] + color_data = extract_color_manifest(self.raw_data) + color_layers = [ + LayerInfo( + id=layer["id"], + name=layer["name"], + color=( + hex_color_to_rgb(layer["color"]) + if layer["color"] is not None + else None + ), + feature_count=layer.get("count"), + ) + for layer in color_data + ] + except ET.ParseError as e: + logger.warning(f"SVG scan failed for {self.source_file.name}: {e}") + self.add_error(f"Could not parse SVG. File may be corrupt: {e}") + except Exception as e: + logger.exception( + f"Unexpected error during SVG scan for {self.source_file.name}" + ) + self.add_error(f"Unexpected error while scanning SVG: {e}") + + return ImportManifest( + title=self.source_file.name, + layers=layers, + color_layers=color_layers, + natural_size_mm=size_mm, + warnings=self._warnings, + errors=self._errors, + is_unitless=is_unitless_svg(self.raw_data), + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + """Shared SourceAsset creation logic.""" + source = SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=SVG_RENDERER, + thumbnail_data=self._render_thumbnail_from_renderer( + SVG_RENDERER, self.trimmed_data + ), + ) + source.base_render_data = self.trimmed_data + + # Get pixel dimensions for rendering from the parsed SVG object, + # which is based on the trimmed_data. This is the authoritative source + # for the trimmed pixel dimensions. + if self.trimmed_data: + facts = self._get_svg_parsing_facts(self.trimmed_data) + if facts: + w_px_float, h_px_float, viewbox = facts + source.width_px = int(w_px_float) + source.height_px = int(h_px_float) + if viewbox: + source.metadata["viewbox"] = viewbox + + # The physical mm size comes from the layout, which is correct. + _ignored1, _ignored2, w_native, h_native = parse_result.document_bounds + source.width_mm = w_native * parse_result.native_unit_to_mm + source.height_mm = h_native * parse_result.native_unit_to_mm + + metadata: dict[str, Any] = {} + try: + ppi = self._get_ppi() + untrimmed_size = get_natural_size(source.original_data, ppi=ppi) + if untrimmed_size: + metadata["untrimmed_width_mm"] = untrimmed_size[0] + metadata["untrimmed_height_mm"] = untrimmed_size[1] + + if source.base_render_data: + trimmed_size = get_natural_size( + source.base_render_data, ppi=ppi + ) + if trimmed_size: + metadata["trimmed_width_mm"] = trimmed_size[0] + metadata["trimmed_height_mm"] = trimmed_size[1] + + # Extract viewbox from trimmed data + try: + root = ET.fromstring(source.base_render_data) + vb_str = root.get("viewBox") + if vb_str: + metadata["viewbox"] = tuple(map(float, vb_str.split())) + except ET.ParseError: + pass + + source.metadata.update(metadata) + except ValueError: + logger.warning("Could not calculate SVG metadata.", exc_info=True) + self.add_warning(_("Could not calculate SVG metadata.")) + + return source + + def _calculate_parsing_basics( + self, + ) -> tuple[Rect, float, Rect | None, Rect] | None: + """ + Common parsing logic. Returns: + (document_bounds, unit_to_mm, untrimmed_document_bounds, + world_frame_of_reference) + or None if parsing fails. + + Note: document_bounds are in Native Units (ViewBox units if available, + otherwise Pixels). + """ + self.trimmed_data = self._analytical_trim(self.raw_data) + if not self.trimmed_data: + logger.error("Failed to prepare trimmed SVG data.") + self.add_error(_("Failed to prepare trimmed SVG data.")) + return None + + # Check dimensions: if no viewBox and no explicit width/height, + # verify there's actual geometry. + try: + check_meta = extract_svg_metadata( + self.trimmed_data.decode("utf-8") + ) + except ValueError: + check_meta = None + has_viewbox = check_meta is not None and check_meta.viewbox is not None + has_explicit_dims = check_meta is not None and ( + check_meta.width is not None or check_meta.height is not None + ) + if not has_viewbox and not has_explicit_dims: + geo = self._convert_svg_to_geometry(self.trimmed_data) + if geo.is_empty(): + self.add_error(_("SVG contains no geometry or dimensions.")) + return None + + facts = self._get_svg_parsing_facts(self.trimmed_data) + if not facts: + self.add_error(_("Could not determine valid SVG dimensions.")) + return None + width_px, height_px, viewbox = facts + + # Get the physical size of the trimmed content + ppi = self._get_ppi() + mm_per_px = 25.4 / ppi + final_dims_mm = get_natural_size(self.trimmed_data, ppi=ppi) + if not final_dims_mm: + final_dims_mm = (width_px * mm_per_px, height_px * mm_per_px) + + if viewbox: + # If ViewBox exists, we use ViewBox units as the Native Units. + vb_x, vb_y, vb_w, vb_h = viewbox + # Return absolute bounds of the trimmed ViewBox. This is critical. + document_bounds = (vb_x, vb_y, vb_w, vb_h) + unit_to_mm = final_dims_mm[0] / vb_w if vb_w > 0 else 1.0 + else: + # No ViewBox: Native Units are Pixels. + document_bounds = (0.0, 0.0, width_px, height_px) + unit_to_mm = final_dims_mm[0] / width_px if width_px > 0 else 1.0 + + # Calculate untrimmed bounds in the same Native Units + untrimmed_document_bounds: Rect | None = None + + # First, try to get the authoritative untrimmed viewbox by parsing + # the original, untrimmed SVG data. This is the correct frame of + # reference for positioning. + untrimmed_facts = self._get_svg_parsing_facts(self.raw_data) + if untrimmed_facts: + _w, _h, untrimmed_vb = untrimmed_facts + if untrimmed_vb: + untrimmed_document_bounds = untrimmed_vb + logger.debug(f"Found untrimmed viewBox: {untrimmed_vb}") + else: + # Fallback for SVGs without a viewbox, use pixel dims + untrimmed_document_bounds = ( + 0.0, + 0.0, + float(_w), + float(_h), + ) + logger.debug( + f"Using untrimmed viewBox: {untrimmed_document_bounds}" + ) + + # If parsing failed, fall back to calculating from physical size + if not untrimmed_document_bounds: + untrimmed_size_mm = get_natural_size(self.raw_data, ppi=ppi) + if untrimmed_size_mm and unit_to_mm > 0: + untrimmed_w = untrimmed_size_mm[0] / unit_to_mm + untrimmed_h = untrimmed_size_mm[1] / unit_to_mm + untrimmed_document_bounds = ( + 0, + 0, + untrimmed_w, + untrimmed_h, + ) + logger.debug( + "Calculated untrimmed bounds from physical size: " + f"{untrimmed_document_bounds}" + ) + + # Calculate the authoritative world frame of reference (mm, Y-Up) + ref_bounds_native = untrimmed_document_bounds or document_bounds + ref_x, _ignored3, ref_w, ref_h = ref_bounds_native + w_mm = ref_w * unit_to_mm + h_mm = ref_h * unit_to_mm + x_mm = ref_x * unit_to_mm + y_mm = 0.0 # The world frame's origin is at its bottom-left. + + world_frame = (x_mm, y_mm, w_mm, h_mm) + + return ( + document_bounds, + unit_to_mm, + untrimmed_document_bounds, + world_frame, + ) + + # --- Low-level Helpers --- + + def _analytical_trim(self, data: bytes) -> bytes: + """Trims the SVG using vector geometry bounds.""" + try: + root = ET.fromstring(data) + + # 1. Get geometry bounds in the SVG's native user coordinate + # system. + geo = self._convert_svg_to_geometry(data) + if geo.is_empty(): + return data + + # Get pixel and user unit dimensions to calculate the scale + facts = self._get_svg_parsing_facts(data) + if not facts: + return data + orig_w_px, orig_h_px, viewbox = facts + + if viewbox: + vb_x, vb_y, orig_vb_w, orig_vb_h = viewbox + else: + vb_x, vb_y, orig_vb_w, orig_vb_h = 0, 0, orig_w_px, orig_h_px + + logger.debug( + f"_analytical_trim: OrigPx={orig_w_px}x{orig_h_px}, " + f"VB=({vb_x}, {vb_y}, {orig_vb_w}, {orig_vb_h})" + ) + + min_x, min_y, max_x, max_y = geo.rect() + + logger.debug( + f"_analytical_trim: Content Bounds (User Units): " + f"min_x={min_x:.4f}, min_y={min_y:.4f}, " + f"max_x={max_x:.4f}, max_y={max_y:.4f}" + ) + + # 3. Calculate new viewBox with padding to prevent clipping + width = max_x - min_x + height = max_y - min_y + trim_padding = 0.01 # Default 1% + if isinstance(self._vectorization_spec, PassthroughSpec): + trim_padding = self._vectorization_spec.trim_padding + padding = max(width, height) * trim_padding + + new_vb_x = min_x - padding + new_vb_y = min_y - padding + new_vb_w = width + (2 * padding) + new_vb_h = height + (2 * padding) + + if new_vb_w <= 1e-6 or new_vb_h <= 1e-6: + return data + + # 4. Calculate the original pixels-per-user-unit scale. + scale_x_render = orig_w_px / orig_vb_w if orig_vb_w > 0 else 1.0 + scale_y_render = orig_h_px / orig_vb_h if orig_vb_h > 0 else 1.0 + + # 5. Calculate new pixel dimensions for the trimmed SVG. + new_w_px = new_vb_w * scale_x_render + new_h_px = new_vb_h * scale_y_render + + # 6. Build the new SVG, using pixel units for consistency. + new_vb_str = f"{new_vb_x:g} {new_vb_y:g} {new_vb_w:g} {new_vb_h:g}" + root.set("viewBox", new_vb_str) + root.set("width", f"{new_w_px:.4f}px") + root.set("height", f"{new_h_px:.4f}px") + + if "preserveAspectRatio" in root.attrib: + del root.attrib["preserveAspectRatio"] + + return ET.tostring(root, encoding="utf-8") + + except (ValueError, ET.ParseError) as e: + logger.warning(f"Analytical trim failed: {e}") + self.add_warning(f"Optimization (trimming) failed: {e}") + return data + + def _get_svg_parsing_facts( + self, data: bytes + ) -> tuple[float, float, Rect | None] | None: + try: + meta = extract_svg_metadata(data.decode()) + except ValueError: + return None + + ppi = self._get_ppi() + w_px = meta.width_px(ppi) + h_px = meta.height_px(ppi) + if w_px is None or h_px is None: + return None + if w_px <= 1e-9 or h_px <= 1e-9: + return None + + return w_px, h_px, meta.viewbox + + def _convert_svg_to_geometry( + self, data: bytes, translate_to_origin: bool = False + ) -> Geometry: + try: + geo = svg_string_to_geometry(data.decode("utf-8"), 1.0, 1.0) + except ValueError: + geo = Geometry() + + if translate_to_origin and not geo.is_empty(): + min_x, min_y, _, _ = geo.rect() + translate_matrix = Matrix.translation(-min_x, -min_y) + geo.transform(translate_matrix) + + return geo diff --git a/rayforge/image/svg/svg_fallback.py b/rayforge/image/svg/svg_fallback.py new file mode 100644 index 000000000..0ee91da04 --- /dev/null +++ b/rayforge/image/svg/svg_fallback.py @@ -0,0 +1,186 @@ +""" +Fallback SVG rendering using Cairo/Rsvg when libvips lacks SVG support. + +This module provides capability detection for libvips' svgload_buffer and +a Cairo-based fallback renderer using PyGObject's Rsvg binding. +""" + +import logging +import warnings +from typing import TYPE_CHECKING, Optional + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +if TYPE_CHECKING: + import cairo + +logger = logging.getLogger(__name__) + +_SVG_LOAD_AVAILABLE: bool | None = None + + +def _check_svg_load_capability() -> bool: + """ + Tests whether pyvips.Image.svgload_buffer is available. + + Some libvips installations are compiled without librsvg support, + causing svgload_buffer to fail. This function tests with a minimal + SVG to determine availability. + + Returns: + True if svgload_buffer works, False otherwise. + """ + minimal_svg = b""" + + +""" + + try: + img = pyvips.Image.svgload_buffer(minimal_svg) + if img.width == 1 and img.height == 1: + return True + except pyvips.Error: + return False + except Exception: + logger.debug("SVG load capability probe failed", exc_info=True) + return False + return False + + +SVG_LOAD_AVAILABLE = _check_svg_load_capability() +"""Boolean indicating whether pyvips.Image.svgload_buffer is available.""" + + +def render_svg_to_cairo( + svg_data: bytes, width: int, height: int +) -> Optional["cairo.ImageSurface"]: + """ + Renders SVG data to a Cairo ImageSurface using PyGObject's Rsvg. + + Args: + svg_data: Raw SVG bytes. + width: Target width in pixels. + height: Target height in pixels. + + Returns: + A cairo.ImageSurface with the rendered SVG, or None on failure. + """ + import cairo + import gi + + gi.require_version("Rsvg", "2.0") + from gi.repository import GLib, Rsvg + + if not svg_data: + return None + + try: + handle = Rsvg.Handle.new_from_data(svg_data) + if handle is None: + logger.error("Failed to create Rsvg.Handle from SVG data") + return None + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + + dimensions = handle.get_dimensions() + doc_width = dimensions.width + doc_height = dimensions.height + + if doc_width > 0 and doc_height > 0: + scale_x = width / doc_width + scale_y = height / doc_height + ctx.scale(scale_x, scale_y) + + viewport = Rsvg.Rectangle() + viewport.x = 0 + viewport.y = 0 + viewport.width = width + viewport.height = height + handle.render_document(ctx, viewport) + return surface + + except (cairo.Error, GLib.Error) as e: + logger.error(f"Failed to render SVG with Cairo/Rsvg: {e}") + return None + + +def cairo_surface_to_vips( + surface: "cairo.ImageSurface", +) -> pyvips.Image | None: + """ + Converts a Cairo ImageSurface to a pyvips Image. + + This function handles the BGRA to RGBA conversion that Cairo uses + internally. + + Args: + surface: A cairo.ImageSurface in ARGB32 format. + + Returns: + A pyvips.Image in RGBA format, or None on failure. + """ + if not surface: + return None + + try: + h = surface.get_height() + w = surface.get_width() + + vips_image = pyvips.Image.new_from_memory( + surface.get_data(), w, h, 4, "uchar" + ) + + b = vips_image[0] + g = vips_image[1] + r = vips_image[2] + a = vips_image[3] + + return r.bandjoin([g, b, a]) + + except pyvips.Error as e: + logger.error(f"Failed to convert Cairo surface to pyvips: {e}") + return None + + +def load_svg_with_fallback( + svg_data: bytes, width: int | None = None, height: int | None = None +) -> pyvips.Image | None: + """ + Loads SVG data using either libvips or Cairo fallback. + + This is a convenience function that automatically selects the appropriate + rendering path based on available capabilities. + + Args: + svg_data: Raw SVG bytes. + width: Target width (required for Cairo fallback). + height: Target height (required for Cairo fallback). + + Returns: + A pyvips.Image, or None on failure. + """ + if SVG_LOAD_AVAILABLE: + try: + return pyvips.Image.svgload_buffer(svg_data) + except pyvips.Error as e: + logger.error(f"Failed to load SVG with pyvips: {e}") + return None + else: + if width is None or height is None: + logger.error("Cairo fallback requires width and height parameters") + return None + + surface = render_svg_to_cairo(svg_data, width, height) + if surface: + return cairo_surface_to_vips(surface) + return None + + +if not SVG_LOAD_AVAILABLE: + logger.warning( + "pyvips svgload_buffer not available. " + "Using Cairo/Rsvg fallback for SVG rendering." + ) diff --git a/rayforge/image/svg/svg_trace.py b/rayforge/image/svg/svg_trace.py new file mode 100644 index 000000000..cc94a5cbf --- /dev/null +++ b/rayforge/image/svg/svg_trace.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import logging +import math +from gettext import gettext as _ +from typing import Any, ClassVar + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Rect + +from ...core.vectorization_spec import ( + TraceSpec, + VectorizationSpec, +) +from .. import util +from ..base_importer import ( + ImporterFeature, +) +from ..engine import NormalizationEngine +from ..structures import LayerGeometry, ParsingResult, VectorizationResult +from ..tracing import VTRACER_PIXEL_LIMIT, trace_surface +from .renderer import SVG_RENDERER +from .svg_base import SvgImporterBase +from .svgutil import trim_svg + +logger = logging.getLogger(__name__) + + +class SvgTraceImporter(SvgImporterBase): + """ + Imports SVG files by rendering them to a high-resolution bitmap and then + tracing the result. + """ + + label = "SVG (Trace Strategy)" + mime_types = () + extensions = () + features: ClassVar[set[ImporterFeature]] = {ImporterFeature.BITMAP_TRACING} + + def __init__(self, data: bytes, source_file: Any | None = None): + super().__init__(data, source_file) + self.traced_artefacts: dict[str, Any] = {} + + def _analytical_trim(self, data: bytes) -> bytes: + return trim_svg(data) + + def parse(self) -> ParsingResult | None: + # 1. Use base class to get dimensions and units + basics = self._calculate_parsing_basics() + if not basics: + # Errors already added by basics + return None + + # Unpack + ( + document_bounds, + unit_to_mm, + untrimmed_document_bounds, + world_frame, + ) = basics + + # Create a temporary result to generate the background transform + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=unit_to_mm, + is_y_down=True, + layers=[], + untrimmed_document_bounds=untrimmed_document_bounds, + world_frame_of_reference=world_frame, + background_world_transform=None, # type: ignore + ) + + bg_item = NormalizationEngine.calculate_layout_item( + document_bounds, temp_result + ) + + # 2. Define single layer (Trace-specific logic) + # For tracing, we treat the whole content as one "layer" + layer_geometries = [ + LayerGeometry( + layer_id="__default__", + name="Traced Content", + content_bounds=document_bounds, + ) + ] + + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=unit_to_mm, + is_y_down=True, + layers=layer_geometries, + untrimmed_document_bounds=untrimmed_document_bounds, + geometry_is_relative_to_bounds=True, + is_cropped_to_content=True, + world_frame_of_reference=world_frame, + background_world_transform=bg_item.world_matrix, + ) + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + if not isinstance(spec, TraceSpec): + raise TypeError("SvgTraceImporter requires a TraceSpec") + + self.traced_artefacts = {} + + # Use the TRIMMED data bounds to determine render size. + document_bounds = parse_result.document_bounds + native_unit_to_mm = parse_result.native_unit_to_mm + + w_native = document_bounds[2] + h_native = document_bounds[3] + w_mm = w_native * native_unit_to_mm + h_mm = h_native * native_unit_to_mm + + if w_mm <= 0 or h_mm <= 0: + logger.warning("Cannot trace SVG: failed to determine size.") + self.add_warning( + _("Cannot determine valid dimensions for tracing.") + ) + return VectorizationResult({}, parse_result) + + aspect = w_mm / h_mm if h_mm > 0 else 1.0 + TARGET_DIM = math.sqrt(VTRACER_PIXEL_LIMIT) + + if aspect >= 1.0: + w_px = int(TARGET_DIM) + h_px = int(TARGET_DIM / aspect) + else: + h_px = int(TARGET_DIM) + w_px = int(TARGET_DIM * aspect) + w_px, h_px = max(1, w_px), max(1, h_px) + + # Render the TRIMMED data to capture the correct content area. + data_to_render = self.trimmed_data or self.raw_data + vips_image = SVG_RENDERER.render_base_image( + data_to_render, width=w_px, height=h_px + ) + + if not vips_image: + logger.error("Failed to render SVG to vips image for tracing.") + self.add_error(_("Failed to rasterize SVG for tracing.")) + return VectorizationResult({}, parse_result) + + if w_mm > 0 and h_mm > 0: + xres = w_px / w_mm + yres = h_px / h_mm + vips_image = vips_image.copy(xres=xres, yres=yres) + + # Store the PNG data only for the duration of this import process. + # DO NOT attach it to the final SourceAsset. + png_data = vips_image.pngsave_buffer() + self.traced_artefacts["png_data"] = png_data + self.traced_artefacts["width_px"] = vips_image.width + self.traced_artefacts["height_px"] = vips_image.height + + normalized_vips = util.normalize_to_rgba(vips_image) + if not normalized_vips: + self.add_error(_("Failed to normalize image data.")) + return VectorizationResult({}, parse_result) + + surface = util.vips_rgba_to_cairo_surface(normalized_vips) + + geometries = trace_surface(surface, spec) + + combined_geo = Geometry() + if geometries: + for geo in geometries: + geo.close_gaps() + combined_geo.extend(geo) + + rendered_width = vips_image.width + rendered_height = vips_image.height + mm_per_px_x, mm_per_px_y = util.get_mm_per_pixel(vips_image) + + # document_bounds contains the offset (vb_x, vb_y) in native units. + offset_x_native = document_bounds[0] + offset_y_native = document_bounds[1] + + offset_x_mm = offset_x_native * native_unit_to_mm + offset_y_mm = offset_y_native * native_unit_to_mm + offset_x_px = offset_x_mm / mm_per_px_x if mm_per_px_x > 0 else 0 + offset_y_px = offset_y_mm / mm_per_px_y if mm_per_px_y > 0 else 0 + + # Shift the geometry to match the original world position + if not combined_geo.is_empty(): + shift_matrix = Matrix.translation(offset_x_px, offset_y_px) + combined_geo.transform(shift_matrix) + + trace_document_bounds = ( + offset_x_px, + offset_y_px, + float(rendered_width), + float(rendered_height), + ) + + trace_untrimmed_bounds: Rect | None = None + if parse_result.untrimmed_document_bounds: + u_native = parse_result.untrimmed_document_bounds + # Convert untrimmed native size to trace pixels + u_x_mm = u_native[0] * native_unit_to_mm + u_y_mm = u_native[1] * native_unit_to_mm + u_w_mm = u_native[2] * native_unit_to_mm + u_h_mm = u_native[3] * native_unit_to_mm + u_x_px = u_x_mm / mm_per_px_x if mm_per_px_x > 0 else 0 + u_y_px = u_y_mm / mm_per_px_y if mm_per_px_y > 0 else 0 + u_w_px = u_w_mm / mm_per_px_x if mm_per_px_x > 0 else 0 + u_h_px = u_h_mm / mm_per_px_y if mm_per_px_y > 0 else 0 + trace_untrimmed_bounds = (u_x_px, u_y_px, u_w_px, u_h_px) + + # Calculate authoritative world frame for the traced image + t_ref_bounds = trace_untrimmed_bounds or trace_document_bounds + t_x, _t_y, t_w, t_h = t_ref_bounds + t_w_mm = t_w * mm_per_px_x + t_h_mm = t_h * mm_per_px_x + t_x_mm = t_x * mm_per_px_x + t_y_mm = 0.0 # The world frame's origin is at its bottom-left. + trace_world_frame = (t_x_mm, t_y_mm, t_w_mm, t_h_mm) + + # Create temporary result to calculate background transform + temp_trace_result = ParsingResult( + document_bounds=trace_document_bounds, + native_unit_to_mm=mm_per_px_x, + is_y_down=True, + layers=[], + untrimmed_document_bounds=trace_untrimmed_bounds, + world_frame_of_reference=trace_world_frame, + background_world_transform=None, # type: ignore + ) + + bg_item_trace = NormalizationEngine.calculate_layout_item( + trace_document_bounds, temp_trace_result + ) + + trace_parse_result = ParsingResult( + document_bounds=trace_document_bounds, + native_unit_to_mm=mm_per_px_x, + is_y_down=True, + layers=[], + geometry_is_relative_to_bounds=False, + untrimmed_document_bounds=trace_untrimmed_bounds, + world_frame_of_reference=trace_world_frame, + background_world_transform=bg_item_trace.world_matrix, + ) + + return VectorizationResult( + geometries_by_layer={None: combined_geo}, + source_parse_result=trace_parse_result, + ) diff --git a/rayforge/image/svg/svg_vector.py b/rayforge/image/svg/svg_vector.py new file mode 100644 index 000000000..3dd1afa7f --- /dev/null +++ b/rayforge/image/svg/svg_vector.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import logging +from typing import ClassVar + +from raygeo.geo import Geometry +from raygeo.svg import ( + svg_string_to_geometries, + svg_string_to_geometries_by_color, + svg_string_to_geometry_by_color, + svg_string_to_geometry_by_layer, +) +from raygeo.svg.color import ColorAttr + +from ...core.vectorization_spec import ( + LayerSource, + PassthroughSpec, + VectorizationSpec, +) +from ..base_importer import ImporterFeature +from ..engine import NormalizationEngine +from ..structures import ( + LayerGeometry, + ParsingResult, + VectorizationResult, +) +from .svg_base import SvgImporterBase +from .svgutil import NO_COLOR_KEY, extract_layer_manifest + +logger = logging.getLogger(__name__) + + +class SvgVectorImporter(SvgImporterBase): + """ + Imports SVG files by parsing vector paths directly. + """ + + label = "SVG (Vector Strategy)" + mime_types = () + extensions = () + features: ClassVar[set[ImporterFeature]] = { + ImporterFeature.DIRECT_VECTOR, + ImporterFeature.LAYER_SELECTION, + ImporterFeature.COLOR_LAYERS, + } + + def parse(self) -> ParsingResult | None: + # 1. Use base class to get dimensions, units, and the parsed SVG object + basics = self._calculate_parsing_basics() + if not basics: + return None + + # Unpack. Both trimmed and untrimmed bounds are now available. + ( + document_bounds, + unit_to_mm, + untrimmed_document_bounds, + world_frame, + ) = basics + + # 2. Extract layer geometry from trimmed data. + assert self.trimmed_data is not None + svg_str = self.trimmed_data.decode("utf-8") + spec = self._vectorization_spec + if ( + isinstance(spec, PassthroughSpec) + and spec.layer_source == LayerSource.COLORS + ): + use_color_layers = True + color_attr = spec.color_attr + else: + use_color_layers = False + color_attr = ColorAttr.ANY + + # 3. Build layer geometries with names from manifest. + if use_color_layers: + layer_geometries = self._layer_geometries_by_color( + svg_str, color_attr + ) + else: + layer_geometries = self._layer_geometries_by_svg(svg_str) + + # Create temporary result to calculate background transform + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=unit_to_mm, + is_y_down=True, + layers=[], + untrimmed_document_bounds=untrimmed_document_bounds, + 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=unit_to_mm, + is_y_down=True, + layers=layer_geometries, + untrimmed_document_bounds=untrimmed_document_bounds, + geometry_is_relative_to_bounds=False, + is_cropped_to_content=True, + world_frame_of_reference=world_frame, + background_world_transform=bg_item.world_matrix, + ) + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + """ + Extracts vector geometry from SVG for direct import. + """ + if not isinstance(spec, PassthroughSpec): + spec = PassthroughSpec() + + assert self.trimmed_data is not None + + svg_str = self.trimmed_data.decode("utf-8") + all_layer_ids = [layer.layer_id for layer in parse_result.layers] + + target_layer_ids = ( + spec.active_layer_ids + if spec.active_layer_ids is not None + else all_layer_ids + ) + + geometries_by_layer: dict[str | None, Geometry] = {} + if spec.layer_source == LayerSource.COLORS: + # Extract one merged geometry per resolved color. + buckets_raw = svg_string_to_geometry_by_color( + svg_str, 1.0, 1.0, spec.color_attr + ) + for color_key, geo in buckets_raw: + if color_key in target_layer_ids and not geo.is_empty(): + geometries_by_layer[color_key] = geo + else: + # Extract per-layer geometries via raygeo (already in user space). + layers_raw = svg_string_to_geometry_by_layer(svg_str, 1.0, 1.0) + for layer_id, geo in layers_raw: + if layer_id in target_layer_ids and not geo.is_empty(): + geometries_by_layer[layer_id] = geo + + # If no layers found, fall back to the whole SVG. + if not geometries_by_layer: + logger.debug( + "No layer-specific geometry found, parsing entire SVG as " + "default." + ) + geos = svg_string_to_geometries(svg_str, 1.0, 1.0) + if geos: + geo = Geometry() + for g in geos: + geo.extend(g) + if not geo.is_empty(): + geometries_by_layer[None] = geo + + return VectorizationResult( + geometries_by_layer=geometries_by_layer, + source_parse_result=parse_result, + ) + + def _layer_geometries_by_svg(self, svg_str: str) -> list[LayerGeometry]: + """Builds LayerGeometry entries from top-level SVG layer groups.""" + layers_raw = svg_string_to_geometry_by_layer(svg_str, 1.0, 1.0) + assert self.trimmed_data is not None + layer_manifest = extract_layer_manifest(self.trimmed_data) + layer_names_by_id = { + layer["id"]: layer["name"] for layer in layer_manifest + } + + layer_geometries: list[LayerGeometry] = [] + for layer_id, geo in layers_raw: + if not geo.is_empty(): + layer_name = layer_names_by_id.get(layer_id, layer_id) + layer_geometries.append( + self._layer_geometry(layer_id, layer_name, geo) + ) + return layer_geometries + + def _layer_geometries_by_color( + self, svg_str: str, color_attr: ColorAttr + ) -> list[LayerGeometry]: + """Builds LayerGeometry entries from resolved SVG colors.""" + buckets_raw = svg_string_to_geometries_by_color( + svg_str, 1.0, 1.0, color_attr + ) + layer_geometries: list[LayerGeometry] = [] + for color_key, geos in buckets_raw: + geo = Geometry() + for g in geos: + geo.extend(g) + if not geo.is_empty(): + layer_geometries.append( + self._layer_geometry( + color_key, + f"Color {color_key}", + geo, + color=( + None if color_key == NO_COLOR_KEY else color_key + ), + ) + ) + return layer_geometries + + def _layer_geometry( + self, + layer_id: str, + name: str, + geo: Geometry, + color: str | None = None, + ) -> LayerGeometry: + min_x, min_y, max_x, max_y = geo.rect() + w = max_x - min_x + h = max_y - min_y + return LayerGeometry( + layer_id=layer_id, + name=name, + content_bounds=(min_x, min_y, w, h), + color=color, + ) diff --git a/rayforge/image/svg/svgutil.py b/rayforge/image/svg/svgutil.py new file mode 100644 index 000000000..9514a07b8 --- /dev/null +++ b/rayforge/image/svg/svgutil.py @@ -0,0 +1,406 @@ +import logging +import warnings +from typing import Any +from xml.etree import ElementTree as ET + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from raygeo.geo.types import Rect +from raygeo.svg import svg_string_to_geometries_by_color +from raygeo.svg.color import ColorAttr +from raygeo.svg.length import parse_svg_length, svg_length_to_mm +from raygeo.svg.metadata import extract_svg_metadata + +from .svg_fallback import ( + SVG_LOAD_AVAILABLE, + cairo_surface_to_vips, + render_svg_to_cairo, +) + +logger = logging.getLogger(__name__) + +# A standard fallback conversion factor for pixel units. Corresponds to 96 DPI. +PPI: float = 96.0 +"""Standard Pixels Per Inch, used for fallback conversions.""" + +MM_PER_PX: float = 25.4 / PPI +"""Conversion factor for pixels to millimeters, based on 96 PPI.""" + +# Raygeo bucket key for shapes whose chosen color attribute is `none` or +# unset. These shapes have no color but still contain real geometry. +NO_COLOR_KEY: str = "_no_color" + +INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape" +SVG_NS = "http://www.w3.org/2000/svg" + +# Tags that represent vector geometry +SHAPE_TAGS = { + "path", + "rect", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "text", + "image", +} + + +# Register namespaces to prevent ElementTree from mangling them (ns0:tags) +try: + ET.register_namespace("", SVG_NS) + ET.register_namespace("inkscape", INKSCAPE_NS) + ET.register_namespace( + "sodipodi", "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + ) + ET.register_namespace("xlink", "http://www.w3.org/1999/xlink") +except ValueError: + pass # Best effort registration + + +def _get_margins_from_data( + data: bytes, +) -> Rect: + """ + Calculates content margins as ratios from raw SVG data using pyvips. + Returns (left, top, right, bottom) margins as fractions of total size. + """ + if not data: + return 0.0, 0.0, 0.0, 0.0 + + try: + root = ET.fromstring(data) + + # 1. Get original dimensions to determine aspect ratio. + w_str = root.get("width") + h_str = root.get("height") + if not w_str or not h_str: + return 0.0, 0.0, 0.0, 0.0 # Cannot determine aspect ratio. + + orig_w, _w_unit = parse_svg_length(w_str) + orig_h, _h_unit = parse_svg_length(h_str) + # Normalise to numeric values (unit suffix is handled separately) + orig_w = float(orig_w) + orig_h = float(orig_h) + + if orig_w <= 0 or orig_h <= 0: + return 0.0, 0.0, 0.0, 0.0 + + aspect_ratio = orig_w / orig_h + + # 2. Calculate proportional dimensions for rendering. + measurement_size = 4096.0 # Use a larger size for better precision + if aspect_ratio > 1: # Wider than tall + render_w = measurement_size + render_h = measurement_size / aspect_ratio + else: # Taller than wide or square + render_h = measurement_size + render_w = measurement_size * aspect_ratio + + # 3. Modify SVG for a large, proportional render. + root.set("width", f"{render_w}px") + root.set("height", f"{render_h}px") + root.set("preserveAspectRatio", "none") + + # Create viewBox if it's missing, which is crucial for the renderer + # to have a coordinate system. + if not root.get("viewBox"): + root.set("viewBox", f"0 0 {orig_w} {orig_h}") + + # Add overflow:visible to ensure all geometry, including parts + # defined by control points outside the viewBox, is rendered for + # accurate margin calculation. + root.set("style", "overflow: visible") + + if SVG_LOAD_AVAILABLE: + img = pyvips.Image.svgload_buffer(ET.tostring(root)) + else: + surface = render_svg_to_cairo( + ET.tostring(root), int(render_w), int(render_h) + ) + if not surface: + return 0.0, 0.0, 0.0, 0.0 + img = cairo_surface_to_vips(surface) + if not img: + return 0.0, 0.0, 0.0, 0.0 + + if img.bands < 4: + img = img.bandjoin(255) # Ensure alpha channel for trimming + + # Create a sharp, binary mask from the alpha channel. + alpha = img[3] + mask = alpha > 0 + # Explicitly tell find_trim that the background color is 0. + left, top, w, h = mask.find_trim(background=0) + + if w == 0 or h == 0: + # No content found, so no margins + return 0.0, 0.0, 0.0, 0.0 + + # 4. Calculate margins as a ratio of the PROPORTIONAL render size. + return ( + left / render_w, + top / render_h, + (render_w - (left + w)) / render_w, + (render_h - (top + h)) / render_h, + ) + except (pyvips.Error, ET.ParseError, ValueError): + # Return zero margins if SVG is invalid or processing fails + return 0.0, 0.0, 0.0, 0.0 + + +def trim_svg(data: bytes) -> bytes: + """ + Crops an SVG to its content by adjusting the viewBox attribute. + + This function renders the SVG to a bitmap, finds the bounding box of the + non-transparent content, and calculates new viewBox and dimensions to + effectively trim the empty space. The original aspect ratio of the + content is preserved. + + Args: + data: The raw SVG data in bytes. + + Returns: + The raw bytes of the modified, trimmed SVG. Returns original data if + no trimming is necessary or if the SVG is invalid. + """ + margins = _get_margins_from_data(data) + # If there's nothing to trim (within a small tolerance), return the + # original data. + if all(m < 1e-5 for m in margins): + return data + + left, top, right, bottom = margins + + try: + root = ET.fromstring(data) + + w_str = root.get("width") + h_str = root.get("height") + if not w_str or not h_str: + return data # Cannot proceed without dimensions + + w_val, w_unit = parse_svg_length(w_str) + h_val, h_unit = parse_svg_length(h_str) + + vb_str = root.get("viewBox") + if vb_str: + vb_x, vb_y, vb_w, vb_h = map(float, vb_str.split()) + else: + # If no viewBox, it's implicitly '0 0 width height' + vb_x, vb_y, vb_w, vb_h = 0, 0, w_val, h_val + + # Calculate new viewBox based on margins + new_vb_x = vb_x + (left * vb_w) + new_vb_y = vb_y + (top * vb_h) + new_vb_w = vb_w * (1 - left - right) + new_vb_h = vb_h * (1 - top - bottom) + + if new_vb_w <= 0 or new_vb_h <= 0: + return data # Avoid creating an invalid SVG + + root.set("viewBox", f"{new_vb_x} {new_vb_y} {new_vb_w} {new_vb_h}") + + # Update width and height to reflect the trimmed size + new_w_val = w_val * (1 - left - right) + new_h_val = h_val * (1 - top - bottom) + root.set("width", f"{new_w_val}{w_unit or 'px'}") + root.set("height", f"{new_h_val}{h_unit or 'px'}") + + # This attribute forces non-proportional scaling and causes issues + # when rendering filtered layers. It's safer to rely on librsvg's + # default proportional scaling. + if "preserveAspectRatio" in root.attrib: + del root.attrib["preserveAspectRatio"] + + return ET.tostring(root) + + except (ET.ParseError, ValueError): + return data + + +def is_unitless_svg(data: bytes) -> bool: + """ + Returns True if the SVG's dimensions have no physical unit, meaning the + physical size depends on a DPI assumption. This is the case when: + + - width/height attributes are missing (viewBox-only), or + - width/height use no unit (bare number) or the "px" unit. + """ + if not data: + return False + try: + meta = extract_svg_metadata(data.decode("utf-8")) + if meta.width is None and meta.height is None: + return True + return meta.width_unit in ("", "px") and meta.height_unit in ("", "px") + except (ValueError, ET.ParseError): + return False + + +def get_natural_size( + data: bytes, ppi: float = PPI +) -> tuple[float, float] | None: + """ + Analyzes raw SVG data to extract its natural, untrimmed dimensions in mm. + + Args: + data: The raw SVG data in bytes. + ppi: Pixels per inch used for converting unitless/px values to mm. + + Returns: + A tuple of (width_mm, height_mm), or None if dimensions cannot be + determined. + """ + if not data: + return None + + try: + meta = extract_svg_metadata(data.decode("utf-8")) + if meta.width is None or meta.height is None: + return None + + width_mm = svg_length_to_mm(f"{meta.width}{meta.width_unit}", dpi=ppi) + height_mm = svg_length_to_mm( + f"{meta.height}{meta.height_unit}", dpi=ppi + ) + return width_mm, height_mm + + except (ValueError, ET.ParseError): + return None + + +def _get_local_tag_name(element: ET.Element) -> str: + """Robustly gets the local tag name, ignoring any namespace.""" + return element.tag.rsplit("}", 1)[-1] + + +def hex_color_to_rgb(color: str) -> tuple[float, float, float]: + """ + Converts a "#rrggbb" hex color string to an RGB tuple in the 0-1 range. + """ + value = color.lstrip("#") + r = int(value[0:2], 16) / 255.0 + g = int(value[2:4], 16) / 255.0 + b = int(value[4:6], 16) / 255.0 + return r, g, b + + +def extract_color_manifest( + data: bytes, color_attr: ColorAttr = ColorAttr.ANY +) -> list[dict[str, Any]]: + """ + Parses the SVG to find distinct colors, treating each as a layer. + + Buckets shapes by their resolved color attribute (`fill`, `stroke`, + `fill_else_stroke`, or `any`) using raygeo, and counts the number of + geometric elements per color. In `any` mode a shape whose fill differs + from its stroke lands in two buckets, one per color. + + Args: + data: Raw SVG data in bytes. + color_attr: The color attribute to bucket by. + + Returns: + A list of layer dicts with keys "id", "name", "count" and "color". + """ + if not data: + return [] + + try: + buckets = svg_string_to_geometries_by_color( + data.decode("utf-8"), 1.0, 1.0, color_attr + ) + except (ValueError, ET.ParseError): + return [] + + layers = [] + for color_key, geos in buckets: + count = len(geos) + color = None if color_key == NO_COLOR_KEY else color_key + layers.append( + { + "id": color_key, + "name": f"Color {color_key}", + "count": count, + "color": color, + } + ) + logger.debug(f"Found color layer: ID='{color_key}', Count={count}") + return layers + + +def extract_layer_manifest(data: bytes) -> list[dict[str, Any]]: + """ + Parses the SVG to find top-level groups with IDs, treating them as layers. + Also counts the number of geometric elements in each layer. + """ + if not data: + return [] + + layers = [] + logger.debug("--- Starting SVG Layer Extraction ---") + try: + root = ET.fromstring(data) + for child in root: + tag = _get_local_tag_name(child) + layer_id = child.get("id") + + if tag == "g" and layer_id: + label = child.get(f"{{{INKSCAPE_NS}}}label") or layer_id + + # Count visual elements recursively to detect empty layers + feature_count = 0 + for elem in child.iter(): + if _get_local_tag_name(elem) in SHAPE_TAGS: + feature_count += 1 + + layers.append( + { + "id": layer_id, + "name": label, + "count": feature_count, + } + ) + logger.debug( + f"Found layer: ID='{layer_id}', " + f"Name='{label}', Count={feature_count}" + ) + except ET.ParseError as e: + logger.error(f"Failed to parse SVG for layer extraction: {e}") + return [] + + return layers + + +def filter_svg_layers(data: bytes, visible_layer_ids: list[str]) -> bytes: + """ + Returns a modified SVG with only specified top-level groups visible. + """ + if not data: + return b"" + + try: + root = ET.fromstring(data) + elements_to_remove = [] + + for child in root: + tag = _get_local_tag_name(child) + if tag == "g": + layer_id = child.get("id") + # If ID exists AND it is NOT in the visible list, remove it. + if layer_id and layer_id not in visible_layer_ids: + elements_to_remove.append(child) + + for elem in elements_to_remove: + root.remove(elem) + + # Registering namespaces at module level helps, but ET.tostring + # needs to know we want to preserve the environment. + return ET.tostring(root, encoding="utf-8") + except ET.ParseError: + return data diff --git a/rayforge/image/tracing.py b/rayforge/image/tracing.py new file mode 100644 index 000000000..74a5b86e5 --- /dev/null +++ b/rayforge/image/tracing.py @@ -0,0 +1,614 @@ +import logging +import sys +import threading +from enum import Enum + +import cairo +import cv2 +import numpy as np +import vtracer +from raygeo.geo import Geometry, Matrix +from raygeo.image.preprocess import denoise_binary, grayscale_to_binary +from raygeo.svg import svg_string_to_geometries + +from ..core.vectorization_spec import TraceSpec, VectorizationSpec +from .hull import get_enclosing_hull, get_hulls_from_image +from .util.srgb import resize_linear_nd + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +_vtracer_lock = threading.Lock() + +BORDER_SIZE = 2 +# A safety limit to prevent processing pathologically complex images. +# If the generates more paths than this, we fall back to convex hulls. +MAX_VECTORS_LIMIT = 25000 +# A pixel count limit to prevent integer overflows in the underlying vtracer +# native library. +VTRACER_PIXEL_LIMIT = 1_220_000 +# On Windows, we use a dedicated thread with increased stack size (8MB) +# allowing us to maintain high resolution without stack overflows. +VTRACER_WINDOWS_SAFE_LIMIT = 1_000_000 + + +class ColorMode(Enum): + BINARY = "binary" + COLOR = "color" + + +def _remove_border_offset(geometries: list[Geometry]) -> None: + """Remove the BORDER_SIZE offset added to vtracer input.""" + m = np.eye(4, dtype=np.float64) + m[0, 3] = -BORDER_SIZE + m[1, 3] = -BORDER_SIZE + for g in geometries: + g.transform(m) + + +def _get_image_from_surface( + surface: cairo.ImageSurface, +) -> tuple[np.ndarray, int]: + """Extracts image data from a Cairo surface.""" + logger.debug("Entering _get_image_from_surface") + surface_format = surface.get_format() + channels = 4 if surface_format == cairo.FORMAT_ARGB32 else 3 + width, height = surface.get_width(), surface.get_height() + stride = surface.get_stride() + buf = surface.get_data() + + # Cairo surfaces may have padding at the end of each row (stride). + # We must attempt to respect the stride, but fall back to dense reading + # if the buffer size doesn't match the stride expectation (common with + # manually created surfaces). + try: + img_data = np.frombuffer(buf, dtype=np.uint8) + img_stride_view = img_data.reshape(height, stride) + # Slice out the valid bytes: width * 4 bytes (ARGB32 is 4 bytes/px) + valid_row_bytes = width * 4 + img = ( + img_stride_view[:, :valid_row_bytes] + .reshape(height, width, 4)[:, :, :channels] + .copy() + ) + except ValueError: + # Fallback for cases where buffer is packed dense (no stride padding) + # but Cairo reports a default padded stride. + img = ( + np.frombuffer(buf, dtype=np.uint8) + .reshape(height, width, channels) + .copy() + ) + + return img, channels + + +def _get_boolean_image_from_color( + img: np.ndarray, + channels: int, + vectorization_spec: VectorizationSpec | None = None, +) -> np.ndarray: + """ + Creates a boolean image from color channels, adding a white border and + using a specified threshold or Otsu's method. + """ + logger.debug("Entering _get_boolean_image_from_color") + + # If the input is already a single channel (Alpha), treat it as grayscale + if len(img.shape) == 2: + gray = img + else: + # It's BGR or BGRA, convert to grayscale first + gray = cv2.cvtColor( + img, + cv2.COLOR_BGRA2GRAY if channels == 4 else cv2.COLOR_BGR2GRAY, + ) + + # Add border to the grayscale image directly. + # value=[255] ensures a white border on the single-channel image + # and satisfies type checkers expecting a Sequence (Scalar). + gray_with_border = cv2.copyMakeBorder( + gray, + BORDER_SIZE, + BORDER_SIZE, + BORDER_SIZE, + BORDER_SIZE, + cv2.BORDER_CONSTANT, + value=[255], + ) + + spec = vectorization_spec + if not isinstance(spec, TraceSpec): + spec = TraceSpec() + + binary = grayscale_to_binary( + gray_with_border.astype(np.uint8), + threshold=spec.threshold, + invert=spec.invert, + auto_threshold=spec.auto_threshold, + ) + return binary > 0 + + +def prepare_surface( + surface: cairo.ImageSurface, + vectorization_spec: VectorizationSpec | None = None, +) -> np.ndarray: + """ + Prepares a Cairo surface for tracing. + """ + logger.debug("Entering prepare_surface") + img, channels = _get_image_from_surface(surface) + + # Handling Transparency for Vectorization: + # If the image has an alpha channel and contains actual transparency + # (min alpha < 250), we assume the user wants to trace the SHAPE of the + # opaque object, regardless of its color. + # + # In this case, we ignore the RGB colors (which might be white on a + # white background) and generate the boolean image from the Alpha channel. + use_alpha_channel = False + if channels == 4 and surface.get_format() == cairo.FORMAT_ARGB32: + alpha = img[:, :, 3] + if np.min(alpha) < 250: + use_alpha_channel = True + + if use_alpha_channel: + # Extract Alpha + alpha = img[:, :, 3] + # We invert Alpha so that Opaque (255) becomes Black (0) and + # Transparent (0) becomes White (255). This matches the + # "Ink on Paper" expectation of the thresholding logic. + img_for_threshold = 255 - alpha + # Effectively single channel + channels = 1 + else: + # Standard Color/B&W image logic. + # If there is an alpha channel but it's fully opaque, we just use RGB. + if channels == 4: + # Drop alpha channel if it exists but isn't used for transparency + img_for_threshold = img[:, :, :3] + channels = 3 + else: + img_for_threshold = img + + boolean_image = _get_boolean_image_from_color( + img_for_threshold, channels, vectorization_spec + ) + return denoise_binary(boolean_image.astype(np.uint8)) > 0 + + +def _fallback_to_enclosing_hull( + cleaned_boolean_image: np.ndarray, + pixels_per_mm_x: float, + pixels_per_mm_y: float, + surface_height: int, +) -> list[Geometry]: + """Generates an enclosing hull as a fallback.""" + logger.debug("Entering _fallback_to_enclosing_hull") + geo = get_enclosing_hull( + cleaned_boolean_image, + pixels_per_mm_x, + pixels_per_mm_y, + surface_height, + BORDER_SIZE, + ) + return [geo] if geo else [] + + +def _encode_image_to_buffer( + cleaned_boolean_image: np.ndarray, +) -> tuple[bool, bytes, str]: + """Encodes a boolean image to BMP bytes (robust format for vtracer).""" + logger.debug("Entering _encode_image_to_buffer") + img_uint8 = (~cleaned_boolean_image * 255).astype(np.uint8) + # Use BMP to minimize decoding overhead (stack usage) in vtracer on Windows + # and avoid potential library mismatches with other formats. + success, buffer = cv2.imencode(".bmp", img_uint8) + if not success: + logger.error("Failed to encode boolean image to BMP for vtracer.") + return False, b"", "" + return True, buffer.tobytes(), "bmp" + + +def _convert_buffer_to_svg_with_vtracer( + img_bytes: bytes, img_format: str, colormode: ColorMode = ColorMode.BINARY +) -> str: + """Converts image bytes to SVG string using vtracer. + + Args: + img_bytes: Image data as bytes + img_format: Image format (e.g., "bmp") + colormode: ColorMode.BINARY or ColorMode.COLOR + """ + logger.debug( + f"Entering _convert_buffer_to_svg_with_vtracer ({colormode.value})" + ) + + # Arguments for vtracer. We use POSITIONAL arguments here to bypass + # a crash in the PyO3 keyword argument parser (extract_arguments_fastcall) + # occurring on Windows with Python 3.14 + vtracer 0.6.11 (old PyO3). + # Signature: + # convert_raw_image_to_svg(bytes, fmt, colormode, hierarchical, mode, + # speckle, color_prec, layer_diff, corner, + # length, max_iter, splice, path_prec) + if colormode == ColorMode.COLOR: + args = ( + img_bytes, + img_format, + "color", + "stacked", + "polygon", + 2, + 8, + 8, + 90, + 4.0, + ) + else: + args = ( + img_bytes, + img_format, + "binary", + "stacked", + "polygon", + 0, + 6, + 16, + 60, + 3.5, + ) + + def _call_native(): + return vtracer.convert_raw_image_to_svg(*args) + + with _vtracer_lock: + if sys.platform == "win32": + # Windows has a small default stack (1MB). vtracer recursive + # algorithms can exceed this on large images (Access Violation + # / Stack Overflow). + # Workaround: Run in a separate thread with an increased stack + # size (8MB). + result = [None] + error = [None] + + def thread_target(): + try: + result[0] = _call_native() + except Exception as e: # noqa: BLE001 - native call boundary + error[0] = e + + # 8MB stack size (matching typical Linux default) + stack_size = 8 * 1024 * 1024 + + # Global stack_size setting affects new threads only. + # We must be careful not to affect other parts of the application + # indefinitely. + try: + old_stack = threading.stack_size(stack_size) + t = threading.Thread(target=thread_target) + t.start() + # Restore immediately + threading.stack_size(old_stack) + t.join() + except (ValueError, OSError, RuntimeError) as e: + # If stack adjustment fails (e.g. platform doesn't support it), + # try direct call and hope for the best. + logger.warning(f"Could not adjust stack size for vtracer: {e}") + return _call_native() + + if error[0]: + raise error[0] + return result[0] + else: + return _call_native() + + +def _extract_svg_from_raw_output(raw_output: str) -> str: + """Extracts valid SVG content from vtracer's raw output.""" + logger.debug("Entering _extract_svg_from_raw_output") + try: + start = raw_output.index("") + len("") + return raw_output[start:end] + except ValueError: + logger.warning("Could not find valid tags in vtracer output.") + raise + + +def _fallback_to_hulls_from_image( + cleaned_boolean_image: np.ndarray, + surface_height: int, +) -> list[Geometry]: + """Generates convex hulls from an image as a fallback.""" + logger.debug("Entering _fallback_to_hulls_from_image") + return get_hulls_from_image( + cleaned_boolean_image, + 1.0, + 1.0, + surface_height, + BORDER_SIZE, + ) + + +def _handle_oversized_image( + image: np.ndarray, original_width: int, original_height: int +) -> tuple[np.ndarray, float, float, int]: + """ + Checks if an image exceeds the pixel limit and, if so, downscales it, + returning the new image, upscaling factors, and new content height. + """ + h_bordered, w_bordered = image.shape + pixel_limit = ( + VTRACER_WINDOWS_SAFE_LIMIT + if sys.platform == "win32" + else VTRACER_PIXEL_LIMIT + ) + if h_bordered * w_bordered <= pixel_limit: + return image, 1.0, 1.0, original_height + + scale = (pixel_limit / (h_bordered * w_bordered)) ** 0.5 + new_w = max(1, int(w_bordered * scale)) + new_h = max(1, int(h_bordered * scale)) + + # Ensure dimensions are multiples of 4 for better memory alignment + new_w = (new_w // 4) * 4 + new_h = (new_h // 4) * 4 + new_w = max(4, new_w) + new_h = max(4, new_h) + + logger.warning( + f"Image is too large for vtracer ({w_bordered}x{h_bordered}px). " + f"Downscaling to {new_w}x{new_h}px to prevent overflow." + ) + + img_uint8 = image.astype(np.uint8) * 255 + resized_img = resize_linear_nd(img_uint8, (new_w, new_h)) + image_to_trace = resized_img > 127 + + upscale_x, upscale_y = 1.0, 1.0 + new_content_w = new_w - (2 * BORDER_SIZE) + new_content_h = new_h - (2 * BORDER_SIZE) + if new_content_w > 0 and new_content_h > 0: + upscale_x = original_width / new_content_w + upscale_y = original_height / new_content_h + + return image_to_trace, upscale_x, upscale_y, new_content_h + + +def _get_geometries_from_image( + image_to_trace: np.ndarray, processing_surface_height: int +) -> list[Geometry]: + """ + Performs the core vectorization of a boolean image using vtracer, + including complexity checks and fallbacks to hull generation. + """ + success, img_bytes, img_fmt = _encode_image_to_buffer(image_to_trace) + if not success: + return _fallback_to_enclosing_hull( + image_to_trace, + 1.0, # scale_x = 1 (pixel units) + 1.0, # scale_y = 1 (pixel units) + processing_surface_height, + ) + + try: + raw_output = _convert_buffer_to_svg_with_vtracer(img_bytes, img_fmt) + svg_str = _extract_svg_from_raw_output(raw_output) + except Exception as e: # noqa: BLE001 - native call boundary + logger.error(f"vtracer failed: {e}") + return _fallback_to_enclosing_hull( + image_to_trace, + 1.0, # scale_x = 1 (pixel units) + 1.0, # scale_y = 1 (pixel units) + processing_surface_height, + ) + + geometries = svg_string_to_geometries(svg_str, 1.0, 1.0) + if geometries: + _remove_border_offset(geometries) + if not geometries: + logger.warning("vtracer produced 0 geometries, falling back to hulls.") + return _fallback_to_hulls_from_image( + image_to_trace, + processing_surface_height, + ) + if len(geometries) >= MAX_VECTORS_LIMIT: + logger.warning( + f"vtracer produced {len(geometries)} geometries, " + f"exceeding limit of {MAX_VECTORS_LIMIT}. " + "Falling back to convex hulls." + ) + return _fallback_to_hulls_from_image( + image_to_trace, + processing_surface_height, + ) + return geometries + + +def _apply_upscaling( + geometries: list[Geometry], upscale_x: float, upscale_y: float +) -> list[Geometry]: + """Applies an upscaling transform to a list of geometries if needed.""" + if upscale_x != 1.0 or upscale_y != 1.0: + logger.debug(f"Upscaling traced geometry by {upscale_x}, {upscale_y}") + upscale_matrix = Matrix.scale(upscale_x, upscale_y) + for geo in geometries: + geo.transform(upscale_matrix) + return geometries + + +def _encode_color_to_buffer( + color_image: np.ndarray, +) -> tuple[bool, bytes, str]: + """Encodes a BGR color image to BMP bytes for vtracer.""" + logger.debug("Entering _encode_color_to_buffer") + if color_image.dtype != np.uint8: + color_image = color_image.astype(np.uint8) + rgb_image = cv2.cvtColor(color_image, cv2.COLOR_BGR2RGB) + success, buffer = cv2.imencode(".bmp", rgb_image) + if not success: + logger.error("Failed to encode color image to BMP for vtracer.") + return False, b"", "" + return True, buffer.tobytes(), "bmp" + + +def _get_geometries_from_color( + color_image: np.ndarray, processing_surface_height: int +) -> list[Geometry]: + """ + Performs vectorization of a color image using vtracer in color mode. + """ + success, img_bytes, img_fmt = _encode_color_to_buffer(color_image) + if not success: + return [] + + try: + raw_output = _convert_buffer_to_svg_with_vtracer( + img_bytes, img_fmt, colormode=ColorMode.COLOR + ) + svg_str = _extract_svg_from_raw_output(raw_output) + except Exception as e: # noqa: BLE001 - native call boundary + logger.error(f"vtracer color failed: {e}") + return [] + + geometries = svg_string_to_geometries(svg_str, 1.0, 1.0) + if not geometries: + logger.warning("vtracer color produced 0 geometries.") + return [] + if len(geometries) >= MAX_VECTORS_LIMIT: + logger.warning( + f"vtracer color produced {len(geometries)} geometries, " + f"exceeding limit of {MAX_VECTORS_LIMIT}." + ) + return [] + return geometries + + +def trace_color_image( + color_image: np.ndarray | None, +) -> list[Geometry]: + """ + Traces a BGR color image and returns a list of Geometry objects. + + Uses vtracer's color mode to trace distinct color regions. + + Args: + color_image: A 3-channel BGR numpy array. + + Returns: + A list of Geometry objects representing the traced shapes. + """ + logger.debug("Entering trace_color_image") + + if color_image is None or color_image.size == 0: + return [] + + if len(color_image.shape) != 3: + color_image = cv2.cvtColor(color_image, cv2.COLOR_GRAY2BGR) + + height, width = color_image.shape[:2] + + pixel_limit = ( + VTRACER_WINDOWS_SAFE_LIMIT + if sys.platform == "win32" + else VTRACER_PIXEL_LIMIT + ) + + if height * width > pixel_limit: + scale = (pixel_limit / (height * width)) ** 0.5 + new_w = max(4, (int(width * scale) // 4) * 4) + new_h = max(4, (int(height * scale) // 4) * 4) + logger.warning( + f"Color image too large ({width}x{height}). " + f"Downscaling to {new_w}x{new_h}." + ) + color_image = resize_linear_nd(color_image, (new_w, new_h)) + upscale_x = width / new_w + upscale_y = height / new_h + content_height = new_h + else: + upscale_x = 1.0 + upscale_y = 1.0 + content_height = height + + color_image = cv2.copyMakeBorder( + color_image, + BORDER_SIZE, + BORDER_SIZE, + BORDER_SIZE, + BORDER_SIZE, + cv2.BORDER_CONSTANT, + value=[255, 255, 255], + ) + processing_height = content_height + 2 * BORDER_SIZE + + content_width = int(width / upscale_x) if upscale_x > 0 else width + + geometries = _get_geometries_from_color(color_image, processing_height) + + def _is_border_geometry(geo: Geometry, w: int, h: int) -> bool: + for poly in geo.to_polygons(): + for pt in poly: + if pt[0] < -1 or pt[1] < -1 or pt[0] > w + 1 or pt[1] > h + 1: + return True + return False + + geometries = [ + g + for g in geometries + if not _is_border_geometry(g, content_width, content_height) + ] + + return _apply_upscaling(geometries, upscale_x, upscale_y) + + +def trace_surface( + surface: cairo.ImageSurface, + vectorization_spec: VectorizationSpec | None = None, +) -> list[Geometry]: + """ + Traces a Cairo surface and returns a list of Geometry objects. It uses + vtracer for high-quality vectorization, includes an adaptive pre-processing + step to handle noisy images, and a fallback mechanism for overly complex + vector results. + """ + logger.debug("Entering trace_surface") + + spec = vectorization_spec + if not isinstance(spec, TraceSpec): + spec = TraceSpec() + + # When threshold is 1.0 (maximum), use the whole image without tracing + if not spec.auto_threshold and spec.threshold == 1.0: + width = surface.get_width() + height = surface.get_height() + logger.info(f"Threshold is 1.0, using whole image: {width}x{height}") + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(width, 0) + geo.line_to(width, height) + geo.line_to(0, height) + geo.close_path() + return [geo] + + cleaned_boolean_image = prepare_surface(surface, vectorization_spec) + + if not np.any(cleaned_boolean_image): + logger.debug("No shapes found in the cleaned image, returning empty.") + return [] + + ( + image_to_trace, + upscale_x, + upscale_y, + processing_surface_height, + ) = _handle_oversized_image( + cleaned_boolean_image, surface.get_width(), surface.get_height() + ) + + geometries = _get_geometries_from_image( + image_to_trace, processing_surface_height + ) + + return _apply_upscaling(geometries, upscale_x, upscale_y) diff --git a/rayforge/image/util/__init__.py b/rayforge/image/util/__init__.py new file mode 100644 index 000000000..23ad89f31 --- /dev/null +++ b/rayforge/image/util/__init__.py @@ -0,0 +1,76 @@ +""" +Image utility functions split into logical submodules. + +This module provides utilities for: +- sRGB <-> linear light conversion (srgb module) +- Grayscale and binary image conversion (grayscale module) +- Transparency manipulation (transparency module) +- PyVips image operations (vips module) +- Unit conversion and layout (unit module) +""" + +from raygeo.image.grayscale import compute_auto_levels, normalize_grayscale + +from .cairo_util import rgba_to_cairo_surface +from .grayscale import ( + convert_surface_to_grayscale_inplace, + get_visible_grayscale_values, + surface_to_binary, + surface_to_grayscale, +) +from .srgb import ( + create_lut_from_color, + linear_to_srgb, + resize_linear_nd, + srgb_to_linear, +) +from .transparency import ( + make_surface_transparent, + make_transparent_except, +) +from .unit import ( + CAIRO_MAX_DIMENSION, + calculate_chunk_layout, + parse_length, + to_mm, +) +from .vips import ( + apply_mask_to_vips_image, + extract_vips_metadata, + get_mm_per_pixel, + get_physical_size_mm, + normalize_to_rgba, + resize_and_crop_from_full_image, + resize_linear, + safe_crop, + vips_rgba_to_cairo_surface, +) + +__all__ = [ + "CAIRO_MAX_DIMENSION", + "apply_mask_to_vips_image", + "calculate_chunk_layout", + "compute_auto_levels", + "convert_surface_to_grayscale_inplace", + "create_lut_from_color", + "extract_vips_metadata", + "get_mm_per_pixel", + "get_physical_size_mm", + "get_visible_grayscale_values", + "linear_to_srgb", + "make_surface_transparent", + "make_transparent_except", + "normalize_grayscale", + "normalize_to_rgba", + "parse_length", + "resize_and_crop_from_full_image", + "resize_linear", + "resize_linear_nd", + "rgba_to_cairo_surface", + "safe_crop", + "srgb_to_linear", + "surface_to_binary", + "surface_to_grayscale", + "to_mm", + "vips_rgba_to_cairo_surface", +] diff --git a/rayforge/image/util/cairo_util.py b/rayforge/image/util/cairo_util.py new file mode 100644 index 000000000..2a45ef0f5 --- /dev/null +++ b/rayforge/image/util/cairo_util.py @@ -0,0 +1,28 @@ +import cairo +import numpy as np + + +def rgba_to_cairo_surface(rgba: np.ndarray) -> cairo.ImageSurface: + """ + Convert an RGBA uint8 array to a premultiplied Cairo ARGB32 ImageSurface. + + Performs premultiplication using uint16 arithmetic and reorders + channels from RGBA to BGRA (Cairo's native memory layout on + little-endian systems). + + Args: + rgba: A (h, w, 4) uint8 array in RGBA order with straight alpha. + + Returns: + A cairo.ImageSurface in FORMAT_ARGB32 with premultiplied alpha. + """ + h, w = rgba.shape[:2] + a = rgba[..., 3].astype(np.uint16) + bgra = np.empty((h, w, 4), dtype=np.uint8) + bgra[..., 0] = (rgba[..., 2].astype(np.uint16) * a // 255).astype(np.uint8) + bgra[..., 1] = (rgba[..., 1].astype(np.uint16) * a // 255).astype(np.uint8) + bgra[..., 2] = (rgba[..., 0].astype(np.uint16) * a // 255).astype(np.uint8) + bgra[..., 3] = rgba[..., 3] + return cairo.ImageSurface.create_for_data( + memoryview(bgra), cairo.FORMAT_ARGB32, w, h + ) diff --git a/rayforge/image/util/grayscale.py b/rayforge/image/util/grayscale.py new file mode 100644 index 000000000..9013ac29e --- /dev/null +++ b/rayforge/image/util/grayscale.py @@ -0,0 +1,106 @@ +""" +Grayscale and binary image conversion utilities for Cairo surfaces. +""" + +import cairo +import numpy as np +from raygeo.image.convert import ( + rgba_to_binary, + rgba_to_grayscale, + rgba_to_grayscale_inplace, +) + + +def _extract_rgba(surface: cairo.ImageSurface) -> tuple: + width = surface.get_width() + height = surface.get_height() + stride_px = surface.get_stride() // 4 + buf = np.frombuffer(surface.get_data(), dtype=np.uint8).copy() + return buf, width, height, stride_px + + +def surface_to_grayscale( + surface: cairo.ImageSurface, +) -> tuple[np.ndarray, np.ndarray]: + """ + Convert a Cairo ARGB32 surface to a grayscale array with alpha handling. + + Args: + surface: Cairo ImageSurface in FORMAT_ARGB32 format. + + Returns: + Tuple of (grayscale_array, alpha_array) as numpy arrays. + grayscale_array: uint8 array with values 0-255. + alpha_array: float32 array with values 0.0-1.0. + """ + buf, width, height, stride = _extract_rgba(surface) + return rgba_to_grayscale(buf, width, height, stride) + + +def surface_to_binary( + surface: cairo.ImageSurface, + threshold: int = 128, + invert: bool = False, +) -> np.ndarray: + """ + Convert a Cairo ARGB32 surface to a binary array using thresholding. + + Transparent pixels are always treated as white (0). + + Args: + surface: Cairo ImageSurface in FORMAT_ARGB32 format. + threshold: Brightness value (0-255) for binarization. + invert: If True, pixels above threshold become black (1). + + Returns: + 2D numpy array with values 0 (white/transparent) or 1 (black). + + Raises: + ValueError: If the surface format is not ARGB32. + """ + if surface.get_format() != cairo.FORMAT_ARGB32: + raise ValueError("Unsupported Cairo surface format") + buf, width, height, stride = _extract_rgba(surface) + return rgba_to_binary(buf, width, height, stride, threshold, invert) + + +def convert_surface_to_grayscale_inplace( + surface: cairo.ImageSurface, +) -> None: + """ + Convert a Cairo ARGB32 surface to grayscale in place. + + Args: + surface: Cairo ImageSurface in FORMAT_ARGB32 format. + + Raises: + ValueError: If the surface format is not ARGB32. + """ + if surface.get_format() != cairo.FORMAT_ARGB32: + raise ValueError("Unsupported Cairo surface format") + width = surface.get_width() + height = surface.get_height() + stride_px = surface.get_stride() // 4 + buf = np.frombuffer(surface.get_data(), dtype=np.uint8) + rgba_to_grayscale_inplace(buf, width, height, stride_px) + + +def get_visible_grayscale_values( + surface: cairo.ImageSurface, + invert: bool = False, +) -> np.ndarray: + """ + Extract grayscale values for visible pixels from a Cairo ARGB32 surface. + + Args: + surface: Cairo ImageSurface in FORMAT_ARGB32 format. + invert: If True, invert grayscale values for visible pixels. + + Returns: + 1D uint8 numpy array of grayscale values for pixels with alpha > 0. + """ + gray_image, alpha = surface_to_grayscale(surface) + if invert: + alpha_mask = alpha > 0 + gray_image[alpha_mask] = 255 - gray_image[alpha_mask] + return gray_image[alpha > 0] diff --git a/rayforge/image/util/srgb.py b/rayforge/image/util/srgb.py new file mode 100644 index 000000000..6ae2d00e2 --- /dev/null +++ b/rayforge/image/util/srgb.py @@ -0,0 +1,80 @@ +""" +sRGB <-> linear light conversion utilities. + +Pure-array conversions delegate to raygeo.image. The remaining +functions (create_lut_from_color, resize_linear_nd) have no raygeo +equivalents and are kept in Python. +""" + +import cv2 +import numpy as np +from raygeo.image.srgb import linear_to_srgb, srgb_to_linear + + +def create_lut_from_color( + color: tuple[float, float, float, float], +) -> np.ndarray: + """ + Create a 256x4 LUT from a single color (grayscale to color gradient). + + Interpolation is performed in linear light so the gradient ramp is + perceptually uniform. The output values are float32 in [0, 1] sRGB + space, matching existing consumers. + """ + r, g, b, a = color + rgb_uint8 = np.clip([r * 255, g * 255, b * 255], 0, 255).astype(np.uint8) + lin = srgb_to_linear(rgb_uint8) + + t = np.linspace(0, 1, 256, dtype=np.float32) + lut = np.zeros((256, 4), dtype=np.float32) + + for c in range(3): + ch_linear = np.clip(lin[c] * t, 0, 1) + ch_uint8 = linear_to_srgb(ch_linear) + lut[:, c] = ch_uint8.astype(np.float32) / 255.0 + + lut[:, 3] = a * t + return lut + + +def resize_linear_nd( + image: np.ndarray, + size: tuple[int, int], + interpolation: int = -1, +) -> np.ndarray: + """ + Resize a uint8 image in linear light, channel by channel. + + Converts each channel to linear float, resizes, and converts + back to sRGB uint8. Channels are processed one at a time to + minimise peak memory. + + Requires OpenCV (cv2) for the actual resize. + + Args: + image: HxW or HxWxC uint8 numpy array (sRGB). + size: Target (width, height) in pixels. + interpolation: OpenCV interpolation flag. Defaults to + cv2.INTER_AREA. + + Returns: + Resized uint8 numpy array with the same number of channels. + """ + if interpolation < 0: + interpolation = cv2.INTER_AREA + + ndim = image.ndim + if ndim == 2: + image = image[:, :, np.newaxis] + + n_channels = image.shape[2] + out_h, out_w = size[1], size[0] + result = np.empty((out_h, out_w, n_channels), dtype=np.uint8) + + for c in range(n_channels): + ch_linear = srgb_to_linear(image[:, :, c]).astype(np.float32) + ch_resized = cv2.resize(ch_linear, size, interpolation=interpolation) + ch_clipped = np.clip(ch_resized, 0, 1).astype(np.float32) + result[:, :, c] = linear_to_srgb(ch_clipped) + + return result[:, :, 0] if ndim == 2 else result diff --git a/rayforge/image/util/transparency.py b/rayforge/image/util/transparency.py new file mode 100644 index 000000000..7054c7eaf --- /dev/null +++ b/rayforge/image/util/transparency.py @@ -0,0 +1,80 @@ +""" +Transparency manipulation utilities for Cairo surfaces. +""" + +import cairo +import numpy +from raygeo.image.transparency import ( + make_transparent_by_brightness, + make_transparent_except_color, +) + + +def make_surface_transparent( + surface: cairo.ImageSurface, threshold: int = 250 +) -> None: + """ + Make "almost white" pixels transparent in a Cairo ARGB32 surface. + + Modifies the surface in place. Pixels with average brightness above + the threshold have their alpha channel set to 0. + + Args: + surface: Cairo ImageSurface in FORMAT_ARGB32 format. + threshold: Brightness threshold (0-255). Pixels with average + RGB value >= threshold become transparent. + + Raises: + ValueError: If the surface format is not ARGB32. + """ + if surface.get_format() != cairo.FORMAT_ARGB32: + raise ValueError("Surface must be in ARGB32 format.") + + width, height = surface.get_width(), surface.get_height() + stride_px = surface.get_stride() // 4 + + data = surface.get_data() + buf = numpy.frombuffer(data, dtype=numpy.uint8).copy() + make_transparent_by_brightness(buf, width, height, stride_px, threshold) + + dst = numpy.frombuffer(data, dtype=numpy.uint8) + dst[:] = buf + surface.mark_dirty() + + +def make_transparent_except( + surface: cairo.ImageSurface, + target_r: int, + target_g: int, + target_b: int, +) -> None: + """ + Make all pixels transparent except those matching a target RGB color. + + Modifies the surface in place. Pixels that do not match the target + color have their alpha channel set to 0. + + Args: + surface: Cairo ImageSurface in FORMAT_ARGB32 format. + target_r: Target red channel value (0-255). + target_g: Target green channel value (0-255). + target_b: Target blue channel value (0-255). + + Raises: + ValueError: If the surface format is not ARGB32. + """ + if surface.get_format() != cairo.FORMAT_ARGB32: + raise ValueError("Surface must be in ARGB32 format.") + + width, height = surface.get_width(), surface.get_height() + stride_px = surface.get_stride() // 4 + + data = surface.get_data() + buf = numpy.frombuffer(data, dtype=numpy.uint8).copy() + make_transparent_except_color( + buf, width, height, stride_px, target_r, target_g, target_b + ) + + dst = numpy.frombuffer(data, dtype=numpy.uint8) + dst[:] = buf + surface.mark_dirty() diff --git a/rayforge/image/util/unit.py b/rayforge/image/util/unit.py new file mode 100644 index 000000000..04d1abe6a --- /dev/null +++ b/rayforge/image/util/unit.py @@ -0,0 +1,71 @@ +""" +Unit conversion and layout utilities. +""" + +import math +import re + +CAIRO_MAX_DIMENSION = 16384 + + +def to_mm(value, unit, px_factor=None): + """Convert a value to millimeters based on its unit.""" + if unit == "cm": + return value * 10 + if unit == "mm": + return value + if unit == "um": + return value * 0.001 + if unit == "in": + return value * 25.4 + if unit == "pt": + return value * 25.4 / 72 + if px_factor and unit in ("", "px"): + return value * px_factor + raise ValueError("Cannot convert to millimeters without DPI information.") + + +def parse_length(s): + if not s: + return 0.0, "px" + m = re.match(r"([0-9.]+)\s*([a-z%]*)", s) + if m: + return float(m.group(1)), m.group(2) or "px" + return float(s), "px" + + +def calculate_chunk_layout( + real_width, real_height, max_chunk_width, max_chunk_height, max_memory_size +): + bytes_per_pixel = 4 + + effective_max_width = min( + max_chunk_width + if max_chunk_width is not None + else CAIRO_MAX_DIMENSION, + CAIRO_MAX_DIMENSION, + ) + chunk_width = min(real_width, effective_max_width) + + possible_heights = [ + min( + max_chunk_height + if max_chunk_height is not None + else CAIRO_MAX_DIMENSION, + CAIRO_MAX_DIMENSION, + ) + ] + if max_memory_size is not None and chunk_width > 0: + possible_heights.append( + math.floor(max_memory_size / (chunk_width * bytes_per_pixel)) + ) + + chunk_height = min(real_height, *possible_heights) + chunk_width, chunk_height = max(1, chunk_width), max(1, chunk_height) + + return ( + chunk_width, + math.ceil(real_width / chunk_width), + chunk_height, + math.ceil(real_height / chunk_height), + ) diff --git a/rayforge/image/util/vips.py b/rayforge/image/util/vips.py new file mode 100644 index 000000000..3ec1d2fe1 --- /dev/null +++ b/rayforge/image/util/vips.py @@ -0,0 +1,274 @@ +""" +PyVips image manipulation utilities. +""" + +import logging +from typing import Any + +import cairo +import numpy +import pyvips +from raygeo.geo import Geometry, Matrix +from raygeo.geo.types import Rect + +from ..geo_renderer import geometry_to_cairo +from .cairo_util import rgba_to_cairo_surface + +logger = logging.getLogger(__name__) + + +def resize_linear( + image: pyvips.Image, h_scale: float, vscale: float | None = None +) -> pyvips.Image: + """ + Resize a pyvips image in linear light for correct interpolation. + + Converts to scRGB (linear float) before resizing, then back to + sRGB. pyvips streams the pipeline tile-by-tile so the float32 + intermediate is never fully resident in memory. + """ + linear = image.colourspace("scrgb") + if vscale is not None: + resized = linear.resize(h_scale, vscale=vscale) + else: + resized = linear.resize(h_scale) + return resized.colourspace("srgb") + + +def resize_and_crop_from_full_image( + full_image: pyvips.Image, + target_w: int, + target_h: int, + crop_window_px: Rect, +) -> pyvips.Image | None: + """ + Scales a full source image up to a high resolution and then crops a + window from it. This preserves maximum detail in the final cropped image. + + Args: + full_image: The original, full-resolution pyvips image. + target_w: The final desired width of the cropped image in pixels. + target_h: The final desired height of the cropped image in pixels. + crop_window_px: A tuple (x, y, w, h) defining the crop area in the + *original* full_image's pixel coordinates. + + Returns: + The high-resolution cropped image, or None on failure. + """ + crop_x, crop_y, crop_w, crop_h = map(int, crop_window_px) + if ( + crop_w <= 0 + or crop_h <= 0 + or crop_x < 0 + or crop_y < 0 + or crop_x + crop_w > full_image.width + or crop_y + crop_h > full_image.height + ): + return pyvips.Image.black(target_w, target_h, bands=4) + + scale_x = target_w / crop_w + scale_y = target_h / crop_h + + if full_image.get_typeof("orientation") != 0: + try: + full_image = full_image.autorot() + except pyvips.Error: + logger.warning("Failed to apply autorotate to image.") + + scaled_full_image = resize_linear(full_image, scale_x, vscale=scale_y) + + scaled_crop_x = int(crop_x * scale_x) + scaled_crop_y = int(crop_y * scale_y) + + return safe_crop( + scaled_full_image, scaled_crop_x, scaled_crop_y, target_w, target_h + ) + + +def safe_crop( + image: pyvips.Image, x: int, y: int, w: int, h: int +) -> pyvips.Image | None: + """ + Crops a pyvips image, safely handling cases where the crop window is + partially or completely outside the image bounds by calculating the + intersection. + + Returns the cropped image, or None if the intersection is empty. + """ + img_w, img_h = image.width, image.height + final_x = max(0, x) + final_y = max(0, y) + end_x = min(x + w, img_w) + end_y = min(y + h, img_h) + final_w = max(0, end_x - final_x) + final_h = max(0, end_y - final_y) + + if final_w > 0 and final_h > 0: + return image.crop(final_x, final_y, final_w, final_h) + + return None + + +def extract_vips_metadata(image: pyvips.Image) -> dict[str, Any]: + """ + Extracts file-based and content-based metadata from a pyvips Image. + """ + metadata = { + "width": image.width, + "height": image.height, + "bands": image.bands, + "format": image.format, + "interpretation": str(image.interpretation), + } + all_fields = image.get_fields() + for field in all_fields: + if field in metadata: + continue + try: + value = image.get(field) + if isinstance(value, bytes): + if "icc-profile" in field: + value = f"" + elif len(value) > 256: + value = f"" + else: + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + value = f"" + elif not isinstance( + value, (str, int, float, bool, list, dict, type(None)) + ): + value = str(value) + metadata[field] = value + except pyvips.Error as e: + logger.debug(f"Could not read metadata field '{field}': {e}") + return metadata + + +def get_mm_per_pixel(image: pyvips.Image) -> tuple[float, float]: + """ + Determines mm per pixel from a vips image metadata. Falls back to 96 DPI. + """ + try: + xres = image.get("xres") + yres = image.get("yres") + + if xres == 1.0 and yres == 1.0: + raise pyvips.Error( + "Default resolution of 1.0 px/mm detected, using fallback." + ) + + return 1.0 / xres, 1.0 / yres + except pyvips.Error: + mm_per_inch = 25.4 + dpi = 96.0 + return (mm_per_inch / dpi), (mm_per_inch / dpi) + + +def get_physical_size_mm(image: pyvips.Image) -> tuple[float, float]: + """ + Determines the physical size of a vips image in mm. + """ + mm_per_px_x, mm_per_px_y = get_mm_per_pixel(image) + width_mm = image.width * mm_per_px_x + height_mm = image.height * mm_per_px_y + return width_mm, height_mm + + +def normalize_to_rgba(image: pyvips.Image) -> pyvips.Image | None: + """ + Normalizes a pyvips image to a 4-band, 8-bit sRGB format (uchar RGBA). + """ + try: + if image.interpretation != "srgb": + image = image.colourspace("srgb") + if not image.hasalpha(): + image = image.addalpha() + if image.bands != 4: + logger.warning( + f"Image normalization had {image.bands} bands, cropping to 4." + ) + image = image[0:4] + if image.format != "uchar": + image = image.cast("uchar") + return image if image.bands == 4 else None + except pyvips.Error as e: + logger.error(f"Failed to normalize image to RGBA: {e}") + return None + + +def vips_rgba_to_cairo_surface(image: pyvips.Image) -> cairo.ImageSurface: + """ + Converts a 4-band RGBA pyvips image to a Cairo ARGB32 ImageSurface. + + Extracts the pixel data as uint8 RGBA and delegates to + rgba_to_cairo_surface for premultiplication and BGRA reorder. + """ + assert image.bands == 4, "Input image must be normalized to RGBA first" + assert image.format == "uchar", "Input image must be 8-bit uchar" + + w, h = image.width, image.height + memory = image.write_to_memory() + rgba = numpy.frombuffer(memory, dtype=numpy.uint8).reshape(h, w, 4) + + return rgba_to_cairo_surface(rgba) + + +def _render_geometry_to_vips_mask( + geometry: Geometry, width: int, height: int +) -> pyvips.Image: + """Renders a Geometry object to a single-band 8-bit vips mask image.""" + surface = cairo.ImageSurface(cairo.FORMAT_A8, width, height) + ctx = cairo.Context(surface) + ctx.set_source_rgba(0, 0, 0, 0) + ctx.paint() + + ctx.set_source_rgba(1, 1, 1, 1) + geometry_to_cairo(geometry, ctx) + ctx.fill() + + stride = surface.get_stride() + cairo_data = surface.get_data() + + if stride == width: + return pyvips.Image.new_from_memory( + cairo_data, width, height, 1, "uchar" + ) + + arr = numpy.frombuffer(cairo_data, dtype=numpy.uint8).reshape( + (height, stride) + ) + clean_data = numpy.ascontiguousarray(arr[:, :width]).tobytes() + + return pyvips.Image.new_from_memory(clean_data, width, height, 1, "uchar") + + +def apply_mask_to_vips_image( + full_image: pyvips.Image, mask_geo: Geometry +) -> pyvips.Image | None: + """ + Masks a vips image using a geometry mask, making areas outside the + geometry transparent. Does NOT crop the image. + + Expects the mask_geo to be NORMALIZED to a 0-1 Y-DOWN coordinate space. + """ + if mask_geo.is_empty(): + return full_image + + rgba_image = normalize_to_rgba(full_image) + if not rgba_image: + return None + + scaled_mask = mask_geo.copy() + scale_matrix = Matrix.scale(rgba_image.width, rgba_image.height) + scaled_mask.transform(scale_matrix) + + mask_vips = _render_geometry_to_vips_mask( + scaled_mask, rgba_image.width, rgba_image.height + ) + + original_alpha = rgba_image[3] + final_alpha = (mask_vips > 128).ifthenelse(original_alpha, 0) + + return rgba_image[0:3].bandjoin(final_alpha) diff --git a/rayforge/license/__init__.py b/rayforge/license/__init__.py new file mode 100644 index 000000000..ec5ad56a7 --- /dev/null +++ b/rayforge/license/__init__.py @@ -0,0 +1,19 @@ +from .gumroad_provider import GumroadProvider +from .patreon_provider import PatreonProvider +from .provider import ( + LicenseProvider, + LicenseResult, + LicenseStatus, + LicenseType, +) +from .validator import LicenseValidator + +__all__ = [ + "GumroadProvider", + "LicenseProvider", + "LicenseResult", + "LicenseStatus", + "LicenseType", + "LicenseValidator", + "PatreonProvider", +] diff --git a/rayforge/license/gumroad_provider.py b/rayforge/license/gumroad_provider.py new file mode 100644 index 000000000..aed629432 --- /dev/null +++ b/rayforge/license/gumroad_provider.py @@ -0,0 +1,280 @@ +import json +import logging +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import yaml + +from .provider import ( + LicenseProvider, + LicenseResult, + LicenseStatus, + LicenseType, +) + +logger = logging.getLogger(__name__) + + +class GumroadProvider(LicenseProvider): + API_URL = "https://api.gumroad.com/v2/licenses/verify" + + def __init__(self, licenses_dir: Path): + self.config_file = licenses_dir / "gumroad.yaml" + self._licenses: dict[str, str] = {} + self._cache: dict[str, dict[str, Any]] = {} + self._load_config() + + @property + def name(self) -> str: + return "gumroad" + + def is_configured(self) -> bool: + return len(self._licenses) > 0 + + def has_license_for(self, product_id: str) -> bool: + return product_id in self._licenses + + def add_license(self, product_id: str, license_key: str) -> None: + self._licenses[product_id] = license_key + self._save_config() + + def remove_license(self, product_id: str) -> None: + self._licenses.pop(product_id, None) + self._cache.pop(product_id, None) + self._save_config() + + def get_licenses(self) -> dict[str, str]: + return dict(self._licenses) + + def get_cached_result(self, product_id: str) -> dict | None: + return self._cache.get(product_id) + + def clear_cache(self, product_id: str | None = None) -> None: + if product_id: + self._cache.pop(product_id, None) + else: + self._cache.clear() + + def validate_key(self, product_id: str, license_key: str) -> LicenseResult: + return self._validate_single(product_id, license_key) + + def validate(self, config: dict) -> LicenseResult: + product_ids = config.get("product_ids", []) + if not product_ids: + product_id = config.get("product_id") + if product_id: + product_ids = [product_id] + + if not product_ids: + return LicenseResult( + status=LicenseStatus.ERROR, + message="Missing product_ids in addon config", + ) + + for product_id in product_ids: + license_key = self._licenses.get(product_id) + if not license_key: + continue + + result = self._validate_single(product_id, license_key) + if result.status == LicenseStatus.VALID: + return result + + if self._licenses: + return LicenseResult( + status=LicenseStatus.INVALID, + message="No valid license found for this product", + ) + + return LicenseResult( + status=LicenseStatus.NOT_FOUND, + message="No license key configured for this product", + ) + + def _validate_single( + self, product_id: str, license_key: str + ) -> LicenseResult: + cached = self._get_valid_cache(product_id) + if cached: + return self._cached_to_result(cached) + + if license_key.startswith("TESTKEY-"): + cached = self._get_valid_cache(product_id) + if cached: + return self._cached_to_result(cached) + return self._create_test_result(product_id) + + try: + data = urllib.parse.urlencode( + { + "product_id": product_id, + "license_key": license_key, + "increment_uses_count": "false", + } + ).encode() + + req = urllib.request.Request( + self.API_URL, data=data, method="POST" + ) + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + + if not result.get("success"): + return LicenseResult( + status=LicenseStatus.INVALID, + message="License key is invalid", + ) + + purchase = result.get("purchase", {}) + + if purchase.get("refunded"): + return LicenseResult( + status=LicenseStatus.INVALID, + message="Purchase was refunded", + ) + + if purchase.get("subscription_cancelled_at"): + return LicenseResult( + status=LicenseStatus.EXPIRED, + message="Subscription has been cancelled", + ) + + if purchase.get("subscription_ended_at"): + return LicenseResult( + status=LicenseStatus.EXPIRED, + message="Subscription has ended", + ) + + license_type = LicenseType.ONE_TIME + if purchase.get("subscription_id"): + license_type = LicenseType.SUBSCRIPTION + + result_obj = LicenseResult( + status=LicenseStatus.VALID, + message="License is valid", + license_type=license_type, + customer_email=purchase.get("email"), + last_validated=datetime.now(tz=timezone.utc), + metadata={ + "product_id": product_id, + "product_name": purchase.get("product_name"), + "purchase_date": purchase.get("sale_timestamp"), + }, + ) + + self._cache_result(product_id, result_obj) + return result_obj + + except urllib.error.URLError as e: + cached = self._get_valid_cache(product_id) + if cached: + logger.warning( + f"Network error during validation, using cache: {e.reason}" + ) + return LicenseResult(**cached) + return LicenseResult( + status=LicenseStatus.ERROR, + message=f"Network error: {e.reason}", + ) + except (OSError, TimeoutError, ValueError) as e: + logger.error(f"Gumroad validation failed: {e}") + return LicenseResult( + status=LicenseStatus.ERROR, + message=f"Validation failed: {e!s}", + ) + + def _create_test_result(self, product_id: str) -> LicenseResult: + expires_at = datetime.now(tz=timezone.utc) + timedelta(days=1) + result = LicenseResult( + status=LicenseStatus.VALID, + message="Test license key", + license_type=LicenseType.ONE_TIME, + expires_at=expires_at, + customer_email="test@example.com", + last_validated=datetime.now(tz=timezone.utc), + metadata={ + "product_id": product_id, + "product_name": "Test Product", + "purchase_date": datetime.now(tz=timezone.utc).isoformat(), + }, + ) + self._cache_result(product_id, result) + return result + + def _get_valid_cache(self, product_id: str) -> dict | None: + cached = self._cache.get(product_id) + if not cached: + return None + + try: + result = self._cached_to_result(cached) + if result.is_valid_for_offline(): + return cached + except (ValueError, TypeError): + pass + return None + + def _cache_result(self, product_id: str, result: LicenseResult) -> None: + cache_data = { + "status": result.status.value, + "message": result.message, + "license_type": result.license_type.value, + "expires_at": result.expires_at.isoformat() + if result.expires_at + else None, + "customer_email": result.customer_email, + "last_validated": result.last_validated.isoformat() + if result.last_validated + else None, + "metadata": result.metadata, + } + self._cache[product_id] = cache_data + self._save_cache() + + def _cached_to_result(self, cached: dict) -> LicenseResult: + status = LicenseStatus(cached.get("status")) + license_type = LicenseType(cached.get("license_type", "unknown")) + last_validated_str = cached.get("last_validated") + last_validated = None + if last_validated_str: + last_validated = datetime.fromisoformat(last_validated_str) + expires_at_str = cached.get("expires_at") + expires_at = None + if expires_at_str: + expires_at = datetime.fromisoformat(expires_at_str) + return LicenseResult( + status=status, + message=cached.get("message", ""), + license_type=license_type, + expires_at=expires_at, + customer_email=cached.get("customer_email"), + last_validated=last_validated, + metadata=cached.get("metadata", {}), + ) + + def _load_config(self) -> None: + if self.config_file.exists(): + try: + with open(self.config_file) as f: + data = yaml.safe_load(f) or {} + if isinstance(data, dict): + self._licenses = data.get("licenses", {}) + self._cache = data.get("cache", {}) + except (OSError, yaml.YAMLError) as e: + logger.warning(f"Failed to load Gumroad config: {e}") + + def _save_config(self) -> None: + self.config_file.parent.mkdir(parents=True, exist_ok=True) + data = { + "licenses": self._licenses, + "cache": self._cache, + } + with open(self.config_file, "w") as f: + yaml.dump(data, f, default_flow_style=False) + + def _save_cache(self) -> None: + self._save_config() diff --git a/rayforge/license/patreon_provider.py b/rayforge/license/patreon_provider.py new file mode 100644 index 000000000..7ac28150e --- /dev/null +++ b/rayforge/license/patreon_provider.py @@ -0,0 +1,324 @@ +import json +import logging +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from threading import Thread +from typing import Any + +import yaml + +from .provider import ( + LicenseProvider, + LicenseResult, + LicenseStatus, + LicenseType, +) + +logger = logging.getLogger(__name__) + + +class OAuthCallbackHandler(BaseHTTPRequestHandler): + def __init__(self, callback, *args, **kwargs): + self.callback = callback + super().__init__(*args, **kwargs) + + def do_GET(self): + if self.path.startswith("/callback"): + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + + code = params.get("code", [None])[0] + error = params.get("error", [None])[0] + + if error: + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authorization Failed

" + b"

You can close this window.

" + ) + self.callback(None, error) + elif code: + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authorization Successful

" + b"

You can close this window and return to Rayforge.

" + b"" + ) + self.callback(code, None) + else: + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Invalid Request

" + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass + + +class PatreonProvider(LicenseProvider): + AUTHORIZE_URL = "https://www.patreon.com/oauth2/authorize" + TOKEN_URL = "https://www.patreon.com/api/oauth2/token" + API_BASE = "https://www.patreon.com/api/oauth2/v2" + REDIRECT_PORT = 8765 + + def __init__(self, licenses_dir: Path, client_id: str): + self.config_file = licenses_dir / "patreon.yaml" + self.client_id = client_id + self._access_token: str | None = None + self._cache: dict[str, dict[str, Any]] = {} + self._load_config() + + @property + def name(self) -> str: + return "patreon" + + def is_configured(self) -> bool: + return self._access_token is not None + + def validate(self, config: dict) -> LicenseResult: + if not self._access_token: + return LicenseResult( + status=LicenseStatus.NOT_FOUND, + message="Patreon account not linked", + ) + + tier_ids = config.get("patreon_tier_ids", []) + if not tier_ids: + return LicenseResult( + status=LicenseStatus.NOT_FOUND, + message="This addon does not support Patreon unlock", + ) + + cached = self._get_valid_cache(tier_ids) + if cached: + return LicenseResult(**cached) + + try: + url = ( + f"{self.API_BASE}/identity" + f"?include=memberships" + f"&fields[member]=patron_status,last_charge_date" + f"&fields[tier]=title" + ) + + req = urllib.request.Request( + url, headers={"Authorization": f"Bearer {self._access_token}"} + ) + + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + + included = result.get("included", []) + tier_map = {} + for item in included: + if item.get("type") == "tier": + tier_map[item["id"]] = item.get("attributes", {}).get( + "title", "" + ) + + for item in included: + if item.get("type") != "member": + continue + + attrs = item.get("attributes", {}) + + if attrs.get("patron_status") != "active_patron": + continue + + tier_rel = ( + item.get("relationships", {}) + .get("currently_entitled_tiers", {}) + .get("data", []) + ) + + entitled_tier_ids = {t["id"] for t in tier_rel} + required_tier_ids = set(tier_ids) + + if entitled_tier_ids & required_tier_ids: + matched_tiers = list(entitled_tier_ids & required_tier_ids) + result_obj = LicenseResult( + status=LicenseStatus.VALID, + message="Active Patreon supporter", + license_type=LicenseType.SUBSCRIPTION, + last_validated=datetime.now(tz=timezone.utc), + metadata={ + "tier_ids": matched_tiers, + }, + ) + + cache_key = self._make_cache_key(tier_ids) + self._cache_result(cache_key, result_obj) + return result_obj + + return LicenseResult( + status=LicenseStatus.INVALID, + message="Not a patron at required tier", + ) + + except urllib.error.URLError as e: + cached = self._get_valid_cache(tier_ids) + if cached: + logger.warning( + f"Network error during Patreon validation, using cache: " + f"{e.reason}" + ) + return LicenseResult(**cached) + return LicenseResult( + status=LicenseStatus.ERROR, + message=f"Network error: {e.reason}", + ) + except (OSError, TimeoutError, ValueError) as e: + logger.error(f"Patreon validation failed: {e}") + return LicenseResult( + status=LicenseStatus.ERROR, + message=f"Validation failed: {e!s}", + ) + + def _make_cache_key(self, tier_ids: list[str]) -> str: + return ",".join(sorted(tier_ids)) + + def _get_valid_cache(self, tier_ids: list[str]) -> dict | None: + cache_key = self._make_cache_key(tier_ids) + cached = self._cache.get(cache_key) + if not cached: + return None + + cached_result = LicenseResult(**cached) + if cached_result.is_valid_for_offline(): + return cached + return None + + def _cache_result(self, cache_key: str, result: LicenseResult) -> None: + cache_data = { + "status": result.status.value, + "message": result.message, + "license_type": result.license_type.value, + "customer_email": result.customer_email, + "last_validated": result.last_validated.isoformat() + if result.last_validated + else None, + "metadata": result.metadata, + } + self._cache[cache_key] = cache_data + self._save_cache() + + def get_oauth_url(self) -> str: + redirect_uri = f"http://127.0.0.1:{self.REDIRECT_PORT}/callback" + params = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "scope": "identity identity.memberships", + } + return f"{self.AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" + + def start_oauth_flow( + self, + on_complete: Callable[[bool, str | None], None], + ) -> tuple[int, Thread]: + """ + Start the OAuth flow by launching a local HTTP server. + + Returns: + Tuple of (port, thread) where thread is running the server. + """ + callback_received = {"code": None, "error": None} + + def callback(code, error): + callback_received["code"] = code + callback_received["error"] = error + + def handler_factory(*args, **kwargs): + return OAuthCallbackHandler(callback, *args, **kwargs) + + server = HTTPServer(("127.0.0.1", self.REDIRECT_PORT), handler_factory) + + def run_server(): + server.handle_request() + if callback_received["code"]: + success = self.exchange_code( + callback_received["code"], self.REDIRECT_PORT + ) + on_complete(success, None) + else: + on_complete(False, callback_received["error"]) + + thread = Thread(target=run_server, daemon=True) + thread.start() + + return self.REDIRECT_PORT, thread + + def exchange_code(self, code: str, redirect_port: int) -> bool: + redirect_uri = f"http://127.0.0.1:{redirect_port}/callback" + data = urllib.parse.urlencode( + { + "code": code, + "client_id": self.client_id, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + } + ).encode() + + try: + req = urllib.request.Request( + self.TOKEN_URL, data=data, method="POST" + ) + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + + self._access_token = result.get("access_token") + if self._access_token: + self._save_config() + return True + except (OSError, TimeoutError, ValueError) as e: + logger.error(f"Failed to exchange Patreon OAuth code: {e}") + + return False + + def unlink(self) -> None: + self._access_token = None + self._cache.clear() + if self.config_file.exists(): + self.config_file.unlink() + + def clear_cache(self) -> None: + self._cache.clear() + self._save_cache() + + def _load_config(self) -> None: + if self.config_file.exists(): + try: + with open(self.config_file) as f: + data = yaml.safe_load(f) or {} + self._access_token = data.get("access_token") + self._cache = data.get("cache", {}) + except (OSError, yaml.YAMLError) as e: + logger.warning(f"Failed to load Patreon config: {e}") + + def _save_config(self) -> None: + self.config_file.parent.mkdir(parents=True, exist_ok=True) + data = { + "access_token": self._access_token, + "cache": self._cache, + } + with open(self.config_file, "w") as f: + yaml.dump(data, f, default_flow_style=False) + + def _save_cache(self) -> None: + self._save_config() diff --git a/rayforge/license/provider.py b/rayforge/license/provider.py new file mode 100644 index 000000000..902de495e --- /dev/null +++ b/rayforge/license/provider.py @@ -0,0 +1,67 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any + + +class LicenseStatus(Enum): + VALID = "valid" + INVALID = "invalid" + EXPIRED = "expired" + NOT_FOUND = "not_found" + ERROR = "error" + + +class LicenseType(Enum): + ONE_TIME = "one_time" + SUBSCRIPTION = "subscription" + UNKNOWN = "unknown" + + +@dataclass +class LicenseResult: + status: LicenseStatus + message: str = "" + license_type: LicenseType = LicenseType.UNKNOWN + expires_at: datetime | None = None + customer_email: str | None = None + last_validated: datetime | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def is_expired(self) -> bool: + if self.expires_at is None: + return False + return datetime.now(tz=timezone.utc) >= self.expires_at + + def is_valid_for_offline(self) -> bool: + if self.status != LicenseStatus.VALID: + return False + if self.is_expired(): + return False + if self.license_type == LicenseType.ONE_TIME: + return True + if self.license_type == LicenseType.SUBSCRIPTION: + if not self.last_validated: + return False + grace_period = timedelta(days=30) + return ( + datetime.now(tz=timezone.utc) - self.last_validated + < grace_period + ) + return False + + +class LicenseProvider(ABC): + @property + @abstractmethod + def name(self) -> str: + pass + + @abstractmethod + def is_configured(self) -> bool: + pass + + @abstractmethod + def validate(self, config: dict[str, Any]) -> LicenseResult: + pass diff --git a/rayforge/license/validator.py b/rayforge/license/validator.py new file mode 100644 index 000000000..21c3df493 --- /dev/null +++ b/rayforge/license/validator.py @@ -0,0 +1,149 @@ +import logging +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import Thread + +from blinker import Signal + +from .gumroad_provider import GumroadProvider +from .patreon_provider import PatreonProvider +from .provider import ( + LicenseProvider, + LicenseResult, + LicenseStatus, +) + +logger = logging.getLogger(__name__) + + +class LicenseValidator: + CACHE_DURATION = timedelta(hours=24) + + def __init__(self, config_dir: Path, patreon_client_id: str | None = None): + self._gumroad: GumroadProvider = GumroadProvider(config_dir) + self._patreon: PatreonProvider | None = None + self._cache: dict[str, tuple] = {} + self.changed = Signal() + + if patreon_client_id: + self._patreon = PatreonProvider(config_dir, patreon_client_id) + + @property + def providers(self) -> dict[str, LicenseProvider]: + result: dict[str, LicenseProvider] = {"gumroad": self._gumroad} + if self._patreon: + result["patreon"] = self._patreon + return result + + def validate(self, addon_id: str, license_config: dict) -> LicenseResult: + cached = self._cache.get(addon_id) + if cached: + result, timestamp = cached + if datetime.now(tz=timezone.utc) - timestamp < self.CACHE_DURATION: + return result + + has_gumroad = license_config.get("product_ids") or license_config.get( + "product_id" + ) + has_patreon = license_config.get("patreon_tier_ids") + + if has_gumroad and self._gumroad.is_configured(): + result = self._gumroad.validate(license_config) + if result.status == LicenseStatus.VALID: + self._cache[addon_id] = (result, datetime.now(tz=timezone.utc)) + return result + + if has_patreon and self._patreon and self._patreon.is_configured(): + result = self._patreon.validate(license_config) + if result.status == LicenseStatus.VALID: + self._cache[addon_id] = ( + result, + datetime.now(tz=timezone.utc), + ) + return result + + if has_gumroad and has_patreon: + message = ( + "License required. Purchase on Gumroad or link Patreon " + "account." + ) + elif has_gumroad: + message = "License required. Purchase on Gumroad." + elif has_patreon: + message = "License required. Link your Patreon account." + else: + message = "License required." + + result = LicenseResult(status=LicenseStatus.NOT_FOUND, message=message) + self._cache[addon_id] = (result, datetime.now(tz=timezone.utc)) + return result + + def check_license( + self, addon_id: str, license_config: dict + ) -> tuple[bool, str, str]: + """ + Check if addon requires and has valid license. + + Returns: + Tuple of (is_allowed, message, purchase_url) + """ + if not license_config or not license_config.get("required"): + return True, "", "" + + result = self.validate(addon_id, license_config) + purchase_url = license_config.get("purchase_url", "") + + if result.status == LicenseStatus.VALID: + return True, result.message, "" + + return False, result.message, purchase_url + + def invalidate_cache(self, addon_id: str | None = None) -> None: + if addon_id: + self._cache.pop(addon_id, None) + else: + self._cache.clear() + + def get_provider(self, name: str) -> LicenseProvider | None: + if name == "gumroad": + return self._gumroad + if name == "patreon": + return self._patreon + return None + + def has_gumroad_license_for(self, product_id: str) -> bool: + return self._gumroad.has_license_for(product_id) + + def add_gumroad_license(self, product_id: str, license_key: str) -> None: + self._gumroad.add_license(product_id, license_key) + self.invalidate_cache() + self.changed.send(self) + + def remove_gumroad_license(self, product_id: str) -> None: + self._gumroad.remove_license(product_id) + self.invalidate_cache() + self.changed.send(self) + + def get_gumroad_licenses(self) -> dict[str, str]: + return self._gumroad.get_licenses() + + def is_patreon_linked(self) -> bool: + return self._patreon is not None and self._patreon.is_configured() + + def unlink_patreon(self) -> None: + if self._patreon: + self._patreon.unlink() + self.invalidate_cache() + + def start_patreon_oauth( + self, on_complete: Callable[[bool, str | None], None] + ) -> tuple[int, Thread] | None: + if not self._patreon: + return None + return self._patreon.start_oauth_flow(on_complete) + + def get_patreon_oauth_url(self) -> str | None: + if not self._patreon: + return None + return self._patreon.get_oauth_url() diff --git a/rayforge/locale/am/LC_MESSAGES/rayforge.po b/rayforge/locale/am/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..8e2cb864a --- /dev/null +++ b/rayforge/locale/am/LC_MESSAGES/rayforge.po @@ -0,0 +1,8441 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "" + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "" + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "" + +#: rayforge/updater.py +msgid "Download" +msgstr "" + +#: rayforge/updater.py +msgid "New version available." +msgstr "" + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "" + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "" + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "" + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "" + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 +msgid "Unknown" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "" + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Automatically check for new Rayforge versions on startup" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Startup behavior" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +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 +msgid "Add Tab Here" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +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 "Paste" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "" + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "" + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Open File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "" +msgstr[1] "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "" +msgstr[1] "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "" + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "" + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "" + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "" + +#: rayforge/image/png/importer.py +msgid "Failed to scan PNG file: {}" +msgstr "" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Failed to process image data." +msgstr "" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF file contains no pages." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "" + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "" diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..06eb722a6 --- /dev/null +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -0,0 +1,8923 @@ +# German translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# Samuel Abels , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2025-07-24 22:08+0200\n" +"Last-Translator: Samuel Abels \n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "Suche nach Rayforge-Updates..." + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "Update-Prüfung fehlgeschlagen." + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "Rayforge {version} ist verfügbar." + +#: rayforge/updater.py +msgid "Download" +msgstr "Herunterladen" + +#: rayforge/updater.py +msgid "New version available." +msgstr "Neue Version verfügbar." + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "Rayforge ist auf dem neuesten Stand." + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "{name} Workflow" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "Flach" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "Drehtisch · {name}" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "Drehtisch" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "Ebene {}" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "{name} (Kopie)" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "Ungültige Anfrage" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "" +"Authentifizierung fehlgeschlagen - bitte überprüfe deinen API-Schlüssel" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "" +"Zugriff verweigert - bitte überprüfe die Berechtigungen deines API-Schlüssels" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "API-Endpunkt nicht gefunden - bitte überprüfe die Basis-URL" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "Anfragenlimit erreicht - bitte warte und versuche es erneut" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "Serverfehler - bitte versuche es später erneut" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "Dienst nicht verfügbar - bitte versuche es später erneut" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "Server hat Fehler {code} zurückgegeben" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "Verbindung fehlgeschlagen - bitte überprüfe dein Netzwerk" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "Modell '{model}' nicht gefunden. Verfügbar: {available}" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "Schnittgeschwindigkeit" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "Verfahrgeschwindigkeit" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "Einstellungen" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "Auswahl" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "Text (Einzeilig)" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "Baudrate darf nicht leer sein." + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "'{rate}' ist keine Standard-Baudrate." + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "Baudrate" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "Verbindungsgeschwindigkeit in Bits pro Sekunde" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "Hostname oder IP-Adresse darf nicht leer sein." + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "Ungültiges Hostname- oder IP-Adressformat." + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "Hostname / IP-Adresse" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "Ganzzahl" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "Der Wert muss mindestens {min_val} betragen." + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "Der Wert darf höchstens {max_val} betragen." + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "Port darf nicht leer sein." + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "Port muss eine Zahl sein." + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "Gleitkommazahl" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "Schieberegler (0-100%)" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "Text (Mehrzeilig)" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "Auswahl (Beschriftet)" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "Boolesch (Schalter)" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "URL darf nicht leer sein." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "URL muss ein Schema enthalten (z. B. 'http://')." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "URL muss einen Hostnamen enthalten." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "URL-Schema muss eines der folgenden sein: {schemes}." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "Ungültige URL: {error}" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "Serieller Port darf nicht leer sein." + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "Serieller Port" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "Mittellinie" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "Innen" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "Außen" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "Innen-Außen" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "Außen-Innen" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "Laser" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "Fräsen" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "PWM" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "Schneiden und Gravieren mit einem Laser" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "Fräsen und Nuten mit einer Spindel" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "Pulsweitenmodulierte Laserleistungssteuerung" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "Drehachsen-Aufsatz für zylindrische Objekte" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "Kern" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "Rohmaterial" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "Quelle" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "Syntaxfehler: {message}" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "Unbekannte Variable oder Funktion: '{name}'" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" +"Operator '{op}' kann nicht zwischen den Typen '{left}' und '{right}' " +"verwendet werden." + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "Kein Treiber" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "Keine Verbindung" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "Maschinenkoordinaten" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "Keine Einstellungen" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "Ressource '{resource}' wird derzeit von '{owner}' verwendet." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" +"Dieser Treiber wurde nicht getestet. Er funktioniert möglicherweise nicht. " +"Verwendung auf eigenes Risiko." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Dieser Treiber ist experimentell und kann ungelöste Probleme aufweisen. " +"Verwende ihn mit Vorsicht." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "Unknown" +msgstr "Unbekannt" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "Leerlauf" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "In Betrieb" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "Angehalten" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "Manuell" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "Alarm" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "Tür" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "Prüfung" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "Referenzfahrt" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "Ruhezustand" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "Werkzeug" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "Warteschlange" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "Gesperrt" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "Entsperren" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "Zyklus" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "Test" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "Frequenz" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "PWM-Frequenz in Hz" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "Pulsbreite" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "Pulsbreite in Mikrosekunden" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" +"Fehler beim Einrichten. Möglicherweise müssen die Geräteeinstellungen " +"bearbeitet werden." + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "Smoothie" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "Smoothieware über eine Telnet-Verbindung" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "Maschinenkoordinaten (G53)" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "Ungültiger Hostname oder IP-Adresse: „{host}“" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "Hostname" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "Die IP-Adresse oder der Hostname des Geräts" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "Port" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "Die Telnet-Portnummer" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "Hostname muss konfiguriert werden." + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "Ruida (UDP)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "Verbindung zu einem Ruida-Lasercontroller über UDP" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "Die IP-Adresse oder der Hostname des Ruida-Controllers" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "Hauptport" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "Der UDP-Port für Hauptbefehle (Standard: 50200)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "Jog-Port" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "Der UDP-Port für Jog-Befehle (Standard: 50207)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "Keine Antwort vom Controller" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "G-Code an einen OctoPrint-Server senden" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "IP-Adresse oder Hostname des OctoPrint-Servers" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "HTTP-Port des OctoPrint-Servers" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "API-Schlüssel" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" +"Gib einen API-Schlüssel manuell ein oder klicke auf 'Zugang anfordern', um " +"einen über das OctoPrint Application Keys Plugin zu erhalten." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" +"API-Schlüssel muss konfiguriert werden. Verwende die Schaltfläche 'Zugang " +"anfordern' oder gib einen API-Schlüssel manuell ein." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "" +"Authentifizierung fehlgeschlagen. Der API-Schlüssel möglicherweise ungültig " +"oder abgelaufen." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" +"Verbindung zu OctoPrint unter '{host}:{port}' konnte nicht hergestellt " +"werden. Überprüfe die Adresse und die Netzwerkverbindung." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "Authentifizierung fehlgeschlagen" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" +"Der API-Schlüssel ist ungültig oder abgelaufen. Bitte authentifiziere dich " +"erneut in den Geräteeinstellungen." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "OctoPrint hat keine Anmeldedaten zurückgegeben." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "Unerwartetes WebSocket-Frame." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "Server hat die WebSocket-Verbindung geschlossen." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "Druck fehlgeschlagen" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" +"OctoPrint hat gemeldet, dass der Druckauftrag fehlgeschlagen ist. Überprüfe " +"OctoPrint für Details." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "Treiber nicht mit einem Host konfiguriert." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "Authentifizierung beim Hochladen fehlgeschlagen." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" +"Drucker ist beschäftigt oder nicht betriebsbereit. Kann keinen neuen Auftrag " +"starten." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" +"OctoPrint hat die Datei akzeptiert, konnte aber nicht mit dem Drucken " +"beginnen. Der Drucker möglicherweise nicht betriebsbereit oder bereits " +"beschäftigt." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "" +"Datei konnte nicht zu OctoPrint unter '{host}:{port}' hochgeladen werden." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" +"OctoPrint unterstützt das Schreiben von Geräte-Firmware-Einstellungen über " +"seine API nicht." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" +"Tastbefehl gesendet. OctoPrint meldet keine Tastergebnisse über seine API." + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "Marlin (Seriell)" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "Marlin-Firmware über serielle Verbindung" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "Serieller Port für das Gerät" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "Port muss konfiguriert werden." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "Baudrate muss konfiguriert werden." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "Port nicht konfiguriert." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "Keine Antwort vom Gerät" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "Automatisch über den Erkennungsassistenten konfiguriert" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "GRBL (Telnet)" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "GRBL-kompatibler Controller über eine Raw-TCP/Telnet-Verbindung" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "TCP-Port für den Raw/Telnet-Dienst" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "Gerätestatus während Aufträgen abfragen" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" +"Das Gerät regelmäßig nach Position und Status abfragen, während ein Auftrag " +"läuft. Warnung: Einige Geräte haben Probleme, eine stabile Verbindung " +"aufrechtzuerhalten, wenn dies verwendet wird!" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "Deadlock-Erkennung" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" +"Erkennt und behebt serielle Kommunikationsdeadlocks während Aufträgen. Wenn " +"deaktiviert, wartet der Treiber einfach auf die Antwort der Maschine. " +"Deaktivieren bei falschen ALARM:3-Fehlern." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "Befehlsbuchstabe fehlt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" +"G-Code-Befehle benötigen einen Buchstaben gefolgt von einem Wert. Der " +"Befehlsbuchstabe wurde nicht gefunden." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "Ungültiges Zahlenformat" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" +"Der Wert fehlt oder ist nicht im richtigen numerischen Format. Überprüfe " +"deine G-Code-Syntax." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "Unbekannter Befehl" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" +"Dieser Grbl-Einstellungsbefehl wird nicht erkannt oder unterstützt. " +"Überprüfe die Befehlssyntax." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "Negativer Wert" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" +"Hier ist eine positive Zahl erforderlich, aber ein negativer Wert wurde " +"empfangen." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "Referenzfahrt deaktiviert" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" +"Referenzfahrt ist in den Einstellungen nicht aktiviert. Aktiviere die " +"Referenzfahrt ($22=1), um diese Funktion zu nutzen." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "Impulsdauer zu kurz" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" +"Die minimale Schrittimpulsdauer muss größer als 3 Mikrosekunden sein. " +"Überprüfe die Einstellung $0." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "Speicherfehler" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" +"Einstellungen wurden aufgrund eines Speicherlesefehlers auf Standardwerte " +"zurückgesetzt. Konfiguriere deine Einstellungen bei Bedarf neu." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "Maschine belegt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" +"Dieser Befehl kann nur verwendet werden, wenn die Maschine im Leerlauf ist. " +"Warte, bis der aktuelle Auftrag abgeschlossen ist." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "Befehle gesperrt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" +"Befehle können nicht gesendet werden, während sich die Maschine im Alarm- " +"oder Jog-Modus befindet. Lösche zuerst den Alarmzustand." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "Referenzfahrt erforderlich" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" +"Soft-Limits können nicht aktiviert werden, ohne dass die Referenzfahrt " +"ebenfalls aktiviert ist. Aktiviere zuerst die Referenzfahrt ($22=1)." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "Zeile zu lang" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" +"Die Befehlszeile enthält zu viele Zeichen und wurde ignoriert. Überprüfe die " +"Dateiformatierung." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "Einstellung zu hoch" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" +"Diese Einstellung überschreitet die maximal unterstützte Schrittrate. " +"Verwende einen niedrigeren Wert." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "Tür geöffnet" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" +"Die Sicherheitstür wurde als geöffnet erkannt. Schließe die Tür und setze " +"den Betrieb fort." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" +"Build-Info oder Startzeile überschreitet das Speicherlimit. Kürze die Zeile." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "Ziel außerhalb des Bereichs" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" +"Jog-Ziel liegt außerhalb der Verfahrgrenzen der Maschine. Bewege dich zu " +"einer Position innerhalb des Bereichs." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "Ungültiger Jog-Befehl" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" +"Jog-Befehl fehlt '=' oder enthält verbotenen G-Code. Überprüfe die Jog-" +"Syntax." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "Lasermodus-Fehler" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" +"Lasermodus erfordert PWM-Ausgabe für den Betrieb. Überprüfe deine " +"Hardwarekonfiguration." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "Spindel läuft nicht" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" +"Ein Bewegungsbefehl wurde gesendet, aber die Spindel läuft nicht. Starte die " +"Spindel vor der Bewegung." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "Spindelgeschwindigkeit stimmt nicht überein" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" +"Die aktuelle Spindelgeschwindigkeit stimmt nicht mit der vom Befehl " +"geforderten Geschwindigkeit überein. Warte, bis die Spindel die " +"Zielgeschwindigkeit erreicht hat." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "Nicht unterstützter Befehl" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" +"Dieser G-Code-Befehl wird von der Maschine nicht unterstützt. Überprüfe " +"deine Postprozessor-Einstellungen." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "Widersprüchliche Befehle" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" +"Mehrere Befehle aus derselben Gruppe in einer Zeile gefunden. Entferne den " +"doppelten Befehl." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "Vorschubrate fehlt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" +"Lege eine Vorschubrate fest, bevor du Bewegungsbefehle verwendest. Füge " +"einen F-Befehl hinzu, um die Geschwindigkeit anzugeben." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "Ganzzahl erforderlich" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" +"Dieser Befehl erfordert einen ganzzahligen Wert. Entferne alle " +"Dezimalstellen." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "Achsenkonflikt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "" +"Mehrere Befehle versuchen, dieselbe Achse zu verwenden. Vereinfache den " +"Befehl." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "Doppeltes Wort" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "Dasselbe G-Code-Wort erscheint mehr als einmal. Entferne das Duplikat." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "Achse fehlt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" +"Dieser Befehl erfordert XYZ-Achsenkoordinaten. Füge die fehlenden " +"Achsenwerte hinzu." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "Zeilennummer außerhalb des Bereichs" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" +"Die Zeilennummer muss zwischen 1 und 9.999.999 liegen. Verwende eine gültige " +"Zeilennummer." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "Wert fehlt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "" +"Dieser Befehl erfordert einen P- oder L-Wert. Füge den fehlenden Parameter " +"hinzu." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "Nicht unterstütztes Koordinatensystem" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" +"Nur G54-G59-Koordinatensysteme werden unterstützt. Verwende stattdessen " +"eines davon." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "Falscher Bewegungsmodus" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" +"G53-Befehl erfordert G0- oder G1-Bewegungsmodus. Stelle zuerst den korrekten " +"Bewegungsmodus ein." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "Unbenutzte Achsenwörter" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" +"Achsenwörter vorhanden, aber G80-Abbruch ist aktiv. Entferne die unbenutzten " +"Achsenwörter." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "Bogendaten fehlen" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" +"G2/G3-Bogenbefehl benötigt XYZ-Koordinaten. Füge die Achsenwerte für die " +"ausgewählte Ebene hinzu." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "Ungültiges Ziel" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" +"Dieser Bogen kann nicht erstellt oder die aktuelle Position nicht angetastet " +"werden. Überprüfe die Zielkoordinaten." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "Bogengeometriefehler" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" +"Bogenberechnung fehlgeschlagen. Versuche, den Bogen in kleinere Stücke zu " +"zerlegen, oder verwende stattdessen IJK-Offset." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "Bogenoffset fehlt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" +"G2/G3-Bogenbefehl benötigt IJK-Offsetwerte. Füge den fehlenden Offset für " +"die ausgewählte Ebene hinzu." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "Unbenutzte Wörter" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" +"Einige G-Code-Wörter in dieser Zeile werden von keinem Befehl verwendet. " +"Entferne die unbenutzten Wörter." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "Falsche Achse für Offset" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" +"Werkzeuglängenoffset funktioniert nur auf der konfigurierten Achse " +"(normalerweise Z-Achse). Überprüfe deine Einstellungen." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "Werkzeugnummer zu hoch" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" +"Werkzeugnummer überschreitet den maximal unterstützten Wert. Verwende eine " +"gültige Werkzeugnummer." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "Hartes Limit" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" +"Ein harter Endschalter wurde ausgelöst. Die Maschine wurde gestoppt und muss " +"zurückgesetzt werden. Überprüfe auf Hindernisse und kontrolliere die " +"Endschalter." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "Weiches Limit" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" +"Die Maschine würde sich über ihre konfigurierten Verfahrgrenzen hinaus " +"bewegen. Überprüfe, ob dein Arbeitsbereich und deine Koordinatenversätze " +"korrekt sind." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "Zyklus abbrechen" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" +"Der aktuell laufende Auftrag wurde während der Bewegung abgebrochen. Setze " +"die Maschine zurück, um fortzufahren." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "Tastfehler — Initial" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" +"Die Taste hat vor dem Erreichen der maximalen Verfahrstrecke keinen Kontakt " +"hergestellt. Überprüfe die Tastverkabelung und Positionierung." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "Tastfehler — Final" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" +"Die Taste konnte nach dem Kontakt nicht zur Zielposition zurückfahren. " +"Überprüfe die Tastkonfiguration." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "Referenzfahrt fehlgeschlagen — Zurücksetzen" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" +"Die Referenzfahrt konnte nicht abgeschlossen werden, da sich die Maschine " +"imAlarmzustand befindet. Lösche den Alarm und versuche es erneut." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "Referenzfahrt fehlgeschlagen — Annäherung" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" +"Der Referenzfahrtzyklus hat den Schalter nicht innerhalb der konfigurierten " +"Verfahrstrecke gefunden. Überprüfe die Schalterverkabelung und dieAbzugs-" +"Einstellungen." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "Referenzfahrt fehlgeschlagen — Abzug" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" +"Der Referenzfahrtzyklus konnte sich nach dem Kontakt nicht erfolgreich vom " +"Schalter lösen. Erhöhe die Abzugsdistanz oder überprüfe den Schalter." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "Referenzfahrt ohne Limits" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" +"Referenzfahrt wurde angefordert, aber Endschalter sind nicht konfiguriert. " +"Aktiviere zuerst die Endschalter." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "Referenzfahrt fehlgeschlagen — Doppelachse" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" +"Die Referenzfahrt ist in einer Doppelachsen-Konfiguration fehlgeschlagen. " +"Eine oder beide Achsen haben ihre Endschalter nicht erreicht. Überprüfe die " +"Verkabelung und Konfiguration der Endschalter." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "Unbekannter Alarm" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "Ungültiger Alarmcode von der Maschine gemeldet." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" +"Die Maschine hat einen unbekannten Alarmcode gemeldet. Überprüfe die " +"Dokumentation deiner Maschine und Firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "Unbekannter Fehler" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "Ungültiger Fehlercode von Maschine gemeldet." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" +"Die Maschine hat einen unbekannten Fehlercode gemeldet. Überprüfe die " +"Dokumentation deiner Maschine und Firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "Schrittmotor-Konfiguration" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "Einstellungen zu Schrittmotor-Timing und Signalpolarität." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "Steuerung & Berichte" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "Einstellungen für GRBLs Bewegungssteuerung und Statusberichte." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "Endschalter & Referenzfahrt" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "Einstellungen für Soft-/Hard-Limits und den Referenzfahrtzyklus." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "Spindel & Laser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "Einstellungen zur Steuerung des Spindel- oder Lasermoduls." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "Achsenkalibrierung" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "Definiert die Schritte pro Millimeter für jede Achse." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "Achsenkinematik" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "" +"Definiert die maximale Geschwindigkeit und Beschleunigung für jede Achse." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "Verfahrweg der Achsen" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "Definiert den maximalen Verfahrweg für jede Achse." + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "GRBL (Seriell)" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "GRBL-kompatible serielle Verbindung" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "RX-Puffergröße überschreiben" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" +"Erzwinge eine bestimmte RX-Puffergröße in Bytes. Setze auf 0 für " +"automatische Erkennung vom Gerät." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "Unbekannte Einstellungen" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" +"Vom Gerät gemeldete Einstellungen, die nicht in der Standardliste enthalten " +"sind." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "Unbekannte Einstellung vom Gerät" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" +"Das Gerät ist so konfiguriert, dass es in Zoll berichtet ($13=1). Alle " +"angezeigten Werte sind in Maschineneinheiten." + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" +"Lasermodus ist nicht aktiviert ($32=0). Für beste Ergebnisse mit " +"Lasergeräten aktivieren." + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "GRBL (Serial Simple)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "GRBL-Seriell mit einfachem Ping-Pong-Protokoll (ohne Pufferzählung)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "Baudrate muss konfiguriert werden." + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "GRBL (Netzwerk)" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "Über Netzwerk mit einem GRBL-kompatiblen Gerät verbinden" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "HTTP-Port" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "Der HTTP-Port für das Gerät" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "WebSocket-Port" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "Der WebSocket-Port für das Gerät" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "Protokollvariante" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "Standard-, ESP3D- oder Longer-GRBL-Variante" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "Standard" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" +"Host ist nicht konfiguriert. Bitte gib eine gültige IP-Adresse oder einen " +"Hostnamen an." + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" +"Verbindung zum Host „{host}“ konnte nicht hergestellt werden. Überprüfe die " +"IP-Adresse und die Netzwerkverbindung." + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "Sperrzone" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "Außerhalb des Arbeitsbereichs" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "Maschinengrenzen" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "{name} (Geräte-Dialekt)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "• Kamerakalibrierung: Matrix + Verzerrung gefunden" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "(keine Felder zugeordnet)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "Gerätename" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "Arbeitsbereich" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "Treiber" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "Baudrate" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "Referenzfahrt beim Start" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "Maximale Verfahrgeschwindigkeit" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "Ursprung" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "Spiegelung X" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "Spiegelung Y" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "Kamerakalibrierung" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "Matrix + Verzerrung importiert" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "Spindelkopf" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "{label} (für {machine_name})" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "Laserkopf" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "Standardmaschine" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "Rotationsmodul" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "Kopf" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "Für diese Maschine ist kein Treiber ausgewählt." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "Treiber „{driver}“ nicht gefunden." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "" +"Während der Validierung ist ein unerwarteter Fehler aufgetreten: {error}" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "GRBL Raster" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" +"Optimiert für GRBL-Rastergravur. Hält den M4-Dynamikleistungsmodus " +"kontinuierlich aktiv und verwendet modale Vorschubgeschwindigkeit, um den " +"Befehls-Overhead während der Scanlinien zu minimieren." + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "Mach4 (M67 Analog)" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" +"Mach4 mit M67 Analogausgang für hochspeed-Rastergravur. Verwendet M67 E0 " +"Q<0-255> für die Laserleistung anstelle von Inline-S-Befehlen, um den " +"Pufferdruck auf dem Controller zu reduzieren." + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "Smoothieware" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "G-Code-Dialekt für Smoothieware-basierte Controller" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "LinuxCNC" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "" +"G-Code für LinuxCNC mit Unterstützung für nativ kubische Bézierkurven (G5)" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "GRBL Dynamisch" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" +"GRBL mit M4 dynamischem Leistungsmodus (Tiefenabhängig). S-Parameter ist in " +"Bewegungsbefehlen enthalten" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "Allgemeine Informationen" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "Bezeichnung" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "Name für die Anzeige" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "Beschreibung" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "Kurzbeschreibung" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "Unveränderte Koordinaten weglassen" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" +"Wenn aktiviert, werden Achsbuchstaben, die sich nicht geändert haben, von G0/" +"G1-Befehlen weggelassen" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "Kontinuierlicher Lasermodus" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" +"Hält den M4-Dynamikleistungsmodus während der Rastergravur kontinuierlich " +"aktiv, anstatt M4/M5 zwischen jedem Segment umzuschalten" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "Modale Vorschubgeschwindigkeit" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" +"F-Vorschubparameter nur dann in Bewegungsbefehlen einschließen, wenn er sich " +"vom vorherigen Wert ändert" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "Befehlsvorlagen" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "Laser An" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "Laser Aus" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "Laser fokussieren" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "Verfahrbewegung" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "Lineare Bewegung" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "Bogen (im Uhrzeigersinn)" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "Bogen (gegen Uhrzeigersinn)" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "Kubische Bézierkurve" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "Werkzeugwechsel" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "Geschwindigkeit setzen" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "Druckluft An" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "Druckluft Aus" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "Referenzfahrt Alle" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "Achse referenzieren" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "Fahre zu" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "Alarm zurücksetzen" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "WCS-Versatz setzen" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "Tastzyklus" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "Verweilen" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "Spindle ein (CW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "Spindle ein (CCW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "Spindle aus" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "Kühlmittel Flut" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "Kühlmittel Nebel" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "Kühlmittel aus" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "Skripte" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "WCS nach Vorspann einfügen" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" +"Füge den aktiven WCS-Befehl (z. B. G54) nach dem Vorspann-Skript ein. Wenn " +"deaktiviert, kannst du stattdessen {machine.active_wcs} im Vorspann " +"verwenden." + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "Vorspann" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "Vorspann" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "Nachspann" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "Nachspann" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "Marlin" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "G-Code für Marlin-basierte Controller, häufig in 3D-Druckern" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "Grbl (Kompatibilität)" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" +"Grbl-Dialekt mit höchster Kompatibilität für die meisten Diodenlaser und " +"Hobby-CNCs" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "Ebenenbeginn" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "Ebenenende" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "Werkstückbeginn" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "Werkstückende" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "Vor der Verarbeitung einer Ebene" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "Nach der Verarbeitung einer Ebene" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "Vor der Verarbeitung eines Werkstücks" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "Nach der Verarbeitung eines Werkstücks" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "Unbenanntes Makro" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "{job_name} fehlgeschlagen: {error}" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" +"Serielle Schnittstellen konnten aufgrund einer Snap-Beschränkung nicht " +"aufgelistet werden! Bitte stell sicher, dass das Gerät über USB " +"angeschlossen ist und führe aus:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" +"Serielle Schnittstellen gefunden, aber keine ist zugänglich. Bitte stell " +"sicher, dass dein Snap die 'serial-port'-Schnittstelle verbunden hat, indem " +"du Folgendes ausführst:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "Wird verbunden" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "Verbunden" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "Fehler" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "Wird geschlossen" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "Getrennt" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "Ruhezustand" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "Maschinen" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "Konfigurierte Maschinen" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "Maschinen hinzufügen oder entfernen." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "Diese Maschine hat eine ungültige Konfiguration." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "Dies ist die aktive Maschine." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "„{name}“ löschen?" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Dieses Maschinenprofil und alle seine Einstellungen werden dauerhaft " +"entfernt. Diese Aktion kann nicht rückgängig gemacht werden." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "Abbrechen" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "Löschen" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "Maschine hinzufügen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "Lizenzen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "Verknüpfe dein Patreon-Konto für frühen Zugang zu neuen Erweiterungen." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "Patreon-Konto verknüpft" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "Frühzugang-Erweiterungen sind freigeschaltet" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "Verknüpfung aufheben" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "Patreon-Konto verknüpfen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "Frühen Zugang zu Premium-Erweiterungen erhalten" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "Verknüpfen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "Erweiterungs-Lizenzen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "Verwalte deine gekauften Lizenzschlüssel." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "Keine Lizenzen installiert" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" +"Kaufe eine Premium-Erweiterung und gib den Lizenzschlüssel während der " +"Installation ein." + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "{addons} (+{count} weitere)" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "Produkt-ID: {id}" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "Entfernen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "Erweiterungen, die eine Lizenz benötigen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "" +"Diese Erweiterungen benötigen eine gültige Lizenz, um aktiviert zu werden" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "Lizenz erforderlich" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "Kaufen" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "Lizenz entfernen?" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" +"Dieser Lizenzschlüssel wird entfernt. Möglicherweise musst du ihn erneut " +"eingeben, um lizenzierte Erweiterungen zu nutzen." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "Standardanbieter" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "Anbieter aktivieren oder deaktivieren" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "Als Standard festlegen" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "Anbieter hinzufügen" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "Keine Anbieter konfiguriert" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "Neuer Anbieter" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "„{name}“ löschen?" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" +"Dieser KI-Anbieter wird dauerhaft entfernt. Diese Aktion kann nicht " +"rückgängig gemacht werden." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "Name" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "Anbietertyp" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "OpenAI-kompatibel" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "Basis-URL" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "Standardmodell" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "Verbindungstest" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "Überprüfen, ob die Anbieterkonfiguration funktioniert" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "Anbieter bearbeiten" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "Anbietereinstellungen" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "Testen..." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "KI" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "KI-Anbieter" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" +"Konfigurieren Sie KI-Anbieter für die Verwendung durch Addons. Addons können " +"Diese Anbieter verwenden, ohne eigene API-Schlüssel zu benötigen." + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "Erweiterungen" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "Installierte Erweiterungen" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "Erweiterungen installieren, aktualisieren und entfernen." + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "Rezepte" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "" +"Verwalte deine gespeicherten Rezepte für verschiedene Materialien und " +"Prozesse." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "Farbregel bearbeiten" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "Aktualisieren Sie die Details der Farbregel:" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "Speichern" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "Farbregel hinzufügen" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "Ordnen Sie eine Farbe einem Schritttyp für SVG-Importe zu." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "Hinzufügen" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "Farbe" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "SVG-Farbe, die diese Regel auslöst" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "Beschriftung (optional)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "Schritttyp" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "Schritttyp, der beim Import dieser Farbe erstellt wird" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "Farbe {color}" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "Dieser Schritttyp ist derzeit nicht verfügbar." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "{step_type} (nicht verfügbar)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "Keine Farbregeln gefunden." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "Farbregel '{color}' löschen?" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" +"Die Farbregel wird dauerhaft entfernt. Diese Aktion kann nicht " +"rückgängiggemacht werden." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "Farbregeln" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" +"Ordnen Sie SVG-Farben Schritttypen zu, damit sie beim Import " +"automatischangewendet werden." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "Materialien" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "Materialbibliotheken" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" +"Verwalte deine Materialbibliotheken. Wähle eine Bibliothek aus, um ihre " +"Materialien anzuzeigen." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "Materialien in der ausgewählten Bibliothek." + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "Kategorien" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "Englisch" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "Deutsch" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "Spanisch" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "Französisch" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "Portugiesisch" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "Ukrainisch" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "Chinesisch (vereinfacht)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "System" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "Hell" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "Dunkel" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "Nichts öffnen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "Letztes Projekt öffnen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "Bestimmtes Projekt öffnen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "Laserfarbe" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "Ebenenfarbe" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "Systemstandard" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "Allgemein" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "Erscheinungsbild" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "Einstellungen zum Erscheinungsbild der Anwendung." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "Thema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "Sprache" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "Die Anwendungssprache. Änderungen erfordern einen Neustart." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "Operationsfarben" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "Wähle, ob Operationsfarben den Laser oder die Ebene darstellen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "Einheiten" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" +"Lege die Anzeigeeinheiten für verschiedene Werte in der gesamten Anwendung " +"fest." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "Länge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "Geschwindigkeit" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "Beschleunigung" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "Verhalten" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "Erweitertes Anwendungsverhalten konfigurieren." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "Operationen automatisch aktualisieren" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" +"Operationen nach jeder Änderung automatisch neu berechnen. Deaktivieren für " +"manuelle Neuberechnung über die Symbolleisten-Schaltfläche" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "Cache-Budget (MB)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" +"Maximaler Speicher für Cache. Szenen mit hoher Komplexität benötigen mehr" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "Nach Updates suchen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +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 "Startup behavior" +msgstr "Startverhalten" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "Projektpfad" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "Durchsuchen..." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "Datenschutz" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" +"Hilf uns, Rayforge zu verbessern, indem du anonyme Nutzungsberichte " +"erlaubst. Es werden keine persönlichen Daten gesammelt." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "Anonyme Nutzungsberichte" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "Hilf Rayforge verbessern" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" +"Mehr " +"erfahren über Nutzungsstatistiken und Datenschutz." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "Neustart erforderlich" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" +"Die Sprache wird erst nach einem Neustart von Rayforge aktiv. Möchtest du " +"jetzt neu starten?" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "_Abbrechen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "_Neu starten" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "Kopien behalten ihre ursprünglichen Ebenen." + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "_Anwenden" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "Raster-Array" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "Raster" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "Zeilen" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "Spalten" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "Versatz" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "Abstand" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "Abstand" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "Versatz ist von Mitte zu Mitte; Abstand ist von Kante zu Kante." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "Spaltenabstand" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "Zeilenabstand" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "Punktrotations-Array" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "Punktrotation" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "Rotiert Kopien an Ort und Stelle um das Zentrum der Auswahl." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "Anzahl" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "Gesamtwinkel (Grad)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "Kreis-Array" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "Kreisförmig" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "Platziert Kopien entlang eines Kreisbogens um ein Zentrum." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "Zentrum X" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "Zentrum Y" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "Radius" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "Kopien drehen" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "Haltesteg verschieben" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "Eine Ebene nach oben verschieben" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "Eine Ebene nach unten verschieben" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "Gruppieren" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +msgstr "Gruppierung aufheben" + +#: 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 +msgid "Add Tab Here" +msgstr "Haltesteg hier hinzufügen" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "Haltesteg entfernen" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "Neue Skizze" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "Neues Material" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +msgstr "Datei importieren…" + +#: 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 "Paste" +msgstr "Einfügen" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "{} Instanz hinzufügen" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "Dateien zum Importieren hier ablegen" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "Bild aus Zwischenablage importiert" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "Fehler beim Importieren des Bildes aus der Zwischenablage" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "3D-Ansicht ist aufgrund fehlender Abhängigkeiten nicht verfügbar." + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "Wähle eine Maschine aus, um die 3D-Ansicht zu öffnen." + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "Material hinzufügen" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "Automatische Anordnung (Einfach)" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "{camera_name} - Objektivkalibrierung" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "Kamera-Bildeinstellungen" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "Bildqualität und Darstellungsparameter anpassen." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "Standard" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "Benutzerdefiniert..." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "Auflösung" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" +"Auflösung der Kameraaufnahme. Standardmäßig wird die native Einstellung der " +"Kamera verwendet." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "Benutzerdefinierte Breite" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "Benutzerdefinierte Höhe" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "YUYV-Format bevorzugen" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" +"Unkomprimiertes YUYV statt MJPEG verwenden. Behebt grüne Artefakte bei " +"einigen USB-Kameras, kann aber die Auflösung oder Bildrate an USB 2.0 " +"reduzieren." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "Automatischer Weißabgleich" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "Weißabgleich automatisch anpassen" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "Weißabgleich (Kelvin)" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "Farbtemperatur für genaue Farbdarstellung" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "Kontrast" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "Unterschied zwischen hellen und dunklen Bereichen" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "Helligkeit" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "Gesamthelligkeit oder Dunkelheit des Bildes" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "Rauschunterdrückung" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "Zeitliche Mittelung, höhere Werte verursachen Schlieren" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "Transparenz" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "Transparenz auf der Arbeitsfläche" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "Bitte wähle ein verfügbares Kameragerät aus" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "Bitte wähle eine konfigurierte Kamera aus" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "Kamera auswählen" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "Keine Kameras konfiguriert." + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "Fehler beim Laden des Bildes für Geräte-ID: {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "Kamera {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "Keine Kameras gefunden." + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "Punkt {n}" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "Diesen Punkt löschen" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "Pixel verschieben:" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "Kameraeigenschaften" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "Konfiguriere die ausgewählte Kamera." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "Geräte-ID" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "Systemkennung für das Kameragerät" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "Anzeigename für diese Kamera" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "Aktiviert" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "Kamerastream ein- oder ausschalten" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "Start" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "Kamera-Assistent" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" +"Geführte Einrichtung: Bildeinstellungen, Objektivkalibrierung und " +"Ausrichtung." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "Konfigurieren" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "Bildeinstellungen" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "Helligkeit, Kontrast, Weißabgleich und Rauschen anpassen" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "Objektivkalibrierung" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "Objektivverzerrung für geradere Linien korrigieren" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "Bildausrichtung" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "Kameraposition und Perspektive kalibrieren" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "Objektivkalibrierung abgeschlossen" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "Objektivkalibrierung noch nicht durchgeführt" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "Bildausrichtung abgeschlossen" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" +"Bildausrichtung muss nach Aktualisierung der Objektivkalibrierung wiederholt " +"werden" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "Bildausrichtung noch nicht durchgeführt" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "Warte auf Kamera..." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" +"Korrigiert die Objektivverzerrung für geradere Linien. Wähle, wie kalibriert " +"werden soll, oder überspringe den Schritt, wenn dein Objektiv eine " +"vernachlässigbare Verzerrung aufweist." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "Automatisch" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "Automatische Kalibrierung" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" +"Drucke eine Kalibrierkarte aus und erfasse sie an mehreren Positionen. Der " +"Assistent berechnet die Verzerrungskoeffizienten für dich." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "Manuell" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "Manuelle Kalibrierung" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "" +"Gib die radialen und tangentialen Verzerrungskoeffizienten von Hand ein." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "Überspringen" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "Kalibrierkarte" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "Anleitung" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" +"Drucken Sie eine Kalibrierungskarte zur Korrektur der Objektivverzeichnung. " +"Die Kartengröße sollte in Ihr Kamerabild passen." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "Kartengröße" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "Passen Sie die Größe an Ihre Arbeitsfläche an." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "Breite" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "Kartenbreite" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "Höhe" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "Kartenhöhe" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "Erzeugtes Muster" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "Details zum Kalibrierungsmuster." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "Rastergröße" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "Quadratgröße" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "Physische Größe" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "Als PDF speichern" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "Kalibrierungskarte zum Drucken exportieren" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "Kalibrierungskarte speichern" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "Kalibrierungskarte gespeichert" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "Einzelbilder erfassen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" +"Nehmen Sie die Karte an verschiedenen Positionen auf. Wichtig: Bildecken und " +"-ränder für eine genaue Verzeichnungskorrektur einbeziehen." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "Status" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "Fortschritt des Kalibrierungsaufnahmeprozesses." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "Aufgenommene Bilder" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "Erkannte Ecken" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "Abdeckung" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "Nicht gestartet" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "Karte verschieben, um weitere Positionen aufzunehmen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "Aufnahmefortschritt" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "Bild aufnehmen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "Löschen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "Kalibrieren" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "Gut" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "Begrenzt — Ränder erreichen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "Schlecht — alle Ecken erreichen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "Kalibrierung fehlgeschlagen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "Kalibrierung abgeschlossen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" +"RMS-Fehler: {rms:.4f} Pixel\n" +"Qualität: {quality}\n" +"Verwendete Bilder: {frames}" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "Verwerfen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "Kalibrierung speichern" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "{camera} - Kamera-Assistent" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "Zurück" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "Weiter" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "Fertig" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "OK" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "Radial 1 (k1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "Radialverzerrung erster Ordnung" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "Radial 2 (k2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "Radialverzerrung zweiter Ordnung" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "Radial 3 (k3)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "Radialverzeichnung dritter Ordnung" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "Tangential 1 (p1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "Tangentialverzerrung erster Ordnung" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "Tangential 2 (p2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "Tangentialverzerrung zweiter Ordnung" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" +"Korrigiert die Objektivverzerrung für geradere Linien. Passe die " +"Koeffizienten manuell an." + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "{camera_name} – Bildausrichtung" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "Verkleinern (Scrollen nach unten)" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "An Fenster anpassen" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "Vergrößern (Scrollen nach oben)" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "{camera_name} – Kamera-Bildeinstellungen" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "Geräte-ID: {device_id}" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "Neue Kamera hinzufügen" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "Keine Kameras konfiguriert" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "Bildverbesserung" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "Rauschen reduzieren und Bildstabilität verbessern." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" +"Zeitliche Mittelung. Höhere Werte entfernen mehr Rauschen, verursachen aber " +"Schlieren." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" +"Gekrümmte Linien mit Radial- (k1, k2) und Tangentialparametern (p1, p2) " +"begradigen. Hinweis: Werte sind normalerweise sehr klein." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "Objektivverzerrungskorrektur (Fisheye)" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "Kamera" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "Kameras" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "Ein Kamerabild direkt auf die Arbeitsfläche streamen." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" +"Klicken Sie auf das Bild, um Referenzpunkte hinzuzufügen. Ziehen Sie, um sie " +"zu verschieben.\n" +"Scrollen zum Zoomen. Mittelklick und Ziehen zum Schwenken.\n" +"Verwenden Sie die Pfeiltasten, um den aktiven Punkt präzise zu verschieben." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "Punkte zurücksetzen" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "Alle Punkte löschen" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "Anwenden" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "Neues Makro hinzufügen" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "Keine Makros konfiguriert" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "Neues Makro" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "Werkzeug {tool_number}, {min_rpm}-{max_rpm} U/min" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" +"Werkzeug {tool_number}, max. Leistung {max_power}, Punktgröße {spot_x}" +"x{spot_y}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "Werkzeug {tool_number}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "Neuen Kopf hinzufügen" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "Keine Köpfe konfiguriert" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "Mindestens ein Kopf ist erforderlich" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "Spindel" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "Neuer Laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "Neue Spindel" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "3D-Modell" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "Wähle ein 3D-Modell für diesen Kopf aus und konfiguriere es." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "Modell" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "Skalierung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "Einheitlicher Skalierungsfaktor für das Modell" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "X-Drehung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "Grad um die X-Achse" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "Y-Drehung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "Grad um die Y-Achse" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "Z-Drehung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "Grad um die Z-Achse" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "Keine" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "Lasereigenschaften" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "Konfiguriere den ausgewählten Laserkopf." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" +"Pulsweitenmodulation-Einstellungen zur Steuerung von Frequenz und Pulsbreite." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "Rahmen" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "" +"Einstellungen für die Rahmenoperation, die die Arbeitsbereichsgrenze " +"nachfährt." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "Werkzeugnummer" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "G-Code-Werkzeugnummer (z. B. T0, T1)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "Diode" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "CO₂" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "Faser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "Lasertyp" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "Art der Laserröhre oder Diode" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "Max. Leistung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "Maximaler Leistungswert im G-Code" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "Fokusleistung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "Leistung in Prozent für die Fokussierung. 0 zum Deaktivieren." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "Punktgröße X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "Größe des Laserpunkts in X-Richtung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "Punktgröße Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "Größe des Laserpunkts in Y-Richtung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "Schnittfarbe" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "Farbe für Schnittoperationen" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "Rasterfarbe" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "Farbe für Gravur-/Rasteroperationen" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "Brennweite" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "Abstand vom Laserkopf zur Arbeitsfläche (Z-Versatz)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "PWM-Frequenz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "Standard-PWM-Frequenz in Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "Max. PWM-Frequenz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "Maximal unterstützte PWM-Frequenz in Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "Standard-Pulsbreite in µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "Min. Pulsbreite" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "Minimale Pulsbreite in µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "Max. Pulsbreite" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "Maximale Pulsbreite in µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "Rahmenleistung" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "Leistung in Prozent für die Umrandung. 0 zum Deaktivieren." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "Rahmengeschwindigkeit" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" +"Geschwindigkeit für den Rahmen. Bei 0 wird die maximale " +"Verfahrgeschwindigkeit der Maschine verwendet" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "Wiederholungsanzahl" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "Anzahl der Wiederholungen der Rahmenkontur" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "Pause an Ecken" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "Pausendauer in Sekunden an jeder Ecke des Rahmens. 0 zum Deaktivieren" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "Spindeleigenschaften" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "Konfiguriere den ausgewählten Spindelkopf." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "Min. U/min" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "Minimale Spindeldrehzahl" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "Max. U/min" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "Maximale Spindeldrehzahl" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "Unterstützt Flutkühlmittel" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "Kühlmittel wird als Flut auf das Werkstück aufgebracht" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "Unterstützt Nebelkühlmittel" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "Kühlmittel wird als Nebel auf das Werkstück aufgebracht" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "Köpfe" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "" +"Du kannst mehrere Laser oder Spindeln konfigurieren, wenn deine Maschine " +"dies unterstützt." + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "Maschine hinzufügen" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "Maschine erstellen" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "Maschine konnte nicht erstellt werden" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "Kamera-Einrichtung nicht verfügbar" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "Kalibriere diese Kamera später über die Maschineneinstellungen." + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "Ausführliche Ausgabe anzeigen (Statusabfragen)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "Rechteck" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "Quader" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "Zylinder" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "Zone hinzufügen" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "Keine Sperrzonen konfiguriert" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "Neue Zone" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "Sperrzonen" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" +"Definiere eingeschränkte Bereiche auf der Arbeitsfläche. Eine Warnung wird " +"angezeigt, bevor ein Auftrag ausgeführt oder exportiert wird, dessen " +"Werkzeugweg eine aktivierte Sperrzone betritt." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "Zone-Eigenschaften" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "Konfiguriere die ausgewählte Zone." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "Form" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "Zonengeometrieform" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "X" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "X-Position in {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "Y" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "Y-Position in {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "Z" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "Z-Position in {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "Tiefe" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "Tiefe (Z-Ausdehnung)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "Zylinderradius" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "Zylinderhöhe" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "Zylinderhöhe" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "Maskierte geschweifte Klammern {{ oder }} werden nicht unterstützt." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "Verschachtelte geschweifte Klammern sind nicht zulässig." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "Unpassende schließende geschweifte Klammer '}' gefunden." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "Unpassende öffnende geschweifte Klammer '{' gefunden." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "Leere geschweifte Klammern '{}' sind nicht zulässig." + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "Nicht unterstützte Variable(n): {vars}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "Dialekt bearbeiten: {label}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "Neuer Dialekt" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "Aus Vorlage aktualisieren" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "Die Bezeichnung darf nicht leer sein." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" +"Wählen Sie eine Vorlage, um deren Einstellungen zu kopieren. Ihre " +"Bezeichnung und Beschreibung bleiben erhalten." + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "G-Code-Hooks" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" +"Füge benutzerdefinierten G-Code hinzu, der an bestimmten Punkten im Auftrag " +"ausgeführt wird." + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "Auf Standard zurücksetzen" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "„{hook_name}“ auf Standard zurücksetzen?" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" +"Dadurch wird dein benutzerdefinierter G-Code für diesen Hook entfernt. Die " +"Maschine verwendet dann wieder ihr integriertes Standardmakro. Diese Aktion " +"kann nicht rückgängig gemacht werden." + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "Zurücksetzen" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "# Dein G-Code hier" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "Geräteprofil-Archive" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "LightBurn-Geräteprofile" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "Alle Dateien" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "Geräteprofil importieren" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "Makro bearbeiten" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "Variable einfügen" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "Makro einfügen" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "Makro für {name} bearbeiten" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "Verfügbare Variablen" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "Keine anderen Makros zum Einfügen vorhanden." + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "Name darf nicht leer sein." + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "Name enthält ungültige Zeichen: {chars}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "Dieser Name wird bereits von einem anderen Makro verwendet." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "Arbeitsversätze bearbeiten" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" +"Gib den Versatz von Maschinennullpunkt zu Arbeitsnullpunkt für das aktive " +"WCS ein." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "X-Versatz" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "Y-Versatz" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "Z-Versatz" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "Zähler zurücksetzen" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "Zähler bearbeiten" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "Zähler entfernen" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "Zähler zurücksetzen?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "Dadurch werden die gesammelten Stunden auf Null zurückgesetzt." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "Zähler entfernen?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" +"Möchtest du diesen Zähler wirklich entfernen? Diese Aktion kann nicht " +"rückgängig gemacht werden." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "Zähler hinzufügen" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "Keine Zähler konfiguriert" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "Neuer Zähler" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "Benachrichtigungsintervall" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" +"Benachrichtigung anzeigen, wenn der Zähler diesen Wert erreicht (Stunden). " +"Auf 0 setzen, um zu deaktivieren." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "Wartung" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "Gesamtstunden" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "Kumulierte Betriebszeit, die von der Maschine verfolgt wird." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "Gesamtbetriebsstunden" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "Kumulierte Maschinenbetriebszeit" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "Gesamtstunden zurücksetzen" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "Wartungszähler" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" +"Wartungsintervalle mit zurücksetzbaren Zählern verfolgen. Für Laserröhren, " +"Schmierung usw. verwenden." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "{time} gesamt" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "Gesamtstunden zurücksetzen?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" +"Dadurch werden die gesamten kumulierten Betriebsstunden auf Null " +"zurückgesetzt. Wartungszähler sind davon nicht betroffen." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "Gerät" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "Geräteeinstellungen" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "Einstellungen direkt vom Gerät lesen oder anwenden." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "Vom Gerät lesen" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "" +"Der aktuelle Treiber unterstützt das Lesen von Geräteeinstellungen nicht." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "Fehlerdetails kopieren" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "Fehler ausblenden" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" +"Das Ändern dieser Werte kann gefährlich sein und deine Maschine unbrauchbar " +"machen!" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" +"Das Gerät kann nach einer Einstellungsänderung neu starten oder die " +"Verbindung vorübergehend trennen." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "Warnung ausblenden" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" +"Klicke auf die Aktualisieren-Schaltfläche, um Einstellungen vom Gerät zu " +"laden." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "Vorgang fehlgeschlagen" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "Maschine nicht verbunden" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "Die Maschine ist nicht verbunden." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "Einstellung erfolgreich angewendet." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "Verbindung nicht möglich: Wird von '{machine}' verwendet" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "Maschine aktiviert." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "LightBurn-Profil importieren?" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" +"LightBurn-Geräteprofile enthalten nur grundlegende Maschineneinstellungen. " +"Das importierte Profil kann unvollständig sein. Bitte überprüfe nach dem " +"Import und konfiguriere zusätzliche Einstellungen wie Laserköpfe, " +"Referenzfahrt, Endschalter, G-Code-Dialekt, Makros und Rotationsmodule." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "Trotzdem importieren" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "Die folgenden Werte werden importiert:" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "Hooks & Makros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "Makros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "Erstellen und verwalten wiederverwendbarer G-Code-Schnipsel." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "Erweitert" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "Pfadverarbeitung" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "Konfiguriere, wie Pfade verarbeitet und optimiert werden." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "Bögen unterstützen" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" +"Generiere Bogenbefehle für glattere Pfade. Deaktiviere, wenn deine Maschine " +"keine Bögen unterstützt" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "Bézierkurven unterstützen" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" +"Native kubische Bézier-Befehle erzeugen. Deaktivieren, wenn Ihre Maschine " +"diese nicht unterstützt" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "Bogen- und Kurventoleranz" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" +"Maximale Abweichung vom ursprünglichen Pfad beim Einpassen von Bögen " +"undKurven. Kleinere Werte erhöhen die Verarbeitungszeit und " +"Auftragsgrößeerheblich." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "Referenzfahrt und Start" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" +"Konfiguriere das Referenzfahrtverhalten und Starteinstellungen, " +"einschließlich automatischer Referenzfahrt und Alarmbehandlung." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "Beim Start referenzieren" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "Beim Start der Anwendung einen Referenzierungsbefehl senden" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "Referenzfahrt für einzelne Achsen erlauben" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "" +"Steuerelemente für die Referenzfahrt einzelner Achsen im manuellen " +"Steuerungsdialog aktivieren" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "Alarm bei Verbindung löschen" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" +"Automatisch einen Entsperrbefehl senden, wenn im ALARM-Zustand verbunden" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "Diesen Dialekt auswählen" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "„{label}“ löschen?" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" +"Dieser benutzerdefinierte Dialekt wird dauerhaft entfernt. Diese Aktion kann " +"nicht rückgängig gemacht werden." + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "Dialekt kann nicht gelöscht werden" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "" +"Dieser Dialekt wird noch von der/den folgenden Maschine(n) verwendet: " +"{machines}" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "Aus Vorlage erstellen" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "Keine benutzerdefinierten Dialekte konfiguriert" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "{label} (Kopie)" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "Maschine auswählen" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "Aktive Maschine auswählen" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "Laser ein-/ausschalten" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "Leistung" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "Laserleistung in Prozent" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "Pulsbreite in µs" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "Dauer" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "Sekunden (0 = dauerhaft)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "Werkzeug {tool_number}, max. Leistung {max_power}" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "{seconds:.1f} s verbleibend" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "G-Code" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "Genauigkeit" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "Konfiguriere die numerische Genauigkeit der Koordinatenausgabe." + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "G-Code-Genauigkeit" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "Anzahl der Dezimalstellen für Koordinaten" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "Dialekt" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "Auswählen, erstellen und verwalten von G-Code-Dialektdefinitionen." + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "Nach Nordwesten bewegen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "Nach Norden bewegen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "Nach Nordosten bewegen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "Nach Westen bewegen (Links)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "Nach Osten bewegen (Rechts)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "Nach Südwesten bewegen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "Nach Süden bewegen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "Nach Südosten bewegen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "Referenzfahrt X" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "Referenzfahrt Y" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "Referenzfahrt Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "An Maschine senden" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "Z-Abstand erhöhen" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "Z-Abstand verringern" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "Laufenden Auftrag abbrechen" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "Vorlage auswählen" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "Wählen Sie einen integrierten Dialekt als Ausgangspunkt." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "Hardware" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "Achsen" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "Konfiguriere die Achsenbereiche und das Koordinatensystem." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "X-Bereich" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "Vollständiger X-Achsen-Verfahrweg" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "Y-Bereich" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "Vollständiger Y-Achsen-Verfahrweg" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "Unten links" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "Oben links" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "Oben rechts" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "Unten rechts" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "Koordinatenursprung (0,0)" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "" +"Die physische Ecke, in der die Koordinaten nach der Referenzfahrt Null sind" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "X-Achsenrichtung umkehren" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "Macht Koordinatenwerte negativ" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "Y-Achsenrichtung umkehren" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "Z-Achsenrichtung umkehren" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" +"Aktivieren, wenn ein positiver Z-Befehl (z. B. G0 Z10) den Kopf nach unten " +"bewegt" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "Arbeitsbereich" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "Ränder definieren den ungenutzten Bereich um die Achsenbereiche." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "Linker Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "Unnutzbarer Bereich am linken Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "Oberer Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "Unnutzbarer Bereich am oberen Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "Rechter Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "Unnutzbarer Bereich am rechten Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "Unterer Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "Unnutzbarer Bereich am unteren Rand" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "Arbeitsbereich-Ursprung ist Koordinaten-Nullpunkt" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" +"Behandelt den Arbeitsbereich-Ursprung als Koordinaten-Nullpunkt. Blendet WCS-" +"Steuerungen aus und verwendet Arbeitsbereich-Ränder als Versatz." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "Software-Grenzen" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" +"Konfigurierbare Sicherheitsgrenzen für manuelles Bewegen. Deaktiviert " +"lassen, um Arbeitsflächengrenzen zu verwenden." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "Benutzerdefinierte Software-Grenzen aktivieren" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "Arbeitsflächengrenzen mit benutzerdefinierten Grenzen überschreiben" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "X Min" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "Minimale X-Koordinate" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "Y Min" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "Minimale Y-Koordinate" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "X Max" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "Maximale X-Koordinate" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "Y Max" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "Maximale Y-Koordinate" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" +"Optional. Konfiguriere alle Kameras, die du für Vorschau und Ausrichtung " +"verwenden möchtest." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" +"Richte Kameras jetzt oder später über die Maschineneinstellungen ein. Der " +"Assistent merkt sich, welche V4L-Geräte du als 'aktiviert' markierst; die " +"detaillierte Objektivkalibrierung erfolgt auf der Kameraeinstellungsseite." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "Keine Kameras erkannt" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "Du kannst Kameras später über die Maschineneinstellungen hinzufügen." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "Controller wählen" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "Welche Art von Controller-Platine verwendet diese Maschine?" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "Controller" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" +"Wähle die Firmware-/Protokollfamilie für diese Maschine. Wenn du dir nicht " +"sicher bist, wähle die nächstliegende Option — einzelne Einstellungen kannst " +"du später verfeinern." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "Keine — nur G-Code-Export" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "Kein physischer Controller; exportiere G-Code in eine Datei" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "Neue Maschine" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" +"Optional. Richte jetzt einen Drehtisch ein oder überspringe diesen Schritt, " +"um später über die Maschineneinstellungen einen hinzuzufügen." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "Modul" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "Wähle Drehtisch-Typ, Achse, Modus und Geometrie." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "Backen / Spannfutter" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "Rollen" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "Drehtisch-Typ" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "Wie das Werkstück gehalten wird" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "Drehachse" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "Welche Achse der Drehtisch verwendet" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "Echte 4. Achse (behält X/Y/Z)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "Achsenersatz (tauscht z. B. Y gegen A)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "Modus" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "Länge pro Umdrehung" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "Automatisch von GRBL $101/$103 übernommen, wenn getastet wird" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "Standard-Werkstück-Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "Max. Werkstücklänge" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "Rollen-Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "Erforderlich bei Verwendung eines Rollen-Drehtisches" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "Achsenrichtung umkehren" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "Rotationsrichtung des Drehtisches umkehren" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "—" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "Ja" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "Nein" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "Metrisch (mm)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "Imperial (Zoll)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "Überprüfen & Benennen" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "" +"Endgültiger Name und Plausibilitätsprüfung vor dem Erstellen der Maschine." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "Ein aussagekräftiger Name für diese Maschine." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "Maschinenname" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "Zusammenfassung" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "Warnungen" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "Keine (nur G-Code-Export)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "Unbekannter Treiber: {}" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "Verbindung" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "Arbeitsbereich X×Y" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "Einheitensystem" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "Max. Verfahrgeschwindigkeit" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "Max. Schnittgeschwindigkeit" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "Referenzierung beim Start" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "Drehtisch-Module" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" +"Kein Treiber ausgewählt — diese Maschine exportiert G-Code nur in Dateien; " +"sie kann keine Aufträge ausführen." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" +"Die Arbeitsbereichsabmessungen sind nicht festgelegt oder nicht positiv." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "Für diese Maschine ist kein Kopf konfiguriert." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "" +"Kopf #{n} sieht wie ein Laser aus, hat aber keine max_power-Einstellung." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "" +"Kopf #{n} sieht wie eine Spindel aus, hat aber keine max_rpm-Einstellung." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "Der Maschinenname ist leer." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "Name fehlt" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "Bitte gib einen Namen ein." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "Gerät erkennen" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" +"Verbinde dich mit dem Gerät und lies seine Konfiguration aus, oder " +"überspringe den Schritt, um die Werte manuell einzugeben." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "Erkennung läuft" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" +"Arbeitsbereich, Geschwindigkeiten und Firmware-Fähigkeiten der Maschine " +"automatisch erkennen, indem die Einstellungen über die Verbindung gelesen " +"werden." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "Jetzt erkennen" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "Erkennung läuft…" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "Verbinde mit Gerät und lese Einstellungen" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "Erkennung fehlgeschlagen" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "Erkennung erfolgreich" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "Arbeitsbereich und Geschwindigkeiten automatisch erkannt." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "Erneut versuchen" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "Wähle einen Ausgangspunkt für die neue Maschine." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "Maschinenvorlagen" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" +"Wähle ein integriertes Profil, um gängige Einstellungen vorzubelegen. Nach " +"verbindungsspezifischen Werten wirst du weiterhin gefragt." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "Geräte suchen…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "Aus Datei importieren…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "Gerät nicht aufgeführt" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "Import fehlgeschlagen" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "KI-Anbieter" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" +"Konfiguriere einen KI-Anbieter, damit der Assistent bekannte " +"Maschinenspezifikationen vorbelegen kann." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" +"Gib einen OpenAI-kompatiblen Endpunkt ein. Er wird nur für die automatische " +"Spezifikationssuche verwendet; du kannst auch überspringen und die Werte von " +"Hand eingeben." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "Standardanbieter" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "Standardmodell (optional)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "Arbeitsbereich (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "Maximale Schnittgeschwindigkeit" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "Koordinatenursprung" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "Kopftyp" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "Maximale Kopfleistung (S-Wert)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "Maximale Kopf-U/min" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "Minimale Kopf-U/min" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "Punktgröße (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "PWM-Frequenz (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "Brennweite" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "KI-Spezifikationssuche" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" +"Wenn deine Maschine ein bekanntes kommerzielles Modell ist, kann die KI " +"Spezifikationswerte aus der Dokumentation des Herstellers vorbelegen." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "Hersteller & Modell" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" +"Gib den Hersteller und den Modellnamen der Maschine ein. Je spezifischer, " +"desto besser — z. B. \"Sculpfun\" / \"S30 Pro\"." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "Hersteller (z. B. Sculpfun)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "Modell (z. B. S30 Pro)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "Spezifikationen suchen" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "Vorschläge" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" +"Vorgeschlagene Werte sind aktiviert; schalte die ab, die du nicht übernehmen " +"willst." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" +"In den Einstellungen ist kein KI-Anbieter konfiguriert. Konfiguriere einen, " +"um die automatische Spezifikationssuche zu aktivieren, oder überspringe " +"diesen Schritt und gib die Werte von Hand ein." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "Suche läuft…" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "Suche fehlgeschlagen" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" +"Die KI konnte keine Spezifikationen für diese Maschine liefern. Du kannst " +"die Werte in den nächsten Schritten manuell eingeben." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "KI schlägt vor: {value}" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "Hauptkopf" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "Gib die Verbindungsparameter für dein Gerät ein." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" +"Gib die Verbindungsparameter ein, die deine Maschine benötigt. Die genauen " +"Felder hängen von dem Controller ab, den du im vorherigen Schritt gewählt " +"hast." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "Durch das gewählte Profil festgelegt" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "Ungültige Eingabe" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "Arbeitsbereich, Ursprung, Geschwindigkeiten und Beschleunigung." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "" +"Physische Ecke, in der die Koordinaten nach der Referenzfahrt Null sind" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "Aktivieren, wenn +Z den Kopf nach unten bewegt" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "Arbeitsflächengrenzen mit benutzerdefinierten Limits überschreiben" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "Geschwindigkeiten" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "Limits in Maschineneinheiten pro Minute." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "Maximale Geschwindigkeit für schnelle Bewegungen" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "Maximale Schnittgeschwindigkeit" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" +"Wird für Zeitschätzungen und die Berechnung des Standard-Overscans verwendet" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "Referenzierungslauf durchführen, wenn die Maschine verbunden wird" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "Einzelachsen-Referenzierung" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "Referenzierung einzelner Achsen zulassen" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "Was ist an der Gantry befestigt: ein Laser, eine Spindel oder beides?" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "Kopftyp" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "Wähle den primären Kopf für diese Maschine." + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "Art des an dieser Maschine befestigten Werkzeugs" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "Kopfname" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "Laser-Einstellungen" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "Max. Leistung (S-Wert)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "Maximaler Laserleistungswert im G-Code" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "Laserstrahlbreite auf der X-Achse" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "Laserstrahlbreite auf der Y-Achse" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "PWM-Frequenz (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "Laser-Modulationsfrequenz" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "Abstand zwischen Linse und Werkstück" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "Achsenersatz" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "Echte 4. Achse" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "{mode}, Achse {axis}" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "Rotationsmodul hinzufügen" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "Keine Rotationsmodule konfiguriert" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "Neues Rotationsmodul" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "Rotationsstandardeinstellungen" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "Standardeinstellungen für neue Ebenen." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "Rotation standardmäßig aktivieren" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "Neue Ebenen verwenden standardmäßig den Rotationsmodus" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "Module" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" +"Definieren Sie die physischen Rotationsmodule Ihrer Maschine. Wählen Sie " +"eines als Standard." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "Verbindungsmodus" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "Wie das Rotationsmodul mit dem Maschinencontroller verbunden ist" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "Achse" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "Achsenbuchstabe für dieses Modul" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "Achse umkehren" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "Drehrichtung der Rotationsachse umkehren" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "Achsenversatz X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "Versatz von Modulposition zur Rotationsachse (X)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "Achsenversatz Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "Versatz von Modulposition zur Rotationsachse (Y)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "Achsenversatz Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "Versatz von Modulposition zur Rotationsachse (Z)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "Spannbacken / Futter" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "Antriebstyp" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "Wie das Rotationsmodul die Werkstückdrehung antreibt" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "Rollendurchmesser" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "Durchmesser der Antriebsrolle" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "Weg pro Umdrehung" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" +"Firmware-Distanz für eine vollständige 360°-Drehung. 0 = roher Umfangsoutput." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "Standard-Werkstückdurchmesser" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "Standarddurchmesser für neue Ebenen mit diesem Modul" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "Maximale Werkstücklänge, die dieses Modul aufnehmen kann" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "X-Position" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "X-Koordinate im Maschinenraum" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "Y-Position" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "Y-Koordinate im Maschinenraum" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "Z-Position" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "Z-Koordinate im Maschinenraum" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "Fähigkeiten" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "Maschinenfähigkeiten" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" +"Fähigkeiten werden aus den Köpfen, Drehtisch-Modulen und expliziten " +"Konfigurationen der Maschine abgeleitet. Sie steuern, welche Schritte beim " +"Hinzufügen zu einem Workflow angeboten werden." + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "explizite Konfiguration" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "unbekannte Quelle" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "{machine_name} - Maschineneinstellungen" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "Maschineneinstellungen" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "Maschinenprofil exportieren" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "Problem melden" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "Exportiert nach {path}" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "Export fehlgeschlagen: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "Maschine" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "Grundlegende Maschinenidentifikation und Konfiguration." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "Treiber-Einstellungen" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "Verbindungs- und Kommunikationseinstellungen für den Maschinentreiber." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "Treiber auswählen" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "Geschwindigkeiten & Beschleunigung" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "Bewegungsparameter für Auftragszeitschätzung und Pfadoptimierung." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" +"Das Einheitensystem, das beim Erzeugen von G-Code und bei der " +"Kommunikationmit dem Gerät verwendet wird. Diese Einstellung ist unabhängig " +"von denEinheiten der Benutzeroberfläche." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "Einheitensystem der Maschine" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "Konfiguration erforderlich: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "Fehler: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "Vom Treiber nicht unterstützt" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" +"Die Präambel enthält G21 (Millimeter), aber das Einheitensystem derMaschine " +"ist auf imperial eingestellt. G-Code-Werte werden in Zollausgegeben — " +"stellen Sie sicher, dass Ihre Präambel dazu passt." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" +"Die Präambel enthält G20 (Zoll), aber das Einheitensystem der Maschine " +"istauf metrisch eingestellt. G-Code-Werte werden in Millimetern ausgegeben —" +"stellen Sie sicher, dass Ihre Präambel dazu passt." + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "Zum Neuanordnen ziehen" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "Variable löschen" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "Schlüssel" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "Standardwert" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "Startwert" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "Minimalwert" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "Endwert" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "Maximalwert" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "Wert anpassen" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "Schiebereglerbereich anpassen" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "Parameter hinzufügen" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "Neuer Parameter" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "Zugang anfordern" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "API-Schlüssel konfiguriert" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "Neuen Schlüssel anfordern" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "Kein API-Schlüssel konfiguriert" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "Hostname und Port müssen zuerst konfiguriert werden" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" +"Gerät nicht erreichbar oder unterstützt keine automatischen Schlüsselanfragen" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "Unerwartete Antwort vom Gerät" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "Zu viele Anfragen. Versuche es später erneut." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "Anfrage fehlgeschlagen: {code}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "Verbindung fehlgeschlagen: {err}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "Warte auf Genehmigung auf dem Gerät…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "Warten…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "Genehmigung abgelaufen. Bitte versuche es erneut." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "Anfrage abgelehnt oder abgelaufen." + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "Autorisierungs-URL" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "Token-URL" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "Client-ID" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "Anmelden" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "Abmelden" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "Token abgelaufen" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "Aktualisieren" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "Authentifiziert" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "Erneut autorisieren" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "Nicht verbunden" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "Aktualisiere…" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "Keine Auswahl" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "Nicht unterstützter Typ: {t}" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "Änderung anwenden" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "Erweiterungs-Register" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "Register wird abgerufen..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "Von URL installieren..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "Verbindung fehlgeschlagen" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "Das Register konnte nicht erreicht werden." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "Keine Erweiterungen im Register gefunden." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "Installieren" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "Aktualisieren" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "Installiert" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "Version {v} bereits installiert" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "Inkompatibel" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "Benötigt {deps}, aber die aktuelle Rayforge-Version ist {current}" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "Nicht verfügbar" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "Manuelle Installation" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "Gib die Git-URL ein." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "Lizenzschlüssel eingeben" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "Lizenzschlüssel" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "Aktivieren" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "" +"Gib den Lizenzschlüssel ein, den du beim Kauf von {addon_name} erhalten hast." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "Bitte gib einen Lizenzschlüssel ein." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "Lizenz wird überprüft..." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "Lizenzüberprüfung fehlgeschlagen." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "Lizenz ungültig" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "Lizenz erforderlich" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" +"{addon_name} ist eine Premium-Erweiterung. Kaufe eine Lizenz, um sie " +"freizuschalten, oder gib deinen Lizenzschlüssel ein, falls du bereits einen " +"hast." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "Lizenz kaufen" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "Diese Erweiterung konnte nicht geladen werden" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "" +"Diese Erweiterung wird entladen, wenn aktive Aufträge abgeschlossen sind" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "" +"Diese Erweiterung ist mit der aktuellen Version von Rayforge inkompatibel" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Diese Erweiterung ist experimentell und kann ungelöste Probleme aufweisen. " +"Verwende sie mit Vorsicht." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "Premium-Erweiterung" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "Integrierte Erweiterung" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "Erweiterung deinstallieren" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "Diese Erweiterung aktivieren oder deaktivieren" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "Neue Erweiterung installieren..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "Keine Erweiterungen installiert." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "Installiere {name}..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "Erweiterung konnte nicht installiert werden." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "Erweiterung kann nicht deaktiviert werden" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" +"Diese Erweiterung kann nicht deaktiviert werden.\n" +"\n" +"{reason}" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "Erweiterung wird deaktiviert, wenn aktive Aufträge abgeschlossen sind." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "" +"Erweiterung konnte nicht deaktiviert werden. Überprüfe die Protokolle für " +"Details." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "Erweiterung und ihre Abhängigkeiten konnten nicht aktiviert werden." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "Abhängigkeiten aktivieren?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" +"Diese Erweiterung benötigt: {deps}\n" +"\n" +"Auch aktivieren?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "Alle aktivieren" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "" +"Erweiterung konnte nicht aktiviert werden. Überprüfe die Protokolle für " +"Details." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "{name} deinstallieren?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" +"Die Erweiterungsdateien werden entfernt. Ein Neustart wird empfohlen, um den " +"Speicher vollständig zu leeren." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "Deinstallieren" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "Fehler beim Löschen der Erweiterung." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "Info" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "Experimentelle Erweiterung aktivieren?" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" +"Die Erweiterung \"{name}\" ist experimentell und kann ungelöste Probleme " +"aufweisen. Verwende sie mit Vorsicht." + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "Trotzdem aktivieren" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "Hilf Rayforge zu verbessern" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" +"Möchtest du helfen, Rayforge zu verbessern, indem du anonyme " +"Nutzungsberichte erlaubst? Dies hilft uns zu verstehen, wie die App genutzt " +"wird und Verbesserungen zu priorisieren.\n" +"\n" +"Es werden keine persönlichen Daten gesammelt." + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "Nein, danke" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "Berichterstattung erlauben" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "Verlauf anzeigen" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "Unbenannte Aktion" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "Letzte Aktion rückgängig machen" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "Letzte Aktion wiederholen" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "Sichtbarkeit des Werkstücks umschalten" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "Sichtbarkeit der Haltestege umschalten" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "Sichtbarkeit des Kamerabilds umschalten" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "3D-Modellsichtbarkeit umschalten" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "Gitter-Sichtbarkeit umschalten" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "Sichtbarkeit der Verfahrwege umschalten" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "Sperrzonensichtbarkeit umschalten" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "Keine Parameter" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "Alle Optionen anzeigen" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "Modell auswählen" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "Auswählen" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "Auftragsprüfung" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "_Fortfahren" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "{} Fehler" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "{} Warnung(en)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "Keine Probleme gefunden." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" +"{summary} gefunden. Fortfahren kann zu Schäden an Ihrer Maschine oder Ihrem " +"Werkstück führen." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "Fehler" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "Wert in {unit}" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "Neu" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "Öffnen..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "Speichern unter..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "Zuletzt geöffnet" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "Importieren..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "G-Code exportieren..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "Dokument exportieren..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "Beenden" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "_Datei" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "Rückgängig" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "Wiederholen" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "Ausschneiden" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "Kopieren" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "Duplizieren" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "Alles auswählen" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "Dokument leeren" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "_Bearbeiten" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "Rechtes Bedienfeld anzeigen" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "Unteres Bedienfeld anzeigen" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "3D-Ansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "Ansicht von oben" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "Vorderansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "Rechte Ansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "Linke Ansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "Rückansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "Isometrische Ansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "Perspektive umschalten" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "_Ansicht" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "Teilen" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "Objekt exportieren..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "Äquidistante Haltestege hinzufügen…" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "Haltestege an Kardinalpunkten hinzufügen" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "Haltestege hinzufügen" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "_Objekt" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "Auswahl eine Ebene nach oben verschieben" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "Auswahl eine Ebene nach unten verschieben" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "Links" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "Rechts" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "Oben" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "Unten" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "Horizontal zentrieren" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "Vertikal zentrieren" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "Ausrichten" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "Horizontal verteilen" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "Vertikal verteilen" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "Verteilen" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "Horizontal spiegeln" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "Vertikal spiegeln" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "Spiegeln" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "Array" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "Anordnen" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "_Werkzeuge" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "Umrandung fahren" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "Auftrag senden" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "Auftrag anhalten / fortsetzen" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "Auftrag abbrechen" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "_Maschine" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "Über" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "Spenden" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "Debug-Protokoll speichern" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "_Hilfe" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "(Keine kürzlichen Elemente)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "Wartungswarnung: {name} hat das Limit erreicht ({curr} / {limit})" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "Zähler anzeigen" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr " (+{tasks} weitere)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "{tasks} Aufgaben" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "Wähle eine Maschine aus, um den G-Code-Export zu aktivieren" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "G-Code generieren" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "Export nicht möglich, während andere Aufgaben ausgeführt werden" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" +"Pipeline muss vor dem Export neu berechnet werden. Drücke F5 zum " +"Neuberechnen." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "Füge ein Werkstück hinzu, um den Export zu aktivieren" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "" +"Füge einen Verarbeitungsschritt hinzu oder aktiviere ihn, um den Export zu " +"ermöglichen." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "Rahmenleistung zum Aktivieren konfigurieren" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "Laserkopf um den belegten Bereich fahren" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" +"Pipeline muss vor dem Senden neu berechnet werden. Drücke F5 zum " +"Neuberechnen." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "Maschine fortsetzen" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "Maschine pausieren" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "Bitte wähle ein einzelnes Objekt zum Exportieren aus." + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "Debug-Protokoll wurde unter {path} gespeichert." + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "Projekt öffnen" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "Bild importieren" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "3D-Ansicht deaktiviert (fehlende Abhängigkeiten wie PyOpenGL)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "3D-Vorschau anzeigen" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "Neu berechnen (Umschalt+Klick zum Erzwingen)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "Unteres Bedienfeld umschalten" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "Auswahl anordnen" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "Haltestege an Kardinalpunkten (N,S,O,W) hinzufügen" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "Haltestege zur Auswahl hinzufügen" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "Maschine referenzieren" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "Maschinenalarm löschen (entsperren)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "Fokuslaser umschalten" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "Maschine nicht vollständig konfiguriert" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" +"Dem Maschinentreiber fehlen erforderliche Einstellungen. Zum Bearbeiten " +"klicken." + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "Horizontal zentrieren" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "Vertikal zentrieren" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "Links ausrichten" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "Rechts ausrichten" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "Oben ausrichten" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "Unten ausrichten" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" +"Erstelle ein ZIP-Archiv mit Protokolldateien und Systeminformationen zur " +"Fehlerbehebung." + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "Aktuelles Projekt einbeziehen" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "Aktuelle Projektdatei zum Debug-Archiv hinzufügen" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "_Speichern" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "Debug-Archiv konnte nicht erstellt werden." + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "Fehler beim Speichern der Datei: {msg}" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "Ein unerwarteter Fehler ist aufgetreten: {error}" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "Nicht gespeicherte Änderungen" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "" +"Das aktuelle Projekt hat nicht gespeicherte Änderungen. Möchtest du sie " +"speichern?" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "_Nicht speichern" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "Neues Projekt erstellt" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "Unbenannt" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "Element hinzufügen" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "Skizze hinzufügen" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "Neues Werkstück erstellen" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "Element(e) ausschneiden" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "Element ausschneiden" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "Element(e) einfügen" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "Element einfügen" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "Element(e) duplizieren" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "Element duplizieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "Auf Vorhandene abbilden" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "Neue Ebenen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "Flachdrücken" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "Ebenen-Importmodus" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "Wie importierte Ebenen auf Dokumentebenen abgebildet werden" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "SVG-Ebenen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "Farben" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "Ebenenquelle" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "Importierte Geometrie nach SVG-Ebene oder Farbe gruppieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "Bild importieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" +"Die Datei erzeugte keine Ausgabe im direkten Vektormodus. Dateien mit Text " +"oder anderen Nicht-Pfad-Elementen sollten vor dem Import in Pfade " +"konvertiert werden (z. B. in Inkscape: Pfad > Objekt zu Pfad)." + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "In Nachzeichnungsmodus wechseln" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "Erneut importieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "Importieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "Importmodus" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "Originalvektoren verwenden" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "Vektordaten direkt importieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "DPI" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" +"Pixel pro Zoll für dimensionslose SVG-Abmessungen. Inkscape ≥0.92 verwendet " +"96, ältere Inkscape verwendet 90, Illustrator verwendet 72" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "Ebenen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "Nachzeichnungseinstellungen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "Gesamtes Bild importieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "Gesamtes Bild ohne Nachzeichnung importieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "Automatischer Schwellenwert" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "Nachzeichnungsschwellenwert automatisch ermitteln" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "Schwellenwert" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "Objekte nachzeichnen, die dunkler als dieser Wert sind" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "Invertieren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "Helle Objekte auf dunklem Hintergrund nachzeichnen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "Ebenen auswählen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "Ebene ist leer" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "Ebene mit {n} Vektoren" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "Vorschau wird generiert..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "Anwendbarkeit" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" +"Definiere, wann dieses Rezept vorgeschlagen werden soll. Leere Felder gelten " +"für jeden Wert." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "Beliebig" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "Schritttypen" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" +"Die Schritttypen, auf die dieses Rezept angewendet wird. Leer lassen, um " +"beliebige Schritttypen zu verwenden." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "Auswählen..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "Schritttypenauswahl aufheben" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "Material" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "Materialauswahl aufheben" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "Min. Dicke" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "Minimale Materialdicke, bei der dieses Rezept angewendet wird." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "Max. Dicke" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "Maximale Materialdicke, bei der dieses Rezept angewendet wird." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "…" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "Nicht gefunden" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "Rezept" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" +"Eine benannte Voreinstellung, die später automatisch angewendet werden kann." + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" +"Die Einstellungen, die von diesem Rezept angewendet werden. Wenn mehrere " +"Schritttypen ausgewählt sind, werden nur die für alle gemeinsamen " +"Einstellungen angezeigt." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "Nachbearbeitung" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" +"Transformer-Einstellungen, die von diesem Rezept angewendet werden. Wenn " +"mehrere Schritttypen ausgewählt sind, werden nur die für alle gemeinsamen " +"Transformer angezeigt." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "Für diesen Schritt sind keine Nachbearbeitungsoptionen verfügbar." + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "Rezept bearbeiten" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "Neues Rezept hinzufügen" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "Unbekannte Maschine" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "Unbekanntes Material" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "Keine Rezepte gefunden." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" +"Das Rezept wird dauerhaft entfernt. Diese Aktion kann nicht rückgängig " +"gemacht werden." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "Rezept auswählen" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "" +"Wähle ein Rezept, das auf den aktuellen Schritt angewendet werden soll." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "Nur kompatible Rezepte anzeigen" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "Schrittname und Rezepteinstellungen." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "Kühlung" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "Kühlmittel, das während dieses Vorgangs verwendet wird." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "{key} ändern" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" +"Transformer, die auf den generierten Werkzeugpfad dieses Schritts angewendet " +"werden." + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "Geschwindigkeit der schnellen Positionierbewegungen" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "Aus" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "Flut" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "Nebel" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "Kühlmittel, das beim Schneiden auf das Werkstück aufgebracht wird" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "Diese Kühlmethode wird von der aktuellen Maschine nicht unterstützt" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "Geschwindigkeit des Schneidvorgangs" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "{name}-Einstellungen" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "Schritteinstellungen" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "Wählen..." + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "Manuelle Einstellungen" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "Rezept '{name}' anwenden" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "Rezept-Transformer anwenden" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "Neues {label}-Rezept" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "Angewendetes Rezept festlegen" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "Rezept '{name}' aktualisieren?" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" +"Dadurch wird das gespeicherte Rezept dauerhaft mit den aktuellen " +"Schritteinstellungen überschrieben. Diese Aktion kann nicht rückgängig " +"gemacht werden." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "1 Material" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "{count} Materialien" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "{count} (Schreibgeschützt)" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "Neue Bibliothek hinzufügen" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "Keine Bibliotheken gefunden." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Der Bibliotheksordner und alle seine Materialien werden dauerhaft entfernt. " +"Diese Aktion kann nicht rückgängig gemacht werden." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "Bibliothek bearbeiten" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "Gib einen neuen Namen für die Bibliothek ein:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "Bibliotheksname" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "Fehler beim Umbenennen der Bibliothek." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "Gib einen Namen für den neuen Bibliotheksordner ein:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +msgstr "" +"Fehler beim Erstellen der Bibliothek. Ein Ordner mit diesem Namen existiert " +"möglicherweise bereits." + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Open File" +msgstr "Datei öffnen" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "Alle unterstützten" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "G-Code-Datei speichern" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "G-Code-Dateien" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "Objekt exportieren" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "Dokument exportieren" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "SVG (Skalierbare Vektorgrafik)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "DXF (CAD-Austauschformat)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "{app_name}-Projekt öffnen" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "{app_name}-Projekt" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "{app_name}-Projekt speichern" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "Material bearbeiten" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "Materialdetails aktualisieren:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "Neues Material hinzufügen" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "Gib die Details für das neue Material ein:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "Kategorie" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "Benutzerdefiniert" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "Neue Ebene hinzufügen" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "Materialeigenschaften" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "Dicke" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "Materialdicke" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "Elemente" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "G-Code-Ansicht" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "Konsole" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "Steuerung" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "Aktuelle Versätze" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "Offsets manuell bearbeiten" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "Aktuelle Position" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "Zur unteren linken Ecke der Auswahl oder des Arbeitsbereichs bewegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "Zur Mitte der Auswahl oder des Arbeitsbereichs bewegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "Zur oberen rechten Ecke der Auswahl oder des Arbeitsbereichs bewegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "Zum Ursprung des aktiven WCS bewegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "Achsen nullen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "Aktuelle X-Position als 0 für aktives WCS festlegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "Aktuelle Y-Position als 0 für aktives WCS festlegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "Aktuelle Z-Position als 0 für aktives WCS festlegen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "Arbeitsnullpunkt an aktueller Position setzen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "Auf Arbeitsfläche klicken, um Arbeitsnullpunkt zu setzen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "Auf Arbeitsfläche klicken, um Arbeitsnullpunkt zu setzen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "Manuelle Geschwindigkeit" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "Manueller Abstand" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "Abstand in Maschineneinheiten" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "" +"Durch die aktuelle Ebene überschrieben. Ändere es in den Ebeneneinstellungen." + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "Offline - Position unbekannt" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" +"Offsets können im Maschinenkoordinatenmodus ({wcs}) nicht gesetzt werden" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "Maschine muss verbunden sein, um Hier Null zu setzen" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "Aktuelle Position als 0 setzen" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "Schritttypen auswählen" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "Wählen Sie aus, auf welche Schritttypen dieses Rezept angewendet wird." + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "Suchen..." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "Fehlende Funktionen" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "Dieses Dokument verwendet eine Funktion, die nicht verfügbar ist: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "Dieses Dokument verwendet Funktionen, die nicht verfügbar sind: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "Das Dokument kann weiterhin bearbeitet und gespeichert werden." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "_OK" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "Material auswählen" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "Wähle ein Material aus den verfügbaren Bibliotheken." + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "Keine Operationen" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "Schritt hinzufügen" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "Schritte neu anordnen" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "Schritt „{name}“ hinzufügen" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "Schritt „{name}“ entfernen" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "Ebeneneinstellungen" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "Diese Ebene löschen" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "Sichtbarkeit der Ebene umschalten" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "Relativ zum {wcs} Ursprung" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "Nullpunkt ist auf der linken Seite" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "X-Position auf 0 zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "Y-Position auf 0 zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "Festes Verhältnis" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "Auf natürliche Breite zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "Auf natürliche Höhe zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "Auf natürliches Seitenverhältnis zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "Winkel" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "Im Uhrzeigersinn ist positiv" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "Scherung" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "Horizontaler Scherwinkel" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "Winkel auf 0° zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "Scherung auf 0° zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "Natürlich: {val}" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "Quelldatei" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "Bild-Metadaten anzeigen" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "Im Dateibrowser anzeigen" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "Vektorbefehle" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "{count} Befehle" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "{name} (nicht gefunden)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "(Keine Quelldatei)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "Haltestege" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "Alle Haltestege entfernen" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "Breite der Haltestege" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "Länge entlang des Pfades" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "Haltesteg-Breite auf Standard (1.0) zurücksetzen" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "{num_tabs} Haltestege" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "Gemischte Werte" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "Anzahl der Haltestege" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "Äquidistante Haltestege anpassen" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "{} aktivieren" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "{} umschalten" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "Unverändert lassen" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "Deaktiviert" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "Diese Funktion ist nicht verfügbar." + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" +"Die erforderliche Komponente '{}' wurde nicht gefunden. Das Dokument kann " +"immer noch gespeichert werden." + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "Sichtbarkeit des Schritts umschalten" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "Bild-Metadaten" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "Metadaten kopieren" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "Keine Metadaten verfügbar." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "Grundlegende Informationen" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "Grundlegende Bildeigenschaften wie Abmessungen und Format." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "Metadaten" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "Alle aus dem Bild extrahierten Metadaten." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "Metadaten in die Zwischenablage kopiert" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "Elementeigenschaften" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "1 Element ausgewählt" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "{count} Elemente ausgewählt" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "Mehrere Elemente" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "Werkstückeigenschaften" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "Gruppeneigenschaften" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "{name} - Einstellungen" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "Schließen" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" +"Grundlegende Ebeneneinstellungen wie Erscheinungsbild und Koordinatensystem." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "Farbe für Operationen in dieser Ebene" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "Koordinatensystem" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" +"Das für diese Ebene zu verwendende Arbeitskoordinatensystem. Standardmäßig " +"das im Hauptfenster ausgewählte WCS verwenden" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "Rotationsvorsatz" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" +"Konfigurieren Sie den Rotationsvorsatz für zylindrische Objekte. Wenn " +"aktiviert, werden Y-Achsen-Bewegungen in Rotationsbewegungen in Grad " +"umgewandelt." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "Rotationsmodus aktivieren" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "Y-Achse in Rotationsachse umwandeln" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "Rotationsmodul für diese Ebene auswählen" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "Objektdurchmesser" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "Durchmesser des zylindrischen Objekts" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "Keine Materialien in der ausgewählten Bibliothek." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "Material kann nicht gelöscht werden" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" +"Dieses Material wird derzeit von einem oder mehreren Rezepten verwendet. " +"Bitte entferne die Rezepte, die dieses Material verwenden, bevor du es " +"löschst." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" +"Das Material wird dauerhaft aus der Bibliothek entfernt. Diese Aktion kann " +"nicht rückgängig gemacht werden." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "Fehler beim Aktualisieren des Materials." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "Fehler beim Hinzufügen des Materials zur Bibliothek." + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "Stapelimport {file_count} Bilder" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" +"{file_count} Bilder importieren:\n" +"{file_names}\n" +"\n" +"Alle Bilder werden mit den Standard-Nachverfolgungseinstellungen " +"nachgezeichnet und an der Ablageposition positioniert." + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "Alle importieren" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "Neuen Schritt hinzufügen..." + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "{count} Schritt" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "{count} Schritte" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "Simulation abspielen" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "Zurück springen" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "Vorwärts springen" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "Wiedergabegeschwindigkeit" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "Simulation pausieren" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "Nicht gefunden" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "UI-Toolkit" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "Grafik & Bildverarbeitung" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "Geometrie" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "Dateiformate & Kommunikation" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "Webseite" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "Problem melden" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "Version" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "Version kopieren" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "Hauptentwickler" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "Lizenz" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "Systeminformationen" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "Versionen von Bibliotheken und Komponenten" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "Systeminformationen kopieren" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "Unterstützer" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "Personen, die das Projekt unterstützt haben" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" +"Ein besonderer Dank an alle, die gespendet haben, um Rayforge zu " +"unterstützen! Ihr haltet den Kaffee und die AI-Tokens am Laufen!" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "Über {app_name}" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "mm/min" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "mm/s" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "in/min" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "in/s" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "mm" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "cm" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "m" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "Zoll" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "Fuß" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "mm/s²" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "cm/s²" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "m/s²" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "in/s²" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "ft/s²" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "{size} B" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "{size:.1f} KB" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "{size:.1f} MB" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "{:.0f}s" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "{}m" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "{}h" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "{line_count:,} Zeilen · {size}" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "— Abgeschnitten (zeigt die ersten 20.000 von {line_count:,} Zeilen) —" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "Suche nach Erweiterungs-Updates..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "Ein Update ist für {name} verfügbar." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "Updates sind für {name1} und {name2} verfügbar." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "Updates sind für {name1}, {name2} und {num} weitere verfügbar." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "Alle installieren" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "Erweiterungs-Updates gefunden." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "Erweiterungen sind auf dem neuesten Stand." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "Installiere Erweiterungs-Updates..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "Erweiterung erfolgreich aktualisiert." +msgstr[1] "{num} Erweiterungen erfolgreich aktualisiert." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "{num_s} Erweiterungen aktualisiert, {num_f} fehlgeschlagen." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "Fehler beim Aktualisieren der Erweiterung." +msgstr[1] "Fehler beim Aktualisieren von {num} Erweiterungen." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "Mit {num_failed} Fehlern abgeschlossen." + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "Alle Erweiterungs-Updates installiert!" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" +"'{file}' kann nicht geöffnet werden. Das benötigte Addon ist möglicherweise " +"deaktiviert." + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "Ein G-Code-Generator für Laserschneider." + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "Pfade zu einer oder mehreren SVG- oder Bild-Eingabedateien." + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" +"Import als direkte Vektoren erzwingen. Dies ist die Standard für " +"unterstützte Dateien." + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" +"Import durch Nachzeichnen der Bitmap-Darstellung der Datei erzwingen. " +"Abbruch, wenn nicht unterstützt." + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "Logging-Level setzen (Standard: INFO)" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" +"Beenden nach dem Importieren von Dokumenten und wenn der Editor sich " +"beruhigt hat. Nützlich zum Testen." + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" +"Pfad zu einem Python-Skript, das ausgeführt wird, nachdem das Hauptfenster " +"vollständig geladen ist. Nützlich für Automatisierung und Tests." + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" +"Pfad zu einem benutzerdefinierten Konfigurationsverzeichnis. Nützlich für " +"Tests mit isolierten Konfigurationen." + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "Aggregieren" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "{status} — {activity}" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "Auftrag wird aggregiert" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "Maschinencode wird generiert" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "Maschinentransformation wird angewendet" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "Verarbeitung" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "Verarbeitung von '{workpiece}' — {step}" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "Zusammenbau" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "Zusammenbau von '{step}'" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "Standardfläche" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "Fläche „{face}“ konnte nicht bearbeitet werden: {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "" +"Region {region} der Fläche „{face}“ konnte nicht bearbeitet werden: {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "Bearbeitungswarnung: {detail}" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "Variable Leistung" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "Konstante Leistung" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "Dithering" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "Mehrere Tiefen" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "Variabel" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "Konstant" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "Mehrfachdurchlauf" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "(+{n} weitere)" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "Fehlt: {}" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "Dieser Transformer ist nicht verfügbar." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "Der Name des aktuell aktiven Koordinatensystems (z. B. 'G54')." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "Der Name des aktuellen Maschinenprofils." + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "Die Breite (X-Achse) des Maschinenarbeitsbereichs." + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "Die Höhe (Y-Achse) des Maschinenarbeitsbereichs." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "Der Name der aktuellen Dokumentdatei (falls gespeichert)." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "Die minimale X-Koordinate des gesamten Auftrags." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "Die minimale Y-Koordinate des gesamten Auftrags." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "Die maximale X-Koordinate des gesamten Auftrags." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "Die maximale Y-Koordinate des gesamten Auftrags." + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "Der X-Versatz des aktuell aktiven WCS." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "Der Y-Versatz des aktuell aktiven WCS." + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "Der Z-Versatz des aktuell aktiven WCS." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "Der Name der aktuellen Ebene, die verarbeitet wird." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "Der Name des aktuellen Werkstücks, das verarbeitet wird." + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "Die X-Position des Werkstücks." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "Die Y-Position des Werkstücks." + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "Die Breite des Werkstücks." + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "Die Höhe des Werkstücks." + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "Element(e) transformieren" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "Element(e) verschieben" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "Elementwinkel ändern" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "Elementscherung ändern" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "Größe von Element(en) ändern" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "Objekt aktualisieren" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "Element umbenennen" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "Element '{name}' löschen" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "Abhängiges Element entfernen" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "Elementdefinition entfernen" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "Elementsichtbarkeit umschalten" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "{filename} importieren" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "{filename} wird importiert..." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" +"Import von {filename} fehlgeschlagen. Die Bilddatei ist möglicherweise " +"beschädigt oder in einem nicht unterstützten Format." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "Import fehlgeschlagen: Aus {filename} wurden keine Elemente erstellt" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "Import fehlgeschlagen." + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "Import abgeschlossen!" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" +"⚠️ Das importierte Element war größer als der Arbeitsbereich und wurde " +"passend verkleinert." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "Export erfolgreich: {name}" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "Objekt erfolgreich exportiert." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "Fehler beim Exportieren des Objekts: {error}" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "Export nicht möglich: Dokument hat keine Geometrie." + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "Dokument erfolgreich exportiert." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "Fehler beim Exportieren des Dokuments: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "Projekt gespeichert: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "Speichern fehlgeschlagen: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "Datei nicht gefunden: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" +"Dieses Projekt verwendet Kühlmethoden, die von der aktuellen Maschine nicht " +"unterstützt werden: {methods}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "{count} Asset(s) benötigen deaktivierte(s) Addon(s)" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "Ungültiges Projektdateiformat" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "Laden fehlgeschlagen: {error}" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "Die folgenden Elemente konnten nicht platziert werden: {item_names}" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "Schritt umbenennen" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "Material entfernen" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "Material {count}" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "Material-Sichtbarkeit umschalten" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "Materialelement umbenennen" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "Materialstärke ändern" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "Material ändern" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "Haltesteg hinzufügen" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "Alle Haltestege entfernen" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "Haltestege umschalten" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "Breite der Haltestege ändern" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "Auf eine andere Ebene verschieben" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "Ebene" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "Ebene umbenennen" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "Aktive Ebene festlegen" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "Ebene „{name}“ entfernen" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "Werkstücke neu anordnen" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "Elemente neu anordnen" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "Array erstellen" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "Array-Kopie erstellen" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "{count} Asset(s) benötigen deaktiviertes Addon '{addon}'" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "Elemente werden gruppiert..." + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "Gruppierung der Elemente wird aufgehoben..." + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "Element(e) teilen" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "Originalelement entfernen" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "Geteilte Fragmente hinzufügen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "Element(e) einfügen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "Element einfügen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "Element(e) duplizieren" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "Element duplizieren" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "Element hinzufügen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "Element entfernen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "Alle Werkstücke entfernen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "Elemente der Ebene löschen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "Kontur(en) löschen" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "Segment(e) löschen" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "An Punkt positionieren" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "Automatisches Layout" + +#: 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 +msgid "Failed to process image data." +msgstr "Verarbeitung der Bilddaten fehlgeschlagen." + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "Bild laden fehlgeschlagen: {}" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "SVG-Metadaten konnten nicht berechnet werden." + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "Zugeschnittene SVG-Daten konnten nicht vorbereitet werden." + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "SVG enthält keine Geometrie oder Abmessungen." + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "Gültige SVG-Abmessungen konnten nicht ermittelt werden." + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "" +"Gültige Abmessungen für das Nachzeichnen konnten nicht ermittelt werden." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "Rasterisierung des SVG für das Nachzeichnen fehlgeschlagen." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "Normalisierung der Bilddaten fehlgeschlagen." + +#: 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." + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "PDF konnte nicht gelesen werden: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "Unerwarteter Fehler beim Scannen der PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "Verarbeitung der PDF-Bilddaten fehlgeschlagen." + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "Fehler beim Lesen der PDF-Seitengrößen: {}" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "PDF-Seite hat keine Abmessungen" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "Rasterisierung des PDF fehlgeschlagen" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "PDF enthält keine Vektorgeometrie." + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "PDF konnte nicht geparst werden: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "LightBurn-Datei ist ungültiges XML: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "LightBurn-Datei ist beschädigt oder ungültig: {}" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "BMP-Header in {} konnte nicht geparst werden" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "Fehler beim Scannen der BMP-Datei: {}" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "Ungültige oder nicht unterstützte BMP-Daten." + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "Bildverarbeitung fehlgeschlagen: {}" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "Datei enthält keine Vektorbefehle." + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "Ruida-Datei ist ungültig: {}" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "Unerwarteter Fehler beim Scannen der Ruida-Datei: {}" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "Ruida-Befehle konnten nicht geparst werden: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "Struktur der DXF-Datei ist ungültig: {}" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "Unerwarteter Fehler beim Scannen der DXF: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "DXF-Datei ist beschädigt oder ungültig: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "Fehler beim Berechnen der Parameter: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "Fehler beim Ausführen des Generators: {}" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "Fehler beim Scannen der JPEG-Datei: {}" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "Floyd Steinberg" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..87c34889f --- /dev/null +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -0,0 +1,8440 @@ +# English translations for PACKAGE package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Samuel , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2025-07-13 11:49+0200\n" +"Last-Translator: Samuel \n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "" + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "" + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "" + +#: rayforge/updater.py +msgid "Download" +msgstr "" + +#: rayforge/updater.py +msgid "New version available." +msgstr "" + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "" + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "" + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "" + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "" + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 +msgid "Unknown" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "" + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Automatically check for new Rayforge versions on startup" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Startup behavior" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +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 +msgid "Add Tab Here" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +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 "Paste" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "" + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "" + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Open File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "" +msgstr[1] "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "" +msgstr[1] "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "" + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "" + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "" + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "" + +#: rayforge/image/png/importer.py +msgid "Failed to scan PNG file: {}" +msgstr "" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Failed to process image data." +msgstr "" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF file contains no pages." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "" + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..1fcef4830 --- /dev/null +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -0,0 +1,8906 @@ +# Spanish translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2025-08-08 10:00+0200\n" +"Last-Translator: Samuel Abels\n" +"Language-Team: Spanish \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "Buscando actualizaciones de Rayforge..." + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "La comprobación de actualizaciones falló." + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "Rayforge {version} está disponible." + +#: rayforge/updater.py +msgid "Download" +msgstr "Descargar" + +#: rayforge/updater.py +msgid "New version available." +msgstr "Nueva versión disponible." + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "Rayforge está actualizado." + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "{name} Flujo de trabajo" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "Plano" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "Rotativo · {name}" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "Rotatorio" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "Capa {}" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "{name} (copia)" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "Solicitud incorrecta" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "Autenticación fallida - por favor verifica tu clave API" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "Acceso denegado - por favor verifica los permisos de tu clave API" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "Endpoint de API no encontrado - por favor verifica la URL base" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "" +"Límite de solicitudes alcanzado - por favor espera e inténtalo de nuevo" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "Error del servidor - por favor inténtalo más tarde" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "Servicio no disponible - por favor inténtalo más tarde" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "El servidor devolvió el error {code}" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "Conexión fallida - por favor verifica tu red" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "Modelo '{model}' no encontrado. Disponibles: {available}" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "Velocidad de corte" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "Velocidad de desplazamiento" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "Ajustes" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "Opción" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "Texto (una línea)" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "La tasa de baudios no puede estar vacía." + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "'{rate}' no es una tasa de baudios estándar." + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "Tasa de baudios" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "Velocidad de conexión en bits por segundo" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "El nombre de host o la dirección IP no pueden estar vacíos." + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "Formato de nombre de host o dirección IP no válido." + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "Nombre de host / IP" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "Entero" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "El valor debe ser como mínimo {min_val}." + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "El valor debe ser como máximo {max_val}." + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "El puerto no puede estar vacío." + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "El puerto debe ser un número." + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "Punto flotante" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "Deslizador (0-100%)" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "Texto (multilínea)" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "Opción (etiquetada)" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "Booleano (Interruptor)" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "La URL no puede estar vacía." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "La URL debe incluir un esquema (p. ej., 'http://')." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "La URL debe incluir un nombre de host." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "El esquema de URL debe ser uno de: {schemes}." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "URL inválida: {error}" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "El puerto serie no puede estar vacío." + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "Puerto serie" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "Línea central" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "Interior" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "Exterior" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "Interior-Exterior" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "Exterior-Interior" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "Láser" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "Fresado" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "PWM" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "Corte y grabado con láser" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "Fresado y mecanizado con un husillo" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "Control de potencia láser modulado por ancho de pulso" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "Accesorio de eje rotativo para objetos cilíndricos" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "Núcleo" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "Material de stock" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "Fuente" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "Error de sintaxis: {message}" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "Variable o función desconocida: '{name}'" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" +"No se puede usar el operador '{op}' entre los tipos '{left}' y '{right}'" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "Ningún controlador" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "Sin conexión" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "Coordenadas de máquina" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "Sin ajustes" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "El recurso '{resource}' está actualmente en uso por '{owner}'." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" +"Este controlador no ha sido probado. Puede que funcione o no. Úselo bajo su " +"propio riesgo." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Este controlador es experimental y puede tener problemas sin resolver. Úselo " +"con precaución." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "Unknown" +msgstr "Desconocido" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "Inactivo" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "En ejecución" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "En espera" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "Jog" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "Alarma" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "Puerta" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "Comprobación" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "Referenciar" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "Reposo" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "Herramienta" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "En cola" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "Bloqueado" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "Desbloquear" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "Ciclo" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "Prueba" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "Frecuencia" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "Frecuencia PWM en Hz" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "Ancho de pulso" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "Ancho de pulso en microsegundos" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" +"Error durante la configuración. Es posible que necesites editar los ajustes " +"del dispositivo." + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "Smoothie" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "Smoothieware a través de una conexión Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "Coordenadas de máquina (G53)" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "Nombre de host o dirección IP no válido: '{host}'" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "Nombre de host" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "La dirección IP o el nombre de host del dispositivo" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "Puerto" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "El número de puerto Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "El nombre de host debe estar configurado." + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "Ruida (UDP)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "Conectar a un controlador láser Ruida mediante UDP" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "La dirección IP o el nombre de host del controlador Ruida" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "Puerto principal" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "El puerto UDP para comandos principales (predeterminado: 50200)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "Puerto de jog" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "El puerto UDP para comandos de jog (predeterminado: 50207)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "Sin respuesta del controlador" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "Enviar código G a un servidor OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "Dirección IP o nombre de host del servidor OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "Puerto HTTP del servidor OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "Clave API" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" +"Ingrese una clave API manualmente o haga clic en 'Solicitar acceso' para " +"obtener una a través del complemento Application Keys de OctoPrint." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" +"La clave API debe estar configurada. Use el botón 'Solicitar acceso' o " +"ingrese una clave API manualmente." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "" +"Autenticación fallida. La clave API puede ser inválida o haber expirado." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" +"No se pudo conectar a OctoPrint en '{host}:{port}'. Verifique la dirección y " +"la conexión de red." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "Autenticación fallida" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" +"La clave API es inválida o ha expirado. Por favor vuelva a autenticarse en " +"la configuración del dispositivo." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "OctoPrint no devolvió datos de inicio de sesión." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "Frame WebSocket inesperado." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "El servidor cerró la conexión WebSocket." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "Impresión fallida" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" +"OctoPrint informó que el trabajo de impresión falló. Verifique OctoPrint " +"para más detalles." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "Controlador no configurado con un host." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "Autenticación fallida durante la carga." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" +"La impresora está ocupada o no operativa. No se puede iniciar un nuevo " +"trabajo." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" +"OctoPrint aceptó el archivo pero no pudo comenzar a imprimir. La impresora " +"puede no estar operativa o ya estar ocupada." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "No se pudo cargar el archivo a OctoPrint en '{host}:{port}'." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" +"OctoPrint no admite la escritura de configuraciones de firmware del " +"dispositivo a través de su API." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" +"Comando de sonda enviado. OctoPrint no informa resultados de la sonda a " +"través de su API." + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "Marlin (Serie)" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "Firmware Marlin mediante conexión serie" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "Puerto serie para el dispositivo" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "El puerto debe estar configurado." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "La tasa de baudios debe estar configurada." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "Puerto no configurado" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "Sin respuesta del dispositivo" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "Autoconfigurado mediante el asistente de detección" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "GRBL (Telnet)" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "Controlador compatible con GRBL mediante conexión TCP/telnet en bruto" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "Puerto TCP para el servicio raw/telnet" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "Consultar estado del dispositivo durante los trabajos" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" +"Consultar periódicamente al dispositivo su posición y estado mientras se " +"ejecuta un trabajo. ¡Advertencia: Algunos dispositivos tienen problemas para " +"mantener una conexión estable si se usa esto!" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "Detección de interbloqueo" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" +"Detecta y recupera los interbloqueos de comunicación serie durante los " +"trabajos. Si está desactivado, el controlador esperará la respuesta de la " +"máquina. Desactívelo si experimenta falsos errores ALARM:3." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "Letra de comando faltante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" +"Los comandos G-code necesitan una letra seguida de un valor. La letra de " +"comando no se encontró." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "Formato de número inválido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" +"El valor falta o no está en el formato numérico correcto. Verifique la " +"sintaxis de su G-code." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "Comando desconocido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" +"Este comando de configuración de Grbl no se reconoce ni se admite. Verifique " +"la sintaxis del comando." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "Valor negativo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" +"Aquí se requiere un número positivo, pero se recibió un valor negativo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "Referenciado desactivado" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" +"El retorno al origen no está habilitado en la configuración. Habilite el " +"retorno al origen ($22=1) para usar esta función." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "Tiempo de pulso demasiado corto" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" +"El tiempo de pulso de paso mínimo debe ser mayor de 3 microsegundos. " +"Verifique la configuración $0." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "Error de memoria" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" +"La configuración se restableció a los valores predeterminados debido a un " +"error de lectura de memoria. Reconfigure su configuración si es necesario." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "Máquina ocupada" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" +"Este comando solo se puede usar cuando la máquina está inactiva. Espere a " +"que termine el trabajo actual." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "Comandos bloqueados" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" +"No se pueden enviar comandos mientras está en modo de alarma o de " +"desplazamiento manual. Borre primero el estado de alarma." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "Retorno al origen requerido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" +"Los límites suaves no se pueden habilitar sin que el retorno al origen " +"también esté habilitado. Habilite primero el retorno al origen ($22=1)." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "Línea demasiado larga" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" +"La línea de comando contiene demasiados caracteres y se ignoró. Verifique el " +"formato de su archivo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "Configuración demasiado alta" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" +"Esta configuración excede la velocidad de paso máxima admitida. Utilice un " +"valor más bajo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "Puerta abierta" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" +"Se detectó que la puerta de seguridad está abierta. Cierre la puerta y " +"reanude la operación." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" +"La información de compilación o la línea de inicio excede el límite de " +"almacenamiento. Acorte la línea." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "Objetivo fuera de rango" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" +"El objetivo de desplazamiento manual está fuera de los límites de " +"desplazamiento de la máquina. Muvase a una posición dentro del rango." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "Comando de desplazamiento manual inválido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" +"El comando de desplazamiento manual falta '=' o contiene G-code prohibido. " +"Verifique la sintaxis de desplazamiento manual." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "Error de modo láser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" +"El modo láser requiere salida PWM para funcionar. Verifique su configuración " +"de hardware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "El husillo no está en funcionamiento" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" +"Se emitió un comando de movimiento pero el husillo no está en " +"funcionamiento. Inicie el husillo antes del movimiento." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "Velocidad del husillo incorrecta" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" +"La velocidad actual del husillo no coincide con la velocidad requerida por " +"el comando. Espere a que el husillo alcance la velocidad objetivo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "Comando no soportado" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" +"Este comando G-code no es admitido por la máquina. Verifique la " +"configuración de su postprocesador." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "Comandos en conflicto" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" +"Se encontraron múltiples comandos del mismo grupo en una línea. Elimine el " +"comando duplicado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "Velocidad de avance faltante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" +"Establezca una velocidad de avance antes de usar comandos de movimiento. " +"Agregue un comando F para especificar la velocidad." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "Entero requerido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" +"Este comando requiere un valor de número entero. Elimine todos los puntos " +"decimales." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "Conflicto de eje" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "Múltiples comandos intentan usar el mismo eje. Simplifique el comando." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "Palabra duplicada" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "La misma palabra G-code aparece más de una vez. Elimine el duplicado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "Eje faltante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" +"Este comando requiere coordenadas de eje XYZ. Agregue los valores de eje " +"faltantes." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "Número de línea fuera de rango" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" +"El número de línea debe estar entre 1 y 9 999 999. Utilice un número de " +"línea válido." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "Valor faltante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "Este comando requiere un valor P o L. Agregue el parámetro faltante." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "Coordenada no admitida" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" +"Solo se admiten los sistemas de coordenadas G54-G59. Utilice uno de estos en " +"su lugar." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "Modo de movimiento incorrecto" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" +"El comando G53 requiere el modo de movimiento G0 o G1. Establezca primero el " +"modo de movimiento correcto." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "Palabras de eje no utilizadas" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" +"Palabras de eje presentes pero la cancelación G80 está activa. Elimine las " +"palabras de eje no utilizadas." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "Datos de arco faltantes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" +"El comando de arco G2/G3 necesita coordenadas XYZ. Agregue los valores de " +"eje para el plano seleccionado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "Objetivo inválido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" +"No se puede crear este arco o sondear la posición actual. Verifique las " +"coordenadas del objetivo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "Error de geometría de arco" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" +"El cálculo del arco falló. Intente dividir el arco en piezas más pequeñas o " +"use el desplazamiento IJK en su lugar." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "Desplazamiento de arco faltante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" +"El comando de arco G2/G3 necesita valores de desplazamiento IJK. Agregue el " +"desplazamiento faltante para el plano seleccionado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "Palabras no utilizadas" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" +"Algunas palabras G-code en esta línea no son utilizadas por ningún comando. " +"Elimine las palabras no utilizadas." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "Eje incorrecto para el desplazamiento" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" +"El desplazamiento de longitud de herramienta solo funciona en el eje " +"configurado (generalmente el eje Z). Verifique su configuración." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "Número de herramienta demasiado alto" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" +"El número de herramienta excede el valor máximo admitido. Utilice un número " +"de herramienta válido." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "Límite duro" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" +"Se activó un interruptor de límite duro. La máquina se ha detenido y " +"necesita ser reiniciada. Verifica si hay obstrucciones y comprueba los " +"interruptores de límite." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "Límite suave" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" +"La máquina se movería más allá de sus límites de recorrido configurados. " +"Verifica que tu área de trabajo y los desplazamientos de coordenadas sean " +"correctos." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "Abortar ciclo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" +"El trabajo en ejecución fue cancelado mientras estaba en movimiento. " +"Reinicia la máquina para continuar." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "Fallo de sonda — Inicial" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" +"La sonda no hizo contacto antes de alcanzar la distancia máxima de " +"recorrido. Verifica el cableado y la posición de la sonda." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "Fallo de sonda — Final" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" +"La sonda no pudo retraerse a la posición objetivo después del contacto. " +"Verifica la configuración de la sonda." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "Fallo de referencia — Reinicio" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" +"La referencia no se pudo completar porque la máquina está en estado de " +"alarma. Limpia la alarma e inténtalo de nuevo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "Fallo de referencia — Aproximación" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" +"El ciclo de referencia no encontró el interruptor dentro de la distancia de " +"recorrido configurada. Verifica el cableado del interruptor y la " +"configuración de retroceso." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "Fallo de referencia — Retroceso" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" +"El ciclo de referencia no pudo retroceder del interruptor después del " +"contacto. Aumenta la distancia de retroceso o verifica el interruptor." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "Referencia sin límites" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" +"Se ordenó la referencia pero los interruptores de límite no están " +"configurados. Habilita primero los interruptores de límite." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "Fallo de referencia — Eje dual" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" +"El posicionamiento de referencia falló en una configuración de eje dual. Uno " +"o ambos ejes no alcanzaron sus interruptores de límite. Verifique el " +"cableado y la configuración de los interruptores de límite." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "Alarma desconocida" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "Código de alarma no válido reportado por la máquina." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" +"La máquina informó un código de alarma no reconocido. Consulte la " +"documentación de su máquina y firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "Error desconocido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "Código de error no válido informado por la máquina." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" +"La máquina reportó un código de error no reconocido. Verifique la " +"documentación de su máquina y firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "Configuración del motor paso a paso" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" +"Ajustes relacionados con la temporización del motor paso a paso y la " +"polaridad de la señal." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "Control e informes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "" +"Ajustes para el control de movimiento y los informes de estado de GRBL." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "Límites y posicionamiento inicial" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "" +"Ajustes para límites de software/hardware y el ciclo de posicionamiento " +"inicial." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "Husillo y láser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "Ajustes para controlar el husillo o el módulo láser." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "Calibración de ejes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "Define los pasos por milímetro para cada eje." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "Cinemática de ejes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "Define la velocidad máxima y la aceleración para cada eje." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "Recorrido del eje" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "Define la distancia máxima de recorrido para cada eje." + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "GRBL (Serial)" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "Conexión serie compatible con GRBL" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "Anular tamaño del búfer RX" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" +"Forzar un tamaño específico del búfer RX en bytes. Establecer en 0 para " +"detectar automáticamente desde el dispositivo." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "Ajustes desconocidos" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" +"Ajustes informados por el dispositivo que no están en la lista estándar." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "Ajuste desconocido del dispositivo" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" +"El dispositivo está configurado para informar en pulgadas ($13=1). Todos los " +"valores mostrados están en unidades de máquina." + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" +"El modo láser no está activado ($32=0). Actívalo para obtener mejores " +"resultados con cortadores láser." + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "GRBL (Serial Simple)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "GRBL serie con protocolo simple ping-pong (sin contador de búfer)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "La velocidad de transmisión debe estar configurada." + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "GRBL (Red)" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "Conectar a un dispositivo compatible con GRBL a través de la red" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "Puerto HTTP" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "El puerto HTTP para el dispositivo" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "Puerto WebSocket" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "El puerto WebSocket para el dispositivo" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "Variante de protocolo" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "Variante GRBL estándar, ESP3D o Longer" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "Estándar" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" +"El host no está configurado. Por favor, establece una dirección IP o un " +"nombre de host válido." + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" +"No se pudo conectar al host '{host}'. Comprueba la dirección IP y la " +"conexión de red." + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "Zona prohibida" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "Fuera del área de trabajo" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "Límites de la máquina" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "{name} (dialecto del dispositivo)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "• Calibración de cámara: matriz + distorsión encontrada" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "(ningún campo asignado)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "Nombre del dispositivo" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "Área de trabajo" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "Controlador" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "Tasa de baudios" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "Referenciar al inicio" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "Velocidad máxima de desplazamiento" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "Origen" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "Reflejar X" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "Reflejar Y" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "Calibración de cámara" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "matriz + distorsión importada" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "Cabezal de husillo" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "{label} (para {machine_name})" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "Cabezal láser" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "Máquina por defecto" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "Módulo rotativo" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "Cabezal" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "No se ha seleccionado un controlador para esta máquina." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "Controlador '{driver}' no encontrado." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "Ocurrió un error inesperado durante la validación: {error}" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "GRBL Raster" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" +"Optimizado para grabado raster GRBL. Mantiene el modo de potencia dinámica " +"M4 continuamente activo y usa velocidad de avance modal para minimizar la " +"sobrecarga de comandos durante las líneas de escaneo." + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "Mach4 (M67 Analógico)" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" +"Mach4 con salida analógica M67 para grabado raster de alta velocidad. Usa " +"M67 E0 Q<0-255> para la potencia del láser en lugar de comandos S en línea, " +"reduciendo la presión del búfer en el controlador." + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "Smoothieware" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "Dialecto G-code para controladores basados en Smoothieware" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "LinuxCNC" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "" +"G-code para LinuxCNC, compatible con curvas Bézier cúbicas nativas (G5)" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "GRBL Dinámico" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" +"GRBL con modo de potencia dinámica M4 (consciente de profundidad). El " +"parámetro S se incluye en los comandos de movimiento" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "Información general" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "Etiqueta" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "Nombre visible para el usuario" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "Descripción" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "Descripción corta" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "Omitir coordenadas sin cambios" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" +"Cuando está habilitado, las letras de eje que no han cambiado se omiten de " +"los comandos G0/G1" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "Modo láser continuo" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" +"Mantiene el modo de potencia dinámica M4 continuamente activo durante el " +"grabado raster en lugar de alternar M4/M5 entre cada segmento" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "Velocidad de avance modal" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" +"Solo incluir el parámetro F de velocidad de avance en comandos de movimiento " +"cuando cambia del valor anterior" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "Plantillas de comandos" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "Encender láser" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "Apagar láser" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "Enfocar láser" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "Movimiento de desplazamiento" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "Movimiento lineal" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "Arco (horario)" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "Arco (antihorario)" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "Bézier Cúbico" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "Cambio de herramienta" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "Establecer velocidad" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "Encender aire" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "Apagar aire" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "Referenciar todo" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "Referenciar eje" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "Mover a" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "Limpiar alarma" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "Establecer desplazamiento WCS" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "Ciclo de sondeo" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "Espera" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "Spindle encendido (CW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "Spindle encendido (CCW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "Spindle apagado" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "Refrigerante inundación" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "Refrigerante niebla" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "Refrigerante apagado" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "Scripts" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "Inyectar WCS después del preámbulo" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" +"Inyectar el comando WCS activo (p. ej., G54) después del script de " +"preámbulo. Cuando está deshabilitado, puedes usar {machine.active_wcs} en el " +"preámbulo en su lugar." + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "Preámbulo" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "Script de preámbulo" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "Epílogo" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "Script de epílogo" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "Marlin" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "G-code para controladores basados en Marlin, común en impresoras 3D" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "Grbl (Compatibilidad)" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" +"Dialecto Grbl con máxima compatibilidad para la mayoría de láseres de diodo " +"y CNC de afición" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "Inicio de capa" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "Fin de capa" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "Inicio de pieza" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "Fin de pieza" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "Antes de procesar una capa" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "Después de procesar una capa" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "Antes de procesar una pieza" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "Después de procesar una pieza" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "Macro sin nombre" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "{job_name} falló: {error}" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" +"¡No se pudieron listar los puertos serie debido a un confinamiento de Snap! " +"Por favor, asegúrate de que el dispositivo esté conectado por USB y " +"ejecuta:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" +"Se encontraron puertos serie, pero ninguno es accesible. Por favor, " +"asegúrate de que tu Snap tenga la interfaz 'serial-port' conectada " +"ejecutando:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "Conectando" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "Conectado" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "Error" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "Cerrando" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "Desconectado" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "En reposo" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "Máquinas" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "Máquinas configuradas" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "Añadir o eliminar máquinas." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "Esta máquina tiene una configuración inválida." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "Esta es la máquina activa." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "¿Eliminar '{name}'?" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Este perfil de máquina y todos sus ajustes se eliminarán permanentemente. " +"Esta acción no se puede deshacer." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "Cancelar" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "Eliminar" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "Añadir máquina" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "Licencias" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "" +"Vincula tu cuenta de Patreon para acceso anticipado a nuevos complementos." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "Cuenta de Patreon vinculada" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "Los complementos de acceso anticipado están desbloqueados" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "Desvincular" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "Vincular cuenta de Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "Obtener acceso anticipado a complementos premium" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "Vincular" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "Licencias de complementos" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "Gestiona tus claves de licencia compradas." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "No hay licencias instaladas" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" +"Compra un complemento premium e introduce la clave de licencia durante la " +"instalación." + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "{addons} (+{count} más)" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "ID de producto: {id}" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "Eliminar" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "Complementos que requieren licencia" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "Estos complementos necesitan una licencia válida para ser activados" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "Licencia requerida" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "Comprar" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "¿Eliminar licencia?" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" +"Esta clave de licencia será eliminada. Es posible que necesites volver a " +"introducirla para usar los complementos con licencia." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "Proveedor predeterminado" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "Habilitar o deshabilitar este proveedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "Establecer como predeterminado" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "Añadir proveedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "No hay proveedores configurados" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "Nuevo proveedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "¿Eliminar '{name}'?" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" +"Este proveedor de IA será eliminado permanentemente. Esta acción no se puede " +"deshacer." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "Nombre" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "Tipo de proveedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "Compatible con OpenAI" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "URL base" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "Modelo predeterminado" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "Prueba de conexión" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "Verificar que la configuración del proveedor funciona" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "Editar proveedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "Configuración del proveedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "Probando..." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "IA" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "Proveedores de IA" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" +"Configure proveedores de IA para su uso por complementos. Los complementos " +"pueden usar estos proveedores sin necesidad de sus propias claves API." + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "Complementos" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "Complementos instalados" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "Instalar, actualizar y eliminar complementos." + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "Recetas" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "Gestiona tus recetas guardadas para diferentes materiales y procesos." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "Editar regla de color" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "Actualice los detalles de la regla de color:" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "Guardar" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "Añadir regla de color" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "Asigne un color a un tipo de paso para importaciones SVG." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "Añadir" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "Color" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "Color SVG que activa esta regla" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "Etiqueta (opcional)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "Tipo de paso" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "Tipo de paso creado al importar este color" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "Color {color}" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "Este tipo de paso no está disponible actualmente." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "{step_type} (no disponible)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "No se encontraron reglas de color." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "¿Eliminar la regla de color '{color}'?" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" +"La regla de color se eliminará permanentemente. Esta acción no se " +"puededeshacer." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "Reglas de color" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" +"Asigne colores SVG a tipos de paso para que se apliquen automáticamente " +"alimportar." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "Materiales" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "Bibliotecas de materiales" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" +"Gestiona tus bibliotecas de materiales. Selecciona una biblioteca para ver " +"sus materiales." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "Materiales en la biblioteca seleccionada." + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "Categorías" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "Inglés" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "Alemán" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "Español" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "Francés" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "Portugués" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "Ucraniano" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "Chino (simplificado)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "Sistema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "Claro" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "Oscuro" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "No abrir nada" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "Abrir último proyecto" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "Abrir proyecto específico" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "Color del láser" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "Color de capa" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "Predeterminado del sistema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "General" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "Apariencia" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "" +"Ajustes relacionados con la apariencia y el comportamiento de la aplicación." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "Tema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "Idioma" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "El idioma de la aplicación. Los cambios requieren un reinicio." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "Colores de operación" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "Elige si los colores de operación representan el láser o la capa" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "Unidades" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" +"Establecer las unidades de visualización para varios valores en toda la " +"aplicación." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "Longitud" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "Velocidad" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "Aceleración" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "Comportamiento" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "Configurar el comportamiento avanzado de la aplicación." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "Actualizar operaciones automáticamente" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" +"Recalcular las operaciones automáticamente después de cada cambio. " +"Desactivar para el recálculo manual mediante el botón de la barra de " +"herramientas" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "Presupuesto de caché (MB)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" +"Memoria máxima para la caché. Las escenas de alta complejidad requieren más" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "Buscar actualizaciones" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +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 "Startup behavior" +msgstr "Comportamiento de inicio" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "Ruta del proyecto" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "Examinar..." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "Privacidad" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" +"Ayúdanos a mejorar Rayforge permitiendo informes de uso anónimos. No se " +"recopilan datos personales." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "Informar uso anónimo" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "Ayudar a mejorar Rayforge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" +"Más " +"información sobre el seguimiento de uso y privacidad." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "Reinicio necesario" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" +"El idioma tendrá efecto después de reiniciar Rayforge. ¿Desea reiniciar " +"ahora?" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "_Cancelar" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "_Reiniciar" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "Las copias mantienen sus capas originales." + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "_Aplicar" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "Cuadrícula" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "Cuadrícula" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "Filas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "Columnas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "Desplazamiento" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "Espacio" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "Espaciado" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "" +"El desplazamiento es de centro a centro; el espacio es de borde a borde." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "Espaciado de columnas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "Espaciado de filas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "Matriz de rotación de puntos" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "Rotación de puntos" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "Rota las copias en su lugar alrededor del centro de la selección." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "Cantidad" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "Ángulo total (grados)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "Matriz circular" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "Circular" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "Coloca copias a lo largo de un arco circular alrededor de un centro." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "Centro X" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "Centro Y" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "Radio" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "Rotar copias" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "Mover pestaña de sujeción" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "Subir una capa" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "Bajar una capa" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "Agrupar" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +msgstr "Desagrupar" + +#: 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 +msgid "Add Tab Here" +msgstr "Añadir pestaña de sujeción aquí" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "Eliminar pestaña de sujeción" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "Nuevo boceto" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "Nuevo material" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +msgstr "Importar archivo…" + +#: 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 "Paste" +msgstr "Pegar" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "Añadir instancia {}" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "Arrastra archivos para importar" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "Imagen importada del portapapeles" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "Error al importar la imagen del portapapeles" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "La vista 3D no está disponible debido a dependencias faltantes." + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "Selecciona una máquina para abrir la vista 3D." + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "Añadir material de base" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "Disposición automática (Simple)" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "{camera_name} - Calibración de lente" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "Ajustes de imagen de la cámara" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "Ajustar parámetros de calidad y apariencia de la imagen." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "Predeterminado" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "Personalizado..." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "Resolución" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" +"Resolución de captura de la cámara. Por defecto usa la configuración nativa " +"de la cámara." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "Ancho personalizado" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "Alto personalizado" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "Preferir formato YUYV" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" +"Usar YUYV sin comprimir en lugar de MJPEG. Corrige los artefactos verdes en " +"algunas cámaras USB pero puede reducir la resolución o la tasa de fotogramas " +"en USB 2.0." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "Balance de blancos automático" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "Ajustar automáticamente el balance de blancos" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "Balance de blancos (Kelvin)" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "Temperatura de color para una representación precisa del color" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "Contraste" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "Diferencia entre áreas claras y oscuras" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "Brillo" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "Luminosidad u oscuridad general de la imagen" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "Reducción de ruido" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "Promedio temporal, valores altos causan arrastre" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "Transparencia" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "Transparencia en la superficie de trabajo" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "Por favor, selecciona un dispositivo de cámara disponible" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "Por favor, selecciona una cámara configurada" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "Seleccionar cámara" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "No hay cámaras configuradas." + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "Error al cargar la imagen para el ID de dispositivo: {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "Cámara {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "No se encontraron cámaras." + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "Punto {n}" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "Eliminar este punto" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "Mover píxel:" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "Propiedades de la cámara" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "Configurar la cámara seleccionada." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "ID del dispositivo" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "Identificador del sistema para el dispositivo de cámara" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "Nombre para mostrar de esta cámara" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "Habilitado" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "Activar o desactivar el flujo de la cámara" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "Inicio" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "Asistente de cámara" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" +"Configuración guiada: ajustes de imagen, calibración de lente y alineación." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "Configurar" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "Ajustes de imagen" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "Ajustar brillo, contraste, balance de blancos y ruido" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "Calibración de lente" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "Corregir la distorsión del lente para líneas más rectas" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "Alineación de imagen" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "Calibrar posición y perspectiva de la cámara" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "Calibración de lente completada" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "Calibración de lente aún no realizada" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "Alineación de imagen completada" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" +"La alineación de imagen debe repetirse después de actualizar la calibración " +"del lente" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "Alineación de imagen aún no realizada" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "Esperando la cámara..." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" +"Corrige la distorsión de la lente para obtener líneas más rectas. Elige cómo " +"calibrar u omítelo si tu lente tiene una distorsión insignificante." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "Automático" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "Calibración automática" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" +"Imprime una tarjeta de calibración y captúrala en varias posiciones. El " +"asistente resuelve los coeficientes de distorsión por ti." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "Manual" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "Calibración manual" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "Introduce a mano los coeficientes de distorsión radial y tangencial." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "Omitir" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "Tarjeta de calibración" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "Instrucciones" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" +"Imprima una tarjeta de calibración para corregir la distorsión de la lente. " +"El tamaño de la tarjeta debe caber en la vista de la cámara." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "Tamaño de tarjeta" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "Ajuste para que se adapte a su superficie de trabajo." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "Ancho" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "Ancho de tarjeta" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "Alto" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "Alto de tarjeta" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "Patrón generado" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "Detalles sobre el patrón de calibración." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "Tamaño de cuadrícula" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "Tamaño de cuadrado" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "Tamaño físico" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "Guardar como PDF" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "Exportar la tarjeta de calibración para imprimir" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "Guardar tarjeta de calibración" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "Tarjeta de calibración guardada" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "Capturar fotogramas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" +"Capture la tarjeta en diferentes posiciones. Importante: incluya las " +"esquinas y bordes de la imagen para una corrección precisa de la distorsión." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "Estado" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "Progreso del proceso de captura de calibración." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "Imágenes capturadas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "Esquinas detectadas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "Cobertura" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "No iniciado" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "Mueva la tarjeta para capturar más posiciones" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "Progreso de captura" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "Capturar imagen" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "Limpiar" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "Calibrar" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "Bueno" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "Limitado — alcance los bordes" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "Deficiente — alcance todas las esquinas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "Calibración fallida" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "Calibración completada" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" +"Error RMS: {rms:.4f} píxeles\n" +"Calidad: {quality}\n" +"Imágenes utilizadas: {frames}" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "Descartar" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "Guardar calibración" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "{camera} - Asistente de cámara" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "Atrás" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "Siguiente" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "Finalizar" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "Aceptar" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "Radial 1 (k1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "Distorsión radial de primer orden" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "Radial 2 (k2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "Distorsión radial de segundo orden" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "Radial 3 (k3)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "Distorsión radial de tercer orden" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "Tangencial 1 (p1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "Distorsión tangencial de primer orden" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "Tangencial 2 (p2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "Distorsión tangencial de segundo orden" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" +"Corrige la distorsión de la lente para obtener líneas más rectas. Ajusta los " +"coeficientes manualmente." + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "{camera_name} – Alineación de imagen" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "Alejar (desplazar hacia abajo)" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "Ajustar a la ventana" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "Acercar (desplazar hacia arriba)" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "{camera_name} - Ajustes de imagen de la cámara" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "ID del dispositivo: {device_id}" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "Añadir nueva cámara" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "No hay cámaras configuradas" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "Mejora de imagen" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "Reducir ruido y mejorar la estabilidad de la imagen." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" +"Promedio temporal. Valores altos eliminan más ruido pero causan arrastre." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" +"Enderezar líneas curvas usando parámetros Radiales (k1, k2) y Tangenciales " +"(p1, p2). Nota: Los valores suelen ser muy pequeños." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "Corrección de distorsión de lente (Ojo de pez)" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "Cámara" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "Cámaras" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "" +"Transmitir una imagen de la cámara directamente sobre la superficie de " +"trabajo." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" +"Haga clic en la imagen para añadir puntos de referencia. Arrástrelos para " +"moverlos.\n" +"Desplácese para hacer zoom. Clic central y arrastrar para desplazar.\n" +"Use las teclas de flecha para mover el punto activo con precisión." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "Restablecer puntos" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "Borrar todos los puntos" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "Aplicar" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "Añadir nueva macro" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "No hay macros configuradas" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "Nueva macro" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "Herramienta {tool_number}, {min_rpm}-{max_rpm} rpm" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" +"Herramienta {tool_number}, pot. máx. {max_power}, tamaño de punto {spot_x}" +"x{spot_y}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "Herramienta {tool_number}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "Añadir nuevo cabezal" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "No hay cabezales configurados" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "Se requiere al menos un cabezal" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "Husillo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "Nuevo láser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "Nuevo husillo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "Modelo 3D" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "Selecciona y configura un modelo 3D para este cabezal." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "Modelo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "Escala" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "Factor de escala uniforme para el modelo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "Rotación X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "Grados alrededor del eje X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "Rotación Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "Grados alrededor del eje Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "Rotación Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "Grados alrededor del eje Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "Ninguno" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "Propiedades del láser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "Configura el cabezal láser seleccionado." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" +"Ajustes de modulación por ancho de pulso para el control de frecuencia y " +"ancho de pulso." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "Enmarcado" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "" +"Configuración de la operación de contorno que traza el límite del trabajo." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "Número de herramienta" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "Número de herramienta de G-code (p. ej., T0, T1)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "Diodo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "CO₂" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "Fibra" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "Tipo de láser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "Tipo de tubo láser o diodo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "Potencia máxima" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "Valor máximo de potencia en G-code" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "Potencia de enfoque" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "Valor de potencia en porcentaje a usar al enfocar. 0 para desactivar." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "Tamaño del punto X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "Tamaño del punto láser en la dirección X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "Tamaño del punto Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "Tamaño del punto láser en la dirección Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "Color de corte" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "Color para operaciones de corte" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "Color de rasterizado" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "Color para operaciones de grabado/rasterizado" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "Distancia focal" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "" +"Distancia de la cabeza láser a la superficie de trabajo (compensación Z)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "Frecuencia PWM" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "Frecuencia PWM predeterminada en Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "Frecuencia PWM máxima" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "Frecuencia PWM máxima soportada en Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "Ancho de pulso predeterminado en µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "Ancho de pulso mínimo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "Ancho de pulso mínimo en µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "Ancho de pulso máximo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "Ancho de pulso máximo en µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "Potencia de encuadre" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "" +"Valor de potencia en porcentaje a usar al encuadrar. 0 para desactivar." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "Velocidad del marco" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" +"Velocidad para el contorno. Déjelo en 0 para usar la velocidad máxima de la " +"máquina" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "Cantidad de repeticiones" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "Número de veces que se traza el contorno" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "Pausa en esquinas" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "" +"Duración de la pausa en segundos en cada esquina del contorno. 0 para " +"desactivar" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "Propiedades del husillo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "Configura el cabezal de husillo seleccionado." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "RPM mínimas" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "Velocidad mínima del husillo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "RPM máximas" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "Velocidad máxima del husillo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "Compatible con refrigerante de inundación" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "Refrigerante aplicado a la pieza de trabajo en forma de inundación" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "Compatible con refrigerante en niebla" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "Refrigerante aplicado a la pieza de trabajo en forma de niebla" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "Cabezales" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "Puedes configurar varios láseres o husillos si tu máquina lo permite." + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "Añadir una máquina" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "Crear máquina" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "No se pudo crear la máquina" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "Configuración de cámara no disponible" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "" +"Calibra esta cámara más tarde desde la página de ajustes de la máquina." + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "Mostrar salida detallada (sondeos de estado)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "Rectángulo" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "Caja" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "Cilindro" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "Añadir zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "No hay zonas prohibidas configuradas" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "Nueva zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "Zonas prohibidas" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" +"Definir áreas restringidas en la superficie de trabajo. Se mostrará una " +"advertencia antes de ejecutar o exportar un trabajo cuya trayectoria entre " +"en cualquier zona prohibida habilitada." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "Propiedades de zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "Configurar la zona seleccionada." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "Forma" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "Forma de geometría de zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "X" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "Posición X en {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "Y" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "Posición Y en {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "Z" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "Posición Z en {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "Profundidad" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "Profundidad (extensión Z)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "Radio del cilindro" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "Altura del cilindro" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "Altura del cilindro" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "Las llaves escapadas {{ o }} no están soportadas." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "No se permiten llaves anidadas." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "Se encontró una llave de cierre '}' sin coincidencia." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "Se encontró una llave de apertura '{' sin coincidencia." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "No se permiten llaves vacías '{}'." + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "Variable(s) no soportada(s): {vars}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "Editar dialecto: {label}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "Nuevo dialecto" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "Actualizar desde plantilla" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "La etiqueta no puede estar vacía." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" +"Seleccione una plantilla para copiar su configuración. Su etiqueta y " +"descripción se conservarán." + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "Hooks de G-code" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" +"Añadir G-code personalizado para ser ejecutado en puntos específicos del " +"trabajo." + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "Restablecer a predeterminado" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "¿Restablecer '{hook_name}' a predeterminado?" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" +"Esto eliminará tu G-code personalizado para este hook. La máquina volverá a " +"usar su macro predeterminada integrada. Esta acción no se puede deshacer." + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "Restablecer" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "# Tu G-code aquí" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "Archivos de perfil de dispositivo" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "Perfiles de dispositivo LightBurn" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "Todos los archivos" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "Importar perfil de dispositivo" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "Editar macro" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "Insertar variable" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "Incluir macro" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "Editar macro para {name}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "Variables disponibles" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "No hay otras macros para incluir." + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "El nombre no puede estar vacío." + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "El nombre contiene caracteres no válidos: {chars}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "Este nombre ya está en uso por otra macro." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "Editar desplazamientos de trabajo" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" +"Introduzca el desplazamiento del cero máquina al cero trabajo para el WCS " +"activo." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "Desplazamiento X" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "Desplazamiento Y" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "Desplazamiento Z" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "Restablecer contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "Editar contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "Eliminar contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "¿Restablecer contador?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "Esto restablecerá las horas acumuladas a cero." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "¿Eliminar contador?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" +"¿Estás seguro de que quieres eliminar este contador? Esta acción no se puede " +"deshacer." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "Añadir contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "No hay contadores configurados" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "Nuevo contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "Intervalo de notificación" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" +"Mostrar notificación cuando el contador alcance este valor (horas). Poner en " +"0 para desactivar." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "Mantenimiento" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "Horas totales" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "Tiempo de funcionamiento acumulado rastreado por la máquina." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "Horas de funcionamiento totales" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "Tiempo de funcionamiento acumulado de la máquina" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "Restablecer horas totales" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "Contadores de mantenimiento" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" +"Realiza un seguimiento de los intervalos de mantenimiento con contadores " +"reajustables. Úsalo para tubos láser, lubricación, etc." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "{time} en total" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "¿Restablecer horas totales?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" +"Esto restablecerá las horas de funcionamiento acumuladas totales a cero. Los " +"contadores de mantenimiento no se verán afectados." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "Dispositivo" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "Ajustes del dispositivo" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "Leer o aplicar ajustes directamente al dispositivo." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "Leer del dispositivo" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "El controlador actual no admite la lectura de ajustes del dispositivo." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "Copiar detalles del error" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "Descartar error" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" +"¡Editar estos valores puede ser peligroso y podría dejar tu máquina " +"inoperable!" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" +"El dispositivo puede reiniciarse o desconectarse temporalmente después de " +"cambiar un ajuste." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "Descartar advertencia" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" +"Haz clic en el botón de actualización para cargar los ajustes desde el " +"dispositivo." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "La operación falló" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "Máquina no conectada" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "La máquina no está conectada." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "Ajuste aplicado correctamente." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "No se puede conectar: en uso por '{machine}'" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "Máquina activada." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "¿Importar perfil de LightBurn?" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" +"Los perfiles de dispositivo LightBurn contienen solo ajustes básicos de la " +"máquina. El perfil importado puede estar incompleto. Después de la " +"importación, revise y configure cualquier ajuste adicional, como cabezales " +"láser, referenciado, finales de carrera, dialecto G-code, macros y módulos " +"rotativos." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "Importar de todas formas" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "Se importarán los siguientes valores:" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "Ganchos y macros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "Macros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "Crear y gestionar fragmentos de G-code reutilizables." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "Avanzado" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "Procesamiento de trayectorias" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "Configurar cómo se procesan y optimizan las trayectorias." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "Soportar arcos" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" +"Generar comandos de arco para trayectorias más suaves. Desactívalo si tu " +"máquina no soporta arcos" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "Soportar curvas Bézier" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" +"Generar comandos Bézier cúbicos nativos. Desactivar si su máquina no los " +"admite" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "Tolerancia de arcos y curvas" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" +"Desviación máxima respecto a la ruta original al ajustar arcos y curvas.Los " +"valores más bajos aumentan drásticamente el tiempo de procesamiento yel " +"tamaño del trabajo." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "Referenciado e inicio" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" +"Configurar el comportamiento de referenciado y los ajustes de inicio, " +"incluyendo el referenciado automático y el manejo de alarmas." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "Posicionar al iniciar" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "Enviar un comando de referenciado al iniciar la aplicación." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "Permitir referenciado de un solo eje" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "" +"Habilitar controles de referenciado de ejes individuales en el diálogo de " +"jog." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "Desactivar alarma al conectar" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" +"Enviar automáticamente un comando de desbloqueo si se conecta en estado de " +"ALARMA" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "Seleccionar este dialecto" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "¿Eliminar '{label}'?" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" +"Este dialecto personalizado se eliminará permanentemente. Esta acción no se " +"puede deshacer." + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "No se puede eliminar el dialecto" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "" +"Este dialecto todavía está siendo utilizado por la(s) siguiente(s) " +"máquina(s): {machines}" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "Crear desde plantilla" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "No hay dialectos personalizados configurados" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "{label} (Copia)" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "Seleccionar máquina" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "Seleccionar máquina activa" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "Activar/desactivar láser" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "Potencia" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "Potencia del láser en porcentaje" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "Ancho de pulso en µs" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "Duración" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "Segundos (0 = continuo)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "Herramienta {tool_number}, potencia máx. {max_power}" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "{seconds:.1f} s restantes" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "Código G" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "Precisión" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "Configurar la precisión numérica de la salida de coordenadas." + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "Precisión del G-code" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "Número de decimales para las coordenadas" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "Dialecto" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "Seleccionar, crear y gestionar definiciones de dialectos de código G." + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "Mover noroeste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "Mover norte" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "Mover noreste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "Mover oeste (izquierda)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "Mover este (derecha)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "Mover suroeste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "Mover sur" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "Mover sureste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "Referenciar X" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "Referenciar Y" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "Referenciar Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "Enviar a la máquina" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "Aumentar Distancia Z" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "Disminuir Distancia Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "Cancelar trabajo en curso" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "Seleccionar plantilla" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "Elija un dialecto integrado como punto de partida." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "Hardware" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "Ejes" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "Configure las extensiones de los ejes y el sistema de coordenadas." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "Extensión X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "Rango de recorrido completo del eje X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "Extensión Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "Rango de recorrido completo del eje Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "Inferior izquierda" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "Superior izquierda" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "Superior derecha" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "Inferior derecha" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "Origen de coordenadas (0,0)" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "" +"La esquina física donde las coordenadas son cero después del referenciado." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "Invertir la dirección del eje X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "Hace que los valores de las coordenadas sean negativos" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "Invertir la dirección del eje Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "Invertir la dirección del eje Z" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" +"Habilitar si un comando Z positivo (p. ej., G0 Z10) mueve el cabezal hacia " +"abajo." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "Área de trabajo" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "" +"Los márgenes definen el espacio no utilizable alrededor de las extensiones " +"de los ejes." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "Margen izquierdo" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "Espacio no utilizable desde el borde izquierdo" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "Margen superior" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "Espacio no utilizable desde el borde superior" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "Margen derecho" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "Espacio no utilizable desde el borde derecho" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "Margen inferior" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "Espacio no utilizable desde el borde inferior" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "El origen del área de trabajo es coordenada cero" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" +"Trata el origen del área de trabajo como coordenada cero. Oculta los " +"controles WCS y usa los márgenes del área de trabajo como desplazamientos." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "Límites de software" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" +"Límites de seguridad configurables para desplazamiento. Déjelo desactivado " +"para usar los límites de la superficie de trabajo." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "Activar límites de software personalizados" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "" +"Sobrescribir los límites de la superficie de trabajo con límites " +"personalizados" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "X Mín" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "Coordenada X mínima" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "Y Mín" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "Coordenada Y mínima" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "X Máx" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "Coordenada X máxima" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "Y Máx" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "Coordenada Y máxima" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" +"Opcional. Configura las cámaras que quieras usar para la vista previa y la " +"alineación." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" +"Configura las cámaras ahora o hazlo más tarde desde los ajustes de la " +"máquina. El asistente registra los dispositivos V4L que marques como " +"«activados»; la calibración detallada de la lente se realiza en la página de " +"ajustes de la cámara." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "No se detectaron cámaras" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "Puedes añadir cámaras más tarde desde los ajustes de la máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "Elegir controlador" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "¿Qué tipo de placa controladora usa esta máquina?" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "Controlador" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" +"Elige la familia de firmware / protocolo para esta máquina. Si no estás " +"seguro, elige la opción más parecida — puedes ajustar valores individuales " +"más tarde." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "Ninguno — solo exportación de G-code" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "Sin controlador físico; exporta G-code a un archivo" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "Nueva máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" +"Opcional. Configura un accesorio rotativo ahora u omite este paso para " +"añadir uno más tarde desde los ajustes de la máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "Módulo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "Elige el tipo de rotativo, el eje, el modo y la geometría." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "Mordazas / mandril" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "Rodillos" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "Tipo de rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "Cómo se sujeta la pieza de trabajo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "Eje rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "Qué eje usa el rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "Verdadero 4.º eje (conserva X/Y/Z)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "Reemplazo de eje (intercambia, p. ej., Y por A)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "Modo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "Longitud por rotación" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "Se obtiene automáticamente de GRBL $101/$103 si se sondea" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "Ø de pieza de trabajo predeterminado" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "Longitud máxima de pieza de trabajo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "Ø del rodillo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "Obligatorio al usar un rotativo de tipo rodillo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "Invertir dirección del eje" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "Invierte la dirección de rotación del rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "—" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "Sí" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "No" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "Métrico (mm)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "Imperial (pulgadas)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "Revisar y nombrar" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "Nombre final y comprobación de coherencia antes de crear la máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "Un nombre descriptivo para esta máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "Nombre de la máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "Resumen" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "Advertencias" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "Ninguno (solo exportación de G-code)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "Controlador desconocido: {}" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "Conexión" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "Área de trabajo X×Y" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "Sistema de unidades" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "Velocidad de desplazamiento máxima" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "Velocidad de corte máxima" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "Referenciar al iniciar" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "Módulos rotativos" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" +"No se seleccionó ningún controlador — esta máquina solo exportará G-code a " +"archivos; no puede ejecutar trabajos." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" +"Las dimensiones del área de trabajo no están definidas o no son positivas." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "No hay ningún cabezal configurado para esta máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "El cabezal #{n} parece un láser pero no tiene un ajuste de max_power." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "El cabezal #{n} parece un husillo pero no tiene un ajuste de max_rpm." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "El nombre de la máquina está vacío." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "Falta el nombre" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "Introduce un nombre." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "Detectar dispositivo" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" +"Conecta al dispositivo y lee su configuración, u omite para introducir los " +"valores manualmente." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "Sondeando" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" +"Detecta automáticamente el área de trabajo, las velocidades y las " +"capacidades del firmware de la máquina leyendo sus ajustes a través de la " +"conexión." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "Sondear ahora" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "Sondeando…" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "Conectando al dispositivo y leyendo ajustes" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "La detección falló" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "Sondeo realizado correctamente" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "Área de trabajo y velocidades detectadas automáticamente." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "Reintentar" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "Elige un punto de partida para la nueva máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "Plantillas de máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" +"Elige un perfil integrado para rellenar previamente los ajustes comunes. " +"Todavía se te pedirán los valores específicos de la conexión." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "Buscar dispositivos…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "Importar desde archivo…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "Dispositivo no listado" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "Importación fallida" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "Proveedor de IA" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" +"Configura un proveedor de IA para que el asistente pueda rellenar " +"previamente las especificaciones de máquinas conocidas." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" +"Introduce un endpoint compatible con OpenAI. Solo se usa para la búsqueda " +"automática de especificaciones; también puedes omitirlo e introducir los " +"valores a mano." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "Proveedor predeterminado" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "Modelo predeterminado (opcional)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "Área de trabajo (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "Velocidad de corte máxima" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "Origen de coordenadas" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "Tipo de cabezal" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "Potencia máxima del cabezal (valor S)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "RPM máximas del cabezal" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "RPM mínimas del cabezal" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "Tamaño del punto (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "Frecuencia PWM (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "Distancia focal" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "Búsqueda de especificaciones con IA" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" +"Si tu máquina es un modelo comercial conocido, la IA puede rellenar " +"previamente los valores de especificación a partir de la documentación del " +"fabricante." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "Fabricante & modelo" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" +"Introduce el fabricante y el nombre del modelo de la máquina. Cuanto más " +"específico, mejor — p. ej. \"Sculpfun\" / \"S30 Pro\"." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "Fabricante (p. ej. Sculpfun)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "Modelo (p. ej. S30 Pro)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "Buscar especificaciones" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "Sugerencias" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" +"Los valores sugeridos están activados; desactiva los que no quieras aplicar." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" +"No hay ningún proveedor de IA configurado en Ajustes. Configura uno para " +"habilitar la búsqueda automática de especificaciones, u omite este paso e " +"introduce los valores a mano." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "Buscando…" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "La búsqueda falló" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" +"La IA no pudo devolver las especificaciones de esta máquina. Puedes " +"introducir los valores manualmente en los siguientes pasos." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "La IA sugiere: {value}" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "Cabezal principal" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "Introduce los parámetros de conexión de tu dispositivo." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" +"Introduce los parámetros de conexión que requiere tu máquina. Los campos " +"exactos dependen del controlador que elegiste en el paso anterior." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "Fijado por el perfil elegido" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "Entrada no válida" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "Área de trabajo, origen, velocidades y aceleración." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "Esquina física donde las coordenadas son cero tras el referenciado" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "Actívalo si +Z mueve el cabezal hacia abajo" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "" +"Reemplazar los límites de la superficie de trabajo con límites personalizados" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "Velocidades" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "Límites en unidades de máquina por minuto." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "Velocidad máxima de movimiento rápido" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "Velocidad máxima de corte" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" +"Utilizado para estimaciones de tiempo y cálculo de la distancia de sobrescan " +"predeterminada" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "Ejecutar ciclo de referenciación al conectar la máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "Referenciación de eje único" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "Permitir referenciación de ejes individuales" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "¿Qué hay montado en el pórtico: un láser, un husillo o ambos?" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "Tipo de cabezal" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "Elige el cabezal principal para esta máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "Tipo de herramienta montada en esta máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "Nombre del cabezal" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "Ajustes del láser" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "Potencia máxima (valor S)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "Valor máximo de potencia láser en GCode" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "Anchura del haz láser en el eje X" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "Anchura del haz láser en el eje Y" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "Frecuencia PWM (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "Frecuencia de modulación del láser" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "Distancia de la lente a la pieza de trabajo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "Reemplazo de eje" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "4.º eje real" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "{mode}, Eje {axis}" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "Añadir módulo rotativo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "No hay módulos rotativos configurados" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "Nuevo módulo rotativo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "Valores predeterminados rotativos" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "Configuración predeterminada aplicada a nuevas capas." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "Activar rotación por defecto" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "Las nuevas capas usarán el modo rotativo por defecto" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "Módulos" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" +"Defina los módulos rotativos físicos conectados a su máquina. Seleccione uno " +"como predeterminado." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "Modo de conexión" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "Cómo se conecta el módulo rotativo al controlador de la máquina" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "Eje" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "Letra de eje para este módulo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "Eje invertido" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "Invertir la dirección de rotación del eje rotativo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "Desplazamiento del eje X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "Desplazamiento desde la posición del módulo al eje de rotación (X)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "Desplazamiento del eje Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "Desplazamiento desde la posición del módulo al eje de rotación (Y)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "Desplazamiento del eje Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "Desplazamiento desde la posición del módulo al eje de rotación (Z)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "Mordazas / Mandril" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "Tipo de accionamiento" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "Cómo el módulo rotativo acciona la rotación de la pieza" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "Diámetro del rodillo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "Diámetro del rodillo de accionamiento" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "Recorrido por rotación" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" +"Distancia de firmware para una rotación completa de 360°. 0 = salida " +"circunferencial en bruto." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "Diámetro de pieza predeterminado" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "Diámetro predeterminado para nuevas capas que usan este módulo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "Longitud máxima de pieza de trabajo que puede acomodar este módulo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "Posición X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "Coordenada X en el espacio de la máquina" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "Posición Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "Coordenada Y en el espacio de la máquina" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "Posición Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "Coordenada Z en el espacio de la máquina" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "Capacidades" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "Capacidades de la máquina" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" +"Las capacidades se deducen de los cabezales de la máquina, los módulos " +"rotativos y cualquier configuración explícita. Controlan qué pasos se " +"ofrecen al añadir a un flujo de trabajo." + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "configuración explícita" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "fuente desconocida" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "{machine_name} - Ajustes de máquina" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "Ajustes de la máquina" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "Exportar perfil de máquina" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "Informar de un problema" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "Exportado a {path}" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "Exportación fallida: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "Máquina" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "Identificación y configuración básica de la máquina." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "Ajustes del controlador" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "Ajustes de conexión y comunicación para el controlador de la máquina." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "Seleccionar controlador" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "Velocidades y aceleración" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" +"Parámetros de movimiento utilizados para la estimación del tiempo de trabajo " +"y la optimización de trayectorias." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" +"El sistema de unidades utilizado al generar código G y comunicarse con " +"eldispositivo. Esta configuración es independiente de las unidades usadas " +"enla interfaz de usuario." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "Sistema de unidades de la máquina" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "Configuración requerida: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "Error: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "No compatible con el controlador" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" +"El preámbulo contiene G21 (milímetros), pero el sistema de unidades de " +"lamáquina está configurado como imperial. Los valores del código G " +"seemitirán en pulgadas; asegúrese de que su preámbulo coincida." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" +"El preámbulo contiene G20 (pulgadas), pero el sistema de unidades de " +"lamáquina está configurado como métrico. Los valores del código G se " +"emitiránen milímetros; asegúrese de que su preámbulo coincida." + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "Arrastrar para reordenar" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "Eliminar variable" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "Clave" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "Valor por defecto" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "Valor inicial" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "Valor mínimo" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "Valor final" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "Valor máximo" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "Ajustar valor" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "Ajustar rango del deslizador" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "Añadir parámetro" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "Nuevo parámetro" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "Solicitar acceso" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "Clave API configurada" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "Solicitar nueva clave" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "Sin clave API configurada" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "El nombre de host y el puerto deben configurarse primero" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" +"Dispositivo no alcanzable o no soporta solicitudes automáticas de claves" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "Respuesta inesperada del dispositivo" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "Demasiadas solicitudes. Inténtelo de nuevo más tarde." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "Solicitud fallida: {code}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "Conexión fallida: {err}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "Esperando aprobación en el dispositivo…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "Esperando…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "Aprobación expirada. Por favor inténtelo de nuevo." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "Solicitud denegada o expirada." + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "URL de autorización" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "URL del token" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "ID de cliente" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "Iniciar sesión" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "Cerrar sesión" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "Token expirado" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "Actualizar" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "Autenticado" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "Reautorizar" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "No conectado" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "Actualizando…" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "Ninguno seleccionado" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "Tipo no soportado: {t}" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "Aplicar cambio" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "Registro de complementos" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "Obteniendo registro..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "Instalar desde URL..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "Fallo de conexión" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "No se pudo acceder al registro." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "No se encontraron complementos en el registro." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "Instalar" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "Actualizar" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "Instalado" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "La versión {v} ya está instalada" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "Incompatible" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "Requiere {deps}, pero la versión actual de Rayforge es {current}" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "No disponible" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "Instalación manual" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "Introduce la URL de Git." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "Introducir clave de licencia" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "Clave de licencia" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "Activar" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "Introduce la clave de licencia que recibiste al comprar {addon_name}." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "Por favor, introduce una clave de licencia." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "Validando licencia..." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "La validación de la licencia falló." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "Licencia inválida" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "Licencia requerida" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" +"{addon_name} es un complemento premium. Compra una licencia para " +"desbloquearlo, o introduce tu clave de licencia si ya tienes una." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "Comprar licencia" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "Error al cargar este complemento" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "Este complemento se descargará cuando terminen los trabajos activos" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "Este complemento es incompatible con la versión actual de Rayforge" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Este complemento es experimental y puede tener problemas sin resolver. Úselo " +"con precaución." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "Complemento premium" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "Complemento integrado" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "Desinstalar complemento" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "Activar o desactivar este complemento" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "Instalar nuevo complemento..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "No hay complementos instalados." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "Instalando {name}..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "Error al instalar el complemento." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "No se puede desactivar el complemento" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" +"Este complemento no se puede desactivar.\n" +"\n" +"{reason}" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "El complemento se desactivará cuando terminen los trabajos activos." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "" +"Error al desactivar el complemento. Consulta los registros para más detalles." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "Error al activar el complemento y sus dependencias." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "¿Activar dependencias?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" +"Este complemento requiere: {deps}\n" +"\n" +"¿Activarlos también?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "Activar todos" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "" +"Error al activar el complemento. Consulta los registros para más detalles." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "¿Desinstalar {name}?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" +"Los archivos del complemento serán eliminados. Se recomienda reiniciar para " +"liberar completamente la memoria." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "Desinstalar" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "Error al eliminar el complemento." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "Información" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "¿Activar complemento experimental?" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" +"El complemento \"{name}\" es experimental y puede tener problemas sin " +"resolver. Úselo con precaución." + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "Activar de todos modos" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "Ayudar a mejorar Rayforge" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" +"¿Te gustaría ayudar a mejorar Rayforge permitiendo informes de uso anónimos? " +"Esto nos ayuda a entender cómo se usa la aplicación y priorizar mejoras.\n" +"\n" +"No se recopilan datos personales." + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "No, gracias" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "Permitir informes" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "Mostrar historial" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "Acción sin nombre" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "Deshacer la última acción" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "Rehacer la última acción" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "Alternar visibilidad de la pieza de trabajo" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "Alternar visibilidad de pestañas de sujeción" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "Alternar visibilidad de la imagen de la cámara" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "Alternar visibilidad del modelo 3D" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "Alternar visibilidad de la cuadrícula" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "Alternar visibilidad del movimiento de desplazamiento" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "Alternar visibilidad de zona prohibida" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "Sin parámetros" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "Mostrar todas las opciones" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "Seleccionar modelo" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "Seleccionar" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "Verificación del trabajo" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "_Continuar" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "{} error(es)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "{} advertencia(s)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "No se encontraron problemas." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" +"Se encontró {summary}. Continuar puede causar daños a su máquina o pieza de " +"trabajo." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "Errores" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "Valor en {unit}" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "Nuevo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "Abrir..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "Guardar como..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "Abrir recientes" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "Importar..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "Exportar G-code..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "Exportar documento..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "Salir" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "_Archivo" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "Deshacer" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "Rehacer" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "Cortar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "Copiar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "Duplicar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "Seleccionar todo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "Limpiar documento" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "_Editar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "Mostrar panel derecho" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "Mostrar panel inferior" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "Vista 3D" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "Vista superior" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "Vista frontal" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "Vista derecha" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "Vista izquierda" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "Vista posterior" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "Vista isométrica" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "Alternar perspectiva" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "_Ver" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "Dividir" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "Exportar objeto..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "Añadir pestañas de sujeción equidistantes…" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "Añadir pestañas de sujeción cardinales" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "Añadir pestañas de sujeción" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "_Objeto" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "Mover selección a la capa superior" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "Mover selección a la capa inferior" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "Izquierda" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "Derecha" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "Arriba" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "Abajo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "Centrar horizontalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "Centrar verticalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "Alinear" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "Distribuir horizontalmente" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "Distribuir verticalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "Distribuir" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "Voltear horizontalmente" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "Voltear verticalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "Voltear" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "Matriz" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "Organizar" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "_Herramientas" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "Encuadrar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "Enviar trabajo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "Pausar / Reanudar trabajo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "Cancelar trabajo" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "_Máquina" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "Acerca de" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "Donar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "Guardar registro de depuración" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "_Ayuda" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "(Sin elementos recientes)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "" +"Alerta de mantenimiento: {name} ha alcanzado su límite ({curr}h / {limit}h)" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "Ver contadores" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr " (+{tasks} más)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "{tasks} tareas" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "Selecciona una máquina para habilitar la exportación de G-code" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "Generar G-code" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "No se puede exportar mientras hay otras tareas en ejecución" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" +"La canalización necesita recalcularse antes de exportar. Presiona F5 para " +"recalcular." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "Añade una pieza de trabajo para habilitar la exportación" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "Añade o habilita un paso de procesamiento para permitir la exportación" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "Configura la potencia de encuadre para habilitar" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "Encuadrar el área ocupada" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" +"La canalización necesita recalcularse antes de enviar. Presiona F5 para " +"recalcular." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "Reanudar máquina" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "Pausar máquina" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "Por favor, selecciona un único objeto para exportar." + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "Registro de depuración guardado en {path}" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "Abrir proyecto" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "Importar imagen" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "Vista 3D deshabilitada (faltan dependencias como PyOpenGL)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "Mostrar vista previa 3D" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "Recalcular (Mayús+Clic para forzar)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "Alternar panel inferior" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "Organizar selección" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "Añadir pestañas cardinales (N,S,E,O)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "Añadir pestañas a la selección" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "Llevar la máquina al origen" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "Desactivar alarma de la máquina (desbloquear)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "Alternar láser de enfoque" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "Máquina no configurada completamente" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" +"Faltan ajustes requeridos en el controlador de la máquina. Haz clic para " +"editar." + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "Centrar horizontalmente" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "Centrar verticalmente" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "Alinear a la izquierda" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "Alinear a la derecha" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "Alinear arriba" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "Alinear abajo" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" +"Crear un archivo ZIP con archivos de registro e información del sistema para " +"resolución de problemas." + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "Incluir proyecto actual" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "Agregar el archivo del proyecto actual al archivo de depuración" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "_Guardar" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "Error al crear el archivo de depuración." + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "Error al guardar el archivo: {msg}" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "Ocurrió un error inesperado: {error}" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "Cambios no guardados" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "El proyecto actual tiene cambios no guardados. ¿Deseas guardarlos?" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "_No guardar" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "Nuevo proyecto creado" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "Sin título" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "Añadir recurso" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "Añadir boceto" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "Crear nueva pieza de trabajo" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "Cortar elemento(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "Cortar elemento" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "Pegar elemento(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "Pegar elemento" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "Duplicar elemento(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "Duplicar elemento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "Mapear a existente" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "Nuevas capas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "Aplanar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "Modo de importación de capas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "Cómo se asignan las capas importadas a las capas del documento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "Capas SVG" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "Colores" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "Origen de las capas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "Agrupar la geometría importada por capa SVG o por color" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "Importar imagen" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" +"El archivo no produjo ninguna salida en modo de vector directo. Los archivos " +"que contienen texto u otros elementos que no son rutas deben convertirse a " +"rutas antes de importar (por ejemplo, en Inkscape: Ruta > Objeto a Ruta)." + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "Cambiar a modo de trazado" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "Reimportar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "Importar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "Modo de importación" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "Usar vectores originales" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "Importar datos de vectores directamente" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "DPI" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" +"Píxeles por pulgada para dimensiones SVG sin unidades. Inkscape ≥0.92 usa " +"96, Inkscape antiguo usa 90, Illustrator usa 72" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "Capas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "Ajustes de trazado" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "Importar imagen completa" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "Importar la imagen completa sin trazar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "Umbral automático" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "Determinar automáticamente el umbral de trazado" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "Umbral" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "Trazar objetos más oscuros que este valor" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "Invertir" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "Trazar objetos claros sobre fondo oscuro" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "Seleccionar capas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "La capa está vacía" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "Capa con {n} vectores" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "Generando vista previa..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "Aplicabilidad" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" +"Define cuándo se debe sugerir esta receta. Deja los campos en blanco para " +"que coincida con cualquier valor." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "Cualquiera" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "Tipos de paso" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" +"Los tipos de paso a los que se aplica esta receta. Déjelo vacío para que " +"coincida con cualquier tipo de paso." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "Seleccionar..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "Borrar selección de tipos de paso" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "Material" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "Borrar selección de material" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "Grosor mín." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "Grosor mínimo del material para aplicar esta receta." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "Grosor máx." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "Grosor máximo del material para aplicar esta receta." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "…" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "No encontrado" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "Receta" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" +"Un preajuste de configuración con nombre que se puede aplicar " +"automáticamente más tarde." + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" +"Los ajustes que aplicará esta receta. Cuando se seleccionan varios tipos de " +"paso, solo se muestran los ajustes comunes a todos." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "Postprocesamiento" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" +"Ajustes de transformador aplicados por esta receta. Cuando se seleccionan " +"varios tipos de paso, solo se muestran los transformadores comunes a todos." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "No hay opciones de postprocesamiento disponibles para este paso." + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "Editar receta" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "Añadir nueva receta" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "Máquina desconocida" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "Material desconocido" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "No se encontraron recetas." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" +"La receta se eliminará permanentemente. Esta acción no se puede deshacer." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "Seleccionar receta" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "Elige una receta para aplicar al paso actual." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "Mostrar solo recetas compatibles" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "Nombre del paso y ajustes de la receta." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "Refrigeración" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "Refrigerante utilizado mientras se ejecuta esta operación." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "Cambiar {key}" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" +"Transformadores aplicados a la trayectoria de herramienta generada por este " +"paso." + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "Velocidad de los movimientos rápidos de posicionamiento" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "Apagado" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "Inundación" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "Niebla" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "Refrigerante aplicado a la pieza de trabajo mientras se corta" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "Este método de refrigeración no es compatible con la máquina actual" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "Velocidad de la operación de corte" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "Ajustes de {name}" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "Ajustes del paso" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "Elegir..." + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "Ajustes manuales" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "Aplicar receta '{name}'" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "Aplicar transformador de receta" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "Nueva receta {label}" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "Establecer receta aplicada" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "¿Actualizar receta '{name}'?" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" +"Esto sobrescribirá permanentemente la receta guardada con los ajustes del " +"paso actual. Esta acción no se puede deshacer." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "1 material" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "{count} materiales" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "{count} (Solo lectura)" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "Añadir nueva biblioteca" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "No se encontraron bibliotecas." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" +"La carpeta de la biblioteca y todos sus materiales se eliminarán " +"permanentemente. Esta acción no se puede deshacer." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "Editar biblioteca" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "Introduce un nuevo nombre para la biblioteca:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "Nombre de la biblioteca" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "Error al renombrar la biblioteca." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "Introduce un nombre para la nueva carpeta de la biblioteca:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +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/file_dialogs.py +msgid "Open File" +msgstr "Abrir archivo" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "Todos los compatibles" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "Guardar archivo G-code" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "Archivos G-code" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "Exportar objeto" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "Exportar documento" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "SVG (Gráficos vectoriales escalables)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "DXF (Formato de intercambio CAD)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "Abrir proyecto {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "Proyecto {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "Guardar proyecto {app_name}" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "Editar material" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "Actualiza los detalles del material:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "Añadir nuevo material" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "Introduce los detalles del nuevo material:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "Categoría" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "Personalizado" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "Añadir nueva capa" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "Propiedades del material de base" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "Grosor" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "Grosor del material" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "Recursos" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "Visor de G-code" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "Consola" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "Controles" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "Desplazamientos actuales" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "Editar desplazamientos manualmente" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "Posición actual" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "" +"Mover a la esquina inferior izquierda de la selección o área de trabajo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "Mover al centro de la selección o área de trabajo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "Mover a la esquina superior derecha de la selección o área de trabajo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "Mover al origen del WCS activo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "Poner Ejes a Cero" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "Establecer la posición X actual como 0 para el WCS activo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "Establecer la posición Y actual como 0 para el WCS activo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "Establecer la posición Z actual como 0 para el WCS activo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "Establecer origen de trabajo en la posición actual" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "Clic en el lienzo para establecer cero de trabajo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "Clic en el lienzo para establecer cero de trabajo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "Velocidad de Jog" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "Distancia de Jog" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "Distancia en unidades de máquina" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "" +"Sobrescrito por la capa actual. Cámbialo en la configuración de capa." + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "Fuera de línea - Posición desconocida" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" +"Los desplazamientos no se pueden establecer en modo de coordenadas máquina " +"({wcs})" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "La máquina debe estar conectada para establecer el cero aquí" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "Establecer posición actual como 0" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "Seleccionar tipos de paso" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "Elija a qué tipos de paso se aplica esta receta." + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "Buscar..." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "Funciones faltantes" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "Este documento usa una función que no está disponible: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "Este documento usa funciones que no están disponibles: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "El documento aún se puede editar y guardar." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "_Aceptar" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "Seleccionar material" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "Elige un material de las bibliotecas disponibles." + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "Sin operaciones" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "Añadir paso" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "Reordenar pasos" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "Añadir paso '{name}'" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "Eliminar paso '{name}'" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "Configuración de capa" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "Eliminar esta capa" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "Alternar visibilidad de la capa" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "Relativo al origen {wcs}" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "El cero está en el lado izquierdo" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "Restablecer posición X a 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "Restablecer posición Y a 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "Proporción fija" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "Restablecer al ancho natural" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "Restablecer al alto natural" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "Restablecer a la relación de aspecto natural" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "Ángulo" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "Sentido horario es positivo" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "Inclinación" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "Ángulo de inclinación horizontal" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "Restablecer ángulo a 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "Restablecer inclinación a 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "Natural: {val}" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "Archivo de origen" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "Mostrar metadatos de la imagen" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "Mostrar en el explorador de archivos" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "Comandos vectoriales" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "{count} comandos" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "{name} (no encontrado)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "(Sin archivo de origen)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "Pestañas de sujeción" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "Eliminar todas las pestañas de sujeción" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "Ancho de pestaña" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "Longitud a lo largo de la trayectoria" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "Restablecer ancho de pestaña al valor predeterminado (1.0)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "{num_tabs} pestañas de sujeción" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "Valores mixtos" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "Número de pestañas" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "Ajustar pestañas equidistantes" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "Habilitar {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "Alternar {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "Dejar sin cambios" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "Desactivado" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "Esta función no está disponible." + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" +"El componente requerido '{}' no se encontró. El documento aún se puede " +"guardar." + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "Alternar visibilidad del paso" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "Metadatos de la imagen" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "Copiar metadatos" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "No hay metadatos disponibles." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "Información básica" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "Propiedades básicas de la imagen como dimensiones y formato." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "Metadatos" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "Todos los metadatos extraídos de la imagen." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "Metadatos copiados al portapapeles" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "Propiedades del elemento" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "1 elemento seleccionado" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "{count} elementos seleccionados" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "Varios elementos" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "Propiedades de la pieza de trabajo" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "Propiedades del grupo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "{name} - Configuración" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "Cerrar" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "Configuración básica de capa como apariencia y sistema de coordenadas." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "Color usado para operaciones en esta capa" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "Sistema de coordenadas" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" +"El origen del sistema de coordenadas de trabajo para esta capa. Por defecto, " +"usar el WCS seleccionado en la ventana principal" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "Accesorio rotativo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" +"Configure el accesorio rotativo para objetos cilíndricos. Cuando está " +"activado, los movimientos del eje Y se convierten en movimientos " +"rotacionales en grados." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "Activar modo rotativo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "Convertir eje Y a eje rotativo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "Seleccione el módulo rotativo para esta capa" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "Diámetro del objeto" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "Diámetro del objeto cilíndrico" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "No hay materiales en la biblioteca seleccionada." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "No se puede eliminar el material" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" +"Este material está siendo utilizado actualmente por una o más recetas. Por " +"favor, elimina las recetas que usan este material antes de borrarlo." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" +"El material se eliminará permanentemente de la biblioteca. Esta acción no se " +"puede deshacer." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "Error al actualizar el material." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "Error al añadir el material a la biblioteca." + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "Importación por lotes de {file_count} imágenes" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" +"Importar {file_count} imágenes:\n" +"{file_names}\n" +"\n" +"Todas las imágenes se trazarán usando los ajustes de trazado predeterminados " +"y se posicionarán en la ubicación de colocación." + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "Importar todo" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "Añadir nuevo paso..." + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "{count} paso" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "{count} pasos" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "Reproducir simulación" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "Retroceder paso" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "Avanzar paso" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "Velocidad de reproducción" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "Pausar simulación" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "No encontrado" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "Kit de herramientas de IU" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "Gráficos e imágenes" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "Geometría" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "Formatos de archivo y comunicación" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "Sitio web" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "Informar de un problema" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "Versión" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "Copiar versión" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "Desarrollador principal" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "Licencia" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "Información del sistema" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "Versiones de bibliotecas y componentes" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "Copiar información del sistema" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "Partidarios" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "Personas que han donado al proyecto" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" +"¡Un agradecimiento especial a todos los que han donado para apoyar a " +"Rayforge! ¡Mantenéis el café y los tokens de AI fluyendo!" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "Acerca de {app_name}" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "mm/min" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "mm/s" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "pulg/min" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "pulg/s" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "mm" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "cm" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "m" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "pulg" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "pies" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "mm/s²" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "cm/s²" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "m/s²" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "pulg/s²" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "pies/s²" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "{size} B" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "{size:.1f} KB" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "{size:.1f} MB" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "{:.0f}s" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "{}m" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "{}h" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "{line_count:,} líneas · {size}" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "— Truncado (mostrando las primeras 20,000 de {line_count:,} líneas) —" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "Buscando actualizaciones de complementos..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "Hay una actualización disponible para {name}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "Hay actualizaciones disponibles para {name1} y {name2}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "Hay actualizaciones disponibles para {name1}, {name2} y otros {num}." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "Instalar todo" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "Se encontraron actualizaciones de complementos." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "Los complementos están actualizados." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "Instalando actualizaciones de complementos..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "Complemento actualizado correctamente." +msgstr[1] "{num} complementos actualizados correctamente." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "{num_s} complementos actualizados, {num_f} fallidos." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "Error al actualizar el complemento." +msgstr[1] "Error al actualizar {num} complementos." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "Finalizado con {num_failed} errores." + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "¡Todas las actualizaciones de complementos instaladas!" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" +"No se puede abrir '{file}'. El complemento requerido puede estar desactivado." + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "Un generador de G-code para cortadoras láser." + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "Rutas a uno o más archivos de entrada SVG o de imagen." + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" +"Forzar importación como vectores directos. Esto es lo predeterminado para " +"archivos compatibles." + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" +"Forzar importación trazando la representación de mapa de bits del archivo. " +"Se aborta si no es compatible." + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "Establecer el nivel de registro (predeterminado: INFO)" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" +"Salir después de importar documentos y que el editor se haya estabilizado. " +"Útil para pruebas." + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" +"Ruta a un script de Python para ejecutar después de que la ventana principal " +"esté completamente cargada. Útil para automatización y pruebas." + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" +"Ruta a un directorio de configuración personalizado. Útil para pruebas con " +"configuraciones aisladas." + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "Agregar" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "{status} — {activity}" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "Agregando trabajo" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "Generando código de máquina" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "Aplicando transformación de máquina" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "Procesando" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "Procesando '{workpiece}' — {step}" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "Ensamblando" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "Ensamblando '{step}'" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "cara predeterminada" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "La cara '{face}' no se pudo mecanizar: {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "La región {region} de la cara '{face}' no se pudo mecanizar: {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "Advertencia de mecanizado: {detail}" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "Potencia variable" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "Potencia constante" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "Difuminado" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "Múltiples profundidades" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "Variable" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "Constante" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "Múltiples pasadas" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "(+{n} más)" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "Falta: {}" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "Este transformador no está disponible." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "" +"El nombre del sistema de coordenadas actualmente activo (p. ej., 'G54')." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "El nombre del perfil de máquina actual." + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "El ancho (eje X) del área de trabajo de la máquina." + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "La altura (eje Y) del área de trabajo de la máquina." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "El nombre del archivo de documento actual (si está guardado)." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "La coordenada X mínima de todo el trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "La coordenada Y mínima de todo el trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "La coordenada X máxima de todo el trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "La coordenada Y máxima de todo el trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "El desplazamiento X del WCS actualmente activo." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "El desplazamiento Y del WCS actualmente activo." + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "El desplazamiento Z del WCS actualmente activo." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "El nombre de la capa actual que se está procesando." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "El nombre de la pieza de trabajo actual que se está procesando." + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "La posición X de la pieza de trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "La posición Y de la pieza de trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "El ancho de la pieza de trabajo." + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "La altura de la pieza de trabajo." + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "Transformar elemento(s)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "Mover elemento(s)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "Cambiar ángulo del elemento" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "Cambiar inclinación del elemento" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "Redimensionar elemento(s)" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "Actualizar activo" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "Renombrar recurso" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "Eliminar recurso '{name}'" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "Eliminar elemento dependiente" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "Eliminar definición del recurso" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "Alternar visibilidad del recurso" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "Importar {filename}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "Importando {filename}..." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" +"Error al importar {filename}. El archivo de imagen puede estar corrupto o en " +"un formato no compatible." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "Error de importación: No se crearon elementos desde {filename}" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "Importación fallida." + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "¡Importación completada!" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" +"⚠️ El elemento importado era más grande que el área de trabajo y se ha " +"reducido para ajustarse." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "Exportación exitosa: {name}" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "Objeto exportado correctamente." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "Error al exportar el objeto: {error}" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "No se puede exportar: El documento no tiene geometría." + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "Documento exportado correctamente." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "Error al exportar el documento: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "Proyecto guardado: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "Error al guardar: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "Archivo no encontrado: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" +"Este proyecto utiliza métodos de refrigeración no compatibles con la máquina " +"actual: {methods}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "{count} recurso(s) requieren complemento(s) desactivado(s)" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "Formato de archivo de proyecto no válido" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "Error al cargar: {error}" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "No se pudieron encajar los siguientes elementos: {item_names}" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "Renombrar paso" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "Eliminar material" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "Material {count}" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "Alternar visibilidad del material de base" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "Renombrar recurso de material de base" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "Cambiar grosor del material de base" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "Cambiar material de base" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "Añadir pestaña de sujeción" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "Eliminar todas las pestañas de sujeción" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "Alternar pestañas de sujeción" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "Cambiar ancho de pestaña" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "Mover a otra capa" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "Capa" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "Renombrar capa" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "Establecer capa activa" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "Eliminar capa '{name}'" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "Reordenar piezas de trabajo" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "Reordenar elementos" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "Crear matriz" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "Crear copia de matriz" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "{count} recurso(s) requieren el complemento desactivado '{addon}'" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "Agrupando elementos..." + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "Desagrupando elementos..." + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "Dividir elemento(s)" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "Eliminar elemento original" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "Añadir fragmentos divididos" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "Pegar elemento(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "Pegar elemento" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "Duplicar elemento(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "Duplicar elemento" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "Añadir elemento" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "Eliminar elemento" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "Eliminar todas las piezas de trabajo" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "Eliminar elementos de la capa" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "Eliminar contorno(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "Eliminar segmento(s)" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "Posicionar en punto" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "Disposición automática" + +#: 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 +msgid "Failed to process image data." +msgstr "Error al procesar datos de imagen." + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "Error al cargar la imagen: {}" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "No se pudieron calcular los metadatos SVG." + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "Error al preparar datos SVG recortados." + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "El SVG no contiene geometría ni dimensiones." + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "No se pudieron determinar dimensiones SVG válidas." + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "No se pudieron determinar dimensiones válidas para trazar." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "Error al rasterizar SVG para trazar." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "Error al normalizar datos de imagen." + +#: 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." + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "No se pudo leer el PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "Error inesperado al escanear el PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "Error al procesar datos de imagen PDF." + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "Error al leer las dimensiones de las páginas del PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "La página PDF tiene dimensiones cero" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "Error al rasterizar PDF" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "El PDF no contiene geometría vectorial." + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "Error al analizar el PDF: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "El archivo LightBurn es XML no válido: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "El archivo LightBurn está dañado o no es válido: {}" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "No se pudo analizar el encabezado BMP en {}" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "Error al escanear el archivo BMP: {}" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "Datos BMP no válidos o no compatibles." + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "Error al procesar la imagen: {}" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "El archivo no contiene comandos de vector." + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "El archivo Ruida no es válido: {}" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "Error inesperado al escanear el archivo Ruida: {}" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "Error al analizar los comandos Ruida: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "La estructura del archivo DXF no es válida: {}" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "Error inesperado al escanear el DXF: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "El archivo DXF está dañado o no es válido: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "Error al calcular los parámetros: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "Error al ejecutar el generador: {}" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "Error al escanear el archivo JPEG: {}" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "Floyd Steinberg" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..867e50950 --- /dev/null +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -0,0 +1,8944 @@ +# French translations for Rayforge package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Samuel , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2025-10-15 07:10+0200\n" +"Last-Translator: Samuel \n" +"Language-Team: French\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 3.4.2\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "Recherche de mises à jour de Rayforge..." + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "La vérification des mises à jour a échoué." + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "Rayforge {version} est disponible." + +#: rayforge/updater.py +msgid "Download" +msgstr "Télécharger" + +#: rayforge/updater.py +msgid "New version available." +msgstr "Nouvelle version disponible." + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "Rayforge est à jour." + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "{name} Workflow" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "Plat" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "Rotatif · {name}" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "Rotatif" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "Couche {}" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "{name} (copie)" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "Mauvaise requête" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "Échec de l'authentification - veuillez vérifier votre clé API" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "Accès interdit - veuillez vérifier les permissions de votre clé API" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "Point de terminaison API introuvable - veuillez vérifier l'URL de base" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "Limite de requêtes atteinte - veuillez patienter et réessayer" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "Erreur serveur - veuillez réessayer plus tard" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "Service indisponible - veuillez réessayer plus tard" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "Le serveur a renvoyé l'erreur {code}" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "Échec de la connexion - veuillez vérifier votre réseau" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "Modèle '{model}' introuvable. Disponibles : {available}" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "Vitesse de coupe" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "Vitesse de déplacement" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "Paramètres" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "Choix" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "Texte (une seule ligne)" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "Le débit en bauds ne peut pas être vide." + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "'{rate}' n'est pas un débit en bauds standard." + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "Débit en bauds" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "Vitesse de connexion en bits par seconde" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "Le nom d’hôte ou l’adresse IP ne peut pas être vide." + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "Format de nom d’hôte ou d’adresse IP non valide." + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "Nom d'hôte / IP" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "Entier" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "La valeur doit être d'au moins {min_val}." + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "La valeur doit être d'au plus {max_val}." + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "Le port ne peut pas être vide." + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "Le port doit être un nombre." + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "Nombre à virgule flottante" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "Curseur (0-100 %)" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "Texte (plusieurs lignes)" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "Choix (étiqueté)" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "Booléen (Commutateur)" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "L'URL ne peut pas être vide." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "L'URL doit inclure un schéma (par ex., 'http://')." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "L'URL doit inclure un nom d'hôte." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "Le schéma de l'URL doit être l'un des suivants : {schemes}." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "URL invalide : {error}" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "Le port série ne peut pas être vide." + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "Port série" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "Ligne centrale" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "Intérieur" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "Extérieur" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "Intérieur-Extérieur" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "Extérieur-Intérieur" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "Laser" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "Fraisage" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "PWM" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "Découpe et gravure au laser" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "Fraisage et usinage avec une broche" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "Contrôle de puissance laser à modulation de largeur d'impulsion" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "Fixation d'axe rotatif pour objets cylindriques" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "Noyau" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "Matériau brut" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "Source" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "Erreur de syntaxe : {message}" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "Variable ou fonction inconnue : « {name} »" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" +"Impossible d'utiliser l'opérateur « {op} » entre les types « {left} » et " +"« {right} »" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "Aucun pilote" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "Aucune connexion" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "Coordonnées machine" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "Aucun paramètre" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "La ressource « {resource} » est actuellement utilisée par « {owner} »." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" +"Ce pilote n'a pas été testé. Il peut fonctionner ou non. Utilisez-le à vos " +"propres risques." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Ce pilote est expérimental et peut contenir des problèmes non résolus. " +"Utilisez-le avec précaution." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "Unknown" +msgstr "Inconnu" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "Inactif" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "Exécuter" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "Pause" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "Déplacement manuel" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "Alarme" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "Porte" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "Vérifier" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "Origine" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "Veille" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "Outil" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "File d’attente" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "Verrouiller" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "Déverrouiller" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "Cycle" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "Test" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "Fréquence" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "Fréquence PWM en Hz" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "Largeur d'impulsion" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "Largeur d'impulsion en microsecondes" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" +"Erreur lors de la configuration. Vous devrez peut-être modifier les " +"paramètres du périphérique." + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "Smoothie" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "Smoothieware via une connexion Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "Coordonnées machine (G53)" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "Nom d’hôte ou adresse IP non valide : « {host} »" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "Nom d’hôte" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "L’adresse IP ou le nom d’hôte du périphérique" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "Port" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "Le numéro de port Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "Le nom d’hôte doit être configuré." + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "Ruida (UDP)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "Se connecter à un contrôleur laser Ruida via UDP" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "L'adresse IP ou le nom d'hôte du contrôleur Ruida" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "Port principal" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "Le port UDP pour les commandes principales (par défaut : 50200)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "Port de déplacement manuel" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "" +"Le port UDP pour les commandes de déplacement manuel (par défaut : 50207)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "Pas de réponse du contrôleur" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "Soumettre du G-code à un serveur OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "Adresse IP ou nom d'hôte du serveur OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "Port HTTP du serveur OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "Clé API" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" +"Entrez une clé API manuellement ou cliquez sur « Demander l'accès » pour en " +"obtenir une via le plugin Application Keys d'OctoPrint." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" +"La clé API doit être configurée. Utilisez le bouton « Demander l'accès » ou " +"entrez une clé API manuellement." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "Échec de l'authentification. La clé API peut être invalide ou expirée." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" +"Impossible de se connecter à OctoPrint à « {host}:{port} ». Vérifiez " +"l'adresse et la connexion réseau." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "Échec de l'authentification" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" +"La clé API est invalide ou a expiré. Veuillez vous réauthentifier dans les " +"paramètres du périphérique." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "OctoPrint n'a renvoyé aucune donnée de connexion." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "Trame WebSocket inattendue." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "Le serveur a fermé la connexion WebSocket." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "Impression échouée" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" +"OctoPrint a signalé l'échec du travail d'impression. Consultez OctoPrint " +"pour plus de détails." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "Pilote non configuré avec un hôte." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "Échec de l'authentification lors du téléchargement." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" +"L'imprimante est occupée ou non opérationnelle. Impossible de démarrer un " +"nouveau travail." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" +"OctoPrint a accepté le fichier mais n'a pas pu démarrer l'impression. " +"L'imprimante peut ne pas être opérationnelle ou être déjà occupée." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "" +"Impossible de télécharger le fichier vers OctoPrint à « {host}:{port} »." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" +"OctoPrint ne prend pas en charge l'écriture des paramètres de firmware du " +"périphérique via son API." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" +"Commande de palpage envoyée. OctoPrint ne signale pas les résultats de " +"palpage via son API." + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "Marlin (Série)" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "Firmware Marlin via connexion série" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "Port série pour le périphérique" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "Le port doit être configuré." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "Le débit en bauds doit être configuré." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "Port non configuré" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "Pas de réponse de l'appareil" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "Auto-configuré via l'assistant de détection" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "GRBL (Telnet)" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "Contrôleur compatible GRBL via connexion TCP/telnet brute" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "Port TCP pour le service raw/telnet" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "Interroger l'état de l'appareil pendant les tâches" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" +"Interroger périodiquement l'appareil pour obtenir sa position et son état " +"pendant l'exécution d'une tâche. Attention : certains appareils ont des " +"difficultés à maintenir une connexion stable si cette option est utilisée !" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "Détection de blocage" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" +"Détecte et récupère les blocages de communication série pendant les travaux. " +"Si désactivé, le pilote attendra simplement que la machine réponde. " +"Désactivez si vous rencontrez de fausses erreurs ALARM:3." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "Lettre de commande manquante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" +"Les commandes G-code nécessitent une lettre suivie d'une valeur. La lettre " +"de commande n'a pas été trouvée." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "Format de nombre invalide" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" +"La valeur est manquante ou n'est pas dans le bon format numérique. Vérifiez " +"la syntaxe de votre G-code." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "Commande inconnue" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" +"Cette commande de paramétrage Grbl n'est pas reconnue ou prise en charge. " +"Vérifiez la syntaxe de la commande." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "Valeur négative" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" +"Un nombre positif est requis ici, mais une valeur négative a été reçue." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "Prise d'origine désactivée" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" +"Le retour à l'origine n'est pas activé dans les paramètres. Activez le " +"retour à l'origine ($22=1) pour utiliser cette fonction." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "Durée d'impulsion trop courte" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" +"Le temps d'impulsion de pas minimum doit être supérieur à 3 microsecondes. " +"Vérifiez le paramètre $0." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "Erreur de mémoire" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" +"Les paramètres ont été réinitialisés aux valeurs par défaut en raison d'une " +"erreur de lecture de la mémoire. Reconfigurez vos paramètres si nécessaire." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "Machine occupée" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" +"Cette commande ne peut être utilisée que lorsque la machine est inactive. " +"Attendez que le travail en cours se termine." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "Commandes verrouillées" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" +"Impossible d'envoyer des commandes en mode d'alarme ou de déplacement " +"manuel. Effacez d'abord l'état d'alarme." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "Retour à l'origine requis" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" +"Les limites logicielles ne peuvent pas être activées sans que le retour à " +"l'origine soit également activé. Activez d'abord le retour à l'origine " +"($22=1)." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "Ligne trop longue" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" +"La ligne de commande contient trop de caractères et a été ignorée. Vérifiez " +"le formatage de votre fichier." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "Paramètre trop élevé" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" +"Ce paramètre dépasse la vitesse de pas maximale prise en charge. Utilisez " +"une valeur plus faible." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "Porte ouverte" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" +"La porte de sécurité a été détectée comme ouverte. Fermez la porte et " +"reprenez l'opération." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" +"Les informations de build ou la ligne de démarrage dépassent la limite de " +"stockage. Raccourcissez la ligne." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "Cible hors de portée" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" +"La cible de déplacement manuel dépasse les limites de déplacement de la " +"machine. Déplacez-vous vers une position dans la plage." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "Commande de déplacement manuel invalide" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" +"La commande de déplacement manuel manque '=' ou contient un G-code interdit. " +"Vérifiez la syntaxe de déplacement manuel." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "Erreur de mode laser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" +"Le mode laser nécessite une sortie PWM pour fonctionner. Vérifiez votre " +"configuration matérielle." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "La broche ne tourne pas" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" +"Une commande de mouvement a été émise mais la broche ne tourne pas. Démarrez " +"la broche avant le mouvement." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "Vitesse de broche incorrecte" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" +"La vitesse actuelle de la broche ne correspond pas à la vitesse requise par " +"la commande. Attendez que la broche atteigne la vitesse cible." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "Commande non prise en charge" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" +"Cette commande G-code n'est pas prise en charge par la machine. Vérifiez les " +"paramètres de votre post-processeur." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "Commandes en conflit" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" +"Plusieurs commandes du même groupe trouvées sur une ligne. Supprimez la " +"commande en double." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "Vitesse d'avance manquante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" +"Définissez une vitesse d'avance avant d'utiliser les commandes de mouvement. " +"Ajoutez une commande F pour spécifier la vitesse." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "Entier requis" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" +"Cette commande nécessite une valeur entière. Supprimez tous les points " +"décimaux." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "Conflit d'axe" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "" +"Plusieurs commandes tentent d'utiliser le même axe. Simplifiez la commande." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "Mot en double" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "Le même mot G-code apparaît plus d'une fois. Supprimez le doublon." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "Axe manquant" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" +"Cette commande nécessite des coordonnées d'axe XYZ. Ajoutez les valeurs " +"d'axe manquantes." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "Numéro de ligne hors plage" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" +"Le numéro de ligne doit être compris entre 1 et 9 999 999. Utilisez un " +"numéro de ligne valide." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "Valeur manquante" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "" +"Cette commande nécessite une valeur P ou L. Ajoutez le paramètre manquant." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "Coordonnée non prise en charge" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" +"Seuls les systèmes de coordonnées G54-G59 sont pris en charge. Utilisez l'un " +"d'entre eux à la place." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "Mode de mouvement incorrect" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" +"La commande G53 nécessite le mode de mouvement G0 ou G1. Définissez d'abord " +"le mode de mouvement correct." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "Mots d'axe non utilisés" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" +"Mots d'axe présents mais l'annulation G80 est active. Supprimez les mots " +"d'axe non utilisés." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "Données d'arc manquantes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" +"La commande d'arc G2/G3 nécessite des coordonnées XYZ. Ajoutez les valeurs " +"d'axe pour le plan sélectionné." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "Cible invalide" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" +"Impossible de créer cet arc ou de sonder la position actuelle. Vérifiez les " +"coordonnées de la cible." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "Erreur de géométrie d'arc" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" +"Le calcul de l'arc a échoué. Essayez de diviser l'arc en plus petits " +"morceaux ou utilisez plutôt un décalage IJK." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "Décalage d'arc manquant" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" +"La commande d'arc G2/G3 nécessite des valeurs de décalage IJK. Ajoutez le " +"décalage manquant pour le plan sélectionné." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "Mots non utilisés" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" +"Certains mots G-code de cette ligne ne sont utilisés par aucune commande. " +"Supprimez les mots non utilisés." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "Mauvais axe pour le décalage" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" +"Le décalage de longueur d'outil ne fonctionne que sur l'axe configuré " +"(généralement l'axe Z). Vérifiez vos paramètres." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "Numéro d'outil trop élevé" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" +"Le numéro d'outil dépasse la valeur maximale prise en charge. Utilisez un " +"numéro d'outil valide." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "Limite matérielle" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" +"Un interrupteur de limite matérielle a été déclenché. La machine s'est " +"arrêtée et doit être réinitialisée. Vérifiez les obstructions et contrôlez " +"vos interrupteurs de fin de course." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "Limite logicielle" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" +"La machine se déplacerait au-delà de ses limites de déplacement configurées. " +"Vérifiez que votre zone de travail et vos décalages de coordonnées sont " +"corrects." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "Abandonner le cycle" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" +"Le travail en cours a été annulé pendant un déplacement. Réinitialisez la " +"machine pour continuer." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "Échec du palpeur — Initial" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" +"Le palpeur n'a pas établi de contact avant d'atteindre la distance de " +"déplacement maximale. Vérifiez le câblage et le positionnement du palpeur." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "Échec du palpeur — Final" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" +"Le palpeur n'a pas pu se rétracter vers la position cible après le contact. " +"Vérifiez la configuration du palpeur." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "Échec du référencement — Réinitialisation" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" +"Le référencement n'a pas pu se terminer car la machine est en état d'alarme. " +"Effacez l'alarme et réessayez." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "Échec du référencement — Approche" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" +"Le cycle de référencement n'a pas trouvé l'interrupteur dans la distance de " +"déplacement configurée. Vérifiez le câblage de l'interrupteur et les " +"paramètres de dégagement." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "Échec du référencement — Dégagement" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" +"Le cycle de référencement n'a pas pu s'éloigner de l'interrupteur après le " +"contact. Augmentez la distance de dégagement ou vérifiez l'interrupteur." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "Référencement sans limites" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" +"Le référencement a été commandé mais les interrupteurs de fin de course ne " +"sont pas configurés. Activez d'abord les interrupteurs de fin de course." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "Échec de prise d'origine — Axe double" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" +"La prise d'origine a échoué sur une configuration à double axe. Un ou les " +"deux axes n'ont pas atteint leurs interrupteurs de fin de course. Vérifiez " +"le câblage et la configuration de vos interrupteurs de fin de course." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "Alarme inconnue" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "Code d'alarme non valide signalé par la machine." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" +"La machine a signalé un code d'alarme non reconnu. Consultez la " +"documentation de votre machine et de votre firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "Erreur inconnue" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "Code d'erreur non valide signalé par la machine." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" +"La machine a signalé un code d'erreur non reconnu. Consultez la " +"documentation de votre machine et de votre firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "Configuration des moteurs pas à pas" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" +"Paramètres liés au minutage et à la polarité des signaux des moteurs pas à " +"pas." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "Commande et rapport" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "Paramètres de contrôle du mouvement et de rapport d’état de GRBL." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "Limites et référencement" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "" +"Paramètres des limites logicielles/mécaniques et du cycle de référencement." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "Broche et laser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "Paramètres de contrôle de la broche ou du module laser." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "Étalonnage des axes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "Définit le nombre de pas par millimètre pour chaque axe." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "Cinématique des axes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "Définit la vitesse et l’accélération maximales pour chaque axe." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "Course des axes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "Définit la distance de déplacement maximale pour chaque axe." + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "GRBL (Série)" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "Connexion série compatible GRBL" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "Surcharge de la taille du tampon RX" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" +"Forcer une taille spécifique du tampon RX en octets. Mettre à 0 pour la " +"détection automatique à partir du périphérique." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "Paramètres inconnus" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" +"Les paramètres rapportés par le périphérique ne figurent pas dans la liste " +"standard." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "Paramètre inconnu provenant du périphérique" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" +"L'appareil est configuré pour rapporter en pouces ($13=1). Toutes les " +"valeurs affichées sont en unités machine." + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" +"Le mode laser n'est pas activé ($32=0). Activez-le pour de meilleurs " +"résultats avec les découpeuses laser." + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "GRBL (Série Simple)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "GRBL série avec protocole ping-pong simple (sans comptage de tampon)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "Le débit en bauds doit être configuré." + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "GRBL (Réseau)" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "Se connecter à un périphérique compatible GRBL via le réseau" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "Port HTTP" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "Le port HTTP du périphérique" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "Port WebSocket" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "Le port WebSocket du périphérique" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "Variante de protocol" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "Variante GRBL standard, ESP3D ou Longer" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "Standard" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" +"L'hôte n'est pas configuré. Veuillez définir une adresse IP ou un nom d'hôte " +"valide." + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" +"Impossible de se connecter à l’hôte « {host} ». Vérifiez l’adresse IP et la " +"connexion réseau." + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "Zone interdite" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "Hors de la zone de travail" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "Limites de la machine" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "{name} (dialecte de l'appareil)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "• Calibrage de la caméra : matrice + distorsion trouvée" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "(aucun champ mappé)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "Nom du périphérique" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "Zone de travail" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "Pilote" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "Débit en bauds" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "Origine au démarrage" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "Vitesse de déplacement max." + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "Origine" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "Miroir X" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "Miroir Y" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "Calibrage de la caméra" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "matrice + distorsion importée" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "Tête de broche" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "{label} (pour {machine_name})" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "Tête laser" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "Machine par défaut" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "Module rotatif" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "Tête" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "Aucun pilote sélectionné pour cette machine." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "Pilote « {driver} » introuvable." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "Une erreur inattendue est survenue lors de la validation : {error}" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "GRBL Raster" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" +"Optimisé pour la gravure raster GRBL. Garde le mode de puissance dynamique " +"M4 actif en continu et utilise la vitesse d'avance modale pour minimiser la " +"surcharge de commande pendant les lignes de balayage" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "Mach4 (M67 Analogique)" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" +"Mach4 avec sortie analogique M67 pour la gravure raster haute vitesse. " +"Utilise M67 E0 Q<0-255> pour la puissance laser au lieu des commandes S en " +"ligne, réduisant la pression du tampon sur le contrôleur." + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "Smoothieware" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "Dialecte G-code pour les contrôleurs basés sur Smoothieware" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "LinuxCNC" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "" +"G-code pour LinuxCNC, prenant en charge les courbes de Bézier cubiques " +"natives (G5)" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "GRBL Dynamique" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" +"GRBL avec mode de puissance dynamique M4 (sensible à la profondeur). Le " +"paramètre S est inclus dans les commandes de mouvement" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "Informations générales" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "Libellé" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "Nom visible par l'utilisateur" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "Description" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "Brève description" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "Omettre les coordonnées inchangées" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" +"Lorsqu'activé, les lettres d'axe qui n'ont pas changé sont omises des " +"commandes G0/G1" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "Mode laser continu" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" +"Garde le mode de puissance dynamique M4 actif en continu pendant la gravure " +"raster au lieu de basculer M4/M5 entre chaque segment" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "Vitesse d'avance modale" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" +"N'inclure le paramètre de vitesse d'avance F dans les commandes de mouvement " +"que lorsqu'il change par rapport à la valeur précédente" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "Modèles de commande" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "Allumer le laser" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "Éteindre le laser" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "Focaliser le laser" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "Déplacement à vide" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "Mouvement linéaire" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "Arc (Sens horaire)" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "Arc (Sens anti-horaire)" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "Bézier Cubique" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "Changement d'outil" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "Définir la vitesse" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "Activer l'air" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "Désactiver l'air" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "Origine tous les axes" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "Origine de l'axe" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "Déplacer vers" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "Effacer l’alarme" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "Définir le décalage du système de coordonnées de travail" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "Cycle de palpage" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "Temporisation" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "Broche marche (CW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "Broche marche (CCW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "Broche arrêt" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "Liquidide de refroidissement inondation" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "Liquidide de refroidissement brouillard" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "Liquidide de refroidissement arrêt" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "Scripts" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "Injecter le WCS après le préambule" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" +"Injecter la commande WCS active (p. ex. G54) après le script de préambule. " +"Lorsqu'elle est désactivée, vous pouvez utiliser {machine.active_wcs} dans " +"le préambule à la place." + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "Préambule" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "Script de préambule" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "Post-scriptum" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "Script post-scriptum" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "Marlin" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "" +"G-code pour les contrôleurs basés sur Marlin, courant dans les imprimantes 3D" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "Grbl (Compatibilité)" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" +"Dialecte Grbl avec la plus haute compatibilité pour la plupart des lasers à " +"diode et CNC de loisir" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "Début de couche" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "Fin de couche" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "Début de pièce" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "Fin de pièce" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "Avant le traitement d'une couche" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "Après le traitement d'une couche" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "Avant le traitement d'une pièce" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "Après le traitement d'une pièce" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "Macro sans nom" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "{job_name} a échoué : {error}" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" +"Échec de l'énumération des ports série en raison d'un confinement Snap ! " +"Veuillez vous assurer que l'appareil est connecté via USB et exécutez :\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" +"Ports série trouvés, mais aucun n'est accessible. Veuillez vous assurer que " +"votre Snap a l'interface 'serial-port' connectée en exécutant :\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "Connexion en cours" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "Connecté" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "Erreur" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "Fermeture en cours" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "Déconnecté" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "En veille" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "Machines" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "Machines configurées" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "Ajouter ou supprimer des machines." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "Cette machine a une configuration non valide." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "C’est la machine active." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "Supprimer « {name} » ?" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Ce profil de machine et tous ses paramètres seront supprimés définitivement. " +"Cette action est irréversible." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "Annuler" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "Supprimer" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "Ajouter une machine" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "Licences" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "" +"Liez votre compte Patreon pour un accès anticipé aux nouvelles extensions." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "Compte Patreon lié" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "Les extensions d'accès anticipé sont déverrouillées" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "Dissocier" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "Lier le compte Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "Obtenir un accès anticipé aux extensions premium" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "Lier" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "Licences d'extensions" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "Gérez vos clés de licence achetées." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "Aucune licence installée" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" +"Achetez une extension premium et entrez la clé de licence lors de " +"l'installation." + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "{addons} (+{count} de plus)" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "ID du produit : {id}" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "Supprimer" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "Extensions nécessitant une licence" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "Ces extensions nécessitent une licence valide pour être activées" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "Licence requise" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "Acheter" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "Supprimer la licence ?" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" +"Cette clé de licence sera supprimée. Vous devrez peut-être la saisir à " +"nouveau pour utiliser les extensions sous licence." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "Fournisseur par défaut" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "Activer ou désactiver ce fournisseur" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "Définir par défaut" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "Ajouter un fournisseur" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "Aucun fournisseur configuré" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "Nouveau fournisseur" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "Supprimer « {name} » ?" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" +"Ce fournisseur d'IA sera définitivement supprimé. Cette action est " +"irréversible." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "Nom" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "Type de fournisseur" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "Compatible OpenAI" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "URL de base" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "Modèle par défaut" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "Test de connexion" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "Vérifier que la configuration du fournisseur fonctionne" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "Modifier le fournisseur" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "Paramètres du fournisseur" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "Test en cours..." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "IA" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "Fournisseurs d'IA" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" +"Configurez les fournisseurs d'IA pour une utilisation par les extensions. " +"Les extensions peuvent utiliser ces fournisseurs sans avoir besoin de leurs " +"propres clés API." + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "Extensions" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "Extensions installées" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "Installer, mettre à jour et supprimer des extensions." + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "Recettes" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "" +"Gérez vos recettes enregistrées pour différents matériaux et processus." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "Modifier la règle de couleur" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "Mettez à jour les détails de la règle de couleur :" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "Enregistrer" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "Ajouter une règle de couleur" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "Associez une couleur à un type d'étape pour les importations SVG." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "Ajouter" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "Couleur" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "Couleur SVG qui déclenche cette règle" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "Libellé (facultatif)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "Type d'étape" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "Type d'étape créé lors de l'importation de cette couleur" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "Couleur {color}" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "Ce type d'étape n'est pas disponible actuellement." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "{step_type} (non disponible)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "Aucune règle de couleur trouvée." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "Supprimer la règle de couleur '{color}' ?" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" +"La règle de couleur sera définitivement supprimée. Cette action ne peut " +"pasêtre annulée." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "Règles de couleur" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" +"Associez les couleurs SVG aux types d'étape afin qu'elles soient " +"appliquéesautomatiquement lors de l'importation." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "Matériaux" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "Bibliothèques de matériaux" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" +"Gérez vos bibliothèques de matériaux. Sélectionnez une bibliothèque pour " +"voir ses matériaux." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "Matériaux dans la bibliothèque sélectionnée." + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "Catégories" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "Anglais" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "Allemand" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "Espagnol" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "Français" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "Portugais" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "Ukrainien" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "Chinois (simplifié)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "Système" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "Clair" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "Sombre" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "Ne rien ouvrir" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "Ouvrir le dernier projet" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "Ouvrir un projet spécifique" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "Couleur du laser" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "Couleur du calque" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "Par défaut du système" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "Général" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "Apparence" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "Paramètres liés à l’apparence et à l’ergonomie de l’application." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "Thème" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "Langue" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "" +"La langue de l'application. Les modifications nécessitent un redémarrage." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "Couleurs des opérations" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "" +"Choisir si les couleurs des opérations représentent le laser ou le calque" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "Unités" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" +"Définit les unités d’affichage pour les différentes valeurs dans l’ensemble " +"de l’application." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "Longueur" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "Vitesse" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "Accélération" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "Comportement" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "Configurer le comportement avancé de l'application." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "Mise à jour automatique des opérations" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" +"Recalculer les opérations automatiquement après chaque modification. " +"Désactiver pour un recalcul manuel via le bouton de la barre d'outils" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "Budget du cache (Mo)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" +"Mémoire maximale pour le cache. Les scènes de haute complexité nécessitent " +"plus" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "Vérifier les mises à jour" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +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 "Startup behavior" +msgstr "Comportement au démarrage" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "Chemin du projet" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "Parcourir..." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "Confidentialité" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" +"Aidez-nous à améliorer Rayforge en autorisant les rapports d'utilisation " +"anonymes. Aucune donnée personnelle n'est collectée." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "Signaler l'utilisation anonyme" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "Aider à améliorer Rayforge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" +"En savoir " +"plus sur le suivi d'utilisation et la confidentialité." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "Redémarrage requis" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" +"La langue prendra effet après le redémarrage de Rayforge. Voulez-vous " +"redémarrer maintenant ?" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "_Annuler" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "_Redémarrer" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "Les copies conservent leurs calques d'origine." + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "_Appliquer" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "Grille" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "Grille" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "Lignes" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "Colonnes" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "Déplacement" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "Écart" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "Espacement" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "Le déplacement est de centre à centre ; l'écart est de bord à bord." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "Espacement des colonnes" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "Espacement des lignes" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "Tableau de rotation de points" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "Rotation de points" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "Fait tourner les copies sur place autour du centre de la sélection." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "Nombre" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "Angle total (degrés)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "Tableau circulaire" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "Circulaire" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "Place les copies le long d'un arc circulaire autour d'un centre." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "Centre X" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "Centre Y" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "Rayon" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "Faire tourner les copies" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "Déplacer la languette" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "Déplacer vers le calque supérieur" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "Déplacer vers le calque inférieur" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "Grouper" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +msgstr "Dégrouper" + +#: 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 +msgid "Add Tab Here" +msgstr "Ajouter une languette ici" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "Supprimer la languette" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "Nouvelle esquisse" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "Nouveau matériau" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +msgstr "Importer un fichier…" + +#: 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 "Paste" +msgstr "Coller" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "Ajouter l'instance {}" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "Déposer les fichiers à importer" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "Image importée depuis le presse-papiers" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "Échec de l'importation de l'image depuis le presse-papiers" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "La vue 3D n’est pas disponible car des dépendances sont manquantes." + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "Sélectionnez une machine pour ouvrir la vue 3D." + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "Ajouter un brut" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "Disposition automatique (Simple)" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "{camera_name} - Calibrage de l'objectif" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "Paramètres d’image de la caméra" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "Ajuster les paramètres de qualité et d'apparence de l'image." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "Par défaut" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "Personnalisé..." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "Résolution" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" +"Résolution de capture de la caméra. Par défaut, utilise le réglage natif de " +"la caméra." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "Largeur personnalisée" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "Hauteur personnalisée" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "Préférer le format YUYV" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" +"Utiliser YUYV non compressé au lieu de MJPEG. Corrige les artefacts verts " +"sur certaines caméras USB mais peut réduire la résolution ou la fréquence " +"d'images sur USB 2.0." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "Balance des blancs automatique" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "Ajuster automatiquement la balance des blancs" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "Balance des blancs (Kelvin)" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "Température de couleur pour une représentation précise des couleurs" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "Contraste" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "Différence entre les zones claires et sombres" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "Luminosité" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "Luminosité ou obscurité globale de l'image" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "Réduction du bruit" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +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 +msgid "Transparency" +msgstr "Transparence" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "Transparence sur la surface de travail" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "Veuillez sélectionner une caméra disponible" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "Veuillez sélectionner une caméra configurée" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "Sélectionner la caméra" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "Aucune caméra configurée." + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "Échec du chargement de l'image pour l'ID de périphérique : {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "Caméra {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "Aucune caméra trouvée." + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "Point {n}" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "Supprimer ce point" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "Déplacer pixel :" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "Propriétés de la caméra" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "Configurer la caméra sélectionnée." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "Identifiant du périphérique" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "Identifiant système du périphérique de caméra" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "Nom d'affichage de cette caméra" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "Activé" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "Activer ou désactiver le flux de la caméra" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "Démarrer" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "Assistant caméra" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" +"Configuration guidée : réglages d'image, calibration d'objectif et " +"alignement." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "Configurer" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "Paramètres d’image" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "Ajuster la luminosité, le contraste, la balance des blancs et le bruit" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "Calibration d'objectif" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "Corriger la distorsion de l'objectif pour des lignes plus droites" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "Alignement de l’image" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "Calibrer la position et la perspective de la caméra" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "Calibrage de l'objectif terminé" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "Calibrage de l'objectif pas encore effectué" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "Alignement d'image terminé" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" +"L'alignement d'image doit être refait après la mise à jour du calibrage de " +"l'objectif" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "Alignement d'image pas encore effectué" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "En attente de la caméra..." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" +"Corriger la distorsion de l'objectif pour des lignes plus droites. " +"Choisissez comment calibrer, ou ignorez si votre objectif présente une " +"distorsion négligeable." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "Automatique" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "Calibration automatique" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" +"Imprimez une carte de calibration et capturez-la à plusieurs positions. " +"L'assistant calcule les coefficients de distorsion pour vous." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "Manuel" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "Calibration manuelle" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "" +"Saisissez manuellement les coefficients de distorsion radiale et " +"tangentielle." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "Ignorer" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "Carte de calibration" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "Instructions" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" +"Imprimez une carte de calibration pour corriger la distorsion de l'objectif. " +"La taille de la carte doit tenir dans le champ de la caméra." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "Taille de la carte" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "Ajustez pour l'adapter à votre surface de travail." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "Largeur" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "Largeur de la carte" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "Hauteur" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "Hauteur de la carte" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "Motif généré" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "Détails sur le motif de calibration." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "Taille de la grille" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "Taille du carré" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "Taille physique" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "Enregistrer en PDF" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "Exporter la carte de calibration pour l'impression" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "Enregistrer la carte de calibration" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "Carte de calibration enregistrée" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "Capturer des images" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" +"Captrez la carte à différentes positions. Important : incluez les coins et " +"les bords de l'image pour une correction précise de la distorsion." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "État" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "Progression du processus de capture de calibration." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "Images capturées" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "Coins détectés" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "Couverture" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "Non démarré" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "Déplacez la carte pour capturer plus de positions" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "Progression de la capture" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "Capturer l'image" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "Effacer" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "Étalonner" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "Bon" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "Limité — atteignez les bords" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "Insuffisant — atteignez tous les coins" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "Étalonnage échoué" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "Étalonnage terminé" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" +"Erreur RMS : {rms:.4f} pixels\n" +"Qualité : {quality}\n" +"Images utilisées : {frames}" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "Ignorer" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "Enregistrer l'étalonnage" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "{camera} - Assistant caméra" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "Retour" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "Suivant" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "Terminer" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "OK" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "Radial 1 (k1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "Distorsion radiale du premier ordre" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "Radial 2 (k2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "Distorsion radiale du second ordre" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "Radial 3 (k3)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "Distorsion radiale de troisième ordre" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "Tangentiel 1 (p1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "Distorsion tangentielle du premier ordre" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "Tangentiel 2 (p2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "Distorsion tangentielle du second ordre" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" +"Corriger la distorsion de l'objectif pour des lignes plus droites. Ajustez " +"les coefficients manuellement." + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "{camera_name} – Alignement de l’image" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "Dézoomer (défiler vers le bas)" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "Ajuster à la fenêtre" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "Zoomer (défiler vers le haut)" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "{camera_name} - Paramètres d’image de la caméra" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "Identifiant du périphérique : {device_id}" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "Ajouter une nouvelle caméra" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "Aucune caméra configurée" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "Amélioration de l'image" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "Réduire le bruit et améliorer la stabilité de l'image." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" +"Moyenne temporelle. Les valeurs élevées éliminent plus de bruit mais " +"provoquent des traînées." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" +"Redresser les lignes courbées avec les paramètres Radial (k1, k2) et " +"Tangentiel (p1, p2). Note : Les valeurs sont généralement très petites." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "Correction de la distorsion de l'objectif (Fisheye)" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "Caméra" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "Caméras" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +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/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" +"Cliquez sur l'image pour ajouter des points de référence. Faites glisser " +"pour les déplacer.\n" +"Défilez pour zoomer. Clic du milieu et glisser pour déplacer.\n" +"Utilisez les touches fléchées pour déplacer le point actif avec précision." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "Réinitialiser les points" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "Effacer tous les points" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "Appliquer" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "Ajouter une nouvelle macro" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "Aucune macro configurée" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "Nouvelle macro" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "Outil {tool_number}, {min_rpm}-{max_rpm} rpm" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" +"Outil {tool_number}, puissance max {max_power}, taille du point {spot_x}" +"x{spot_y}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "Outil {tool_number}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "Ajouter une nouvelle tête" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "Aucune tête configurée" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "Au moins une tête est requise" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "Broche" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "Nouveau laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "Nouvelle broche" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "Modèle 3D" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "Sélectionnez et configurez un modèle 3D pour cette tête." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "Modèle" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "Échelle" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "Facteur d'échelle uniforme pour le modèle" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "Rotation X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "Degrés autour de l'axe X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "Rotation Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "Degrés autour de l'axe Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "Rotation Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "Degrés autour de l'axe Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "Aucun" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "Propriétés du laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "Configurez la tête laser sélectionnée." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" +"Paramètres de modulation de largeur d'impulsion pour le contrôle de la " +"fréquence et de la largeur d'impulsion." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "Cadrage" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "Paramètres de l'opération de contour qui trace la limite du travail." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "Numéro d’outil" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "Numéro d’outil G-code (par ex. : T0, T1)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "Diode" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "CO₂" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "Fibre" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "Type de laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "Type de tube laser ou de diode" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "Puissance max." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "Valeur de puissance maximale dans le G-code" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "Puissance de mise au point" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "" +"Valeur de puissance en pourcentage à utiliser pour la mise au point. 0 pour " +"désactiver." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "Taille du point X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "Taille du point laser dans la direction X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "Taille du point Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "Taille du point laser dans la direction Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "Couleur de coupe" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "Couleur pour les opérations de coupe" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "Couleur de trame" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "Couleur pour les opérations de gravure/tramage" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "Distance focale" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "Distance de la tête laser à la surface de travail (décalage Z)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "Fréquence PWM" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "Fréquence PWM par défaut en Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "Fréquence PWM maximale" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "Fréquence PWM maximale prise en charge en Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "Largeur d'impulsion par défaut en µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "Largeur d'impulsion minimale" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "Largeur d'impulsion minimale en µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "Largeur d'impulsion maximale" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "Largeur d'impulsion maximale en µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "Puissance de cadrage" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "" +"Valeur de puissance en pourcentage à utiliser pour le cadrage. 0 pour " +"désactiver." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "Vitesse du cadre" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" +"Vitesse pour le contour. Laissez à 0 pour utiliser la vitesse maximale de la " +"machine" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "Nombre de répétitions" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "Nombre de fois que le contour est tracé" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "Pause aux coins" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "Durée de pause en secondes à chaque coin du contour. 0 pour désactiver" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "Propriétés de la broche" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "Configurez la tête de broche sélectionnée." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "RPM min." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "Vitesse minimale de la broche" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "RPM max." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "Vitesse maximale de la broche" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "Prend en charge le liquide de refroidissement par inondation" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "Liquide de refroidissement déversé sur la pièce" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "Prend en charge le liquide de refroidissement par brouillard" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "Liquide de refroidissement pulvérisé sur la pièce" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "Têtes" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "" +"Vous pouvez configurer plusieurs lasers ou broches si votre machine le " +"permet." + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "Ajouter une machine" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "Créer une machine" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "Impossible de créer la machine" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "Configuration de la caméra indisponible" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "" +"Calibrer cette caméra plus tard depuis la page des paramètres de la machine." + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "Afficher la sortie détaillée (interrogations d'état)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "Rectangle" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "Boîte" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "Cylindre" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "Ajouter une zone" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "Aucune zone interdite configurée" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "Nouvelle zone" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "Zones interdites" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" +"Définissez les zones restreintes sur la surface de travail. Un avertissement " +"sera affiché avant d'exécuter ou d'exporter un travail dont le parcours " +"d'outil pénètre dans une zone interdite activée." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "Propriétés de zone" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "Configurez la zone sélectionnée." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "Forme" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "Forme de la géométrie de la zone" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "X" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "Position X en {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "Y" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "Position Y en {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "Z" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "Position Z en {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "Profondeur" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "Profondeur (étendue en Z)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "Rayon du cylindre" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "Hauteur du cylindre" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "Hauteur du cylindre" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "Les accolades échappées {{ ou }} ne sont pas prises en charge." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "Les accolades imbriquées ne sont pas autorisées." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "Accolade fermante '}' trouvée." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "Accolade ouvrante '{' trouvée." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "Les accolades vides '{}' ne sont pas autorisées." + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "Variable(s) non prise(s) en charge : {vars}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "Modifier le dialecte : {label}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "Nouveau dialecte" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "Mettre à jour depuis un modèle" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "L'étiquette ne peut pas être vide." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" +"Sélectionnez un modèle pour copier ses paramètres. Votre libellé et votre " +"description seront conservés." + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "Hooks G-code" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" +"Ajoutez du G-code personnalisé à exécuter à des moments précis du travail." + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "Rétablir les valeurs par défaut" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "Rétablir « {hook_name} » à sa valeur par défaut ?" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" +"Cela supprimera votre G-code personnalisé pour ce hook. La machine reviendra " +"à l’utilisation de sa macro par défaut intégrée. Cette action est " +"irréversible." + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "Réinitialiser" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "# Votre G-code ici" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "Archives de profils d'appareil" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "Profils de périphérique LightBurn" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "Tous les fichiers" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "Importer le profil d'appareil" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "Modifier la macro" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "Insérer une variable" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "Inclure une macro" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "Modifier la macro pour {name}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "Variables disponibles" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "Aucune autre macro à inclure." + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "Le nom ne peut pas être vide." + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "Le nom contient des caractères non valides : {chars}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "Ce nom est déjà utilisé par une autre macro." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "Modifier les décalages de travail" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" +"Entrez le décalage du zéro machine vers le zéro travail pour le WCS actif." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "Décalage X" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "Décalage Y" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "Décalage Z" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "Réinitialiser le compteur" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "Modifier le compteur" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "Supprimer le compteur" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "Réinitialiser le compteur ?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "Ceci réinitialisera les heures accumulées à zéro." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "Supprimer le compteur ?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" +"Voulez-vous vraiment supprimer ce compteur ? Cette action est irréversible." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "Ajouter un compteur" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "Aucun compteur configuré" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "Nouveau compteur" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "Intervalle de notification" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" +"Afficher une notification lorsque le compteur atteint cette valeur (heures). " +"Définir à 0 pour désactiver." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "Maintenance" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "Total des heures" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "Temps de fonctionnement cumulé suivi par la machine." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "Total des heures de fonctionnement" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "Temps de fonctionnement cumulé de la machine" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "Réinitialiser le total des heures" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "Compteurs de maintenance" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" +"Suivre les intervalles de maintenance avec des compteurs réinitialisables. " +"Pour les tubes laser, la lubrification, etc." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "{time} au total" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "Réinitialiser le total des heures ?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" +"Ceci réinitialisera le total cumulé des heures de fonctionnement à zéro. Les " +"compteurs de maintenance ne seront pas affectés." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "Périphérique" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "Paramètres du périphérique" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "Lire ou appliquer les paramètres directement sur le périphérique." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "Lire depuis le périphérique" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "" +"Le pilote actuel ne prend pas en charge la lecture des paramètres du " +"périphérique." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "Copier les détails de l’erreur" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "Ignorer l’erreur" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" +"Modifier ces valeurs peut être dangereux et rendre votre machine " +"inutilisable !" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" +"Le périphérique peut redémarrer ou se déconnecter temporairement après la " +"modification d’un paramètre." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "Ignorer l’avertissement" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" +"Cliquez sur le bouton d’actualisation pour charger les paramètres depuis le " +"périphérique." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "Échec de l’opération" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "Machine non connectée" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "La machine n'est pas connectée." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "Paramètre appliqué avec succès." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "Connexion impossible : utilisé par « {machine} »" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "Machine activée." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "Importer le profil LightBurn ?" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" +"Les profils de périphérique LightBurn ne contiennent que des paramètres " +"machine de base. Le profil importé peut être incomplet. Après l'importation, " +"veuillez vérifier et configurer tous les paramètres supplémentaires tels que " +"les têtes laser, la prise d'origine, les fins de course, le dialecte G-code, " +"les macros et les modules rotatifs." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "Importer quand même" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "Les valeurs suivantes seront importées :" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "Hooks et Macros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "Macros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "Créez et gérez des extraits de G-code réutilisables." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "Avancé" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "Traitement du parcours" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "Configurer le traitement et l'optimisation des parcours." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "Prendre en charge les arcs" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" +"Générer des commandes d'arc pour des parcours plus fluides. Désactiver si " +"votre machine ne prend pas en charge les arcs" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "Prendre en charge les courbes de Bézier" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" +"Générer des commandes Bézier cubiques natives. Désactiver si votre machine " +"ne les prend pas en charge" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "Tolérance des arcs et des courbes" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" +"Déviation maximale par rapport au tracé d'origine lors de l'ajustement " +"desarcs et des courbes. Des valeurs plus faibles augmentent " +"considérablementle temps de traitement et la taille du travail." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "Prise d'origine et démarrage" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" +"Configurer le comportement de prise d'origine et les paramètres de " +"démarrage, y compris la prise d'origine automatique et la gestion des " +"alarmes." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "Retour à l’origine au démarrage" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "Envoyer une commande de prise d'origine au démarrage de l'application" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "Autoriser la prise d'origine sur un seul axe" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +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 "Clear Alarm On Connect" +msgstr "Effacer l'alarme à la connexion" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" +"Envoyer automatiquement une commande de déverrouillage si la connexion " +"s’effectue alors que la machine est en état d’ALARME" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "Sélectionner ce dialecte" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "Supprimer « {label} » ?" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" +"Ce dialecte personnalisé sera définitivement supprimé. Cette action est " +"irréversible." + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "Impossible de supprimer le dialecte" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "" +"Ce dialecte est toujours utilisé par la/les machine(s) suivante(s) : " +"{machines}" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "Créer depuis un modèle" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "Aucun dialecte personnalisé configuré" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "{label} (Copie)" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "Sélectionner une machine" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "Sélectionner la machine active" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "Activer/désactiver le laser" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "Puissance" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "Puissance du laser en pourcentage" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "Largeur d'impulsion en µs" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "Durée" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "Secondes (0 = continu)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "Outil {tool_number}, puissance max. {max_power}" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "{seconds:.1f} s restantes" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "G-code" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "Précision" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "Configurer la précision numérique des coordonnées en sortie." + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "Précision du G-code" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "Nombre de décimales pour les coordonnées" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "Dialecte" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "Sélectionner, créer et gérer les définitions de dialectes G-code." + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "Déplacer vers le Nord-Ouest" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "Déplacer vers le Nord" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "Déplacer vers le Nord-Est" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "Déplacer vers l'Ouest (Gauche)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "Déplacer vers l'Est (Droite)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "Déplacer vers le Sud-Ouest" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "Déplacer vers le Sud" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "Déplacer vers le Sud-Est" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "Origine X" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "Origine Y" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "Origine Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "Envoyer à la machine" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "Augmenter la Distance Z" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "Diminuer la Distance Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "Annuler la tâche en cours" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "Sélectionner un modèle" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "Choisissez un dialecte intégré comme point de départ." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "Matériel" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "Axes" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "Configurez les étendues des axes et le système de coordonnées." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "Étendue X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "Plage de déplacement complète de l'axe X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "Étendue Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "Plage de déplacement complète de l'axe Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "En bas à gauche" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "En haut à gauche" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "En haut à droite" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "En bas à droite" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "Origine des coordonnées (0,0)" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "" +"Le coin physique où les coordonnées sont à zéro après la prise d'origine." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "Inverser la direction de l'axe X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "Rend les valeurs des coordonnées négatives" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "Inverser la direction de l'axe Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "Inverser la direction de l'axe Z" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" +"Activer si une commande Z positive (p. ex., G0 Z10) déplace la tête vers le " +"bas." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "Zone de travail" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "" +"Les marges définissent l'espace inutilisable autour des étendues des axes." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "Marge gauche" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "Espace inutilisable depuis le bord gauche" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "Marge supérieure" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "Espace inutilisable depuis le bord supérieur" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "Marge droite" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "Espace inutilisable depuis le bord droit" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "Marge inférieure" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "Espace inutilisable depuis le bord inférieur" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "L'origine de la zone de travail est le zéro des coordonnées" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" +"Traite l'origine de la zone de travail comme le zéro des coordonnées. Masque " +"les contrôles WCS et utilise les marges de la zone de travail comme " +"décalages." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "Limites logicielles" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" +"Limites de sécurité configurables pour le déplacement manuel. Laisser " +"désactivé pour utiliser les limites de la surface de travail." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "Activer les limites logicielles personnalisées" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "" +"Remplacer les limites de la surface de travail par des limites personnalisées" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "X Min" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "Coordonnée X minimale" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "Y Min" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "Coordonnée Y minimale" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "X Max" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "Coordonnée X maximale" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "Y Max" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "Coordonnée Y maximale" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" +"Facultatif. Configurez les caméras que vous souhaitez utiliser pour l'aperçu " +"et l'alignement." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" +"Configurez les caméras maintenant ou plus tard depuis les paramètres de la " +"machine. L'assistant enregistre les périphériques V4L que vous marquez comme " +"« activés » ; la calibration détaillée de l'objectif s'effectue sur la page " +"des paramètres de la caméra." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "Aucune caméra détectée" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "" +"Vous pourrez ajouter des caméras plus tard depuis les paramètres de la " +"machine." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "Choisir le contrôleur" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "Quel type de carte contrôleur utilise cette machine ?" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "Contrôleur" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" +"Choisissez la famille de firmwares / protocoles pour cette machine. Si vous " +"n'êtes pas sûr, choisissez la correspondance la plus proche — vous pourrez " +"affiner les réglages individuels plus tard." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "Aucun — export G-code uniquement" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "Aucun contrôleur physique ; export du G-code vers un fichier" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "Nouvelle machine" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" +"Facultatif. Configurez une fixation rotative maintenant ou ignorez cette " +"étape pour en ajouter une plus tard depuis les paramètres de la machine." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "Module" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "Choisissez le type de rotation, l'axe, le mode et la géométrie." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "Mors / mandrin" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "Rouleaux" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "Type de rotation" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "Comment la pièce est maintenue" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "Axe de rotation" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "L'axe utilisé par le rotatif" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "Véritable 4e axe (conserve X/Y/Z)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "Remplacement d'axe (échange p. ex. Y contre A)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "Mode" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "Longueur par rotation" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "Récupéré automatiquement depuis GRBL $101/$103 si sondage" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "Ø de pièce par défaut" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "Longueur max. de la pièce" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "Ø du rouleau" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "Requis pour un rotatif de type rouleaux" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "Inverser le sens de l'axe" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "Inverser le sens de rotation du rotatif" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "—" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "Oui" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "Non" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "Métrique (mm)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "Impérial (pouces)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "Réviser et nommer" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "Nom final et vérification de cohérence avant de créer la machine." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "Un nom convivial pour cette machine." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "Nom de la machine" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "Résumé" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "Avertissements" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "Aucun (export G-code uniquement)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "Pilote inconnu : {}" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "Connexion" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "Zone de travail X×Y" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "Système d'unités" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "Vitesse maximale de déplacement" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "Vitesse maximale de découpe" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "Prise d'origine au démarrage" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "Modules rotatifs" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" +"Aucun pilote sélectionné — cette machine ne fera qu'exporter le G-code vers " +"des fichiers ; elle ne peut pas exécuter de travaux." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" +"Les dimensions de la zone de travail ne sont pas définies ou sont non " +"positives." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "Aucune tête n'est configurée pour cette machine." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "La tête #{n} ressemble à un laser mais n'a pas de réglage max_power." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "La tête #{n} ressemble à une broche mais n'a pas de réglage max_rpm." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "Le nom de la machine est vide." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "Nom manquant" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "Veuillez saisir un nom." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "Découvrir le périphérique" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" +"Connectez-vous au périphérique et lisez sa configuration, ou ignorez pour " +"saisir les valeurs manuellement." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "Sondage" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" +"Découvrez automatiquement la zone de travail, les vitesses et les capacités " +"du firmware de la machine en lisant ses réglages via la connexion." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "Sonder maintenant" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "Sondage…" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "Connexion au périphérique et lecture des réglages" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "La détection a échoué" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "Sondage réussi" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "Zone de travail et vitesses détectées automatiquement." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "Réessayer" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "Choisissez un point de départ pour la nouvelle machine." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "Modèles de machine" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" +"Choisissez un profil intégré pour pré-remplir les réglages courants. Des " +"valeurs spécifiques à la connexion vous seront toujours demandées." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "Rechercher des appareils…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "Importer depuis un fichier…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "Périphérique non répertorié" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "Échec de l'importation" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "Fournisseur IA" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" +"Configurez un fournisseur IA afin que l'assistant puisse pré-remplir les " +"spécifications connues de la machine." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" +"Saisissez un point de terminaison compatible OpenAI. Il n'est utilisé que " +"pour la recherche automatique des spécifications ; vous pouvez aussi " +"l'ignorer et saisir les valeurs à la main." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "Fournisseur par défaut" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "Modèle par défaut (facultatif)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "Zone de travail (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "Vitesse de coupe max." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "Origine des coordonnées" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "Type de tête" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "Puissance maximale de la tête (valeur S)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "RPM max. de la tête" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "RPM min. de la tête" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "Taille du spot (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "Fréquence PWM (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "Distance focale" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "Recherche de spécifications IA" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" +"Si votre machine est un modèle commercial connu, l'IA peut pré-remplir les " +"valeurs de spécifications à partir de la documentation du fabricant." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "Fabricant & modèle" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" +"Saisissez le fabricant et le nom du modèle de la machine. Plus c'est précis, " +"mieux c'est — p. ex. « Sculpfun » / « S30 Pro »." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "Fabricant (p. ex. Sculpfun)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "Modèle (p. ex. S30 Pro)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "Rechercher les spécifications" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "Suggestions" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" +"Les valeurs suggérées sont activées ; désactivez celles que vous ne voulez " +"pas appliquer." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" +"Aucun fournisseur IA n'est configuré dans les paramètres. Configurez-en un " +"pour activer la recherche automatique de spécifications, ou ignorez cette " +"étape et saisissez les valeurs à la main." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "Recherche…" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "Échec de la recherche" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" +"L'IA n'a pas pu renvoyer de spécifications pour cette machine. Vous pouvez " +"saisir les valeurs manuellement aux étapes suivantes." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "L'IA suggère : {value}" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "Tête principale" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "Saisissez les paramètres de connexion de votre périphérique." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" +"Saisissez les paramètres de connexion requis par votre machine. Les champs " +"exacts dépendent du contrôleur que vous avez choisi à l'étape précédente." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "Fixé par le profil choisi" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "Saisie non valide" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "Zone de travail, origine, vitesses et accélération." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "" +"Coin physique où les coordonnées sont nulles après le retour à l'origine" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "Activer si +Z abaisse la tête" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "" +"Remplacer les limites de la surface de travail par des limites personnalisées" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "Vitesses" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "Limites en unités machine par minute." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "Vitesse maximale de déplacement rapide" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "Vitesse maximale de découpe" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" +"Utilisé pour les estimations de temps et le calcul de la distance de " +"surbalayage par défaut" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "" +"Exécuter le cycle de prise d'origine lors de la connexion de la machine" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "Prise d'origine mono-axe" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "Autoriser la prise d'origine par axe individuel" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "" +"Qu'est-ce qui est fixé au portique : un laser, une broche, ou les deux ?" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "Type de tête" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "Choisissez la tête principale pour cette machine." + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "Type d'outil fixé à cette machine" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "Nom de la tête" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "Paramètres du laser" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "Puissance max (valeur S)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "Valeur maximale de puissance laser en GCode" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "Largeur du faisceau laser sur l'axe X" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "Largeur du faisceau laser sur l'axe Y" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "Fréquence PWM (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "Fréquence de modulation du laser" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "Distance objectif-pièce" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "Remplacement d'axe" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "4e axe véritable" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "{mode}, Axe {axis}" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "Ajouter un module rotatif" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "Aucun module rotatif configuré" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "Nouveau module rotatif" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "Paramètres par défaut rotatifs" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "Paramètres par défaut appliqués aux nouveaux calques." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "Activer la rotation par défaut" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "Les nouveaux calques utiliseront le mode rotatif par défaut" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "Modules" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" +"Définissez les modules rotatifs physiques connectés à votre machine. " +"Sélectionnez-en un comme valeur par défaut." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "Mode de connexion" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "Comment le module rotatif est connecté au contrôleur de la machine" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "Axe" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "Lettre d'axe pour ce module" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "Axe inversé" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "Inverser le sens de rotation de l'axe rotatif" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "Décalage d'axe X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "Décalage entre la position du module et l'axe de rotation (X)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "Décalage d'axe Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "Décalage entre la position du module et l'axe de rotation (Y)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "Décalage d'axe Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "Décalage entre la position du module et l'axe de rotation (Z)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "Mâchoires / Mandrin" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "Type d'entraînement" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "Comment le module rotatif entraîne la rotation de la pièce" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "Diamètre du rouleau" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "Diamètre du rouleau d'entraînement" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "Déplacement par rotation" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" +"Distance firmware pour une rotation complète de 360°. 0 = sortie " +"circonférentielle brute." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "Diamètre de pièce par défaut" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "Diamètre par défaut pour les nouveaux calques utilisant ce module" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "Longueur maximale de pièce que ce module peut accueillir" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "Position X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "Coordonnée X dans l'espace machine" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "Position Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "Coordonnée Y dans l'espace machine" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "Position Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "Coordonnée Z dans l'espace machine" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "Capacités" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "Capacités de la machine" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" +"Les capacités sont déduites des têtes de la machine, des modules rotatifs et " +"de toute configuration explicite. Elles contrôlent les étapes proposées lors " +"de l'ajout à un workflow." + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "configuration explicite" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "source inconnue" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "{machine_name} - Paramètres de la machine" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "Paramètres de la machine" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "Exporter le profil de machine" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "Signaler un problème" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "Exporté vers {path}" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "Échec de l’exportation : {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "Machine" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "Identification et configuration de base de la machine." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "Paramètres du pilote" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "" +"Paramètres de connexion et de communication pour le pilote de la machine." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "Sélectionner un pilote" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "Vitesses et accélération" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" +"Paramètres de mouvement utilisés pour l'estimation du temps de travail et " +"l'optimisation du parcours." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" +"Le système d'unités utilisé lors de l'émission du G-code et de " +"lacommunication avec l'appareil. Ce réglage est indépendant des " +"unitésutilisées dans l'interface utilisateur." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "Système d'unités de la machine" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "Configuration requise : {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "Erreur : {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "Non supporté par le pilote" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" +"Le préambule contient G21 (millimètres) mais le système d'unités de " +"lamachine est réglé sur impérial. Les valeurs de G-code seront émises " +"enpouces — assurez-vous que votre préambule correspond." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" +"Le préambule contient G20 (pouces) mais le système d'unités de la machineest " +"réglé sur métrique. Les valeurs de G-code seront émises en millimètres— " +"assurez-vous que votre préambule correspond." + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "Glisser pour réorganiser" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "Supprimer la variable" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "Clé" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "Valeur par défaut" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "Valeur de départ" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "Valeur minimale" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "Valeur de fin" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "Valeur maximale" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "Ajuster la valeur" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "Ajuster la plage du curseur" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "Ajouter un paramètre" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "Nouveau paramètre" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "Demander l'accès" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "Clé API configurée" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "Demander une nouvelle clé" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "Aucune clé API configurée" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "Le nom d'hôte et le port doivent être configurés d'abord" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" +"Périphérique inaccessible ou ne prend pas en charge les demandes de clé " +"automatiques" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "Réponse inattendue du périphérique" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "Trop de requêtes. Réessayez plus tard." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "Échec de la requête : {code}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "Échec de la connexion : {err}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "En attente d'approbation sur le périphérique…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "En attente…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "Délai d'approbation écoulé. Veuillez réessayer." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "Demande refusée ou expirée." + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "URL d'autorisation" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "URL du jeton" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "ID client" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "Se connecter" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "Se déconnecter" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "Jeton expiré" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "Actualiser" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "Authentifié" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "Réautoriser" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "Non connecté" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "Actualisation…" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "Aucun sélectionné" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "Type non pris en charge : {t}" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "Appliquer la modification" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "Registre des extensions" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "Récupération du registre..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "Installer depuis une URL..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "Échec de la connexion" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "Impossible d'atteindre le registre." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "Aucune extension trouvée dans le registre." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "Installer" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "Mettre à jour" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "Installé" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "La version {v} est déjà installée" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "Incompatible" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "Nécessite {deps}, mais la version actuelle de Rayforge est {current}" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "Indisponible" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "Installation manuelle" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "Saisissez l'URL Git." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "Saisir la clé de licence" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "Clé de licence" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "Activer" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "" +"Saisissez la clé de licence que vous avez reçue lors de l'achat de " +"{addon_name}." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "Veuillez saisir une clé de licence." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "Validation de la licence..." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "La validation de la licence a échoué." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "Licence invalide" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "Licence requise" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" +"{addon_name} est une extension premium. Achetez une licence pour la " +"déverrouiller, ou saisissez votre clé de licence si vous en avez déjà une." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "Acheter une licence" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "Échec du chargement de cette extension" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "" +"Cette extension sera déchargée lorsque les tâches actives seront terminées" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "Cette extension est incompatible avec la version actuelle de Rayforge" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Cette extension est expérimentale et peut contenir des problèmes non " +"résolus. Utilisez-la avec précaution." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "Extension premium" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "Extension intégrée" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "Désinstaller l'extension" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "Activer ou désactiver cette extension" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "Installer une nouvelle extension..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "Aucune extension installée." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "Installation de {name}..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "Échec de l'installation de l'extension." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "Impossible de désactiver l'extension" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" +"Cette extension ne peut pas être désactivée.\n" +"\n" +"{reason}" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "" +"L'extension sera désactivée lorsque les tâches actives seront terminées." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "" +"Échec de la désactivation de l'extension. Consultez les journaux pour plus " +"de détails." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "Échec de l'activation de l'extension et de ses dépendances." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "Activer les dépendances ?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" +"Cette extension nécessite : {deps}\n" +"\n" +"Les activer également ?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "Tout activer" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "" +"Échec de l'activation de l'extension. Consultez les journaux pour plus de " +"détails." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "Désinstaller {name} ?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" +"Les fichiers de l'extension seront supprimés. Un redémarrage est recommandé " +"pour libérer complètement la mémoire." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "Désinstaller" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "Erreur lors de la suppression de l'extension." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "Info" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "Activer l'extension expérimentale ?" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" +"L'extension \"{name}\" est expérimentale et peut contenir des problèmes non " +"résolus. Utilisez-la avec précaution." + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "Activer quand même" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "Aider à améliorer Rayforge" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" +"Souhaitez-vous aider à améliorer Rayforge en autorisant les rapports " +"d'utilisation anonymes ? Cela nous aide à comprendre comment l'application " +"est utilisée et à prioriser les améliorations.\n" +"\n" +"Aucune donnée personnelle n'est collectée." + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "Non, merci" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "Autoriser les rapports" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "Afficher l'historique" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "Action sans nom" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "Annuler la dernière action" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "Rétablir la dernière action" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "Afficher/Masquer la visibilité de la pièce" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "Afficher/Masquer la visibilité des languettes" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "Afficher/Masquer la visibilité de l’image de la caméra" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "Basculer la visibilité du modèle 3D" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "Basculer la visibilité de la grille" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "Afficher/Masquer la visibilité des déplacements à vide" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "Basculer la visibilité de la zone interdite" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "Aucun paramètre" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "Afficher toutes les options" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "Sélectionner un modèle" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "Sélectionner" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "Vérification du travail" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "_Continuer" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "{} erreur(s)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "{} avertissement(s)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "Aucun problème trouvé." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" +"{summary} trouvé(s). Continuer peut endommager votre machine ou votre pièce." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "Erreurs" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "Valeur en {unit}" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "Nouveau" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "Ouvrir..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "Enregistrer sous..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "Ouvrir les récents" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "Importer..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "Exporter le G-code..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "Exporter le document..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "Quitter" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "_Fichier" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "Annuler" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "Rétablir" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "Couper" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "Copier" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "Dupliquer" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "Tout sélectionner" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "Effacer le document" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "_Édition" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "Afficher le panneau droit" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "Afficher le panneau inférieur" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "Vue 3D" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "Vue de dessus" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "Vue de face" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "Vue droite" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "Vue gauche" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "Vue arrière" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "Vue isométrique" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "Basculer la perspective" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "_Vue" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "Scinder" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "Exporter l'objet..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "Ajouter des languettes équidistantes..." + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "Ajouter des languettes cardinales" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "Ajouter des languettes" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "_Objet" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "Déplacer la sélection vers le calque supérieur" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "Déplacer la sélection vers le calque inférieur" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "Gauche" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "Droite" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "Haut" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "Bas" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "Centrer horizontalement" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "Centrer verticalement" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "Aligner" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "Répartir horizontalement" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "Répartir verticalement" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "Distribuer" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "Retourner horizontalement" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "Retourner verticalement" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "Retourner" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "Tableau" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "Agencer" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "_Outils" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "Cadrer" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "Envoyer la tâche" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "Pause / Reprendre la tâche" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "Annuler la tâche" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "_Machine" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "À propos" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "Faire un don" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "Enregistrer le journal de débogage" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "_Aide" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "(Aucun élément récent)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "Alerte de maintenance : {name} a atteint sa limite ({curr} / {limit})" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "Voir les compteurs" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr " (+{tasks} de plus)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "{tasks} tâches" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "Sélectionner une machine pour activer l’exportation du G-code" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "Générer le G-code" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "Impossible d'exporter pendant que d'autres tâches sont en cours" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" +"Le pipeline doit être recalculé avant l'exportation. Appuyez sur F5 pour " +"recalculer." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "Ajouter une pièce pour activer l’exportation" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "" +"Ajouter ou activer une étape de traitement pour permettre l'exportation" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "Configurer la puissance du cadrage pour l'activer" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "Faire un cycle de la tête laser autour de la zone occupée" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" +"Le pipeline doit être recalculé avant l'envoi. Appuyez sur F5 pour " +"recalculer." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "Reprendre" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "Mettre en pause" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "Veuillez sélectionner un seul objet à exporter." + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "Journal de débogage enregistré dans {path}" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "Ouvrir le projet" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "Importer une image" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "Vue 3D désactivée (dépendances manquantes, comme PyOpenGL)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "Afficher l’aperçu 3D" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "Recalculer (Maj+Clic pour forcer)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "Basculer le panneau inférieur" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "Agencer la sélection" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "Ajouter des languettes cardinales (N, S, E, O)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "Ajouter des languettes à la sélection" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "Retour à l'origine" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "Effacer l’alarme de la machine (déverrouiller)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "Activer/Désactiver le laser de mise au point" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "Machine non entièrement configurée" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" +"Des paramètres requis sont manquants pour le pilote de la machine. Cliquez " +"pour modifier." + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "Centrer horizontalement" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "Centrer verticalement" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "Aligner à gauche" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "Aligner à droite" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "Aligner en haut" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "Aligner en bas" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" +"Créer une archive ZIP avec les fichiers journaux et les informations système " +"pour le dépannage." + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "Inclure le projet actuel" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "Ajouter le fichier du projet actuel à l'archive de débogage" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "_Enregistrer" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "Échec de la création de l’archive de débogage." + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "Erreur lors de l’enregistrement du fichier : {msg}" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "Une erreur inattendue s’est produite : {error}" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "Modifications non enregistrées" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "" +"Le projet actuel contient des modifications non enregistrées. Voulez-vous " +"les enregistrer ?" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "_Ne pas enregistrer" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "Nouveau projet créé" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "Sans titre" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "Ajouter une ressource" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "Ajouter un croquis" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "Créer une nouvelle pièce" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "Couper le(s) élément(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "Couper l'élément" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "Coller le(s) élément(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "Coller l'élément" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "Dupliquer le(s) élément(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "Dupliquer l'élément" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "Mapper vers existant" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "Nouveaux calques" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "Aplatir" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "Mode d'importation des calques" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "Comment les calques importés sont mappés aux calques du document" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "Calques SVG" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "Couleurs" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "Source des calques" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "Grouper la géométrie importée par calque SVG ou par couleur" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "Importer une image" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" +"Le fichier n'a produit aucune sortie en mode vecteur direct. Les fichiers " +"contenant du texte ou d'autres éléments qui ne sont pas des chemins doivent " +"être convertis en chemins avant l'importation (par ex., dans Inkscape : " +"Chemin > Objet vers Chemin)." + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "Passer en mode vectorisation" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "Réimporter" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "Importer" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "Mode d'importation" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "Utiliser les vecteurs originaux" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "Importer les données vectorielles directement" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "DPI" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" +"Pixels par pouce pour les dimensions SVG sans unité. Inkscape ≥0.92 utilise " +"96, Inkscape plus ancien utilise 90, Illustrator utilise 72" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "Calques" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "Paramètres de vectorisation" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "Importer l'image entière" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "Importer l'image entière sans vectorisation" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "Seuil automatique" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "Déterminer automatiquement le seuil de vectorisation" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "Seuil" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "Vectoriser les objets plus sombres que cette valeur" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "Inverser" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "Vectoriser les objets clairs sur fond sombre" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "Sélectionner les calques" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "Le calque est vide" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "Calque avec {n} vecteurs" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "Génération de l'aperçu..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "Applicabilité" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" +"Définissez quand cette recette doit être suggérée. Laissez les champs vides " +"pour correspondre à n'importe quelle valeur." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "Indifférent" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "Types d'étapes" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" +"Les types d'étapes auxquels cette recette s'applique. Laissez vide pour " +"correspondre à n'importe quel type d'étape." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "Sélectionner..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "Effacer la sélection des types d'étapes" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "Matériau" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "Effacer la sélection de matériau" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "Épaisseur min." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "Épaisseur minimale du brut pour que cette recette s'applique." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "Épaisseur max." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "Épaisseur maximale du brut pour que cette recette s'applique." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "…" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "Non trouvé" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "Recette" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" +"Un préréglage de paramètres nommé qui peut être appliqué automatiquement " +"plus tard." + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" +"Les paramètres qui seront appliqués par cette recette. Lorsque plusieurs " +"types d'étapes sont sélectionnés, seuls les paramètres communs à tous sont " +"affichés." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "Post-traitement" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" +"Paramètres de transformateur appliqués par cette recette. Lorsque plusieurs " +"types d'étapes sont sélectionnés, seuls les transformateurs communs à tous " +"sont affichés." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "Aucune option de post-traitement disponible pour cette étape." + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "Modifier la recette" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "Ajouter une nouvelle recette" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "Machine inconnue" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "Matériau inconnu" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "Aucune recette trouvée." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" +"La recette sera supprimée définitivement. Cette action est irréversible." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "Sélectionner une recette" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "Choisissez une recette à appliquer à l'étape actuelle." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "Afficher uniquement les recettes compatibles" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "Nom de l'étape et paramètres de la recette." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "Refroidissement" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "Liquide de refroidissement utilisé pendant cette opération." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "Modifier {key}" + +#: 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." + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "Vitesse des déplacements de positionnement rapide" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "Arrêt" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "Inondation" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "Brouillard" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "Liquide de refroidissement appliqué sur la pièce pendant la coupe" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "" +"Cette méthode de refroidissement n'est pas prise en charge par la machine " +"actuelle" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "Vitesse de l'opération de coupe" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "Paramètres de {name}" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "Paramètres de l'étape" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "Choisir..." + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "Paramètres manuels" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "Appliquer la recette « {name} »" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "Appliquer le transformateur de la recette" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "Nouvelle recette {label}" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "Définir la recette appliquée" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "Mettre à jour la recette « {name} » ?" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" +"Ceci écrasera de manière permanente la recette enregistrée avec les " +"paramètres actuels de l'étape. Cette action est irréversible." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "1 matériau" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "{count} matériaux" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "{count} (Lecture seule)" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "Ajouter une nouvelle bibliothèque" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "Aucune bibliothèque trouvée." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Le dossier de la bibliothèque et tous ses matériaux seront définitivement " +"supprimés. Cette action est irréversible." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "Modifier la bibliothèque" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "Saisir un nouveau nom pour la bibliothèque :" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "Nom de la bibliothèque" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "Échec du renommage de la bibliothèque." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "Saisir un nom pour le nouveau dossier de bibliothèque :" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +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/file_dialogs.py +msgid "Open File" +msgstr "Ouvrir un fichier" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "Tous les formats supportés" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "Enregistrer le fichier G-code" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "Fichiers G-code" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "Exporter l'objet" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "Exporter le document" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "SVG (Graphiques vectoriels évolutifs)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "DXF (Format d'échange CAO)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "Ouvrir le projet {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "Projet {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "Enregistrer le projet {app_name}" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "Modifier le matériau" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "Mettre à jour les détails du matériau :" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "Ajouter un nouveau matériau" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "Saisir les détails du nouveau matériau :" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "Catégorie" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "Personnalisé" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "Ajouter un nouveau calque" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "Propriétés du brut" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "Épaisseur" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "Épaisseur du matériau" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "Ressources" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "Visualiseur de G-code" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "Console" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "Contrôles" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "Décalages actuels" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "Modifier les décalages manuellement" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "Position actuelle" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "" +"Déplacer vers le coin inférieur gauche de la sélection ou de la zone de " +"travail" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "Déplacer vers le centre de la sélection ou de la zone de travail" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "" +"Déplacer vers le coin supérieur droit de la sélection ou de la zone de " +"travail" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "Déplacer à l'origine du WCS actif" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "Mettre les Axes à Zéro" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "Définir la position X actuelle comme 0 pour le WCS actif" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "Définir la position Y actuelle comme 0 pour le WCS actif" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "Définir la position Z actuelle comme 0 pour le WCS actif" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "Définir l'origine de travail à la position actuelle" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "Cliquer sur le canevas pour définir le zéro de travail" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "Cliquer sur le canevas pour définir le zéro de travail" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "Vitesse de déplacement manuel" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "Distance de déplacement manuel" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "Distance en unités machine" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "" +"Remplacé par le calque actuel. Modifiez-le dans les paramètres du calque." + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "Hors ligne - Position inconnue" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" +"Les décalages ne peuvent pas être définis en mode de coordonnées machine " +"({wcs})" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "La machine doit être connectée pour définir le zéro ici" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "Définir la position actuelle comme 0" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "Sélectionner les types d'étapes" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "Choisissez les types d'étapes auxquels cette recette s'applique." + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "Rechercher..." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "Fonctionnalités manquantes" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "Ce document utilise une fonctionnalité qui n'est pas disponible : {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "" +"Ce document utilise des fonctionnalités qui ne sont pas disponibles : {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "Le document peut toujours être modifié et enregistré." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "_OK" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "Sélectionner le matériau" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "Choisissez un matériau parmi les bibliothèques disponibles." + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "Aucune opération" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "Ajouter une étape" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "Réorganiser les étapes" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "Ajouter l’étape « {name} »" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "Supprimer l'étape « {name} »" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "Paramètres du calque" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "Supprimer ce calque" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "Afficher/Masquer la visibilité du calque" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "Relatif à l'origine {wcs}" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "Le zéro est à gauche" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "Réinitialiser la position X à 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "Réinitialiser la position Y à 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "Ratio fixe" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "Réinitialiser à la largeur d'origine" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "Réinitialiser à la hauteur d'origine" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "Réinitialiser au rapport d'aspect d'origine" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "Angle" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "Le sens horaire est positif" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "Cisaillement" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "Angle de cisaillement horizontal" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "Réinitialiser l’angle à 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "Réinitialiser le cisaillement à 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "Naturel : {val}" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "Fichier source" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "Afficher les métadonnées de l'image" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "Afficher dans l'explorateur de fichiers" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "Commandes vectorielles" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "{count} commandes" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "{name} (non trouvé)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "(Aucun fichier source)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "Languettes" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "Supprimer toutes les languettes" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "Largeur de la languette" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "Longueur le long du chemin" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "Réinitialiser la largeur de languette par défaut (1.0)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "{num_tabs} languettes" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "Valeurs mixtes" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "Nombre de languettes" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "Ajuster les languettes équidistantes" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "Activer {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "Basculer {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "Laisser inchangé" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "Désactivé" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "Cette fonctionnalité n'est pas disponible." + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" +"Le composant requis '{}' est introuvable. Le document peut toujours être " +"enregistré." + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "Afficher/Masquer la visibilité de l’étape" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "Métadonnées de l'image" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "Copier les métadonnées" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "Aucune métadonnée disponible." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "Informations de base" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "Propriétés de base de l'image comme les dimensions et le format." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "Métadonnées" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "Toutes les métadonnées extraites de l'image." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "Métadonnées copiées dans le presse-papiers" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "Propriétés de l’élément" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "1 élément sélectionné" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "{count} éléments sélectionnés" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "Éléments multiples" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "Propriétés de la pièce" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "Propriétés du groupe" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "{name} - Paramètres" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "Fermer" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" +"Paramètres de base du calque tels que l'apparence et le système de " +"coordonnées." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "Couleur utilisée pour les opérations dans ce calque" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "Système de coordonnées" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" +"L'origine du système de coordonnées de travail à utiliser pour ce calque. " +"Par défaut, utiliser le WCS sélectionné dans la fenêtre principale" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "Accessoire rotatif" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" +"Configurez l'accessoire rotatif pour les objets cylindriques. Lorsqu'il est " +"activé, les mouvements de l'axe Y sont convertis en mouvements rotationnels " +"en degrés." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "Activer le mode rotatif" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "Convertir l'axe Y en axe rotatif" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "Sélectionnez le module rotatif pour ce calque" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "Diamètre de l'objet" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "Diamètre de l'objet cylindrique" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "Aucun matériau dans la bibliothèque sélectionnée." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "Impossible de supprimer le matériau" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" +"Ce matériau est actuellement utilisé par une ou plusieurs recettes. Veuillez " +"supprimer les recettes qui l'utilisent avant de le supprimer." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" +"Le matériau sera définitivement supprimé de la bibliothèque. Cette action " +"est irréversible." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "Échec de la mise à jour du matériau." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "Échec de l'ajout du matériau à la bibliothèque." + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "Importation par lot de {file_count} images" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" +"Importer {file_count} images :\n" +"{file_names}\n" +"\n" +"Toutes les images seront tracées en utilisant les paramètres de " +"vectorisation par défaut et positionnées à l'emplacement de dépôt." + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "Tout importer" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "Ajouter une nouvelle étape..." + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "{count} étape" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "{count} étapes" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "Lecture de la simulation" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "Reculer" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "Avancer" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "Vitesse de lecture" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "Pause simulation" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "Non trouvé" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "Boîte à outils d'interface utilisateur" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "Graphismes et imagerie" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "Géométrie" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "Formats de fichiers et communication" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "Site web" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "Signaler un problème" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "Version" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "Copier la version" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "Développeur principal" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "Licence" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "Informations système" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "Versions des bibliothèques et des composants" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "Copier les informations système" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "Soutiens" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "Personnes qui ont fait un don au projet" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" +"Un grand merci à tous ceux qui ont fait un don pour soutenir Rayforge! Vous " +"faites couler le café et les jetons d'IA !" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "À propos de {app_name}" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "mm/min" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "mm/s" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "po/min" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "po/s" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "mm" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "cm" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "m" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "po" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "pi" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "mm/s²" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "cm/s²" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "m/s²" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "po/s²" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "pi/s²" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "{size} o" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "{size:.1f} Ko" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "{size:.1f} Mo" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "{:.0f}s" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "{}m" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "{}h" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "{line_count:,} lignes · {size}" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "— Tronqué (affichage des 20 000 premières lignes sur {line_count:,}) —" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "Recherche de mises à jour des extensions..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "Une mise à jour est disponible pour {name}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "Des mises à jour sont disponibles pour {name1} et {name2}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "" +"Des mises à jour sont disponibles pour {name1}, {name2} et {num} autres." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "Tout installer" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "Mises à jour d'extensions trouvées." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "Les extensions sont à jour." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "Installation des mises à jour des extensions..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "Extension mise à jour avec succès." +msgstr[1] "{num} extensions mises à jour avec succès." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "{num_s} extensions mises à jour, {num_f} échouées." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "Échec de la mise à jour de l'extension." +msgstr[1] "Échec de la mise à jour de {num} extensions." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "Terminé avec {num_failed} erreurs." + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "Toutes les mises à jour des extensions ont été installées !" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" +"Impossible d'ouvrir '{file}'. L'extension requise est peut-être désactivée." + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "Un générateur de G-code pour découpeuses laser." + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "Chemins vers un ou plusieurs fichiers d’entrée SVG ou image." + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" +"Forcer l'importation en tant que vecteurs directs. C'est la valeur par " +"défaut pour les fichiers pris en charge." + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" +"Forcer l'importation en traçant la représentation bitmap du fichier. " +"Abandonne si non pris en charge." + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "Définir le niveau de journalisation (par défaut : INFO)" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" +"Quitter après l'importation des documents et le chargement de l'éditeur. " +"Utile pour les tests." + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" +"Chemin vers un script Python à exécuter après le chargement complet de la " +"fenêtre principale. Utile pour l'automatisation et les tests." + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" +"Chemin vers un répertoire de configuration personnalisé. Utile pour les " +"tests avec des configurations isolées." + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "Agréger" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "{status} — {activity}" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "Agrégation du travail" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "Génération du code machine" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "Application de la transformation machine" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "Traitement" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "Traitement de '{workpiece}' — {step}" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "Assemblage" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "Assemblage de '{step}'" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "face par défaut" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "La face '{face}' n'a pas pu être usinée : {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "" +"La région {region} de la face '{face}' n'a pas pu être usinée : {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "Avertissement d'usinage : {detail}" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "Puissance variable" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "Puissance constante" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "Tramage" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "Profondeurs multiples" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "Variable" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "Constante" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "Passages multiples" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "(+{n} de plus)" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "Manquant : {}" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "Ce transformateur n'est pas disponible." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "Le nom du système de coordonnées actuellement actif (p. ex. 'G54')." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "Le nom du profil de machine actuel." + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "La largeur (axe X) de la zone de travail de la machine." + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "La hauteur (axe Y) de la zone de travail de la machine." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "Le nom du fichier de document actuel (si enregistré)." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "La coordonnée X minimale de tout le travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "La coordonnée Y minimale de tout le travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "La coordonnée X maximale de tout le travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "La coordonnée Y maximale de tout le travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "Le décalage X du système de coordonnées de travail actif." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "Le décalage Y du système de coordonnées de travail actif." + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "Le décalage Z du système de coordonnées de travail actif." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "Le nom de la couche actuelle en cours de traitement." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "Le nom de la pièce de travail actuelle en cours de traitement." + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "La position X de la pièce de travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "La position Y de la pièce de travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "La largeur de la pièce de travail." + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "La hauteur de la pièce de travail." + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "Transformer le(s) élément(s)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "Déplacer le(s) élément(s)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "Modifier l’angle de l’élément" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "Modifier le cisaillement de l’élément" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "Redimensionner le(s) élément(s)" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "Mettre à jour la ressource" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "Renommer la ressource" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "Supprimer la ressource « {name} »" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "Supprimer l'élément dépendant" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "Supprimer la définition de la ressource" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "Activer/Désactiver la visibilité de la ressource" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "Importer {filename}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "Importation de {filename}..." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" +"Échec de l'importation de {filename}. Le fichier d'image peut être corrompu " +"ou dans un format non pris en charge." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "" +"Échec de l'importation : aucun élément n'a été créé à partir de {filename}" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "Échec de l’importation." + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "Importation terminée !" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" +"⚠️ L’élément importé était plus grand que la zone de travail et a été " +"redimensionné pour s'adapter." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "Exportation réussie : {name}" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "Objet exporté avec succès." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "Échec de l'exportation de l'objet : {error}" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "Impossible d'exporter : le document n'a pas de géométrie." + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "Document exporté avec succès." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "Échec de l'exportation du document : {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "Projet enregistré : {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "Échec de l'enregistrement : {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "Fichier non trouvé : {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" +"Ce projet utilise des méthodes de refroidissement non prises en charge par " +"la machine actuelle : {methods}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "{count} ressource(s) nécessite(nt) des extension(s) désactivée(s)" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "Format de fichier de projet non valide" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "Échec du chargement : {error}" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "Impossible de placer les éléments suivants : {item_names}" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "Renommer l'étape" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "Supprimer le matériau" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "Brut {count}" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "Afficher/Masquer la visibilité du brut" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "Renommer la ressource de brut" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "Modifier l’épaisseur du brut" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "Modifier le matériau du brut" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "Ajouter une languette" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "Effacer les languettes" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "Activer/Désactiver les languettes" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "Modifier la largeur de la languette" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "Déplacer vers un autre calque" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "Calque" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "Renommer le calque" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "Définir le calque actif" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "Supprimer le calque « {name} »" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "Réorganiser les pièces" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "Réorganiser les éléments" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "Créer un tableau" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "Créer une copie du tableau" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "{count} ressource(s) nécessite(nt) l'extension désactivée '{addon}'" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "Groupement des éléments..." + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "Dégroupement des éléments..." + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "Scinder le(s) élément(s)" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "Supprimer l'élément original" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "Ajouter les fragments scindés" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "Coller le(s) élément(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "Coller l’élément" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "Dupliquer le(s) élément(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "Dupliquer l’élément" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "Ajouter un élément" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "Supprimer l’élément" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "Supprimer toutes les pièces" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "Vider les éléments du calque" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "Supprimer le(s) contour(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "Supprimer le(s) segment(s)" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "Positionner au point" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "Disposition automatique" + +#: 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 +msgid "Failed to process image data." +msgstr "Échec du traitement des données d'image." + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "Échec du chargement de l'image : {}" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "Impossible de calculer les métadonnées SVG." + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "Échec de la préparation des données SVG rognées." + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "Le SVG ne contient ni géométrie ni dimensions." + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "Impossible de déterminer des dimensions SVG valides." + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "Impossible de déterminer des dimensions valides pour le traçage." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "Échec de la rasterisation du SVG pour le traçage." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "Échec de la normalisation des données d'image." + +#: 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." + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "Impossible de lire le PDF : {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "Erreur inattendue lors de l'analyse du PDF : {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "Échec du traitement des données d'image PDF." + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "Échec de la lecture des dimensions des pages du PDF : {}" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "La page PDF a des dimensions nulles." + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "Échec de la rasterisation du PDF." + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "Le PDF ne contient aucune géométrie vectorielle." + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "Échec de l'analyse du PDF : {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "Le fichier LightBurn est un XML non valide : {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "Le fichier LightBurn est corrompu ou non valide : {}" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "Impossible d'analyser l'en-tête BMP dans {}" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "Échec de l'analyse du fichier BMP : {}" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "Données BMP non valides ou non prises en charge." + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "Échec du traitement de l'image : {}" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "Le fichier ne contient aucune commande de vecteur." + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "Le fichier Ruida est non valide : {}" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "Erreur inattendue lors de l'analyse du fichier Ruida : {}" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "Échec de l'analyse des commandes Ruida : {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "La structure du fichier DXF est non valide : {}" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "Erreur inattendue lors de l'analyse du DXF : {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "Le fichier DXF est corrompu ou non valide : {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "Échec du calcul des paramètres : {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "Échec de l'exécution du générateur : {}" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "Échec de l'analyse du fichier JPEG : {}" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "Floyd Steinberg" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..8ced78e87 --- /dev/null +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -0,0 +1,8864 @@ +# Portuguese translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# Samuel Abels , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2025-07-24 22:09+0200\n" +"Last-Translator: Samuel Abels \n" +"Language-Team: Portuguese \n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "Verificando atualizações do Rayforge..." + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "A verificação de atualizações falhou." + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "Rayforge {version} está disponível." + +#: rayforge/updater.py +msgid "Download" +msgstr "Baixar" + +#: rayforge/updater.py +msgid "New version available." +msgstr "Nova versão disponível." + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "Rayforge está atualizado." + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "{name} Fluxo de trabalho" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "Plano" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "Rotativo · {name}" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "Rotativo" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "Camada {}" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "{name} (cópia)" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "Requisição inválida" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "Falha na autenticação - por favor verifique sua chave API" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "Acesso negado - por favor verifique as permissões da sua chave API" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "Endpoint da API não encontrado - por favor verifique a URL base" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "Limite de requisições atingido - por favor aguarde e tente novamente" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "Erro do servidor - por favor tente novamente mais tarde" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "Serviço indisponível - por favor tente novamente mais tarde" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "O servidor retornou o erro {code}" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "Falha na conexão - por favor verifique sua rede" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "Modelo '{model}' não encontrado. Disponíveis: {available}" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "Velocidade de Corte" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "Velocidade de Deslocamento" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "Configurações" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "Escolha" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "Texto (Linha Única)" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "A taxa de transmissão não pode estar vazia." + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "'{rate}' não é uma taxa de transmissão padrão." + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "Taxa de Transmissão" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "Velocidade da conexão em bits por segundo" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "O nome do host ou endereço IP não pode estar vazio." + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "Formato de nome de host ou endereço IP inválido." + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "Nome do Host / IP" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "Inteiro" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "O valor deve ser no mínimo {min_val}." + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "O valor deve ser no máximo {max_val}." + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "A porta não pode estar vazia." + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "A porta deve ser um número." + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "Ponto Flutuante" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "Deslizador (0-100%)" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "Texto (Múltiplas Linhas)" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "Escolha (Rotulada)" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "Booleano (Interruptor)" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "A URL não pode estar vazia." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "A URL deve incluir um esquema (p. ex., 'http://')." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "A URL deve incluir um nome de host." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "O esquema da URL deve ser um de: {schemes}." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "URL inválida: {error}" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "A porta serial não pode estar vazia." + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "Porta Serial" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "Linha central" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "Interno" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "Externo" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "Interno-Externo" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "Externo-Interno" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "Laser" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "Fresamento" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "PWM" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "Corte e gravação com laser" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "Fresagem e fresagem por recorte com fuso" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "Controlo de potência do laser por modulação de largura de pulso" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "Acessório de eixo rotativo para objetos cilíndricos" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "Núcleo" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "Material Bruto" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "Fonte" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "Erro de Sintaxe: {message}" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "Variável ou função desconhecida: '{name}'" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" +"Não é possível usar o operador '{op}' entre os tipos '{left}' e '{right}'" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "Nenhum driver" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "Sem conexão" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "Coordenadas de máquina" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "Nenhuma configuração" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "O recurso '{resource}' está atualmente em uso por '{owner}'." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" +"Este driver não foi testado. Pode ou não funcionar. Use por sua conta e " +"risco." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Este driver é experimental e pode ter problemas não resolvidos. Use-o com " +"cautela." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "Unknown" +msgstr "Desconhecido" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "Ocioso" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "Em execução" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "Em espera" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "Jog" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "Alarme" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "Porta" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "Verificação" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "Referenciar" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "Repouso" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "Ferramenta" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "Fila" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "Bloqueado" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "Desbloquear" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "Ciclo" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "Teste" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "Frequência" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "Frequência PWM em Hz" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "Largura de pulso" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "Largura de pulso em microssegundos" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" +"Erro durante a configuração. Pode precisar de editar as configurações do " +"dispositivo." + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "Smoothie" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "Smoothieware através de uma conexão Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "Coordenadas de máquina (G53)" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "Nome de host ou endereço IP inválido: '{host}'" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "Nome do Host" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "O endereço IP ou nome do host do dispositivo" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "Porta" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "O número da porta Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "O Nome do host deve ser configurado." + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "Ruida (UDP)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "Conectar a um controlador de laser Ruida via UDP" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "O endereço IP ou nome do host do controlador Ruida" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "Porta principal" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "A porta UDP para comandos principais (padrão: 50200)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "Porta de jog" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "A porta UDP para comandos de jog (padrão: 50207)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "Sem resposta do controlador" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "Enviar G-code para um servidor OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "Endereço IP ou nome do host do servidor OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "Porta HTTP do servidor OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "Chave API" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" +"Insira uma chave API manualmente ou clique em 'Solicitar acesso' para obter " +"uma através do plugin Application Keys do OctoPrint." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" +"A chave API deve estar configurada. Use o botão 'Solicitar acesso' ou insira " +"uma chave API manualmente." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "Autenticação falhou. A chave API pode ser inválida ou expirada." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" +"Não foi possível conectar ao OctoPrint em '{host}:{port}'. Verifique o " +"endereço e a conexão de rede." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "Autenticação falhou" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" +"A chave API é inválida ou expirou. Por favor, reautentique nas configurações " +"do dispositivo." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "OctoPrint não retornou dados de login." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "Frame WebSocket inesperado." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "O servidor fechou a conexão WebSocket." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "Impressão falhou" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" +"OctoPrint relatou que o trabalho de impressão falhou. Verifique o OctoPrint " +"para detalhes." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "Driver não configurado com um host." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "Autenticação falhou durante o envio." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" +"A impressora está ocupada ou não operacional. Não é possível iniciar um novo " +"trabalho." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" +"OctoPrint aceitou o arquivo mas não conseguiu iniciar a impressão. A " +"impressora pode não estar operacional ou já estar ocupada." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "Não foi possível enviar o arquivo para o OctoPrint em '{host}:{port}'." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" +"OctoPrint não suporta a escrita de configurações de firmware do dispositivo " +"através de sua API." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" +"Comando de sonda enviado. OctoPrint não relata resultados de sonda via sua " +"API." + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "Marlin (Serial)" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "Firmware Marlin via conexão serial" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "Porta serial para o dispositivo" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "A porta deve ser configurada." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "A taxa de transmissão deve ser configurada." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "Porta não configurada" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "Sem resposta do dispositivo" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "Autoconfigurado via assistente de detecção" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "GRBL (Telnet)" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "Controlador compatível com GRBL via conexão TCP/telnet bruta" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "Porta TCP para o serviço raw/telnet" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "Consultar status do dispositivo durante os trabalhos" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" +"Consultar periodicamente o dispositivo quanto à posição e ao status durante " +"a execução de um trabalho. Aviso: Alguns dispositivos têm dificuldade em " +"manter uma conexão estável se isso for usado!" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "Detecção de impasse" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" +"Detecta e recupera de impasses de comunicação serial durante os trabalhos. " +"Se desativado, o driver simplesmente aguardará a máquina responder. Desative " +"se você experimentar falsos erros ALARM:3." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "Letra de comando ausente" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" +"Os comandos G-code necessitam uma letra seguida de um valor. A letra de " +"comando não foi encontrada." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "Formato de número inválido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" +"O valor está ausente ou não está no formato numérico correto. Verifique a " +"sintaxe do seu G-code." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "Comando desconhecido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" +"Este comando de configuração do Grbl não é reconhecido ou suportado. " +"Verifique a sintaxe do comando." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "Valor negativo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" +"Um número positivo é necessário aqui, mas um valor negativo foi recebido." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "Referenciação Desativada" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" +"O retorno à origem não está ativado nas configurações. Ative o retorno à " +"origem ($22=1) para usar este recurso." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "Tempo de pulso muito curto" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" +"O tempo de pulso de passo mínimo deve ser maior que 3 microsegundos. " +"Verifique a configuração $0." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "Erro de memória" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" +"As configurações foram redefinidas para os padrões devido a uma falha de " +"leitura de memória. Reconfigure suas configurações se necessário." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "Máquina ocupada" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" +"Este comando só pode ser usado quando a máquina está ociosa. Aguarde o " +"trabalho atual terminar." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "Comandos bloqueados" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" +"Não é possível enviar comandos enquanto em modo de alarme ou movimento " +"manual. Limpe o estado de alarme primeiro." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "Retorno à origem necessário" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" +"Os limites suaves não podem ser ativados sem que o retorno à origem também " +"esteja ativado. Ative o retorno à origem primeiro ($22=1)." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "Linha muito longa" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" +"A linha de comando tem muitos caracteres e foi ignorada. Verifique a " +"formatação do seu arquivo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "Configuração muito alta" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" +"Esta configuração excede a taxa de passo máxima suportada. Use um valor mais " +"baixo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "Porta aberta" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" +"A porta de segurança foi detectada como aberta. Feche a porta e retome a " +"operação." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" +"As informações de build ou a linha de inicialização excedem o limite de " +"armazenamento. Encurte a linha." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "Alvo fora de alcance" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" +"O alvo de movimento manual está além dos limites de deslocamento da máquina. " +"Mova para uma posição dentro do alcance." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "Comando de movimento manual inválido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" +"O comando de movimento manual está faltando '=' ou contém G-code proibido. " +"Verifique a sintaxe de movimento manual." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "Erro de modo laser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" +"O modo laser requer saída PWM para funcionar. Verifique a configuração do " +"seu hardware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "Eixo-motor não está em funcionamento" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" +"Um comando de movimento foi emitido mas o eixo-motor não está em " +"funcionamento. Inicie o eixo-motor antes do movimento." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "Velocidade do eixo-motor incorreta" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" +"A velocidade atual do eixo-motor não corresponde à velocidade exigida pelo " +"comando. Aguarde até que o eixo-motor atinja a velocidade desejada." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "Comando Não Suportado" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" +"Este comando G-code não é suportado pela máquina. Verifique as configurações " +"do seu pós-processador." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "Comandos em conflito" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" +"Múltiplos comandos do mesmo grupo encontrados em uma linha. Remova o comando " +"duplicado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "Taxa de avanço ausente" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" +"Defina uma taxa de avanço antes de usar comandos de movimento. Adicione um " +"comando F para especificar a velocidade." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "Inteiro necessário" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" +"Este comando requer um valor de número inteiro. Remova todos os pontos " +"decimais." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "Conflito de eixo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "Múltiplos comandos tentando usar o mesmo eixo. Simplifique o comando." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "Palavra duplicada" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "A mesma palavra G-code aparece mais de uma vez. Remova o duplicado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "Eixo ausente" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" +"Este comando requer coordenadas de eixo XYZ. Adicione os valores de eixo " +"ausentes." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "Número de linha fora de alcance" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" +"O número de linha deve estar entre 1 e 9 999 999. Use um número de linha " +"válido." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "Valor ausente" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "Este comando requer um valor P ou L. Adicione o parâmetro ausente." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "Coordenada não suportada" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" +"Apenas os sistemas de coordenadas G54-G59 são suportados. Use um deles em " +"vez." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "Modo de movimento errado" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" +"O comando G53 requer o modo de movimento G0 ou G1. Defina o modo de " +"movimento correto primeiro." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "Palavras de eixo não utilizadas" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" +"Palavras de eixo presentes mas o cancelamento G80 está ativo. Remova as " +"palavras de eixo não utilizadas." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "Dados de arco ausentes" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" +"O comando de arco G2/G3 precisa de coordenadas XYZ. Adicione os valores de " +"eixo para o plano selecionado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "Alvo inválido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" +"Não é possível criar este arco ou sondar a posição atual. Verifique as " +"coordenadas do alvo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "Erro de geometria de arco" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" +"O cálculo do arco falhou. Tente dividir o arco em peças menores ou use o " +"deslocamento IJK em vez disso." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "Deslocamento de arco ausente" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" +"O comando de arco G2/G3 precisa de valores de deslocamento IJK. Adicione o " +"deslocamento ausente para o plano selecionado." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "Palavras não utilizadas" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" +"Algumas palavras G-code nesta linha não são usadas por nenhum comando. " +"Remova as palavras não utilizadas." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "Eixo errado para deslocamento" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" +"O deslocamento de comprimento da ferramenta só funciona no eixo configurado " +"(geralmente eixo Z). Verifique suas configurações." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "Número de ferramenta muito alto" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" +"O número da ferramenta excede o valor máximo suportado. Use um número de " +"ferramenta válido." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "Limite físico" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" +"Um interruptor de limite físico foi acionado. A máquina parou e precisa ser " +"reiniciada. Verifique obstruções e confirme os interruptores de limite." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "Limite lógico" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" +"A máquina se moveria além dos limites de percurso configurados. Verifique se " +"sua área de trabalho e os deslocamentos de coordenadas estão corretos." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "Abortar ciclo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" +"O trabalho em execução foi cancelado durante o movimento. Reinicie a máquina " +"para continuar." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "Falha da sonda — Inicial" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" +"A sonda não fez contato antes de atingir a distância máxima de percurso. " +"Verifique a fiação e o posicionamento da sonda." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "Falha da sonda — Final" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" +"A sonda não conseguiu retrair para a posição de destino após o contato. " +"Verifique a configuração da sonda." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "Falha de referenciação — Reinicialização" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" +"A referenciação não pôde ser concluída porque a máquina está em estado de " +"alarme. Limpe o alarme e tente novamente." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "Falha de referenciação — Aproximação" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" +"O ciclo de referenciação não encontrou o interruptor na distância de " +"percurso configurada. Verifique a fiação do interruptor e as configurações " +"de recuo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "Falha de referenciação — Recuo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" +"O ciclo de referenciação não conseguiu recuar do interruptor após o contato. " +"Aumente a distância de recuo ou verifique o interruptor." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "Referenciação sem limites" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" +"A referenciação foi comandada, mas os interruptores de limite não estão " +"configurados. Ative os interruptores de limite primeiro." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "Falha na referenciação — Eixo duplo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" +"A referenciação falhou numa configuração de eixo duplo. Um ou ambos os eixos " +"não atingiram os seus interruptores de fim de curso. Verifique a cablagem e " +"a configuração dos interruptores de fim de curso." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "Alarme desconhecido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "Código de alarme inválido reportado pela máquina." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" +"A máquina reportou um código de alarme não reconhecido. Consulte a " +"documentação da sua máquina e do firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "Erro Desconhecido" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "Código de erro inválido reportado pela máquina." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" +"A máquina reportou um código de erro não reconhecido. Verifique a " +"documentação da máquina e do firmware." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "Configuração do Motor de Passo" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" +"Configurações relacionadas ao tempo do motor de passo e à polaridade do " +"sinal." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "Controle e Relatórios" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "" +"Configurações para o controle de movimento e relatório de status do GRBL." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "Limites e Referenciação" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "" +"Configurações para limites de software/hardware e o ciclo de referenciação." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "Fuso e Laser" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "Configurações para controlar o fuso ou o módulo de laser." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "Calibração dos Eixos" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "Define os passos por milímetro para cada eixo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "Cinemática dos Eixos" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "Define a velocidade máxima e a aceleração para cada eixo." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "Percurso dos Eixos" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "Define a distância máxima de percurso para cada eixo." + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "GRBL (Serial)" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "Conexão serial compatível com GRBL" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "Substituir tamanho do buffer RX" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" +"Forçar um tamanho específico do buffer RX em bytes. Defina como 0 para " +"detectar automaticamente do dispositivo." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "Configurações Desconhecidas" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" +"Configurações reportadas pelo dispositivo que não estão na lista padrão." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "Configuração desconhecida do dispositivo" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" +"O dispositivo está configurado para reportar em polegadas ($13=1). Todos os " +"valores exibidos estão em unidades de máquina." + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" +"O modo laser não está ativado ($32=0). Ative-o para melhores resultados com " +"cortadores a laser." + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "GRBL (Serial Simples)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "GRBL serial com protocolo ping-pong simples (sem contagem de buffer)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "A taxa de transmissão deve ser configurada." + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "GRBL (Rede)" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "Conectar a um dispositivo compatível com GRBL pela rede" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "Porta HTTP" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "A porta HTTP para o dispositivo" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "Porta WebSocket" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "A porta WebSocket para o dispositivo" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "Variante de protocolo" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "Variante GRBL Standard, ESP3D ou Longer" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "Standard" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" +"O host não está configurado. Por favor, defina um endereço IP ou nome do " +"host válido." + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" +"Não foi possível conectar ao host '{host}'. Verifique o endereço IP e a " +"conexão de rede." + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "Zona proibida" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "Fora da área de trabalho" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "Limites da máquina" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "{name} (dialeto do dispositivo)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "• Calibração da câmera: matriz + distorção encontrada" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "(nenhum campo mapeado)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "Nome do dispositivo" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "Área de trabalho" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "Driver" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "Taxa de transmissão" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "Referenciar ao iniciar" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "Velocidade máxima de deslocamento" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "Origem" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "Espelhar X" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "Espelhar Y" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "Calibração da câmera" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "matriz + distorção importada" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "Cabeça de Fuso" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "{label} (para {machine_name})" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "Cabeça de Laser" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "Máquina Padrão" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "Módulo rotativo" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "Cabeça" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "Nenhum driver selecionado para esta máquina." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "Driver '{driver}' não encontrado." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "Ocorreu um erro inesperado durante a validação: {error}" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "GRBL Raster" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" +"Otimizado para gravação raster GRBL. Mantém o modo de potência dinâmica M4 " +"ativamente e usa velocidade de avanço modal para minimizar a sobrecarga de " +"comando durante as linhas de varredura" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "Mach4 (M67 Analógico)" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" +"Mach4 com saída analógica M67 para gravação raster de alta velocidade. Usa " +"M67 E0 Q<0-255> para potência do laser em vez de comandos S em linha, " +"reduzindo a pressão do buffer no controlador." + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "Smoothieware" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "Dialeto G-code para controladores baseados em Smoothieware" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "LinuxCNC" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "G-code para LinuxCNC, com suporte a curvas Bézier cúbicas nativas (G5)" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "GRBL Dinâmico" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" +"GRBL com modo de potência dinâmica M4 (sensível à profundidade). O parâmetro " +"S está incluído nos comandos de movimento" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "Informações Gerais" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "Rótulo" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "Nome de exibição para o usuário" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "Descrição" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "Descrição Curta" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "Omitir coordenadas inalteradas" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" +"Quando ativado, as letras dos eixos que não foram alteradas são omitidas dos " +"comandos G0/G1" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "Modo laser contínuo" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" +"Mantém o modo de potência dinâmica M4 continuamente ativo durante a gravação " +"raster em vez de alternar M4/M5 entre cada segmento" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "Velocidade de avanço modal" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" +"Incluir apenas o parâmetro F de velocidade de avanço nos comandos de " +"movimento quando ele muda do valor anterior" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "Modelos de Comando" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "Ligar Laser" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "Desligar Laser" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "Focar laser" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "Movimento de Deslocamento" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "Movimento Linear" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "Arco (Horário)" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "Arco (Anti-horário)" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "Bézier Cúbico" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "Troca de Ferramenta" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "Definir Velocidade" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "Ligar Ar" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "Desligar Ar" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "Referenciar Tudo" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "Referenciar Eixo" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "Mover Para" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "Limpar Alarme" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "Definir Deslocamento WCS" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "Ciclo de Sondagem" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "Espera" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "Spindle ligado (CW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "Spindle ligado (CCW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "Spindle desligado" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "Refrigeração inundação" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "Refrigeração névoa" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "Refrigeração desligada" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "Scripts" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "Injetar WCS após o preâmbulo" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" +"Injetar o comando WCS ativo (p. ex., G54) após o script de preâmbulo. Quando " +"desativado, você pode usar {machine.active_wcs} no preâmbulo em vez disso." + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "Preâmbulo" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "Script de preâmbulo" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "Pós-escrito" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "Script de pós-escrito" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "Marlin" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "G-code para controladores baseados em Marlin, comum em impressoras 3D" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "Grbl (Compatibilidade)" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" +"Dialeto Grbl com maior compatibilidade para a maioria dos lasers de diodo e " +"CNCs de hobby" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "Início de camada" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "Fim de camada" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "Início da peça" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "Fim da peça" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "Antes de processar uma camada" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "Após processar uma camada" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "Antes de processar uma peça" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "Após processar uma peça" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "Macro Sem Nome" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "{job_name} falhou: {error}" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" +"Falha ao listar as portas seriais devido a um confinamento do Snap! Por " +"favor, garanta que o dispositivo esteja conectado via USB e execute:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" +"Portas seriais encontradas, mas nenhuma está acessível. Por favor, garanta " +"que o seu Snap tenha a interface 'serial-port' conectada executando:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "Conectando" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "Conectado" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "Erro" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "Fechando" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "Desconectado" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "Em repouso" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "Máquinas" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "Máquinas Configuradas" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "Adicione ou remova máquinas." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "Esta máquina tem uma configuração inválida." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "Esta é a máquina ativa." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "Excluir ‘{name}’?" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Este perfil de máquina e todas as suas configurações serão removidos " +"permanentemente. Esta ação não pode ser desfeita." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "Cancelar" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "Excluir" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "Adicionar máquina" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "Licenças" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "Vincule sua conta do Patreon para acesso antecipado a novas extensões." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "Conta do Patreon vinculada" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "Extensões de acesso antecipado desbloqueadas" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "Desvincular" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "Vincular conta do Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "Obter acesso antecipado a extensões premium" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "Vincular" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "Licenças de extensões" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "Gerencie suas chaves de licença compradas." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "Nenhuma licença instalada" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" +"Compre uma extensão premium e insira a chave de licença durante a instalação." + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "{addons} (+{count} mais)" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "ID do produto: {id}" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "Remover" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "Extensões que requerem licença" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "Estas extensões precisam de uma licença válida para serem ativadas" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "Licença requerida" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "Comprar" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "Remover licença?" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" +"Esta chave de licença será removida. Pode ser necessário inseri-la novamente " +"para usar extensões licenciadas." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "Provedor padrão" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "Ativar ou desativar este provedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "Definir como padrão" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "Adicionar provedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "Nenhum provedor configurado" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "Novo provedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "Excluir '{name}'?" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" +"Este provedor de IA será removido permanentemente. Esta ação não pode ser " +"desfeita." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "Nome" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "Tipo de provedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "Compatível com OpenAI" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "URL base" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "Modelo padrão" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "Teste de conexão" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "Verificar se a configuração do provedor está funcionando" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "Editar provedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "Configurações do provedor" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "Testando..." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "IA" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "Provedores de IA" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" +"Configure provedores de IA para uso por extensões. As extensões podem usar " +"esses provedores sem precisar de suas próprias chaves API." + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "Extensões" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "Extensões Instaladas" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "Instalar, atualizar e remover extensões." + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "Receitas" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "Gerencie suas receitas salvas para diferentes materiais e processos." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "Editar regra de cor" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "Atualize os detalhes da regra de cor:" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "Salvar" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "Adicionar regra de cor" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "Mapeie uma cor para um tipo de etapa nas importações SVG." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "Adicionar" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "Cor" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "Cor SVG que aciona esta regra" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "Rótulo (opcional)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "Tipo de Passo" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "Tipo de etapa criado ao importar esta cor" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "Cor {color}" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "Este tipo de etapa não está disponível no momento." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "{step_type} (indisponível)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "Nenhuma regra de cor encontrada." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "Excluir a regra de cor '{color}'?" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" +"A regra de cor será removida permanentemente. Esta ação não pode serdesfeita." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "Regras de cor" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" +"Mapeie cores SVG para tipos de etapa para que sejam aplicadasautomaticamente " +"ao importar." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "Materiais" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "Bibliotecas de Material" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" +"Gerencie suas bibliotecas de material. Selecione uma biblioteca para " +"visualizar seus materiais." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "Materiais na biblioteca selecionada." + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "Categorias" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "Inglês" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "Alemão" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "Espanhol" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "Francês" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "Português" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "Ucraniano" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "Chinês (simplificado)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "Sistema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "Claro" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "Escuro" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "Não abrir nada" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "Abrir último projeto" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "Abrir projeto específico" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "Cor do laser" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "Cor da camada" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "Padrão do sistema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "Geral" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "Aparência" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "" +"Configurações relacionadas à aparência e ao comportamento da aplicação." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "Tema" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "Idioma" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "O idioma da aplicação. As alterações requerem um reinício." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "Cores das operações" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "Escolha se as cores das operações representam o laser ou a camada" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "Unidades" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" +"Defina as unidades de exibição para vários valores em toda a aplicação." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "Comprimento" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "Velocidade" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "Aceleração" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "Comportamento" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "Configurar o comportamento avançado da aplicação." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "Atualizar operações automaticamente" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" +"Recalcular operações automaticamente após cada alteração. Desativar para " +"recálculo manual através do botão da barra de ferramentas" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "Orçamento de cache (MB)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "Memória máxima para cache. Cenas de alta complexidade exigem mais" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "Verificar atualizações" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +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 "Startup behavior" +msgstr "Comportamento de inicialização" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "Caminho do projeto" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "Navegar..." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "Privacidade" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" +"Ajude-nos a melhorar o Rayforge permitindo relatórios de uso anônimos. " +"Nenhum dado pessoal é coletado." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "Relatar Uso Anônimo" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "Ajudar a melhorar o Rayforge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" +"Saiba " +"mais sobre rastreamento de uso e privacidade." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "Reinício necessário" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" +"O idioma terá efeito após reiniciar o Rayforge. Deseja reiniciar agora?" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "_Cancelar" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "_Reiniciar" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "As cópias mantêm suas camadas originais." + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "_Aplicar" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "Grade" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "Grade" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "Linhas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "Colunas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "Deslocamento" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "Espaço" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "Espaçamento" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "O deslocamento é de centro a centro; o espaço é de borda a borda." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "Espaçamento de colunas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "Espaçamento de linhas" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "Matriz de rotação de pontos" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "Rotação de pontos" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "Gira as cópias no local ao redor do centro da seleção." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "Contagem" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "Ângulo total (graus)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "Matriz circular" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "Circular" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "Coloca cópias ao longo de um arco circular ao redor de um centro." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "Centro X" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "Centro Y" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "Raio" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "Girar cópias" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "Mover Aba" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "Mover uma Camada para Cima" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "Mover uma Camada para Baixo" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "Agrupar" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +msgstr "Desagrupar" + +#: 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 +msgid "Add Tab Here" +msgstr "Adicionar Aba de Fixação Aqui" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "Remover Aba de Fixação" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "Novo esboço" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "Novo material" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +msgstr "Importar arquivo…" + +#: 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 "Paste" +msgstr "Colar" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "Adicionar instância {}" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "Arraste e solte arquivos para importar" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "Imagem importada da área de transferência" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "Falha ao importar imagem da área de transferência" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "A visualização 3D não está disponível devido a dependências ausentes." + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "Selecione uma máquina para abrir a visualização 3D." + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "Adicionar Material" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "Layout automático (Simples)" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "{camera_name} - Calibração de lente" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "Configurações de Imagem da Câmera" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "Ajustar parâmetros de qualidade e aparência da imagem." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "Padrão" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "Personalizado..." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "Resolução" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" +"Resolução de captura da câmera. O padrão usa a configuração nativa da câmera." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "Largura personalizada" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "Altura personalizada" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "Preferir formato YUYV" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" +"Usar YUYV não comprimido em vez de MJPEG. Corrige artefatos verdes em " +"algumas câmeras USB, mas pode reduzir a resolução ou a taxa de quadros em " +"USB 2.0." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "Balanço de Branco Automático" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "Ajustar automaticamente o balanço de branco" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "Balanço de Branco (Kelvin)" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "Temperatura de cor para representação precisa das cores" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "Contraste" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "Diferença entre áreas claras e escuras" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "Brilho" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "Luminosidade ou escuridão geral da imagem" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "Redução de ruído" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "Média temporal, valores altos causam arrasto" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "Transparência" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "Transparência na superfície de trabalho" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "Por favor, selecione um dispositivo de câmera disponível" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "Por favor, selecione uma câmera configurada" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "Selecionar Câmera" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "Nenhuma câmera configurada." + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "Falha ao carregar imagem para o ID do Dispositivo: {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "Câmera {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "Nenhuma câmera encontrada." + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "Ponto {n}" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "Excluir este ponto" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "Mover pixel:" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "Propriedades da Câmera" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "Configurar a câmera selecionada." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "ID do Dispositivo" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "Identificador do sistema para o dispositivo de câmera" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "Nome de exibição desta câmera" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "Ativado" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "Ativar ou desativar o fluxo da câmera" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "Iniciar" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "Assistente de Câmara" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" +"Configuração guiada: definições de imagem, calibração de lente e alinhamento." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "Configurar" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "Configurações de Imagem" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "Ajustar brilho, contraste, balanço de branco e ruído" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "Calibração de lente" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "Corrigir distorção da lente para linhas mais retas" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "Alinhamento da Imagem" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "Calibrar posição e perspectiva da câmera" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "Calibração de lente concluída" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "Calibração de lente ainda não realizada" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "Alinhamento de imagem concluído" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" +"O alinhamento de imagem deve ser refeito após a atualização da calibração da " +"lente" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "Alinhamento de imagem ainda não realizado" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "Aguardando câmera..." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" +"Corrija a distorção da lente para obter linhas mais retas. Escolha como " +"calibrar ou ignore se a distorção da sua lente for insignificante." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "Automático" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "Calibração Automática" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" +"Imprima um cartão de calibração e capture-o em várias posições. O assistente " +"resolve os coeficientes de distorção por si." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "Manual" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "Calibração Manual" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "" +"Introduza os coeficientes de distorção radial e tangencial manualmente." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "Ignorar" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "Cartão de Calibração" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "Instruções" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" +"Imprima um cartão de calibração para corrigir a distorção da lente. O " +"tamanho do cartão deve caber na vista da câmera." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "Tamanho do cartão" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "Ajuste para caber na sua superfície de trabalho." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "Largura" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "Largura do cartão" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "Altura" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "Altura do cartão" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "Padrão gerado" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "Detalhes sobre o padrão de calibração." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "Tamanho da grade" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "Tamanho do quadrado" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "Tamanho físico" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "Salvar como PDF" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "Exportar o cartão de calibração para impressão" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "Salvar cartão de calibração" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "Cartão de calibração salvo" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "Capturar Fotogramas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" +"Capture o cartão em diferentes posições. Importante: inclua os cantos e " +"bordas da imagem para correção precisa da distorção." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "Status" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "Progresso do processo de captura de calibração." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "Imagens capturadas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "Cantos detectados" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "Cobertura" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "Não iniciado" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "Mova o cartão para capturar mais posições" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "Progresso da captura" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "Capturar imagem" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "Limpar" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "Calibrar" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "Bom" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "Limitado — alcance as bordas" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "Insuficiente — alcance todos os cantos" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "Calibração falhou" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "Calibração concluída" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" +"Erro RMS: {rms:.4f} pixels\n" +"Qualidade: {quality}\n" +"Imagens usadas: {frames}" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "Descartar" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "Salvar calibração" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "{camera} - Assistente de Câmara" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "Voltar" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "Avançar" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "Concluir" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "OK" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "Radial 1 (k1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "Distorção radial de primeira ordem" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "Radial 2 (k2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "Distorção radial de segunda ordem" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "Radial 3 (k3)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "Distorção radial de terceira ordem" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "Tangencial 1 (p1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "Distorção tangencial de primeira ordem" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "Tangencial 2 (p2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "Distorção tangencial de segunda ordem" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" +"Corrija a distorção da lente para obter linhas mais retas. Ajuste os " +"coeficientes manualmente." + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "{camera_name} – Alinhamento da Imagem" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "Diminuir zoom (rolar para baixo)" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "Ajustar à janela" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "Aumentar zoom (rolar para cima)" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "{camera_name} - Configurações de Imagem da Câmera" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "ID do Dispositivo: {device_id}" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "Adicionar Nova Câmera" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "Nenhuma câmera configurada" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "Melhoria de imagem" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "Reduzir ruído e melhorar a estabilidade da imagem." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "Média temporal. Valores altos removem mais ruído mas causam arrasto." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" +"Endireitar linhas curvas usando parâmetros Radiais (k1, k2) e Tangenciais " +"(p1, p2). Nota: Os valores geralmente são muito pequenos." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "Correção de distorção de lente (Olho de peixe)" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "Câmera" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "Câmeras" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +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/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" +"Clique na imagem para adicionar pontos de referência. Arraste para movê-" +"los.\n" +"Role para ajustar o zoom. Clique com o botão do meio e arraste para mover.\n" +"Use as teclas de seta para mover o ponto ativo com precisão." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "Redefinir Pontos" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "Limpar Todos os Pontos" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "Aplicar" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "Adicionar Nova Macro" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "Nenhuma macro configurada" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "Nova Macro" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "Ferramenta {tool_number}, {min_rpm}-{max_rpm} rpm" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" +"Ferramenta {tool_number}, pot. máx. {max_power}, tamanho do ponto {spot_x}" +"x{spot_y}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "Ferramenta {tool_number}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "Adicionar Nova Cabeça" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "Nenhuma cabeça configurada" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "É necessária pelo menos uma cabeça" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "Fuso" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "Novo Laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "Novo Fuso" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "Modelo 3D" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "Selecione e configure um modelo 3D para esta cabeça." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "Modelo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "Escala" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "Fator de escala uniforme para o modelo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "Rotação X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "Graus em torno do eixo X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "Rotação Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "Graus em torno do eixo Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "Rotação Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "Graus em torno do eixo Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "Nenhum" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "Propriedades do Laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "Configure a cabeça de laser selecionada." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" +"Configurações de modulação por largura de pulso para controle de frequência " +"e largura de pulso." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "Enquadramento" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "Configurações da operação de contorno que traça o limite do trabalho." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "Número da Ferramenta" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "Número da ferramenta do G-code (ex: T0, T1)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "Diodo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "CO₂" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "Fibra" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "Tipo de laser" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "Tipo de tubo laser ou diodo" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "Potência Máxima" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "Valor de potência máxima no GCode" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "Potência de Foco" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "Valor de potência em percentagem a usar ao focar. 0 para desativar." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "Tamanho do Ponto X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "Tamanho do ponto do laser na direção X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "Tamanho do Ponto Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "Tamanho do ponto do laser na direção Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "Cor de corte" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "Cor para operações de corte" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "Cor de rasterização" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "Cor para operações de gravação/rasterização" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "Distância focal" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "Distância da cabeça laser até a superfície de trabalho (compensação Z)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "Frequência PWM" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "Frequência PWM padrão em Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "Frequência PWM máxima" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "Frequência PWM máxima suportada em Hz" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "Largura de pulso padrão em µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "Largura de pulso mínima" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "Largura de pulso mínima em µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "Largura de pulso máxima" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "Largura de pulso máxima em µs" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "Potência de Enquadramento" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "" +"Valor de potência em percentagem a usar ao enquadrar. 0 para desativar." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "Velocidade do quadro" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" +"Velocidade para o contorno. Deixe em 0 para usar a velocidade máxima da " +"máquina" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "Número de repetições" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "Número de vezes para traçar o contorno" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "Pausa nos cantos" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "" +"Duração da pausa em segundos em cada canto do contorno. 0 para desativar" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "Propriedades do Fuso" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "Configure a cabeça de fuso selecionada." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "RPM Mín" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "Velocidade mínima do fuso" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "RPM Máx" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "Velocidade máxima do fuso" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "Suporta Refrigerante de Imersão" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "Refrigerante aplicado à peça como imersão" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "Suporta Refrigerante por Nebulização" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "Refrigerante aplicado à peça como névoa" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "Cabeças" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "Pode configurar vários lasers ou fusos se a sua máquina suportar." + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "Adicionar uma Máquina" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "Criar Máquina" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "Não foi possível criar a máquina" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "Configuração da câmara indisponível" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "" +"Calibre esta câmara mais tarde a partir da página de definições da máquina." + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "Mostrar saída detalhada (consultas de estado)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "Retângulo" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "Caixa" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "Cilindro" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "Adicionar zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "Não há zonas proibidas configuradas" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "Nova zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "Zonas proibidas" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" +"Defina áreas restritas na superfície de trabalho. Um aviso será mostrado " +"antes de executar ou exportar um trabalho cuja trajetória da ferramenta " +"entre em qualquer zona proibida ativada." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "Propriedades da zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "Configure a zona selecionada." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "Forma" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "Forma da geometria da zona" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "X" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "Posição X em {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "Y" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "Posição Y em {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "Z" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "Posição Z em {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "Profundidade" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "Profundidade (extensão Z)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "Raio do cilindro" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "Altura do cilindro" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "Altura do cilindro" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "As chaves escapadas {{ ou }} não são suportadas." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "Chaves aninhadas não são permitidas." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "Chave de fechamento '}' encontrada." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "Chave de abertura '{' encontrada." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "Chaves vazias '{}' não são permitidas." + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "Variável(is) não suportada(s): {vars}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "Editar Dialeto: {label}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "Novo Dialeto" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "Atualizar do modelo" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "O rótulo não pode estar vazio." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" +"Selecione um modelo para copiar suas configurações. Seu rótulo e descrição " +"serão preservados." + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "Hooks de G-code" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" +"Adicione G-code personalizado para ser executado em pontos específicos do " +"trabalho." + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "Redefinir para Padrão" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "Redefinir '{hook_name}' para padrão?" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" +"Isso removerá seu G-code personalizado para este hook. A máquina voltará a " +"usar sua macro padrão integrada. Esta ação não pode ser desfeita." + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "Redefinir" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "# Seu G-code aqui" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "Arquivos de perfil de dispositivo" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "Perfis de dispositivo LightBurn" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "Todos os arquivos" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "Importar perfil de dispositivo" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "Editar Macro" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "Inserir Variável" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "Incluir Macro" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "Editar Macro para {name}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "Variáveis Disponíveis" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "Nenhuma outra macro para incluir." + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "O nome não pode estar vazio." + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "O nome contém caracteres inválidos: {chars}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "Este nome já está em uso por outra macro." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "Editar deslocamentos de trabalho" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" +"Introduza o deslocamento do zero da máquina para o zero do trabalho para o " +"WCS ativo." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "Deslocamento X" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "Deslocamento Y" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "Deslocamento Z" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "Redefinir Contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "Editar Contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "Remover Contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "Redefinir Contador?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "Isso redefinirá as horas acumuladas para zero." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "Remover Contador?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" +"Tem certeza que deseja remover este contador? Esta ação não pode ser " +"desfeita." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "Adicionar Contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "Nenhum contador configurado" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "Novo Contador" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "Intervalo de Notificação" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" +"Mostrar notificação quando o contador atingir este valor (horas). Defina " +"como 0 para desativar." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "Manutenção" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "Total de Horas" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "Tempo de operação cumulativo rastreado pela máquina." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "Total de Horas de Operação" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "Tempo de operação acumulado da máquina" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "Redefinir Total de Horas" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "Contadores de Manutenção" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" +"Acompanhe os intervalos de manutenção com contadores reiniciáveis. Use para " +"tubos de laser, lubrificação, etc." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "{time} total" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "Redefinir Total de Horas?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" +"Isso redefinirá o total de horas de operação acumuladas para zero. Os " +"contadores de manutenção não serão afetados." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "Dispositivo" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "Configurações do Dispositivo" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "Leia ou aplique configurações diretamente no dispositivo." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "Ler do Dispositivo" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "O driver atual não suporta a leitura de configurações do dispositivo." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "Copiar Detalhes do Erro" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "Dispensar Erro" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" +"Editar estes valores pode ser perigoso e pode tornar a sua máquina " +"inoperável!" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" +"O dispositivo pode reiniciar ou desconectar-se temporariamente após a " +"alteração de uma configuração." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "Dispensar Aviso" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" +"Clique no botão de atualizar para carregar as configurações do dispositivo." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "A operação falhou" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "Máquina Não Conectada" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "A máquina não está conectada." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "Configuração aplicada com sucesso." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "Não é possível conectar: Em uso por '{machine}'" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "Máquina ativada." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "Importar perfil LightBurn?" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" +"Os perfis de dispositivo LightBurn contêm apenas configurações básicas da " +"máquina. O perfil importado pode estar incompleto. Após a importação, revise " +"e configure quaisquer configurações adicionais, como cabeças de laser, " +"referenciação, fins de curso, dialeto G-code, macros e módulos rotativos." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "Importar mesmo assim" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "Os seguintes valores serão importados:" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "Hooks e Macros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "Macros" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "Crie e gerencie trechos de G-code reutilizáveis." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "Avançado" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "Processamento de Caminhos" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "Configurar como os caminhos são processados e otimizados." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "Suportar Arcos" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" +"Gerar comandos de arco para caminhos mais suaves. Desative se a sua máquina " +"não suportar arcos" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "Suportar curvas Bézier" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" +"Gerar comandos Bézier cúbicos nativos. Desativar se a sua máquina não os " +"suportar" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "Tolerância de arcos e curvas" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" +"Desvio máximo do caminho original ao ajustar arcos e curvas. Valoresmenores " +"aumentam drasticamente o tempo de processamento e o tamanho dotrabalho." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "Referenciação e Inicialização" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" +"Configurar comportamento de referenciação e definições de inicialização, " +"incluindo referenciação automática e tratamento de alarmes." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "Referenciar ao Iniciar" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "Enviar um comando de referenciação quando a aplicação inicia" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "Permitir Referenciação de Eixo Único" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +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 "Clear Alarm On Connect" +msgstr "Limpar Alarme ao Conectar" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" +"Enviar automaticamente um comando de desbloqueio se conectar em estado de " +"ALARME" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "Selecionar este dialeto" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "Excluir '{label}'?" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" +"Este dialeto personalizado será removido permanentemente. Esta ação não pode " +"ser desfeita." + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "Não é Possível Excluir o Dialeto" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "Este dialeto ainda é usado pela(s) seguinte(s) máquina(s): {machines}" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "Criar do modelo" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "Nenhum dialeto personalizado configurado" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "{label} (Cópia)" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "Selecionar Máquina" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "Selecionar máquina ativa" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "Ligar/desligar laser" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "Potência" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "Potência do laser em percentagem" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "Largura de pulso em µs" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "Duração" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "Segundos (0 = contínuo)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "Ferramenta {tool_number}, potência máx. {max_power}" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "{seconds:.1f} s restantes" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "G-code" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "Precisão" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "Configurar a precisão numérica da saída de coordenadas." + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "Precisão do G-code" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "Número de casas decimais para coordenadas" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "Dialeto" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "Selecionar, criar e gerir definições de dialeto G-code." + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "Mover Noroeste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "Mover Norte" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "Mover Nordeste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "Mover Oeste (Esquerda)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "Mover Leste (Direita)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "Mover Sudoeste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "Mover Sul" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "Mover Sudeste" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "Referenciar X" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "Referenciar Y" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "Referenciar Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "Enviar para a máquina" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "Aumentar Distância Z" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "Diminuir Distância Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "Cancelar trabalho em execução" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "Selecionar modelo" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "Escolha um dialeto integrado como ponto de partida." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "Hardware" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "Eixos" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "Configure as extensões dos eixos e o sistema de coordenadas." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "Extensão X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "Faixa de deslocamento completa do eixo X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "Extensão Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "Faixa de deslocamento completa do eixo Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "Inferior Esquerdo" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "Superior Esquerdo" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "Superior Direito" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "Inferior Direito" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "Origem das Coordenadas (0,0)" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "O canto físico onde as coordenadas são zero após a referenciação" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "Inverter Direção do Eixo X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "Inverte os valores das coordenadas para negativos" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "Inverter Direção do Eixo Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "Inverter Direção do Eixo Z" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "Ativar se um comando Z positivo (ex: G0 Z10) mover a cabeça para baixo" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "Área de Trabalho" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "" +"As margens definem o espaço inutilizável ao redor das extensões dos eixos." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "Margem Esquerda" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "Espaço inutilizável da borda esquerda" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "Margem Superior" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "Espaço inutilizável da borda superior" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "Margem Direita" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "Espaço inutilizável da borda direita" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "Margem Inferior" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "Espaço inutilizável da borda inferior" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "Origem da Área de Trabalho é Coordenada Zero" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" +"Trata a origem da área de trabalho como coordenada zero. Oculta controles " +"WCS e usa margens da área de trabalho como deslocamentos." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "Limites de Software" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" +"Limites de segurança configuráveis para deslocamento. Deixe desativado para " +"usar os limites da superfície de trabalho." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "Ativar Limites de Software Personalizados" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "" +"Substituir limites da superfície de trabalho com limites personalizados" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "X Mín" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "Coordenada X mínima" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "Y Mín" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "Coordenada Y mínima" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "X Máx" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "Coordenada X máxima" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "Y Máx" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "Coordenada Y máxima" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" +"Opcional. Configure as câmaras que pretender usar para pré-visualização e " +"alinhamento." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" +"Configure as câmaras agora ou mais tarde a partir das definições da máquina. " +"O assistente regista quais os dispositivos V4L que marcar como 'ativados'; a " +"calibração detalhada da lente é efetuada na página de definições da câmara." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "Nenhuma câmara detetada" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "Pode adicionar câmaras mais tarde a partir das definições da máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "Escolher Controlador" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "Que tipo de placa de controlo utiliza esta máquina?" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "Controlador" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" +"Escolha a família de firmware / protocolo para esta máquina. Se não tiver a " +"certeza, escolha a correspondência mais próxima — pode afinar definições " +"individuais mais tarde." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "Nenhum — apenas exportação de G-code" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "Sem controlador físico; exporta G-code para um ficheiro" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "Nova Máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" +"Opcional. Configure um acessório rotativo agora ou ignore este passo para " +"adicionar um mais tarde a partir das definições da máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "Módulo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "Escolha o tipo de rotativo, o eixo, o modo e a geometria." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "Garras / mandril" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "Rolos" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "Tipo de Rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "Como a peça é fixada" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "Eixo Rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "Qual o eixo usado pelo rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "4.º Eixo Real (mantém X/Y/Z)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "Substituição de Eixo (troca e.g. Y por A)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "Modo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "Comprimento por Rotação" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "Obtido automaticamente do GRBL $101/$103 se fizer sondagem" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "Peça Padrão Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "Comprimento máx. da peça" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "Rolo Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "Necessário ao usar rotativo de tipo rolo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "Inverter Direção do Eixo" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "Inverte a direção de rotação do rotativo" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "—" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "Sim" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "Não" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "Métrico (mm)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "Imperial (polegadas)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "Rever e Nomear" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "Nome final e verificação de sanidade antes de criar a máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "Um nome amigável para esta máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "Nome da Máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "Resumo" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "Avisos" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "Nenhum (apenas exportação de G-code)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "Controlador desconhecido: {}" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "Conexão" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "Área de Trabalho X×Y" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "Sistema de unidades" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "Velocidade Máxima de Deslocamento" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "Velocidade Máxima de Corte" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "Referenciar ao Iniciar" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "Módulos Rotativos" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" +"Nenhum controlador selecionado — esta máquina só exporta G-code para " +"ficheiros; não pode executar trabalhos." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" +"As dimensões da área de trabalho não estão definidas ou não são positivas." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "Nenhuma cabeça está configurada para esta máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "A cabeça #{n} parece um laser mas não tem definição de max_power." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "A cabeça #{n} parece um fuso mas não tem definição de max_rpm." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "O nome da máquina está em branco." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "Nome em falta" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "Por favor, introduza um nome." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "Descobrir Dispositivo" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" +"Ligue-se ao dispositivo e leia a sua configuração, ou ignore para introduzir " +"os valores manualmente." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "Sondagem" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" +"Descubra automaticamente a área de trabalho, as velocidades e as capacidades " +"do firmware da máquina lendo as suas definições através da ligação." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "Sondar Agora" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "A sondar…" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "A ligar ao dispositivo e a ler as definições" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "A detecção falhou" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "Sondagem concluída" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "Área de trabalho e velocidades detetadas automaticamente." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "Tentar Novamente" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "Escolha um ponto de partida para a nova máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "Modelos de Máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" +"Escolha um perfil incorporado para pré-preencher definições comuns. Ainda " +"lhe serão pedidos valores específicos da ligação." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "Pesquisar dispositivos…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "Importar de arquivo…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "Dispositivo Não Listado" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "Falha na importação" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "Fornecedor de IA" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" +"Configure um fornecedor de IA para que o assistente possa pré-preencher " +"especificações conhecidas de máquinas." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" +"Introduza um endpoint compatível com OpenAI. Isto só é usado para a pesquisa " +"automática de especificações; pode também ignorar e introduzir os valores " +"manualmente." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "Fornecedor Padrão" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "Modelo Padrão (opcional)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "Área de trabalho (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "Velocidade de corte máxima" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "Origem das coordenadas" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "Tipo de cabeça" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "Potência máxima da cabeça (valor S)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "RPM máx da cabeça" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "RPM mín da cabeça" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "Tamanho do ponto (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "Frequência PWM (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "Distância focal" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "Pesquisa de Especificações com IA" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" +"Se a sua máquina for um modelo comercial conhecido, a IA pode pré-preencher " +"valores de especificação a partir da documentação do fabricante." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "Fabricante & Modelo" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" +"Introduza o fabricante e o nome do modelo da máquina. Quanto mais " +"específico, melhor — e.g. \"Sculpfun\" / \"S30 Pro\"." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "Fabricante (e.g. Sculpfun)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "Modelo (e.g. S30 Pro)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "Procurar Especificações" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "Sugestões" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" +"Os valores sugeridos estão ativados; desative os que não quiser aplicar." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" +"Nenhum fornecedor de IA está configurado nas Definições. Configure um para " +"ativar a pesquisa automática de especificações, ou ignore este passo e " +"introduza os valores manualmente." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "A procurar…" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "A pesquisa falhou" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" +"A IA não conseguiu devolver especificações para esta máquina. Pode " +"introduzir os valores manualmente nos próximos passos." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "A IA sugere: {value}" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "Cabeça Principal" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "Introduza os parâmetros de ligação do seu dispositivo." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" +"Introduza os parâmetros de ligação que a sua máquina requer. Os campos " +"exatos dependem do controlador que escolheu no passo anterior." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "Fixo pelo perfil escolhido" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "Entrada inválida" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "Área de trabalho, origem, velocidades e aceleração." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "Canto físico onde as coordenadas são zero após o homing" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "Ative se +Z mover a cabeça para baixo" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "" +"Substituir os limites da superfície de trabalho por limites personalizados" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "Velocidades" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "Limites em unidades de máquina por minuto." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "Velocidade máxima de movimento rápido" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "Velocidade máxima de corte" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" +"Usado para estimativas de tempo e cálculo da distância de sobrescan padrão" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "Executar ciclo de referenciamento ao conectar a máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "Referenciamento de Eixo Único" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "Permitir referenciamento de eixos individuais" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "O que está acoplado ao pórtico: um laser, um fuso, ou ambos?" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "Tipo de Cabeça" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "Escolha a cabeça principal para esta máquina." + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "Tipo de ferramenta acoplada a esta máquina" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "Nome da Cabeça" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "Definições do Laser" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "Potência Máxima (valor S)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "Valor máximo de potência do laser em GCode" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "Largura do feixe de laser no eixo X" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "Largura do feixe de laser no eixo Y" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "Frequência PWM (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "Frequência de modulação do laser" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "Distância da lente à peça" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "Substituição de eixo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "4.º eixo verdadeiro" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "{mode}, Eixo {axis}" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "Adicionar módulo rotativo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "Nenhum módulo rotativo configurado" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "Novo módulo rotativo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "Padrões rotativos" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "Configurações padrão aplicadas a novas camadas." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "Ativar rotação por padrão" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "Novas camadas usarão o modo rotativo por padrão" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "Módulos" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" +"Defina os módulos rotativos físicos conectados à sua máquina. Selecione um " +"como padrão." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "Modo de conexão" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "Como o módulo rotativo está conectado ao controlador da máquina" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "Eixo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "Letra do eixo para este módulo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "Eixo invertido" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "Inverter a direção de rotação do eixo rotativo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "Deslocamento do eixo X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "Deslocamento da posição do módulo para o eixo de rotação (X)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "Deslocamento do eixo Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "Deslocamento da posição do módulo para o eixo de rotação (Y)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "Deslocamento do eixo Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "Deslocamento da posição do módulo para o eixo de rotação (Z)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "Mandíbulas / Mandril" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "Tipo de acionamento" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "Como o módulo rotativo aciona a rotação da peça" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "Diâmetro do rolo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "Diâmetro do rolo de acionamento" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "Deslocamento por rotação" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" +"Distância do firmware para uma rotação completa de 360°. 0 = saída " +"circunferencial bruta." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "Diâmetro padrão da peça" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "Diâmetro padrão para novas camadas usando este módulo" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "Comprimento máximo da peça que este módulo pode acomodar" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "Posição X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "Coordenada X no espaço da máquina" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "Posição Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "Coordenada Y no espaço da máquina" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "Posição Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "Coordenada Z no espaço da máquina" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "Capacidades" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "Capacidades da Máquina" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" +"As capacidades são inferidas a partir das cabeças, módulos rotativos e " +"qualquer configuração explícita da máquina. Controlam que passos são " +"oferecidos ao adicionar a um fluxo de trabalho." + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "configuração explícita" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "origem desconhecida" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "{machine_name} - Configurações da Máquina" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "Configurações da Máquina" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "Exportar perfil de máquina" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "Reportar um problema" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "Exportado para {path}" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "Exportação falhou: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "Máquina" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "Identificação e configuração básica da máquina." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "Configurações do Driver" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "Definições de conexão e comunicação para o controlador da máquina." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "Selecionar driver" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "Velocidades e Aceleração" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" +"Parâmetros de movimento usados para estimativa de tempo de trabalho e " +"otimização de caminhos." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" +"O sistema de unidades usado ao emitir código G e ao comunicar com " +"odispositivo. Esta configuração é independente das unidades usadas " +"nainterface do usuário." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "Sistema de unidades da máquina" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "Configuração necessária: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "Erro: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "Não suportado pelo driver" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" +"O preâmbulo contém G21 (milímetros), mas o sistema de unidades da " +"máquinaestá definido como imperial. Os valores de código G serão emitidos " +"empolegadas — verifique se o seu preâmbulo corresponde." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" +"O preâmbulo contém G20 (polegadas), mas o sistema de unidades da máquinaestá " +"definido como métrico. Os valores de código G serão emitidos emmilímetros — " +"verifique se o seu preâmbulo corresponde." + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "Arrastar para reordenar" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "Excluir Variável" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "Chave" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "Valor Padrão" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "Valor Inicial" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "Valor Mínimo" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "Valor Final" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "Valor Máximo" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "Ajustar Valor" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "Ajustar Intervalo do Deslizador" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "Adicionar Parâmetro" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "Novo parâmetro" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "Solicitar acesso" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "Chave API configurada" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "Solicitar nova chave" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "Nenhuma chave API configurada" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "O nome do host e a porta devem ser configurados primeiro" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" +"Dispositivo inacessível ou não suporta solicitações automáticas de chaves" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "Resposta inesperada do dispositivo" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "Muitas solicitações. Tente novamente mais tarde." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "Solicitação falhou: {code}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "Conexão falhou: {err}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "Aguardando aprovação no dispositivo…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "Aguardando…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "Tempo de aprovação esgotado. Por favor, tente novamente." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "Solicitação negada ou expirada." + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "URL de autorização" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "URL do token" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "ID do cliente" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "Entrar" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "Sair" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "Token expirado" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "Atualizar" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "Autenticado" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "Reautorizar" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "Não conectado" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "Atualizando…" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "Nenhum Selecionado" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "Tipo não suportado: {t}" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "Aplicar Alteração" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "Registo de Extensões" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "A obter registo..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "Instalar a partir de URL..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "Falha na Conexão" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "Não foi possível aceder ao registo." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "Nenhuma extensão encontrada no registo." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "Instalar" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "Atualizar" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "Instalado" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "Versão {v} já instalada" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "Incompatível" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "Requer {deps}, mas a versão atual do rayforge é {current}" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "Indisponível" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "Instalação Manual" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "Introduza o URL do Git." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "Introduzir chave de licença" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "Chave de licença" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "Ativar" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "Introduza a chave de licença que recebeu ao comprar {addon_name}." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "Por favor, introduza uma chave de licença." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "A validar licença..." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "A validação da licença falhou." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "Licença inválida" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "Licença necessária" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" +"{addon_name} é uma extensão premium. Compre uma licença para a desbloquear, " +"ou introduza a sua chave de licença se já tiver uma." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "Comprar licença" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "Falha ao carregar esta extensão" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "Esta extensão será descarregada quando as tarefas ativas terminarem" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "Esta extensão é incompatível com a versão atual do Rayforge" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Esta extensão é experimental e pode ter problemas não resolvidos. Use-a com " +"cautela." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "Extensão premium" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "Extensão integrada" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "Desinstalar Extensão" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "Ativar ou desativar esta extensão" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "Instalar Nova Extensão..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "Nenhuma extensão instalada." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "Instalando {name}..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "Falha ao instalar extensão." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "Não é Possível Desativar a Extensão" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" +"Esta extensão não pode ser desativada.\n" +"\n" +"{reason}" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "A extensão será desativada quando as tarefas ativas terminarem." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "Falha ao desativar a extensão. Verifique os logs para detalhes." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "Falha ao ativar a extensão e suas dependências." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "Ativar Dependências?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" +"Esta extensão requer: {deps}\n" +"\n" +"Ativá-las também?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "Ativar Todas" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "Falha ao ativar a extensão. Verifique os logs para detalhes." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "Desinstalar {name}?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" +"Os arquivos da extensão serão removidos. Reiniciará para limpar " +"completamente a memória." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "Desinstalar" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "Erro ao apagar a extensão." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "Informação" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "Ativar extensão experimental?" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" +"A extensão \"{name}\" é experimental e pode ter problemas não resolvidos. " +"Use-a com cautela." + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "Ativar mesmo assim" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "Ajudar a Melhorar o Rayforge" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" +"Gostaria de ajudar a melhorar o Rayforge permitindo relatórios de uso " +"anônimos? Isso nos ajuda a entender como o aplicativo é usado e priorizar " +"melhorias.\n" +"\n" +"Nenhum dado pessoal é coletado." + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "Não, Obrigado" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "Permitir Relatórios" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "Mostrar Histórico" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "Ação Sem Nome" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "Desfazer a última ação" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "Refazer a última ação" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "Alternar visibilidade da peça de trabalho" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "Alternar visibilidade das abas de fixação" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "Alternar visibilidade da imagem da câmera" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "Alternar visibilidade do modelo 3D" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "Alternar visibilidade da grade" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "Alternar visibilidade do movimento de deslocamento" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "Alternar visibilidade da zona proibida" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "Sem parâmetros" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "Mostrar todas as opções" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "Selecionar modelo" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "Selecionar" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "Verificação do trabalho" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "_Continuar" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "{} erro(s)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "{} aviso(s)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "Nenhum problema encontrado." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" +"Encontrado(s) {summary}. Continuar pode causar danos à sua máquina ou peça " +"de trabalho." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "Erros" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "Valor em {unit}" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "Novo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "Abrir..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "Guardar como..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "Abrir recente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "Importar..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "Exportar G-code..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "Exportar documento..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "Sair" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "_Ficheiro" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "Desfazer" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "Refazer" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "Cortar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "Copiar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "Duplicar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "Selecionar tudo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "Limpar documento" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "_Editar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "Mostrar painel direito" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "Mostrar painel inferior" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "Vista 3D" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "Vista de cima" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "Vista frontal" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "Vista direita" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "Vista esquerda" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "Vista posterior" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "Vista Isométrica" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "Alternar Perspectiva" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "_Exibir" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "Dividir" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "Exportar Objeto..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "Adicionar Abas de Fixação Equidistantes…" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "Adicionar Abas de Fixação Cardeais" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "Adicionar Abas de Fixação" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "_Objeto" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "Mover Seleção para a Camada de Cima" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "Mover Seleção para a Camada de Baixo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "Esquerda" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "Direita" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "Topo" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "Base" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "Centralizar Horizontalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "Centralizar Verticalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "Alinhar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "Distribuir Horizontalmente" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "Distribuir Verticalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "Distribuir" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "Virar Horizontalmente" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "Virar Verticalmente" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "Virar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "Matriz" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "Organizar" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "_Ferramentas" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "Enquadrar" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "Enviar Trabalho" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "Pausar / Retomar Trabalho" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "Cancelar Trabalho" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "_Máquina" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "Sobre" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "Doar" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "Salvar Log de Depuração" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "_Ajuda" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "(Sem Itens Recentes)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "Alerta de Manutenção: {name} atingiu o seu limite ({curr} / {limit})" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "Ver Contadores" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr " (+{tasks} mais)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "{tasks} tarefas" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "Selecione uma máquina para habilitar a exportação de G-code" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "Gerar G-code" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "Não é possível exportar enquanto outras tarefas estão em execução" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" +"O pipeline precisa ser recalculado antes da exportação. Pressione F5 para " +"recalcular." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "Adicione uma peça de trabalho para habilitar a exportação" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "" +"Adicione ou ative uma etapa de processamento para habilitar a exportação" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "Configure a potência de enquadramento para habilitar" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "Percorrer a área ocupada com a cabeça do laser" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" +"O pipeline precisa ser recalculado antes do envio. Pressione F5 para " +"recalcular." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "Retomar máquina" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "Pausar máquina" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "Por favor, selecione um único objeto para exportar." + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "Log de depuração salvo em {path}" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "Abrir Projeto" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "Importar imagem" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "Visualização 3D desativada (dependências ausentes, como PyOpenGL)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "Mostrar Pré-visualização 3D" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "Recalcular (Shift+Clique para forçar)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "Alternar painel inferior" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "Organizar seleção" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "Adicionar Abas Cardeais (N,S,L,O)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "Adicionar Abas à seleção" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "Referenciar a máquina" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "Limpar alarme da máquina (desbloquear)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "Alternar laser de foco" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "Máquina não configurada completamente" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" +"Faltam configurações obrigatórias no driver da máquina. Clique para editar." + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "Centralizar Horizontalmente" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "Centralizar Verticalmente" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "Alinhar à Esquerda" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "Alinhar à Direita" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "Alinhar ao Topo" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "Alinhar à Base" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" +"Criar um arquivo ZIP com logs e informações do sistema para resolução de " +"problemas." + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "Incluir projeto atual" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "Adicionar o arquivo do projeto atual ao arquivo de depuração" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "_Guardar" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "Falha ao criar o arquivo de depuração." + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "Erro ao salvar o arquivo: {msg}" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "Ocorreu um erro inesperado: {error}" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "Alterações Não Guardadas" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "O projeto atual tem alterações não guardadas. Deseja guardá-las?" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "_Não Guardar" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "Novo projeto criado" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "Sem Título" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "Adicionar Recurso" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "Adicionar esboço" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "Criar nova peça de trabalho" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "Cortar elemento(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "Cortar elemento" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "Colar elemento(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "Colar elemento" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "Duplicar elemento(s)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "Duplicar elemento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "Mapear para existente" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "Novas camadas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "Achatar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "Modo de importação de camadas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "Como as camadas importadas são mapeadas para camadas do documento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "Camadas SVG" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "Cores" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "Origem das camadas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "Agrupar a geometria importada por camada SVG ou por cor" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "Importar imagem" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" +"O ficheiro não produziu saída no modo vetorial direto. Ficheiros contendo " +"texto ou outros elementos não-caminho devem ser convertidos em caminhos " +"antes de importar (p. ex., no Inkscape: Caminho > Objeto para Caminho)." + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "Mudar para Modo de Rastreamento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "Reimportar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "Importar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "Modo de Importação" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "Usar Vetores Originais" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "Importar dados vetoriais diretamente" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "DPI" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" +"Pixels por polegada para dimensões SVG sem unidades. Inkscape ≥0.92 usa 96, " +"Inkscape mais antigo usa 90, Illustrator usa 72" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "Camadas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "Configurações de Rastreamento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "Importar Imagem Completa" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "Importar a imagem completa sem rastreamento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "Limiar Automático" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "Determinar automaticamente o limiar de rastreamento" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "Limiar" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "Rastrear objetos mais escuros que este valor" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "Inverter" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "Rastrear objetos claros em um fundo escuro" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "Selecionar Camadas" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "Camada está vazia" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "Camada com {n} vetores" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "Gerando pré-visualização..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "Aplicabilidade" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" +"Defina quando esta receita deve ser sugerida. Deixe os campos em branco para " +"corresponder a qualquer valor." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "Qualquer" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "Tipos de etapa" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" +"Os tipos de etapa aos quais esta receita se aplica. Deixe vazio para " +"corresponder a qualquer tipo de etapa." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "Selecionar..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "Limpar seleção de tipos de etapa" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "Material" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "Limpar Seleção de Material" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "Espessura Mín." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "Espessura mínima do material para esta receita ser aplicada" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "Espessura Máx." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "Espessura máxima do material para esta receita ser aplicada" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "…" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "Não encontrado" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "Receita" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" +"Uma predefinição de configurações nomeada que pode ser aplicada " +"automaticamente mais tarde." + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" +"As configurações que serão aplicadas por esta receita. Quando vários tipos " +"de etapa são selecionados, apenas as configurações comuns a todos são " +"mostradas." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "Pós-processamento" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" +"Configurações de transformador aplicadas por esta receita. Quando vários " +"tipos de etapa são selecionados, apenas os transformadores comuns a todos " +"são mostrados." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "Nenhuma opção de pós-processamento disponível para esta etapa." + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "Editar Receita" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "Adicionar Nova Receita" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "Máquina Desconhecida" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "Material Desconhecido" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "Nenhuma receita encontrada." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" +"A receita será removida permanentemente. Esta ação não pode ser desfeita." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "Selecionar Receita" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "Escolha uma receita para aplicar à etapa atual." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "Mostrar apenas receitas compatíveis" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "Nome do passo e definições da receita." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "Refrigeração" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "Refrigerante usado enquanto esta operação é executada." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "Alterar {key}" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" +"Transformadores aplicados ao caminho de ferramenta gerado por esta etapa." + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "Velocidade dos movimentos rápidos de posicionamento" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "Desligado" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "Inundação" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "Névoa" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "Refrigerante aplicado à peça enquanto corta" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "Este método de refrigeração não é suportado pela máquina atual" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "Velocidade da operação de corte" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "Configurações de {name}" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "Configurações da Etapa" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "Escolher..." + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "Configurações Manuais" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "Aplicar Receita '{name}'" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "Aplicar transformador da receita" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "Nova receita {label}" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "Definir Receita Aplicada" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "Atualizar Receita '{name}'?" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" +"Isso substituirá permanentemente a receita salva com as configurações da " +"etapa atual. Esta ação não pode ser desfeita." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "1 material" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "{count} materiais" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "{count} (Somente leitura)" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "Adicionar nova biblioteca" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "Nenhuma biblioteca encontrada." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" +"A pasta da biblioteca e todos os seus materiais serão removidos " +"permanentemente. Esta ação não pode ser desfeita." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "Editar Biblioteca" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "Insira um novo nome para a biblioteca:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "Nome da biblioteca" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "Falha ao renomear biblioteca." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "Insira um nome para a nova pasta da biblioteca:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +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/file_dialogs.py +msgid "Open File" +msgstr "Abrir Arquivo" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "Todos os suportados" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "Salvar Arquivo G-code" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "Arquivos G-code" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "Exportar Objeto" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "Exportar Documento" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "SVG (Gráficos Vetoriais Escaláveis)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "DXF (Formato de Troca CAD)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "Abrir Projeto {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "Projeto {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "Guardar Projeto {app_name}" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "Editar Material" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "Atualize os detalhes do material:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "Adicionar Novo Material" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "Insira os detalhes para o novo material:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "Categoria" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "Personalizado" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "Adicionar Nova Camada" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "Propriedades do Material" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "Espessura" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "Espessura do material" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "Recursos" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "Visualizador de G-code" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "Console" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "Controles" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "Deslocamentos Atuais" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "Editar deslocamentos manualmente" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "Posição Atual" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "Mover para o canto inferior esquerdo da seleção ou área de trabalho" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "Mover para o centro da seleção ou área de trabalho" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "Mover para o canto superior direito da seleção ou área de trabalho" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "Mover para origem do WCS ativo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "Zerar Eixos" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "Definir a posição X atual como 0 para o WCS ativo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "Definir a posição Y atual como 0 para o WCS ativo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "Definir a posição Z atual como 0 para o WCS ativo" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "Definir Zero de Trabalho na Posição Atual" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "Clicar na área de trabalho para definir zero de trabalho" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "Clicar na área de trabalho para definir zero de trabalho" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "Velocidade Jog" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "Distância Jog" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "Distância em unidades de máquina" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "Substituído pela camada atual. Altere nas configurações da camada." + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "Fora de linha - Posição desconhecida" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" +"Os deslocamentos não podem ser definidos no modo de coordenadas da máquina " +"({wcs})" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "A máquina deve estar conectada para definir o zero aqui" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "Definir a posição atual como 0" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "Selecionar tipos de etapa" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "Escolha a quais tipos de etapa esta receita se aplica." + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "Pesquisar..." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "Recursos Ausentes" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "Este documento usa um recurso que não está disponível: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "Este documento usa recursos que não estão disponíveis: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "O documento ainda pode ser editado e salvo." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "_OK" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "Selecionar Material" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "Escolha um material das bibliotecas disponíveis." + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "Sem operações" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "Adicionar etapa" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "Reordenar etapas" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "Adicionar etapa '{name}'" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "Remover etapa '{name}'" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "Configurações da camada" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "Excluir esta camada" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "Alternar visibilidade da camada" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "Relativo à origem {wcs}" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "O zero fica no lado esquerdo" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "Redefinir posição X para 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "Redefinir posição Y para 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "Proporção Fixa" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "Restaurar para a largura natural" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "Restaurar para a altura natural" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "Redefinir para a proporção natural" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "Ângulo" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "Sentido horário é positivo" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "Inclinação" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "Ângulo de inclinação horizontal" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "Redefinir ângulo para 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "Restaurar inclinação para 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "Natural: {val}" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "Arquivo de Origem" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "Mostrar Metadados da Imagem" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "Mostrar no Explorador de Arquivos" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "Comandos Vetoriais" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "{count} comandos" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "{name} (não encontrado)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "(Nenhum arquivo de origem)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "Abas de Fixação" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "Remover todas as abas de fixação" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "Largura da Aba" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "Comprimento ao longo do caminho" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "Redefinir largura da aba para o padrão (1.0)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "{num_tabs} abas de fixação" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "Valores mistos" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "Número de Abas" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "Ajustar Abas Equidistantes" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "Ativar {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "Alternar {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "Deixar inalterado" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "Desativado" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "Este recurso não está disponível." + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" +"O componente necessário '{}' não pôde ser encontrado. O documento ainda pôde " +"ser salvo." + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "Alternar visibilidade da etapa" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "Metadados da Imagem" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "Copiar Metadados" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "Nenhum metadado disponível." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "Informações Básicas" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "Propriedades básicas da imagem como dimensões e formato." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "Metadados" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "Todos os metadados extraídos da imagem." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "Metadados copiados para a área de transferência" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "Propriedades do Item" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "1 item selecionado" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "{count} itens selecionados" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "Vários Itens" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "Propriedades da Peça de Trabalho" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "Propriedades do Grupo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "{name} - Definições" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "Fechar" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" +"Configurações básicas da camada, como aparência e sistema de coordenadas." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "Cor usada para operações nesta camada" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "Sistema de coordenadas" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" +"A origem do sistema de coordenadas de trabalho para esta camada. Por padrão, " +"usar o WCS selecionado na janela principal" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "Acessório rotativo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" +"Configure o acessório rotativo para objetos cilíndricos. Quando ativado, " +"movimentos do eixo Y são convertidos em movimentos rotacionais em graus." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "Ativar modo rotativo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "Converter eixo Y em eixo rotativo" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "Selecione o módulo rotativo para esta camada" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "Diâmetro do objeto" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "Diâmetro do objeto cilíndrico" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "Nenhum material na biblioteca selecionada." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "Não é possível Excluir Material" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" +"Este material está sendo usado atualmente por uma ou mais receitas. Por " +"favor, remova as receitas que usam este material antes de excluí-lo." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" +"O material será removido permanentemente da biblioteca. Esta ação não pode " +"ser desfeita." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "Falha ao atualizar material." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "Falha ao adicionar material à biblioteca." + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "Importação em Lote de {file_count} Imagens" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" +"Importar {file_count} imagens:\n" +"{file_names}\n" +"\n" +"Todas as imagens serão rastreadas usando as configurações de rastreamento " +"padrão e posicionadas na localização de solta." + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "Importar Tudo" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "Adicionar Nova Etapa..." + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "{count} etapa" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "{count} etapas" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "Reproduzir simulação" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "Retroceder" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "Avançar" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "Velocidade de reprodução" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "Pausar simulação" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "Não encontrado" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "Kit de Ferramentas de UI" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "Gráficos e Imagens" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "Geometria" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "Formatos de Arquivo e Comunicação" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "Site" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "Reportar um Problema" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "Versão" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "Copiar Versão" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "Desenvolvedor Principal" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "Licença" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "Informações do Sistema" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "Versões de bibliotecas e componentes" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "Copiar Informações do Sistema" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "Apoiadores" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "Pessoas que doaram para o projeto" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" +"Um agradecimento especial a todos os que doaram para apoiar o Rayforge! " +"Vocês mantêm o café e os tokens de AI fluindo!" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "Sobre {app_name}" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "mm/min" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "mm/s" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "pol/min" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "pol/s" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "mm" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "cm" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "m" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "pol" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "pés" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "mm/s²" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "cm/s²" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "m/s²" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "pol/s²" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "pés/s²" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "{size} B" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "{size:.1f} KB" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "{size:.1f} MB" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "{:.0f}s" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "{}m" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "{}h" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "{line_count:,} linhas · {size}" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "— Truncado (mostrando as primeiras 20.000 de {line_count:,} linhas) —" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "A verificar atualizações de extensões..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "Está disponível uma atualização para {name}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "Estão disponíveis atualizações para {name1} e {name2}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "Estão disponíveis atualizações para {name1}, {name2} e outros {num}." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "Instalar Tudo" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "Atualizações de extensões encontradas." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "As extensões estão atualizadas." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "A instalar atualizações de extensões..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "Extensão atualizada com sucesso." +msgstr[1] "{num} extensões atualizadas com sucesso." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "{num_s} extensões atualizadas, {num_f} falharam." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "Falha ao atualizar extensão." +msgstr[1] "Falha ao atualizar {num} extensões." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "Concluído com {num_failed} erros." + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "Todas as atualizações de extensões instaladas!" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" +"Não foi possível abrir '{file}'. A extensão necessária pode estar desativada." + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "Um gerador de GCode para cortadoras a laser." + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "Caminhos para um ou mais arquivos SVG ou de imagem de entrada." + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" +"Forçar a importação como vetores diretos. Este é o padrão para ficheiros " +"suportados." + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" +"Forçar a importação rastreando a representação bitmap do ficheiro. Aborta se " +"não suportado." + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "Definir o nível de log (padrão: INFO)" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" +"Sair após importar documentos e o editor estiver estabilizado. Útil para " +"testes." + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" +"Caminho para um script Python a ser executado após a janela principal ser " +"totalmente carregada. Útil para automação e testes." + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" +"Caminho para um diretório de configuração personalizado. Útil para testes " +"com configurações isoladas." + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "Agregar" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "{status} — {activity}" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "Agregando trabalho" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "Gerando código de máquina" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "Aplicando transformação de máquina" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "Processando" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "Processando '{workpiece}' — {step}" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "Montando" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "Montando '{step}'" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "face padrão" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "Não foi possível maquinar a face '{face}': {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "Não foi possível maquinar a região {region} da face '{face}': {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "Aviso de maquinagem: {detail}" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "Potência variável" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "Potência constante" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "Pontilhado" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "Múltiplas profundidades" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "Variável" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "Constante" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "Múltiplas passadas" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "(+{n} mais)" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "Ausente: {}" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "Este transformador não está disponível." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "O nome do sistema de coordenadas atualmente ativo (p. ex., 'G54')." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "O nome do perfil da máquina atual." + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "A largura (eixo X) da área de trabalho da máquina." + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "A altura (eixo Y) da área de trabalho da máquina." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "O nome do arquivo de documento atual (se salvo)." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "A coordenada X mínima de todo o trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "A coordenada Y mínima de todo o trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "A coordenada X máxima de todo o trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "A coordenada Y máxima de todo o trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "O deslocamento X do WCS atualmente ativo." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "O deslocamento Y do WCS atualmente ativo." + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "O deslocamento Z do WCS atualmente ativo." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "O nome da camada atual que está sendo processada." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "O nome da peça de trabalho atual que está sendo processada." + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "A posição X da peça de trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "A posição Y da peça de trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "A largura da peça de trabalho." + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "A altura da peça de trabalho." + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "Transformar item(ns)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "Mover item(ns)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "Alterar ângulo do item" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "Alterar inclinação do item" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "Redimensionar item(ns)" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "Atualizar Ativo" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "Renomear Recurso" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "Excluir Recurso '{name}'" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "Remover item dependente" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "Remover definição do recurso" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "Alternar Visibilidade do Recurso" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "Importar {filename}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "Importando {filename}..." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" +"Falha ao importar {filename}. O ficheiro de imagem pode estar corrompido ou " +"num formato não suportado." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "Importação falhou: Nenhum item foi criado de {filename}" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "Falha na importação." + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "Importação concluída!" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" +"⚠️ O item importado era maior que a área de trabalho e foi redimensionado " +"para caber." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "Exportação bem-sucedida: {name}" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "Objeto exportado com sucesso." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "Falha ao exportar objeto: {error}" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "Não é possível exportar: O documento não tem geometria." + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "Documento exportado com sucesso." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "Falha ao exportar documento: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "Projeto guardado: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "Falha ao guardar: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "Ficheiro não encontrado: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" +"Este projeto usa métodos de refrigeração não suportados pela máquina atual: " +"{methods}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "{count} recurso(s) requer(em) extensão(ões) desativada(s)" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "Formato de ficheiro de projeto inválido" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "Falha ao carregar: {error}" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "Não foi possível encaixar os seguintes itens: {item_names}" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "Renomear etapa" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "Remover material" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "Material {count}" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "Alternar visibilidade do material" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "Renomear Recurso de Material" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "Alterar espessura do material" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "Alterar material de base" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "Adicionar Aba de Fixação" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "Limpar Abas de Fixação" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "Alternar Abas de Fixação" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "Alterar Largura da Aba" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "Mover para outra camada" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "Camada" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "Renomear camada" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "Definir camada ativa" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "Remover camada '{name}'" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "Reordenar peças" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "Reordenar itens" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "Criar matriz" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "Criar cópia da matriz" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "{count} recurso(s) requer(em) a extensão desativada '{addon}'" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "Agrupando itens..." + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "Desagrupando itens..." + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "Dividir item(ns)" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "Remover item original" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "Adicionar fragmentos divididos" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "Colar item(ns)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "Colar item" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "Duplicar item(ns)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "Duplicar item" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "Adicionar item" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "Remover item" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "Remover todas as peças de trabalho" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "Limpar Itens da Camada" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "Excluir contorno(s)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "Excluir segmento(s)" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "Posicionar no Ponto" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "Layout Automático" + +#: 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 +msgid "Failed to process image data." +msgstr "Falha ao processar dados de imagem." + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "Falha ao carregar a imagem: {}" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "Não foi possível calcular metadados SVG." + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "Falha ao preparar dados SVG aparados." + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "O SVG não contém geometria ou dimensões." + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "Não foi possível determinar dimensões SVG válidas." + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "Não é possível determinar dimensões válidas para rastreamento." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "Falha ao rasterizar SVG para rastreamento." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "Falha ao normalizar dados de imagem." + +#: 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." + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "Não foi possível ler o PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "Erro inesperado ao escanear o PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "Falha ao processar dados de imagem PDF." + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "Falha ao ler as dimensões das páginas do PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "A página PDF tem dimensões zero" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "Falha ao rasterizar PDF" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "O PDF não contém geometria vetorial." + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "Falha ao analisar o PDF: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "O arquivo LightBurn é XML inválido: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "O arquivo LightBurn está corrompido ou é inválido: {}" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "Não foi possível analisar o cabeçalho BMP em {}" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "Falha ao escanear o arquivo BMP: {}" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "Dados BMP inválidos ou não suportados." + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "Falha no processamento da imagem: {}" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "O ficheiro não contém comandos vetoriais." + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "O arquivo Ruida é inválido: {}" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "Erro inesperado ao escanear o arquivo Ruida: {}" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "Falha ao analisar os comandos Ruida: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "A estrutura do arquivo DXF é inválida: {}" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "Erro inesperado ao escanear o DXF: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "O arquivo DXF está corrompido ou é inválido: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "Falha ao calcular os parâmetros: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "Falha ao executar o gerador: {}" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "Falha ao escanear o arquivo JPEG: {}" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "Floyd Steinberg" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot new file mode 100644 index 000000000..8e2cb864a --- /dev/null +++ b/rayforge/locale/rayforge.pot @@ -0,0 +1,8441 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "" + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "" + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "" + +#: rayforge/updater.py +msgid "Download" +msgstr "" + +#: rayforge/updater.py +msgid "New version available." +msgstr "" + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "" + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "" + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "" + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "" + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 +msgid "Unknown" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "" + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Automatically check for new Rayforge versions on startup" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Startup behavior" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +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 +msgid "Add Tab Here" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +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 "Paste" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "" + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "" + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Open File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "" +msgstr[1] "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "" +msgstr[1] "" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "" + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "" + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "" + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "" + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "" + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "" + +#: rayforge/image/png/importer.py +msgid "Failed to scan PNG file: {}" +msgstr "" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Failed to process image data." +msgstr "" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "" + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF file contains no pages." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "" + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "" + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..dfd06aa4d --- /dev/null +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -0,0 +1,8833 @@ +# Ukrainian translations for Rayforge. +# Copyright (C) 2025 The Rayforge Project +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2026-02-23 01:17+0100\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Ukrainian\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ?0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ?1 :2);\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "Перевірка оновлень Rayforge..." + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "Помилка перевірки оновлень." + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "Rayforge {version} доступний." + +#: rayforge/updater.py +msgid "Download" +msgstr "Завантажити" + +#: rayforge/updater.py +msgid "New version available." +msgstr "Доступна нова версія." + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "Rayforge оновлений." + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "Робочий процес {name}" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "Плоский" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "Роторний · {name}" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "Поворотний" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "Шар {}" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "{name} (копія)" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "Невірний запит" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "Помилка автентифікації - перевірте ваш API-ключ" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "Доступ заборонено - перевірте дозволи вашого API-ключа" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "API-ендпоінт не знайдено - перевірте базову URL-адресу" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "Перевищено ліміт запитів - зачекайте і спробуйте знову" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "Помилка сервера - спробуйте пізніше" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "Сервіс недоступний - спробуйте пізніше" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "Сервер повернув помилку {code}" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "Помилка з'єднання - перевірте вашу мережу" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "Модель '{model}' не знайдено. Доступні: {available}" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "Швидкість різання" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "Швидкість переміщення" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "Налаштування" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "Вибір" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "Текст (один рядок)" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "Швидкість передачі не може бути порожньою." + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "'{rate}' не є стандартною швидкістю передачі." + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "Швидкість передачі" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "Швидкість з'єднання в бітах за секунду" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "Ім'я хоста або IP-адреса не можуть бути порожніми." + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "Неправильний формат імені хоста або IP-адреси." + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "Ім'я хоста / IP" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "Ціле число" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "Значення має бути не менше {min_val}." + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "Значення має бути не більше {max_val}." + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "Порт не може бути порожнім." + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "Порт має бути числом." + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "Дробове число" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "Повзунок (0-100%)" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "Текст (багато рядків)" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "Вибір (з мітками)" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "Логічне (Перемикач)" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "URL не може бути порожнім." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "URL має містити схему (наприклад, 'http://')." + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "URL має містити ім'я хоста." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "Схема URL має бути однією з: {schemes}." + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "Неправильний URL: {error}" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "Послідовний порт не може бути порожнім." + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "Послідовний порт" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "Центральна лінія" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "Всередині" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "Ззовні" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "Всередині-Ззовні" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "Ззовні-Всередині" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "Лазер" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "Фрезерування" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "ШІМ" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "Різання та гравіювання лазером" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "Фрезерування та контурна обробка шпинделем" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "Широтно-імпульсне керування потужністю лазера" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "Поворотний пристрій для циліндричних об'єктів" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "Ядро" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "Матеріал заготовки" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "Джерело" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "Синтаксична помилка: {message}" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "Невідома змінна або функція: '{name}'" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "Неможливо використати оператор '{op}' між типами '{left}' та '{right}'" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "Без драйвера" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "Без з'єднання" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "Машинні координати" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "Без налаштувань" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "Ресурс '{resource}' зараз використовується '{owner}'." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "" +"Цей драйвер не тестувався. Він може працювати або ні. Використовуйте на " +"власний ризик." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Цей драйвер експериментальний і може мати невирішені проблеми. " +"Використовуйте з обережністю." + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 +msgid "Unknown" +msgstr "Невідомо" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "Бездіяльність" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "Запуск" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "Пауза" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "Ручне переміщення" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "Тривога" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "Двері" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "Перевірка" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "Дім" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "Сон" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "Інструмент" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "Черга" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "Блокування" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "Розблокування" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "Цикл" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "Тест" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "Частота" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "Частота ШІМ у Гц" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "Ширина імпульсу" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "Ширина імпульсу в мікросекундах" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "" +"Помилка під час налаштування. Можливо, потрібно редагувати налаштування " +"пристрою." + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "Smoothie" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "Smoothieware через Telnet-з'єднання" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "Машинні координати (G53)" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "Неправильне ім'я хоста або IP-адреса: '{host}'" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "Ім'я хоста" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "IP-адреса або ім'я хоста пристрою" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "Порт" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "Номер порту Telnet" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "Ім'я хоста має бути налаштоване." + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "Ruida (UDP)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "Підключення до лазерного контролера Ruida через UDP" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "IP-адреса або ім'я хоста контролера Ruida" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "Основний порт" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "UDP-порт для основних команд (за замовчуванням: 50200)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "Порт ручного переміщення" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "UDP-порт для команд ручного переміщення (за замовчуванням: 50207)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "Немає відповіді від контролера" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "Надіслати G-код на сервер OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "IP-адреса або ім'я хоста сервера OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "HTTP-порт сервера OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "Ключ API" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" +"Введіть API-ключ вручну або натисніть 'Запитати доступ', щоб отримати його " +"через плагін Application Keys OctoPrint." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "" +"API-ключ має бути налаштований. Використовуйте кнопку 'Запитати доступ' або " +"введіть API-ключ вручну." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "Помилка автентифікації. API-ключ може бути недійсним або простроченим." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "" +"Не вдалося підключитися до OctoPrint на '{host}:{port}'. Перевірте адресу та " +"мережеве з'єднання." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "Помилка автентифікації" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "" +"API-ключ недійсний або прострочений. Будь ласка, повторіть автентифікацію в " +"налаштуваннях пристрою." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "OctoPrint не повернув дані для входу." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "Неочікуваний кадр WebSocket." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "Сервер закрив з'єднання WebSocket." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "Друк не вдався" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "" +"OctoPrint повідомив про невдачу завдання друку. Перевірте OctoPrint для " +"деталей." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "Драйвер не налаштований з хостом." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "Помилка автентифікації під час завантаження." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "Принтер зайнятий або не працює. Неможливо розпочати нове завдання." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "" +"OctoPrint прийняв файл, але не зміг розпочати друк. Принтер може бути не " +"робочим або вже зайнятий." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "Не вдалося завантажити файл на OctoPrint за адресою '{host}:{port}'." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "" +"OctoPrint не підтримує запис налаштувань прошивки пристрою через свій API." + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "" +"Команду зонда надіслано. OctoPrint не повідомляє результати зондування через " +"свій API." + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "Marlin (Serial)" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "Прошивка Marlin через послідовне з'єднання" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "Послідовний порт для пристрою" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "Порт має бути налаштований." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "Швидкість передачі має бути налаштована." + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "Порт не налаштовано" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "Немає відповіді від пристрою" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "Автоматично налаштовано через майстер зондування" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "GRBL (Telnet)" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "Сумісний з GRBL контролер через з'єднання TCP/telnet" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "TCP-порт для служби raw/telnet" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "Опитувати статус пристрою під час завдань" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" +"Періодично запитувати пристрій про позицію та статус під час виконання " +"завдання. Попередження: Деякі пристрої мають проблеми з підтримкою " +"стабільного з'єднання, якщо це використовується!" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "Виявлення взаємоблокування" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" +"Виявляє та відновлює після взаємоблокувань послідовної комунікації під час " +"завдань. Якщо вимкнено, драйвер просто чекатиме відповіді машини. Вимкніть, " +"якщо отримуєте помилкові помилки ALARM:3." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "Відсутня буква команди" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "" +"Команди G-code потребують букви зі значенням. Букву команди не знайдено." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "Неправильний формат числа" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "" +"Значення відсутнє або має неправильний числовий формат. Перевірте синтаксис " +"G-code." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "Невідома команда" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "" +"Ця команда налаштування Grbl не розпізнається або не підтримується. " +"Перевірте синтаксис команди." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "Від'ємне значення" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "Тут потрібне додатне число, але отримано від'ємне значення." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "Пошук дому вимкнено" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "" +"Пошук дому не увімкнено в налаштуваннях. Увімкніть пошук дому ($22=1) для " +"використання цієї функції." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "Час імпульсу занадто короткий" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "" +"Мінімальний час крокового імпульсу має бути більше 3 мікросекунд. Перевірте " +"налаштування $0." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "Помилка пам'яті" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "" +"Налаштування скинуто до стандартних через помилку читання пам'яті. " +"Переналаштуйте параметри за потреби." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "Машина зайнята" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "" +"Цю команду можна використовувати лише коли машина бездіяльна. Дочекайтеся " +"завершення поточного завдання." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "Команди заблоковано" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "" +"Неможливо надсилати команди в режимі тривоги або ручного переміщення. " +"Спочатку очистіть стан тривоги." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "Потрібен пошук дому" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "" +"М'які обмеження не можна увімкнути без увімкненого пошуку дому. Спочатку " +"увімкніть пошук дому ($22=1)." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "Рядок занадто довгий" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "" +"Рядок команди містить занадто багато символів і був ігнорований. Перевірте " +"форматування файлу." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "Значення занадто велике" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "" +"Це налаштування перевищує максимальну підтримувану швидкість кроків. " +"Використовуйте менше значення." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "Двері відчинено" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "" +"Виявлено відчинені запобіжні двері. Закрийте двері та відновіть роботу." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "" +"Інформація про збірку або стартовий рядок перевищує ліміт сховища. Скоротіть " +"рядок." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "Ціль поза діапазоном" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "" +"Ціль ручного переміщення виходить за межі ходу машини. Перемістіться в " +"позицію в межах діапазону." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "Неправильна команда ручного переміщення" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "" +"У команді ручного переміщення відсутній '=' або вона містить заборонений G-" +"code. Перевірте синтаксис." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "Помилка лазерного режиму" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "" +"Лазерний режим потребує PWM-виходу для роботи. Перевірте конфігурацію " +"обладнання." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "Шпіндель не працює" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "" +"Надіслано команду руху, але шпиндель не працює. Запустіть шпиндель перед " +"початком руху." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "Невідповідність швидкості шпинделя" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "" +"Поточна швидкість шпінделя не відповідає швидкості, необхідній для команди. " +"Дечекайтесь, поки шпіндель досягне цільової швидкості." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "Непідтримувана команда" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "" +"Ця команда G-code не підтримується машиною. Перевірте налаштування " +"постпроцесора." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "Конфліктуючі команди" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "" +"Знайдено кілька команд з однієї групи в одному рядку. Видаліть дублікат " +"команди." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "Відсутня швидкість подачі" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "" +"Встановіть швидкість подачі перед використанням команд переміщення. Додайте " +"команду F для вказання швидкості." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "Потрібне ціле число" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "" +"Ця команда потребує цілого числового значення. Видаліть десяткові точки." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "Конфлікт осей" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "Кілька команд намагаються використати ту саму вісь. Спростіть команду." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "Дубльове слово" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "" +"Те саме слово G-code з'являється більше одного разу. Видаліть дублікат." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "Відсутня вісь" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "" +"Ця команда потребує координати осей XYZ. Додайте відсутні значення осей." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "Номер рядка поза діапазоном" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "" +"Номер рядка має бути від 1 до 9,999,999. Використовуйте дійсний номер рядка." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "Відсутнє значення" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "Ця команда потребує значення P або L. Додайте відсутній параметр." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "Непідтримувана система координат" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "" +"Підтримуються лише системи координат G54-G59. Використовуйте одну з них." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "Неправильний режим переміщення" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "" +"Команда G53 потребує режим переміщення G0 або G1. Спочатку встановіть " +"правильний режим." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "Невикористані слова осей" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "" +"Слова осей присутні, але активне скасування G80. Видаліть невикористані " +"слова осей." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "Відсутні дані дуги" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "" +"Команда дуги G2/G3 потребує координати XYZ. Додайте значення осей для " +"вибраної площини." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "Неправильна ціль" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "" +"Неможливо створити цю дугу або зондування в поточну позицію. Перевірте " +"цільові координати." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "Помилка геометрії дуги" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "" +"Помилка обчислення дуги. Спробуйте розбити дугу на менші частини або " +"використайте зсув IJK." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "Відсутній зсув дуги" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "" +"Команда дуги G2/G3 потребує значення зсуву IJK. Додайте відсутній зсув для " +"вибраної площини." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "Невикористані слова" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "" +"Деякі слова G-code в цьому рядку не використовуються жодною командою. " +"Видаліть невикористані слова." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "Неправильна вісь для зсуву" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "" +"Зсув довжини інструменту працює лише на налаштованій осі (зазвичай вісь Z). " +"Перевірте ваші налаштування." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "Номер інструменту занадто великий" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "" +"Номер інструменту перевищує максимальне підтримуване значення. " +"Використовуйте дійсний номер інструменту." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "Жорстке обмеження" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" +"Спрацював кінцевий вимикач жорсткого обмеження. Машина зупинилася і потребує " +"скидання. Перевірте наявність перешкод та перевірте кінцеві вимикачі." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "Програмне обмеження" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "" +"Машина вийшла б за налаштовані межі переміщення. Перевірте правильність " +"вашої робочої зони та зміщень координат." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "Перервати цикл" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "" +"Поточне завдання було скасовано під час руху. Скиньте машину, щоб продовжити." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "Помилка зонда — Початковий" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "" +"Зонд не встановив контакт до досягнення максимальної відстані переміщення. " +"Перевірте підключення та позиціонування зонда." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "Помилка зонда — Кінцевий" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "" +"Зонд не зміг повернутися до цільової позиції після контакту. Перевірте " +"конфігурацію зонда." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "Помилка пошуку дому — Скидання" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "" +"Пошук дому не зміг завершитися, оскільки машина знаходиться в стані тривоги. " +"Скиньте тривогу і спробуйте знову." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "Помилка пошуку дому — Наближення" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "" +"Цикл пошуку дому не знайшов вимикач у межах налаштованої відстані " +"переміщення. Перевірте підключення вимикача та налаштування відведення." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "Помилка пошуку дому — Відведення" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "" +"Цикл пошуку дому не зміг успішно відійти від вимикача після контакту. " +"Збільште відстань відведення або перевірте вимикач." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "Пошук дому без обмежень" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "" +"Подано команду пошуку дому, але кінцеві вимикачі не налаштовані. Спочатку " +"увімкніть кінцеві вимикачі." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "Збій пошуку дому — Подвійна вісь" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" +"Помилка пошуку дому в конфігурації з подвійною віссю. Одна або обидві осі не " +"досягли своїх кінцевих вимикачів. Перевірте підключення та налаштування " +"кінцевих вимикачів." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "Невідома тривога" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "Машина повідомила невірний код тривоги." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "" +"Машина повідомила нерозпізнаний код тривоги. Перевірте документацію вашої " +"машини та прошивки." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "Невідома помилка" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "Неправильний код помилки, повідомлений машиною." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "" +"Машина повідомила нерозпізнаний код помилки. Перевірте документацію вашої " +"машини та прошивки." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "Конфігурація stepper-моторів" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "" +"Налаштування, пов'язані з таймінгом stepper-моторів та полярністю сигналу." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "Керування та звітність" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "Налаштування керування рухом та звітності про статус GRBL." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "Обмеження та пошук дому" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "Налаштування м'яких/жорстких обмежень та циклу пошуку дому." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "Шпиндель та лазер" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "Налаштування керування шпинделем або лазерним модулем." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "Калібрування осей" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "Визначає кроки на міліметр для кожної осі." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "Кінематика осей" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "Визначає максимальну швидкість та прискорення для кожної осі." + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "Хід осей" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "Визначає максимальну дистанцію ходу для кожної осі." + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "GRBL (Послідовний)" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "Сумісний з GRBL послідовний зв'язок" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "Перевизначення розміру буфера RX" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "" +"Примусовий конкретний розмір буфера RX у байтах. Встановіть 0 для " +"автоматичного визначення з пристрою." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "Невідомі налаштування" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "Налаштування, повідомлені пристроєм, відсутні в стандартному списку." + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "Невідоме налаштування від пристрою" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "" +"Пристрій налаштовано на звітування в дюймах ($13=1). Усі відображені " +"значення – у машинних одиницях." + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "" +"Лазерний режим не увімкнено ($32=0). Увімкніть його для найкращих " +"результатів з лазерними різаками." + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "GRBL (Serial Simple)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "" +"GRBL послідовний з простим протоколом ping-pong (без підрахунку буфера)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "Швидкість передачі має бути налаштована." + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "GRBL (Мережа)" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "Підключення до сумісного з GRBL пристрою через мережу" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "HTTP-порт" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "HTTP-порт для пристрою" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "WebSocket-порт" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "WebSocket-порт для пристрою" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "Варіант протоколу" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "Стандартний, ESP3D або Longer варіант GRBL" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "Стандартний" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "" +"Хост не налаштовано. Будь ласка, встановіть дійсну IP-адресу або ім'я хоста." + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "" +"Не вдалося підключитися до хоста '{host}'. Перевірте IP-адресу та мережеве " +"з'єднання." + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "Заборонена зона" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "Поза робочою зоною" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "Межі машини" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "{name} (діалект пристрою)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "• Калібрування камери: матрицю + спотворення знайдено" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "(не зіставлено жодного поля)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "Назва пристрою" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "Робоча область" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "Драйвер" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "Швидкість передачі" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "Пошук дому при запуску" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "Максимальна швидкість переміщення" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "Початок координат" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "Дзеркало X" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "Дзеркало Y" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "Калібрування камери" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "матрицю + спотворення імпортовано" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "Шпиндельна голівка" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "{label} (для {machine_name})" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "Лазерна головка" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "Типовий верстат" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "Ротаційний модуль" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "Голівка" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "Не вибрано драйвер для цього верстата." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "Драйвер '{driver}' не знайдено." + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "Виникла неочікувана помилка під час перевірки: {error}" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "GRBL Raster" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" +"Оптимізовано для растрового гравіювання GRBL. Підтримує динамічний режим " +"потужності M4 активним та використовує модальну швидкість подачі для " +"мінімізації навантаження на команду під час ліній сканування" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "Mach4 (M67 Аналоговий)" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" +"Mach4 з аналоговим виходом M67 для високошвидкісного растрового гравіювання. " +"Використовує M67 E0 Q<0-255> для потужності лазера замість рядкових команд " +"S, зменшуючи тиск буфера на контролер." + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "Smoothieware" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "Діалект G-коду для контролерів на базі Smoothieware" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "LinuxCNC" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "G-код для LinuxCNC з підтримкою власних кубічних кривих Безьє (G5)" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "GRBL Динамічний" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "" +"GRBL з динамічною потужністю M4 (режим з урахуванням глибини). Параметр S " +"включено в команди переміщення" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "Загальна інформація" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "Мітка" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "Назва для користувача" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "Опис" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "Короткий опис" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "Опускати незмінені координати" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "" +"Коли увімкнено, літери осей, що не змінилися, опускаються в командах G0/G1" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "Безперервний режим лазера" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" +"Підтримує динамічний режим M4 активним під час растрового гравіювання " +"замість перемикання M4/M5 між кожним сегментом" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "Модальна швидкість подачі" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "" +"Включати параметр F швидкості подачі в командах переміщення лише коли він " +"змінюється від попереднього значення" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "Шаблони команд" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "Лазер увімкнено" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "Лазер вимкнено" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "Фокусувати лазер" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "Переміщення без різання" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "Лінійне переміщення" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "Дуга (за годинниковою стрілкою)" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "Дуга (проти годинникової стрілки)" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "Кубічна крива Безьє" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "Зміна інструменту" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "Встановити швидкість" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "Повітря увімкнено" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "Повітря вимкнено" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "Повернути все" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "Повернути вісь" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "Перемістити до" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "Очистити тривогу" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "Встановити зміщення WCS" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "Цикл зондування" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "Затримка" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "Шпиндель увімкнено (CW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "Шпиндель увімкнено (CCW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "Шпиндель вимкнено" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "Охолоджувач заливний" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "Охолоджувач туманний" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "Охолоджувач вимкнено" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "Скрипти" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "Вставити WCS після преамбули" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" +"Вставити активну команду WCS (наприклад, G54) після скрипту преамбули. Якщо " +"вимкнено, ви можете використовувати {machine.active_wcs} у преамбулі." + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "Преамбула" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "Скрипт преамбули" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "Постскриптум" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "Скрипт постскриптуму" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "Marlin" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "G-код для контролерів на базі Marlin, поширений у 3D-принтерах" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "Grbl (Сумісний)" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "" +"Діалект Grbl з найвищою сумісністю для більшості діодних лазерів та хобі-ЧПК" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "Початок шару" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "Кінець шару" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "Початок заготовки" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "Кінець заготовки" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "Перед обробкою шару" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "Після обробки шару" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "Перед обробкою заготовки" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "Після обробки заготовки" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "Безіменний макрос" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "{job_name} не вдався: {error}" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" +"Не вдалося отримати список послідовних портів через обмеження Snap! " +"Переконайтеся, що пристрій підключено через USB, і виконайте:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" +"Послідовні порти знайдено, але жоден недоступний. Переконайтеся, що ваш Snap " +"підключено до інтерфейсу 'serial-port', виконавши:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "Підключення" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "Підключено" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "Помилка" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "Закриття" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "Відключено" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "Сон" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "Верстати" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "Налаштовані верстати" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "Додати або видалити верстати." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "Цей верстат має недійсну конфігурацію." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "Це активний верстат." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "Видалити ‘{name}’?" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Цей профіль верстата та всі його налаштування будуть остаточно видалені. Цю " +"дію неможливо скасувати." + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "Скасувати" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "Видалити" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "Додати верстат" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "Ліцензії" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "" +"Прив'яжіть свій обліковий запис Patreon для раннього доступу до нових " +"доповнень." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "Обліковий запис Patreon прив'язано" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "Доповнення з раннім доступом розблоковано" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "Від'єднати" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "Прив'язати обліковий запис Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "Отримати ранній доступ до преміум доповнень" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "Прив'язати" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "Ліцензії доповнень" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "Керуйте вашими придбаними ліцензійними ключами." + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "Ліцензії не встановлено" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "" +"Купіть преміум доповнення та введіть ліцензійний ключ під час встановлення." + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "{addons} (+{count} ще)" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "ID товару: {id}" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "Видалити" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "Доповнення, що потребують ліцензії" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "Ці доповнення потребують дійсної ліцензії для активації" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "Потрібна ліцензія" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "Купити" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "Видалити ліцензію?" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "" +"Цей ліцензійний ключ буде видалено. Можливо, вам доведеться ввести його " +"знову, щоб використовувати ліцензовані доповнення." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "Провайдер за замовчуванням" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "Увімкнути або вимкнути цього провайдера" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "Встановити за замовчуванням" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "Додати провайдера" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "Провайдери не налаштовані" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "Новий провайдер" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "Видалити '{name}'?" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "Цей провайдер ШІ буде остаточно видалений. Цю дію неможливо скасувати." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "Назва" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "Тип провайдера" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "Сумісний з OpenAI" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "Базова URL-адреса" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "Модель за замовчуванням" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "Тест з'єднання" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "Перевірити, чи конфігурація провайдера працює" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "Редагувати провайдера" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "Налаштування провайдера" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "Тестування..." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "ШІ" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "Провайдери ШІ" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "" +"Налаштуйте провайдерів ШІ для використання додатками. Додатки можуть " +"використовувати цих провайдерів без необхідності мати власні ключі API." + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "Додатки" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "Встановлені додатки" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "Встановлення, оновлення та видалення додатків." + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "Рецепти" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "Керування збереженими рецептами для різних матеріалів та процесів." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "Редагувати правило кольору" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "Оновіть деталі правила кольору:" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "Зберегти" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "Додати правило кольору" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "Зіставте колір із типом кроку для імпорту SVG." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "Додати" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "Колір" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "Колір SVG, що запускає це правило" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "Позначка (необов'язково)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "Тип кроку" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "Тип кроку, створений під час імпорту цього кольору" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "Колір {color}" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "Цей тип кроку наразі недоступний." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "{step_type} (недоступно)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "Правила кольору не знайдено." + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "Видалити правило кольору '{color}'?" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "Правило кольору буде остаточно видалено. Цю дію неможливо скасувати." + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "Правила кольору" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "" +"Зіставте кольори SVG із типами кроків, щоб вони застосовувалися " +"автоматичнопід час імпорту." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "Матеріали" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "Бібліотеки матеріалів" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "" +"Керування бібліотеками матеріалів. Виберіть бібліотеку для перегляду " +"матеріалів." + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "Матеріали у вибраній бібліотеці." + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "Категорії" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "Англійська" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "Німецька" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "Іспанська" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "Французька" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "Португальська" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "Українська" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "Китайська (спрощена)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "Система" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "Світла" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "Темна" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "Нічого не відкривати" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "Відкрити останній проект" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "Відкрити конкретний проект" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "Колір лазера" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "Колір шару" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "Системна за замовчуванням" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "Загальні" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "Вигляд" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "Налаштування, пов'язані з виглядом програми." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "Тема" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "Мова" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "Мова програми. Зміни потребують перезапуску." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "Кольори операцій" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "Виберіть, чи кольори операцій представляють лазер чи шар" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "Одиниці" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "Встановити одиниці відображення для різних значень у програмі." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "Довжина" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "Швидкість" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "Прискорення" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "Поведінка" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "Налаштування розширеного поведінки програми." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "Автоматичне оновлення операцій" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "" +"Автоматично перераховувати операції після кожної зміни. Вимкніть для ручного " +"перерахунку через кнопку на панелі інструментів" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "Бюджет кешу (МБ)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "" +"Максимальна пам'ять для кешу. Сцени високої складності потребують більше" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "Перевірити оновлення" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Automatically check for new Rayforge versions on startup" +msgstr "Автоматично перевіряти наявність нових версій Rayforge при запуску" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Startup behavior" +msgstr "Поведінка при запуску" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "Шлях до проекту" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "Огляд..." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "Приватність" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "" +"Допоможіть нам покращити Rayforge, дозволивши анонімні звіти про " +"використання. Персональні дані не збираються." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "Повідомити про анонімне використання" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "Допомогти покращити Rayforge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" +"Дізнатися " +"більше про відстеження використання та приватність." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "Потрібен перезапуск" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "" +"Мова набуде чинності після перезапуску Rayforge. Бажаєте перезапустити зараз?" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "_Скасувати" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "_Перезапустити" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "Копії зберігають свої оригінальні шари." + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "_Застосувати" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "Решітка" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "Решітка" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "Рядки" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "Стовпці" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "Зміщення" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "Проміжок" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "Відстань" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "Зміщення від центру до центру; проміжок від краю до краю." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "Відстань між стовпцями" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "Відстань між рядками" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "Масив ротації точок" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "Ротація точок" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "Обертає копії на місці навколо центру виділення." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "Кількість" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "Загальний кут (градуси)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "Круговий масив" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "Круговий" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "Розміщує копії вздовж дуги навколо центру." + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "Центр X" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "Центр Y" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "Радіус" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "Обернути копії" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "Перемістити вкладку" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "Перемістити на шар вгору" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "Перемістити на шар вниз" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "Згрупувати" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +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 +msgid "Add Tab Here" +msgstr "Додати вкладку тут" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "Видалити вкладку" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "Новий ескіз" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "Новий матеріал" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +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 "Paste" +msgstr "Вставити" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "Додати екземпляр {}" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "Перетягніть файли для імпорту" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "Зображення імпортовано з буфера обміну" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "Не вдалося імпортувати зображення з буфера обміну" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "3D-перегляд недоступний через відсутні залежності." + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "Виберіть верстат, щоб відкрити 3D-перегляд." + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "Додати матеріал" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "Автоматичне компонування (Просте)" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "{camera_name} - Калібрування об'єктива" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "Налаштування зображення камери" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "Налаштувати параметри якості та зовнішнього вигляду зображення." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "Типово" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "Довільне..." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "Роздільна здатність" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "" +"Роздільна здатність захоплення камери. За замовчуванням використовується " +"рідне налаштування камери." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "Довільна ширина" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "Довільна висота" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "Надавати перевагу формату YUYV" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" +"Використовувати нестиснений YUYV замість MJPEG. Усуває зелені артефакти на " +"деяких USB-камерах, але може зменшити роздільну здатність або частоту кадрів " +"на USB 2.0." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "Автобаланс білого" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "Автоматично регулювати баланс білого" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "Баланс білого (Кельвін)" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "Колірна температура для точного відтворення кольорів" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "Контраст" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "Різниця між світлими та темними ділянками" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "Яскравість" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "Загальна світлість або темрява зображення" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "Зменшення шуму" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "Часове усереднення, вищі значення викликають розмиття" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "Прозорість" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "Прозорість на робочій поверхні" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "Будь ласка, виберіть доступну камеру" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "Будь ласка, виберіть налаштовану камеру" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "Вибрати камеру" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "Немає налаштованих камер." + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "Не вдалося завантажити зображення для ID пристрою: {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "Камера {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "Камери не знайдено." + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "Точка {n}" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "Видалити цю точку" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "Зсув пікселя:" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "Властивості камери" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "Налаштувати вибрану камеру." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "ID пристрою" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "Системний ідентифікатор камери" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "Відображуване ім'я цієї камери" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "Увімкнено" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "Увімкнути або вимкнути потік камери" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "Старт" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "Майстер камери" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "" +"Інтерактивне налаштування: параметри зображення, калібрування лінзи та " +"вирівнювання." + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "Налаштувати" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "Налаштування зображення" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "Налаштувати яскравість, контраст, баланс білого та шум" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "Калібрування об'єктива" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "Виправити спотворення об'єктива для пряміших ліній" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "Вирівнювання зображення" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "Калібрувати положення та перспективу камери" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "Калібрування об'єктива завершено" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "Калібрування об'єктива ще не виконано" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "Вирівнювання зображення завершено" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "" +"Вирівнювання зображення потрібно повторити після оновлення калібрування " +"об'єктива" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "Вирівнювання зображення ще не виконано" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "Очікування камери..." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" +"Виправлення спотворень лінзи для пряміших ліній. Оберіть спосіб калібрування " +"або пропустіть, якщо ваша лінза має незначні спотворення." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "Автоматично" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "Автоматичне калібрування" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "" +"Роздрукуйте калібрувальну картку та відзніміть її в кількох позиціях. " +"Майстер сам обчислить коефіцієнти спотворень." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "Вручну" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "Ручне калібрування" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "Введіть коефіцієнти радіальних і тангенціальних спотворень вручну." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "Пропустити" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "Калібрувальна картка" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "Інструкції" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "" +"Надрукуйте калібрувальну картку для виправлення дисторсії об'єктива. Розмір " +"картки має вміщуватися в огляд камери." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "Розмір картки" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "Налаштуйте для вашої робочої поверхні." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "Ширина" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "Ширина картки" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "Висота" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "Висота картки" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "Згенерований шаблон" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "Деталі про калібрувальний шаблон." + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "Розмір сітки" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "Розмір квадрата" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "Фізичний розмір" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "Зберегти як PDF" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "Експортувати калібрувальну картку для друку" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "Зберегти калібрувальну картку" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "Калібрувальну картку збережено" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "Захоплення кадрів" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" +"Захопіть картку в різних положеннях. Важливо: включіть кути та краї " +"зображення для точного виправлення дисторсії." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "Статус" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "Прогрес процесу захоплення калібрування." + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "Захоплені кадри" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "Виявлено кутів" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "Покриття" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "Не розпочато" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "Перемістіть картку для захоплення більше позицій" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "Прогрес захоплення" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "Захопити кадр" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "Очистити" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "Калібрувати" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "Добре" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "Обмежено — досяжте країв" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "Погано — досяжте всіх кутів" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "Калібрування не вдалося" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "Калібрування завершено" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" +"Помилка RMS: {rms:.4f} пікселів\n" +"Якість: {quality}\n" +"Використано кадрів: {frames}" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "Відхилити" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "Зберегти калібрування" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "{camera} - Майстер камери" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "Назад" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "Далі" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "Готово" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "Гаразд" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "Радіальний 1 (k1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "Радіальна дисторсія першого порядку" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "Радіальний 2 (k2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "Радіальна дисторсія другого порядку" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "Радіальна 3 (k3)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "Радіальна дисторсія третього порядку" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "Тангенціальний 1 (p1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "Тангенціальна дисторсія першого порядку" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "Тангенціальний 2 (p2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "Тангенціальна дисторсія другого порядку" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "" +"Виправлення спотворень лінзи для пряміших ліній. Налаштуйте коефіцієнти " +"вручну." + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "{camera_name} – Вирівнювання зображення" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "Зменшити (прокрутка вниз)" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "Вмістити у вікно" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "Збільшити (прокрутка вгору)" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "{camera_name} - Налаштування зображення камери" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "ID пристрою: {device_id}" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "Додати нову камеру" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "Камери не налаштовано" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "Покращення зображення" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "Зменшити шум та покращити стабільність зображення." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "" +"Часове усереднення. Вищі значення видаляють більше шуму, але викликають " +"розмиття." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" +"Випрямляти вигнуті лінії використовуючи радіальні (k1, k2) та тангенціальні " +"(p1, p2) параметри. Примітка: Значення зазвичай дуже малі." + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "Корекція дисторсії об'єктива (Риб'яче око)" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "Камера" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "Камери" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "Транслювати зображення камери безпосередньо на робочу поверхню." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" +"Натисніть на зображення, щоб додати опорні точки. Перетягуйте для " +"переміщення.\n" +"Прокручуйте для масштабування. Середня кнопка та перетягування для " +"панорамування.\n" +"Використовуйте клавіші стрілок для точного переміщення активної точки." + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "Скинути точки" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "Очистити всі точки" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "Застосувати" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "Додати новий макрос" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "Макроси не налаштовано" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "Новий макрос" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "Інструмент {tool_number}, {min_rpm}-{max_rpm} об/хв" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "" +"Інструмент {tool_number}, макс. потужність {max_power}, розмір плями {spot_x}" +"x{spot_y}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "Інструмент {tool_number}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "Додати нову голівку" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "Голівки не налаштовані" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "Потрібна щонайменше одна голівка" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "Шпиндель" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "Новий лазер" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "Новий шпиндель" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "3D-модель" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "Оберіть та налаштуйте 3D-модель для цієї голівки." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "Модель" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "Масштаб" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "Рівномірний коефіцієнт масштабування для моделі" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "Обертання X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "Градуси навколо осі X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "Обертання Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "Градуси навколо осі Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "Обертання Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "Градуси навколо осі Z" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "Немає" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "Властивості лазера" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "Налаштуйте вибрану лазерну голівку." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "" +"Налаштування широтно-імпульсної модуляції для контролю частоти та ширини " +"імпульсу." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "Обрамлення" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "Налаштування операції обведення контуру меж завдання." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "Номер інструменту" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "Номер інструменту G-коду (наприклад, T0, T1)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "Діод" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "CO₂" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "Волокно" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "Тип лазера" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "Тип лазерної трубки або діода" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "Макс. потужність" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "Максимальне значення потужності в GCode" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "Потужність фокусування" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "Значення потужності у відсотках для фокусування. 0 для вимкнення" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "Розмір плями X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "Розмір лазерної плями в напрямку X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "Розмір плями Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "Розмір лазерної плями в напрямку Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "Колір різання" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "Колір для операцій різання" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "Колір растру" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "Колір для операцій гравіювання/растру" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "Фокусна відстань" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "Відстань від лазерної голови до робочої поверхні (зсув Z)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "Частота ШІМ" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "Типова частота ШІМ у Гц" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "Макс. частота ШІМ" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "Максимальна підтримувана частота ШІМ у Гц" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "Типова ширина імпульсу в мкс" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "Мін. ширина імпульсу" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "Мінімальна ширина імпульсу в мкс" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "Макс. ширина імпульсу" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "Максимальна ширина імпульсу в мкс" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "Потужність обведення" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "Значення потужності у відсотках для обведення. 0 для вимкнення" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "Швидкість обведення" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "" +"Швидкість обведення контуру. Залиште 0, щоб використовувати максимальну " +"швидкість переміщення машини" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "Кількість повторень" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "Кількість разів для обведення контуру" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "Пауза на кутах" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "Тривалість паузи в секундах на кожному куті контуру. 0 для вимкнення" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "Властивості шпинделя" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "Налаштуйте вибрану шпиндельну голівку." + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "Мін. об/хв" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "Мінімальна швидкість шпинделя" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "Макс. об/хв" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "Максимальна швидкість шпинделя" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "Підтримка затоплювального охолодження" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "Охолодження, що подається на заготовку затопленням" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "Підтримка туманного охолодження" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "Охолодження, що подається на заготовку у вигляді туману" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "Голівки" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "" +"Ви можете налаштувати кілька лазерів або шпинделів, якщо ваша машина це " +"підтримує." + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "Додати машину" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "Створити машину" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "Не вдалося створити машину" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "Налаштування камери недоступне" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "Відкалібруйте цю камеру пізніше на сторінці налаштувань машини." + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "Показати детальний вивід (опитування статусу)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "Прямокутник" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "Блок" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "Циліндр" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "Додати зону" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "Немає налаштованих заборонених зон" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "Нова зона" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "Заборонені зони" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" +"Визначте обмежені ділянки на робочій поверхні. Попередження буде показано " +"перед виконанням або експортом завдання, траєкторія інструменту якого " +"входить у будь-яку увімкнену заборонену зону." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "Властивості зони" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "Налаштуйте вибрану зону." + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "Форма" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "Геометрична форма зони" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "X" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "Позиція X у {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "Y" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "Позиція Y у {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "Z" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "Позиція Z у {wcs}" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "Глибина" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "Глибина (протяжність по Z)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "Радіус циліндра" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "Висота циліндра" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "Висота циліндра" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "Екрановані дужки {{ або }} не підтримуються." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "Вкладені дужки не дозволені." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "Знайдено незакриту дужку '}'." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "Знайдено невідкриту дужку '{'." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "Порожні дужки '{}' не дозволені." + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "Непідтримувані змінні: {vars}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "Редагувати діалект: {label}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "Новий діалект" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "Оновити з шаблону" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "Мітка не може бути порожньою." + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "" +"Виберіть шаблон, щоб скопіювати його налаштування. Ваша мітка та опис будуть " +"збережені." + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "Хуки G-коду" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "Додати власний G-код для виконання у певних точках завдання." + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "Відновити за замовчуванням" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "Відновити '{hook_name}' за замовчуванням?" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" +"Це видалить ваш власний G-код для цього хуку. Верстат повернеться до " +"використання вбудованого макросу за замовчуванням. Цю дію неможливо " +"скасувати." + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "Скинути" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "# Ваш G-код тут" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "Архіви профілів пристроїв" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "Профілі пристроїв LightBurn" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "Усі файли" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "Імпортувати профіль пристрою" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "Редагувати макрос" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "Вставити змінну" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "Включити макрос" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "Редагувати макрос для {name}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "Доступні змінні" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "Немає інших макросів для включення." + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "Назва не може бути порожньою." + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "Назва містить неприпустимі символи: {chars}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "Ця назва вже використовується іншим макросом." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "Редагувати робочі зміщення" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "" +"Введіть зміщення від машинного нуля до робочого нуля для активного WCS." + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "Зсув X" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "Зсув Y" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "Зсув Z" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "Скинути лічильник" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "Редагувати лічильник" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "Видалити лічильник" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "Скинути лічильник?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "Це скине накопичені години до нуля." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "Видалити лічильник?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "" +"Ви впевнені, що хочете видалити цей лічильник? Цю дію неможливо скасувати." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "Додати лічильник" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "Лічильники не налаштовані" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "Новий лічильник" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "Інтервал сповіщень" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "" +"Показувати сповіщення, коли лічильник досягає цього значення (годин). " +"Встановіть 0, щоб вимкнути." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "Технічне обслуговування" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "Загальні години" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "Накопичений час роботи, що відстежується верстатом." + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "Загальний час роботи" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "Накопичений час роботи верстата" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "Скинути загальні години" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "Лічильники технічного обслуговування" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "" +"Відстежуйте інтервали обслуговування за допомогою лічильників, що " +"скидаються. Використовуйте для лазерних трубок, змащування тощо." + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "{time} загалом" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "Скинути загальні години?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "" +"Це скине загальний накопичений час роботи до нуля. Лічильники технічного " +"обслуговування не будуть змінені." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "Пристрій" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "Налаштування пристрою" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "Зчитайте або застосуйте налаштування безпосередньо до пристрою." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "Зчитати з пристрою" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "Поточний драйвер не підтримує зчитування налаштувань пристрою." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "Копіювати деталі помилки" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "Закрити помилку" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "" +"Редагування цих значень може бути небезпечним і може вивести ваш верстат з " +"ладу!" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "" +"Пристрій може перезавантажитися або тимчасово від'єднатися після зміни " +"налаштування." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "Закрити попередження" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "Натисніть кнопку оновлення, щоб завантажити налаштування з пристрою." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "Операція не вдалася" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "Верстат не підключено" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "Верстат не підключено." + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "Налаштування успішно застосовано." + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "Не вдалося підключитися: Використовується '{machine}'" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "Верстат активовано." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "Імпортувати профіль LightBurn?" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" +"Профілі пристроїв LightBurn містять лише основні налаштування машини. " +"Імпортований профіль може бути неповним. Після імпорту перегляньте та " +"налаштуйте додаткові параметри, такі як лазерні головки, пошук дому, кінцеві " +"вимикачі, діалект G-code, макроси та поворотні модулі." + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "Все одно імпортувати" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "Наступні значення буде імпортовано:" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "Хуки та макроси" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "Макроси" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "Створюйте та керуйте багаторазовими фрагментами G-коду." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "Додатково" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "Обробка шляху" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "Налаштуйте, як обробляються та оптимізуються шляхи." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "Підтримка дуг" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "" +"Генерувати команди дуг для плавніших шляхів. Вимкніть, якщо ваш верстат не " +"підтримує дуги" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "Підтримка кривих Безьє" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "" +"Генерувати власні кубічні команди Безьє. Вимкніть, якщо ваша машина їх не " +"підтримує" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "Допуск дуг і кривих" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" +"Максимальне відхилення від початкового шляху під час підгонки дуг і " +"кривих.Нижчі значення значно збільшують час обробки та розмір завдання." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "Паркування та запуск" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "" +"Налаштуйте поведінку паркування та параметри запуску, включаючи автоматичне " +"паркування та обробку тривог." + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "Паркування при запуску" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "Надіслати команду паркування при запуску програми" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "Дозволити паркування окремих осей" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "" +"Увімкнути елементи керування паркуванням окремих осей у діалозі переміщення" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "Очистити тривогу при підключенні" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "" +"Автоматично надсилати команду розблокування, якщо підключено у стані ТРИВОГИ" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "Вибрати цей діалект" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "Видалити '{label}'?" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "" +"Цей користувацький діалект буде остаточно видалено. Цю дію неможливо " +"скасувати." + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "Неможливо видалити діалект" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "Цей діалект все ще використовується наступними верстатами: {machines}" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "Створити з шаблону" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "Користувацькі діалекти не налаштовані" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "{label} (Копія)" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "Вибрати верстат" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "Вибрати активний верстат" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "Увімкнути/вимкнути лазер" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "Потужність" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "Потужність лазера у відсотках" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "Тривалість імпульсу в мкс" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "Тривалість" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "Секунди (0 = безперервно)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "Інструмент {tool_number}, макс. потужність {max_power}" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "{seconds:.1f} с залишилось" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "G-код" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "Точність" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "Налаштуйте числову точність виводу координат." + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "Точність G-коду" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "Кількість десяткових знаків для координат" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "Діалект" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "Виберіть, створюйте та керуйте визначеннями діалектів G-коду." + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "Перемістити на північний захід" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "Перемістити на північ" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "Перемістити на північний схід" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "Перемістити на захід (вліво)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "Перемістити на схід (вправо)" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "Перемістити на південний захід" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "Перемістити на південь" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "Перемістити на південний схід" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "Паркування X" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "Паркування Y" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "Паркування Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "Надіслати на верстат" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "Збільшити відстань Z" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "Зменшити відстань Z" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "Скасувати поточне завдання" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "Вибрати шаблон" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "Виберіть вбудований діалект як початкову точку." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "Обладнання" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "Осі" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "Налаштуйте межі осей та систему координат." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "Межа X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "Повний діапазон переміщення по осі X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "Межа Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "Повний діапазон переміщення по осі Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "Лівий нижній" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "Лівий верхній" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "Правий верхній" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "Правий нижній" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "Початок координат (0,0)" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "Фізичний кут, де координати дорівнюють нулю після паркування" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "Інвертувати напрямок осі X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "Робить значення координат від'ємними" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "Інвертувати напрямок осі Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "Інвертувати напрямок осі Z" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "" +"Увімкніть, якщо позитивна команда Z (наприклад, G0 Z10) переміщує головку " +"вниз" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "Робоча зона" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "Поля визначають непридатний простір навколо меж осей." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "Ліве поле" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "Непридатний простір від лівого краю" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "Верхнє поле" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "Непридатний простір від верхнього краю" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "Праве поле" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "Непридатний простір від правого краю" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "Нижнє поле" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "Непридатний простір від нижнього краю" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "Початок робочої зони є координатою нуль" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "" +"Вважає початок робочої зони координатою нуль. Приховує елементи керування " +"WCS та використовує поля робочої зони як зміщення." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "М'які обмеження" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "" +"Налаштовувані межі безпеки для переміщення. Залиште вимкненим, щоб " +"використовувати межі робочої поверхні." + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "Увімкнути власні м'які обмеження" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "Замінити межі робочої поверхні власними обмеженнями" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "X мін" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "Мінімальна координата X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "Y мін" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "Мінімальна координата Y" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "X макс" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "Максимальна координата X" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "Y макс" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "Максимальна координата Y" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "" +"Необов'язково. Налаштуйте будь-які камери, які ви хочете використовувати для " +"попереднього перегляду та вирівнювання." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" +"Налаштуйте камери зараз або зробіть це пізніше в налаштуваннях машини. " +"Майстер запам'ятовує, які V4L-пристрої ви позначили як 'увімкнені'; детальне " +"калібрування лінзи виконується на сторінці налаштувань камери." + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "Камери не виявлено" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "Ви можете додати камери пізніше в налаштуваннях машини." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "Вибір контролера" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "Яку плату контролера використовує ця машина?" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "Контролер" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" +"Оберіть сімейство прошивки / протоколів для цієї машини. Якщо не впевнені, " +"оберіть найближчий варіант — окремі налаштування можна уточнити пізніше." + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "Немає — лише експорт G-code" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "Немає фізичного контролера; експорт G-code у файл" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "Нова машина" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "" +"Необов'язково. Налаштуйте поворотний пристрій зараз або пропустіть цей крок, " +"щоб додати його пізніше з налаштувань машини." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "Модуль" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "Оберіть тип повороту, вісь, режим та геометрію." + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "Кулачки / патрон" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "Ролики" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "Тип повороту" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "Як утримується заготовка" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "Поворотна вісь" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "Яку вісь використовує поворотний пристрій" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "Справжня 4-та вісь (зберігає X/Y/Z)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "Заміна осі (замінює, наприклад, Y на A)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "Режим" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "Довжина на оберт" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "Автоматично отримується з GRBL $101/$103 під час зондування" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "Заготовка за замовчуванням Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "Макс. довжина заготовки" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "Ролик Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "Потрібно при використанні роликового поворотного пристрою" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "Реверсувати напрямок осі" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "Інвертувати напрямок обертання поворотного пристрою" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "—" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "Так" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "Ні" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "Метрична (мм)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "Імперська (дюйми)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "Перегляд та назва" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "Фінальна назва та перевірка перед створенням машини." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "Зручна назва для цієї машини." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "Назва машини" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "Підсумок" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "Попередження" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "Немає (лише експорт G-code)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "Невідомий драйвер: {}" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "З'єднання" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "Робоча зона X×Y" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "Система одиниць" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "Макс. швидкість переміщення" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "Макс. швидкість різання" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "Паркування при запуску" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "Поворотні модулі" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "" +"Драйвер не вибрано — ця машина лише експортуватиме G-code у файли; вона не " +"може виконувати завдання." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "Розміри робочої зони не встановлені або не є додатними." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "Для цієї машини не налаштована жодна голівка." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "Голівка #{n} схожа на лазер, але не має налаштування max_power." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "Голівка #{n} схожа на шпиндель, але не має налаштування max_rpm." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "Назва машини порожня." + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "Відсутня назва" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "Будь ласка, введіть назву." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "Виявити пристрій" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "" +"Підключіться до пристрою та прочитайте його конфігурацію, або пропустіть, " +"щоб ввести значення вручну." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "Зондування" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "" +"Автоматичне виявлення робочої зони машини, швидкостей та можливостей " +"прошивки шляхом читання її налаштувань через з'єднання." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "Зондувати зараз" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "Зондування…" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "Підключення до пристрою та читання налаштувань" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "Зондування не вдалося" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "Зондування успішне" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "Робочу зону та швидкості виявлено автоматично." + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "Повторити" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "Оберіть початкову точку для нової машини." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "Шаблони машин" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "" +"Оберіть вбудований профіль, щоб попередньо заповнити типові налаштування. " +"Вас все одно попросять ввести значення, пов'язані з підключенням." + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "Пошук пристроїв…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "Імпортувати з файлу…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "Пристрій не в списку" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "Помилка імпорту" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "AI-провайдер" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "" +"Налаштуйте AI-провайдера, щоб майстер міг попередньо заповнювати відомі " +"характеристики машин." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" +"Введіть сумісну з OpenAI адресу. Вона використовується лише для " +"автоматичного пошуку характеристик; ви також можете пропустити цей крок і " +"ввести значення вручну." + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "Провайдер за замовчуванням" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "Модель за замовчуванням (необов'язково)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "Робоча зона (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "Макс. швидкість різання" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "Початок координат" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "Тип голівки" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "Макс. потужність голівки (значення S)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "Макс. об/хв голівки" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "Мін. об/хв голівки" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "Розмір плями (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "Частота ШІМ (Гц)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "Фокусна відстань" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "AI-пошук характеристик" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "" +"Якщо ваша машина — відома комерційна модель, AI може попередньо заповнити " +"значення характеристик з документації виробника." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "Виробник & модель" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" +"Введіть виробника та назву моделі машини. Що конкретніше, то краще — " +"наприклад, \"Sculpfun\" / \"S30 Pro\"." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "Виробник (наприклад, Sculpfun)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "Модель (наприклад, S30 Pro)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "Пошук характеристик" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "Пропозиції" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "" +"Запропоновані значення ввімкнено; вимкніть ті, які не хочете застосовувати." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" +"У Налаштуваннях не налаштовано AI-провайдера. Налаштуйте його, щоб увімкнути " +"автоматичний пошук характеристик, або пропустіть цей крок і введіть значення " +"вручну." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "Пошук…" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "Помилка пошуку" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "" +"AI не зміг повернути характеристики для цієї машини. Ви можете ввести " +"значення вручну на наступних кроках." + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "AI пропонує: {value}" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "Основна голівка" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "Введіть параметри з'єднання для вашого пристрою." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "" +"Введіть параметри з'єднання, необхідні вашій машині. Точні поля залежать від " +"контролера, який ви обрали на попередньому кроці." + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "Зафіксовано вибраним профілем" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "Неправильний ввід" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "Робоча зона, початок координат, швидкості та прискорення." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "Фізичний кут, де координати дорівнюють нулю після пошуку дому" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "Увімкніть, якщо +Z опускає голівку вниз" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "Перевизначити межі робочої поверхні власними обмеженнями" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "Швидкості" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "Межі в одиницях машини за хвилину." + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "Максимальна швидкість швидкого переміщення" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "Максимальна швидкість різання" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "" +"Використовується для оцінки часу та розрахунку відстані оверскану за " +"замовчуванням" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "Виконувати цикл паркування при підключенні машини" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "Паркування по одній осі" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "Дозволити паркування окремих осей" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "Що прикріплено до порталу: лазер, шпиндель чи обидва?" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "Тип голівки" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "Оберіть основну голівку для цієї машини." + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "Тип інструменту, прикріпленого до цієї машини" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "Назва голівки" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "Налаштування лазера" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "Макс. потужність (значення S)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "Максимальне значення потужності лазера в GCode" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "Ширина лазерного променя по осі X" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "Ширина лазерного променя по осі Y" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "Частота ШІМ (Гц)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "Частота модуляції лазера" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "Відстань від лінзи до заготовки" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "Заміна осі" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "Справжня 4-та вісь" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "{mode}, Вісь {axis}" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "Додати ротаційний модуль" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "Ротаційні модулі не налаштовано" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "Новий ротаційний модуль" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "Ротаційні стандартні значення" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "Стандартні налаштування для нових шарів." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "Увімкнути ротацію за замовчуванням" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "Нові шари за замовчуванням використовуватимуть ротаційний режим" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "Модулі" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "" +"Визначте фізичні ротаційні модулі, підключені до вашої машини. Виберіть один " +"як стандартний." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "Режим підключення" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "Як роторний модуль підключений до контролера машини" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "Вісь" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "Літера осі для цього модуля" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "Інвертована вісь" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "Змінити напрямок обертання роторної осі" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "Зміщення осі X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "Зміщення від позиції модуля до осі обертання (X)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "Зміщення осі Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "Зміщення від позиції модуля до осі обертання (Y)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "Зміщення осі Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "Зміщення від позиції модуля до осі обертання (Z)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "Кулачки / Патрон" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "Тип приводу" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "Як роторний модуль приводить в обертання деталь" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "Діаметр ролика" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "Діаметр приводного ролика" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "Переміщення за оберт" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "" +"Відстань прошивки для одного повного оберту на 360°. 0 = необроблений " +"коловий вихід." + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "Стандартний діаметр заготівлі" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "Стандартний діаметр для нових шарів, що використовують цей модуль" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "Максимальна довжина заготовки, яку може обслуговувати цей модуль" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "Позиція X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "Координата X у просторі машини" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "Позиція Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "Координата Y у просторі машини" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "Позиція Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "Координата Z у просторі машини" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "Можливості" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "Можливості машини" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" +"Можливості визначаються на основі голівок машини, поворотних модулів та будь-" +"якої явної конфігурації. Вони визначають, які кроки пропонуються під час " +"додавання до робочого процесу." + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "явна конфігурація" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "невідоме джерело" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "{machine_name} - Налаштування верстата" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "Налаштування верстата" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "Експортувати профіль машини" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "Повідомити про проблему" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "Експортовано до {path}" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "Помилка експорту: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "Верстат" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "Базова ідентифікація та налаштування верстата." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "Налаштування драйвера" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "Налаштування підключення та зв'язку для драйвера верстата." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "Виберіть драйвер" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "Швидкості та прискорення" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "" +"Параметри руху, що використовуються для оцінки часу завдання та оптимізації " +"шляху." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" +"Система одиниць, що використовується під час генерації G-коду та зв'язку " +"зпристроєм. Цей параметр не залежить від одиниць, які використовуються " +"вінтерфейсі користувача." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "Система одиниць машини" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "Потрібне налаштування: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "Помилка: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "Не підтримується драйвером" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" +"Преамбула містить G21 (міліметри), але систему одиниць машини встановленона " +"імперську. Значення G-коду будуть виведені в дюймах — переконайтеся, щоваша " +"преамбула відповідає." + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" +"Преамбула містить G20 (дюйми), але систему одиниць машини встановлено " +"наметричну. Значення G-коду будуть виведені в міліметрах — переконайтеся, " +"щоваша преамбула відповідає." + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "Перетягніть для зміни порядку" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "Видалити змінну" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "Ключ" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "Значення за замовчуванням" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "Початкове значення" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "Мінімальне значення" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "Кінцеве значення" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "Максимальне значення" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "Налаштувати значення" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "Налаштувати діапазон повзунка" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "Додати параметр" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "Новий параметр" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "Запитати доступ" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "API-ключ налаштовано" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "Запитати новий ключ" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "API-ключ не налаштовано" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "Ім'я хоста та порт повинні бути налаштовані спочатку" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "Пристрій недоступний або не підтримує автоматичні запити ключів" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "Неочікувана відповідь від пристрою" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "Забагато запитів. Спробуйте пізніше." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "Запит не вдався: {code}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "З'єднання не вдалося: {err}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "Очікування підтвердження на пристрої…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "Очікування…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "Час очікування підтвердження вичерпано. Будь ласка, спробуйте знову." + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "Запит відхилено або прострочено." + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "URL авторизації" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "URL токена" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "ID клієнта" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "Увійти" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "Вийти" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "Токен прострочений" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "Оновити" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "Автентифіковано" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "Повторна авторизація" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "Не підключено" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "Оновлення…" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "Нічого не вибрано" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "Непідтримуваний тип: {t}" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "Застосувати зміни" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "Реєстр додатків" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "Отримання реєстру..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "Встановити з URL..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "З'єднання не вдалося" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "Не вдалося зв'язатися з реєстром." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "Додатків не знайдено в реєстрі." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "Встановити" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "Оновити" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "Встановлено" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "Версія {v} вже встановлена" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "Несумісний" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "Потрібно {deps}, але поточна версія rayforge — {current}" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "Недоступний" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "Ручне встановлення" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "Введіть URL Git." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "Введіть ключ ліцензії" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "Ключ ліцензії" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "Активувати" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "Введіть ключ ліцензії, який ви отримали при купівлі {addon_name}." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "Будь ласка, введіть ключ ліцензії." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "Перевірка ліцензії..." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "Помилка перевірки ліцензії." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "Недійсна ліцензія" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "Потрібна ліцензія" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" +"{addon_name} — це преміум-доповнення. Купіть ліцензію, щоб розблокувати " +"його, або введіть свій ключ ліцензії, якщо він у вас вже є." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "Купити ліцензію" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "Не вдалося завантажити цей додаток" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "Цей додаток буде видалено з пам'яті після завершення активних завдань" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "Цей додаток несумісний з поточною версією Rayforge" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "" +"Цей додаток експериментальний і може мати невирішені проблеми. " +"Використовуйте з обережністю." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "Преміум-доповнення" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "Вбудований додаток" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "Видалити додаток" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "Увімкнути або вимкнути цей додаток" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "Встановити новий додаток..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "Не встановлено жодного додатка." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "Встановлення {name}..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "Не вдалося встановити додаток." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "Неможливо вимкнути додаток" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" +"Цей додаток неможливо вимкнути.\n" +"\n" +"{reason}" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "Додаток буде вимкнено після завершення активних завдань." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "Не вдалося вимкнути додаток. Перевірте журнали для отримання деталей." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "Не вдалося увімкнути додаток та його залежності." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "Увімкнути залежності?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" +"Цей додаток потребує: {deps}\n" +"\n" +"Увімкнути їх також?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "Увімкнути все" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "Не вдалося увімкнути додаток. Перевірте журнали для отримання деталей." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "Видалити {name}?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "" +"Файли додатка будуть видалені. Рекомендується перезавантажити для повного " +"очищення пам'яті." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "Видалити" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "Помилка при видаленні додатка." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "Інформація" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "Увімкнути експериментальний додаток?" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "" +"Додаток \"{name}\" експериментальний і може мати невирішені проблеми. " +"Використовуйте з обережністю." + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "Увімкнути в будь-якому разі" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "Допомогти покращити Rayforge" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" +"Бажаєте допомогти покращити Rayforge, дозволивши анонімні звіти про " +"використання? Це допоможе нам зрозуміти, як використовується програма, та " +"визначити пріоритети покращень.\n" +"\n" +"Персональні дані не збираються." + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "Ні, дякую" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "Дозволити звіти" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "Показати історію" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "Безіменна дія" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "Скасувати останню дію" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "Повторити останню дію" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "Перемкнути видимість робочої деталі" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "Перемкнути видимість вкладок" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "Перемкнути видимість зображення камери" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "Перемкнути видимість 3D-моделі" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "Перемкнути видимість сітки" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "Перемкнути видимість холостих переміщень" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "Перемкнути видимість забороненої зони" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "Немає параметрів" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "Показати всі параметри" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "Вибрати модель" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "Вибрати" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "Перевірка завдання" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "_Продовжити" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "{} помилка(ок)" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "{} попередження" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "Проблем не знайдено." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "" +"Знайдено {summary}. Продовження може призвести до пошкодження вашої машини " +"або деталі." + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "Помилки" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "Значення в {unit}" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "Новий" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "Відкрити..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "Зберегти як..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "Нещодавні файли" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "Імпортувати..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "Експортувати G-код..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "Експортувати документ..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "Вийти" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "_Файл" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "Скасувати" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "Повторити" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "Різання" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "Копіювати" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "Дублювати" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "Вибрати все" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "Очистити документ" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "_Редагування" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "Показати праву панель" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "Показати нижню панель" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "3D-вигляд" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "Вигляд зверху" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "Вигляд спереду" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "Правий вигляд" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "Лівий вигляд" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "Задній вигляд" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "Ізометричний вигляд" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "Перемкнути перспективу" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "_Вигляд" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "Розділити" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "Експортувати об'єкт..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "Додати рівновіддалені вкладки…" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "Додати кардинальні вкладки" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "Додати вкладки" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "_Об'єкт" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "Перемістити вибране на шар вище" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "Перемістити вибране на шар нижче" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "Ліворуч" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "Праворуч" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "Верх" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "Низ" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "Центрувати по горизонталі" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "Центрувати по вертикалі" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "Вирівняти" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "Розподілити по горизонталі" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "Розподілити по вертикалі" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "Розподілити" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "Віддзеркалити по горизонталі" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "Віддзеркалити по вертикалі" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "Віддзеркалити" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "Масив" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "Впорядкувати" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "_Інструменти" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "Рамка" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "Надіслати завдання" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "Призупинити / Відновити завдання" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "Скасувати завдання" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "_Верстат" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "Про програму" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "Підтримати" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "Зберегти журнал налагодження" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "_Допомога" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "(Немає останніх елементів)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "" +"Попередження про обслуговування: {name} досягнув свого ліміту ({curr} / " +"{limit})" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "Переглянути лічильники" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr " (+{tasks} ще)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "{tasks} завдань" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "Виберіть верстат, щоб увімкнути експорт G-коду" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "Генерувати G-код" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "Неможливо експортувати, поки виконуються інші завдання" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "" +"Конвеєр потребує перерахунку перед експортом. Натисніть F5 для перерахунку." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "Додайте робочу деталь, щоб увімкнути експорт" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "Додайте або увімкніть крок обробки, щоб увімкнути експорт" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "Налаштуйте потужність рамки, щоб увімкнути" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "Обійти лазерну голову навколо зайнятої області" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "" +"Конвеєр потребує перерахунку перед надсиланням. Натисніть F5 для перерахунку." + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "Відновити верстат" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "Призупинити верстат" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "Будь ласка, будь ласка, виберіть один об'єкт для експорту." + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "Журнал налагодження збережено до {path}" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "Відкрити проект" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "Імпортувати зображення" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "3D-вигляд вимкнено (відсутні залежності, такі як PyOpenGL)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "Показати 3D-перегляд" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "Перерахувати (Shift+Клік для примусу)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "Перемкнути нижню панель" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "Впорядкувати вибране" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "Додати кардинальні вкладки (Пн, Пд, Сх, Зх)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "Додати вкладки до вибраного" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "Повернути верстат у початкове положення" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "Очистити сигнал тривоги верстата (розблокувати)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "Перемкнути фокусний лазер" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "Верстат налаштовано не повністю" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "" +"Драйвер верстата не має необхідних налаштувань. Натисніть для редагування." + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "Центрувати по горизонталі" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "Центрувати по вертикалі" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "Вирівняти ліворуч" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "Вирівняти праворуч" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "Вирівняти вгорі" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "Вирівняти внизу" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "" +"Створити ZIP-архів з файлами журналів та системною інформацією для усунення " +"несправностей." + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "Включити поточний проект" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "Додати файл поточного проекту до архіву налагодження" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "_Зберегти" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "Не вдалося створити архів налагодження." + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "Помилка збереження файлу: {msg}" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "Сталася неочікувана помилка: {error}" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "Незбережені зміни" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "Поточний проект має незбережені зміни. Бажаєте зберегти їх?" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "_Не зберігати" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "Новий проект створено" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "Без назви" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "Додати ресурс" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "Додати ескіз" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "Створити нову деталь" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "Вирізати елемент(и)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "Вирізати елемент" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "Вставити елемент(и)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "Вставити елемент" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "Дублювати елемент(и)" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "Дублювати елемент" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "Відобразити на існуючі" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "Нові шари" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "Розгладити" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "Режим імпорту шару" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "Як імпортовані шари відображаються на шари документа" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "SVG-шари" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "Кольори" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "Джерело шару" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "Групувати імпортовану геометрію за SVG-шаром або кольором" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "Імпортувати зображення" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" +"Файл не дав вихідних даних у режимі прямих векторів. Файли, що містять текст " +"або інші неконтурні елементи, слід перетворити на контури перед " +"імпортуванням (наприклад, у Inkscape: Контур > Об'єкт у контур)." + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "Перейти в режим трасування" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "Повторно імпортувати" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "Імпортувати" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "Режим імпорту" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "Використати оригінальні вектори" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "Імпортувати векторні дані безпосередньо" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "DPI" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" +"Пікселів на дюйм для безрозмірних розмірів SVG. Inkscape ≥0.92 використовує " +"96, старіший Inkscape використовує 90, Illustrator використовує 72" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "Шари" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "Налаштування трасування" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "Імпортувати все зображення" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "Імпортувати все зображення без трасування" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "Авто поріг" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "Автоматично визначити поріг трасування" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "Поріг" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "Трасувати об'єкти темніші за це значення" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "Інвертувати" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "Трасувати світлі об'єкти на темному фоні" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "Вибрати шари" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "Шар порожній" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "Шар з {n} векторами" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "Генерування попереднього перегляду..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "Застосовність" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "" +"Визначте, коли пропонувати цей рецепт. Залиште поля порожніми для " +"відповідності будь-якому значенню." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "Будь-який" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "Типи кроків" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "" +"Типи кроків, до яких застосовується цей рецепт. Залиште порожнім, щоб " +"відповідати будь-якому типу кроку." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "Вибрати..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "Очистити вибір типів кроків" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "Матеріал" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "Очистити вибір матеріалу" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "Мін. товщина" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "Мінімальна товщина заготовки для застосування цього рецепта" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "Макс. товщина" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "Максимальна товщина заготовки для застосування цього рецепта" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "…" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "Не знайдено" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "Рецепт" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "" +"Іменований пресет налаштувань, який можна автоматично застосувати пізніше." + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "" +"Налаштування, які будуть застосовані цим рецептом. Коли вибрано кілька типів " +"кроків, показуються лише налаштування, спільні для всіх них." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "Постобробка" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" +"Налаштування трансформаторів, застосовані цим рецептом. Коли вибрано кілька " +"типів кроків, показуються лише трансформатори, спільні для всіх них." + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "Для цього кроку немає доступних опцій постобробки." + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "Редагувати рецепт" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "Додати новий рецепт" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "Невідома машина" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "Невідомий матеріал" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "Рецепти не знайдено." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "Рецепт буде остаточно видалено. Цю дію неможливо скасувати." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "Вибрати рецепт" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "Виберіть рецепт для застосування до поточного кроку." + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "Показати лише сумісні рецепти" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "Назва кроку та налаштування рецепта." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "Охолодження" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "Охолоджувач, який використовується під час виконання цієї операції." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "Змінити {key}" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "" +"Трансформатори, застосовані до згенерованого шляху інструмента цього кроку." + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "Швидкість швидких позиціонувальних рухів" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "Вимкнено" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "Заливний" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "Туманний" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "Охолоджувач, що подається на заготовку під час різання" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "Цей метод охолодження не підтримується поточною машиною" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "Швидкість операції різання" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "Налаштування {name}" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "Налаштування кроку" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "Вибрати..." + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "Ручні налаштування" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "Застосувати рецепт '{name}'" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "Застосувати трансформатор рецепта" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "Новий рецепт {label}" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "Встановити застосований рецепт" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "Оновити рецепт '{name}'?" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "" +"Це остаточно перезапише збережений рецепт поточними налаштуваннями кроку. Цю " +"дію неможливо скасувати." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "1 матеріал" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "{count} матеріалів" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "{count} (Тільки для читання)" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "Додати нову бібліотеку" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "Бібліотек не знайдено." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "" +"Папку бібліотеки та всі її матеріали буде остаточно видалено. Цю дію " +"неможливо скасувати." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "Редагувати бібліотеку" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "Введіть нову назву бібліотеки:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "Назва бібліотеки" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "Не вдалося перейменувати бібліотеку." + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "Введіть назву для нової папки бібліотеки:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +msgstr "" +"Не вдалося створити бібліотеку. Папка з такою назвою може вже існувати." + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Open File" +msgstr "Відкрити файл" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "Усі підтримувані" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "Зберегти G-code файл" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "G-code файли" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "Експортувати об'єкт" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "Експортувати документ" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "SVG (масштабована векторна графіка)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "DXF (формат обміну CAD)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "Відкрити проект {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "Проект {app_name}" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "Зберегти проект {app_name}" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "Редагувати матеріал" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "Оновіть деталі матеріалу:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "Додати новий матеріал" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "Введіть деталі нового матеріалу:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "Категорія" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "Власний" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "Додати новий шар" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "Властивості заготовки" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "Товщина" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "Товщина матеріалу" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "Ресурси" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "Переглядач G-code" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "Консоль" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "Елементи керування" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "Поточні зміщення" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "Редагувати зміщення вручну" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "Поточна позиція" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "Перемістити в нижній лівий кут вибраного або робочої області" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "Перемістити в центр вибраного або робочої області" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "Перемістити у верхній правий кут вибраного або робочої області" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "Перемістити до початку активного WCS" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "Обнулити осі" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "Встановити поточну позицію X як 0 для активного WCS" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "Встановити поточну позицію Y як 0 для активного WCS" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "Встановити поточну позицію Z як 0 для активного WCS" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "Встановити робочий нуль у поточній позиції" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "Натисніть на полотно, щоб встановити робочий нуль" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "Натисніть на полотно, щоб встановити робочий нуль" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "Швидкість переміщення" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "Відстань переміщення" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "Відстань в одиницях верстата" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "Перевизначено поточним шаром. Змініть у налаштуваннях шару." + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "Офлайн - позиція невідома" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "Зміщення не можна встановити в режимі машинних координат ({wcs})" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "Верстат має бути підключено для встановлення нуля тут" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "Встановити поточну позицію як 0" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "Вибрати типи кроків" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "Виберіть типи кроків, до яких застосовується цей рецепт." + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "Пошук..." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "Відсутні функції" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "Цей документ використовує функцію, яка недоступна: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "Цей документ використовує функції, які недоступні: {}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "Документ все ще можна редагувати та зберегти." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "_Гаразд" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "Вибрати матеріал" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "Виберіть матеріал з доступних бібліотек." + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "Немає операцій" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "Додати крок" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "Змінити порядок кроків" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "Додати крок '{name}'" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "Видалити крок '{name}'" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "Налаштування шару" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "Видалити цей шар" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "Перемкнути видимість шару" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "Відносно початку {wcs}" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "Нуль з лівого боку" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "Скинути позицію X до 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "Скинути позицію Y до 0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "Фіксоване співвідношення" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "Скинути до природної ширини" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "Скинути до природної висоти" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "Скинути до природного співвідношення сторін" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "Кут" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "За годинниковою стрілкою — додатно" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "Нахил" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "Кут горизонтального нахилу" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "Скинути кут до 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "Скинути нахил до 0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "Натуральний: {val}" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "Вихідний файл" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "Показати метадані зображення" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "Показати у файловому менеджері" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "Векторні команди" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "{count} команд" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "{name} (не знайдено)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "(Немає вихідного файлу)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "Табуляції" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "Видалити всі табуляції" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "Ширина табуляції" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "Довжина вздовж контуру" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "Скинути ширину табуляції до стандартної (1.0)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "{num_tabs} табуляцій" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "Змішані значення" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "Кількість табуляцій" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "Налаштувати рівновіддалені табуляції" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "Увімкнути {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "Перемкнути {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "Залишити без змін" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "Вимкнено" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "Ця функція недоступна." + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "Не вдалося знайти необхідний компонент '{}'. Документ можна зберегти." + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "Перемкнути видимість кроку" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "Метадані зображення" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "Копіювати метадані" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "Метадані недоступні" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "Основна інформація" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "Основні властивості зображення, такі як розміри та формат." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "Метадані" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "Усі метадані, витягнуті з зображення." + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "Метадані скопійовано до буфера обміну" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "Властивості елемента" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "1 елемент вибрано" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "{count} елементів вибрано" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "Кілька елементів" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "Властивості робочої деталі" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "Властивості групи" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "{name} - Налаштування" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "Закрити" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "" +"Основні налаштування шару, такі як зовнішній вигляд та система координат." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "Колір, використовуваний для операцій у цьому шарі" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "Система координат" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "" +"Початок системи робочих координат для цього шару. Типово використовувати " +"WCS, вибраний у головному вікні" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "Ротаційна насадка" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" +"Налаштуйте ротаційну насадку для циліндричних об'єктів. Коли увімкнено, рухи " +"по осі Y перетворюються на обертальні рухи в градусах." + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "Увімкнути ротаційний режим" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "Перетворити вісь Y на ротаційну вісь" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "Виберіть ротаційний модуль для цього шару" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "Діаметр об'єкта" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "Діаметр циліндричного об'єкта" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "У вибраній бібліотеці немає матеріалів." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "Неможливо видалити матеріал" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "" +"Цей матеріал зараз використовується одним або кількома рецептами. Будь " +"ласка, видаліть рецепти, що використовують цей матеріал, перед його " +"видаленням." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "" +"Матеріал буде остаточно видалено з бібліотеки. Цю дію неможливо скасувати." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "Не вдалося оновити матеріал." + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "Не вдалося додати матеріал до бібліотеки." + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "Пакетний імпорт {file_count} зображень" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" +"Імпортувати {file_count} зображень:\n" +"{file_names}\n" +"\n" +"Усі зображення будуть трасовані з типовими налаштуваннями трасування та " +"розміщені у місці перетягування." + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "Імпортувати все" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "Додати новий крок..." + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "{count} крок" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "{count} кроків" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "Відтворити симуляцію" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "Крок назад" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "Крок уперед" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "Швидкість відтворення" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "Призупинити симуляцію" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "Не знайдено" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "Інструментарій інтерфейсу" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "Графіка та зображення" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "Геометрія" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "Формати файлів та комунікація" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "Веб-сайт" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "Повідомити про проблему" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "Версія" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "Копіювати версію" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "Головний розробник" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "Ліцензія" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "Системна інформація" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "Версії бібліотек та компонентів" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "Копіювати системну інформацію" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "Прихильники" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "Люди, які пожертвували на проект" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "" +"Особлива подяка всім, хто пожертвував на підтримку Rayforge! Ви забезпечуєте " +"нас кавою та AI-токенами!" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "Про {app_name}" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "мм/хв" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "мм/с" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "дюйм/хв" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "дюйм/с" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "мм" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "см" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "м" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "дюйм" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "фут" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "мм/с²" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "см/с²" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "м/с²" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "дюйм/с²" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "фут/с²" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "{size} Б" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "{size:.1f} КБ" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "{size:.1f} МБ" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "{:.0f}с" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "{}хв" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "{}год" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "{line_count:,} рядків · {size}" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "— Обрізано (показано перші 20,000 з {line_count:,} рядків) —" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "Перевірка оновлень додатків..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "Доступне оновлення для {name}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "Доступні оновлення для {name1} та {name2}." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "Доступні оновлення для {name1}, {name2} та ще {num}." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "Встановити все" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "Знайдено оновлення додатків." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "Додатки оновлені." + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "Встановлення оновлень додатків..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "Додаток успішно оновлено." +msgstr[1] "{num} додатків успішно оновлено." +msgstr[2] "{num} додатків успішно оновлено." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "{num_s} додатків оновлено, {num_f} не вдалося." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "Не вдалося оновити додаток." +msgstr[1] "Не вдалося оновити {num} додатків." +msgstr[2] "Не вдалося оновити {num} додатків." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "Завершено з {num_failed} помилками." + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "Всі оновлення додатків встановлено!" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "" +"Неможливо відкрити '{file}'. Необхідний необхідний додаток може бути " +"вимкнено." + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "Генератор G-коду для лазерних різаків." + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "Шляхи до одного або кількох вхідних файлів SVG або зображень." + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "" +"Примусовий імпорт як прямих векторів. Це типово для підтримуваних файлів." + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "" +"Примусовий імпорт через трасування растрового зображення файлу. " +"Переривається, якщо не підтримується." + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "Встановити рівень журналювання (типово: INFO)" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "" +"Вийти після імпорту документів та стабілізації редактора. Корисно для " +"тестування." + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "" +"Шлях до скрипта Python для виконання після повного завантаження головного " +"вікна. Корисно для автоматизації та тестування." + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "" +"Шлях до каталогу користувацької конфігурації. Корисно для тестування з " +"ізольованими конфігураціями." + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "Агрегування" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "{status} — {activity}" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "Агрегування завдання" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "Генерація машинного коду" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "Застосування перетворення машини" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "Обробка" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "Обробка '{workpiece}' — {step}" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "Збирання" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "Збирання '{step}'" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "грань за замовчуванням" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "Грань '{face}' не вдалося обробити: {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "Область {region} грані '{face}' не вдалося обробити: {detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "Попередження обробки: {detail}" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "Змінна потужність" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "Постійна потужність" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "Дизеринг" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "Множинні глибини" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "Змінне" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "Постійне" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "Багатопрохідний" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "(+{n} ще)" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "Відсутнє: {}" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "Цей трансформатор недоступний." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "Назва поточної активної системи координат (напр. 'G54')." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "Назва поточного профілю машини." + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "Ширина (вісь X) робочої зони машини." + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "Висота (вісь Y) робочої зони машини." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "Назва поточного файлу документа (якщо збережено)." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "Мінімальна координата X усього завдання." + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "Мінімальна координата Y усього завдання." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "Максимальна координата X усього завдання." + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "Максимальна координата Y усього завдання." + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "Зміщення X поточної активної WCS." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "Зміщення Y поточної активної WCS." + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "Зміщення Z поточної активної WCS." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "Назва поточного шару, що обробляється." + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "Назва поточної заготовки, що обробляється." + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "Позиція X заготовки." + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "Позиція Y заготовки." + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "Ширина заготовки." + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "Висота заготовки." + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "Трансформувати елемент(и)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "Перемістити елемент(и)" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "Змінити кут елемента" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "Змінити нахил елемента" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "Змінити розмір елемента(ів)" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "Оновити актив" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "Перейменувати ресурс" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "Видалити ресурс '{name}'" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "Видалити залежний елемент" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "Видалити визначення ресурсу" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "Перемкнути видимість ресурсу" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "Імпортувати {filename}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "Імпортування {filename}..." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "" +"Не вдалося імпортувати {filename}. Файл зображення може бути пошкодженим або " +"у непідтримуваному форматі." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "Помилка імпорту: Не створено жодного елемента з {filename}" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "Помилка імпорту." + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "Імпорт завершено!" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "" +"⚠️ Імпортований елемент був більший за робочу область і був зменшений для " +"відповідності." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "Експорт успішний: {name}" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "Об'єкт успішно експортовано." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "Не вдалося експортувати об'єкт: {error}" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "Неможливо експортувати: Документ не має геометрії." + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "Документ успішно експортовано." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "Не вдалося експортувати документ: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "Проект збережено: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "Помилка збереження: {error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "Файл не знайдено: {name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "" +"Цей проєкт використовує методи охолодження, які не підтримуються поточною " +"машиною: {methods}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "{count} ресурс(и) потребують вимкнені розширення" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "Неправильний формат файлу проекту" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "Помилка завантаження: {error}" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "Не вдалося розмістити наступні елементи: {item_names}" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "Перейменувати крок" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "Видалити матеріал" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "Заготовка {count}" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "Перемкнути видимість заготовки" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "Перейменувати ресурс заготовки" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "Змінити товщину заготовки" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "Змінити матеріал заготовки" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "Додати таб" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "Очистити таби" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "Перемкнути таби" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "Змінити ширину табу" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "Перемістити на інший шар" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "Шар" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "Перейменувати шар" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "Встановити активний шар" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "Видалити шар '{name}'" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "Перевпорядкувати деталі" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "Змінити порядок елементів" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "Створити масив" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "Створити копію масиву" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "{count} ресурс(и) потребують вимкнене розширення '{addon}'" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "Групування елементів..." + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "Розгрупування елементів..." + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "Розділити елемент(и)" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "Видалити вихідний елемент" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "Додати фрагменти розбиття" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "Вставити елемент(и)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "Вставити елемент" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "Дублювати елемент(и)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "Дублювати елемент" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "Додати елемент" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "Видалити елемент" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "Видалити всі робочі деталі" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "Очистити елементи шару" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "Видалити контур(и)" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "Видалити сегмент(и)" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "Позиціювати в точці" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "Автоматичне компонування" + +#: rayforge/image/png/importer.py +msgid "Failed to scan PNG file: {}" +msgstr "Не вдалося сканувати файл PNG: {}" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Failed to process image data." +msgstr "Не вдалося обробити дані зображення." + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "Не вдалося завантажити зображення: {}" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "Не вдалося обчислити метадані SVG." + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "Не вдалося підготувати обрізані дані SVG." + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "SVG не містить геометрії або розмірів." + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "Не вдалося визначити допустимі розміри SVG." + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "Не вдалося визначити допустимі розміри для трасування." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "Не вдалося растеризувати SVG для трасування." + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "Не вдалося нормалізувати дані зображення." + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF file contains no pages." +msgstr "PDF-файл не містить сторінок." + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "Не вдалося прочитати PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "Неочікувана помилка під час сканування PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "Не вдалося обробити дані зображення PDF." + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "Не вдалося прочитати розміри сторінок PDF: {}" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "Сторінка PDF має нульові розміри" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "Не вдалося растеризувати PDF" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "PDF не містить векторної геометрії." + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "Не вдалося розібрати PDF: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "Файл LightBurn містить недійсний XML: {}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "Файл LightBurn пошкоджено або він недійсний: {}" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "Не вдалося розібрати заголовок BMP у {}" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "Не вдалося сканувати файл BMP: {}" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "Неприпустимі або непідтримувані дані BMP." + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "Не вдалося обробити зображення: {}" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "Файл не містить векторних команд." + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "Файл Ruida недійсний: {}" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "Неочікувана помилка під час сканування файлу Ruida: {}" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "Не вдалося розібрати команди Ruida: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "Структура файлу DXF недійсна: {}" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "Неочікувана помилка під час сканування DXF: {}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "Файл DXF пошкоджено або він недійсний: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "Не вдалося розрахувати параметри: {}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "Не вдалося виконати генератор: {}" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "Не вдалося сканувати файл JPEG: {}" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "Floyd Steinberg" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po new file mode 100644 index 000000000..c8e01eb48 --- /dev/null +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -0,0 +1,8520 @@ +# Chinese (Simplified) translations for Rayforge package. +# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the Rayforge package. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Rayforge\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-12 17:33+0200\n" +"PO-Revision-Date: 2025-07-13 11:49+0200\n" +"Last-Translator: FULL NAME \n" +"Language-Team: Chinese (Simplified)\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: rayforge/updater.py +msgid "Checking for Rayforge updates..." +msgstr "正在检查 Rayforge 更新..." + +#: rayforge/updater.py rayforge/addon_mgr/update_cmd.py +msgid "Update check failed." +msgstr "更新检查失败。" + +#: rayforge/updater.py +#, python-brace-format +msgid "Rayforge {version} is available." +msgstr "Rayforge {version} 可用。" + +#: rayforge/updater.py +msgid "Download" +msgstr "下载" + +#: rayforge/updater.py +msgid "New version available." +msgstr "有新版本可用。" + +#: rayforge/updater.py +msgid "Rayforge is up to date." +msgstr "Rayforge 已是最新版本。" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "{name} Workflow" +msgstr "{name} 工作流" + +#: rayforge/core/layer.py +msgid "Flat" +msgstr "平面" + +#: rayforge/core/layer.py +#, python-brace-format +msgid "Rotary · {name}" +msgstr "旋转 · {name}" + +#: rayforge/core/layer.py rayforge/core/capability.py +msgid "Rotary" +msgstr "旋转轴" + +#: rayforge/core/doc.py +msgid "Layer {}" +msgstr "图层 {}" + +#: rayforge/core/stock.py +#, python-brace-format +msgid "{name} (copy)" +msgstr "{name} (副本)" + +#: rayforge/core/ai/provider.py +msgid "Bad request" +msgstr "错误的请求" + +#: rayforge/core/ai/provider.py +msgid "Authentication failed - please check your API key" +msgstr "身份验证失败 - 请检查您的API密钥" + +#: rayforge/core/ai/provider.py +msgid "Access forbidden - please check your API key permissions" +msgstr "访问被拒绝 - 请检查您的API密钥权限" + +#: rayforge/core/ai/provider.py +msgid "API endpoint not found - please check the base URL" +msgstr "未找到API端点 - 请检查基础URL" + +#: rayforge/core/ai/provider.py +msgid "Rate limited - please wait and try again" +msgstr "请求频率受限 - 请等待后重试" + +#: rayforge/core/ai/provider.py +msgid "Server error - please try again later" +msgstr "服务器错误 - 请稍后重试" + +#: rayforge/core/ai/provider.py +msgid "Service unavailable - please try again later" +msgstr "服务不可用 - 请稍后重试" + +#: rayforge/core/ai/provider.py +#, python-brace-format +msgid "Server returned error {code}" +msgstr "服务器返回错误 {code}" + +#: rayforge/core/ai/openai_provider.py +msgid "Connection failed - please check your network" +msgstr "连接失败 - 请检查您的网络" + +#: rayforge/core/ai/openai_provider.py +#, python-brace-format +msgid "Model '{model}' not found. Available: {available}" +msgstr "未找到模型'{model}'。可用模型:{available}" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Cut Speed" +msgstr "切割速度" + +#: rayforge/core/step.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Travel Speed" +msgstr "移动速度" + +#: rayforge/core/step.py rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/settings/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Settings" +msgstr "设置" + +#: rayforge/core/varset/choicevar.py +msgid "Choice" +msgstr "选择" + +#: rayforge/core/varset/var.py +msgid "Text (Single Line)" +msgstr "文本 (单行)" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud rate cannot be empty." +msgstr "波特率不能为空。" + +#: rayforge/core/varset/baudratevar.py +#, python-brace-format +msgid "'{rate}' is not a standard baud rate." +msgstr "'{rate}' 不是标准波特率。" + +#: rayforge/core/varset/baudratevar.py +msgid "Baud Rate" +msgstr "波特率" + +#: rayforge/core/varset/baudratevar.py +msgid "Connection speed in bits per second" +msgstr "连接速度(位/秒)" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname or IP address cannot be empty." +msgstr "主机名或IP地址不能为空。" + +#: rayforge/core/varset/hostnamevar.py +msgid "Invalid hostname or IP address format." +msgstr "无效的主机名或IP地址格式。" + +#: rayforge/core/varset/hostnamevar.py +msgid "Hostname / IP" +msgstr "主机名 / IP" + +#: rayforge/core/varset/intvar.py +msgid "Integer" +msgstr "整数" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at least {min_val}." +msgstr "值必须至少为 {min_val}。" + +#: rayforge/core/varset/intvar.py rayforge/core/varset/floatvar.py +#, python-brace-format +msgid "Value must be at most {max_val}." +msgstr "值最多为 {max_val}。" + +#: rayforge/core/varset/portvar.py +msgid "Port cannot be empty." +msgstr "端口不能为空。" + +#: rayforge/core/varset/portvar.py +msgid "Port must be a number." +msgstr "端口必须是数字。" + +#: rayforge/core/varset/floatvar.py +msgid "Floating Point" +msgstr "浮点数" + +#: rayforge/core/varset/floatvar.py +msgid "Slider (0-100%)" +msgstr "滑块 (0-100%)" + +#: rayforge/core/varset/textareavar.py +msgid "Text (Multi-Line)" +msgstr "文本 (多行)" + +#: rayforge/core/varset/labeledchoicevar.py +msgid "Choice (Labeled)" +msgstr "选择(带标签)" + +#: rayforge/core/varset/boolvar.py +msgid "Boolean (Switch)" +msgstr "布尔值 (开关)" + +#: rayforge/core/varset/urlvar.py +msgid "URL cannot be empty." +msgstr "URL不能为空。" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a scheme (e.g., 'http://')." +msgstr "URL必须包含协议(例如'http://')。" + +#: rayforge/core/varset/urlvar.py +msgid "URL must include a hostname." +msgstr "URL必须包含主机名。" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "URL scheme must be one of: {schemes}." +msgstr "URL协议必须是以下之一:{schemes}。" + +#: rayforge/core/varset/urlvar.py +#, python-brace-format +msgid "Invalid URL: {error}" +msgstr "无效的URL:{error}" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial port cannot be empty." +msgstr "串口不能为空。" + +#: rayforge/core/varset/serialportvar.py +msgid "Serial Port" +msgstr "串口" + +#: rayforge/core/cut_side.py +msgid "Centerline" +msgstr "中心线" + +#: rayforge/core/cut_side.py +msgid "Inside" +msgstr "内部" + +#: rayforge/core/cut_side.py +msgid "Outside" +msgstr "外部" + +#: rayforge/core/cut_side.py +msgid "Inside-Outside" +msgstr "内-外" + +#: rayforge/core/cut_side.py +msgid "Outside-Inside" +msgstr "外-内" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Laser" +msgstr "激光" + +#: rayforge/core/capability.py +msgid "Mill" +msgstr "铣削" + +#: rayforge/core/capability.py rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM" +msgstr "PWM" + +#: rayforge/core/capability.py +msgid "Cutting and engraving with a laser" +msgstr "使用激光进行切割和雕刻" + +#: rayforge/core/capability.py +msgid "Milling and routing with a spindle" +msgstr "使用主轴进行铣削和雕刻" + +#: rayforge/core/capability.py +msgid "Pulse-width-modulated laser power control" +msgstr "脉宽调制激光功率控制" + +#: rayforge/core/capability.py +msgid "Rotary axis attachment for cylindrical objects" +msgstr "用于圆柱形物体的旋转轴附件" + +#: rayforge/core/model_manager.py +msgid "Core" +msgstr "核心" + +#: rayforge/core/stock_asset.py +msgid "Stock Material" +msgstr "原材料" + +#: rayforge/core/source_asset.py +msgid "Source" +msgstr "来源" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Syntax Error: {message}" +msgstr "语法错误:{message}" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Unknown variable or function: '{name}'" +msgstr "未知的变量或函数:'{name}'" + +#: rayforge/core/expression/errors.py +#, python-brace-format +msgid "Cannot use operator '{op}' between types '{left}' and '{right}'" +msgstr "不能在类型 '{left}' 和 '{right}' 之间使用运算符 '{op}'" + +#: rayforge/machine/driver/dummy.py rayforge/ui_gtk/machine/machine_dropdown.py +#: rayforge/ui_gtk/machine/status_widget.py +msgid "No driver" +msgstr "无驱动" + +#: rayforge/machine/driver/dummy.py +msgid "No connection" +msgstr "无连接" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Machine Coordinates" +msgstr "机器坐标" + +#: rayforge/machine/driver/dummy.py +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No settings" +msgstr "无设置" + +#: rayforge/machine/driver/driver.py +#, python-brace-format +msgid "Resource '{resource}' is currently in use by '{owner}'." +msgstr "资源'{resource}'当前正被'{owner}'使用。" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver has not been tested. It may or may not work. Use it at your own " +"risk." +msgstr "此驱动程序未经测试。可能有效也可能无效。使用风险自负。" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "此驱动程序为实验性质,可能存在未解决的问题。请谨慎使用。" + +#: rayforge/machine/driver/driver.py +msgid "" +"This driver is experimental and almost certainly buggy. It may not work " +"reliably. Use it at your own risk." +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 +msgid "Unknown" +msgstr "未知" + +#: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Idle" +msgstr "空闲" + +#: rayforge/machine/driver/driver.py +msgid "Run" +msgstr "运行" + +#: rayforge/machine/driver/driver.py +msgid "Hold" +msgstr "暂停" + +#: rayforge/machine/driver/driver.py rayforge/machine/models/dialect/base.py +msgid "Jog" +msgstr "点动" + +#: rayforge/machine/driver/driver.py +msgid "Alarm" +msgstr "报警" + +#: rayforge/machine/driver/driver.py +msgid "Door" +msgstr "门" + +#: rayforge/machine/driver/driver.py +msgid "Check" +msgstr "检查" + +#: rayforge/machine/driver/driver.py rayforge/ui_gtk/main_menu.py +msgid "Home" +msgstr "回原点" + +#: rayforge/machine/driver/driver.py +msgid "Sleep" +msgstr "休眠" + +#: rayforge/machine/driver/driver.py +msgid "Tool" +msgstr "刀具" + +#: rayforge/machine/driver/driver.py +msgid "Queue" +msgstr "队列" + +#: rayforge/machine/driver/driver.py +msgid "Lock" +msgstr "锁定" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Unlock" +msgstr "解锁" + +#: rayforge/machine/driver/driver.py +msgid "Cycle" +msgstr "循环" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Test" +msgstr "测试" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Frequency" +msgstr "频率" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "PWM frequency in Hz" +msgstr "PWM频率(Hz)" + +#: rayforge/machine/driver/driver.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse Width" +msgstr "脉冲宽度" + +#: rayforge/machine/driver/driver.py +msgid "Pulse width in microseconds" +msgstr "脉冲宽度(微秒)" + +#: rayforge/machine/driver/driver.py +msgid "Error during setup. You may need to edit device settings." +msgstr "设置期间出错。您可能需要编辑设备设置。" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothie" +msgstr "Smoothie" + +#: rayforge/machine/driver/smoothie.py +msgid "Smoothieware via a Telnet connection" +msgstr "通过Telnet连接的Smoothieware" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Machine Coordinates (G53)" +msgstr "机器坐标(G53)" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "Invalid hostname or IP address: '{host}'" +msgstr "无效的主机名或IP地址:'{host}'" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname" +msgstr "主机名" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The IP address or hostname of the device" +msgstr "设备的IP地址或主机名" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port" +msgstr "端口" + +#: rayforge/machine/driver/smoothie.py +msgid "The Telnet port number" +msgstr "Telnet端口号" + +#: rayforge/machine/driver/smoothie.py +#: rayforge/machine/driver/ruida/ruida_driver.py +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Hostname must be configured." +msgstr "必须配置主机名。" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Ruida (UDP)" +msgstr "Ruida (UDP)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Connect to a Ruida laser controller over UDP" +msgstr "通过UDP连接到Ruida激光控制器" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The IP address or hostname of the Ruida controller" +msgstr "Ruida控制器的IP地址或主机名" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Main Port" +msgstr "主端口" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for main commands (default: 50200)" +msgstr "主命令的UDP端口(默认:50200)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "Jog Port" +msgstr "点动端口" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "The UDP port for jog commands (default: 50207)" +msgstr "点动命令的UDP端口(默认:50207)" + +#: rayforge/machine/driver/ruida/ruida_driver.py +msgid "No response from controller" +msgstr "控制器无响应" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint" +msgstr "OctoPrint" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Submit G-code to an OctoPrint server" +msgstr "将G代码提交到OctoPrint服务器" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "IP address or hostname of the OctoPrint server" +msgstr "OctoPrint服务器的IP地址或主机名" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "HTTP port of the OctoPrint server" +msgstr "OctoPrint服务器的HTTP端口" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API Key" +msgstr "API密钥" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Enter an API key manually or click 'Request Access' to obtain one via " +"OctoPrint's Application Keys plugin." +msgstr "" +"手动输入API密钥,或点击\"请求访问\"通过OctoPrint的Application Keys插件获取。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"API key must be configured. Use the 'Request Access' button or enter an API " +"key manually." +msgstr "必须配置API密钥。使用\"请求访问\"按钮或手动输入API密钥。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed. API key may be invalid or expired." +msgstr "身份验证失败。API密钥可能无效或已过期。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "" +"Could not connect to OctoPrint at '{host}:{port}'. Check the address and " +"network connection." +msgstr "无法连接到 '{host}:{port}' 上的OctoPrint。请检查地址和网络连接。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication Failed" +msgstr "身份验证失败" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"The API key is invalid or has expired. Please re-authenticate in device " +"settings." +msgstr "API密钥无效或已过期。请在设备设置中重新认证。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "OctoPrint returned no login data." +msgstr "OctoPrint未返回登录数据。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Unexpected WebSocket frame." +msgstr "意外的WebSocket帧。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Server closed WebSocket connection." +msgstr "服务器关闭了WebSocket连接。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Print Failed" +msgstr "打印失败" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint reported that the print job failed. Check OctoPrint for details." +msgstr "OctoPrint报告打印作业失败。请查看OctoPrint了解详情。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Driver not configured with a host." +msgstr "驱动未配置主机。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Authentication failed during upload." +msgstr "上传时身份验证失败。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "Printer is busy or not operational. Cannot start a new job." +msgstr "打印机忙碌或不可操作。无法启动新作业。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint accepted the file but could not start printing. The printer may " +"not be operational or is already busy." +msgstr "OctoPrint已接受文件但无法开始打印。打印机可能不可操作或已忙碌。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +#, python-brace-format +msgid "Could not upload file to OctoPrint at '{host}:{port}'." +msgstr "无法将文件上传到 '{host}:{port}' 的OctoPrint。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"OctoPrint does not support writing device firmware settings through its API." +msgstr "OctoPrint不支持通过其API写入设备固件设置。" + +#: rayforge/machine/driver/octoprint/octoprint_driver.py +msgid "" +"Probe command sent. OctoPrint does not report probe results via its API." +msgstr "探测命令已发送。OctoPrint不通过其API报告探测结果。" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin (Serial)" +msgstr "Marlin (串行)" + +#: rayforge/machine/driver/marlin/marlin_serial.py +msgid "Marlin firmware via serial connection" +msgstr "通过串行连接的Marlin固件" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Serial port for the device" +msgstr "设备的串口" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Port must be configured." +msgstr "必须配置端口。" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Baud rate must be configured." +msgstr "必须配置波特率。" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Port not configured" +msgstr "端口未配置" + +#: rayforge/machine/driver/marlin/marlin_serial.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "No response from device" +msgstr "设备无响应" + +#: rayforge/machine/driver/marlin/marlin_probe.py +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "Auto-configured via probe wizard" +msgstr "通过探测向导自动配置" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL (Telnet)" +msgstr "GRBL (Telnet)" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "GRBL-compatible controller over a raw TCP/telnet connection" +msgstr "通过原始TCP/telnet连接的GRBL兼容控制器" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +msgid "TCP port for the raw/telnet service" +msgstr "raw/telnet服务的TCP端口" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Poll device status during jobs" +msgstr "轮询设备作业状态" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Periodically query the device for position and status while a job is " +"running. Warning: Some devices have trouble maintaining a stable connection " +"if this is used!" +msgstr "" +"在作业运行时定期查询设备的位置和状态。警告:某些设备在使用此功能时可能无法保" +"持稳定连接!" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "Deadlock detection" +msgstr "死锁检测" + +#: rayforge/machine/driver/grbl/grbl_telnet.py +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Detect and recover from serial communication deadlocks during jobs. If " +"disabled, the driver will simply wait for the machine to respond. Disable if " +"you experience false ALARM:3 errors." +msgstr "" +"在作业期间检测并恢复串行通信死锁。如果禁用,驱动程序将仅等待机器响应。如果您" +"遇到错误的ALARM:3错误,请禁用此功能。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Command Letter" +msgstr "缺少命令字母" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G-code commands need a letter followed by a value. The command letter was " +"not found." +msgstr "G代码命令需要一个字母后跟一个值。未找到命令字母。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Number Format" +msgstr "无效的数字格式" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The value is missing or not in the correct numeric format. Check your G-code " +"syntax." +msgstr "值缺失或格式不正确。请检查您的G代码语法。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Command" +msgstr "未知命令" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This Grbl setting command is not recognized or supported. Check the command " +"syntax." +msgstr "此Grbl设置命令未被识别或支持。请检查命令语法。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Negative Value" +msgstr "负值" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "A positive number is required here, but a negative value was received." +msgstr "此处需要正数,但接收到负值。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Disabled" +msgstr "回原点已禁用" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing is not enabled in settings. Enable homing ($22=1) to use this feature." +msgstr "设置中未启用回原点。启用回原点($22=1)以使用此功能。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Pulse Time Too Short" +msgstr "脉冲时间太短" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Minimum step pulse time must be greater than 3 microseconds. Check setting " +"$0." +msgstr "最小步进脉冲时间必须大于3微秒。请检查设置$0。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Memory Error" +msgstr "内存错误" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Settings reset to defaults due to a memory read failure. Reconfigure your " +"settings if needed." +msgstr "由于内存读取失败,设置已重置为默认值。如需要,请重新配置您的设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Machine Busy" +msgstr "机器忙碌" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command can only be used when the machine is idle. Wait for the current " +"job to finish." +msgstr "此命令只能在机器空闲时使用。请等待当前作业完成。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Commands Locked" +msgstr "命令已锁定" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot send commands while in alarm or jog mode. Clear the alarm state first." +msgstr "在报警或点动模式下无法发送命令。请先清除报警状态。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Required" +msgstr "需要回原点" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Soft limits cannot be enabled without homing also enabled. Enable homing " +"first ($22=1)." +msgstr "不启用回原点就无法启用软限位。请先启用回原点($22=1)。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Too Long" +msgstr "行太长" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The command line has too many characters and was ignored. Check your file " +"formatting." +msgstr "命令行字符太多,已被忽略。请检查您的文件格式。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Setting Too High" +msgstr "设置值过高" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This setting exceeds the maximum step rate supported. Use a lower value." +msgstr "此设置超过了支持的最大步进速率。请使用较低的值。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Door Open" +msgstr "门已打开" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The safety door was detected as open. Close the door and resume operation." +msgstr "检测到安全门已打开。请关闭门并恢复操作。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Build info or startup line exceeds storage limit. Shorten the line." +msgstr "构建信息或启动行超出存储限制。请缩短该行。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Target Out of Range" +msgstr "目标超出范围" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog target is beyond the machine's travel limits. Move to a position within " +"range." +msgstr "点动目标超出了机器的行程限制。请移动到范围内的位置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Jog Command" +msgstr "无效的点动命令" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Jog command is missing '=' or contains prohibited G-code. Check the jog " +"syntax." +msgstr "点动命令缺少'='或包含禁止的G代码。请检查点动语法。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Laser Mode Error" +msgstr "激光模式错误" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Laser mode requires PWM output to work. Check your hardware configuration." +msgstr "激光模式需要PWM输出才能工作。请检查您的硬件配置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Not Running" +msgstr "主轴未运行" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A motion command was issued but the spindle is not running. Start the " +"spindle before motion." +msgstr "已发出运动命令,但主轴未运行。请在运动前启动主轴。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle Speed Mismatch" +msgstr "主轴速度不匹配" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The current spindle speed does not match the speed required by the command. " +"Wait for the spindle to reach the target speed." +msgstr "当前主轴速度与命令要求的速度不匹配。请等待主轴达到目标速度。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Command" +msgstr "不支持的命令" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This G-code command is not supported by the machine. Check your post-" +"processor settings." +msgstr "此G代码命令不被机器支持。请检查您的后处理器设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Conflicting Commands" +msgstr "冲突的命令" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Multiple commands from the same group found on one line. Remove the " +"duplicate command." +msgstr "在一行中发现了来自同一组的多个命令。请删除重复的命令。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Feed Rate Missing" +msgstr "缺少进给速率" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Set a feed rate before using motion commands. Add an F command to specify " +"speed." +msgstr "在使用运动命令之前设置进给速率。添加F命令以指定速度。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Integer Required" +msgstr "需要整数" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a whole number value. Remove any decimal points." +msgstr "此命令需要整数值。请删除任何小数点。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Conflict" +msgstr "轴冲突" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Multiple commands trying to use the same axis. Simplify the command." +msgstr "多个命令试图使用同一轴。请简化命令。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Duplicate Word" +msgstr "重复的字" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "The same G-code word appears more than once. Remove the duplicate." +msgstr "相同的G代码字出现多次。请删除重复项。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Axis" +msgstr "缺少轴" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"This command requires XYZ axis coordinates. Add the missing axis values." +msgstr "此命令需要XYZ轴坐标。请添加缺失的轴值。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line Number Out of Range" +msgstr "行号超出范围" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Line number must be between 1 and 9,999,999. Use a valid line number." +msgstr "行号必须在1到9,999,999之间。请使用有效的行号。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Value" +msgstr "缺少值" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "This command requires a P or L value. Add the missing parameter." +msgstr "此命令需要P或L值。请添加缺失的参数。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unsupported Coordinate" +msgstr "不支持的坐标系" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Only G54-G59 coordinate systems are supported. Use one of these instead." +msgstr "仅支持G54-G59坐标系。请使用其中之一。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Motion Mode" +msgstr "错误的运动模式" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G53 command requires G0 or G1 motion mode. Set the correct motion mode first." +msgstr "G53命令需要G0或G1运动模式。请先设置正确的运动模式。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Axis Words" +msgstr "未使用的轴字" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Axis words present but G80 cancel is active. Remove the unused axis words." +msgstr "存在轴字但G80取消处于活动状态。请删除未使用的轴字。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Data" +msgstr "缺少圆弧数据" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs XYZ coordinates. Add the axis values for the " +"selected plane." +msgstr "G2/G3圆弧命令需要XYZ坐标。请为所选平面添加轴值。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid Target" +msgstr "无效的目标" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Cannot create this arc or probe to current position. Check the target " +"coordinates." +msgstr "无法创建此圆弧或探测到当前位置。请检查目标坐标。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Arc Geometry Error" +msgstr "圆弧几何错误" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Arc calculation failed. Try breaking the arc into smaller pieces or use IJK " +"offset instead." +msgstr "圆弧计算失败。尝试将圆弧分解为较小的部分或使用IJK偏移。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Missing Arc Offset" +msgstr "缺少圆弧偏移" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"G2/G3 arc command needs IJK offset values. Add the missing offset for the " +"selected plane." +msgstr "G2/G3圆弧命令需要IJK偏移值。请为所选平面添加缺失的偏移。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unused Words" +msgstr "未使用的字" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Some G-code words in this line are not used by any command. Remove the " +"unused words." +msgstr "此行中的某些G代码字未被任何命令使用。请删除未使用的字。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Wrong Axis for Offset" +msgstr "偏移的轴错误" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool length offset only works on the configured axis (usually Z-axis). Check " +"your settings." +msgstr "刀具长度偏移仅适用于配置的轴(通常是Z轴)。请检查您的设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Tool Number Too High" +msgstr "刀具号过高" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Tool number exceeds the maximum supported value. Use a valid tool number." +msgstr "刀具号超过了支持的最大值。请使用有效的刀具号。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Hard Limit" +msgstr "硬件限位" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"A hard limit switch was triggered. The machine has stopped and needs to be " +"reset. Check for obstructions and verify your limit switches." +msgstr "" +"触发了硬件限位开关。机器已停止并需要复位。请检查是否有障碍物并验证限位开关。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Soft Limit" +msgstr "软件限位" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine would move beyond its configured travel limits. Check that your " +"work area and coordinate offsets are correct." +msgstr "机器将超出其配置的行程限制。请检查工作区域和坐标偏移是否正确。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Abort Cycle" +msgstr "中止循环" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The currently running job was cancelled while in motion. Reset the machine " +"to continue." +msgstr "当前运行的作业在运动中被取消。请复位机器以继续。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Initial" +msgstr "探测失败 — 初始" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe did not make contact before the maximum travel distance was " +"reached. Check the probe wiring and positioning." +msgstr "探测头在达到最大行程距离之前未接触。请检查探测头的接线和定位。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Probe Fail — Final" +msgstr "探测失败 — 最终" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The probe failed to retract to the target position after contact. Check the " +"probe configuration." +msgstr "探测头在接触后未能缩回到目标位置。请检查探测头配置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Reset" +msgstr "回原点失败 — 复位" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was not able to complete because the machine is in an alarm state. " +"Clear the alarm and try again." +msgstr "由于机器处于报警状态,回原点无法完成。请清除报警后重试。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Approach" +msgstr "回原点失败 — 接近" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to find the switch within the configured travel " +"distance. Check your switch wiring and pull-off settings." +msgstr "回原点循环未能在配置的行程距离内找到开关。请检查开关接线和回退设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Pulloff" +msgstr "回原点失败 — 回退" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The homing cycle failed to successfully pull off the switch after contact. " +"Increase the pull-off distance or check the switch." +msgstr "回原点循环在接触后未能成功从开关回退。请增加回退距离或检查开关。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Home Without Limits" +msgstr "无限制回原点" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing was commanded but limit switches are not configured. Enable limit " +"switches first." +msgstr "已发出回原点命令但限位开关未配置。请先启用限位开关。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Homing Fail — Dual Axis" +msgstr "回原点失败 — 双轴" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"Homing failed on a dual-axis configuration. One or both axes did not reach " +"their limit switches. Check your limit switch wiring and configuration." +msgstr "" +"双轴配置下回原点失败。一个或两个轴未到达其限位开关。请检查限位开关的接线和配" +"置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Alarm" +msgstr "未知报警" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid alarm code reported by machine." +msgstr "机器报告了无效的报警代码。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized alarm code. Check your machine and " +"firmware documentation." +msgstr "机器报告了无法识别的报警代码。请查阅您的机器和固件文档。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Unknown Error" +msgstr "未知错误" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Invalid error code reported by machine." +msgstr "机器报告了无效的错误代码。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "" +"The machine reported an unrecognized error code. Check your machine and " +"firmware documentation." +msgstr "机器报告了无法识别的错误代码。请检查您的机器和固件文档。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Stepper Configuration" +msgstr "步进配置" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings related to stepper motor timing and signal polarity." +msgstr "与步进电机时序和信号极性相关的设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Control & Reporting" +msgstr "控制与报告" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for GRBL's motion control and status reporting." +msgstr "GRBL的运动控制和状态报告设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Limits & Homing" +msgstr "限位与回原点" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for soft/hard limits and the homing cycle." +msgstr "软/硬限位和回原点循环的设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Spindle & Laser" +msgstr "主轴与激光" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Settings for controlling the spindle or laser module." +msgstr "控制主轴或激光模块的设置。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Calibration" +msgstr "轴校准" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the steps-per-millimeter for each axis." +msgstr "定义每个轴的每毫米步数。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Kinematics" +msgstr "轴运动学" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum rate and acceleration for each axis." +msgstr "定义每个轴的最大速率和加速度。" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Axis Travel" +msgstr "轴行程" + +#: rayforge/machine/driver/grbl/grbl_util.py +msgid "Defines the maximum travel distance for each axis." +msgstr "定义每个轴的最大行程距离。" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL (Serial)" +msgstr "GRBL(串口)" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "GRBL-compatible serial connection" +msgstr "GRBL兼容的串口连接" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "RX Buffer Size Override" +msgstr "RX缓冲区大小覆盖" + +#: rayforge/machine/driver/grbl/grbl_serial.py +msgid "" +"Force a specific RX buffer size in bytes. Set to 0 to auto-detect from the " +"device." +msgstr "强制指定RX缓冲区大小(字节)。设置为0以从设备自动检测。" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown Settings" +msgstr "未知设置" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Settings reported by the device not in the standard list." +msgstr "设备报告的设置不在标准列表中。" + +#: rayforge/machine/driver/grbl/grbl_serial.py +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Unknown setting from device" +msgstr "来自设备的未知设置" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Device is configured to report in inches ($13=1). All values shown are in " +"machine units." +msgstr "设备已配置为以英寸报告($13=1)。所有显示的值均为机器单位。" + +#: rayforge/machine/driver/grbl/grbl_probe.py +msgid "" +"Laser mode is not enabled ($32=0). Enable it for best results with laser " +"cutters." +msgstr "激光模式未启用($32=0)。建议启用以获得最佳激光切割效果。" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL (Serial Simple)" +msgstr "GRBL(简易串口)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "GRBL serial with simple ping-pong protocol (no buffer counting)" +msgstr "采用简单乒乓协议的GRBL串口(无缓冲计数)" + +#: rayforge/machine/driver/grbl/grbl_serial_simple.py +msgid "Baudrate must be configured." +msgstr "必须配置波特率。" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "GRBL (Network)" +msgstr "GRBL(网络)" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Connect to a GRBL-compatible device over the network" +msgstr "通过网络连接到GRBL兼容设备" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "HTTP Port" +msgstr "HTTP端口" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The HTTP port for the device" +msgstr "设备的HTTP端口" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "WebSocket Port" +msgstr "WebSocket端口" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "The WebSocket port for the device" +msgstr "设备的WebSocket端口" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Protocol variant" +msgstr "协议变体" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard, ESP3D, or Longer GRBL variant" +msgstr "标准、ESP3D 或 Longer GRBL 变体" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Standard" +msgstr "标准" + +#: rayforge/machine/driver/grbl/grbl_network.py +msgid "Host is not configured. Please set a valid IP address or hostname." +msgstr "主机未配置。请设置有效的IP地址或主机名。" + +#: rayforge/machine/driver/grbl/grbl_network.py +#, python-brace-format +msgid "" +"Could not connect to host '{host}'. Check the IP address and network " +"connection." +msgstr "无法连接到主机'{host}'。请检查IP地址和网络连接。" + +#: rayforge/machine/sanity/result.py rayforge/machine/models/zone.py +msgid "No-Go Zone" +msgstr "禁入区域" + +#: rayforge/machine/sanity/result.py +msgid "Outside Work Area" +msgstr "超出工作区域" + +#: rayforge/machine/sanity/result.py +msgid "Machine Extent" +msgstr "机器范围" + +#: rayforge/machine/device/profile.py +#, python-brace-format +msgid "{name} (device dialect)" +msgstr "{name}(设备方言)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "• Camera calibration: matrix + distortion found" +msgstr "• 相机标定:矩阵+畸变已找到" + +#: rayforge/machine/device/lightburn_importer.py +msgid "(no fields mapped)" +msgstr "(未映射字段)" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Device name" +msgstr "设备名称" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Work area" +msgstr "工作区域" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Driver" +msgstr "驱动" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Baud rate" +msgstr "波特率" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Home on start" +msgstr "启动时回原点" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max travel speed" +msgstr "最大移动速度" + +#: rayforge/machine/device/lightburn_importer.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Origin" +msgstr "原点" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror X" +msgstr "镜像X" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Mirror Y" +msgstr "镜像Y" + +#: rayforge/machine/device/lightburn_importer.py +msgid "Camera calibration" +msgstr "相机标定" + +#: rayforge/machine/device/lightburn_importer.py +msgid "matrix + distortion imported" +msgstr "矩阵+畸变已导入" + +#: rayforge/machine/models/spindle.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Spindle Head" +msgstr "主轴头" + +#: rayforge/machine/models/dialect_manager.py +#: rayforge/machine/models/machine.py +#, python-brace-format +msgid "{label} (for {machine_name})" +msgstr "{label}(用于{machine_name})" + +#: rayforge/machine/models/laser.py +#: rayforge/ui_gtk/machine/laser_control_widget.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +msgid "Laser Head" +msgstr "激光头" + +#: rayforge/machine/models/machine.py +msgid "Default Machine" +msgstr "默认机器" + +#: rayforge/machine/models/rotary_module.py +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Module" +msgstr "旋转模块" + +#: rayforge/machine/models/head.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head" +msgstr "机头" + +#: rayforge/machine/models/controller.py +msgid "No driver selected for this machine." +msgstr "未为此机器选择驱动。" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "Driver '{driver}' not found." +msgstr "未找到驱动'{driver}'。" + +#: rayforge/machine/models/controller.py +#, python-brace-format +msgid "An unexpected error occurred during validation: {error}" +msgstr "验证期间发生意外错误:{error}" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "GRBL Raster" +msgstr "GRBL 栅格" + +#: rayforge/machine/models/dialect/grbl_raster.py +msgid "" +"Optimized for GRBL raster engraving. Keeps M4 dynamic power mode " +"continuously active and uses modal feedrate to minimize command overhead " +"during scan lines" +msgstr "" +"针对 GRBL 栅格雕刻进行了优化。保持 M4 动态功率模式持续激活,并在扫描线期间使" +"用模态进给速度以最大程度减少命令开销" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "Mach4 (M67 Analog)" +msgstr "Mach4 (M67 模拟输出)" + +#: rayforge/machine/models/dialect/mach4_m67.py +msgid "" +"Mach4 with M67 analog output for high-speed raster engraving. Uses M67 E0 " +"Q<0-255> for laser power instead of inline S commands, reducing buffer " +"pressure on the controller." +msgstr "" +"具有M67模拟输出的Mach4,用于高速光栅雕刻。使用M67 E0 Q<0-255>进行激光功率控" +"制,而不是内联S命令,从而减少控制器的缓冲区压力。" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "Smoothieware" +msgstr "Smoothieware" + +#: rayforge/machine/models/dialect/smoothieware.py +msgid "G-code dialect for Smoothieware-based controllers" +msgstr "基于Smoothieware控制器的G代码方言" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "LinuxCNC" +msgstr "LinuxCNC" + +#: rayforge/machine/models/dialect/linuxcnc.py +msgid "G-code for LinuxCNC, supporting native cubic bezier (G5)" +msgstr "适用于 LinuxCNC 的 G 代码,支持原生三次贝塞尔曲线 (G5)" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "GRBL Dynamic" +msgstr "GRBL动态" + +#: rayforge/machine/models/dialect/grbl_dynamic.py +msgid "" +"GRBL with M4 dynamic power (Depth-Aware) mode. S parameter is included in " +"motion commands" +msgstr "具有M4动态功率(深度感知)模式的GRBL。S参数包含在运动命令中" + +#: rayforge/machine/models/dialect/base.py +msgid "General Information" +msgstr "常规信息" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Label" +msgstr "标签" + +#: rayforge/machine/models/dialect/base.py +msgid "User-facing name" +msgstr "面向用户的名称" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/varset/varset_editor.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "Description" +msgstr "描述" + +#: rayforge/machine/models/dialect/base.py +msgid "Short description" +msgstr "简短描述" + +#: rayforge/machine/models/dialect/base.py +msgid "Omit unchanged coordinates" +msgstr "省略未更改的坐标" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"When enabled, axis letters that haven't changed are omitted from G0/G1 " +"commands" +msgstr "启用后,G0/G1命令中省略未更改的轴字母" + +#: rayforge/machine/models/dialect/base.py +msgid "Continuous laser mode" +msgstr "连续激光模式" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Keeps M4 dynamic power mode continuously active during raster engraving " +"instead of toggling M4/M5 between each segment" +msgstr "" +"在光栅雕刻期间保持M4动态功率模式持续激活,而不是在每个段落之间切换M4/M5" + +#: rayforge/machine/models/dialect/base.py +msgid "Modal feedrate" +msgstr "模态进给速度" + +#: rayforge/machine/models/dialect/base.py +msgid "" +"Only include the F feedrate parameter in motion commands when it changes " +"from the previous value" +msgstr "仅在运动命令中的进给速率与前一个值不同时才包含F参数" + +#: rayforge/machine/models/dialect/base.py +msgid "Command Templates" +msgstr "命令模板" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser On" +msgstr "激光开启" + +#: rayforge/machine/models/dialect/base.py +msgid "Laser Off" +msgstr "激光关闭" + +#: rayforge/machine/models/dialect/base.py +msgid "Focus Laser On" +msgstr "聚焦激光" + +#: rayforge/machine/models/dialect/base.py +msgid "Travel Move" +msgstr "移动" + +#: rayforge/machine/models/dialect/base.py +msgid "Linear Move" +msgstr "直线移动" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CW)" +msgstr "圆弧(顺时针)" + +#: rayforge/machine/models/dialect/base.py +msgid "Arc (CCW)" +msgstr "圆弧(逆时针)" + +#: rayforge/machine/models/dialect/base.py +msgid "Bezier Cubic" +msgstr "三次贝塞尔曲线" + +#: rayforge/machine/models/dialect/base.py +msgid "Tool Change" +msgstr "换刀" + +#: rayforge/machine/models/dialect/base.py +msgid "Set Speed" +msgstr "设置速度" + +#: rayforge/machine/models/dialect/base.py +msgid "Air On" +msgstr "吹气开启" + +#: rayforge/machine/models/dialect/base.py +msgid "Air Off" +msgstr "吹气关闭" + +#: rayforge/machine/models/dialect/base.py +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home All" +msgstr "全部回原点" + +#: rayforge/machine/models/dialect/base.py +msgid "Home Axis" +msgstr "轴回原点" + +#: rayforge/machine/models/dialect/base.py +msgid "Move To" +msgstr "移动到" + +#: rayforge/machine/models/dialect/base.py rayforge/ui_gtk/main_menu.py +msgid "Clear Alarm" +msgstr "清除报警" + +#: rayforge/machine/models/dialect/base.py +msgid "Set WCS Offset" +msgstr "设置WCS偏移" + +#: rayforge/machine/models/dialect/base.py +msgid "Probe Cycle" +msgstr "探测循环" + +#: rayforge/machine/models/dialect/base.py +msgid "Dwell" +msgstr "停留" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CW)" +msgstr "主轴开启 (CW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle On (CCW)" +msgstr "主轴开启 (CCW)" + +#: rayforge/machine/models/dialect/base.py +msgid "Spindle Off" +msgstr "主轴关闭" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Flood" +msgstr "冷却液喷淋" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Mist" +msgstr "冷却液雾化" + +#: rayforge/machine/models/dialect/base.py +msgid "Coolant Off" +msgstr "冷却液关闭" + +#: rayforge/machine/models/dialect/base.py +msgid "Scripts" +msgstr "脚本" + +#: rayforge/machine/models/dialect/base.py +msgid "Inject WCS after Preamble" +msgstr "在前言后注入WCS" + +#: rayforge/machine/models/dialect/base.py +#, python-brace-format +msgid "" +"Inject the active WCS command (e.g., G54) after the preamble script. When " +"disabled, you can use {machine.active_wcs} in the preamble instead." +msgstr "" +"在前言脚本后注入活动的WCS命令(例如G54)。禁用时,您可以在前言中使用" +"{machine.active_wcs}代替。" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble" +msgstr "前言" + +#: rayforge/machine/models/dialect/base.py +msgid "Preamble script" +msgstr "前言脚本" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript" +msgstr "后记" + +#: rayforge/machine/models/dialect/base.py +msgid "Postscript script" +msgstr "后记脚本" + +#: rayforge/machine/models/dialect/marlin.py +msgid "Marlin" +msgstr "Marlin" + +#: rayforge/machine/models/dialect/marlin.py +msgid "G-code for Marlin-based controllers, common in 3D printers" +msgstr "基于Marlin控制器的G代码,常用于3D打印机" + +#: rayforge/machine/models/dialect/grbl.py +msgid "Grbl (Compat)" +msgstr "Grbl(兼容)" + +#: rayforge/machine/models/dialect/grbl.py +msgid "" +"Grbl dialect with highest compatibility for most diode lasers and hobby CNCs" +msgstr "Grbl方言,对大多数二极管激光器和业余CNC具有最高兼容性" + +#: rayforge/machine/models/macro.py +msgid "Layer Start" +msgstr "图层开始" + +#: rayforge/machine/models/macro.py +msgid "Layer End" +msgstr "图层结束" + +#: rayforge/machine/models/macro.py +msgid "Workpiece Start" +msgstr "工件开始" + +#: rayforge/machine/models/macro.py +msgid "Workpiece End" +msgstr "工件结束" + +#: rayforge/machine/models/macro.py +msgid "Before processing a layer" +msgstr "处理图层之前" + +#: rayforge/machine/models/macro.py +msgid "After processing a layer" +msgstr "处理图层之后" + +#: rayforge/machine/models/macro.py +msgid "Before processing a workpiece" +msgstr "处理工件之前" + +#: rayforge/machine/models/macro.py +msgid "After processing a workpiece" +msgstr "处理工件之后" + +#: rayforge/machine/models/macro.py +msgid "Unnamed Macro" +msgstr "未命名宏" + +#: rayforge/machine/cmd.py +#, python-brace-format +msgid "{job_name} failed: {error}" +msgstr "{job_name}失败:{error}" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"Failed to list serial ports due to a Snap confinement! Please ensure the " +"device is connected via USB and run:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" +msgstr "" +"由于Snap限制而无法列出串口!请确保设备通过USB连接并运行:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/serial.py +#, python-brace-format +msgid "" +"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 {snap_name}:serial-port" +msgstr "" +"找到串口,但无法访问。请确保您的Snap已连接'serial-port'接口,运行:\n" +"\n" +"sudo snap set system experimental.hotplug=true\n" +"sudo snap connect {snap_name}:serial-port" + +#: rayforge/machine/transport/transport.py +msgid "Connecting" +msgstr "正在连接" + +#: rayforge/machine/transport/transport.py +msgid "Connected" +msgstr "已连接" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Error" +msgstr "错误" + +#: rayforge/machine/transport/transport.py +msgid "Closing" +msgstr "正在关闭" + +#: rayforge/machine/transport/transport.py +#: rayforge/ui_gtk/machine/connection_status_widget.py +msgid "Disconnected" +msgstr "已断开" + +#: rayforge/machine/transport/transport.py +msgid "Sleeping" +msgstr "休眠中" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Machines" +msgstr "机器" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Configured Machines" +msgstr "已配置的机器" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add or remove machines." +msgstr "添加或移除机器。" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This machine has an invalid configuration." +msgstr "此机器配置无效。" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "This is the active machine." +msgstr "这是当前活动的机器。" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#, python-brace-format +msgid "Delete ‘{name}’?" +msgstr "删除 '{name}'?" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "" +"This machine profile and all its settings will be permanently removed. This " +"action cannot be undone." +msgstr "此机器配置文件及其所有设置将被永久删除。此操作无法撤销。" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/selection_dialog.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/machine/template_selector.py +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/debug_log_dialog.py +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +#: 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/step_type_selection_dialog.py +#: rayforge/ui_gtk/doceditor/material_selector.py +#: rayforge/ui_gtk/doceditor/material_list.py +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Cancel" +msgstr "取消" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/layer_column.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Delete" +msgstr "删除" + +#: rayforge/ui_gtk/settings/machine_settings_page.py +msgid "Add Machine" +msgstr "添加机器" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Licenses" +msgstr "许可证" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon" +msgstr "Patreon" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link your Patreon account for early access to new addons." +msgstr "关联您的Patreon账户以抢先体验新插件。" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Patreon Account Linked" +msgstr "Patreon账户已关联" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Early access addons are unlocked" +msgstr "抢先体验插件已解锁" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Unlink" +msgstr "取消关联" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link Patreon Account" +msgstr "关联Patreon账户" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Get early access to premium addons" +msgstr "获取高级插件抢先体验" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Link" +msgstr "关联" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addon Licenses" +msgstr "插件许可证" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Manage your purchased license keys." +msgstr "管理您购买的许可证密钥。" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "No licenses installed" +msgstr "未安装许可证" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Purchase a premium addon and enter the license key during installation." +msgstr "购买高级插件并在安装过程中输入许可证密钥。" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "{addons} (+{count} more)" +msgstr "{addons} (还有{count}个)" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#, python-brace-format +msgid "Product ID: {id}" +msgstr "产品ID:{id}" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +msgid "Remove" +msgstr "移除" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Addons Requiring License" +msgstr "需要许可证的插件" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "These addons need a valid license to be activated" +msgstr "这些插件需要有效的许可证才能激活" + +#: rayforge/ui_gtk/settings/license_settings_page.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "License required" +msgstr "需要许可证" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Buy" +msgstr "购买" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "Remove License?" +msgstr "删除许可证?" + +#: rayforge/ui_gtk/settings/license_settings_page.py +msgid "" +"This license key will be removed. You may need to re-enter it to use " +"licensed addons." +msgstr "此许可证密钥将被删除。您可能需要重新输入它才能使用许可插件。" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default provider" +msgstr "默认提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Enable or disable this provider" +msgstr "启用或禁用此提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Set as default" +msgstr "设为默认" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Add Provider" +msgstr "添加提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "No providers configured" +msgstr "未配置提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "New Provider" +msgstr "新建提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +#, python-brace-format +msgid "Delete '{name}'?" +msgstr "删除 '{name}'?" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"This AI provider will be permanently removed. This action cannot be undone." +msgstr "此AI提供商将被永久删除。此操作无法撤销。" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Name" +msgstr "名称" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Type" +msgstr "提供商类型" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "OpenAI Compatible" +msgstr "OpenAI兼容" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Base URL" +msgstr "基础URL" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Default Model" +msgstr "默认模型" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Connection Test" +msgstr "连接测试" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Verify the provider configuration is working" +msgstr "验证提供商配置是否正常工作" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Edit Provider" +msgstr "编辑提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Provider Settings" +msgstr "提供商设置" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "Testing..." +msgstr "测试中..." + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI" +msgstr "人工智能" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "AI Providers" +msgstr "AI提供商" + +#: rayforge/ui_gtk/settings/ai_settings_page.py +msgid "" +"Configure AI providers for use by addons. Addons can use these providers " +"without needing their own API keys." +msgstr "配置AI提供商供插件使用。插件可以使用这些提供商,无需自己的API密钥。" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Addons" +msgstr "插件" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Installed Addons" +msgstr "已安装插件" + +#: rayforge/ui_gtk/settings/addon_manager_page.py +msgid "Install, update, and remove addons." +msgstr "安装、更新和移除插件。" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Recipes" +msgstr "配方" + +#: rayforge/ui_gtk/settings/recipe_manager_page.py +msgid "Manage your saved recipes for different materials and processes." +msgstr "管理您为不同材料和工艺保存的配方。" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Edit Color Rule" +msgstr "编辑颜色规则" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Update the color rule details:" +msgstr "更新颜色规则详细信息:" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/dialect_editor.py +#: rayforge/ui_gtk/machine/gcode_editor.py +#: rayforge/ui_gtk/machine/wcs_dialog.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Save" +msgstr "保存" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Add Color Rule" +msgstr "添加颜色规则" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Map a color to a step type for SVG imports." +msgstr "为 SVG 导入将颜色映射到步骤类型。" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Add" +msgstr "添加" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Color" +msgstr "颜色" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "SVG color that triggers this rule" +msgstr "触发此规则的 SVG 颜色" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Label (optional)" +msgstr "标签(可选)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step Type" +msgstr "步骤类型" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Step type created when this color is imported" +msgstr "导入此颜色时创建的步骤类型" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Color {color}" +msgstr "颜色 {color}" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "This step type is not currently available." +msgstr "此步骤类型当前不可用。" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "{step_type} (unavailable)" +msgstr "{step_type}(不可用)" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "No color rules found." +msgstr "未找到颜色规则。" + +#: rayforge/ui_gtk/settings/color_presets_page.py +#, python-brace-format +msgid "Delete color rule '{color}'?" +msgstr "删除颜色规则“{color}”?" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"The color rule will be permanently removed. This action cannot be undone." +msgstr "颜色规则将被永久删除。此操作无法撤消。" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "Color Rules" +msgstr "颜色规则" + +#: rayforge/ui_gtk/settings/color_presets_page.py +msgid "" +"Map SVG colors to step types so they are applied automatically when " +"importing." +msgstr "将 SVG 颜色映射到步骤类型,以便在导入时自动应用。" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials" +msgstr "材料" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Material Libraries" +msgstr "材料库" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Manage your material libraries. Select a library to view its materials." +msgstr "管理您的材料库。选择一个库以查看其材料。" + +#: rayforge/ui_gtk/settings/material_manager_page.py +msgid "Materials in the selected library." +msgstr "所选库中的材料。" + +#: rayforge/ui_gtk/settings/settings_dialog.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Categories" +msgstr "类别" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "English" +msgstr "英语" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "German" +msgstr "德语" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Spanish" +msgstr "西班牙语" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "French" +msgstr "法语" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Portuguese" +msgstr "葡萄牙语" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Ukrainian" +msgstr "乌克兰语" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Chinese (Simplified)" +msgstr "简体中文" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/about.py +msgid "System" +msgstr "系统" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Light" +msgstr "浅色" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Dark" +msgstr "深色" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open nothing" +msgstr "不打开任何内容" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open last project" +msgstr "打开上次项目" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Open specific project" +msgstr "打开指定项目" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Laser Color" +msgstr "激光颜色" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Layer Color" +msgstr "图层颜色" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "System Default" +msgstr "系统默认" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "General" +msgstr "常规" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Appearance" +msgstr "外观" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Settings related to the application's look and feel." +msgstr "与应用程序外观和感觉相关的设置。" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Theme" +msgstr "主题" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Language" +msgstr "语言" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "The application language. Changes require a restart." +msgstr "应用程序语言。更改需要重启。" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Operation Colors" +msgstr "操作颜色" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Choose whether operation colors represent the laser or the layer" +msgstr "选择操作颜色代表激光还是图层" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Units" +msgstr "单位" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Set the display units for various values throughout the application." +msgstr "为应用程序中的各种值设置显示单位。" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Length" +msgstr "长度" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Speed" +msgstr "速度" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Acceleration" +msgstr "加速度" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Behavior" +msgstr "行为" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Configure advanced application behavior." +msgstr "配置高级应用程序行为。" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Auto-update operations" +msgstr "自动更新操作" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Recalculate operations automatically after each change. Disable for manual " +"recalculation via the toolbar button" +msgstr "每次更改后自动重新计算操作。禁用后可通过工具栏按钮手动重新计算" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Cache budget (MB)" +msgstr "缓存预算 (MB)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Maximum memory for cache. High complexity scenes require more" +msgstr "缓存的最大内存。高复杂度场景需要更多内存" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Check for updates" +msgstr "检查更新" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Automatically check for new Rayforge versions on startup" +msgstr "启动时自动检查新版本的 Rayforge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Startup behavior" +msgstr "启动行为" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Project path" +msgstr "项目路径" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Browse..." +msgstr "浏览..." + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Privacy" +msgstr "隐私" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"Help us improve Rayforge by allowing anonymous usage reporting. No personal " +"data is collected." +msgstr "允许匿名使用报告来帮助我们改进 Rayforge。不会收集任何个人数据。" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Report Anonymous Usage" +msgstr "报告匿名使用情况" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Help improve Rayforge" +msgstr "帮助改进 Rayforge" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Learn " +"more about usage tracking and privacy." +msgstr "" +"了解更多关于使用跟踪和隐私的信息。" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Restart required" +msgstr "需要重启" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "" +"The language will take effect after restarting Rayforge. Would you like to " +"restart now?" +msgstr "语言将在重启 Rayforge 后生效。是否现在重启?" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Cancel" +msgstr "取消(_C)" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "_Restart" +msgstr "重启(_R)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Copies keep their original layers." +msgstr "副本保留其原始图层。" + +#: rayforge/ui_gtk/array_dialog.py +msgid "_Apply" +msgstr "_应用" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Grid Array" +msgstr "网格阵列" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Grid" +msgstr "网格" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rows" +msgstr "行" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Columns" +msgstr "列" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement" +msgstr "位移" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Gap" +msgstr "间隙" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Spacing" +msgstr "间距" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Displacement is center-to-center; gap is edge-to-edge." +msgstr "位移为中心到中心;间隙为边缘到边缘。" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Column spacing" +msgstr "列间距" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Row spacing" +msgstr "行间距" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Point Rotation Array" +msgstr "点旋转阵列" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Point Rotation" +msgstr "点旋转" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotates copies in place around the selection's centre." +msgstr "围绕选择中心原地旋转副本。" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Count" +msgstr "数量" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Total angle (deg)" +msgstr "总角度(度)" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Circular Array" +msgstr "圆形阵列" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Circular" +msgstr "圆形" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Places copies along a circular arc around a centre." +msgstr "围绕中心沿圆弧放置副本。" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center X" +msgstr "中心 X" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Center Y" +msgstr "中心 Y" + +#: rayforge/ui_gtk/array_dialog.py rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Radius" +msgstr "半径" + +#: rayforge/ui_gtk/array_dialog.py +msgid "Rotate copies" +msgstr "旋转副本" + +#: rayforge/ui_gtk/canvas2d/elements/tab_handle.py +msgid "Move Tab" +msgstr "移动标签" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Up a Layer" +msgstr "向上移动一层" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Move Down a Layer" +msgstr "向下移动一层" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Group" +msgstr "编组" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Ungroup" +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 +msgid "Add Tab Here" +msgstr "在此添加标签" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/tab_cmd.py +msgid "Remove Tab" +msgstr "移除标签" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Sketch" +msgstr "新建草图" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "New Stock" +msgstr "新建材料" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Import File…" +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 "Paste" +msgstr "粘贴" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py rayforge/doceditor/edit_cmd.py +msgid "Add {} Instance" +msgstr "添加 {} 实例" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Drop files to import" +msgstr "拖放文件以导入" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Image imported from clipboard" +msgstr "从剪贴板导入的图像" + +#: rayforge/ui_gtk/canvas2d/drag_drop_cmd.py +msgid "Failed to import image from clipboard" +msgstr "从剪贴板导入图像失败" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "3D view is not available due to missing dependencies." +msgstr "由于缺少依赖项,3D视图不可用。" + +#: rayforge/ui_gtk/view_mode_cmd.py +msgid "Select a machine to open the 3D view." +msgstr "选择一台机器以打开3D视图。" + +#: rayforge/ui_gtk/actions.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/doceditor/stock_cmd.py +msgid "Add Stock" +msgstr "添加毛坯" + +#: rayforge/ui_gtk/actions.py +msgid "Auto Layout (Simple)" +msgstr "自动布局(简单)" + +#: rayforge/ui_gtk/camera/lens_calibration_dialog.py +#, python-brace-format +msgid "{camera_name} - Lens Calibration" +msgstr "{camera_name} - 镜头校准" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera Image Settings" +msgstr "摄像头图像设置" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Adjust image quality and appearance parameters." +msgstr "调整图像质量和外观参数。" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Default" +msgstr "默认" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom..." +msgstr "自定义..." + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Resolution" +msgstr "分辨率" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Camera capture resolution. Default uses the camera's native setting." +msgstr "相机捕获分辨率。默认使用相机的原生设置。" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Width" +msgstr "自定义宽度" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Custom Height" +msgstr "自定义高度" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Prefer YUYV Format" +msgstr "优先使用 YUYV 格式" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "" +"Use uncompressed YUYV instead of MJPEG. Fixes green artifacts on some USB " +"cameras but may reduce resolution or frame rate on USB 2.0." +msgstr "" +"使用未压缩的 YUYV 而非 MJPEG。修复某些 USB 摄像头上的绿色伪影,但在 USB 2.0 " +"上可能会降低分辨率或帧率。" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Auto White Balance" +msgstr "自动白平衡" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Automatically adjust white balance" +msgstr "自动调整白平衡" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "White Balance (Kelvin)" +msgstr "白平衡(开尔文)" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Color temperature for accurate color representation" +msgstr "用于准确色彩表现的色温" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Contrast" +msgstr "对比度" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Difference between light and dark areas" +msgstr "明暗区域之间的差异" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Brightness" +msgstr "亮度" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Overall lightness or darkness of the image" +msgstr "图像的整体明暗度" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Noise Reduction" +msgstr "降噪" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Temporal averaging, higher values cause trailing" +msgstr "时间平均,较高的值会导致拖尾" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency" +msgstr "透明度" + +#: rayforge/ui_gtk/camera/image_settings_widget.py +msgid "Transparency on the worksurface" +msgstr "工作台上的透明度" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select an available camera device" +msgstr "请选择可用的摄像头设备" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Please select a configured camera" +msgstr "请选择已配置的摄像头" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "Select Camera" +msgstr "选择摄像头" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras configured." +msgstr "未配置任何摄像头。" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Failed to load image for Device ID: {device_id}" +msgstr "无法加载设备ID的图像:{device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +#, python-brace-format +msgid "Camera {device_id}" +msgstr "摄像头 {device_id}" + +#: rayforge/ui_gtk/camera/selection_dialog.py +msgid "No cameras found." +msgstr "未找到摄像头。" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +#, python-brace-format +msgid "Point {n}" +msgstr "点 {n}" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Delete this point" +msgstr "删除此点" + +#: rayforge/ui_gtk/camera/point_bubble_widget.py +msgid "Nudge Pixel:" +msgstr "微调像素:" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Camera Properties" +msgstr "摄像头属性" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure the selected camera." +msgstr "配置选定的摄像头。" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Device ID" +msgstr "设备ID" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "System identifier for the camera device" +msgstr "摄像头设备的系统标识符" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Display name for this camera" +msgstr "此摄像头的显示名称" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enabled" +msgstr "已启用" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Turn the camera stream on or off" +msgstr "开启或关闭摄像头流" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Start" +msgstr "开始" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Camera Wizard" +msgstr "相机向导" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Guided setup: image settings, lens calibration, and alignment." +msgstr "引导式设置:图像设置、镜头校准和对齐。" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Configure" +msgstr "配置" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/image_settings_page.py +msgid "Image Settings" +msgstr "图像设置" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Adjust brightness, contrast, white balance, and noise" +msgstr "调整亮度、对比度、白平衡和噪点" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Lens Calibration" +msgstr "镜头校准" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Correct lens distortion for straighter lines" +msgstr "校正镜头畸变以获得更直的线条" + +#: rayforge/ui_gtk/camera/properties_widget.py +#: rayforge/ui_gtk/camera/wizard/alignment_page.py +msgid "Image Alignment" +msgstr "图像对齐" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Calibrate camera position and perspective" +msgstr "校准摄像头位置和透视" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration completed" +msgstr "镜头校准完成" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Lens calibration not yet performed" +msgstr "镜头校准尚未执行" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment completed" +msgstr "图像对齐完成" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment must be redone after lens calibration was updated" +msgstr "镜头校准更新后必须重新进行图像对齐" + +#: rayforge/ui_gtk/camera/properties_widget.py +msgid "Image alignment not yet performed" +msgstr "图像对齐尚未执行" + +#: rayforge/ui_gtk/camera/capture_surface.py +msgid "Waiting for camera..." +msgstr "等待相机..." + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Correct lens distortion for straighter lines. Choose how to calibrate, or " +"skip if your lens has negligible distortion." +msgstr "" +"校正镜头畸变以获得更直的线条。选择校准方式,如果镜头畸变可忽略不计,也可以跳" +"过。" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic" +msgstr "自动" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Automatic Calibration" +msgstr "自动校准" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "" +"Print a calibration card and capture it at several positions. The wizard " +"solves the distortion coefficients for you." +msgstr "打印一张校准卡并在多个位置采集。向导会为您求解畸变系数。" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual" +msgstr "手动" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Manual Calibration" +msgstr "手动校准" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +msgid "Enter the radial and tangential distortion coefficients by hand." +msgstr "手动输入径向和切向畸变系数。" + +#: rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Skip" +msgstr "跳过" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration Card" +msgstr "校准卡" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Instructions" +msgstr "说明" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "" +"Print a calibration card to correct lens distortion. The card size should " +"fit within your camera view." +msgstr "打印校准卡以校正镜头畸变。卡片大小应适合您的相机视野。" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card Size" +msgstr "卡片大小" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Adjust to fit your work surface." +msgstr "调整以适合您的工作台面。" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Width" +msgstr "宽度" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card width" +msgstr "卡片宽度" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Height" +msgstr "高度" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Card height" +msgstr "卡片高度" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Generated Pattern" +msgstr "生成的图案" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Details about the calibration pattern." +msgstr "校准图案的详细信息。" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Grid Size" +msgstr "网格大小" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Square Size" +msgstr "方格大小" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Physical Size" +msgstr "物理尺寸" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save to PDF" +msgstr "保存为 PDF" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Export the calibration card for printing" +msgstr "导出校准卡以供打印" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Save Calibration Card" +msgstr "保存校准卡" + +#: rayforge/ui_gtk/camera/wizard/card_page.py +msgid "Calibration card saved" +msgstr "校准卡已保存" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frames" +msgstr "采集画面" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "" +"Capture the card at different positions. Important: include the image " +"corners and edges for accurate distortion correction." +msgstr "" +"在不同位置捕获校准卡。重要提示:请包含图像的角落和边缘以获得准确的畸变校正。" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Status" +msgstr "状态" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Progress of the calibration capture process." +msgstr "校准捕获过程的进度。" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Captured Frames" +msgstr "已捕获的画面" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Corners Detected" +msgstr "检测到的角点" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Coverage" +msgstr "覆盖率" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Not started" +msgstr "未开始" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Move card to capture more positions" +msgstr "移动卡片以捕获更多位置" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Progress" +msgstr "捕获进度" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Capture Frame" +msgstr "捕获画面" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Clear" +msgstr "清除" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibrate" +msgstr "校准" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Good" +msgstr "良好" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Limited — reach edges" +msgstr "有限 — 请到达边缘" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Poor — reach all corners" +msgstr "不足 — 请到达所有角落" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Failed" +msgstr "校准失败" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Calibration Complete" +msgstr "校准完成" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +#, python-brace-format +msgid "" +"RMS Error: {rms:.4f} pixels\n" +"Quality: {quality}\n" +"Frames used: {frames}" +msgstr "" +"RMS 误差:{rms:.4f} 像素\n" +"质量:{quality}\n" +"使用帧数:{frames}" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Discard" +msgstr "放弃" + +#: rayforge/ui_gtk/camera/wizard/capture_page.py +msgid "Save Calibration" +msgstr "保存校准" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#, python-brace-format +msgid "{camera} - Camera Wizard" +msgstr "{camera} - 相机向导" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Back" +msgstr "返回" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Next" +msgstr "下一步" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +msgid "Finish" +msgstr "完成" + +#: rayforge/ui_gtk/camera/wizard/wizard.py +#: rayforge/ui_gtk/machine/dialect_list.py +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#: rayforge/ui_gtk/addon_manager/addon_list.py +#: rayforge/ui_gtk/doceditor/material_library_list.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "OK" +msgstr "确定" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 1 (k1)" +msgstr "径向 1 (k1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order radial distortion" +msgstr "一阶径向畸变" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Radial 2 (k2)" +msgstr "径向 2 (k2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order radial distortion" +msgstr "二阶径向畸变" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Radial 3 (k3)" +msgstr "径向 3 (k3)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Third order radial distortion" +msgstr "三阶径向畸变" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 1 (p1)" +msgstr "切向 1 (p1)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "First order tangential distortion" +msgstr "一阶切向畸变" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Tangential 2 (p2)" +msgstr "切向 2 (p2)" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "Second order tangential distortion" +msgstr "二阶切向畸变" + +#: rayforge/ui_gtk/camera/lens_calibration_widget.py +msgid "" +"Correct lens distortion for straighter lines. Adjust the coefficients " +"manually." +msgstr "校正镜头畸变以获得更直的线条。手动调整系数。" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +#, python-brace-format +msgid "{camera_name} – Image Alignment" +msgstr "{camera_name} – 图像对齐" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom Out (Scroll Down)" +msgstr "缩小(向下滚动)" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Fit to Window" +msgstr "适应窗口" + +#: rayforge/ui_gtk/camera/alignment_dialog.py +msgid "Zoom In (Scroll Up)" +msgstr "放大(向上滚动)" + +#: rayforge/ui_gtk/camera/image_settings_dialog.py +#, python-brace-format +msgid "{camera_name} - Camera Image Settings" +msgstr "{camera_name} - 摄像头图像设置" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#, python-brace-format +msgid "Device ID: {device_id}" +msgstr "设备ID:{device_id}" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Add New Camera" +msgstr "添加新摄像头" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "No cameras configured" +msgstr "未配置摄像头" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Image Enhancement" +msgstr "图像增强" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Reduce noise and improve image stability." +msgstr "减少噪点并提高图像稳定性。" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Temporal averaging. Higher values remove more noise but cause trailing." +msgstr "时间平均。较高的值可消除更多噪点但会导致拖尾。" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "" +"Straighten bowed lines using Radial (k1, k2) and Tangential (p1, p2) " +"parameters. Note: Values are usually very small." +msgstr "" +"使用径向(k1, k2)和切向(p1, p2)参数拉直弯曲的线条。注意:值通常非常小。" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Lens Distortion Correction (Fisheye)" +msgstr "镜头畸变校正(鱼眼)" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Camera" +msgstr "摄像头" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Cameras" +msgstr "摄像头" + +#: rayforge/ui_gtk/camera/camera_preferences_page.py +msgid "Stream a camera image directly onto the work surface." +msgstr "将摄像头图像直接流式传输到工作台。" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "" +"Click the image to add reference points. Drag to move them.\n" +"Scroll to Zoom. Middle-click and drag to Pan.\n" +"Use the Arrow Keys to nudge the active point precisely." +msgstr "" +"点击图像添加参考点。拖动以移动它们。\n" +"滚动以缩放。中键点击并拖动以平移。\n" +"使用方向键精确微调活动点。" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Reset Points" +msgstr "重置点" + +#: rayforge/ui_gtk/camera/alignment_widget.py +msgid "Clear All Points" +msgstr "清除所有点" + +#: rayforge/ui_gtk/camera/alignment_widget.py +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Apply" +msgstr "应用" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "Add New Macro" +msgstr "添加新宏" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "No macros configured" +msgstr "未配置宏" + +#: rayforge/ui_gtk/machine/macro_list.py +msgid "New Macro" +msgstr "新宏" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, {min_rpm}-{max_rpm} rpm" +msgstr "刀具 {tool_number},{min_rpm}-{max_rpm} rpm" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}, spot size {spot_x}x{spot_y}" +msgstr "工具 {tool_number},最大功率 {max_power},光斑大小 {spot_x}x{spot_y}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#, python-brace-format +msgid "Tool {tool_number}" +msgstr "刀具 {tool_number}" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Add New Head" +msgstr "添加新机头" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "No heads configured" +msgstr "未配置机头" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "At least one head is required" +msgstr "至少需要一个机头" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spindle" +msgstr "主轴" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Laser" +msgstr "新激光" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "New Spindle" +msgstr "新主轴" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "3D Model" +msgstr "3D 模型" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Select and configure a 3D model for this head." +msgstr "为此机头选择并配置 3D 模型。" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Model" +msgstr "模型" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Scale" +msgstr "缩放" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Uniform scale factor for the model" +msgstr "模型的统一缩放因子" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X Rotation" +msgstr "X 旋转" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the X axis" +msgstr "绕 X 轴的度数" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y Rotation" +msgstr "Y 旋转" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Y axis" +msgstr "绕 Y 轴的度数" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Rotation" +msgstr "Z 旋转" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Degrees around the Z axis" +msgstr "绕 Z 轴的度数" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "None" +msgstr "无" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Properties" +msgstr "激光属性" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected laser head." +msgstr "配置所选激光头。" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pulse Width Modulation settings for frequency and pulse width control." +msgstr "脉冲宽度调制设置,用于控制频率和脉冲宽度。" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Framing" +msgstr "边框" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Settings for the frame outline operation that traces the job boundary." +msgstr "追踪作业边界的边框轮廓操作设置。" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Tool Number" +msgstr "工具编号" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "G-code tool number (e.g., T0, T1)" +msgstr "G代码工具编号(例如:T0、T1)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Diode" +msgstr "二极管" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "CO₂" +msgstr "CO₂" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Fiber" +msgstr "光纤" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Laser Type" +msgstr "激光类型" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Type of laser tube or diode" +msgstr "激光管或二极管的类型" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Power" +msgstr "最大功率" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum power value in GCode" +msgstr "G代码中的最大功率值" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Focus Power" +msgstr "聚焦功率" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when focusing. 0 to disable" +msgstr "聚焦时使用的功率百分比。0表示禁用" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size X" +msgstr "光斑大小 X" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the X direction" +msgstr "X方向上的激光光斑大小" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Spot Size Y" +msgstr "光斑大小 Y" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Size of the laser spot in the Y direction" +msgstr "Y方向上的激光光斑大小" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Cut Color" +msgstr "切割颜色" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for cutting operations" +msgstr "切割操作的颜色" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Raster Color" +msgstr "光栅颜色" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Color for engraving/raster operations" +msgstr "雕刻/光栅操作的颜色" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Focal Distance" +msgstr "焦距" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Distance from the laser head to the work surface (Z offset)" +msgstr "激光头到工作表面的距离(Z 偏移)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "PWM Frequency" +msgstr "PWM频率" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default PWM frequency in Hz" +msgstr "默认PWM频率(Hz)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max PWM Frequency" +msgstr "最大PWM频率" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum supported PWM frequency in Hz" +msgstr "最大支持的PWM频率(Hz)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Default pulse width in µs" +msgstr "默认脉冲宽度(µs)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Min Pulse Width" +msgstr "最小脉冲宽度" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum pulse width in µs" +msgstr "最小脉冲宽度(µs)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Max Pulse Width" +msgstr "最大脉冲宽度" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum pulse width in µs" +msgstr "最大脉冲宽度(µs)" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Power" +msgstr "边框功率" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Power value in percent to use when framing. 0 to disable" +msgstr "边框时使用的功率百分比。0表示禁用" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Frame Speed" +msgstr "边框速度" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Speed for frame outline. Leave at 0 to use the machine's max travel speed" +msgstr "边框轮廓速度。保持为 0 以使用机器的最大移动速度" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Repeat Count" +msgstr "重复次数" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Number of times to trace the frame outline" +msgstr "描边框轮廓的次数" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Pause at Corners" +msgstr "角落暂停" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"Pause duration in seconds at each corner of the frame outline. 0 to disable" +msgstr "边框轮廓每个角落的暂停时间(秒)。0 表示禁用" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Spindle Properties" +msgstr "主轴属性" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Configure the selected spindle head." +msgstr "配置所选主轴头。" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Min RPM" +msgstr "最小 RPM" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Minimum spindle speed" +msgstr "主轴最小转速" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max RPM" +msgstr "最大 RPM" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Maximum spindle speed" +msgstr "主轴最大转速" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Flood Coolant" +msgstr "支持浇注冷却液" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a flood" +msgstr "冷却液以浇注方式输送到工件" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Supports Mist Coolant" +msgstr "支持喷雾冷却液" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "Coolant delivered to the workpiece as a mist" +msgstr "冷却液以喷雾方式输送到工件" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Heads" +msgstr "机头" + +#: rayforge/ui_gtk/machine/head_preferences_page.py +msgid "" +"You can configure multiple lasers or spindles if your machine supports it." +msgstr "如果您的机器支持,您可以配置多个激光器或主轴。" + +#: rayforge/ui_gtk/machine/unified_wizard.py +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Add a Machine" +msgstr "添加机器" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Create Machine" +msgstr "创建机器" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Could not create machine" +msgstr "无法创建机器" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Camera setup unavailable" +msgstr "相机设置不可用" + +#: rayforge/ui_gtk/machine/unified_wizard.py +msgid "Calibrate this camera later from the machine settings page." +msgstr "稍后可从机器设置页面校准此相机。" + +#: rayforge/ui_gtk/machine/console.py +msgid "Show verbose output (status polls)" +msgstr "显示详细输出(状态轮询)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Rectangle" +msgstr "矩形" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Box" +msgstr "盒子" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder" +msgstr "圆柱体" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Add Zone" +msgstr "添加区域" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "No no-go zones configured" +msgstr "未配置禁入区域" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "New Zone" +msgstr "新区域" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "No-Go Zones" +msgstr "禁入区域" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "" +"Define restricted areas on the work surface. A warning will be shown before " +"running or exporting a job whose toolpath enters any enabled no-go zone." +msgstr "" +"定义工作表面上的限制区域。在运行或导出其刀具路径进入任何已启用禁入区域的作业" +"前将显示警告。" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone Properties" +msgstr "区域属性" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Configure the selected zone." +msgstr "配置选定的区域。" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Shape" +msgstr "形状" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Zone geometry shape" +msgstr "区域几何形状" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "X" +msgstr "X" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "X position in {wcs}" +msgstr "X 位置 ({wcs})" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Y" +msgstr "Y" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Y position in {wcs}" +msgstr "Y 位置 ({wcs})" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Z" +msgstr "Z" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +#, python-brace-format +msgid "Z position in {wcs}" +msgstr "Z 位置 ({wcs})" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth" +msgstr "深度" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Depth (Z extent)" +msgstr "深度(Z 范围)" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder radius" +msgstr "圆柱体半径" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder Height" +msgstr "圆柱体高度" + +#: rayforge/ui_gtk/machine/nogo_zones_page.py +msgid "Cylinder height" +msgstr "圆柱体高度" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Escaped braces {{ or }} are not supported." +msgstr "不支持转义大括号 {{ 或 }}。" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Nested braces are not allowed." +msgstr "不允许嵌套大括号。" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched closing brace '}' found." +msgstr "发现不匹配的闭合大括号 '}'。" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Unmatched opening brace '{' found." +msgstr "发现不匹配的开放大括号 '{'。" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Empty braces '{}' are not allowed." +msgstr "不允许空大括号 '{}'。" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Unsupported variable(s): {vars}" +msgstr "不支持的变量:{vars}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +#, python-brace-format +msgid "Edit Dialect: {label}" +msgstr "编辑方言:{label}" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "New Dialect" +msgstr "新方言" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Update from Template" +msgstr "从模板更新" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "Label cannot be empty." +msgstr "标签不能为空。" + +#: rayforge/ui_gtk/machine/dialect_editor.py +msgid "" +"Select a template to copy its settings. Your label and description will be " +"preserved." +msgstr "选择一个模板以复制其设置。您的标签和描述将被保留。" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "G-code Hooks" +msgstr "G代码钩子" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "Add custom G-code to be executed at specific points in the job." +msgstr "添加自定义G代码,在作业中的特定点执行。" + +#: rayforge/ui_gtk/machine/hook_list.py rayforge/ui_gtk/varset/varsetwidget.py +msgid "Reset to Default" +msgstr "重置为默认值" + +#: rayforge/ui_gtk/machine/hook_list.py +#, python-brace-format +msgid "Reset '{hook_name}' to Default?" +msgstr "将'{hook_name}'重置为默认值?" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "" +"This will remove your custom G-code for this hook. The machine will revert " +"to using its built-in default macro. This action cannot be undone." +msgstr "" +"这将移除此钩子的自定义G代码。机器将恢复使用其内置的默认宏。此操作无法撤销。" + +#: rayforge/ui_gtk/machine/hook_list.py +#: rayforge/ui_gtk/machine/maintenance_page.py rayforge/doceditor/file_cmd.py +msgid "Reset" +msgstr "重置" + +#: rayforge/ui_gtk/machine/hook_list.py +msgid "# Your G-code here" +msgstr "# 您的G代码在此" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Device Profile archives" +msgstr "设备配置文件归档" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "LightBurn device profiles" +msgstr "LightBurn设备配置文件" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "All files" +msgstr "所有文件" + +#: rayforge/ui_gtk/machine/profile_importer.py +msgid "Import Device Profile" +msgstr "导入设备配置文件" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Edit Macro" +msgstr "编辑宏" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Insert Variable" +msgstr "插入变量" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Include Macro" +msgstr "包含宏" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Edit Macro for {name}" +msgstr "编辑{name}的宏" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Available Variables" +msgstr "可用变量" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "No other macros to include." +msgstr "没有其他宏可包含。" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "Name cannot be empty." +msgstr "名称不能为空。" + +#: rayforge/ui_gtk/machine/gcode_editor.py +#, python-brace-format +msgid "Name contains invalid characters: {chars}" +msgstr "名称包含无效字符:{chars}" + +#: rayforge/ui_gtk/machine/gcode_editor.py +msgid "This name is already used by another macro." +msgstr "此名称已被另一个宏使用。" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Edit Work Offsets" +msgstr "编辑工件偏移" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Enter the offset from Machine Zero to Work Zero for the active WCS." +msgstr "输入从机器零点到工件零点的偏移,用于活动WCS。" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "X Offset" +msgstr "X 偏移" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Y Offset" +msgstr "Y 偏移" + +#: rayforge/ui_gtk/machine/wcs_dialog.py +msgid "Z Offset" +msgstr "Z 偏移" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter" +msgstr "重置计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Edit Counter" +msgstr "编辑计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter" +msgstr "移除计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Counter?" +msgstr "重置计数器?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "This will reset the accumulated hours to zero." +msgstr "这将把累计小时数重置为零。" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Remove Counter?" +msgstr "移除计数器?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Are you sure you want to remove this counter? This action cannot be undone." +msgstr "您确定要移除此计数器吗?此操作无法撤销。" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Add Counter" +msgstr "添加计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "No counters configured" +msgstr "未配置计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "New Counter" +msgstr "新计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Notification Interval" +msgstr "通知间隔" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Show notification when counter reaches this value (hours). Set to 0 to " +"disable." +msgstr "当计数器达到此值(小时)时显示通知。设置为0以禁用。" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Maintenance" +msgstr "维护" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Hours" +msgstr "总工时" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative operating time tracked by the machine." +msgstr "机器跟踪的累计运行时间。" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Total Operating Hours" +msgstr "总运行工时" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Cumulative machine operating time" +msgstr "累计机器运行时间" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours" +msgstr "重置总工时" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Maintenance Counters" +msgstr "维护计数器" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"Track maintenance intervals with resettable counters. Use for laser tubes, " +"lubrication, etc." +msgstr "使用可重置的计数器跟踪维护间隔。用于激光管、润滑等。" + +#: rayforge/ui_gtk/machine/maintenance_page.py +#, python-brace-format +msgid "{time} total" +msgstr "总计 {time}" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "Reset Total Hours?" +msgstr "重置总工时?" + +#: rayforge/ui_gtk/machine/maintenance_page.py +msgid "" +"This will reset the total cumulative operating hours to zero. Maintenance " +"counters will not be affected." +msgstr "这将把总累计运行工时重置为零。维护计数器将不受影响。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Device" +msgstr "设备" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Device Settings" +msgstr "设备设置" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read or apply settings directly to the device." +msgstr "直接读取或应用设置到设备。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Read from Device" +msgstr "从设备读取" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The current driver does not support reading device settings." +msgstr "当前驱动不支持读取设备设置。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Copy Error Details" +msgstr "复制错误详情" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Error" +msgstr "忽略错误" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"Editing these values can be dangerous and may render your machine inoperable!" +msgstr "编辑这些值可能很危险,并可能导致您的机器无法操作!" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "" +"The device may restart or temporarily disconnect after a setting is changed." +msgstr "更改设置后,设备可能会重启或暂时断开连接。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Dismiss Warning" +msgstr "忽略警告" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Click the refresh button to load settings from the device." +msgstr "点击刷新按钮从设备加载设置。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Operation failed" +msgstr "操作失败" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine Not Connected" +msgstr "机器未连接" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "The machine is not connected." +msgstr "机器未连接。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Setting applied successfully." +msgstr "设置应用成功。" + +#: rayforge/ui_gtk/machine/device_settings_page.py +#, python-brace-format +msgid "Cannot connect: Used by '{machine}'" +msgstr "无法连接:正被'{machine}'使用" + +#: rayforge/ui_gtk/machine/device_settings_page.py +msgid "Machine activated." +msgstr "机器已激活。" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import LightBurn profile?" +msgstr "导入LightBurn配置?" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "" +"LightBurn device profiles contain only basic machine settings. The imported " +"profile may be incomplete. After import, please review and configure any " +"additional settings such as laser heads, homing, end stops, G-code dialect, " +"macros, and rotary modules." +msgstr "" +"LightBurn设备配置文件仅包含基本机器设置。导入的配置可能不完整。导入后,请检查" +"并配置任何其他设置,如激光头、回原点、限位开关、G代码方言、宏和旋转模块。" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "Import Anyway" +msgstr "仍然导入" + +#: rayforge/ui_gtk/machine/lbdev_import_dialog.py +msgid "The following values will be imported:" +msgstr "将导入以下值:" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hooks & Macros" +msgstr "钩子和宏" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py rayforge/ui_gtk/main_menu.py +msgid "Macros" +msgstr "宏" + +#: rayforge/ui_gtk/machine/hooks_macros_page.py +msgid "Create and manage reusable G-code snippets." +msgstr "创建和管理可重用的G代码片段。" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Advanced" +msgstr "高级" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Path Processing" +msgstr "路径处理" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Configure how paths are processed and optimized." +msgstr "配置路径的处理和优化方式。" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Arcs" +msgstr "支持圆弧" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate arc commands for smoother paths. Disable if your machine does not " +"support arcs" +msgstr "生成圆弧命令以获得更平滑的路径。如果您的机器不支持圆弧,请禁用" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Support Bézier Curves" +msgstr "支持贝塞尔曲线" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Generate native cubic Bézier commands. Disable if your machine does not " +"support them" +msgstr "生成原生三次贝塞尔命令。如果您的机器不支持,请禁用" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Arc and Curve Tolerance" +msgstr "弧线和曲线公差" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Maximum deviation from original path when fitting arcs and curves. Lower " +"values drastically increase processing time and job size" +msgstr "" +"拟合圆弧和曲线时相对于原始路径的最大偏差。较低的值会大幅增加处理时间和作业大" +"小。" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Homing and Startup" +msgstr "回原点和启动" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"Configure homing behavior and startup settings, including automatic homing " +"and alarm handling." +msgstr "配置回原点行为和启动设置,包括自动回原点和报警处理。" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Home On Start" +msgstr "启动时回原点" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Send a homing command when the application starts" +msgstr "应用程序启动时发送回原点命令" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Allow Single Axis Homing" +msgstr "允许单轴回原点" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Enable individual axis homing controls in the jog dialog" +msgstr "在点动对话框中启用单独的轴回原点控制" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Clear Alarm On Connect" +msgstr "连接时清除报警" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Automatically send an unlock command if connected in an ALARM state" +msgstr "如果在报警状态下连接,自动发送解锁命令" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Select this dialect" +msgstr "选择此方言" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "Delete '{label}'?" +msgstr "删除'{label}'?" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "" +"This custom dialect will be permanently removed. This action cannot be " +"undone." +msgstr "此自定义方言将被永久删除。此操作无法撤销。" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Cannot Delete Dialect" +msgstr "无法删除方言" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "This dialect is still used by the following machine(s): {machines}" +msgstr "此方言仍被以下机器使用:{machines}" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "Create from Template" +msgstr "从模板创建" + +#: rayforge/ui_gtk/machine/dialect_list.py +msgid "No custom dialects configured" +msgstr "未配置自定义方言" + +#: rayforge/ui_gtk/machine/dialect_list.py +#, python-brace-format +msgid "{label} (Copy)" +msgstr "{label}(副本)" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select Machine" +msgstr "选择机器" + +#: rayforge/ui_gtk/machine/machine_dropdown.py +msgid "Select active machine" +msgstr "选择活动机器" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Toggle laser on/off" +msgstr "开启/关闭激光" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Power" +msgstr "功率" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Laser power in percent" +msgstr "激光功率(百分比)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Pulse width in µs" +msgstr "脉冲宽度(µs)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Duration" +msgstr "持续时间" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +msgid "Seconds (0 = continuous)" +msgstr "秒(0 = 连续)" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "Tool {tool_number}, max power {max_power}" +msgstr "工具 {tool_number},最大功率 {max_power}" + +#: rayforge/ui_gtk/machine/laser_control_widget.py +#, python-brace-format +msgid "{seconds:.1f} s remaining" +msgstr "剩余 {seconds:.1f} 秒" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "G-code" +msgstr "G代码" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Precision" +msgstr "精度" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Configure the numeric precision of coordinate output." +msgstr "配置坐标输出的数值精度。" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "G-code Precision" +msgstr "G代码精度" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Number of decimal places for coordinates" +msgstr "坐标的小数位数" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Dialect" +msgstr "方言" + +#: rayforge/ui_gtk/machine/gcode_settings_page.py +msgid "Select, create and manage G-code dialect definitions." +msgstr "选择、创建和管理G代码方言定义。" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-West" +msgstr "向西北移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North" +msgstr "向北移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move North-East" +msgstr "向东北移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move West (Left)" +msgstr "向西(左)移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move East (Right)" +msgstr "向东(右)移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-West" +msgstr "向西南移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South" +msgstr "向南移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Move South-East" +msgstr "向东南移动" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home X" +msgstr "X轴归零" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Y" +msgstr "Y轴归零" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Home Z" +msgstr "Z轴归零" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/mainwindow.py +#: rayforge/ui_gtk/toolbar.py +msgid "Send to machine" +msgstr "发送到机器" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Increase Z-Distance" +msgstr "增加Z距离" + +#: rayforge/ui_gtk/machine/jog_widget.py +msgid "Decrease Z-Distance" +msgstr "减少Z距离" + +#: rayforge/ui_gtk/machine/jog_widget.py rayforge/ui_gtk/toolbar.py +msgid "Cancel running job" +msgstr "取消运行中的作业" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Select a Template" +msgstr "选择模板" + +#: rayforge/ui_gtk/machine/template_selector.py +msgid "Choose a built-in dialect as a starting point." +msgstr "选择一个内置方言作为起点。" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Hardware" +msgstr "硬件" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Axes" +msgstr "轴" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Configure the axis extents and coordinate system." +msgstr "配置轴范围和坐标系。" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Extent" +msgstr "X轴范围" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full X-axis travel range" +msgstr "X轴完整行程范围" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Extent" +msgstr "Y轴范围" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Full Y-axis travel range" +msgstr "Y轴完整行程范围" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Left" +msgstr "左下" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Left" +msgstr "左上" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Right" +msgstr "右上" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Right" +msgstr "右下" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Coordinate Origin (0,0)" +msgstr "坐标原点 (0,0)" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "The physical corner where coordinates are zero after homing" +msgstr "回原点后坐标为零的物理角落" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse X-Axis Direction" +msgstr "反转X轴方向" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Makes coordinate values negative" +msgstr "使坐标值为负" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Y-Axis Direction" +msgstr "反转Y轴方向" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Reverse Z-Axis Direction" +msgstr "反转Z轴方向" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Enable if a positive Z command (e.g., G0 Z10) moves the head down" +msgstr "如果正Z命令(例如 G0 Z10)使激光头向下移动,请启用此项" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work Area" +msgstr "工作区域" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Margins define the unusable space around the axis extents." +msgstr "边距定义轴范围周围不可使用的空间。" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Left Margin" +msgstr "左边距" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from left edge" +msgstr "左边缘不可用空间" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Top Margin" +msgstr "上边距" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from top edge" +msgstr "上边缘不可用空间" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Right Margin" +msgstr "右边距" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from right edge" +msgstr "右边缘不可用空间" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Bottom Margin" +msgstr "下边距" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Unusable space from bottom edge" +msgstr "下边缘不可用空间" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Workarea Origin Is Coordinate Zero" +msgstr "工作区原点为坐标零点" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "" +"Treat workarea origin as coordinate zero. Hides WCS controls and uses " +"workarea margins as offsets." +msgstr "将工作区原点视为坐标零点。隐藏WCS控制并使用工作区边距作为偏移量。" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Soft Limits" +msgstr "软限位" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "" +"Configurable safety bounds for jogging. Leave disabled to use work surface " +"bounds." +msgstr "手动移动的可配置安全边界。保持禁用以使用工作面边界。" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable Custom Soft Limits" +msgstr "启用自定义软限位" + +#: rayforge/ui_gtk/machine/hardware_page.py +msgid "Override work surface bounds with custom limits" +msgstr "使用自定义限位覆盖工作面边界" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Min" +msgstr "X最小值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum X coordinate" +msgstr "X坐标最小值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Min" +msgstr "Y最小值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Minimum Y coordinate" +msgstr "Y坐标最小值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "X Max" +msgstr "X最大值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum X coordinate" +msgstr "X坐标最大值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Y Max" +msgstr "Y最大值" + +#: rayforge/ui_gtk/machine/hardware_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Maximum Y coordinate" +msgstr "Y坐标最大值" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Optional. Configure any cameras you want to use for preview and alignment." +msgstr "可选。配置您想用于预览和对齐的任何相机。" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "" +"Set up cameras now or do it later from machine settings. The wizard records " +"which V4L devices you mark as 'enabled'; detailed lens calibration is " +"performed on the camera settings page." +msgstr "" +"现在设置相机,或稍后从机器设置中进行。向导会记录您标记为“启用”的 V4L 设备;详" +"细的镜头校准在相机设置页面中执行。" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "No cameras detected" +msgstr "未检测到相机" + +#: rayforge/ui_gtk/machine/wizard_pages/camera_page.py +msgid "You can add cameras later from machine settings." +msgstr "您可以稍后从机器设置中添加相机。" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Choose Controller" +msgstr "选择控制器" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "What kind of controller board does this machine use?" +msgstr "这台机器使用哪种控制器板?" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "Controller" +msgstr "控制器" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "" +"Pick the firmware / protocol family for this machine. If you aren't sure, " +"choose the closest match — you can refine individual settings later." +msgstr "" +"为此机器选择固件/协议系列。如果不确定,请选择最接近的匹配项 — 之后您可以微调" +"各个设置。" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "None — G-code export only" +msgstr "无 — 仅导出 G-code" + +#: rayforge/ui_gtk/machine/wizard_pages/controller_page.py +msgid "No physical controller; export G-code to a file" +msgstr "无实体控制器;将 G-code 导出到文件" + +#: rayforge/ui_gtk/machine/wizard_pages/__init__.py +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "New Machine" +msgstr "新机器" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "" +"Optional. Set up a rotary attachment now or skip this step to add one later " +"from machine settings." +msgstr "可选。立即设置旋转附件,或跳过此步骤以稍后从机器设置中添加。" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Module" +msgstr "模块" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Pick rotary type, axis, mode, and geometry." +msgstr "选择旋转类型、轴、模式和几何。" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Jaws / chuck" +msgstr "卡爪 / 卡盘" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rollers" +msgstr "滚轮" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Type" +msgstr "旋转类型" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "How the workpiece is held" +msgstr "工件的固定方式" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Rotary Axis" +msgstr "旋转轴" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Which axis the rotary uses" +msgstr "旋转使用的轴" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "True 4th Axis (keeps X/Y/Z)" +msgstr "真正的第四轴(保留 X/Y/Z)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Axis Replacement (swaps e.g. Y for A)" +msgstr "轴替换(例如将 Y 换成 A)" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Mode" +msgstr "模式" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Length per Rotation" +msgstr "每圈长度" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Auto-fetched from GRBL $101/$103 if probing" +msgstr "探测时自动从 GRBL $101/$103 获取" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Default Workpiece Ø" +msgstr "默认工件直径 Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Max Workpiece Length" +msgstr "最大工件长度" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Roller Ø" +msgstr "滚轮直径 Ø" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Required when using roller-type rotary" +msgstr "使用滚轮式旋转时必须填写" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Reverse Axis Direction" +msgstr "反转轴方向" + +#: rayforge/ui_gtk/machine/wizard_pages/rotary_page.py +msgid "Invert the rotary's rotation direction" +msgstr "反转旋转装置的旋转方向" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "—" +msgstr "—" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Yes" +msgstr "是" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "No" +msgstr "否" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Metric (mm)" +msgstr "公制(毫米)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Imperial (inches)" +msgstr "英制(英寸)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Review & Name" +msgstr "检查并命名" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Final name and sanity check before creating the machine." +msgstr "创建机器前的最终命名和合理性检查。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "A friendly name for this machine." +msgstr "此机器的友好名称。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine Name" +msgstr "机器名称" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Summary" +msgstr "摘要" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Warnings" +msgstr "警告" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "None (G-code export only)" +msgstr "无(仅导出 G-code)" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Unknown driver: {}" +msgstr "未知驱动程序:{}" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Connection" +msgstr "连接" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work Area X×Y" +msgstr "工作区域 X×Y" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Unit System" +msgstr "单位制" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Travel Speed" +msgstr "最大移动速度" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Max Cut Speed" +msgstr "最大切割速度" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Home on Start" +msgstr "启动时归零" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Rotary Modules" +msgstr "旋转模块" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "" +"No driver selected — this machine will only export G-code to files; it " +"cannot run jobs." +msgstr "未选择驱动程序 — 此机器只能将 G-code 导出到文件;无法运行作业。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Work area dimensions are unset or non-positive." +msgstr "工作区域尺寸未设置或非正数。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "No head is configured for this machine." +msgstr "未为此机器配置机头。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a laser but has no max_power setting." +msgstr "机头 #{n} 看起来像激光器,但没有 max_power 设置。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +#, python-brace-format +msgid "Head #{n} looks like a spindle but has no max_rpm setting." +msgstr "机头 #{n} 看起来像主轴,但没有 max_rpm 设置。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Machine name is blank." +msgstr "机器名称为空。" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Missing name" +msgstr "缺少名称" + +#: rayforge/ui_gtk/machine/wizard_pages/review_page.py +msgid "Please enter a name." +msgstr "请输入名称。" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Discover Device" +msgstr "发现设备" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Connect to the device and read its configuration, or skip to enter the " +"values manually." +msgstr "连接到设备并读取其配置,或跳过以手动输入数值。" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing" +msgstr "正在探测" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "" +"Auto-discover the machine's working area, speeds, and firmware capabilities " +"by reading its settings over the connection." +msgstr "通过连接读取其设置,自动发现机器的工作区域、速度和固件功能。" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe Now" +msgstr "立即探测" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probing…" +msgstr "正在探测…" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Connecting to device and reading settings" +msgstr "正在连接设备并读取设置" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe failed" +msgstr "探测失败" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Probe succeeded" +msgstr "探测成功" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Working area and speeds auto-detected." +msgstr "已自动检测工作区域和速度。" + +#: rayforge/ui_gtk/machine/wizard_pages/probe_page.py +msgid "Retry" +msgstr "重试" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Pick a starting point for the new machine." +msgstr "为新机器选择起点。" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Machine Templates" +msgstr "机器模板" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "" +"Pick a built-in profile to pre-fill common settings. You will still be asked " +"for connection-specific values." +msgstr "选择内置配置文件以预填常用设置。您仍需填写连接相关的数值。" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Search devices…" +msgstr "搜索设备…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import from File…" +msgstr "从文件导入…" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Device Not Listed" +msgstr "设备未列出" + +#: rayforge/ui_gtk/machine/wizard_pages/profile_page.py +msgid "Import Failed" +msgstr "导入失败" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "AI Provider" +msgstr "AI 提供商" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Configure an AI provider so the wizard can pre-fill known machine " +"specifications." +msgstr "配置 AI 提供商,以便向导预填已知的机器规格。" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "" +"Enter an OpenAI-compatible endpoint. This is only used for the automatic " +"spec lookup; you can also skip and enter the values by hand." +msgstr "" +"输入兼容 OpenAI 的端点。这仅用于自动规格查询;您也可以跳过并手动输入数值。" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Provider" +msgstr "默认提供商" + +#: rayforge/ui_gtk/machine/wizard_pages/provider_page.py +msgid "Default Model (optional)" +msgstr "默认模型(可选)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Work area (X, Y)" +msgstr "工作区域 (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Max cut speed" +msgstr "最大切割速度" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Coordinate origin" +msgstr "坐标原点" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head type" +msgstr "机头类型" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max power (S-value)" +msgstr "机头最大功率(S 值)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head max RPM" +msgstr "机头最大 RPM" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Head min RPM" +msgstr "机头最小 RPM" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Spot size (X, Y)" +msgstr "光斑尺寸 (X, Y)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "PWM frequency (Hz)" +msgstr "PWM 频率 (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Focal distance" +msgstr "焦距" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "AI Spec Lookup" +msgstr "AI 规格查询" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"If your machine is a known commercial model, the AI can pre-fill " +"specification values from the manufacturer's documentation." +msgstr "如果您的机器是已知的商业型号,AI 可以从制造商的文档中预填规格值。" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor & Model" +msgstr "厂商 & 型号" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"Enter the machine's vendor (manufacturer) and model name. The more specific, " +"the better — e.g. \"Sculpfun\" / \"S30 Pro\"." +msgstr "" +"输入机器的厂商(制造商)和型号名称。越具体越好 — 例如 \"Sculpfun\" / \"S30 " +"Pro\"。" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Vendor (e.g. Sculpfun)" +msgstr "厂商(例如 Sculpfun)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Model (e.g. S30 Pro)" +msgstr "型号(例如 S30 Pro)" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Look Up Specs" +msgstr "查询规格" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggestions" +msgstr "建议" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Suggested values are switched on; turn off any you don't want applied." +msgstr "建议值已开启;关闭您不想应用的值。" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"No AI provider is configured in Settings. Configure one to enable automatic " +"spec lookup, or skip this step and enter the values by hand." +msgstr "" +"设置中未配置 AI 提供商。配置一个以启用自动规格查询,或跳过此步骤并手动输入数" +"值。" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Looking up…" +msgstr "正在查询…" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Lookup failed" +msgstr "查询失败" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "" +"The AI couldn't return specifications for this machine. You can enter the " +"values manually in the next steps." +msgstr "AI 无法返回此机器的规格。您可以在后续步骤中手动输入数值。" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +#, python-brace-format +msgid "AI suggests: {value}" +msgstr "AI 建议:{value}" + +#: rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py +msgid "Main Head" +msgstr "主机头" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Enter the connection parameters for your device." +msgstr "输入设备的连接参数。" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "" +"Enter the connection parameters your machine requires. The exact fields " +"depend on the controller you chose in the previous step." +msgstr "输入您的机器所需的连接参数。具体字段取决于您在上一步中选择的控制器。" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Fixed by the chosen profile" +msgstr "由所选配置文件固定" + +#: rayforge/ui_gtk/machine/wizard_pages/connection_page.py +msgid "Invalid input" +msgstr "无效输入" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Work area, origin, speeds and acceleration." +msgstr "工作区域、原点、速度和加速度。" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Physical corner where coordinates are zero after homing" +msgstr "归位后坐标为零的物理角" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Enable if +Z moves head down" +msgstr "如果 +Z 使机头下移则启用" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Override work-surface bounds with custom limits" +msgstr "使用自定义限制覆盖工作台边界" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Speeds" +msgstr "速度" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Limits in machine units per minute." +msgstr "以每分钟机器单位为单位。" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum rapid movement speed" +msgstr "最大快速移动速度" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Maximum cutting speed" +msgstr "最大切割速度" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Used for time estimations and calculating the default overscan distance" +msgstr "用于时间估算和计算默认过冲距离" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Run homing cycle when machine connects" +msgstr "机器连接时执行归零循环" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Single-Axis Homing" +msgstr "单轴归零" + +#: rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +msgid "Allow homing individual axes" +msgstr "允许单轴归零" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "What's attached to the gantry: a laser, a spindle, or both?" +msgstr "龙门架上安装了什么:激光器、主轴还是两者?" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Type" +msgstr "机头类型" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Pick the primary head for this machine." +msgstr "为此机器选择主机头。" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Type of tool attached to this machine" +msgstr "此机器安装的工具类型" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Head Name" +msgstr "机头名称" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser Settings" +msgstr "激光设置" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max Power (S-value)" +msgstr "最大功率(S值)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Max laser power value in GCode" +msgstr "GCode 中的最大激光功率值" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on X axis" +msgstr "X 轴上的激光束宽度" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser beam width on Y axis" +msgstr "Y 轴上的激光束宽度" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "PWM Frequency (Hz)" +msgstr "PWM 频率 (Hz)" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Laser modulation frequency" +msgstr "激光调制频率" + +#: rayforge/ui_gtk/machine/wizard_pages/head_page.py +msgid "Lens-to-workpiece distance" +msgstr "镜头到工件的距离" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Replacement" +msgstr "轴替换" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "True 4th Axis" +msgstr "真正的第四轴" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#, python-brace-format +msgid "{mode}, Axis {axis}" +msgstr "{mode},轴 {axis}" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Add Rotary Module" +msgstr "添加旋转模块" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "No rotary modules configured" +msgstr "未配置旋转模块" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New Rotary Module" +msgstr "新建旋转模块" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Rotary Defaults" +msgstr "旋转默认设置" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default settings applied to new layers." +msgstr "应用于新图层的默认设置。" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Enable Rotary by Default" +msgstr "默认启用旋转" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "New layers will default to rotary mode" +msgstr "新图层将默认使用旋转模式" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Modules" +msgstr "模块" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Define the physical rotary modules attached to your machine. Select one as " +"the default." +msgstr "定义连接到您的机器的物理旋转模块。选择一个作为默认值。" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Connection Mode" +msgstr "连接模式" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary is connected to the machine controller" +msgstr "旋转模块如何连接到机器控制器" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis" +msgstr "轴" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis letter for this module" +msgstr "此模块的轴字母" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reversed Axis" +msgstr "反转轴" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Reverse the rotation direction of the rotary axis" +msgstr "反转旋转轴的旋转方向" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset X" +msgstr "轴偏移 X" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (X)" +msgstr "从模块位置到旋转轴的偏移量(X)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Y" +msgstr "轴偏移 Y" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Y)" +msgstr "从模块位置到旋转轴的偏移量(Y)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Axis Offset Z" +msgstr "轴偏移 Z" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Offset from module position to rotation axis (Z)" +msgstr "从模块位置到旋转轴的偏移量(Z)" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Jaws / Chuck" +msgstr "卡爪 / 卡盘" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Drive Type" +msgstr "驱动类型" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "How the rotary module drives the workpiece rotation" +msgstr "旋转模块如何驱动工件旋转" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Roller Diameter" +msgstr "滚轮直径" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Diameter of the drive roller" +msgstr "驱动滚轮的直径" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Travel per Rotation" +msgstr "每转行程" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "" +"Firmware distance for one full 360° rotation. 0 = raw circumferential output." +msgstr "固件完成一次完整360°旋转的距离。0 = 原始周向输出。" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default Workpiece Diameter" +msgstr "默认工件直径" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Default diameter for new layers using this module" +msgstr "使用此模块的新图层的默认直径" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Maximum workpiece length this module can accommodate" +msgstr "此模块可容纳的最大工件长度" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "X Position" +msgstr "X位置" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "X coordinate in machine space" +msgstr "机器空间中的 X 坐标" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Y Position" +msgstr "Y位置" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Y coordinate in machine space" +msgstr "机器空间中的 Y 坐标" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z Position" +msgstr "Z 位置" + +#: rayforge/ui_gtk/machine/rotary_module_page.py +msgid "Z coordinate in machine space" +msgstr "机器空间中的 Z 坐标" + +#: rayforge/ui_gtk/machine/capabilities_page.py +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Capabilities" +msgstr "功能" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "Machine Capabilities" +msgstr "机器功能" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "" +"Capabilities are inferred from the machine's heads, rotary modules, and any " +"explicit configuration. They control which steps are offered when adding to " +"a workflow." +msgstr "" +"功能根据机器的机头、旋转模块以及任何显式配置推断。它们控制添加到工作流时可用" +"的步骤。" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "explicit configuration" +msgstr "显式配置" + +#: rayforge/ui_gtk/machine/capabilities_page.py +msgid "unknown source" +msgstr "未知来源" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "{machine_name} - Machine Settings" +msgstr "{machine_name} - 机器设置" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/ui_gtk/main_menu.py +msgid "Machine Settings" +msgstr "机器设置" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Export Machine Profile" +msgstr "导出机器配置文件" + +#: rayforge/ui_gtk/machine/settings_dialog.py +msgid "Report an issue" +msgstr "报告问题" + +#: rayforge/ui_gtk/machine/settings_dialog.py +#, python-brace-format +msgid "Exported to {path}" +msgstr "已导出到 {path}" + +#: rayforge/ui_gtk/machine/settings_dialog.py rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export failed: {error}" +msgstr "导出失败:{error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Machine" +msgstr "机器" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Basic machine identification and configuration." +msgstr "基本机器识别和配置。" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Driver Settings" +msgstr "驱动程序设置" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Connection and communication settings for the machine driver." +msgstr "机器驱动程序的连接和通信设置。" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Select driver" +msgstr "选择驱动程序" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Speeds & Acceleration" +msgstr "速度和加速度" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Movement parameters used for job time estimation and path optimization." +msgstr "用于作业时间估算和路径优化的运动参数。" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The unit system used when emitting G-code and communicating with the device. " +"This setting is independent of the units used in the user interface." +msgstr "" +"生成 G 代码和与设备通信时使用的单位制。此设置与用户界面中使用的单位无关。" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Machine Unit System" +msgstr "机器单位制" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Configuration required: {error}" +msgstr "需要配置: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +#, python-brace-format +msgid "Error: {error}" +msgstr "错误: {error}" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "Not supported by the driver" +msgstr "驱动程序不支持" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G21 (millimeters) but the machine unit system is set " +"to imperial. G-code values will be emitted in inches — ensure your preamble " +"matches." +msgstr "" +"程序头包含 G21(毫米),但机器单位制设置为英制。G 代码值将以英寸输出——请确保" +"程序头匹配。" + +#: rayforge/ui_gtk/machine/general_preferences_page.py +msgid "" +"The preamble contains G20 (inches) but the machine unit system is set to " +"metric. G-code values will be emitted in millimeters — ensure your preamble " +"matches." +msgstr "" +"程序头包含 G20(英寸),但机器单位制设置为公制。G 代码值将以毫米输出——请确保" +"程序头匹配。" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Drag to reorder" +msgstr "拖动以重新排序" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Delete Variable" +msgstr "删除变量" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Key" +msgstr "键" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Default Value" +msgstr "默认值" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Start Value" +msgstr "起始值" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Minimum Value" +msgstr "最小值" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "End Value" +msgstr "结束值" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Maximum Value" +msgstr "最大值" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Value" +msgstr "调整值" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Adjust Slider Range" +msgstr "调整滑块范围" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "Add Parameter" +msgstr "添加参数" + +#: rayforge/ui_gtk/varset/varset_editor.py +msgid "New Parameter" +msgstr "新参数" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request Access" +msgstr "请求访问" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "API key configured" +msgstr "API密钥已配置" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request New Key" +msgstr "请求新密钥" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "No API key configured" +msgstr "未配置API密钥" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Hostname and port must be configured first" +msgstr "必须先配置主机名和端口" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Device not reachable or does not support automatic key requests" +msgstr "设备不可达或不支持自动密钥请求" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Unexpected response from device" +msgstr "设备返回了意外响应" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Too many requests. Try again later." +msgstr "请求过多。请稍后重试。" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Request failed: {code}" +msgstr "请求失败:{code}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#, python-brace-format +msgid "Connection failed: {err}" +msgstr "连接失败:{err}" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Waiting for approval on device…" +msgstr "正在设备上等待批准…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Waiting…" +msgstr "等待中…" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Approval timed out. Please try again." +msgstr "批准超时。请重试。" + +#: rayforge/ui_gtk/varset/adapter/appkey.py +msgid "Request denied or expired." +msgstr "请求被拒绝或已过期。" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authorize URL" +msgstr "授权URL" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token URL" +msgstr "令牌URL" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Client ID" +msgstr "客户端ID" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign In" +msgstr "登录" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Sign Out" +msgstr "登出" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Token expired" +msgstr "令牌已过期" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refresh" +msgstr "刷新" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Authenticated" +msgstr "已认证" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Re-authorize" +msgstr "重新授权" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Not connected" +msgstr "未连接" + +#: rayforge/ui_gtk/varset/adapter/oauth.py +msgid "Refreshing…" +msgstr "刷新中…" + +#: rayforge/ui_gtk/varset/adapter/base.py +msgid "None Selected" +msgstr "未选择" + +#: rayforge/ui_gtk/varset/adapter/registry.py +#, python-brace-format +msgid "Unsupported type: {t}" +msgstr "不支持的类型:{t}" + +#: rayforge/ui_gtk/varset/varsetwidget.py +msgid "Apply Change" +msgstr "应用更改" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Addon Registry" +msgstr "插件注册表" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Fetching registry..." +msgstr "正在获取注册表..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install from URL..." +msgstr "从URL安装..." + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Connection Failed" +msgstr "连接失败" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Could not reach the registry." +msgstr "无法连接到注册表。" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "No addons found in registry." +msgstr "注册表中未找到插件。" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Install" +msgstr "安装" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Update" +msgstr "更新" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Installed" +msgstr "已安装" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Version {v} already installed" +msgstr "版本 {v} 已安装" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Incompatible" +msgstr "不兼容" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +#, python-brace-format +msgid "Requires {deps}, but current rayforge version is {current}" +msgstr "需要 {deps},但当前Rayforge版本是 {current}" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Unavailable" +msgstr "不可用" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Manual Install" +msgstr "手动安装" + +#: rayforge/ui_gtk/addon_manager/addon_dialog.py +msgid "Enter the Git URL." +msgstr "输入Git URL。" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Enter License Key" +msgstr "输入许可证密钥" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Key" +msgstr "许可证密钥" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Activate" +msgstr "激活" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "Enter the license key you received when purchasing {addon_name}." +msgstr "输入您购买{addon_name}时收到的许可证密钥。" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Please enter a license key." +msgstr "请输入许可证密钥。" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Validating license..." +msgstr "正在验证许可证..." + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License validation failed." +msgstr "许可证验证失败。" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Invalid" +msgstr "许可证无效" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "License Required" +msgstr "需要许可证" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +#, python-brace-format +msgid "" +"{addon_name} is a premium addon. Purchase a license to unlock it, or enter " +"your license key if you already have one." +msgstr "" +"{addon_name}是高级插件。购买许可证以解锁,或如果您已有许可证密钥请输入。" + +#: rayforge/ui_gtk/addon_manager/license_dialog.py +msgid "Buy License" +msgstr "购买许可证" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to load this addon" +msgstr "加载此插件失败" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon will be unloaded when active jobs finish" +msgstr "此插件将在活动作业完成后卸载" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "This addon is incompatible with the current version of Rayforge" +msgstr "此插件与当前版本的Rayforge不兼容" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"This addon is experimental and may have unresolved issues. Use it with " +"caution." +msgstr "此插件为实验性质,可能存在未解决的问题。请谨慎使用。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Premium addon" +msgstr "高级插件" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Built-in addon" +msgstr "内置插件" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall Addon" +msgstr "卸载插件" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable or disable this addon" +msgstr "启用或禁用此插件" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Install New Addon..." +msgstr "安装新插件..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "No addons installed." +msgstr "未安装插件。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Installing {name}..." +msgstr "正在安装 {name}..." + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to install addon." +msgstr "安装插件失败。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Cannot Disable Addon" +msgstr "无法禁用插件" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon cannot be disabled.\n" +"\n" +"{reason}" +msgstr "" +"无法禁用此插件。\n" +"\n" +"{reason}" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Addon will be disabled when active jobs complete." +msgstr "插件将在活动作业完成后禁用。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to disable addon. Check the logs for details." +msgstr "禁用插件失败。请检查日志以获取详细信息。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon and its dependencies." +msgstr "启用插件及其依赖项失败。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable Dependencies?" +msgstr "启用依赖项?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "" +"This addon requires: {deps}\n" +"\n" +"Enable them as well?" +msgstr "" +"此插件需要:{deps}\n" +"\n" +"是否同时启用它们?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Enable All" +msgstr "全部启用" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Failed to enable addon. Check the logs for details." +msgstr "启用插件失败。请查看日志以获取详细信息。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +#, python-brace-format +msgid "Uninstall {name}?" +msgstr "卸载 {name}?" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "" +"The addon files will be removed. Restart recommended to fully clear memory." +msgstr "插件文件将被删除。建议重启以完全清除内存。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Uninstall" +msgstr "卸载" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Error deleting addon." +msgstr "删除插件时出错。" + +#: rayforge/ui_gtk/addon_manager/addon_list.py +msgid "Info" +msgstr "信息" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Experimental Addon?" +msgstr "启用实验性插件?" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +#, python-brace-format +msgid "" +"The addon \"{name}\" is experimental and may have unresolved issues. Use it " +"with caution." +msgstr "插件 \"{name}\" 为实验性质,可能存在未解决的问题。请谨慎使用。" + +#: rayforge/ui_gtk/addon_manager/experimental_dialog.py +msgid "Enable Anyway" +msgstr "仍然启用" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Help Improve Rayforge" +msgstr "帮助改进 Rayforge" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "" +"Would you like to help improve Rayforge by allowing anonymous usage " +"reporting? This helps us understand how the app is used and prioritize " +"improvements.\n" +"\n" +"No personal data is collected." +msgstr "" +"您是否愿意通过允许匿名使用报告来帮助改进 Rayforge?这有助于我们了解应用程序的" +"使用方式并确定改进的优先级。\n" +"\n" +"不会收集任何个人数据。" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "No Thanks" +msgstr "不用了,谢谢" + +#: rayforge/ui_gtk/shared/usage_consent_dialog.py +msgid "Allow Reporting" +msgstr "允许报告" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Show History" +msgstr "显示历史记录" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Unnamed Action" +msgstr "未命名操作" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Undo the last action" +msgstr "撤销上一步操作" + +#: rayforge/ui_gtk/shared/undo_button.py +msgid "Redo the last action" +msgstr "重做上一步操作" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle workpiece visibility" +msgstr "切换工件可见性" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle tab visibility" +msgstr "切换标签可见性" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle camera image visibility" +msgstr "切换摄像头图像可见性" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle 3D model visibility" +msgstr "切换 3D 模型可见性" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle grid visibility" +msgstr "切换网格可见性" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle travel move visibility" +msgstr "切换移动路径可见性" + +#: rayforge/ui_gtk/shared/visibility_overlay.py +msgid "Toggle no-go zone visibility" +msgstr "切换禁入区域可见性" + +#: rayforge/ui_gtk/shared/preferences_group.py +msgid "No parameters" +msgstr "无参数" + +#: rayforge/ui_gtk/shared/splitbutton.py +msgid "Show all options" +msgstr "显示所有选项" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +msgid "Select Model" +msgstr "选择模型" + +#: rayforge/ui_gtk/shared/model_selection_dialog.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Select" +msgstr "选择" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Job Sanity Check" +msgstr "作业检查" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "_Proceed" +msgstr "_继续" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} error(s)" +msgstr "{} 个错误" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "{} warning(s)" +msgstr "{} 个警告" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "No issues found." +msgstr "未发现问题。" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +#, python-brace-format +msgid "" +"Found {summary}. Proceeding may cause damage to your machine or workpiece." +msgstr "发现{summary}。继续操作可能会损坏您的机器或工件。" + +#: rayforge/ui_gtk/shared/sanity_check_dialog.py +msgid "Errors" +msgstr "错误" + +#: rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py +#, python-brace-format +msgid "Value in {unit}" +msgstr "{unit} 中的值" + +#: rayforge/ui_gtk/main_menu.py +msgid "New" +msgstr "新建" + +#: rayforge/ui_gtk/main_menu.py +msgid "Open..." +msgstr "打开..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Save As..." +msgstr "另存为..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Open Recent" +msgstr "打开最近的文件" + +#: rayforge/ui_gtk/main_menu.py +msgid "Import..." +msgstr "导入..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export G-code..." +msgstr "导出G代码..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Document..." +msgstr "导出文档..." + +#: rayforge/ui_gtk/main_menu.py +msgid "Quit" +msgstr "退出" + +#: rayforge/ui_gtk/main_menu.py +msgid "_File" +msgstr "文件(_F)" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Undo" +msgstr "撤销" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Redo" +msgstr "重做" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Cut" +msgstr "切割" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Copy" +msgstr "复制" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Duplicate" +msgstr "创建副本" + +#: rayforge/ui_gtk/main_menu.py +msgid "Select All" +msgstr "全选" + +#: rayforge/ui_gtk/main_menu.py +msgid "Clear Document" +msgstr "清空文档" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Edit" +msgstr "编辑(_E)" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Right Panel" +msgstr "显示右侧面板" + +#: rayforge/ui_gtk/main_menu.py +msgid "Show Bottom Panel" +msgstr "显示底部面板" + +#: rayforge/ui_gtk/main_menu.py +msgid "3D View" +msgstr "3D视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top View" +msgstr "顶视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Front View" +msgstr "前视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right View" +msgstr "右视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left View" +msgstr "左视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Back View" +msgstr "后视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Isometric View" +msgstr "等轴测视图" + +#: rayforge/ui_gtk/main_menu.py +msgid "Toggle Perspective" +msgstr "切换透视" + +#: rayforge/ui_gtk/main_menu.py +msgid "_View" +msgstr "视图(_V)" + +#: rayforge/ui_gtk/main_menu.py +msgid "Split" +msgstr "分割" + +#: rayforge/ui_gtk/main_menu.py +msgid "Export Object..." +msgstr "导出对象..." + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +msgid "Add Equidistant Tabs…" +msgstr "添加等距标签…" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Cardinal Tabs" +msgstr "添加方位标签" + +#: rayforge/ui_gtk/main_menu.py rayforge/doceditor/tab_cmd.py +msgid "Add Tabs" +msgstr "添加标签" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Object" +msgstr "对象(_O)" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Above" +msgstr "将选中项移至上层" + +#: rayforge/ui_gtk/main_menu.py +msgid "Move Selection to Layer Below" +msgstr "将选中项移至下层" + +#: rayforge/ui_gtk/main_menu.py +msgid "Left" +msgstr "左" + +#: rayforge/ui_gtk/main_menu.py +msgid "Right" +msgstr "右" + +#: rayforge/ui_gtk/main_menu.py +msgid "Top" +msgstr "顶" + +#: rayforge/ui_gtk/main_menu.py +msgid "Bottom" +msgstr "底" + +#: rayforge/ui_gtk/main_menu.py +msgid "Horizontally Center" +msgstr "水平居中" + +#: rayforge/ui_gtk/main_menu.py +msgid "Vertically Center" +msgstr "垂直居中" + +#: rayforge/ui_gtk/main_menu.py +msgid "Align" +msgstr "对齐" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Horizontally" +msgstr "水平分布" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/layout_cmd.py +msgid "Spread Vertically" +msgstr "垂直分布" + +#: rayforge/ui_gtk/main_menu.py +msgid "Distribute" +msgstr "分布" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Horizontal" +msgstr "水平翻转" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/toolbar.py +#: rayforge/doceditor/transform_cmd.py +msgid "Flip Vertical" +msgstr "垂直翻转" + +#: rayforge/ui_gtk/main_menu.py +msgid "Flip" +msgstr "翻转" + +#: rayforge/ui_gtk/main_menu.py +msgid "Array" +msgstr "阵列" + +#: rayforge/ui_gtk/main_menu.py +msgid "Arrange" +msgstr "排列" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Tools" +msgstr "工具(_T)" + +#: rayforge/ui_gtk/main_menu.py +msgid "Frame" +msgstr "走边框" + +#: rayforge/ui_gtk/main_menu.py +msgid "Send Job" +msgstr "发送作业" + +#: rayforge/ui_gtk/main_menu.py +msgid "Pause / Resume Job" +msgstr "暂停/恢复作业" + +#: rayforge/ui_gtk/main_menu.py +msgid "Cancel Job" +msgstr "取消作业" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Machine" +msgstr "机器(_M)" + +#: rayforge/ui_gtk/main_menu.py +msgid "About" +msgstr "关于" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/about.py +msgid "Donate" +msgstr "捐赠" + +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/debug_log_dialog.py +msgid "Save Debug Log" +msgstr "保存调试日志" + +#: rayforge/ui_gtk/main_menu.py +msgid "_Help" +msgstr "帮助(_H)" + +#: rayforge/ui_gtk/main_menu.py +msgid "(No Recent Items)" +msgstr "(无最近项目)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Maintenance Alert: {name} has reached its limit ({curr} / {limit})" +msgstr "维护警报:{name}已达到其限制 ({curr} / {limit})" + +#: rayforge/ui_gtk/mainwindow.py +msgid "View Counters" +msgstr "查看计数器" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid " (+{tasks} more)" +msgstr " (+{tasks} 更多)" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "{tasks} tasks" +msgstr "{tasks} 个任务" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Select a machine to enable G-code export" +msgstr "选择机器以启用G代码导出" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Generate G-code" +msgstr "生成G代码" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Cannot export while other tasks are running" +msgstr "无法在其他任务运行时导出" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before export. Press F5 to recalculate." +msgstr "导出前需要重新计算流水线。按F5重新计算。" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add a workpiece to enable export" +msgstr "添加工件以启用导出" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Add or enable a processing step to enable export" +msgstr "添加或启用处理步骤以启用导出" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Configure frame power to enable" +msgstr "配置边框功率以启用" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Cycle laser head around the occupied area" +msgstr "沿占用区域循环移动激光头" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Pipeline needs recalculation before sending. Press F5 to recalculate." +msgstr "发送前需要重新计算流水线。按F5重新计算。" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Resume machine" +msgstr "恢复机器" + +#: rayforge/ui_gtk/mainwindow.py rayforge/ui_gtk/toolbar.py +msgid "Pause machine" +msgstr "暂停机器" + +#: rayforge/ui_gtk/mainwindow.py +msgid "Please select a single object to export." +msgstr "请选择单个对象以导出。" + +#: rayforge/ui_gtk/mainwindow.py +#, python-brace-format +msgid "Debug log saved to {path}" +msgstr "调试日志已保存至 {path}" + +#: rayforge/ui_gtk/toolbar.py +msgid "Open Project" +msgstr "打开项目" + +#: rayforge/ui_gtk/toolbar.py +msgid "Import image" +msgstr "导入图像" + +#: rayforge/ui_gtk/toolbar.py +msgid "3D view disabled (missing dependencies like PyOpenGL)" +msgstr "3D视图已禁用(缺少PyOpenGL等依赖项)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Show 3D Preview" +msgstr "显示3D预览" + +#: rayforge/ui_gtk/toolbar.py +msgid "Recalculate (Shift+Click to force)" +msgstr "重新计算(Shift+点击强制执行)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle bottom panel" +msgstr "切换底部面板" + +#: rayforge/ui_gtk/toolbar.py +msgid "Arrange selection" +msgstr "排列选中项" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Cardinal Tabs (N,S,E,W)" +msgstr "添加方位标签 (北,南,东,西)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Add Tabs to selection" +msgstr "添加标签到选中项" + +#: rayforge/ui_gtk/toolbar.py +msgid "Home the machine" +msgstr "机器回原点" + +#: rayforge/ui_gtk/toolbar.py +msgid "Clear machine alarm (unlock)" +msgstr "清除机器报警 (解锁)" + +#: rayforge/ui_gtk/toolbar.py +msgid "Toggle focus laser" +msgstr "切换聚焦激光" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine not fully configured" +msgstr "机器未完全配置" + +#: rayforge/ui_gtk/toolbar.py +msgid "Machine driver is missing required settings. Click to edit." +msgstr "机器驱动程序缺少必要设置。点击以编辑。" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Horizontally" +msgstr "水平居中" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Center Vertically" +msgstr "垂直居中" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Left" +msgstr "左对齐" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Right" +msgstr "右对齐" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Top" +msgstr "顶对齐" + +#: rayforge/ui_gtk/toolbar.py rayforge/doceditor/layout_cmd.py +msgid "Align Bottom" +msgstr "底对齐" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "" +"Create a ZIP archive with log files and system information for " +"troubleshooting." +msgstr "创建包含日志文件和系统信息的ZIP归档文件用于故障排除。" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Include current project" +msgstr "包含当前项目" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Add the current project file to the debug archive" +msgstr "将当前项目文件添加到调试归档中" + +#: rayforge/ui_gtk/debug_log_dialog.py rayforge/ui_gtk/project_cmd.py +msgid "_Save" +msgstr "保存(_S)" + +#: rayforge/ui_gtk/debug_log_dialog.py +msgid "Failed to create debug archive." +msgstr "创建调试归档失败。" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "Error saving file: {msg}" +msgstr "保存文件错误:{msg}" + +#: rayforge/ui_gtk/debug_log_dialog.py +#, python-brace-format +msgid "An unexpected error occurred: {error}" +msgstr "发生意外错误:{error}" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Unsaved Changes" +msgstr "未保存的更改" + +#: rayforge/ui_gtk/project_cmd.py +msgid "The current project has unsaved changes. Do you want to save them?" +msgstr "当前项目有未保存的更改。您想保存吗?" + +#: rayforge/ui_gtk/project_cmd.py +msgid "_Don't Save" +msgstr "不保存(_D)" + +#: rayforge/ui_gtk/project_cmd.py +msgid "New project created" +msgstr "新项目已创建" + +#: rayforge/ui_gtk/project_cmd.py +msgid "Untitled" +msgstr "未命名" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Asset" +msgstr "添加资产" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Add Sketch" +msgstr "添加草图" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Create New Workpiece" +msgstr "创建新工件" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset(s)" +msgstr "剪切素材" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Cut asset" +msgstr "剪切素材" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset(s)" +msgstr "粘贴素材" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Paste asset" +msgstr "粘贴素材" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset(s)" +msgstr "复制素材" + +#: rayforge/ui_gtk/doceditor/asset_browser.py +msgid "Duplicate asset" +msgstr "复制素材" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Map to Existing" +msgstr "映射到现有" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "New Layers" +msgstr "新建图层" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Flatten" +msgstr "展平" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Import Mode" +msgstr "图层导入模式" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "How imported layers are mapped to document layers" +msgstr "导入的图层如何映射到文档图层" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "SVG Layers" +msgstr "SVG 图层" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Colors" +msgstr "颜色" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer Source" +msgstr "图层来源" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Group imported geometry by SVG layer or by color" +msgstr "按 SVG 图层或颜色对导入的几何图形分组" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Image" +msgstr "导入图像" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"The file produced no output in direct vector mode. Files containing text or " +"other non-path elements should be converted to paths before importing (e.g., " +"in Inkscape: Path > Object to Path)." +msgstr "" +"该文件在直接矢量模式下没有产生输出。包含文本或其他非路径元素的文件应在导入前" +"转换为路径(例如,在 Inkscape 中:路径 > 对象转路径)。" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Switch to Trace Mode" +msgstr "切换到描摹模式" + +#: rayforge/ui_gtk/doceditor/import_dialog.py rayforge/doceditor/file_cmd.py +msgid "Re-Import" +msgstr "重新导入" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import" +msgstr "导入" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Mode" +msgstr "导入模式" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Use Original Vectors" +msgstr "使用原始矢量" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import vector data directly" +msgstr "直接导入矢量数据" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "DPI" +msgstr "DPI" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "" +"Pixels per inch for unitless SVG dimensions. Inkscape ≥0.92 uses 96, older " +"Inkscape uses 90, Illustrator uses 72" +msgstr "" +"无单位 SVG 尺寸的每英寸像素数。Inkscape ≥0.92 使用 96,较旧的 Inkscape 使用 " +"90,Illustrator 使用 72" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Layers" +msgstr "图层" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace Settings" +msgstr "描摹设置" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import Whole Image" +msgstr "导入整个图像" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Import the entire image without tracing" +msgstr "导入整个图像而不进行描摹" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Auto Threshold" +msgstr "自动阈值" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Automatically determine the trace threshold" +msgstr "自动确定描摹阈值" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Threshold" +msgstr "阈值" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace objects darker than this value" +msgstr "描摹暗于此值的对象" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Invert" +msgstr "反转" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Trace light objects on a dark background" +msgstr "描摹深色背景上的浅色对象" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Select Layers" +msgstr "选择图层" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Layer is empty" +msgstr "图层为空" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +#, python-brace-format +msgid "Layer with {n} vectors" +msgstr "包含 {n} 个矢量的图层" + +#: rayforge/ui_gtk/doceditor/import_dialog.py +msgid "Generating preview..." +msgstr "正在生成预览..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Applicability" +msgstr "适用性" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"Define when this recipe should be suggested. Leave fields blank to match any " +"value." +msgstr "定义何时建议此配方。留空字段以匹配任何值。" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Any" +msgstr "任何" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Step Types" +msgstr "步骤类型" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "" +"The step types this recipe applies to. Leave empty to match any step type." +msgstr "此配方适用的步骤类型。留空以匹配任何步骤类型。" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Select..." +msgstr "选择..." + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Step Types Selection" +msgstr "清除步骤类型选择" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material" +msgstr "材料" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Clear Material Selection" +msgstr "清除材料选择" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Min Thickness" +msgstr "最小厚度" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Minimum stock thickness for this recipe to apply" +msgstr "应用此配方的最小毛坯厚度" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Max Thickness" +msgstr "最大厚度" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Maximum stock thickness for this recipe to apply" +msgstr "应用此配方的最大毛坯厚度" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "…" +msgstr "…" + +#: rayforge/ui_gtk/doceditor/recipes/pages/applicability.py +msgid "Not Found" +msgstr "未找到" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Recipe" +msgstr "配方" + +#: rayforge/ui_gtk/doceditor/recipes/pages/general.py +msgid "A named preset of settings that can be automatically applied later." +msgstr "一组命名的设置预设,稍后可自动应用。" + +#: rayforge/ui_gtk/doceditor/recipes/pages/settings.py +msgid "" +"The settings that will be applied by this recipe. When multiple step types " +"are selected, only settings common to all of them are shown." +msgstr "此配方将应用的设置。选择多个步骤类型时,仅显示所有步骤类型共有的设置。" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Post Processing" +msgstr "后处理" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +msgid "" +"Transformer settings applied by this recipe. When multiple step types are " +"selected, only transformers common to all of them are shown." +msgstr "" +"此配方应用的转换器设置。选择多个步骤类型时,仅显示所有步骤类型共有的转换器。" + +#: rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "No post-processing options available for this step." +msgstr "此步骤无可用的后处理选项。" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +msgid "Edit Recipe" +msgstr "编辑配方" + +#: rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Add New Recipe" +msgstr "添加新配方" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Machine" +msgstr "未知机器" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "Unknown Material" +msgstr "未知材料" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "No recipes found." +msgstr "未找到配方。" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_list.py +msgid "The recipe will be permanently removed. This action cannot be undone." +msgstr "配方将被永久删除。此操作无法撤销。" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Select Recipe" +msgstr "选择配方" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Choose a recipe to apply to the current step." +msgstr "选择要应用于当前步骤的配方。" + +#: rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py +msgid "Show only compatible recipes" +msgstr "仅显示兼容的配方" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Step name and recipe settings." +msgstr "步骤名称和配方设置。" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Cooling" +msgstr "冷却" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +msgid "Coolant used while this operation runs." +msgstr "此操作运行期间使用的冷却液。" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/base.py +#: rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py +#, python-brace-format +msgid "Change {key}" +msgstr "更改{key}" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py +msgid "Transformers applied to this step's generated toolpath." +msgstr "应用于此步骤生成的刀具路径的转换器。" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py +msgid "Speed of rapid positioning moves" +msgstr "快速定位移动的速度" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Off" +msgstr "关闭" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Flood" +msgstr "喷淋" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +#: rayforge/doceditor/file_cmd.py +msgid "Mist" +msgstr "雾化" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "Coolant delivered to the workpiece while cutting" +msgstr "切割时输送到工件的冷却液" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py +msgid "This cooling method is not supported by the current machine" +msgstr "当前机器不支持此冷却方法" + +#: rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py +msgid "Speed of the cutting operation" +msgstr "切割操作的速度" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +#, python-brace-format +msgid "{name} Settings" +msgstr "{name} 设置" + +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Step Settings" +msgstr "步骤设置" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Choose..." +msgstr "选择..." + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Manual Settings" +msgstr "手动设置" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Apply Recipe '{name}'" +msgstr "应用配方 '{name}'" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Apply Recipe Transformer" +msgstr "应用配方转换器" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "New {label} Recipe" +msgstr "新建 {label} 配方" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "Set Applied Recipe" +msgstr "设置应用的配方" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +#, python-brace-format +msgid "Update Recipe '{name}'?" +msgstr "更新配方 '{name}'?" + +#: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py +msgid "" +"This will permanently overwrite the saved recipe with the current step " +"settings. This action cannot be undone." +msgstr "这将用当前步骤设置永久覆盖保存的配方。此操作无法撤销。" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "1 material" +msgstr "1 种材料" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} materials" +msgstr "{count} 种材料" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +#, python-brace-format +msgid "{count} (Read-only)" +msgstr "{count} (只读)" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Add New Library" +msgstr "添加新库" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "No libraries found." +msgstr "未找到库。" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "" +"The library folder and all its materials will be permanently removed. This " +"action cannot be undone." +msgstr "该库文件夹及其所有材料将被永久删除。此操作无法撤销。" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Edit Library" +msgstr "编辑库" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a new name for the library:" +msgstr "输入库的新名称:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Library name" +msgstr "库名称" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to rename library." +msgstr "重命名库失败。" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Enter a name for the new library folder:" +msgstr "输入新库文件夹的名称:" + +#: rayforge/ui_gtk/doceditor/material_library_list.py +msgid "Failed to create library. A folder with that name may already exist." +msgstr "创建库失败。具有该名称的文件夹可能已存在。" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Open File" +msgstr "打开文件" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "All supported" +msgstr "所有支持的格式" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Save G-code File" +msgstr "保存G代码文件" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "G-code files" +msgstr "G代码文件" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Object" +msgstr "导出对象" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +msgid "Export Document" +msgstr "导出文档" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/svg/exporter.py +msgid "SVG (Scalable Vector Graphics)" +msgstr "SVG(可缩放矢量图形)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py rayforge/image/dxf/exporter.py +msgid "DXF (CAD Exchange Format)" +msgstr "DXF(CAD交换格式)" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Open {app_name} Project" +msgstr "打开 {app_name} 项目" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "{app_name} Project" +msgstr "{app_name} 项目" + +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Save {app_name} Project" +msgstr "保存 {app_name} 项目" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Edit Material" +msgstr "编辑材料" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Update the material details:" +msgstr "更新材料详情:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Add New Material" +msgstr "添加新材料" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Enter the details for the new material:" +msgstr "输入新材料的详细信息:" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Category" +msgstr "类别" + +#: rayforge/ui_gtk/doceditor/add_material_dialog.py +msgid "Custom" +msgstr "自定义" + +#: rayforge/ui_gtk/doceditor/layers_tab.py +msgid "Add New Layer" +msgstr "添加新图层" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Stock Properties" +msgstr "毛坯属性" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Thickness" +msgstr "厚度" + +#: rayforge/ui_gtk/doceditor/stock_properties_dialog.py +msgid "Material thickness" +msgstr "材料厚度" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Assets" +msgstr "资产" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "G-code Viewer" +msgstr "G代码查看器" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Console" +msgstr "控制台" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Controls" +msgstr "控件" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Offsets" +msgstr "当前偏移" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Edit Offsets Manually" +msgstr "手动编辑偏移" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Current Position" +msgstr "当前位置" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Lower-Left of Selection or Workarea" +msgstr "移动到选择或工作区域的左下角" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Center of Selection or Workarea" +msgstr "移动到选择或工作区域的中心" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Upper-Right of Selection or Workarea" +msgstr "移动到选择或工作区域的右上角" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Move to Origin of Active WCS" +msgstr "移动到活动WCS原点" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Zero Axes" +msgstr "归零轴" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current X position as 0 for active WCS" +msgstr "将当前X位置设置为活动WCS的0" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Y position as 0 for active WCS" +msgstr "将当前Y位置设置为活动WCS的0" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current Z position as 0 for active WCS" +msgstr "将当前Z位置设置为活动WCS的0" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set Work Zero at Current Position" +msgstr "在当前位置设置工件零点" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click Canvas to Set Work Zero" +msgstr "点击画布设置工作零点" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Click on canvas to set work zero" +msgstr "点击画布设置工作零点" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Speed" +msgstr "点动速度" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Jog Distance" +msgstr "点动距离" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Distance in machine units" +msgstr "以机器单位表示的距离" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Overridden by the current layer. Change it in the layer settings." +msgstr "已被当前图层覆盖。请在图层设置中更改。" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Offline - Position Unknown" +msgstr "离线 - 位置未知" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +#, python-brace-format +msgid "Offsets cannot be set in Machine Coordinate Mode ({wcs})" +msgstr "无法在机器坐标模式({wcs})下设置偏移" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Machine must be connected to set Zero Here" +msgstr "必须连接机器才能在此处归零" + +#: rayforge/ui_gtk/doceditor/bottom_panel.py +msgid "Set current position as 0" +msgstr "将当前位置设置为0" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Select Step Types" +msgstr "选择步骤类型" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Choose which step types this recipe applies to." +msgstr "选择此配方适用的步骤类型。" + +#: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py +msgid "Search..." +msgstr "搜索..." + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "Missing Features" +msgstr "缺失的功能" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses a feature that is not available: {}" +msgstr "此文档使用了不可用的功能:{}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "This document uses features that are not available: {}" +msgstr "此文档使用了不可用的功能:{}" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "The document can still be edited and saved." +msgstr "该文档仍可编辑和保存。" + +#: rayforge/ui_gtk/doceditor/missing_features_dialog.py +msgid "_OK" +msgstr "_确定" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Select Material" +msgstr "选择材料" + +#: rayforge/ui_gtk/doceditor/material_selector.py +msgid "Choose a material from the available libraries." +msgstr "从可用库中选择材料。" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "No Operations" +msgstr "无操作" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +msgid "Add Step" +msgstr "添加步骤" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Reorder steps" +msgstr "重新排序步骤" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Add step '{name}'" +msgstr "添加步骤 '{name}'" + +#: rayforge/ui_gtk/doceditor/workflow_row.py +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "Remove step '{name}'" +msgstr "移除步骤 '{name}'" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Layer Settings" +msgstr "图层设置" + +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Delete this layer" +msgstr "删除此图层" + +#: rayforge/ui_gtk/doceditor/layer_column.py rayforge/doceditor/layer_cmd.py +msgid "Toggle layer visibility" +msgstr "切换图层可见性" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Relative to {wcs} origin" +msgstr "相对于{wcs}原点" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Zero is on the left side" +msgstr "零点在左侧" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset X position to 0" +msgstr "重置X位置为0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset Y position to 0" +msgstr "重置Y位置为0" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Fixed Ratio" +msgstr "固定比例" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural width" +msgstr "重置为自然宽度" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural height" +msgstr "重置为自然高度" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset to natural aspect ratio" +msgstr "重置为自然宽高比" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Angle" +msgstr "角度" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Clockwise is positive" +msgstr "顺时针为正" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Shear" +msgstr "剪切" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Horizontal shear angle" +msgstr "水平剪切角" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset angle to 0°" +msgstr "重置角度为0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +msgid "Reset shear to 0°" +msgstr "重置剪切为0°" + +#: rayforge/ui_gtk/doceditor/property_providers/transform.py +#, python-brace-format +msgid "Natural: {val}" +msgstr "自然:{val}" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Source File" +msgstr "源文件" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show Image Metadata" +msgstr "显示图像元数据" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Show in File Browser" +msgstr "在文件浏览器中显示" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Vector Commands" +msgstr "矢量命令" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{count} commands" +msgstr "{count} 个命令" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{name} (not found)" +msgstr "{name} (未找到)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "(No source file)" +msgstr "(无源文件)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Tabs" +msgstr "标签" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Remove all tabs" +msgstr "移除所有标签" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Tab Width" +msgstr "标签宽度" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Length along the path" +msgstr "沿路径的长度" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Reset tab width to default (1.0)" +msgstr "将标签宽度重置为默认值(1.0)" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +#, python-brace-format +msgid "{num_tabs} tabs" +msgstr "{num_tabs} 个标签" + +#: rayforge/ui_gtk/doceditor/property_providers/workpiece.py +msgid "Mixed values" +msgstr "混合值" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Number of Tabs" +msgstr "标签数量" + +#: rayforge/ui_gtk/doceditor/add_tabs_popover.py +msgid "Adjust Equidistant Tabs" +msgstr "调整等距标签" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Enable {}" +msgstr "启用 {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Toggle {}" +msgstr "切换 {}" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Leave Unchanged" +msgstr "保持不变" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py +msgid "Disabled" +msgstr "已禁用" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "This feature is not available." +msgstr "此功能不可用。" + +#: rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py +msgid "" +"The required component '{}' could not be found. The document can still be " +"saved." +msgstr "找不到所需的组件 '{}'。该文档仍可保存。" + +#: rayforge/ui_gtk/doceditor/step_box.py +msgid "Toggle step visibility" +msgstr "切换步骤可见性" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Image Metadata" +msgstr "图像元数据" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Copy Metadata" +msgstr "复制元数据" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "No metadata available" +msgstr "无可用元数据" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic Information" +msgstr "基本信息" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Basic image properties like dimensions and format." +msgstr "图像的基本属性,如尺寸和格式。" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata" +msgstr "元数据" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "All metadata extracted from the image." +msgstr "从图像中提取的所有元数据。" + +#: rayforge/ui_gtk/doceditor/image_metadata_dialog.py +msgid "Metadata copied to clipboard" +msgstr "元数据已复制到剪贴板" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Item Properties" +msgstr "项目属性" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "1 item selected" +msgstr "选择了 1 个项目" + +#: rayforge/ui_gtk/doceditor/item_properties.py +#, python-brace-format +msgid "{count} items selected" +msgstr "选择了 {count} 个项目" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Multiple Items" +msgstr "多个项目" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Workpiece Properties" +msgstr "工件属性" + +#: rayforge/ui_gtk/doceditor/item_properties.py +msgid "Group Properties" +msgstr "组属性" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +#, python-brace-format +msgid "{name} - Settings" +msgstr "{name} - 设置" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Close" +msgstr "关闭" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Basic layer settings such as appearance and coordinate system." +msgstr "基本图层设置,如外观和坐标系。" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Color used for operations in this layer" +msgstr "此图层中操作使用的颜色" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Coordinate System" +msgstr "坐标系" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"The work coordinate system origin to use for this layer. By default, use the " +"WCS selected in the main window" +msgstr "此图层使用的工作坐标系原点。默认使用主窗口中选择的WCS" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Rotary Attachment" +msgstr "旋转附件" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "" +"Configure rotary attachment for cylindrical objects. When enabled, Y-axis " +"movements are converted to rotational movements in degrees." +msgstr "" +"为圆柱形物体配置旋转附件。启用后,Y 轴运动将转换为以度为单位的旋转运动。" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Enable Rotary Mode" +msgstr "启用旋转模式" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Convert Y-axis to rotary axis" +msgstr "将 Y 轴转换为旋转轴" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Select the rotary module for this layer" +msgstr "选择此图层的旋转模块" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Object Diameter" +msgstr "物体直径" + +#: rayforge/ui_gtk/doceditor/layer_settings_dialog.py +msgid "Diameter of the cylindrical object" +msgstr "圆柱体对象的直径" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "No materials in selected library." +msgstr "所选库中无材料。" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Cannot Delete Material" +msgstr "无法删除材料" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"This material is currently used by one or more recipes. Please remove the " +"recipes that use this material before deleting it." +msgstr "此材料目前正被一个或多个配方使用。请在删除之前移除使用此材料的配方。" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "" +"The material will be permanently removed from the library. This action " +"cannot be undone." +msgstr "材料将从库中永久删除。此操作无法撤销。" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to update material." +msgstr "更新材料失败。" + +#: rayforge/ui_gtk/doceditor/material_list.py +msgid "Failed to add material to library." +msgstr "添加材料到库失败。" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "Batch Import {file_count} Images" +msgstr "批量导入 {file_count} 个图像" + +#: rayforge/ui_gtk/doceditor/import_handler.py +#, python-brace-format +msgid "" +"Import {file_count} images:\n" +"{file_names}\n" +"\n" +"All images will be traced using the default tracing settings and positioned " +"at the drop location." +msgstr "" +"导入 {file_count} 个图像:\n" +"{file_names}\n" +"\n" +"所有图像将使用默认描摹设置进行描摹,并定位在拖放位置。" + +#: rayforge/ui_gtk/doceditor/import_handler.py +msgid "Import All" +msgstr "全部导入" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +msgid "Add New Step..." +msgstr "添加新步骤..." + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} step" +msgstr "{count} 个步骤" + +#: rayforge/ui_gtk/doceditor/workflow_view.py +#, python-brace-format +msgid "{count} steps" +msgstr "{count} 个步骤" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Play simulation" +msgstr "播放模拟" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step backward" +msgstr "后退" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Step forward" +msgstr "前进" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Playback speed" +msgstr "播放速度" + +#: rayforge/ui_gtk/sim3d/playback_overlay.py +msgid "Pause simulation" +msgstr "暂停模拟" + +#: rayforge/ui_gtk/about.py +msgid "Not found" +msgstr "未找到" + +#: rayforge/ui_gtk/about.py +msgid "UI Toolkit" +msgstr "UI工具包" + +#: rayforge/ui_gtk/about.py +msgid "Graphics & Imaging" +msgstr "图形与成像" + +#: rayforge/ui_gtk/about.py +msgid "Geometry" +msgstr "几何" + +#: rayforge/ui_gtk/about.py +msgid "File Formats & Communication" +msgstr "文件格式与通信" + +#: rayforge/ui_gtk/about.py +msgid "Website" +msgstr "网站" + +#: rayforge/ui_gtk/about.py +msgid "Report an Issue" +msgstr "报告问题" + +#: rayforge/ui_gtk/about.py +msgid "Version" +msgstr "版本" + +#: rayforge/ui_gtk/about.py +msgid "Copy Version" +msgstr "复制版本" + +#: rayforge/ui_gtk/about.py +msgid "Lead Developer" +msgstr "首席开发者" + +#: rayforge/ui_gtk/about.py +msgid "License" +msgstr "许可证" + +#: rayforge/ui_gtk/about.py +msgid "System Information" +msgstr "系统信息" + +#: rayforge/ui_gtk/about.py +msgid "Versions of libraries and components" +msgstr "库和组件的版本" + +#: rayforge/ui_gtk/about.py +msgid "Copy System Information" +msgstr "复制系统信息" + +#: rayforge/ui_gtk/about.py +msgid "Supporters" +msgstr "支持者" + +#: rayforge/ui_gtk/about.py +msgid "People who donated to the project" +msgstr "捐赠给项目的人" + +#: rayforge/ui_gtk/about.py +msgid "" +"Special thanks go to everyone who has donated to support Rayforge! You keep " +"the coffee and the AI tokens flowing!" +msgstr "特别感谢所有捐款支持Rayforge的人!你们让咖啡和AI代币源源不断!" + +#: rayforge/ui_gtk/about.py +#, python-brace-format +msgid "About {app_name}" +msgstr "关于 {app_name}" + +#: rayforge/shared/units/definitions.py +msgid "mm/min" +msgstr "毫米/分钟" + +#: rayforge/shared/units/definitions.py +msgid "mm/s" +msgstr "毫米/秒" + +#: rayforge/shared/units/definitions.py +msgid "in/min" +msgstr "英寸/分钟" + +#: rayforge/shared/units/definitions.py +msgid "in/s" +msgstr "英寸/秒" + +#: rayforge/shared/units/definitions.py +msgid "mm" +msgstr "毫米" + +#: rayforge/shared/units/definitions.py +msgid "cm" +msgstr "厘米" + +#: rayforge/shared/units/definitions.py +msgid "m" +msgstr "米" + +#: rayforge/shared/units/definitions.py +msgid "in" +msgstr "英寸" + +#: rayforge/shared/units/definitions.py +msgid "ft" +msgstr "英尺" + +#: rayforge/shared/units/definitions.py +msgid "mm/s²" +msgstr "毫米/秒²" + +#: rayforge/shared/units/definitions.py +msgid "cm/s²" +msgstr "厘米/秒²" + +#: rayforge/shared/units/definitions.py +msgid "m/s²" +msgstr "米/秒²" + +#: rayforge/shared/units/definitions.py +msgid "in/s²" +msgstr "英寸/秒²" + +#: rayforge/shared/units/definitions.py +msgid "ft/s²" +msgstr "英尺/秒²" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size} B" +msgstr "{size} 字节" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} KB" +msgstr "{size:.1f} KB" + +#: rayforge/shared/util/size.py +#, python-brace-format +msgid "{size:.1f} MB" +msgstr "{size:.1f} MB" + +#: rayforge/shared/util/time_format.py +msgid "{:.0f}s" +msgstr "{:.0f}秒" + +#: rayforge/shared/util/time_format.py +msgid "{}m" +msgstr "{}分钟" + +#: rayforge/shared/util/time_format.py +msgid "{}h" +msgstr "{}小时" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "{line_count:,} lines · {size}" +msgstr "{line_count:,} 行 · {size}" + +#: rayforge/shared/gcodeedit/viewer.py +msgid "— Truncated (showing first 20,000 of {line_count:,} lines) —" +msgstr "— 已截断(显示 {line_count:,} 行中的前 20,000 行)—" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Checking for addon updates..." +msgstr "正在检查插件更新..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "An update is available for {name}." +msgstr "{name} 有可用更新。" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1} and {name2}." +msgstr "{name1} 和 {name2} 有可用更新。" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Updates are available for {name1}, {name2}, and {num} others." +msgstr "{name1}、{name2} 和其他 {num} 个项目有可用更新。" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Install All" +msgstr "全部安装" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addon updates found." +msgstr "发现插件更新。" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Addons are up to date." +msgstr "插件已是最新。" + +#: rayforge/addon_mgr/update_cmd.py +msgid "Installing addon updates..." +msgstr "正在安装插件更新..." + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Addon successfully updated." +msgid_plural "{num} addons successfully updated." +msgstr[0] "插件已成功更新。" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "{num_s} addons updated, {num_f} failed." +msgstr "{num_s} 个插件已更新,{num_f} 个失败。" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Failed to update addon." +msgid_plural "Failed to update {num} addons." +msgstr[0] "更新插件失败。" + +#: rayforge/addon_mgr/update_cmd.py +#, python-brace-format +msgid "Finished with {num_failed} errors." +msgstr "完成,但有 {num_failed} 个错误。" + +#: rayforge/addon_mgr/update_cmd.py +msgid "All addon updates installed!" +msgstr "所有插件更新已安装!" + +#: rayforge/app.py +#, python-brace-format +msgid "Cannot open '{file}'. The required addon may be disabled." +msgstr "'{file}' 无法打开。所需的附加组件可能已被禁用。" + +#: rayforge/app.py +msgid "A GCode generator for laser cutters." +msgstr "激光切割机的G代码生成器。" + +#: rayforge/app.py +msgid "Paths to one or more input SVG or image files." +msgstr "一个或多个输入SVG或图像文件的路径。" + +#: rayforge/app.py +msgid "" +"Force import as direct vectors. This is the default for supported files." +msgstr "强制导入为直接矢量。这是支持文件的默认设置。" + +#: rayforge/app.py +msgid "" +"Force import by tracing the file's bitmap representation. Aborts if not " +"supported." +msgstr "通过描摹文件的位图表示强制导入。如果不支持则中止。" + +#: rayforge/app.py +msgid "Set the logging level (default: INFO)" +msgstr "设置日志记录级别 (默认: INFO)" + +#: rayforge/app.py +msgid "" +"Exit after importing documents and the editor has settled. Useful for " +"testing." +msgstr "导入文档并编辑器稳定后退出。用于测试。" + +#: rayforge/app.py +msgid "" +"Path to a Python script to execute after the main window is fully loaded. " +"Useful for automation and testing." +msgstr "主窗口完全加载后执行的 Python 脚本路径。适用于自动化和测试。" + +#: rayforge/app.py +msgid "" +"Path to a custom configuration directory. Useful for testing with isolated " +"configs." +msgstr "自定义配置目录的路径。适用于使用独立配置进行测试。" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregate" +msgstr "聚合" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "{status} — {activity}" +msgstr "{status} — {activity}" + +#: rayforge/pipeline/status_messages.py +msgid "Aggregating job" +msgstr "正在聚合作业" + +#: rayforge/pipeline/status_messages.py +msgid "Generating machine code" +msgstr "正在生成机器代码" + +#: rayforge/pipeline/status_messages.py +msgid "Applying machine transform" +msgstr "正在应用机器变换" + +#: rayforge/pipeline/status_messages.py +msgid "Processing" +msgstr "处理中" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Processing '{workpiece}' — {step}" +msgstr "正在处理 '{workpiece}' — {step}" + +#: rayforge/pipeline/status_messages.py +msgid "Assembling" +msgstr "正在组装" + +#: rayforge/pipeline/status_messages.py +#, python-brace-format +msgid "Assembling '{step}'" +msgstr "正在组装 '{step}'" + +#: rayforge/pipeline/assembly_warnings.py +msgid "default face" +msgstr "默认面" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Face '{face}' could not be machined: {detail}" +msgstr "无法加工面“{face}”:{detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Region {region} of face '{face}' could not be machined: {detail}" +msgstr "无法加工面“{face}”的区域 {region}:{detail}" + +#: rayforge/pipeline/assembly_warnings.py +#, python-brace-format +msgid "Machining warning: {detail}" +msgstr "加工警告:{detail}" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable Power" +msgstr "可变功率" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant Power" +msgstr "恒定功率" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Dither" +msgstr "抖动" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multiple Depths" +msgstr "多深度" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Variable" +msgstr "可变" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Constant" +msgstr "恒定" + +#: rayforge/pipeline/stage/assembler_helpers.py +msgid "Multi-Pass" +msgstr "多遍" + +#: rayforge/pipeline/intent_controller.py +#, python-brace-format +msgid "(+{n} more)" +msgstr "(+{n} 更多)" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "Missing: {}" +msgstr "缺失:{}" + +#: rayforge/pipeline/transformer/placeholder.py +msgid "This transformer is not available." +msgstr "此转换器不可用。" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the currently active coordinate system (e.g. 'G54')." +msgstr "当前活动坐标系的名称(例如 'G54')。" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current machine profile." +msgstr "当前机器配置文件的名称。" + +#: rayforge/pipeline/encoder/context.py +msgid "The width (X-axis) of the machine work area." +msgstr "机器工作区域的宽度(X轴)。" + +#: rayforge/pipeline/encoder/context.py +msgid "The height (Y-axis) of the machine work area." +msgstr "机器工作区域的高度(Y轴)。" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current document file (if saved)." +msgstr "当前文档文件的名称(如果已保存)。" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum X coordinate of the entire job." +msgstr "整个作业的最小X坐标。" + +#: rayforge/pipeline/encoder/context.py +msgid "The minimum Y coordinate of the entire job." +msgstr "整个作业的最小Y坐标。" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum X coordinate of the entire job." +msgstr "整个作业的最大X坐标。" + +#: rayforge/pipeline/encoder/context.py +msgid "The maximum Y coordinate of the entire job." +msgstr "整个作业的最大Y坐标。" + +#: rayforge/pipeline/encoder/context.py +msgid "The X offset of the currently active WCS." +msgstr "当前活动WCS的X偏移。" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y offset of the currently active WCS." +msgstr "当前活动WCS的Y偏移。" + +#: rayforge/pipeline/encoder/context.py +msgid "The Z offset of the currently active WCS." +msgstr "当前活动WCS的Z偏移。" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current layer being processed." +msgstr "当前正在处理的图层名称。" + +#: rayforge/pipeline/encoder/context.py +msgid "The name of the current workpiece being processed." +msgstr "当前正在处理的工件名称。" + +#: rayforge/pipeline/encoder/context.py +msgid "The X position of the workpiece." +msgstr "工件的X位置。" + +#: rayforge/pipeline/encoder/context.py +msgid "The Y position of the workpiece." +msgstr "工件的Y位置。" + +#: rayforge/pipeline/encoder/context.py +msgid "The width of the workpiece." +msgstr "工件的宽度。" + +#: rayforge/pipeline/encoder/context.py +msgid "The height of the workpiece." +msgstr "工件的高度。" + +#: rayforge/doceditor/transform_cmd.py +msgid "Transform item(s)" +msgstr "变换项目" + +#: rayforge/doceditor/transform_cmd.py +msgid "Move item(s)" +msgstr "移动项目" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item angle" +msgstr "更改项目角度" + +#: rayforge/doceditor/transform_cmd.py +msgid "Change item shear" +msgstr "更改项目剪切" + +#: rayforge/doceditor/transform_cmd.py +msgid "Resize item(s)" +msgstr "调整项目大小" + +#: rayforge/doceditor/asset_cmd.py +msgid "Update Asset" +msgstr "更新资产" + +#: rayforge/doceditor/asset_cmd.py +msgid "Rename Asset" +msgstr "重命名资产" + +#: rayforge/doceditor/asset_cmd.py +#, python-brace-format +msgid "Delete Asset '{name}'" +msgstr "删除资产 '{name}'" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove dependent item" +msgstr "移除依赖项" + +#: rayforge/doceditor/asset_cmd.py +msgid "Remove asset definition" +msgstr "移除资产定义" + +#: rayforge/doceditor/asset_cmd.py +msgid "Toggle Asset Visibility" +msgstr "切换资源可见性" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import {filename}" +msgstr "导入 {filename}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Importing {filename}..." +msgstr "正在导入 {filename}..." + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"Failed to import {filename}. The image file may be corrupted or in an " +"unsupported format." +msgstr "导入 {filename} 失败。图像文件可能已损坏或格式不支持。" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Import failed: No items were created from {filename}" +msgstr "导入失败:未从 {filename} 创建任何项目" + +#: rayforge/doceditor/file_cmd.py +msgid "Import failed." +msgstr "导入失败。" + +#: rayforge/doceditor/file_cmd.py +msgid "Import complete!" +msgstr "导入完成!" + +#: rayforge/doceditor/file_cmd.py +msgid "" +"⚠️ Imported item was larger than the work area and has been scaled down to " +"fit." +msgstr "⚠️ 导入的项目大于工作区域,已缩加以适应。" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Export successful: {name}" +msgstr "导出成功:{name}" + +#: rayforge/doceditor/file_cmd.py +msgid "Object exported successfully." +msgstr "对象导出成功。" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export object: {error}" +msgstr "导出对象失败:{error}" + +#: rayforge/doceditor/file_cmd.py +msgid "Cannot export: Document has no geometry." +msgstr "无法导出:文档没有几何图形。" + +#: rayforge/doceditor/file_cmd.py +msgid "Document exported successfully." +msgstr "文档导出成功。" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Failed to export document: {error}" +msgstr "导出文档失败:{error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Project saved: {name}" +msgstr "项目已保存:{name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Save failed: {error}" +msgstr "保存失败:{error}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "File not found: {name}" +msgstr "文件未找到:{name}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "" +"This project uses cooling methods not supported by the current machine: " +"{methods}" +msgstr "此项目使用的冷却方法不受当前机器支持:{methods}" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon(s)" +msgstr "{count} 个资源需要已禁用的插件" + +#: rayforge/doceditor/file_cmd.py +msgid "Invalid project file format" +msgstr "无效的项目文件格式" + +#: rayforge/doceditor/file_cmd.py +#, python-brace-format +msgid "Load failed: {error}" +msgstr "加载失败:{error}" + +#: rayforge/doceditor/layout/auto.py +#, python-brace-format +msgid "Could not fit the following items: {item_names}" +msgstr "无法容纳以下项目:{item_names}" + +#: rayforge/doceditor/step_cmd.py +msgid "Rename step" +msgstr "重命名步骤" + +#: rayforge/doceditor/stock_cmd.py +msgid "Remove Stock Asset" +msgstr "移除材料" + +#: rayforge/doceditor/stock_cmd.py +#, python-brace-format +msgid "Stock {count}" +msgstr "毛坯 {count}" + +#: rayforge/doceditor/stock_cmd.py +msgid "Toggle stock visibility" +msgstr "切换毛坯可见性" + +#: rayforge/doceditor/stock_cmd.py +msgid "Rename Stock Asset" +msgstr "重命名毛坯资产" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock thickness" +msgstr "更改毛坯厚度" + +#: rayforge/doceditor/stock_cmd.py +msgid "Change stock material" +msgstr "更改毛坯材料" + +#: rayforge/doceditor/tab_cmd.py +msgid "Add Tab" +msgstr "添加标签" + +#: rayforge/doceditor/tab_cmd.py +msgid "Clear Tabs" +msgstr "清除标签" + +#: rayforge/doceditor/tab_cmd.py +msgid "Toggle Tabs" +msgstr "切换标签" + +#: rayforge/doceditor/tab_cmd.py +msgid "Change Tab Width" +msgstr "更改标签宽度" + +#: rayforge/doceditor/layer_cmd.py +msgid "Move to another layer" +msgstr "移动到另一图层" + +#: rayforge/doceditor/layer_cmd.py +msgid "Layer" +msgstr "图层" + +#: rayforge/doceditor/layer_cmd.py +msgid "Rename layer" +msgstr "重命名图层" + +#: rayforge/doceditor/layer_cmd.py +msgid "Set active layer" +msgstr "设置活动图层" + +#: rayforge/doceditor/layer_cmd.py +#, python-brace-format +msgid "Remove layer '{name}'" +msgstr "移除图层 '{name}'" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder workpieces" +msgstr "重新排列工件" + +#: rayforge/doceditor/layer_cmd.py +msgid "Reorder items" +msgstr "重新排序项目" + +#: rayforge/doceditor/array_cmd.py +msgid "Create Array" +msgstr "创建阵列" + +#: rayforge/doceditor/array_cmd.py +msgid "Create array copy" +msgstr "创建阵列副本" + +#: rayforge/doceditor/editor.py +#, python-brace-format +msgid "{count} asset(s) require disabled addon '{addon}'" +msgstr "{count} 个资源需要已禁用的插件 '{addon}'" + +#: rayforge/doceditor/group_cmd.py +msgid "Grouping items..." +msgstr "正在分组项目..." + +#: rayforge/doceditor/group_cmd.py +msgid "Ungrouping items..." +msgstr "正在取消分组项目..." + +#: rayforge/doceditor/split_cmd.py +msgid "Split item(s)" +msgstr "分割项目" + +#: rayforge/doceditor/split_cmd.py +msgid "Remove original item" +msgstr "移除原始项目" + +#: rayforge/doceditor/split_cmd.py +msgid "Add split fragments" +msgstr "添加分割片段" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item(s)" +msgstr "粘贴项目" + +#: rayforge/doceditor/edit_cmd.py +msgid "Paste item" +msgstr "粘贴项目" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item(s)" +msgstr "创建项目副本" + +#: rayforge/doceditor/edit_cmd.py +msgid "Duplicate item" +msgstr "创建项目副本" + +#: rayforge/doceditor/edit_cmd.py +msgid "Add item" +msgstr "添加项目" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove item" +msgstr "移除项目" + +#: rayforge/doceditor/edit_cmd.py +msgid "Remove all workpieces" +msgstr "移除所有工件" + +#: rayforge/doceditor/edit_cmd.py +msgid "Clear Layer Items" +msgstr "清除图层项目" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete contour(s)" +msgstr "删除轮廓" + +#: rayforge/doceditor/edit_cmd.py +msgid "Delete segment(s)" +msgstr "删除线段" + +#: rayforge/doceditor/layout_cmd.py +msgid "Position at Point" +msgstr "定位在点" + +#: rayforge/doceditor/layout_cmd.py +msgid "Auto Layout" +msgstr "自动布局" + +#: rayforge/image/png/importer.py +msgid "Failed to scan PNG file: {}" +msgstr "扫描PNG文件失败:{}" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Failed to process image data." +msgstr "处理图像数据失败。" + +#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +msgid "Image load failed: {}" +msgstr "图像加载失败:{}" + +#: rayforge/image/svg/svg_base.py +msgid "Could not calculate SVG metadata." +msgstr "无法计算SVG元数据。" + +#: rayforge/image/svg/svg_base.py +msgid "Failed to prepare trimmed SVG data." +msgstr "准备修剪的SVG数据失败。" + +#: rayforge/image/svg/svg_base.py +msgid "SVG contains no geometry or dimensions." +msgstr "SVG不包含几何图形或尺寸。" + +#: rayforge/image/svg/svg_base.py +msgid "Could not determine valid SVG dimensions." +msgstr "无法确定有效的SVG尺寸。" + +#: rayforge/image/svg/svg_trace.py +msgid "Cannot determine valid dimensions for tracing." +msgstr "无法确定描摹的有效尺寸。" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to rasterize SVG for tracing." +msgstr "光栅化SVG以进行描摹失败。" + +#: rayforge/image/svg/svg_trace.py +msgid "Failed to normalize image data." +msgstr "归一化图像数据失败。" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF file contains no pages." +msgstr "PDF文件不包含页面。" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "Could not read PDF: {}" +msgstr "无法读取PDF:{}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Unexpected error while scanning PDF: {}" +msgstr "扫描PDF时发生意外错误:{}" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to process PDF image data." +msgstr "处理PDF图像数据失败。" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to read PDF page dimensions: {}" +msgstr "读取PDF页面尺寸失败:{}" + +#: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py +msgid "PDF page has zero dimensions" +msgstr "PDF页面尺寸为零" + +#: rayforge/image/pdf/pdf_trace.py +msgid "Failed to rasterize PDF" +msgstr "光栅化PDF失败" + +#: rayforge/image/pdf/pdf_vector.py +msgid "PDF contains no vector geometry." +msgstr "PDF 不包含矢量几何图形。" + +#: rayforge/image/pdf/pdf_vector.py +msgid "Failed to parse PDF: {}" +msgstr "解析PDF失败:{}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is invalid XML: {}" +msgstr "LightBurn文件是无效的XML:{}" + +#: rayforge/image/lightburn/importer.py +msgid "LightBurn file is corrupt or invalid: {}" +msgstr "LightBurn文件已损坏或无效:{}" + +#: rayforge/image/bmp/importer.py +msgid "Could not parse BMP header in {}" +msgstr "无法解析{}中的BMP头" + +#: rayforge/image/bmp/importer.py +msgid "Failed to scan BMP file: {}" +msgstr "扫描BMP文件失败:{}" + +#: rayforge/image/bmp/importer.py +msgid "Invalid or unsupported BMP data." +msgstr "无效或不支持的BMP数据。" + +#: rayforge/image/bmp/importer.py +msgid "Image processing failed: {}" +msgstr "图像处理失败:{}" + +#: rayforge/image/ruida/importer.py +msgid "File contains no vector commands." +msgstr "文件不包含矢量命令。" + +#: rayforge/image/ruida/importer.py +msgid "Ruida file is invalid: {}" +msgstr "Ruida文件无效:{}" + +#: rayforge/image/ruida/importer.py +msgid "Unexpected error while scanning Ruida file: {}" +msgstr "扫描Ruida文件时发生意外错误:{}" + +#: rayforge/image/ruida/importer.py +msgid "Failed to parse Ruida commands: {}" +msgstr "解析Ruida命令失败:{}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file structure is invalid: {}" +msgstr "DXF文件结构无效:{}" + +#: rayforge/image/dxf/importer.py +msgid "Unexpected error while scanning DXF: {}" +msgstr "扫描DXF时发生意外错误:{}" + +#: rayforge/image/dxf/importer.py +msgid "DXF file is corrupt or invalid: {}" +msgstr "DXF文件已损坏或无效:{}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to calculate parameters: {}" +msgstr "计算参数失败:{}" + +#: rayforge/image/procedural/importer.py +msgid "Failed to execute generator: {}" +msgstr "执行生成器失败:{}" + +#: rayforge/image/jpg/importer.py +msgid "Failed to scan JPEG file: {}" +msgstr "扫描JPEG文件失败:{}" + +#: rayforge/image/dither.py +msgid "Floyd Steinberg" +msgstr "Floyd Steinberg" + +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" + +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" + +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" diff --git a/rayforge/logging_setup.py b/rayforge/logging_setup.py new file mode 100644 index 000000000..78cd963e6 --- /dev/null +++ b/rayforge/logging_setup.py @@ -0,0 +1,256 @@ +# ruff: noqa: LOG015 +import logging +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import ClassVar + +from blinker import Signal + +from .config import LOG_DIR + +_ui_formatter_instance: logging.Formatter | None = None +_ui_log_records: list[logging.LogRecord] = [] + +LOG_FILES_TO_KEEP = 5 + +# Global signal for UI log events +ui_log_event_received = Signal() + + +class UILogFilter(logging.Filter): + """ + This filter only allows log records that are intended for the user-facing + log dialog, such as machine events, warnings, and errors. + + Log Categories: + - MACHINE_EVENT: Important machine responses and events + - ERROR: Error messages + - WARNING: Warning messages + - STATE_CHANGE: Device state changes + - USER_COMMAND: Commands entered by user in console + - MACHINE_RESPONSE: Responses to user commands + - STATUS_POLL: Frequent status poll responses (filtered by default) + + UI_CATEGORIES: Always shown in UI + VERBOSE_CATEGORIES: Shown only when verbose mode is enabled + """ + + UI_CATEGORIES: ClassVar[set[str]] = { + "MACHINE_EVENT", + "ERROR", + "WARNING", + "STATE_CHANGE", + "USER_COMMAND", + } + + VERBOSE_CATEGORIES: ClassVar[set[str]] = { + "STATUS_POLL", + "MACHINE_RESPONSE", + } + + def filter(self, record: logging.LogRecord) -> bool: + category = record.__dict__.get("log_category") + return ( + category in self.UI_CATEGORIES + or category in self.VERBOSE_CATEGORIES + ) + + +class ConsoleFormatter(logging.Formatter): + """ + A formatter for the console UI that formats messages based on their + category for better readability. + + Format by category: + - USER_COMMAND: "> {message}" (no timestamp) + - ERROR: "ERROR {message}" + - WARNING: "WARN {message}" + - STATUS_POLL: "{timestamp} {message}" + - Others: "{timestamp} {message}" + """ + + def format(self, record: logging.LogRecord) -> str: + category = record.__dict__.get("log_category") + message = record.getMessage() + + if category == "USER_COMMAND": + return f"> {message}" + elif category == "ERROR": + return f"ERROR {message}" + elif category == "WARNING": + return f"WARN {message}" + elif category == "STATUS_POLL": + timestamp = self.formatTime(record, self.datefmt) + return f"{timestamp} {message}" + else: + timestamp = self.formatTime(record, self.datefmt) + return f"{timestamp} {message}" + + +class ConsoleLogFilter(logging.Filter): + """ + This filter rejects log records that are categorized as 'RAW_IO' + to prevent spamming the console with low-level communication data. + """ + + def filter(self, record: logging.LogRecord) -> bool: + return record.__dict__.get("log_category") != "RAW_IO" + + +class SessionFileFormatter(logging.Formatter): + """ + Formatter for the session log file that enriches each line with extra + attributes (log_category, machine_id) when present on the LogRecord. + """ + + def format(self, record: logging.LogRecord) -> str: + category = record.__dict__.get("log_category") + machine_id = record.__dict__.get("machine_id") + parts = [] + if category: + parts.append(category) + if machine_id: + parts.append(machine_id) + tag = f" [{' | '.join(parts)}]" if parts else "" + original = super().format(record) + if tag: + level = record.levelname + original = original.replace(f" {level} - ", f" {level}{tag} - ", 1) + return original + + +class UILogHandler(logging.Handler): + """ + A custom logging handler that forwards filtered log records to the UI + via a blinker signal and stores them in a buffer for console history. + """ + + def emit(self, record: logging.LogRecord): + _ui_log_records.append(record) + log_entry = self.format(record) + category = record.__dict__.get("log_category") + machine_id = record.__dict__.get("machine_id") + ui_log_event_received.send( + self, message=log_entry, category=category, machine_id=machine_id + ) + + +def _cleanup_old_logs(log_dir: Path, keep_count: int): + """ + Deletes old log files, keeping only the most recent 'keep_count' number + of logs. + """ + try: + # Find all log files that match our session pattern + log_files: list[Path] = sorted( + log_dir.glob("session-*.log"), + key=lambda p: p.stat().st_mtime, # Sort by modification time + reverse=True, # Newest first + ) + + # If we have more logs than we want to keep, delete the oldest ones + if len(log_files) > keep_count: + files_to_delete = log_files[keep_count:] + logging.debug( + f"Log cleanup: Deleting {len(files_to_delete)} old log files." + ) + for f in files_to_delete: + try: + f.unlink() + except OSError as e: + logging.warning(f"Could not delete old log file {f}: {e}") + except OSError as e: + # We don't want a logging failure to crash the app startup + logging.error(f"An unexpected error occurred during log cleanup: {e}") + + +def get_ui_log_records() -> list[logging.LogRecord]: + """ + Returns the buffered UI log records. + Used by the Console widget to populate history. + """ + return _ui_log_records + + +def get_ui_formatter() -> logging.Formatter | None: + """ + Returns the global instance of the Formatter used for the UI Log. + """ + return _ui_formatter_instance + + +def setup_logging(loglevel_str: str): + """ + Configures the root logger with console, file, and in-memory handlers. + This replaces the need for logging.basicConfig(). + + Args: + loglevel_str: The desired logging level for the console as a string + (e.g., "INFO", "DEBUG"). + """ + global _ui_formatter_instance + + log_level = getattr(logging, loglevel_str.upper(), logging.INFO) + root_logger = logging.getLogger() + + # Clear any handlers already configured (e.g., by basicConfig) + if root_logger.hasHandlers(): + root_logger.handlers.clear() + + # Set root logger to the lowest level to capture everything. + # Handlers will then filter messages by their own specific levels. + root_logger.setLevel(logging.DEBUG) + + # 1. Add the console handler IMMEDIATELY after clearing. + # This prevents logging calls in helper functions (like _cleanup_old_logs) + # from implicitly re-triggering basicConfig. + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + console_handler.setFormatter(console_formatter) + console_handler.addFilter(ConsoleLogFilter()) + root_logger.addHandler(console_handler) + + # Now it is safe to run functions that might log things. + _cleanup_old_logs(LOG_DIR, LOG_FILES_TO_KEEP) + + # 2. Session File Handler (for persistent, detailed logs) + timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") + log_file = LOG_DIR / f"session-{timestamp}.log" + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter( + SessionFileFormatter( + "%(asctime)s - %(process)d - %(threadName)s - %(name)s - " + "%(levelname)s - %(message)s" + ) + ) + root_logger.addHandler(file_handler) + + # 3. UI Log Handler (for the MachineLogDialog) + ui_handler = UILogHandler() + ui_handler.setLevel(logging.INFO) # Don't show DEBUG messages in UI log + ui_handler.addFilter(UILogFilter()) + # Create the formatter and store it in our global instance + _ui_formatter_instance = ConsoleFormatter(datefmt="%Y-%m-%d %H:%M:%S") + ui_handler.setFormatter(_ui_formatter_instance) + root_logger.addHandler(ui_handler) + + # Silence noisy third-party loggers + pyvips_loggers = [ + "pyvips", + "pyvips.vobject", + "pyvips.voperation", + "pyvips.error", + ] + for name in pyvips_loggers: + logger = logging.getLogger(name) + logger.setLevel(logging.WARNING) + logger.propagate = False + logger.handlers = [] + + logging.info(f"Logging configured. Console level: {loglevel_str}") + logging.info(f"Session log file: {log_file}") diff --git a/rayforge/machine/__init__.py b/rayforge/machine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/machine/assembly.py b/rayforge/machine/assembly.py new file mode 100644 index 000000000..31e715607 --- /dev/null +++ b/rayforge/machine/assembly.py @@ -0,0 +1,490 @@ +import math +from collections import defaultdict +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + TYPE_CHECKING, +) + +import numpy as np +from raygeo.geo.types import Point3D +from raygeo.ops.axis import Axis + +from ..core.model import Model + +if TYPE_CHECKING: + from ..simulator.machine_state import MachineState + + +class JointType(Enum): + FIXED = "fixed" + PRISMATIC = "prismatic" + REVOLUTE = "revolute" + + +class LinkRole(Enum): + HEAD = "head" + CHUCK = "chuck" + + +@dataclass +class Link: + name: str + parent: str | None + joint_type: JointType + joint_axis: tuple[float, float, float] = (0.0, 0.0, 0.0) + driver_axis: Axis | None = None + local_transform: np.ndarray = field( + default_factory=lambda: np.eye(4, dtype=np.float64) + ) + model: Model | None = None + model_transform: np.ndarray = field( + default_factory=lambda: np.eye(4, dtype=np.float64) + ) + role: LinkRole | None = None + + def __post_init__(self): + if self.joint_type != JointType.FIXED and self.driver_axis is None: + raise ValueError( + f"Link '{self.name}': {self.joint_type.value} joint " + f"requires a driver_axis" + ) + if self.joint_type == JointType.FIXED and self.driver_axis is not None: + raise ValueError( + f"Link '{self.name}': FIXED joint cannot have a driver_axis" + ) + + +class Assembly: + def __init__(self, links: list[Link]): + if not links: + raise ValueError("Assembly must have at least one link") + self._links = links + self._link_map: dict[str, Link] = {} + self._children: dict[str, list[str]] = defaultdict(list) + self._roots: list[str] = [] + + for link in links: + if link.name in self._link_map: + raise ValueError(f"Duplicate link name: '{link.name}'") + self._link_map[link.name] = link + + for link in links: + if link.parent is None: + self._roots.append(link.name) + elif link.parent not in self._link_map: + raise ValueError( + f"Link '{link.name}' references unknown parent " + f"'{link.parent}'" + ) + else: + self._children[link.parent].append(link.name) + + if not self._roots: + raise ValueError("Assembly must have exactly one root link") + if len(self._roots) > 1: + raise ValueError( + f"Assembly must have exactly one root link, " + f"found: {self._roots}" + ) + + self._validate_no_cycles() + + self._roles: dict[LinkRole, list[str]] = {} + self._chuck_diameters: dict[str, float] = {} + self._chuck_axis_offsets: dict[str, np.ndarray] = {} + self._index_roles() + + def _validate_no_cycles(self) -> None: + visited: set[str] = set() + stack: set[str] = set() + + def visit(name: str) -> None: + if name in stack: + raise ValueError(f"Cycle detected involving link '{name}'") + if name in visited: + return + stack.add(name) + for child in self._children.get(name, []): + visit(child) + stack.remove(name) + visited.add(name) + + for root in self._roots: + visit(root) + + all_names = set(self._link_map.keys()) + unreachable = all_names - visited + if unreachable: + raise ValueError(f"Unreachable links: {sorted(unreachable)}") + + def _index_roles(self) -> None: + for link in self._links: + if link.role is None: + continue + self._roles.setdefault(link.role, []).append(link.name) + + def set_chuck_diameter(self, chuck_name: str, diameter: float) -> None: + self._chuck_diameters[chuck_name] = diameter + + def set_chuck_axis_offset( + self, chuck_name: str, offset: np.ndarray + ) -> None: + self._chuck_axis_offsets[chuck_name] = offset.copy() + + @property + def chuck_axis_offset(self) -> np.ndarray: + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return np.zeros(3, dtype=np.float64) + return self._chuck_axis_offsets.get( + chuck_names[0], np.zeros(3, dtype=np.float64) + ) + + def set_rotary_diameter(self, diameter: float) -> None: + for name in self._roles.get(LinkRole.CHUCK, []): + self._chuck_diameters[name] = diameter + + @property + def chuck_diameters(self) -> dict[str, float]: + return dict(self._chuck_diameters) + + @property + def has_rotary(self) -> bool: + return bool(self._roles.get(LinkRole.CHUCK)) + + @property + def rotary_diameter(self) -> float | None: + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return None + return self._chuck_diameters.get(chuck_names[0]) + + def get_link(self, name: str) -> Link | None: + return self._link_map.get(name) + + def get_links_by_role(self, role: LinkRole) -> list[Link]: + names = self._roles.get(role, []) + return [self._link_map[n] for n in names] + + def get_model_links(self) -> list[Link]: + """Return all links that have a 3D model assigned.""" + return [link for link in self._links if link.model is not None] + + def model_world_transforms( + self, + state: "MachineState", + wcs_offset: Point3D = (0.0, 0.0, 0.0), + ) -> dict[str, np.ndarray]: + """Return a 4x4 world transform for each link with a 3D model. + + For prismatic joints, the transform includes the animated axis + offset so models move with the gantry. For revolute joints the + transform is the static base pose (the joint rotation is + typically visualized separately by a cylinder renderer). + + The link's ``model_transform`` is applied on top of the base + pose and carries scale, rotation and offsets that are purely + visual (e.g. focal-distance offset for laser heads). + + *wcs_offset* is applied in Z to non-rotary links (heads) so + models sit at the correct work-coordinate height. + """ + fk = self.forward_kinematics(state) + chuck_names = set(self._roles.get(LinkRole.CHUCK, [])) + transforms: dict[str, np.ndarray] = {} + for link in self._links: + if link.model is None: + continue + if link.joint_type == JointType.REVOLUTE: + parent = self._link_map[link.parent] if link.parent else None + if parent is not None and parent.name in fk: + pos_p, rot_p = fk[parent.name] + parent_t = np.eye(4, dtype=np.float64) + parent_t[:3, :3] = rot_p + parent_t[:3, 3] = [ + pos_p[0], + pos_p[1], + pos_p[2], + ] + base = parent_t @ link.local_transform + else: + base = link.local_transform.copy() + else: + pos, rot = fk[link.name] + base = np.eye(4, dtype=np.float64) + base[:3, :3] = rot + base[:3, 3] = [pos[0], pos[1], pos[2]] + t = base @ link.model_transform + if link.name not in chuck_names: + t[2, 3] += wcs_offset[2] + transforms[link.name] = t + return transforms + + def cylinder_base_transform(self) -> np.ndarray: + """Return the static 4x4 cylinder base pose (no spin). + + Derives position and orientation from the rotary_base link's + local_transform (= module.transform), with scale stripped and + the stored chuck axis_offset applied. Independent of machine + state — the rotary_base is a FIXED child of the root. + + Returns: + 4x4 affine matrix (float64), rotation-only (no scale). + """ + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return np.eye(4, dtype=np.float64) + link = self._link_map.get(chuck_names[0]) + if link is None: + return np.eye(4, dtype=np.float64) + parent = self._link_map.get(link.parent) if link.parent else None + if parent is not None: + base = parent.local_transform.copy() + else: + base = link.local_transform.copy() + + rot3 = base[:3, :3].copy() + for col in range(3): + norm = np.linalg.norm(rot3[:, col]) + if norm > 1e-12: + rot3[:, col] /= norm + + axis_offset = self.chuck_axis_offset + result = np.eye(4, dtype=np.float64) + result[:3, :3] = rot3 + result[:3, 3] = base[:3, 3] + rot3 @ axis_offset + return result + + @property + def cylinder_axis_index(self) -> int: + """Index (0=X, 1=Y) of the cylinder axis in world frame. + + Derived from the rotary_base link's rotation matrix: the + column corresponding to the chuck's driver axis gives the + world-space cylinder direction. + """ + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return 0 + chuck = self._link_map[chuck_names[0]] + parent = self._link_map.get(chuck.parent) if chuck.parent else None + if parent is not None: + base = parent.local_transform + else: + base = chuck.local_transform + rot3 = base[:3, :3].copy() + for col in range(3): + norm = np.linalg.norm(rot3[:, col]) + if norm > 1e-12: + rot3[:, col] /= norm + world_dir = rot3[:, 0] + if abs(world_dir[1]) > abs(world_dir[0]): + return 1 + return 0 + + def head_rotary_positions( + self, + state: "MachineState", + diameter: float, + focal_distance: float = 0.0, + ) -> dict[str, np.ndarray]: + """Return head positions above the cylinder surface. + + Positions each HEAD link at the top of the cylinder at the + correct along-cylinder offset, plus *focal_distance* above + the surface. Uses the static cylinder base pose (no spin). + + Args: + state: Machine state (for FK head positions). + diameter: Cylinder diameter. + focal_distance: Height above the cylinder surface. + + Returns: + Dict of {head_name: 3D world position (float64 array)}. + """ + head_names = self._roles.get(LinkRole.HEAD, []) + if not head_names: + return {} + + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return {} + + radius = diameter / 2.0 if diameter > 0 else 0.0 + + cyl_t = self.cylinder_base_transform() + + fk_heads = self.head_positions(state) + + result: dict[str, np.ndarray] = {} + for name in head_names: + if name not in fk_heads: + continue + hx, _hy, hz = fk_heads[name] + + local = np.array( + [ + hx, + 0.0, + radius + focal_distance + hz, + 1.0, + ], + dtype=np.float64, + ) + world = cyl_t @ local + result[name] = world[:3].copy() + return result + + def cylinder_world_transform( + self, + state: "MachineState", + axis_offset: np.ndarray, + ) -> np.ndarray: + """Return the 4x4 world transform for the rotary cylinder. + + The cylinder is a child of the chuck link in the kinematic + chain. The transform includes: + + 1. Chuck base pose from FK (position + orientation, no scale) + 2. Axis offset in the chuck's local frame + 3. Revolute joint spin from the machine state + + Args: + state: Current machine state (for FK + revolute angle). + axis_offset: 3D offset from the module's mounting position. + + Returns: + 4x4 affine matrix (float64), rotation-only (no scale). + """ + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return np.eye(4, dtype=np.float64) + link = self._link_map.get(chuck_names[0]) + if link is None: + return np.eye(4, dtype=np.float64) + fk = self.forward_kinematics(state) + parent = self._link_map.get(link.parent) if link.parent else None + if parent is not None and parent.name in fk: + pos_p, rot_p = fk[parent.name] + parent_t = np.eye(4, dtype=np.float64) + parent_t[:3, :3] = rot_p + parent_t[:3, 3] = [pos_p[0], pos_p[1], pos_p[2]] + base = parent_t @ link.local_transform + else: + base = link.local_transform.copy() + + result = np.eye(4, dtype=np.float64) + rot3 = base[:3, :3].copy() + for col in range(3): + norm = np.linalg.norm(rot3[:, col]) + if norm > 1e-12: + rot3[:, col] /= norm + result[:3, :3] = rot3 + result[:3, 3] = base[:3, 3] + rot3 @ axis_offset + + if ( + link.driver_axis is not None + and link.joint_type == JointType.REVOLUTE + ): + angle = state.axes.get(link.driver_axis, 0.0) + angle_rad = math.radians(angle) + axis = np.array(link.joint_axis, dtype=np.float64) + rot = _rotation_matrix_4x4(axis, angle_rad) + result = result @ rot + + return result + + def forward_kinematics( + self, state: "MachineState" + ) -> dict[str, tuple[Point3D, np.ndarray]]: + result: dict[str, tuple[Point3D, np.ndarray]] = {} + root = self._roots[0] + root_link = self._link_map[root] + root_transform = root_link.local_transform.copy() + pos = root_transform[:3, 3] + result[root] = ( + (float(pos[0]), float(pos[1]), float(pos[2])), + root_transform[:3, :3].copy(), + ) + self._fk_recursive(root, root_transform, state, result) + return result + + def _fk_recursive( + self, + parent_name: str, + parent_transform: np.ndarray, + state: "MachineState", + result: dict[str, tuple[Point3D, np.ndarray]], + ) -> None: + for child_name in self._children.get(parent_name, []): + link = self._link_map[child_name] + transform = parent_transform @ link.local_transform.copy() + + if link.joint_type == JointType.PRISMATIC: + assert link.driver_axis is not None + offset = state.axes.get(link.driver_axis, 0.0) + axis = np.array(link.joint_axis, dtype=np.float64) + transform[:3, 3] += offset * axis + elif link.joint_type == JointType.REVOLUTE: + assert link.driver_axis is not None + angle = state.axes.get(link.driver_axis, 0.0) + angle_rad = math.radians(angle) + axis = np.array(link.joint_axis, dtype=np.float64) + rot = _rotation_matrix_4x4(axis, angle_rad) + transform = transform @ rot + + pos = transform[:3, 3] + result[child_name] = ( + (float(pos[0]), float(pos[1]), float(pos[2])), + transform[:3, :3].copy(), + ) + self._fk_recursive(child_name, transform, state, result) + + def head_positions( + self, + state: "MachineState", + wcs_offset: Point3D = (0.0, 0.0, 0.0), + ) -> dict[str, Point3D]: + head_names = self._roles.get(LinkRole.HEAD, []) + if not head_names: + raise ValueError("Assembly has no links with role HEAD") + poses = self.forward_kinematics(state) + return { + name: ( + poses[name][0][0], + poses[name][0][1], + poses[name][0][2] + wcs_offset[2], + ) + for name in head_names + } + + def chuck_angles(self, state: "MachineState") -> dict[str, float]: + chuck_names = self._roles.get(LinkRole.CHUCK, []) + if not chuck_names: + return {} + result: dict[str, float] = {} + for name in chuck_names: + chuck = self._link_map[name] + assert chuck.driver_axis is not None + angle = state.axes.get(chuck.driver_axis, 0.0) + result[name] = math.radians(angle) + return result + + +def _rotation_matrix_4x4(axis: np.ndarray, angle: float) -> np.ndarray: + c = math.cos(angle) + s = math.sin(angle) + t = 1 - c + x, y, z = axis + m = np.eye(4, dtype=np.float64) + m[0, 0] = t * x * x + c + m[0, 1] = t * x * y - s * z + m[0, 2] = t * x * z + s * y + m[1, 0] = t * x * y + s * z + m[1, 1] = t * y * y + c + m[1, 2] = t * y * z - s * x + m[2, 0] = t * x * z - s * y + m[2, 1] = t * y * z + s * x + m[2, 2] = t * z * z + c + return m diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py new file mode 100644 index 000000000..a4a3eeb38 --- /dev/null +++ b/rayforge/machine/cmd.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable, Coroutine +from gettext import gettext as _ +from typing import TYPE_CHECKING + +import numpy as np +from blinker import Signal +from raygeo.ops import Ops + +from ..context import get_context +from ..pipeline.artifact import JobArtifact +from ..pipeline.artifact.handle import BaseArtifactHandle +from ..pipeline.encoder.base import EncodedOutput +from ..pipeline.encoder.context import GcodeContext, JobInfo +from ..shared.util.template import TemplateFormatter +from .driver import get_driver_cls +from .driver.dummy import NoDeviceDriver +from .job_monitor import JobMonitor +from .models.coordspace import MachineSpace + +if TYPE_CHECKING: + from raygeo.ops.axis import Axis + + from ..doceditor.editor import DocEditor + from .models.laser import Laser + from .models.machine import Machine + + +logger = logging.getLogger(__name__) + + +class MachineCmd: + """Handles commands sent to the machine driver.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + self._scheduler = editor.task_manager.schedule_on_main_thread + self.job_started = Signal() + self._current_monitor: JobMonitor | None = None + self._on_progress_callback: Callable[[dict], None] | None = None + + @property + def is_job_running(self) -> bool: + """Returns True if a monitored job is currently running.""" + return self._current_monitor is not None + + def select_tool(self, machine: Machine, head_index: int): + """Adds a 'select_head' task to the task manager.""" + if not (0 <= head_index < len(machine.heads)): + logger.error(f"Invalid head index {head_index} for tool selection") + return + + head = machine.heads[head_index] + tool_number = head.tool_number + + self._editor.task_manager.add_coroutine( + lambda ctx: machine.select_tool(tool_number), key="select-head" + ) + + def _progress_handler(self, sender, metrics): + """Signal handler for job progress updates.""" + logger.debug(f"JobMonitor progress: {metrics}") + if self._on_progress_callback: + self._scheduler(self._on_progress_callback, metrics) + + async def _execute_monitored_job( + self, + ops: Ops, + machine: Machine, + on_progress: Callable[[dict], None] | None = None, + encoded: EncodedOutput | None = None, + ): + """ + Internal helper to execute a job on a driver while managing + a JobMonitor for progress reporting. + """ + if self._current_monitor: + msg = "Tried to start a job while another is running." + logger.warning(msg) + # A running job is a failure condition for starting a new one. + raise RuntimeError(msg) + + if ops.is_empty(): + logger.warning("Job has no operations. Skipping execution.") + if machine.driver: + machine.driver.job_finished.send(machine.driver) + return + + # Store the callback + self._on_progress_callback = on_progress + + def cleanup_monitor(): + """Cleans up the monitor when the job is done.""" + logger.debug("Job finished, cleaning up monitor.") + if self._current_monitor: + try: + self._current_monitor.progress_updated.disconnect( + self._progress_handler + ) + finally: + # Ensure the flag is cleared even if disconnect fails. + self._current_monitor = None + self._on_progress_callback = None + + try: + self._current_monitor = JobMonitor(ops) + + if self._on_progress_callback: + logger.debug("Connecting progress handler to JobMonitor") + self._current_monitor.progress_updated.connect( + self._progress_handler + ) + + # Signal that the job has started. + self._scheduler(self.job_started.send, self) + + # Pipeline must have produced encoded output. + if encoded is None: + raise RuntimeError("Pipeline did not produce encoded output.") + + if machine.reports_granular_progress: + await machine.driver.run( + encoded, + self._editor.doc, + ops, + on_command_done=self._current_monitor.update_progress, + ) + else: + await machine.driver.run( + encoded, + self._editor.doc, + ops, + on_command_done=None, + ) + if self._current_monitor: + self._current_monitor.mark_as_complete() + + estimated_seconds = ops.estimate_time( + default_feed_rate=machine.max_cut_speed, + default_rapid_rate=machine.max_travel_speed, + acceleration=machine.acceleration, + ) + estimated_hours = estimated_seconds / 3600.0 + machine.add_machine_hours(estimated_hours) + logger.info( + f"Job completed. Estimated time: {estimated_hours:.3f}h " + f"added to machine hours." + ) + finally: + cleanup_monitor() + + async def _run_frame_action( + self, + artifact: JobArtifact, + machine: Machine, + on_progress: Callable[[dict], None] | None, + ): + """The specific machine action for a framing job.""" + if not isinstance(artifact, JobArtifact): + raise TypeError("_run_frame_action received a non-JobArtifact") + ops = artifact.ops + + head = machine.get_default_laser_head() + if head is None: + raise ValueError("Machine has no laser heads configured.") + if not head.frame_power_percent: + logger.warning("Framing cancelled: Frame power is zero.") + return + + frame_speed = ( + head.frame_speed + if head.frame_speed > 0 + else machine.max_travel_speed + ) + + min_x, min_y, max_x, max_y = ops.rect() + + frame_ops = Ops() + frame_ops.set_head(head.uid) + frame_ops.set_power(head.frame_power_percent) + frame_ops.set_feed_rate(frame_speed) + + corners = [ + (min_x, min_y), + (min_x, max_y), + (max_x, max_y), + (max_x, min_y), + (min_x, min_y), + ] + prev = corners[0] + for corner in corners[1:]: + frame_ops.move_to(*prev) + frame_ops.line_to(*corner) + if head.frame_corner_pause > 0: + frame_ops.dwell(head.frame_corner_pause * 1000) + prev = corner + + frame_with_laser = frame_ops * head.frame_repeat_count + frame_with_laser.job_end() + + # Transform world-space frame ops to machine space. + space = MachineSpace.from_machine(machine) + combined = space.get_world_to_machine_matrix() + if machine.reverse_z_axis: + z_flip = np.eye(4) + z_flip[2, 2] = -1.0 + combined = z_flip @ combined + frame_with_laser.transform(combined) + + # Encode via the driver encoder (no pre-processing needed). + encoder = _create_driver_encoder(machine) + encoded = encoder.encode(frame_with_laser, machine, self._editor.doc) + + await self._execute_monitored_job( + frame_with_laser, + machine, + on_progress=on_progress, + encoded=encoded, + ) + + async def _run_send_action( + self, + artifact: JobArtifact, + machine: Machine, + on_progress: Callable[[dict], None] | None, + ): + """The specific machine action for a send job.""" + if not isinstance(artifact, JobArtifact): + raise TypeError("_run_send_action received a non-JobArtifact") + + await self._execute_monitored_job( + artifact.ops, + machine, + on_progress=on_progress, + encoded=artifact.encoded_output, + ) + + async def _start_job( + self, + machine: Machine, + job_name: str, + final_job_action: Callable[..., Coroutine], + on_progress: Callable[[dict], None] | None = None, + ): + """ + Generic, awaitable job starter that orchestrates assembly and + execution. + """ + handle: BaseArtifactHandle | None = None + artifact_store = self._editor.pipeline.artifact_store + + try: + # 1. Await the job artifact generation from the pipeline + handle = await self._editor.pipeline.generate_job_artifact_async() + + if not handle: + logger.warning( + f"{job_name.capitalize()} job has no operations." + ) + return + + # 2. Use the safe context manager to acquire and release the + # artifact + with artifact_store.checkout_handle(handle) as artifact: + if not artifact: + raise ValueError( + "Failed to retrieve artifact from handle." + ) + + await final_job_action(artifact, machine, on_progress) + + except Exception as e: + logger.exception(f"Failed to assemble or execute {job_name} job") + self._editor.notification_requested.send( + self, + message=_("{job_name} failed: {error}").format( + job_name=job_name.capitalize(), error=e + ), + ) + # Manually release handle on error if checkout was not entered + if handle and "artifact" not in locals(): + artifact_store.release(handle) + raise + + async def frame_job( + self, + machine: Machine, + on_progress: Callable[[dict], None] | None = None, + ): + """ + Asynchronously generates ops and runs a framing job. + This is an awaitable coroutine. + """ + await self._start_job( + machine, + job_name="framing", + final_job_action=self._run_frame_action, + on_progress=on_progress, + ) + + async def send_job( + self, + machine: Machine, + on_progress: Callable[[dict], None] | None = None, + ): + """ + Asynchronously generates ops and sends the job to the machine. + This is an awaitable coroutine. + """ + await self._start_job( + machine, + job_name="sending", + final_job_action=self._run_send_action, + on_progress=on_progress, + ) + + def run_send_job(self, machine: Machine): + """ + Schedules the send_job coroutine to run via the task manager. + """ + self._editor.task_manager.add_coroutine( + lambda ctx: self._start_job( + machine, + job_name="sending", + final_job_action=self._run_send_action, + on_progress=None, + ), + key="send-job", + ) + + def set_hold(self, machine: Machine, is_requesting_hold: bool): + """ + Adds a task to set the machine's hold state (pause/resume). + """ + driver = machine.driver + self._editor.task_manager.add_coroutine( + lambda ctx: driver.set_hold(is_requesting_hold), key="set-hold" + ) + + def cancel_job(self, machine: Machine): + """Adds a task to cancel the currently running job on the machine.""" + driver = machine.driver + self._editor.task_manager.add_coroutine( + lambda ctx: driver.cancel(), key="cancel-job" + ) + + def clear_alarm(self, machine: Machine): + """Adds a task to clear any active alarm on the machine.""" + driver = machine.driver + self._editor.task_manager.add_coroutine( + lambda ctx: driver.clear_alarm(), key="clear-alarm" + ) + + def jog(self, machine: Machine, deltas: dict[Axis, float], speed: int): + """ + Adds a task to jog the machine along specific axes. + """ + self._editor.task_manager.add_coroutine( + lambda ctx: machine.jog(deltas, speed) + ) + + def execute_macro_by_uid(self, machine: Machine, macro_uid: str): + """Finds a macro by UID, expands it, and runs it on the machine.""" + macro = machine.macros.get(macro_uid) + if not macro or not macro.enabled: + logger.warning( + f"Macro with UID {macro_uid} not found or disabled." + ) + return + + # A macro executed outside a job context has limited information. + # We provide a dummy JobInfo for variables that might expect it. + context = GcodeContext( + machine=machine, + doc=self._editor.doc, + job=JobInfo(extents=(0, 0, 0, 0)), + ) + formatter = TemplateFormatter(machine, context) + expanded_lines = formatter.expand_macro(macro) + gcode_to_run = "\n".join(expanded_lines) + + # We use the machine's run_raw method, which is simpler than building a + # full job and allows macros to be self-contained. + self._editor.task_manager.add_coroutine( + lambda ctx: machine.run_raw(gcode_to_run), + key=f"macro-{macro_uid}", + ) + + def set_power( + self, + head: Laser, + percent: float, + machine: Machine | None = None, + ): + """ + Adds a task to set the laser power to a specific percentage. + + Args: + head: The laser head to control + percent: Power percentage (0-1.0). 0 disables power. + """ + if machine is None: + config = get_context().config + machine = config.machine + if machine: + self._editor.task_manager.add_coroutine( + lambda ctx: machine.set_power(head, percent) + ) + + def set_focus_power( + self, + head: Laser, + percent: float, + machine: Machine | None = None, + ): + """ + Adds a task to set the laser power for focus mode. + + Args: + head: The laser head to control + percent: Power percentage (0-1.0). 0 disables power. + """ + if machine is None: + config = get_context().config + machine = config.machine + if machine: + self._editor.task_manager.add_coroutine( + lambda ctx: machine.set_focus_power(head, percent) + ) + + def home(self, machine: Machine, axis: Axis | None = None): + """Adds a task to home a specific axis.""" + self._editor.task_manager.add_coroutine(lambda ctx: machine.home(axis)) + + def move_to(self, machine: Machine, x: float, y: float): + """Adds a task to move to an absolute position.""" + driver = machine.driver + if driver: + self._editor.task_manager.add_coroutine( + lambda ctx: driver.move_to(x, y), key="move-to" + ) + + +def _create_driver_encoder(machine: Machine): + """Instantiate the machine's driver encoder.""" + if machine.driver_name: + try: + driver_cls = get_driver_cls(machine.driver_name) + except (ValueError, ImportError): + driver_cls = NoDeviceDriver + else: + driver_cls = NoDeviceDriver + return driver_cls.create_encoder(machine) diff --git a/rayforge/machine/device/__init__.py b/rayforge/machine/device/__init__.py new file mode 100644 index 000000000..e25e59e61 --- /dev/null +++ b/rayforge/machine/device/__init__.py @@ -0,0 +1,13 @@ +from .manager import DeviceProfileManager +from .profile import ( + DeviceMeta, + DeviceProfile, + export_machine_to_dir, +) + +__all__ = [ + "DeviceMeta", + "DeviceProfile", + "DeviceProfileManager", + "export_machine_to_dir", +] diff --git a/rayforge/machine/device/lightburn_importer.py b/rayforge/machine/device/lightburn_importer.py new file mode 100644 index 000000000..4d8eb96a1 --- /dev/null +++ b/rayforge/machine/device/lightburn_importer.py @@ -0,0 +1,318 @@ +""" +LightBurn .lbdev device profile importer. + +Provides parsing and conversion of LightBurn device profile files +into Rayforge :class:`DeviceProfile` objects. The mapping is +best-effort; users are warned that some configuration may need +manual adjustment. +""" + +import json +import logging +from dataclasses import dataclass +from gettext import gettext as _ +from pathlib import Path +from typing import Any + +from ...machine.models.machine import Origin +from .profile import ( + DeviceMeta, + DeviceProfile, + MachineConfig, +) + +logger = logging.getLogger(__name__) + +_DRIVER_MAP: dict[str, str | None] = { + "Serial": "GrblSerialDriver", + "Network": "GrblNetworkDriver", + "Ruida": "RuidaDriver", + "EZCAD": None, + "LaserCAD": None, + "LightBurn": None, + "Galvo": None, + "Custom": None, +} + +_CUT_ORIGIN_MAP: dict[int, str | None] = { + 0: None, + 1: "top_left", + 2: "bottom_left", +} + +_DEFAULT_GRBL_DIALECT: dict[str, Any] = { + "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, + "omit_unchanged_coords": True, +} + + +@dataclass +class ImportSummary: + """ + Human-readable summary of values imported from a LightBurn profile. + + Displayed to the user so they can verify what was captured and + what may need manual configuration. + """ + + name: str = "" + axis_extents: tuple[float, float] | None = None + driver: str | None = None + driver_args: dict[str, Any] | None = None + home_on_start: bool | None = None + max_travel_speed: int | None = None + origin: str | None = None + mirror_x: bool | None = None + mirror_y: bool | None = None + camera_calibration: bool = False + + def to_lines(self) -> list[str]: + """Return a bulleted list of human-readable summary lines.""" + lines: list[str] = [] + if self.name: + lines.append(f"\u2022 Device name: {self.name}") + if self.axis_extents: + w, h = self.axis_extents + lines.append(f"\u2022 Work area: {w:.0f} \u00d7 {h:.0f} mm") + if self.driver: + lines.append(f"\u2022 Driver: {self.driver}") + if self.driver_args and "baudrate" in self.driver_args: + lines.append(f"\u2022 Baud rate: {self.driver_args['baudrate']}") + if self.home_on_start is not None: + lines.append(f"\u2022 Home on start: {self.home_on_start}") + if self.max_travel_speed is not None: + lines.append( + f"\u2022 Max travel speed: {self.max_travel_speed} mm/min" + ) + if self.origin: + lines.append(f"\u2022 Origin: {self.origin}") + if self.mirror_x is not None: + lines.append(f"\u2022 Mirror X: {self.mirror_x}") + if self.mirror_y is not None: + lines.append(f"\u2022 Mirror Y: {self.mirror_y}") + if self.camera_calibration: + lines.append( + _("\u2022 Camera calibration: matrix + distortion found") + ) + if not lines: + lines.append(_("(no fields mapped)")) + return lines + + def to_items(self) -> list[tuple[str, str]]: + """Return ``(field_label, value)`` pairs for table display.""" + items: list[tuple[str, str]] = [] + if self.name: + items.append((_("Device name"), self.name)) + if self.axis_extents: + w, h = self.axis_extents + items.append((_("Work area"), f"{w:.0f} \u00d7 {h:.0f} mm")) + if self.driver: + items.append((_("Driver"), self.driver)) + if self.driver_args and "baudrate" in self.driver_args: + items.append((_("Baud rate"), str(self.driver_args["baudrate"]))) + if self.home_on_start is not None: + items.append((_("Home on start"), str(self.home_on_start))) + if self.max_travel_speed is not None: + items.append( + (_("Max travel speed"), f"{self.max_travel_speed} mm/min") + ) + if self.origin: + items.append((_("Origin"), self.origin)) + if self.mirror_x is not None: + items.append((_("Mirror X"), str(self.mirror_x))) + if self.mirror_y is not None: + items.append((_("Mirror Y"), str(self.mirror_y))) + if self.camera_calibration: + items.append( + (_("Camera calibration"), _("matrix + distortion imported")) + ) + return items + + +def parse_lbdev(path: Path) -> dict[str, Any]: + """Parse a LightBurn .lbdev JSON file and return the first device.""" + if not path.exists(): + raise FileNotFoundError(f"LightBurn profile not found: {path}") + try: + with open(path, "r", encoding="utf-8-sig") as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid LightBurn profile JSON: {e}") + + if not isinstance(data, dict) or "DeviceList" not in data: + raise ValueError("LightBurn profile missing 'DeviceList' key") + device_list = data["DeviceList"] + if not device_list: + raise ValueError("LightBurn profile has empty DeviceList") + return device_list[0] + + +def _map_driver(device_type: str) -> str | None: + return _DRIVER_MAP.get(device_type) + + +def _map_origin(cut_origin: int | None) -> str | None: + if cut_origin is None: + return None + return _CUT_ORIGIN_MAP.get(cut_origin) + + +def _parse_camera_data( + settings: dict[str, Any], +) -> dict[str, Any] | None: + """Extract camera calibration data from LightBurn settings.""" + + camera_matrix_raw = settings.get("cameraMatrix") + distortion_raw = settings.get("distortionMatrix") + + if not camera_matrix_raw and not distortion_raw: + return None + + cam: dict[str, Any] = {} + + name = settings.get("LastCamera") or "LightBurn Camera" + cam["name"] = name + cam["device_id"] = name.lower().replace(" ", "_") + + if camera_matrix_raw and len(camera_matrix_raw) == 9: + cam["camera_matrix_fx"] = float(camera_matrix_raw[0]) + cam["camera_matrix_fy"] = float(camera_matrix_raw[4]) + cam["camera_matrix_cx"] = float(camera_matrix_raw[6]) + cam["camera_matrix_cy"] = float(camera_matrix_raw[7]) + + if distortion_raw and len(distortion_raw) == 5: + cam["distortion_k1"] = float(distortion_raw[0]) + cam["distortion_k2"] = float(distortion_raw[1]) + cam["distortion_p1"] = float(distortion_raw[2]) + cam["distortion_p2"] = float(distortion_raw[3]) + cam["distortion_k3"] = float(distortion_raw[4]) + + is_fisheye = settings.get("cameraIsFisheye") + if is_fisheye is not None: + cam["camera_is_fisheye"] = bool(is_fisheye) + + is_head = settings.get("isHeadCamera") + if is_head is not None: + cam["is_head_camera"] = bool(is_head) + + map_scale = settings.get("mapScale") + if map_scale is not None: + cam["map_scale"] = float(map_scale) + + return cam + + +def convert_to_profile( + lbdev_path: Path, +) -> tuple[DeviceProfile, ImportSummary]: + """ + Parse a LightBurn ``.lbdev`` file and produce a + :class:`DeviceProfile` and :class:`ImportSummary`. + + The returned ``DeviceProfile`` has no ``source_dir`` set. Use + :meth:`DeviceProfileManager.install_from_lbdev` to persist it + to the user devices directory. + """ + device_data = parse_lbdev(lbdev_path) + meta, machine_config, summary = _convert(device_data) + + profile = DeviceProfile( + meta=meta, + machine_config=machine_config, + dialect_config=_DEFAULT_GRBL_DIALECT, + ) + + return profile, summary + + +def _convert( + device_data: dict[str, Any], +) -> tuple[DeviceMeta, "MachineConfig", "ImportSummary"]: + """ + Convert a parsed LightBurn device dict to Rayforge models. + """ + name = device_data.get("DisplayName") or device_data.get("Name", "Unknown") + meta = DeviceMeta( + name=name, + ) + summary = ImportSummary(name=name) + + kwargs: dict[str, Any] = {} + + device_type = device_data.get("Type", "") + driver_name = _map_driver(device_type) + if driver_name: + kwargs["driver"] = driver_name + summary.driver = driver_name + + settings = device_data.get("Settings", {}) + + baud_rate = settings.get("BaudRate") + if baud_rate and driver_name == "GrblSerialDriver": + kwargs["driver_args"] = {"baudrate": str(baud_rate)} + summary.driver_args = {"baudrate": str(baud_rate)} + + width = device_data.get("Width") + height = device_data.get("Height") + if width is not None and height is not None: + kwargs["axis_extents"] = (float(width), float(height)) + summary.axis_extents = (float(width), float(height)) + + home = device_data.get("HomeOnStartup") + if home is not None: + kwargs["home_on_start"] = bool(home) + summary.home_on_start = bool(home) + + rapid_speed = settings.get("Sim_RapidSpeed") + max_speed_x = settings.get("Sim_MaxSpeedX") + if rapid_speed is not None: + kwargs["max_travel_speed"] = int(rapid_speed) + summary.max_travel_speed = int(rapid_speed) + elif max_speed_x is not None: + kwargs["max_travel_speed"] = int(max_speed_x) + summary.max_travel_speed = int(max_speed_x) + + cut_origin = settings.get("CutOrigin") + mapped_origin = _map_origin(cut_origin) + if mapped_origin: + kwargs["origin"] = Origin(mapped_origin) + summary.origin = mapped_origin + + mirror_x = device_data.get("MirrorX") + mirror_y = device_data.get("MirrorY") + if mirror_x is not None: + summary.mirror_x = bool(mirror_x) + if mirror_y is not None: + summary.mirror_y = bool(mirror_y) + + camera_data = _parse_camera_data(settings) + if camera_data: + kwargs["cameras"] = [camera_data] + summary.camera_calibration = True + + return meta, MachineConfig(**kwargs), summary diff --git a/rayforge/machine/device/manager.py b/rayforge/machine/device/manager.py new file mode 100644 index 000000000..855219e15 --- /dev/null +++ b/rayforge/machine/device/manager.py @@ -0,0 +1,365 @@ +import logging +import re +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +import yaml + +from ...core.model import ModelLibrary +from .lightburn_importer import ( + ImportSummary, + convert_to_profile, +) +from .profile import ( + CURRENT_API_VERSION, + DIALECT_FILENAME, + MANIFEST_FILENAME, + DeviceProfile, + export_machine_to_dir, + parse_meta, +) + +if TYPE_CHECKING: + from ...context import RayforgeContext + from ...core.model_manager import ModelManager + from ...machine.models.machine import Machine + +logger = logging.getLogger(__name__) + +_UNSAFE_FILENAME_RE = re.compile(r"[^\w\-. ]") + + +def _safe_filename(name: str) -> str: + return _UNSAFE_FILENAME_RE.sub("_", name) or "device" + + +def _find_manifest_in_zip(zf: zipfile.ZipFile) -> str: + for name in zf.namelist(): + if Path(name).name == MANIFEST_FILENAME: + parts = Path(name).parts + if len(parts) <= 2: + return name + raise ValueError(f"No {MANIFEST_FILENAME} found in zip archive") + + +def _extract_zip_to( + zf: zipfile.ZipFile, + dest_dir: Path, + prefix: str, +): + resolved_dest = dest_dir.resolve() + for name in zf.namelist(): + if name.endswith("/"): + continue + if prefix: + if not name.startswith(prefix + "/"): + continue + relative = name[len(prefix) + 1 :] + else: + relative = name + if not relative: + continue + dest_file = (dest_dir / relative).resolve() + try: + dest_file.relative_to(resolved_dest) + except ValueError: + raise ValueError(f"Zip entry '{name}' escapes target directory") + dest_file.parent.mkdir(parents=True, exist_ok=True) + with zf.open(name) as src, open(dest_file, "wb") as dst: + shutil.copyfileobj(src, dst) + + +class DeviceProfileManager: + """ + Discovers, loads, and manages device profiles from one or more + source directories. + + When a device profile contains a ``models/`` directory, it is + automatically registered as a read-only library in the + application's :class:`ModelManager`. + """ + + def __init__( + self, + source_dirs: list[Path] | None = None, + install_dir: Path | None = None, + ): + self._source_dirs: list[Path] = source_dirs or [] + self._install_dir: Path | None = install_dir + self._profiles: dict[str, DeviceProfile] = {} + self._load_errors: dict[str, str] = {} + + @property + def source_dirs(self) -> list[Path]: + return list(self._source_dirs) + + @property + def install_dir(self) -> Path | None: + return self._install_dir + + def add_source_dir(self, directory: Path): + if directory not in self._source_dirs: + self._source_dirs.append(directory) + + def discover( + self, + context: Optional["RayforgeContext"] = None, + ) -> list[DeviceProfile]: + """ + Scan all source directories for device profiles and return + the loaded list. + + Profiles with the same name from later source directories + override earlier ones (user profiles override built-in). + + If *context* is provided, device profiles that contain a + ``models/`` directory are registered as read-only libraries + in ``context.model_mgr``. + """ + self._profiles.clear() + self._load_errors.clear() + + for source_dir in self._source_dirs: + if not source_dir.exists(): + continue + self._scan_directory(source_dir) + + if context is not None: + self._register_model_libraries(context) + + return self.get_all() + + def get_all(self) -> list[DeviceProfile]: + return sorted(self._profiles.values(), key=lambda p: p.name.lower()) + + def get(self, name: str) -> DeviceProfile | None: + return self._profiles.get(name) + + def get_load_errors(self) -> dict[str, str]: + return dict(self._load_errors) + + def load_profile(self, path: Path) -> DeviceProfile: + """ + Load a single device profile from the given directory. + + Also registers it in the internal profile map, replacing any + existing profile with the same name. + """ + pkg = DeviceProfile.from_path(path) + self._profiles[pkg.name] = pkg + return pkg + + def install_from_zip(self, zip_path: Path) -> DeviceProfile: + """ + Install a device profile from a ``.zip`` file. + + Validates that the zip contains a ``device.yaml`` with a + compatible ``api_version``, then extracts to the install + directory. Uses a temporary directory so a failed extraction + never leaves corrupted state. + + Raises: + FileNotFoundError: if ``zip_path`` does not exist. + RuntimeError: if no install directory is configured. + ValueError: if the zip contains no ``device.yaml`` manifest, + an entry escapes the target directory, or the manifest + is invalid (unsupported ``api_version``, missing + ``device.name``, etc.). + TypeError: if the manifest sections have the wrong types. + zipfile.BadZipFile: if ``zip_path`` is not a valid zip + archive. + OSError: on filesystem errors during extraction. + """ + if not zip_path.exists(): + raise FileNotFoundError(f"Zip file not found: {zip_path}") + + if self._install_dir is None: + raise RuntimeError("No install directory configured") + + with zipfile.ZipFile(zip_path, "r") as zf: + manifest_name = _find_manifest_in_zip(zf) + + with zf.open(manifest_name) as f: + data = yaml.safe_load(f) + + meta = parse_meta(data, zip_path) + dest_dir = self._install_dir / _safe_filename(meta.name) + + prefix = str(Path(manifest_name).parent) + if prefix == ".": + prefix = "" + + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) / "staging" + tmp_dir.mkdir() + _extract_zip_to(zf, tmp_dir, prefix) + + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.move(str(tmp_dir), str(dest_dir)) + + return self.load_profile(dest_dir) + + def install_from_lbdev( + self, lbdev_path: Path + ) -> tuple[DeviceProfile, ImportSummary]: + """ + Install a device profile from a LightBurn ``.lbdev`` file. + + Parses the file, writes ``device.yaml`` and ``dialect.yaml`` + into the user devices directory, and loads the resulting + :class:`DeviceProfile`. + + Returns ``(profile, summary)`` so the caller can display the + :class:`ImportSummary` to the user. + + Raises: + FileNotFoundError: if ``lbdev_path`` does not exist. + RuntimeError: if no install directory is configured. + ValueError: if the LightBurn profile is invalid (malformed + JSON, missing or empty ``DeviceList``, etc.). + TypeError: if the resulting manifest sections have the + wrong types. + OSError: on filesystem errors during install. + """ + if not lbdev_path.exists(): + raise FileNotFoundError( + f"LightBurn profile not found: {lbdev_path}" + ) + + if self._install_dir is None: + raise RuntimeError("No install directory configured") + + profile, summary = convert_to_profile(lbdev_path) + + dest_dir = self._install_dir / _safe_filename(profile.name) + + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + device_yaml = { + "api_version": CURRENT_API_VERSION, + "device": { + "name": profile.meta.name, + }, + "machine": profile.machine_config.to_dict(), + } + with open(dest_dir / MANIFEST_FILENAME, "w") as f: + yaml.safe_dump(device_yaml, f, sort_keys=False) + + if profile.dialect_config: + with open(dest_dir / DIALECT_FILENAME, "w") as f: + yaml.safe_dump(profile.dialect_config, f, sort_keys=False) + + loaded = DeviceProfile.from_path(dest_dir) + self._profiles[loaded.name] = loaded + return loaded, summary + + def export_to_zip(self, profile: DeviceProfile, dest: Path) -> Path: + """ + Zip a device profile directory. + + Returns the path to the created ``.rfdevice.zip`` file. + The zip is written to a temporary file first and then + atomically renamed to avoid partial writes. + """ + if profile.source_dir is None: + raise ValueError("Profile has no source directory") + + dest.mkdir(parents=True, exist_ok=True) + zip_path = dest / f"{_safe_filename(profile.name)}.rfdevice.zip" + + with tempfile.TemporaryDirectory() as tmp: + tmp_zip = Path(tmp) / "output.zip" + source_resolved = profile.source_dir.resolve() + with zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in sorted(profile.source_dir.rglob("*")): + if file_path.is_file(): + arcname = file_path.resolve().relative_to( + source_resolved + ) + zf.write(file_path, arcname) + + if zip_path.exists(): + zip_path.unlink() + shutil.move(str(tmp_zip), str(zip_path)) + + return zip_path + + def export_machine( + self, + machine: "Machine", + dest: Path, + model_mgr: Optional["ModelManager"] = None, + ) -> Path: + """ + Export a :class:`Machine` as a shareable ``.rfdevice.zip``. + + Creates a temporary device profile directory from the + machine's current configuration, zips it, and cleans up. + The zip is written to a temporary file first and then + atomically renamed to avoid partial writes. + """ + safe = _safe_filename(machine.name) + dest.mkdir(parents=True, exist_ok=True) + zip_path = dest / f"{safe}.rfdevice.zip" + + with tempfile.TemporaryDirectory() as tmp: + pkg_dir = Path(tmp) / safe + export_machine_to_dir(machine, pkg_dir, model_mgr) + + tmp_zip = Path(tmp) / "output.zip" + pkg_resolved = pkg_dir.resolve() + with zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in sorted(pkg_dir.rglob("*")): + if file_path.is_file(): + arcname = file_path.resolve().relative_to(pkg_resolved) + zf.write(file_path, arcname) + + if zip_path.exists(): + zip_path.unlink() + shutil.move(str(tmp_zip), str(zip_path)) + + return zip_path + + def _scan_directory(self, directory: Path): + for child in sorted(directory.iterdir()): + if not child.is_dir(): + continue + manifest = child / MANIFEST_FILENAME + if not manifest.exists(): + continue + try: + pkg = DeviceProfile.from_path(child) + self._profiles[pkg.name] = pkg + logger.debug(f"Loaded device profile: {pkg.name} from {child}") + except (OSError, ValueError, TypeError, yaml.YAMLError) as e: + key = str(child) + self._load_errors[key] = str(e) + logger.error( + f"Failed to load device profile from {child}: {e}" + ) + + def _register_model_libraries(self, context: "RayforgeContext"): + model_mgr = context.model_mgr + for pkg in self._profiles.values(): + if pkg.source_dir is None: + continue + models_dir = pkg.source_dir / "models" + if not models_dir.is_dir(): + continue + lib_id = f"device:{pkg.name}" + lib = ModelLibrary( + library_id=lib_id, + display_name=pkg.name, + path=models_dir, + read_only=True, + ) + if model_mgr.add_library(lib): + logger.debug( + f"Registered model library for device: {pkg.name}" + ) diff --git a/rayforge/machine/device/profile.py b/rayforge/machine/device/profile.py new file mode 100644 index 000000000..04fecc753 --- /dev/null +++ b/rayforge/machine/device/profile.py @@ -0,0 +1,523 @@ +import logging +import shutil +from dataclasses import asdict, dataclass +from dataclasses import fields as dc_fields +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional + +import yaml + +from ...camera.models.camera import Camera +from ...camera.v4l import migrate_camera_data +from ...core.model import Model +from ...machine.driver import get_driver_cls +from ...machine.models.dialect import GcodeDialect +from ...machine.models.head import head_from_dict +from ...machine.models.machine import Machine, Origin +from ...machine.models.macro import Macro, MacroTrigger +from ...machine.models.rotary_module import RotaryModule +from ...machine.models.zone import Zone +from ...shared.units.system import UnitSystem + +if TYPE_CHECKING: + from ...context import RayforgeContext + from ...core.model_manager import ModelManager + +logger = logging.getLogger(__name__) + +MANIFEST_FILENAME = "device.yaml" +DIALECT_FILENAME = "dialect.yaml" + +CURRENT_API_VERSION = 1 + + +@dataclass +class DeviceMeta: + name: str + vendor: str = "" + model: str = "" + description: str = "" + api_version: int = CURRENT_API_VERSION + + +_DEVICE_META_FIELDS = frozenset(f.name for f in dc_fields(DeviceMeta)) + + +def parse_meta(data: dict, manifest_path: Path) -> DeviceMeta: + api_version = data.get("api_version", 1) + if api_version != CURRENT_API_VERSION: + raise ValueError( + f"Unsupported api_version {api_version} in " + f"{manifest_path} (expected {CURRENT_API_VERSION})" + ) + + device_data = data.get("device", {}) + if not isinstance(device_data, dict): + raise TypeError(f"Invalid 'device' section in {manifest_path}") + + if not device_data.get("name"): + raise ValueError(f"Missing 'device.name' in {manifest_path}") + + filtered = { + k: v for k, v in device_data.items() if k in _DEVICE_META_FIELDS + } + filtered.setdefault("api_version", api_version) + return DeviceMeta(**filtered) + + +_TUPLE_FIELDS = frozenset({"axis_extents", "work_margins", "soft_limits"}) + +_VALID_ORIGINS = sorted(o.value for o in Origin) + + +def parse_machine_config(data: dict, manifest_path: Path) -> "MachineConfig": + raw = data.get("machine", {}) + if not isinstance(raw, dict): + raise TypeError(f"Invalid 'machine' section in {manifest_path}") + + _validate_machine_config(raw, manifest_path) + + kwargs = {} + for key in _MACHINE_CONFIG_KEYS: + if key not in raw: + continue + value = raw[key] + if key == "origin": + try: + value = Origin(value) + except ValueError: + raise ValueError( + f"Invalid origin '{raw['origin']}' in " + f"{manifest_path}. Must be one of: " + f"{_VALID_ORIGINS}" + ) + elif key == "unit_system": + try: + value = UnitSystem(value) + except ValueError: + raise ValueError( + f"Invalid unit_system '{raw['unit_system']}' in " + f"{manifest_path}. Must be one of: " + f"{sorted(u.value for u in UnitSystem)}" + ) + elif key in _TUPLE_FIELDS: + value = tuple(value) + kwargs[key] = value + + return MachineConfig(**kwargs) + + +def _validate_machine_config(config: dict[str, Any], manifest_path: Path): + for key in config: + if key not in _MACHINE_CONFIG_KEYS: + logger.warning( + f"Unknown machine config key '{key}' in {manifest_path}" + ) + + if "axis_extents" in config: + ae = config["axis_extents"] + if ( + not isinstance(ae, list) + or len(ae) != 2 + or not all(isinstance(v, (int, float)) for v in ae) + ): + raise ValueError( + f"'axis_extents' must be a list of two numbers " + f"in {manifest_path}" + ) + + if "work_margins" in config: + wm = config["work_margins"] + if ( + not isinstance(wm, list) + or len(wm) != 4 + or not all(isinstance(v, (int, float)) for v in wm) + ): + raise ValueError( + f"'work_margins' must be a list of four numbers " + f"in {manifest_path}" + ) + + if "soft_limits" in config: + sl = config["soft_limits"] + if ( + not isinstance(sl, list) + or len(sl) != 4 + or not all(isinstance(v, (int, float)) for v in sl) + ): + raise ValueError( + f"'soft_limits' must be a list of four numbers " + f"in {manifest_path}" + ) + + if "heads" in config and not isinstance(config["heads"], list): + raise ValueError(f"'heads' must be a list in {manifest_path}") + + if "hookmacros" in config and not isinstance(config["hookmacros"], list): + raise ValueError(f"'hookmacros' must be a list in {manifest_path}") + + if "rotary_modules" in config and not isinstance( + config["rotary_modules"], list + ): + raise ValueError(f"'rotary_modules' must be a list in {manifest_path}") + + if "nogo_zones" in config and not isinstance(config["nogo_zones"], list): + raise ValueError(f"'nogo_zones' must be a list in {manifest_path}") + + if "cameras" in config and not isinstance(config["cameras"], list): + raise ValueError(f"'cameras' must be a list in {manifest_path}") + + +def _resolve_and_copy_models( + device_data: dict, + dest_dir: Path, + model_mgr: "ModelManager", +): + machine_section = device_data["machine"] + models_dir = dest_dir / "models" + copied: set = set() + + for head_data in machine_section.get("heads", []): + _copy_model_ref(head_data, models_dir, model_mgr, copied) + + for rm_data in machine_section.get("rotary_modules", []): + _copy_model_ref(rm_data, models_dir, model_mgr, copied) + + +def _copy_model_ref( + data: dict, + models_dir: Path, + model_mgr: "ModelManager", + copied: set, +): + mp = data.get("model_path") + if not mp: + return + resolved = model_mgr.resolve(Model.from_path(Path(mp))) + if resolved is None or not resolved.is_file(): + return + models_dir.mkdir(parents=True, exist_ok=True) + filename = resolved.name + if filename not in copied: + shutil.copy2(resolved, models_dir / filename) + copied.add(filename) + data["model_path"] = filename + + +@dataclass +class MachineConfig: + driver: str | None = None + driver_args: dict[str, Any] | None = None + driver_config: dict[str, Any] | None = None + gcode_precision: int | None = None + supports_arcs: bool | None = None + supports_curves: bool | None = None + axis_extents: tuple[float, float] | None = None + work_margins: tuple[float, float, float, float] | None = None + soft_limits: tuple[float, float, float, float] | None = None + origin: Origin | None = None + max_travel_speed: int | None = None + max_cut_speed: int | None = None + home_on_start: bool | None = None + acceleration: int | None = None + single_axis_homing_enabled: bool | None = None + rotary_enabled_default: bool | None = None + unit_system: UnitSystem | None = None + heads: list[dict[str, Any]] | None = None + capabilities: list[str] | None = None + hookmacros: list[dict[str, Any]] | None = None + rotary_modules: list[dict[str, Any]] | None = None + nogo_zones: list[dict[str, Any]] | None = None + cameras: list[dict[str, Any]] | None = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in asdict(self).items(): + if value is None: + continue + if isinstance(value, tuple): + result[key] = list(value) + elif isinstance(value, (Origin, UnitSystem)): + result[key] = value.value + else: + result[key] = value + return result + + @classmethod + def from_machine(cls, machine: Machine) -> "MachineConfig": + heads = None + if machine.heads: + heads = [] + for head in machine.heads: + d = head.to_dict() + d.pop("uid", None) + heads.append(d) + + rotary_modules = None + if machine.rotary_modules: + rotary_modules = [] + for rm in machine.rotary_modules.values(): + d = rm.to_dict() + d.pop("uid", None) + rotary_modules.append(d) + + hookmacros = None + if machine.hookmacros: + hookmacros = [] + for trigger, macro in machine.hookmacros.items(): + d = macro.to_dict() + d["trigger"] = trigger.name + hookmacros.append(d) + + nogo_zones = None + if machine.nogo_zones: + nogo_zones = [z.to_dict() for z in machine.nogo_zones.values()] + + cameras = None + if machine.cameras: + cameras = [c.to_dict() for c in machine.cameras] + + work_margins = None + if machine.work_margins != (0, 0, 0, 0): + work_margins = machine.work_margins + + driver_config = None + if machine.driver_config: + driver_config = machine.driver_config.copy() + + return cls( + driver=machine.driver_name or None, + driver_args=machine.driver_args or None, + driver_config=driver_config, + gcode_precision=machine.gcode_precision, + supports_arcs=machine.supports_arcs, + supports_curves=machine.supports_curves, + axis_extents=machine.axis_extents, + work_margins=work_margins, + soft_limits=machine.soft_limits, + origin=machine.origin, + max_travel_speed=machine.max_travel_speed, + max_cut_speed=machine.max_cut_speed, + home_on_start=machine.home_on_start, + acceleration=machine.acceleration, + single_axis_homing_enabled=(machine.single_axis_homing_enabled), + rotary_enabled_default=machine.rotary_enabled_default, + unit_system=machine.unit_system, + heads=heads, + capabilities=( + [ + c.value + for c in sorted( + machine._explicit_capabilities, key=lambda c: c.value + ) + ] + if machine._explicit_capabilities + else None + ), + hookmacros=hookmacros, + rotary_modules=rotary_modules, + nogo_zones=nogo_zones, + cameras=cameras, + ) + + +_MACHINE_CONFIG_KEYS = frozenset(f.name for f in dc_fields(MachineConfig)) + + +@dataclass +class DeviceProfile: + meta: DeviceMeta + machine_config: MachineConfig + dialect_config: dict[str, Any] + source_dir: Path | None = None + + @property + def name(self) -> str: + return self.meta.name + + @classmethod + def from_path(cls, path: Path) -> "DeviceProfile": + """ + Load a device profile from a directory containing a + ``device.yaml`` manifest. A ``dialect.yaml`` file is required + for G-code drivers but optional for non-G-code drivers. + + Args: + path: Path to the directory containing device.yaml. + + Returns: + A DeviceProfile instance. + + Raises: + FileNotFoundError: If device.yaml is not found. + FileNotFoundError: If dialect.yaml is not found and the + driver uses G-code. + ValueError: If the manifest or dialect is invalid. + """ + manifest_path = path / MANIFEST_FILENAME + if not manifest_path.exists(): + raise FileNotFoundError( + f"Device manifest not found: {manifest_path}" + ) + + with open(manifest_path, "r") as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict): + raise TypeError( + f"Invalid device manifest: {manifest_path} is not a mapping" + ) + + meta = parse_meta(data, manifest_path) + machine_config = parse_machine_config(data, manifest_path) + + driver_uses_gcode = True + if machine_config.driver: + driver_cls = get_driver_cls(machine_config.driver) + driver_uses_gcode = driver_cls.uses_gcode + + dialect_config: dict[str, Any] = {} + dialect_path = path / DIALECT_FILENAME + if dialect_path.exists(): + with open(dialect_path, "r") as f: + dialect_config = yaml.safe_load(f) + GcodeDialect.validate_template_dict( + dialect_config, str(dialect_path) + ) + elif driver_uses_gcode: + raise FileNotFoundError(f"Dialect file not found: {dialect_path}") + + return cls( + meta=meta, + machine_config=machine_config, + dialect_config=dialect_config, + source_dir=path, + ) + + def create_machine(self, context: "RayforgeContext") -> Machine: + m = Machine(context) + m.name = self.meta.name + cfg = self.machine_config + + context.machine_mgr.add_machine(m) + + driver_uses_gcode = True + if cfg.driver: + driver_cls = get_driver_cls(cfg.driver) + driver_uses_gcode = driver_cls.uses_gcode + try: + m.set_driver(driver_cls, cfg.driver_args) + except (ValueError, TypeError, RuntimeError) as exc: + logger.error( + f"Failed to create driver {cfg.driver} " + f"for device '{self.name}': {exc}" + ) + + if cfg.driver_config is not None: + m.driver_config = cfg.driver_config.copy() + + if driver_uses_gcode and self.dialect_config: + new_label = _("{name} (device dialect)").format(name=self.name) + dialect = GcodeDialect.from_template_dict( + self.dialect_config, + label=new_label, + description="", + is_custom=True, + ) + context.dialect_mgr.add_dialect(dialect) + m.dialect_uid = dialect.uid + elif not driver_uses_gcode: + m.dialect_uid = None + + if cfg.gcode_precision is not None: + m.gcode_precision = cfg.gcode_precision + if cfg.supports_arcs is not None: + m.supports_arcs = cfg.supports_arcs + if cfg.supports_curves is not None: + m.supports_curves = cfg.supports_curves + if cfg.axis_extents is not None: + m.set_axis_extents(*cfg.axis_extents) + if cfg.work_margins is not None: + m.set_work_margins(*cfg.work_margins) + if cfg.soft_limits is not None: + m.set_soft_limits(*cfg.soft_limits) + if cfg.origin is not None: + m.origin = cfg.origin + if cfg.max_travel_speed is not None: + m.max_travel_speed = cfg.max_travel_speed + if cfg.max_cut_speed is not None: + m.max_cut_speed = cfg.max_cut_speed + if cfg.home_on_start is not None: + m.home_on_start = cfg.home_on_start + if cfg.acceleration is not None: + m.acceleration = cfg.acceleration + if cfg.single_axis_homing_enabled is not None: + m.single_axis_homing_enabled = cfg.single_axis_homing_enabled + if cfg.rotary_enabled_default is not None: + m.rotary_enabled_default = cfg.rotary_enabled_default + if cfg.unit_system is not None: + m.unit_system = cfg.unit_system + + if cfg.hookmacros is not None: + for s_data in cfg.hookmacros: + try: + trigger = MacroTrigger[s_data["trigger"]] + m.hookmacros[trigger] = Macro.from_dict(s_data) + except (KeyError, ValueError) as e: + logger.warning(f"Skipping invalid hook in device: {e}") + + m.cameras = [] + if cfg.cameras is not None: + for cam_data in cfg.cameras: + m.add_camera(Camera.from_dict(migrate_camera_data(cam_data))) + + if cfg.heads is not None: + for head in m.heads[:]: + m.remove_head(head) + for head_data in cfg.heads: + m.add_head(head_from_dict(head_data)) + + if cfg.capabilities is not None: + m.set_explicit_capabilities( + Machine._parse_capabilities(cfg.capabilities) + ) + + if cfg.rotary_modules is not None: + for rm_data in cfg.rotary_modules: + m.add_rotary_module(RotaryModule.from_dict(rm_data)) + + if cfg.nogo_zones is not None: + for z_data in cfg.nogo_zones: + m.add_nogo_zone(Zone.from_dict(z_data)) + + return m + + +def export_machine_to_dir( + machine: Machine, + dest_dir: Path, + model_mgr: Optional["ModelManager"] = None, +) -> DeviceProfile: + dest_dir.mkdir(parents=True, exist_ok=True) + + mc = MachineConfig.from_machine(machine) + device_data = { + "api_version": CURRENT_API_VERSION, + "device": {"name": machine.name}, + "machine": mc.to_dict(), + } + + if model_mgr is not None: + _resolve_and_copy_models(device_data, dest_dir, model_mgr) + + with open(dest_dir / MANIFEST_FILENAME, "w") as f: + yaml.safe_dump(device_data, f, sort_keys=False) + + if machine.dialect is not None: + with open(dest_dir / DIALECT_FILENAME, "w") as f: + yaml.safe_dump( + machine.dialect.to_template_dict(), f, sort_keys=False + ) + + return DeviceProfile.from_path(dest_dir) diff --git a/rayforge/machine/driver/__init__.py b/rayforge/machine/driver/__init__.py new file mode 100644 index 000000000..fc9aba72b --- /dev/null +++ b/rayforge/machine/driver/__init__.py @@ -0,0 +1,59 @@ +import inspect +from typing import cast + +from .driver import ( + DRIVER_MATURITY_LABELS, + Driver, + DriverMaturity, + PWMParams, +) +from .dummy import NoDeviceDriver +from .grbl import ( + GrblNetworkDriver, + GrblSerialDriver, + GrblSerialSimpleDriver, + GrblTelnetDriver, +) +from .marlin import MarlinSerialDriver +from .octoprint import OctoPrintDriver +from .ruida import RuidaDriver +from .smoothie import SmoothieDriver + + +def isdriver(obj): + return ( + inspect.isclass(obj) and issubclass(obj, Driver) and obj is not Driver + ) + + +drivers = [ + cast(type[Driver], obj) for obj in list(locals().values()) if isdriver(obj) +] + +driver_by_classname = {o.__name__: o for o in drivers} + + +def get_driver_cls(classname: str, default=NoDeviceDriver): + return driver_by_classname.get(classname, default) + + +def register_driver(driver: type[Driver]): + driver_by_classname[driver.__name__] = driver + drivers.append(driver) + + +__all__ = [ + "DRIVER_MATURITY_LABELS", + "Driver", + "DriverMaturity", + "GrblNetworkDriver", + "GrblSerialDriver", + "GrblSerialSimpleDriver", + "GrblTelnetDriver", + "MarlinSerialDriver", + "NoDeviceDriver", + "OctoPrintDriver", + "PWMParams", + "RuidaDriver", + "SmoothieDriver", +] diff --git a/rayforge/machine/driver/driver.py b/rayforge/machine/driver/driver.py new file mode 100644 index 000000000..f12d0002a --- /dev/null +++ b/rayforge/machine/driver/driver.py @@ -0,0 +1,674 @@ +import logging +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import Enum, auto +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Optional, +) + +from blinker import Signal +from raygeo.ops.axis import Axis + +from ...context import RayforgeContext +from ...core.varset import IntVar, VarSet +from ...shared.units.system import UnitSystem + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ...core.doc import Doc + from ...pipeline.encoder.base import EncodedOutput, OpsEncoder + from ..device.profile import DeviceProfile + from ..models.dialect import GcodeDialect + from ..models.head import Head + from ..models.laser import Laser + from ..models.machine import Machine + + +logger = logging.getLogger(__name__) + + +class DriverPrecheckError(Exception): + """Custom exception for non-fatal pre-flight check failures.""" + + +class DriverSetupError(Exception): + """Custom exception for driver setup failures.""" + + +class DeviceConnectionError(Exception): + """Custom exception for failures to communicate with a device.""" + + +class ResourceBusyError(DeviceConnectionError): + """ + Raised when attempting to connect to a resource (e.g. serial port) + that is already in use by another configured machine. + """ + + def __init__(self, resource: str, owner_name: str): + self.resource = resource + self.owner_name = owner_name + super().__init__( + _( + "Resource '{resource}' is currently in use by '{owner}'." + ).format(resource=resource, owner=owner_name) + ) + + +class DriverMaturity(Enum): + STABLE = auto() + UNTESTED = auto() + EXPERIMENTAL = auto() + KNOWN_BUGGY = auto() + + +DRIVER_MATURITY_LABELS = { + DriverMaturity.STABLE: "", + DriverMaturity.UNTESTED: _( + "This driver has not been tested. It may or may not " + "work. Use it at your own risk." + ), + DriverMaturity.EXPERIMENTAL: _( + "This driver is experimental and may have " + "unresolved issues. Use it with caution." + ), + DriverMaturity.KNOWN_BUGGY: _( + "This driver is experimental and almost certainly buggy. It may not " + "work reliably. Use it at your own risk." + ), +} + + +class DeviceStatus(Enum): + UNKNOWN = auto() + IDLE = auto() + RUN = auto() + HOLD = auto() + JOG = auto() + ALARM = auto() + DOOR = auto() + CHECK = auto() + HOME = auto() + SLEEP = auto() + TOOL = auto() + QUEUE = auto() + LOCK = auto() + UNLOCK = auto() + CYCLE = auto() + TEST = auto() + + +# Translatable labels for DeviceStatus enums +DEVICE_STATUS_LABELS = { + DeviceStatus.UNKNOWN: _("Unknown"), + DeviceStatus.IDLE: _("Idle"), + DeviceStatus.RUN: _("Run"), + DeviceStatus.HOLD: _("Hold"), + DeviceStatus.JOG: _("Jog"), + DeviceStatus.ALARM: _("Alarm"), + DeviceStatus.DOOR: _("Door"), + DeviceStatus.CHECK: _("Check"), + DeviceStatus.HOME: _("Home"), + DeviceStatus.SLEEP: _("Sleep"), + DeviceStatus.TOOL: _("Tool"), + DeviceStatus.QUEUE: _("Queue"), + DeviceStatus.LOCK: _("Lock"), + DeviceStatus.UNLOCK: _("Unlock"), + DeviceStatus.CYCLE: _("Cycle"), + DeviceStatus.TEST: _("Test"), +} + + +@dataclass +class DeviceError: + """Error with code, title and description.""" + + code: int + title: str + description: str + + +Pos = tuple[float | None, ...] # x, y, z[, a] in mm + + +@dataclass +class DeviceState: + """Represents the complete state of a device at a moment in time.""" + + status: DeviceStatus = DeviceStatus.UNKNOWN + error: DeviceError | None = None + machine_pos: Pos = (None, None, None) + work_pos: Pos = (None, None, None) + wco: Pos = (0.0, 0.0, 0.0) # Work Coordinate Offset + feed_rate: int | None = None + spindle_speed: int | None = None + buffer_available: int | None = None + buffer_rx_available: int | None = None + + +@dataclass +class PWMParams: + """PWM configuration reported by a driver for a laser head.""" + + frequency: int + max_frequency: int + pulse_width: int + min_pulse_width: int + max_pulse_width: int + + +def pwm_varset(params: PWMParams) -> VarSet: + """Build the PWM frequency / pulse-width settings VarSet.""" + return VarSet( + vars=[ + IntVar( + key="frequency", + label=_("Frequency"), + description=_("PWM frequency in Hz"), + default=params.frequency, + min_val=1, + max_val=params.max_frequency, + ), + IntVar( + key="pulse_width", + label=_("Pulse Width"), + description=_("Pulse width in microseconds"), + default=params.pulse_width, + min_val=params.min_pulse_width, + max_val=params.max_pulse_width, + ), + ] + ) + + +class Driver(ABC): + """ + Abstract base class for all drivers. + All drivers must provide the following methods: + + setup() + cleanup() + connect() + run() + move_to() + + All drivers provide the following signals: + state_changed: emitted when the DeviceState changes + command_status_changed: to monitor a command that was sent + connection_status_changed: signals connectivity changes + probe_status_changed: emits status during a probing cycle + wcs_updated: emitted when work coordinate system data is updated + """ + + label: str + subtitle: str + supports_settings: bool = False + # Drivers that send files via the network may not be able to + # report granular progress updates during the execution of a job. + reports_granular_progress: bool = False + uses_gcode: bool = True + maturity: DriverMaturity = DriverMaturity.STABLE + supports_probing: bool = False + # When True, the firmware applies its own overscan, so Rayforge's + # OverscanTransformer would double it up and should be skipped. + native_overscan: bool = False + # When True, the driver can query the device to detect its + # native unit system (metric vs imperial). + supports_unit_detection: bool = False + + @property + @abstractmethod + def machine_space_wcs(self) -> str: + """ + Returns the machine space coordinate system identifier. + This is an immutable coordinate system with zero offset. + """ + + @property + @abstractmethod + def machine_space_wcs_display_name(self) -> str: + """ + Returns a human-readable display name for the machine space + coordinate system. + """ + + @property + def supported_wcs(self) -> list[str]: + """ + Returns the list of supported mutable Work Coordinate Systems. + + The first item should be the default WCS for this driver. + Drivers may override this to provide driver-specific WCS names. + """ + return ["G54", "G55", "G56", "G57", "G58", "G59"] + + def __init__(self, context: RayforgeContext, machine: "Machine"): + self._context = context + self._machine = machine + self.state_changed = Signal() + self.command_status_changed = Signal() + self.connection_status_changed = Signal() + self.settings_read = Signal() + self.job_finished = Signal() + self.probe_status_changed = Signal() + self.wcs_updated = Signal() + self.config_changed = Signal() + self.config: dict[str, Any] = {} + self.did_setup = False + self.state: DeviceState = DeviceState() + + @property + def dialect(self) -> "GcodeDialect": + assert self._machine.dialect is not None + return self._machine.dialect + + def _log_extra(self, category: str) -> dict[str, str | None]: + """Helper to create log extra dict with machine_id and category.""" + return { + "log_category": category, + "machine_id": self._machine.id if self._machine else None, + } + + def _to_machine_length(self, mm: float) -> float: + """ + Convert a length in millimeters to the machine's native units. + + Returns the value unchanged for metric machines, and inches + rounded to four decimal places for imperial machines. Used when + sending dimensional values to the device (e.g. jog distances, + WCS offsets). + """ + scale = self._machine.unit_system.scale_from_mm + if scale == 1.0: + return mm + return round(mm * scale, 4) + + def _to_machine_speed(self, mm_per_min: float) -> float: + """ + Convert a speed in mm/min to the machine's native units per minute. + + Returns the value unchanged for metric machines, and inches per + minute for imperial machines. + """ + scale = self._machine.unit_system.scale_from_mm + if scale == 1.0: + return mm_per_min + return round(mm_per_min * scale, 4) + + def _from_machine_length(self, value: float) -> float: + """ + Convert a length in the machine's native units back to millimeters. + + Used when interpreting positions reported by the device (e.g. + status reports, probe results) which arrive in machine units. + """ + return value / self._machine.unit_system.scale_from_mm + + @property + def resource_uri(self) -> str | None: + """ + Returns a unique identifier for the physical resource used by this + driver (e.g. 'serial:///dev/ttyUSB0' or 'tcp://192.168.1.50:80'). + + If multiple machines share this URI, the driver will prevent them + from connecting simultaneously. Returns None if the driver does not + lock a physical resource. + """ + return None + + @classmethod + @abstractmethod + def precheck(cls, **kwargs: Any) -> None: + """ + A non-blocking, static check of the configuration that can be run + before driver instantiation. It should raise DriverPrecheckError + on failure. These failures are considered non-fatal warnings. + """ + + @abstractmethod + def _setup_implementation(self, **kwargs: Any) -> None: + """ + Driver-specific setup implementation. Subclasses should override + this method to perform their setup logic. If setup fails, this + method should raise DriverSetupError. + """ + + def setup(self, **kwargs: Any): + """ + The method will be invoked with a dictionary of values gathered + from the UI, based on the VarSet returned by get_setup_vars(). + """ + assert not self.did_setup + self.state.error = None + try: + self._setup_implementation(**kwargs) + except DriverSetupError as e: + logger.error(f"Setup failed: {e}") + self.state.error = DeviceError( + -999, + str(e), + _("Error during setup. You may need to edit device settings."), + ) + self.did_setup = True + + async def cleanup(self): + self.did_setup = False + self.state.error = None + + @classmethod + @abstractmethod + def get_setup_vars(cls) -> "VarSet": + """ + Returns a VarSet defining the parameters needed for setup(). + This is used to dynamically generate the user interface. + """ + + @classmethod + @abstractmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + """ + Factory method to return an OpsEncoder instance suitable for this + driver class and the specific machine configuration. + """ + + @classmethod + async def probe( + cls, context: "RayforgeContext", **kwargs: Any + ) -> tuple["DeviceProfile", list[str]]: + """ + Probe a device at the given connection parameters and return + an auto-populated ``(DeviceProfile, warnings)`` tuple. + Only called if supports_probing is True. + + Raises on connection failure or timeout. + """ + raise NotImplementedError + + def get_encoder(self) -> "OpsEncoder": + """ + Convenience wrapper to get the encoder for this driver instance's + machine. Delegates to the static factory method. + """ + return self.create_encoder(self._machine) + + def supports_pwm(self, head: "Head") -> bool: + """ + Returns whether the driver supports PWM for the given head. + + Subclasses may override this to report driver-specific support + (e.g., PWM on Ruida CO2/fiber lasers but not diode lasers). The + base implementation reports no PWM support. + """ + return False + + def get_pwm_params(self, head: "Head") -> PWMParams | None: + """ + Returns the PWM parameters reported by the driver for the given + head, or None when the driver reports no PWM support. + """ + return None + + async def detect_unit_system(self) -> UnitSystem | None: + """ + Queries the device to detect its native unit system. + + Returns the detected ``UnitSystem``, or ``None`` when the + driver cannot determine it (e.g. the device did not respond, + or the firmware does not expose a unit-system setting). + + The base implementation always returns ``None``. Drivers that + set ``supports_unit_detection = True`` should override this. + + This is called by the controller after a successful connection + when ``machine.auto_detect_units`` is enabled. + """ + return None + + @abstractmethod + def get_setting_vars(self) -> list["VarSet"]: + """ + Returns a VarSet defining the device's settings. + The VarSet should define the settings but may have empty values. + """ + + async def connect(self) -> None: + """ + Checks for resource conflicts with other machines, then establishes + the connection via _connect_implementation(). + """ + my_uri = self.resource_uri + if my_uri: + # Check all other machines managed by the context + # We access the internal dictionary to avoid overhead + machines = self._context.machine_mgr.machines.values() + for other_machine in machines: + if other_machine is self._machine: + continue + + if ( + other_machine.is_connected() + and other_machine.driver + and other_machine.driver.resource_uri == my_uri + ): + raise ResourceBusyError(my_uri, other_machine.name) + + await self._connect_implementation() + + @abstractmethod + async def _connect_implementation(self) -> None: + """ + Establishes the connection and maintains it. i.e. auto reconnect. + On errors or lost connection it should continue trying. + """ + + @abstractmethod + async def run( + self, + encoded: "EncodedOutput", + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + """ + Executes the given encoded output. + + Args: + encoded: The encoded output containing machine code and op map + doc: The document context + ops: The Ops object used to generate the encoded output. + on_command_done: Optional sync or async callback called when each + command is done. Called with the op_index. + """ + + @abstractmethod + async def run_raw(self, machine_code: str) -> None: + """ + Executes a raw command (e.g. G-code if that is what the machine + supports). + + Args: + machine_code: The raw machine code to execute. + """ + + @abstractmethod + async def set_hold(self, hold: bool = True) -> None: + """ + Sends a command to put the currently executing program on hold. + If hold is False, sends the command to remove the hold. + """ + + @abstractmethod + async def cancel(self) -> None: + """ + Sends a command to cancel the currently executing program. + """ + + def can_home(self, axis: Optional["Axis"] = None) -> bool: + """ + Check if this device supports homing for the given axis or axes. + + Args: + axis: Optional axis to check. If None, checks if any homing + is supported. + + Returns: + True if the device supports homing the specified axis/axes, + False otherwise + """ + return True + + @abstractmethod + async def home(self, axes: Optional["Axis"] = None) -> None: + """ + Sends a command to home machine. + + Args: + axes: Optional axis or combination of axes to home. If None, + homes all axes. Can be a single Axis or multiple axes + using binary operators (e.g. Axis.X|Axis.Y) + """ + + @abstractmethod + async def move_to(self, pos_x: float, pos_y: float) -> None: + """ + Moves to the given position. Values are given mm. + """ + + @abstractmethod + async def select_tool(self, tool_number: int) -> None: + """ + Sends a command to select a new tool/laser head by its number. + """ + + @abstractmethod + async def read_settings(self) -> None: + """ + Reads the configuration settings from the device. + Upon completion, it should emit the `settings_read` signal with the + retrieved settings as a dictionary. + """ + + @abstractmethod + async def write_setting(self, key: str, value: Any) -> None: + """ + Writes a single configuration setting to the device. + """ + + @abstractmethod + async def clear_alarm(self) -> None: + """ + Sends a command to clear any active alarm state. + """ + + @abstractmethod + async def set_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. + percent: Power percentage (0-1.0). 0 disables power. + """ + + @abstractmethod + async def set_focus_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power for focus mode. + + Some lasers use different commands for focusing vs cutting + (e.g., M3 for constant power vs M4 for dynamic power). + + Args: + head: The laser head to control. + percent: Power percentage (0-1.0). 0 disables power. + """ + + def can_jog(self, axis: Optional["Axis"] = None) -> bool: + """ + Check if this device supports jogging for the given axis or axes. + + Args: + axis: Optional axis to check. If None, checks if any jogging + is supported. + + Returns: + True if the device supports jogging the specified axis/axes, + False otherwise + """ + return False + + @abstractmethod + async def jog(self, speed: int, **deltas: float) -> None: + """ + Jogs the machine along specified axes. + + Args: + speed: The jog speed in mm/min. + **deltas: Keyword arguments where the key is the axis name + (e.g. 'x', 'y') and the value is the distance in mm. + """ + + @abstractmethod + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + """ + Sends a command to the controller to define the offset for a + specific WCS slot (e.g. "G54"). + """ + + @abstractmethod + async def read_wcs_offsets(self) -> dict[str, Pos]: + """ + Sends a command to query all current WCS offsets from the controller. + + Returns: + A dictionary where keys are WCS slot names (e.g., "G54") and + values are (x, y, z) offset tuples. + """ + + async def read_parser_state(self) -> str | None: + """ + Sends a command to query the active G-code modal states, specifically + to find the active coordinate system (e.g., "G54"). + + Returns: + The active WCS string if found, otherwise None. + """ + return None + + async def select_wcs(self, wcs: str) -> None: + """ + Selects the active Work Coordinate System on the controller. + + For G-code based controllers (GRBL, Smoothie), this is typically + done via G-code commands during job execution. Drivers that require + immediate selection should override this method. + + Args: + wcs: The WCS slot to select (e.g., "G54", "REF0", "MACHINE") + """ + + @abstractmethod + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + """ + Initiates a single probing move along the specified axis. The move + is performed in the negative direction if max_travel is negative. + + Args: + axis: The axis to probe along. + max_travel: The maximum distance to travel in mm. The sign + indicates direction. + feed_rate: The speed of the probing move in mm/min. + + Returns: + The absolute machine coordinates (x, y, z) of the trigger point, + or None if the probe failed to trigger. + """ diff --git a/rayforge/machine/driver/dummy.py b/rayforge/machine/driver/dummy.py new file mode 100644 index 000000000..b6778f848 --- /dev/null +++ b/rayforge/machine/driver/dummy.py @@ -0,0 +1,258 @@ +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +from raygeo.ops.axis import Axis + +from ...context import RayforgeContext +from ...core.varset import VarSet +from ...pipeline.encoder.base import EncodedOutput, OpsEncoder +from ...pipeline.encoder.gcode import GcodeEncoder +from ..transport import TransportStatus +from .driver import DeviceStatus, Driver, Pos + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ...core.doc import Doc + from ..models.laser import Laser + from ..models.machine import Machine + + +logger = logging.getLogger(__name__) + + +class NoDeviceDriver(Driver): + """ + A dummy driver that is used if the user has no machine. + """ + + label = _("No driver") + subtitle = _("No connection") + supports_settings = False + reports_granular_progress = True + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + # Internal state for WCS offsets to behave like a stateful machine + # Initialize from machine's persisted state to prevent overwriting + # loaded configuration with defaults upon connection. + self._offsets: dict[str, Pos] = cast( + dict[str, Pos], + {k: v.offset for k, v in machine.coordinate_systems.items()}, + ) + + # Ensure standard keys exist + defaults = ["G54", "G55", "G56", "G57", "G58", "G59"] + for key in defaults: + if key not in self._offsets: + self._offsets[key] = (0.0, 0.0, 0.0) + + @property + def machine_space_wcs(self) -> str: + """ + Returns the machine space coordinate system identifier. + This is an immutable coordinate system with zero offset. + """ + return "MACHINE" + + @property + def machine_space_wcs_display_name(self) -> str: + """ + Returns a human-readable display name for the machine space + coordinate system. + """ + return _("Machine Coordinates") + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + pass + + def _setup_implementation(self, **kwargs: Any) -> None: + pass + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet(title=_("No settings")) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + """Returns a GcodeEncoder configured for the machine's dialect.""" + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + def get_setting_vars(self) -> list["VarSet"]: + return [VarSet(title=_("No settings"))] + + async def _connect_implementation(self) -> None: + # Simulate connection sequence + self.connection_status_changed.send( + self, status=TransportStatus.CONNECTING + ) + await asyncio.sleep(0.1) + + # Set IDLE state so the UI knows we are ready and not "busy" + self.state.status = DeviceStatus.IDLE + self.state_changed.send(self, state=self.state) + + self.connection_status_changed.send( + self, status=TransportStatus.CONNECTED + ) + + # Upon connect, broadcast WCS state (matches loaded machine state) + self.wcs_updated.send(self, offsets=self._offsets) + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + """ + Dummy implementation that simulates command execution. + + This implementation iterates through the ops defined in op_map and + simulates execution by calling the on_command_done callback for each + command with a small delay. + """ + op_map = encoded.op_map + # We assume ops are indexed 0..N-1. + num_ops = op_map.op_count if op_map else 0 + + # Simulate command execution with delays + for op_index in range(num_ops): + # Small delay to simulate execution time + await asyncio.sleep(0.01) + + # Call the callback if provided, awaiting it if it's a coroutine + if on_command_done is not None: + try: + result = on_command_done(op_index) + if inspect.isawaitable(result): + await result + except Exception: + # Don't let callback exceptions stop execution + logger.debug( + "Job callback raised on op %d", op_index, exc_info=True + ) + self.job_finished.send(self) + + async def run_raw(self, machine_code: str) -> None: + """ + Dummy implementation that simulates raw G-code execution. + """ + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + for line in lines: + logger.info(line, extra=self._log_extra("USER_COMMAND")) + await asyncio.sleep(0.01) + self.job_finished.send(self) + + async def set_hold(self, hold: bool = True) -> None: + pass + + async def cancel(self) -> None: + pass + + def can_home(self, axis: Axis | None = None) -> bool: + """Dummy driver supports homing for all axes.""" + return True + + async def home(self, axes: Axis | None = None) -> None: + pass + + async def move_to(self, pos_x, pos_y) -> None: + pass + + async def select_tool(self, tool_number: int) -> None: + pass + + async def read_settings(self) -> None: + pass + + async def write_setting(self, key: str, value: Any) -> None: + pass + + async def clear_alarm(self) -> None: + pass + + async def set_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + # Dummy driver doesn't control any hardware, so just log the call + logger.info( + f"set_power called with head {head.uid} at {percent * 100:.1f}%", + extra={"log_category": "DRIVER_CMD"}, + ) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power for focus mode. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + logger.info( + f"set_focus_power: head {head.uid} at {percent * 100:.1f}%", + extra={"log_category": "DRIVER_CMD"}, + ) + + def can_jog(self, axis: Axis | None = None) -> bool: + """Dummy driver supports jogging for all axes.""" + return True + + async def jog(self, speed: int, **deltas: float) -> None: + pass + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + """Dummy implementation, updates internal state.""" + self._offsets[wcs_slot] = (x, y, z) + # Notify machine that the driver updated offsets + self.wcs_updated.send(self, offsets=self._offsets) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + """Dummy implementation, returns internal state.""" + self.wcs_updated.send(self, offsets=self._offsets) + return self._offsets + + async def read_parser_state(self) -> str | None: + """ + Simulate reading the active WCS state. + Returns the machine's active WCS to treat the client's selection + as the source of truth for the dummy driver. + """ + return self._machine.active_wcs + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + """ + Dummy implementation, simulates a successful probe after a short delay. + """ + self.probe_status_changed.send( + self, message=f"Simulating probe cycle for axis {axis.name}..." + ) + await asyncio.sleep(0.5) + # Simulate a successful probe at a fixed position + simulated_pos = (10.0, 15.0, -1.0) + self.probe_status_changed.send( + self, message=f"Probe triggered at {simulated_pos}" + ) + return simulated_pos diff --git a/rayforge/machine/driver/grbl/__init__.py b/rayforge/machine/driver/grbl/__init__.py new file mode 100644 index 000000000..5ff896175 --- /dev/null +++ b/rayforge/machine/driver/grbl/__init__.py @@ -0,0 +1,11 @@ +from .grbl_network import GrblNetworkDriver +from .grbl_serial import GrblSerialDriver +from .grbl_serial_simple import GrblSerialSimpleDriver +from .grbl_telnet import GrblTelnetDriver + +__all__ = [ + "GrblNetworkDriver", + "GrblSerialDriver", + "GrblSerialSimpleDriver", + "GrblTelnetDriver", +] diff --git a/rayforge/machine/driver/grbl/grbl_network.py b/rayforge/machine/driver/grbl/grbl_network.py new file mode 100644 index 000000000..e443c3399 --- /dev/null +++ b/rayforge/machine/driver/grbl/grbl_network.py @@ -0,0 +1,860 @@ +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) +from urllib.parse import quote + +import aiohttp + +from ....context import RayforgeContext +from ....core.varset import ChoiceVar, HostnameVar, PortVar, Var, VarSet +from ....core.varset.hostnamevar import is_valid_hostname_or_ip +from ....pipeline.encoder.base import EncodedOutput, OpsEncoder +from ....pipeline.encoder.gcode import GcodeEncoder +from ....shared.units.system import UnitSystem, inches_to_mm +from ...transport import HttpTransport, TransportStatus, WebSocketTransport +from ..driver import ( + Axis, + DeviceConnectionError, + DeviceStatus, + Driver, + DriverPrecheckError, + DriverSetupError, + Pos, +) +from .grbl_probe import probe_grbl_device +from .grbl_util import ( + CommandRequest, + command_url, + detect_unit_system_from_settings, + eeprom_info_url, + execute_url, + fw_info_url, + gcode_to_p_number, + get_grbl_setting_varsets, + grbl_setting_re, + hw_info_url, + is_report_in_inches, + parse_grbl_parser_state, + parse_state, + prb_re, + status_url, + upload_url, + wcs_re, +) + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ....core.doc import Doc + from ...device.profile import DeviceProfile + from ...models.laser import Laser + from ...models.machine import Machine + + +logger = logging.getLogger(__name__) + + +class GrblNetworkDriver(Driver): + """ + A next-generation driver for GRBL-compatible controllers that use a + modern file upload API and allows reading/writing device settings. + """ + + label = _("GRBL (Network)") + subtitle = _("Connect to a GRBL-compatible device over the network") + supports_settings = True + supports_probing = True + supports_unit_detection = True + reports_granular_progress = False + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self.host = None + self.port = None + self.ws_port = None + self.protocol = None + self.http = None + self.websocket = None + self.keep_running = False + self._is_cancelled = False + self._connection_task: asyncio.Task | None = None + self._current_request: CommandRequest | None = None + self._cmd_lock = asyncio.Lock() + self._report_in_inches: bool = False + + @classmethod + async def probe( + cls, context: "RayforgeContext", **kwargs: Any + ) -> tuple["DeviceProfile", list[str]]: + return await probe_grbl_device(cls, context, **kwargs) + + @property + def machine_space_wcs(self) -> str: + return "G53" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates (G53)") + + @property + def resource_uri(self) -> str | None: + if self.host: + # We assume port 80 is the control port for locking purposes + # even if ws_port is different. + return f"tcp://{self.host}:{self.port}" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + if not is_valid_hostname_or_ip(host): + raise DriverPrecheckError( + _("Invalid hostname or IP address: '{host}'").format(host=host) + ) + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + HostnameVar( + key="host", + label=_("Hostname"), + description=_("The IP address or hostname of the device"), + ), + PortVar( + key="port", + label=_("HTTP Port"), + description=_("The HTTP port for the device"), + default=80, + ), + PortVar( + key="ws_port", + label=_("WebSocket Port"), + description=_("The WebSocket port for the device"), + default=81, + ), + ChoiceVar( + key="protocol", + label=_("Protocol variant"), + description=_("Standard, ESP3D, or Longer GRBL variant"), + default="ESP3D", + choices=["ESP3D", "Longer"], + null_label=_("Standard"), + ), + ] + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + """Returns a GcodeEncoder configured for the machine's dialect.""" + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + def _setup_implementation(self, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + port = cast(int, kwargs.get("port", 80)) + ws_port = cast(int, kwargs.get("ws_port", 81)) + protocol = cast(str, kwargs.get("protocol", "ESP3D")) + if not host: + raise DriverSetupError(_("Hostname must be configured.")) + + self.host = host + self.port = port + self.ws_port = ws_port + self.protocol = protocol + + self.http_base = f"http://{host}:{port}" + self.http = HttpTransport( + f"{self.http_base}{status_url}", receive_interval=0.5 + ) + self.http.received.connect(self.on_http_data_received) + self.http.status_changed.connect(self.on_http_status_changed) + + ws_url = f"ws://{host}:{ws_port}/" + self.websocket = WebSocketTransport(ws_url, self.http_base) + self.websocket.received.connect(self.on_websocket_data_received) + self.websocket.status_changed.connect(self.on_websocket_status_changed) + + async def cleanup(self): + self.keep_running = False + if self._connection_task: + self._connection_task.cancel() + if self.websocket: + await self.websocket.disconnect() + self.websocket.received.disconnect(self.on_websocket_data_received) + self.websocket.status_changed.disconnect( + self.on_websocket_status_changed + ) + self.websocket = None + if self.http: + await self.http.disconnect() + self.http.received.disconnect(self.on_http_data_received) + self.http.status_changed.disconnect(self.on_http_status_changed) + self.http = None + await super().cleanup() + + async def _http_get(self, url: str) -> str: + logger.debug( + f"GET {url}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": f"GET {url}", + }, + ) + async with ( + aiohttp.ClientSession() as session, + session.get(url) as response, + ): + data = await response.text() + logger.debug( + f"GET {url} response: {data}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": data.encode("utf-8"), + }, + ) + return data + + async def _send_command(self, command): + if not self.host: + # Raise a user-friendly error immediately if host is not configured + raise DeviceConnectionError( + _( + "Host is not configured. Please set a valid" + " IP address or hostname." + ) + ) + + encoded = quote(command, safe="") + url = f"{self.http_base}{command_url.format(command=encoded)}" + logger.debug( + f"GET {url}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": f"GET {url}", + }, + ) + try: + async with ( + aiohttp.ClientSession() as session, + session.get(url) as response, + ): + response.raise_for_status() # Check for 4xx/5xx errors + data = await response.text() + logger.debug( + f"GET {url} response: {data}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": data.encode("utf-8"), + }, + ) + return data + except aiohttp.ClientError as e: + msg = _( + "Could not connect to host '{host}'. Check the IP address" + " and network connection." + ).format(host=self.host) + raise DeviceConnectionError(msg) from e + + async def _upload(self, gcode, filename): + """ + Overrides the base GrblDriver's upload method with a standard + multipart/form-data POST request. + """ + form = aiohttp.FormData() + if self.protocol == "Longer": + form.add_field("path", "/") + form.add_field("size", str(len(gcode))) + form.add_field( + "file", + gcode, + filename=filename, + content_type="application/octet-stream", + ) + else: + form.add_field( + "file", gcode, filename=filename, content_type="text/plain" + ) + url = f"{self.http_base}{upload_url}?path=/" + + log_data = f"POST to {url} with file '{filename}' size {len(gcode)}" + logger.debug( + log_data, + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": log_data, + }, + ) + async with ( + aiohttp.ClientSession() as session, + session.post(url, data=form) as response, + ): + response.raise_for_status() + data = await response.text() + + logger.debug( + f"POST {url} response: {data}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": data.encode("utf-8"), + }, + ) + return data + + async def _execute(self, filename): + url = f"{self.http_base}{execute_url.format(filename=filename)}" + logger.debug( + f"GET {url}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": f"GET {url}", + }, + ) + async with ( + aiohttp.ClientSession() as session, + session.get(url) as response, + ): + data = await response.text() + + logger.debug( + f"GET {url} response: {data}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": data.encode("utf-8"), + }, + ) + return data + + async def _connect_implementation(self): + if not self.host: + self._update_connection_status( + TransportStatus.DISCONNECTED, "No host configured" + ) + return + + self.keep_running = True + self._connection_task = asyncio.create_task(self._connection_loop()) + + async def _connection_loop(self) -> None: + assert self.http and self.websocket + while self.keep_running: + self._update_connection_status(TransportStatus.CONNECTING) + try: + logger.info("Fetching hardware info...") + await self._http_get(f"{self.http_base}{hw_info_url}") + + logger.info("Fetching device info...") + await self._http_get(f"{self.http_base}{fw_info_url}") + + logger.info("Fetching EEPROM info...") + await self._http_get(f"{self.http_base}{eeprom_info_url}") + + logger.info("Starting HTTP and WebSocket transports...") + async with asyncio.TaskGroup() as tg: + tg.create_task(self.http.connect()) + tg.create_task(self.websocket.connect()) + + except Exception as e: # noqa: BLE001 - connection loop boundary + self._update_connection_status(TransportStatus.ERROR, str(e)) + finally: + if self.websocket: + await self.websocket.disconnect() + if self.http: + await self.http.disconnect() + + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(5) + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + if not self.host: + raise ConnectionError("Driver not configured with a host.") + + gcode = encoded.text + op_map = encoded.op_map + + try: + self._is_cancelled = False + + # For GRBL driver, we don't track individual commands + # since we upload the entire file at once + if on_command_done is not None: + # Call the callback for each op to indicate completion + num_ops = op_map.op_count if op_map else 0 + + for op_index in range(num_ops): + result = on_command_done(op_index) + if inspect.isawaitable(result): + await result + + await self._upload(gcode, "rayforge.gcode") + if not self._is_cancelled: + await self._execute("rayforge.gcode") + except Exception as e: + self._update_connection_status(TransportStatus.ERROR, str(e)) + raise + finally: + if not self._is_cancelled: + self.job_finished.send(self) + + async def run_raw(self, machine_code: str) -> None: + """ + Executes a raw G-code string by uploading it as a file to the device + and then starting the job. + """ + if not self.host: + raise ConnectionError("Driver not configured with a host.") + + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + if not lines: + return + for line in lines: + logger.info(line, extra=self._log_extra("USER_COMMAND")) + + try: + self._is_cancelled = False + await self._upload(machine_code, "rayforge_raw.gcode") + if not self._is_cancelled: + await self._execute("rayforge_raw.gcode") + except Exception as e: + self._update_connection_status(TransportStatus.ERROR, str(e)) + raise + finally: + if not self._is_cancelled: + self.job_finished.send(self) + + async def execute_interactive_command(self, command: str) -> list[str]: + """ + Sends a command via HTTP and waits for the full response from the + WebSocket, including an 'ok' or 'error:'. + """ + async with self._cmd_lock: + if not self.websocket or not self.websocket.is_connected: + raise DeviceConnectionError("Device is not connected.") + + logger.info(command, extra=self._log_extra("USER_COMMAND")) + request = CommandRequest(command=command) + self._current_request = request + try: + # Trigger command via HTTP. We don't care about the response. + await self._send_command(command) + # Wait for the response to arrive on the WebSocket. + await asyncio.wait_for(request.finished.wait(), timeout=10.0) + return request.response_lines + except asyncio.TimeoutError as e: + msg = f"Command '{command}' timed out." + raise DeviceConnectionError(msg) from e + finally: + self._current_request = None + + async def set_hold(self, hold: bool = True) -> None: + await self._send_command("!" if hold else "~") + + async def cancel(self) -> None: + self._is_cancelled = True + # Soft reset: send Ctrl-X (0x18) as a raw byte. _send_command + # URL-encodes the argument, so '\x18' becomes '%18' on the wire — + # which is what the ESP3D/FluidNC web interface interprets as the + # GRBL soft-reset byte. (Do NOT pass the literal string '%18', + # because quote() would double-encode the '%' to '%2518'.) + await self._send_command("\x18") + self.job_finished.send(self) + + def can_home(self, axis: Axis | None = None) -> bool: + """GRBL supports homing for all axes.""" + return True + + async def home(self, axes: Axis | None = None) -> None: + """ + Homes the specified axes or all axes if none specified. + + Args: + axes: Optional axis or combination of axes to home. If None, + homes all axes. Can be a single Axis or multiple axes + using binary operators (e.g. Axis.X|Axis.Y) + """ + dialect = self.dialect + + # Execute the homing command(s) + if axes is None: + await self.execute_interactive_command(dialect.home_all) + else: + for axis in axes: + cmd = dialect.home_axis.format(axis_letter=axis.name) + await self.execute_interactive_command(cmd) + + # The following works around a quirk in some Grbl versions: + # After homing, the machine is still in G54, but forgets its + # offset. To re-activate the offset, we toggle to another + # WCS and then back. + # Just sending G54 is ignored if GRBL thinks it's already in G54. + active_wcs = self._machine.active_wcs + temp_wcs = "G55" if active_wcs == "G54" else "G54" + + # Flush planner buffer + await self.execute_interactive_command("G4 P0.01") + + # Toggle sequence + await self.execute_interactive_command(temp_wcs) + await self.execute_interactive_command(active_wcs) + + async def move_to(self, pos_x, pos_y) -> None: + dialect = self.dialect + cmd = dialect.move_to.format( + speed=self._to_machine_speed(1500), + x=self._to_machine_length(float(pos_x)), + y=self._to_machine_length(float(pos_y)), + ) + await self.execute_interactive_command(cmd) + + async def select_tool(self, tool_number: int) -> None: + """Sends a tool change command for the given tool number.""" + dialect = self.dialect + cmd = dialect.tool_change.format(tool_number=tool_number) + await self.execute_interactive_command(cmd) + + async def clear_alarm(self) -> None: + dialect = self.dialect + response = await self.execute_interactive_command(dialect.clear_alarm) + has_error = any(line.startswith("error:") for line in response) + if not has_error: + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def set_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + # Get the dialect for power control commands + dialect = self.dialect + + if percent <= 0: + # Disable power + cmd = dialect.laser_off + else: + # Enable power with the specified percentage + power_abs = percent * head.max_power + cmd = dialect.laser_on.format(power=power_abs) + + await self.execute_interactive_command(cmd) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power for focus mode using the focus_laser_on command. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + dialect = self.dialect + + if percent <= 0: + await self._wait_for_idle() + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.focus_laser_on.format(power=power_abs) + + await self.execute_interactive_command(cmd) + + async def _wait_for_idle(self, timeout: float = 5.0): + deadline = asyncio.get_event_loop().time() + timeout + while self.state.status == DeviceStatus.JOG: + if asyncio.get_event_loop().time() > deadline: + logger.warning( + "Timed out waiting for JOG to finish before " + "sending laser-off command." + ) + break + await asyncio.sleep(0.05) + + def can_jog(self, axis: Axis | None = None) -> bool: + """GRBL supports jogging for all axes.""" + return True + + async def jog(self, speed: int, **deltas: float) -> None: + """ + Jogs the machine using GRBL's $J command. + + Args: + speed: The jog speed in mm/min + **deltas: Axis names and distances (e.g. x=10.0, y=5.0) + """ + dialect = self.dialect + cmd_parts = [dialect.jog.format(speed=self._to_machine_speed(speed))] + + for axis_name, distance in deltas.items(): + cmd_parts.append( + f"{axis_name.upper()}{self._to_machine_length(distance)}" + ) + + # If no axes specified, do nothing + if len(cmd_parts) == 1: + return + + cmd = " ".join(cmd_parts) + await self.execute_interactive_command(cmd) + + def on_http_data_received(self, sender, data: bytes): + pass + + def on_http_status_changed( + self, sender, status: TransportStatus, message: str | None = None + ): + self._update_command_status(status, message) + + def on_websocket_data_received(self, sender, data: bytes): + logger.debug( + f"WS RX: {data}", + extra={"log_category": "RAW_IO", "direction": "RX", "data": data}, + ) + try: + data_str = data.decode("utf-8").strip() + except UnicodeDecodeError: + logger.warning(f"Received non-UTF8 data on WebSocket: {data!r}") + return + + for line in data_str.splitlines(): + is_status_report = line.startswith("<") and line.endswith(">") + is_ok = line == "ok" + if is_status_report: + log_category = "STATUS_POLL" + elif is_ok: + log_category = "MACHINE_RESPONSE" + else: + log_category = "MACHINE_EVENT" + logger.info(line, extra=self._log_extra(log_category)) + request = self._current_request + + # If a command is awaiting a response, collect the lines. + if request and not request.finished.is_set(): + request.response_lines.append(line) + + # Process line for state updates, regardless of active request. + if is_status_report: + state = parse_state( + line, + self.state, + lambda message: logger.info(message), + report_in_inches=self._report_in_inches, + ) + old_status = self.state.status + if state != self.state: + self.state = state + if state.status != old_status: + logger.info( + f"Device state changed: {self.state.status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) + elif line == "ok": + self._update_command_status(TransportStatus.IDLE) + if request: + request.finished.set() + elif line.startswith("error:"): + self._update_command_status( + TransportStatus.ERROR, message=line + ) + if request: + request.finished.set() + + def on_websocket_status_changed( + self, sender, status: TransportStatus, message: str | None = None + ): + self._update_connection_status(status, message) + + def get_setting_vars(self) -> list["VarSet"]: + return get_grbl_setting_varsets() + + async def detect_unit_system(self) -> UnitSystem | None: + """ + Queries the device's ``$$`` settings and infers the unit + system from the ``$13`` (Report in inches) flag. + """ + try: + response_lines = await self.execute_interactive_command("$$") + except (ConnectionError, asyncio.TimeoutError) as e: + logger.warning(f"Unit system detection failed: {e}") + return None + self._report_in_inches = is_report_in_inches(response_lines) + return detect_unit_system_from_settings(response_lines) + + async def read_settings(self) -> None: + response_lines = await self.execute_interactive_command("$$") + self._report_in_inches = is_report_in_inches(response_lines) + # Get the list of VarSets, which serve as our template + known_varsets = self.get_setting_vars() + + # For efficient lookup, map each setting key to its parent VarSet + key_to_varset_map = { + var.key: varset for varset in known_varsets for var in varset + } + + unknown_vars = VarSet( + title=_("Unknown Settings"), + description=_( + "Settings reported by the device not in the standard list." + ), + ) + + for line in response_lines: + match = grbl_setting_re.match(line) + if match: + key, value_str = match.groups() + # Find which VarSet this key belongs to + target_varset = key_to_varset_map.get(key) + if target_varset: + # Update the value in the correct VarSet + target_varset[key] = value_str + else: + # This setting is not defined in our known VarSets + if unknown_vars.get(key) is None: + unknown_vars.add( + Var( + key=key, + label=f"${key}", + var_type=str, + value=value_str, + description=_("Unknown setting from device"), + ) + ) + + # The result is the list of known VarSets (now populated) + result = known_varsets + if len(unknown_vars) > 0: + # Append the VarSet of unknown settings if any were found + result.append(unknown_vars) + + num_settings = sum(len(vs) for vs in result) + logger.info( + f"Driver settings read with {num_settings} settings.", + extra={"log_category": "DRIVER_EVENT"}, + ) + self.settings_read.send(self, settings=result) + + async def write_setting(self, key: str, value: Any) -> None: + """Writes a setting by sending '$='.""" + if isinstance(value, bool): + value = 1 if value else 0 + cmd = f"${key}={value}" + await self.execute_interactive_command(cmd) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + p_num = gcode_to_p_number(wcs_slot) + if p_num is None: + raise ValueError(f"Invalid WCS slot: {wcs_slot}") + dialect = self.dialect + cmd = dialect.set_wcs_offset.format( + p_num=p_num, + x=self._to_machine_length(x), + y=self._to_machine_length(y), + z=self._to_machine_length(z), + ) + await self.execute_interactive_command(cmd) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + response_lines = await self.execute_interactive_command("$#") + offsets = {} + for line in response_lines: + match = wcs_re.match(line) + if match: + slot, x_str, y_str, z_str = match.groups() + z_str = z_str or "0.000" + parsed: Pos = (float(x_str), float(y_str), float(z_str)) + offsets[slot] = ( + tuple(inches_to_mm(v) for v in parsed) + if self._report_in_inches + else parsed + ) + self.wcs_updated.send(self, offsets=offsets) + return offsets + + async def read_parser_state(self) -> str | None: + """Reads the $G parser state to determine the active WCS.""" + try: + response_lines = await self.execute_interactive_command("$G") + return parse_grbl_parser_state(response_lines) + except DeviceConnectionError as e: + logger.warning(f"Could not read parser state: {e}") + return None + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + assert axis.name, "Probing requires a single, named axis." + axis_letter = axis.name.upper() + dialect = self.dialect + cmd = dialect.probe_cycle.format( + axis_letter=axis_letter, + max_travel=self._to_machine_length(max_travel), + feed_rate=self._to_machine_speed(feed_rate), + ) + + self.probe_status_changed.send( + self, message=f"Probing {axis_letter}..." + ) + response_lines = await self.execute_interactive_command(cmd) + + for line in response_lines: + match = prb_re.match(line) + if match: + x_str, y_str, z_str, success = match.groups() + if int(success) == 1: + pos: Pos = (float(x_str), float(y_str), float(z_str)) + if self._report_in_inches: + pos = tuple(inches_to_mm(v) for v in pos) + self.probe_status_changed.send( + self, message=f"Probe triggered at {pos}" + ) + return pos + + self.probe_status_changed.send(self, message="Probe failed") + return None + + def _update_command_status( + self, status: TransportStatus, message: str | None = None + ): + log_data = f"Command status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.command_status_changed.send(self, status=status, message=message) + + def _update_connection_status( + self, status: TransportStatus, message: str | None = None + ): + log_data = f"Connection status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.connection_status_changed.send( + self, status=status, message=message + ) diff --git a/rayforge/machine/driver/grbl/grbl_probe.py b/rayforge/machine/driver/grbl/grbl_probe.py new file mode 100644 index 000000000..06b413ac0 --- /dev/null +++ b/rayforge/machine/driver/grbl/grbl_probe.py @@ -0,0 +1,221 @@ +import asyncio +import logging +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Protocol, + runtime_checkable, +) + +from blinker import Signal + +from ....shared.units.system import UnitSystem +from ...transport import TransportStatus +from .grbl_util import ( + extract_device_name, + grbl_opt_re, + parse_grbl_settings, + parse_opt_info, + parse_version, +) + +if TYPE_CHECKING: + from ....context import RayforgeContext + from ...device.profile import DeviceProfile + from ...models.machine import Machine + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class _GrblProbeDriver(Protocol): + """ + Protocol describing the interface ``probe_grbl_device`` needs + from any Grbl driver. Any driver implementing these methods + can be probed — serial, telnet, network, or otherwise. + """ + + connection_status_changed: Signal + + def __init__( + self, + context: "RayforgeContext", + machine: "Machine", + ) -> None: ... + + def setup(self, **kwargs: Any) -> None: ... + + async def connect(self) -> None: ... + + async def cleanup(self) -> None: ... + + async def execute_interactive_command(self, command: str) -> list[str]: ... + + +async def probe_grbl_device( + driver_cls: type[_GrblProbeDriver], + context: "RayforgeContext", + **kwargs: Any, +) -> tuple["DeviceProfile", list[str]]: + """ + Shared probe orchestration for all Grbl drivers. + + Creates a temporary machine + driver, connects, queries $I and $$, + disconnects, and returns a ``(DeviceProfile, warnings)`` tuple. + """ + from ...models.machine import Machine + + machine = Machine(context) + driver = driver_cls(context, machine) + driver.setup(**kwargs) + + connected = asyncio.Event() + + def _on_status(sender, status=None, message=None, **kw): + if status == TransportStatus.CONNECTED: + connected.set() + + driver.connection_status_changed.connect(_on_status) + try: + await driver.connect() + await asyncio.wait_for(connected.wait(), timeout=15.0) + build_info = await driver.execute_interactive_command("$I") + settings_lines = await driver.execute_interactive_command("$$") + finally: + driver.connection_status_changed.disconnect(_on_status) + await driver.cleanup() + context.dialect_mgr.dialects_changed.disconnect( + machine._on_dialects_changed + ) + + profile, warnings = build_grbl_profile(build_info, settings_lines) + profile.machine_config.driver = driver_cls.__name__ + profile.machine_config.driver_args = kwargs + return profile, warnings + + +def build_grbl_profile( + build_info: list[str], + settings_lines: list[str], +) -> tuple["DeviceProfile", list[str]]: + """ + Build a ``DeviceProfile`` from raw Grbl ``$I`` and ``$`` + response lines. + + This is a pure data-transformation function with no I/O. + The caller is responsible for communicating with the device + and passing the collected response lines. + + Returns a ``(DeviceProfile, warnings)`` tuple where *warnings* + is a list of human-readable strings about potential issues + detected in the device configuration. + """ + from ...device.profile import ( + DeviceMeta, + DeviceProfile, + MachineConfig, + ) + + rx_buffer_size: int | None = None + compile_flags: str = "" + for line in build_info: + rx = parse_opt_info(line) + if rx is not None: + rx_buffer_size = rx + match = grbl_opt_re.search(line) + if match: + compile_flags = match.group(1) + + settings = parse_grbl_settings(settings_lines) + warnings: list[str] = [] + + name = extract_device_name(build_info) + axis_x = settings.get("130") + axis_y = settings.get("131") + max_x_rate = settings.get("110") + max_y_rate = settings.get("111") + x_accel = settings.get("120") + y_accel = settings.get("121") + max_spindle = settings.get("30") + laser_mode = settings.get("32") + homing_enabled = settings.get("22") + report_inches = settings.get("13") + + max_speed: int | None = None + if max_x_rate is not None and max_y_rate is not None: + max_speed = int(min(max_x_rate, max_y_rate)) + + accel: int | None = None + if x_accel is not None and y_accel is not None: + accel = int(min(x_accel, y_accel)) + + single_axis_homing = "H" in compile_flags + + driver_config: dict[str, Any] = {} + if rx_buffer_size is not None: + driver_config["rx_buffer_size"] = rx_buffer_size + + fw_version = parse_version(build_info) + if fw_version is not None: + driver_config["firmware_version"] = fw_version + + arc_tol = settings.get("12") + if arc_tol is not None: + driver_config["arc_tolerance"] = arc_tol + + extents: tuple[float, float] | None = None + if axis_x is not None and axis_y is not None: + extents = (float(axis_x), float(axis_y)) + + heads: list[dict[str, Any]] | None = None + if max_spindle is not None: + heads = [{"max_power": int(max_spindle)}] + + home_on_start: bool | None = None + if homing_enabled is not None: + home_on_start = bool(int(homing_enabled)) + + if report_inches is not None and int(report_inches): + warnings.append( + _( + "Device is configured to report in inches " + "($13=1). All values shown are in machine " + "units." + ) + ) + + detected_unit_system = UnitSystem.METRIC + if report_inches is not None and int(report_inches): + detected_unit_system = UnitSystem.IMPERIAL + + if laser_mode is not None and not int(laser_mode): + warnings.append( + _( + "Laser mode is not enabled ($32=0). " + "Enable it for best results with laser " + "cutters." + ) + ) + + return ( + DeviceProfile( + meta=DeviceMeta( + name=name, + description=_("Auto-configured via probe wizard"), + ), + machine_config=MachineConfig( + driver_config=driver_config or None, + axis_extents=extents, + max_travel_speed=max_speed, + max_cut_speed=max_speed, + acceleration=accel, + home_on_start=home_on_start, + single_axis_homing_enabled=(single_axis_homing or None), + unit_system=detected_unit_system, + heads=heads, + ), + dialect_config={}, + ), + warnings, + ) diff --git a/rayforge/machine/driver/grbl/grbl_serial.py b/rayforge/machine/driver/grbl/grbl_serial.py new file mode 100644 index 000000000..33e740c75 --- /dev/null +++ b/rayforge/machine/driver/grbl/grbl_serial.py @@ -0,0 +1,1644 @@ +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +import serial.serialutil + +from ....context import RayforgeContext +from ....core.varset import ( + BaudrateVar, + IntVar, + SerialPortVar, + Var, + VarSet, +) +from ....pipeline.encoder.base import ( + EncodedOutput, + MachineCodeOpMap, + OpsEncoder, +) +from ....pipeline.encoder.gcode import GcodeEncoder +from ....shared.units.system import UnitSystem, inches_to_mm +from ...transport import SerialTransport, TransportStatus +from ...transport.grbl import ( + DEFAULT_GRBL_RX_BUFFER_SIZE, + BufferStallError, + GrblResponseType, + GrblSerialTransport, +) +from ...transport.serial import SerialPortPermissionError +from ..driver import ( + Axis, + DeviceConnectionError, + DeviceError, + DeviceStatus, + Driver, + DriverPrecheckError, + DriverSetupError, + Pos, +) +from .grbl_probe import probe_grbl_device +from .grbl_util import ( + CommandRequest, + alarm_code_to_device_error, + detect_unit_system_from_settings, + error_code_to_device_error, + gcode_to_p_number, + get_grbl_setting_varsets, + grbl_setting_re, + is_report_in_inches, + parse_grbl_parser_state, + parse_opt_info, + parse_state, + parse_version, + prb_re, + strip_gcode_comments, + wcs_re, +) + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ....core.doc import Doc + from ...device.profile import DeviceProfile + from ...models.laser import Laser + from ...models.machine import Machine + +logger = logging.getLogger(__name__) + + +class GrblSerialDriver(Driver): + """ + An advanced GRBL serial driver that supports reading and writing + device settings ($$ commands). + """ + + label = _("GRBL (Serial)") + subtitle = _("GRBL-compatible serial connection") + supports_settings = True + reports_granular_progress = True + supports_probing = True + supports_unit_detection = True + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self.grbl_transport: GrblSerialTransport | None = None + self.keep_running = False + self._connection_task: asyncio.Task | None = None + self._current_request: CommandRequest | None = None + self._interactive_request: CommandRequest | None = None + self._cmd_lock = asyncio.Lock() + self._command_queue: asyncio.Queue[CommandRequest] = asyncio.Queue() + self._command_task: asyncio.Task | None = None + self._is_cancelled = False + self._raw_grbl_status: DeviceStatus = DeviceStatus.UNKNOWN + self._job_running = False + self._on_command_done: ( + Callable[[int], None | Awaitable[None]] | None + ) = None + self._last_reported_op_index = -1 + self._job_exception: Exception | None = None + self._poll_status_while_running: bool = False + self._deadlock_detection: bool = False + self._rx_buffer_size_override: int = 0 + self._report_in_inches: bool = False + self._handshake_received = asyncio.Event() + self._loop: asyncio.AbstractEventLoop | None = None + + @property + def machine_space_wcs(self) -> str: + return "G53" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates (G53)") + + @property + def resource_uri(self) -> str | None: + if self.grbl_transport and self.grbl_transport.port: + return f"serial://{self.grbl_transport.port}" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + """Checks for systemic serial port issues before setup.""" + try: + SerialTransport.check_serial_permissions_globally() + except SerialPortPermissionError as e: + # Re-raise as a precheck error for the UI. + raise DriverPrecheckError(str(e)) from e + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + SerialPortVar( + key="port", + label=_("Port"), + description=_("Serial port for the device"), + ), + BaudrateVar( + "baudrate", + choices=SerialTransport.list_baud_rates(), + ), + Var( + key="poll_status_while_running", + label=_("Poll device status during jobs"), + description=_( + "Periodically query the device for position and " + "status while a job is running. Warning: Some " + "devices have trouble maintaining a stable " + "connection if this is used!" + ), + var_type=bool, + default=False, + ), + Var( + key="deadlock_detection", + label=_("Deadlock detection"), + description=_( + "Detect and recover from serial communication " + "deadlocks during jobs. If disabled, the driver " + "will simply wait for the machine to respond. " + "Disable if you experience false ALARM:3 errors." + ), + var_type=bool, + default=False, + ), + IntVar( + key="rx_buffer_size_override", + label=_("RX Buffer Size Override"), + description=_( + "Force a specific RX buffer size in bytes. " + "Set to 0 to auto-detect from the device." + ), + default=0, + min_val=0, + max_val=1024, + ), + ] + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + """Returns a GcodeEncoder configured for the machine's dialect.""" + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + @classmethod + async def probe( + cls, context: "RayforgeContext", **kwargs: Any + ) -> tuple["DeviceProfile", list[str]]: + return await probe_grbl_device(cls, context, **kwargs) + + def _setup_implementation(self, **kwargs: Any) -> None: + port = cast(str, kwargs.get("port", "")) + baudrate = kwargs.get("baudrate", 115200) + self._poll_status_while_running = bool( + kwargs.get("poll_status_while_running", False) + ) + self._deadlock_detection = bool( + kwargs.get("deadlock_detection", False) + ) + self._rx_buffer_size_override = int( + kwargs.get("rx_buffer_size_override", 0) or 0 + ) + + if not port: + raise DriverSetupError(_("Port must be configured.")) + if not baudrate: + raise DriverSetupError(_("Baud rate must be configured.")) + + # Note that we intentionally do not check if the serial + # port exists, as a missing port is a common occurance when + # e.g. the USB cable is not plugged in, and not a sign of + # misconfiguration. + + if port.startswith("/dev/ttyS"): + logger.warning( + f"Port {port} is a hardware serial port, which is unlikely " + f"for USB-based GRBL devices." + ) + + serial_transport = SerialTransport(port, baudrate) + self.grbl_transport = GrblSerialTransport(serial_transport) + self.grbl_transport.received.connect(self.on_serial_data_received) + self.grbl_transport.status_changed.connect( + self.on_serial_status_changed + ) + + def on_serial_status_changed( + self, sender, status: TransportStatus, message: str | None = None + ): + """ + Handle status changes from the serial transport. + + Suppresses the transport-level CONNECTED signal to prevent + premature actions (like $G/$# sync) before the driver has + verified the device responds. The driver emits its own + CONNECTED in _connection_loop after handshake verification. + """ + if status == TransportStatus.CONNECTED: + logger.debug( + "Suppressing transport-level CONNECTED. Driver will " + "emit CONNECTED after handshake verification." + ) + return + logger.debug( + f"Serial transport status changed: {status}, message: {message}" + ) + self._update_connection_status(status, message) + + async def cleanup(self): + logger.debug("Cleanup initiated.") + self.keep_running = False + self._is_cancelled = False + self._job_running = False + self._on_command_done = None + if self.grbl_transport: + self.grbl_transport.reset() + self._job_exception = None + + # Cancel tasks and wait for them to ensure loops terminate + if self._connection_task: + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + except Exception as e: # noqa: BLE001 - awaited task cleanup + logger.warning( + f"Ignored exception in connection task during cleanup: {e}" + ) + self._connection_task = None + + if self._command_task: + self._command_task.cancel() + try: + await self._command_task + except asyncio.CancelledError: + pass + except Exception as e: # noqa: BLE001 - awaited task cleanup + logger.warning( + f"Ignored exception in command task during cleanup: {e}" + ) + self._command_task = None + + if self.grbl_transport: + self.grbl_transport.received.disconnect( + self.on_serial_data_received + ) + self.grbl_transport.status_changed.disconnect( + self.on_serial_status_changed + ) + # Close the serial port to prevent duplicate readers/writers + if self.grbl_transport.is_connected: + await self.grbl_transport.disconnect() + + await super().cleanup() + logger.debug("Cleanup completed.") + + async def _send_realtime(self, command: str, add_newline: bool = True): + logger.debug(f"Sending realtime command: {command}") + if not self.grbl_transport or not self.grbl_transport.is_connected: + raise ConnectionError("Serial transport not initialized") + payload = (command + ("\n" if add_newline else "")).encode("utf-8") + await self.grbl_transport.send_control(payload) + + async def _connect_implementation(self): + """ + Launches the connection loop as a background task and returns, + allowing the UI to remain responsive. + """ + # Defensive cleanup of existing tasks + if self._connection_task and not self._connection_task.done(): + logger.warning( + "Connect called with active connection task. Cleaning up." + ) + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + + if self._command_task and not self._command_task.done(): + self._command_task.cancel() + try: + await self._command_task + except asyncio.CancelledError: + pass + + # Check if setup was successful (grbl_transport exists) + if not self.grbl_transport: + logger.error( + "Cannot connect: Transport not initialized " + "(check port settings)." + ) + self._update_connection_status( + TransportStatus.ERROR, _("Port not configured") + ) + return + + logger.debug("Connect initiated.") + self._loop = asyncio.get_running_loop() + self.keep_running = True + self._is_cancelled = False + self._job_running = False + self._on_command_done = None + self.grbl_transport.reset() + self._job_exception = None + self._connection_task = asyncio.create_task(self._connection_loop()) + self._command_task = asyncio.create_task(self._process_command_queue()) + + def _set_request_finished(self, request: "CommandRequest") -> None: + if not request.finished.is_set(): + if self._loop is not None: + self._loop.call_soon_threadsafe(request.finished.set) + else: + request.finished.set() + + async def _connection_loop(self) -> None: + logger.debug("Entering _connection_loop.") + while self.keep_running: + logger.debug("Attempting connection…") + + try: + transport = self.grbl_transport + if not transport: + raise DriverSetupError("Transport not initialized") + + await transport.connect() + logger.debug( + "Serial port opened. Verifying device response..." + ) + + self._handshake_received.clear() + await self._send_realtime("?", add_newline=False) + + try: + await asyncio.wait_for( + self._handshake_received.wait(), timeout=2.0 + ) + except asyncio.TimeoutError: + logger.warning( + "No response from device. Port may be a phantom " + "COM port without a connected device." + ) + await transport.disconnect() + self._update_connection_status( + TransportStatus.ERROR, + _("No response from device"), + ) + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(5) + continue + + logger.info("Connection established successfully.") + + self._apply_cached_rx_buffer_size() + + try: + await self.execute_interactive_command("$I") + except (ConnectionError, asyncio.TimeoutError) as e: + logger.warning(f"Failed to retrieve build info: {e}") + + self._warn_if_buffer_size_unknown() + + self._update_connection_status(TransportStatus.CONNECTED) + + logger.debug("Connection verified. Starting status polling.") + while transport.is_connected and self.keep_running: + # Skip status polling during jobs if configured + if ( + not self._poll_status_while_running + and self._job_running + ): + await asyncio.sleep(0.5) + continue + + async with self._cmd_lock: + try: + payload = b"?" + await transport.send_poll(payload) + except ConnectionError as e: + logger.warning( + "Connection lost while sending poll" + f" command: {e}" + ) + break + await asyncio.sleep(0.5) + + if not self.keep_running or not transport.is_connected: + break + + except (serial.serialutil.SerialException, OSError) as e: + logger.error(f"Connection error: {e}") + # Don't update status here - the transport's status_changed + # signal already sent ERROR status via + # on_serial_status_changed. + except asyncio.CancelledError: + logger.info("Connection loop cancelled.") + break + except Exception as e: + logger.exception("Unexpected error in connection loop") + self._update_connection_status(TransportStatus.ERROR, str(e)) + finally: + if self.grbl_transport and self.grbl_transport.is_connected: + logger.debug("Disconnecting transport in finally block") + await self.grbl_transport.disconnect() + + if not self.keep_running: + break + + logger.debug("Connection lost. Reconnecting in 5s…") + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(5) + + logger.debug("Leaving _connection_loop.") + + async def _process_command_queue(self) -> None: + logger.debug("Entering _process_command_queue.") + while self.keep_running: + try: + request = await self._command_queue.get() + if ( + not self.grbl_transport + or not self.grbl_transport.is_connected + or self._is_cancelled + ): + logger.warning( + "Cannot process command: Serial transport not " + "connected or job is cancelled. Dropping command." + ) + self._set_request_finished(request) + self._command_queue.task_done() + continue + + self._current_request = request + try: + cmd_text = request.command.strip() + if cmd_text: + logger.info( + cmd_text, + extra=self._log_extra("USER_COMMAND"), + ) + + async with self._cmd_lock: + if ( + not self.grbl_transport + or not self.grbl_transport.is_connected + ): + raise ConnectionError( + "Serial transport disconnected during command." + ) + await self.grbl_transport.send_command(request.payload) + + # Wait for the response to arrive outside the lock + # so that status polling and gcode streaming are not + # blocked. The timeout is handled by the caller + # (_execute_command). + await request.finished.wait() + + except ConnectionError as e: + logger.error(f"Connection error during command: {e}") + self._update_connection_status( + TransportStatus.ERROR, + str(e), + ) + finally: + self._current_request = None + self._command_queue.task_done() + + # Release lock briefly to allow status polling + await asyncio.sleep(0.1) + + except asyncio.CancelledError: + logger.info("Command queue processing cancelled.") + break + except Exception as e: + logger.exception("Unexpected error in command queue") + self._update_connection_status(TransportStatus.ERROR, str(e)) + logger.debug("Leaving _process_command_queue.") + + def _start_job( + self, + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ): + """Initializes state for a new streaming job.""" + self._is_cancelled = False + self._job_running = True + self._on_command_done = on_command_done + self._last_reported_op_index = -1 + self._job_exception = None + if self.grbl_transport: + self.grbl_transport.reset_flow_control() + + async def _recover_from_deadlock( + self, transport, hold_lock: bool = True + ) -> None: + """ + Recover from a detected deadlock by sending a G4 P0.01 dwell. + When its 'ok' arrives, the planner buffer is guaranteed empty. + Then reset host-side buffer accounting. + + When *hold_lock* is False the caller already holds + ``_cmd_lock`` (e.g. the stall callback inside ``send_gcode``). + """ + if not transport or not transport.is_connected: + logger.warning("Cannot recover: transport disconnected.") + return + logger.info("Deadlock recovery: sending G4 P0.01 to drain planner.") + try: + + async def _do_recovery(): + if ( + not self.grbl_transport + or not self.grbl_transport.is_connected + ): + return + transport.reset_flow_control() + await transport.send_gcode(b"G4 P0.01\n") + + if hold_lock: + async with self._cmd_lock: + await _do_recovery() + else: + await _do_recovery() + try: + await asyncio.wait_for( + transport.pending_queue.join(), timeout=30.0 + ) + logger.info("Deadlock recovery: all pending acks received.") + except asyncio.TimeoutError: + logger.warning( + "Deadlock recovery: timed out waiting for acks " + "after G4 P0.01. Resetting host buffers." + ) + transport.reset() + except (ConnectionError, OSError) as e: + logger.warning(f"Deadlock recovery failed: {e}") + + def _is_grbl_idle_or_desynced(self, transport) -> bool: + """ + Check if GRBL is actually idle or buffer tracking has + desynchronized. + + During jobs, the IDLE status is overridden to RUN for the + UI, so ``self.state.status == IDLE`` is never true. Instead + we check: (1) the raw GRBL status before the override, (2) + whether GRBL reports full buffer availability while we still + have pending commands, or (3) whether a non-busy raw status + (IDLE/HOLD) has been seen repeatedly with a non-empty + pending queue — indicating a desync even without ``Bf:``. + """ + if self.state.status == DeviceStatus.IDLE: + return True + if self._raw_grbl_status in ( + DeviceStatus.IDLE, + DeviceStatus.HOLD, + ): + return True + + buf_avail = self.state.buffer_available + return ( + buf_avail is not None + and transport._rx_buffer_size > 0 + and buf_avail >= transport._rx_buffer_size + ) + + async def _poll_and_check_idle( + self, transport, hold_lock: bool = True + ) -> bool: + """ + Send a realtime status poll and check if GRBL is idle. + + Resets ``_raw_grbl_status`` to UNKNOWN, sends a ``?`` poll + (which bypasses the RX buffer), and waits briefly for a + fresh status report. Returns True only if the fresh + response confirms GRBL is idle or buffer-desynchronized. + + When *hold_lock* is False the caller already holds + ``_cmd_lock`` (e.g. the stall callback inside ``send_gcode``). + """ + self._raw_grbl_status = DeviceStatus.UNKNOWN + try: + + async def _do_poll(): + if ( + not self.grbl_transport + or not self.grbl_transport.is_connected + ): + return + await transport.send_poll(b"?") + + if hold_lock: + async with self._cmd_lock: + await _do_poll() + else: + await _do_poll() + except (ConnectionError, OSError): + return False + for _attempt in range(10): + await asyncio.sleep(0.1) + if self._raw_grbl_status != DeviceStatus.UNKNOWN: + break + return self._is_grbl_idle_or_desynced(transport) + + async def _on_buffer_stall( + self, transport: GrblSerialTransport, command_len: int + ) -> bool: + """ + Callback for ``transport.send_gcode()`` when the buffer-space + wait times out. + + Called from within ``send_gcode`` which is inside the + driver's ``_cmd_lock``, so poll and recovery must skip the + lock. pyserial serializes concurrent writes, making this + safe. + + Returns True to retry the wait, False to abort the job. + """ + if self._is_cancelled: + return False + if not self._deadlock_detection: + logger.debug( + "Buffer stall timed out (deadlock detection disabled). " + "Retrying." + ) + return True + + if await self._poll_and_check_idle(transport, hold_lock=False): + if not transport.needs_space(command_len): + logger.info( + "Buffer freed during status poll. Continuing streaming." + ) + return True + logger.warning( + "Deadlock detected during streaming. " + "Attempting G4 P0.01 recovery." + ) + await self._recover_from_deadlock(transport, hold_lock=False) + if not transport.needs_space(command_len): + return True + logger.error("Recovery failed: buffer still full.") + return False + logger.info( + "Timeout waiting for buffer space (machine not IDLE). " + "This is normal during slow moves. Retrying." + ) + return True + + async def _send_gcode_line( + self, + transport, + line: str, + command_bytes: bytes, + op_index: int | None, + timeout: float, + ) -> None: + """Send a single gcode line with buffer accounting.""" + async with self._cmd_lock: + if not self.grbl_transport or not self.grbl_transport.is_connected: + raise ConnectionError( + "Serial transport disconnected during job." + ) + + logger.info(line, extra=self._log_extra("USER_COMMAND")) + + await transport.send_gcode( + command_bytes, + op_index, + timeout=timeout, + on_stall=self._on_buffer_stall, + ) + + async def _drain_pending_acks(self, transport, timeout: float) -> None: + """Wait for all pending acks, recovering from deadlocks.""" + while not transport.pending_queue.empty(): + if self._job_exception or self.state.status == DeviceStatus.ALARM: + break + if self._is_cancelled: + break + + try: + await asyncio.wait_for( + transport.pending_queue.join(), timeout=timeout + ) + logger.debug("All 'ok' responses received.") + break + except asyncio.TimeoutError: + if await self._poll_and_check_idle(transport): + if transport.pending_queue.empty(): + logger.info( + "Pending acks resolved during status poll." + ) + break + logger.warning( + "Deadlock detected at end of job. " + "Attempting G4 P0.01 recovery." + ) + await self._recover_from_deadlock(transport) + else: + logger.warning( + "Timeout waiting for acks " + "(machine not IDLE). Retrying." + ) + + async def _stream_gcode( + self, + gcode_lines: list[str], + op_map: MachineCodeOpMap | None = None, + command_times: list[float] | None = None, + ): + """ + The core G-code streaming logic using character-counting protocol. + Assumes _start_job() has been called. + """ + total = len(gcode_lines) + logger.debug(f"Starting GRBL streaming job with {total} lines.") + transport = self.grbl_transport + if not transport: + raise ConnectionError("Transport not initialized") + job_completed_successfully = False + min_timeout = 5.0 + max_timeout = 120.0 + safety_factor = 3.0 + default_timeout = 30.0 + sent_count = 0 + try: + for line_idx, line in enumerate(gcode_lines): + if ( + self._is_cancelled + or self._job_exception + or self.state.status == DeviceStatus.ALARM + ): + logger.info( + "Job cancelled, errored, or machine in ALARM " + "state. Stopping G-code sending." + ) + if ( + self.state.status == DeviceStatus.ALARM + and not self._job_exception + ): + self._job_exception = DeviceConnectionError( + "Machine entered ALARM state during job." + ) + break + + line = strip_gcode_comments(line) + if not line: + continue + + op_index = op_map.op_for_line(line_idx) if op_map else None + command_bytes = (line + "\n").encode("utf-8") + + if ( + command_times is not None + and op_index is not None + and op_index < len(command_times) + ): + estimated = command_times[op_index] + timeout = min( + max_timeout, + max(min_timeout, estimated * safety_factor), + ) + else: + timeout = default_timeout + + try: + await self._send_gcode_line( + transport, + line, + command_bytes, + op_index, + timeout, + ) + except BufferStallError: + if not self._job_exception: + self._job_exception = DeviceConnectionError( + "Deadlock recovery failed." + ) + if ( + self._job_exception + or self.state.status == DeviceStatus.ALARM + ): + break + + sent_count += 1 + if sent_count % 500 == 0: + logger.debug( + f"Streaming progress: {sent_count}/{total} lines sent" + ) + await asyncio.sleep(0) + + if not self._is_cancelled and not self._job_exception: + logger.debug( + "All G-code sent. Waiting for all 'ok' responses." + ) + await self._drain_pending_acks(transport, default_timeout) + + if self._job_exception: + raise self._job_exception + + job_completed_successfully = not self._is_cancelled + + except ( + asyncio.CancelledError, + ConnectionError, + DeviceConnectionError, + ) as e: + logger.warning(f"Job interrupted: {e!r}") + job_completed_successfully = False + # If not cancelled explicitly, send a cancel command + if not self._is_cancelled: + logger.info(f"Calling cancel() due to interruption: {e!r}") + await self.cancel() + # Do not re-raise ConnectionError or + # DeviceConnectionError, let the task finish "failed" + # Only re-raise CancelledError to propagate cancellation + # upwards. + if isinstance(e, asyncio.CancelledError): + raise + finally: + self._raw_grbl_status = DeviceStatus.UNKNOWN + self._job_running = False + self._on_command_done = None + if job_completed_successfully: + self.job_finished.send(self) + logger.debug( + f"G-code streaming finished successfully " + f"({sent_count}/{total} lines)." + ) + elif self._is_cancelled: + logger.debug( + f"G-code streaming cancelled at line {sent_count}/{total}." + ) + elif self._job_exception: + logger.debug( + f"G-code streaming aborted by exception at " + f"line {sent_count}/{total}: " + f"{self._job_exception}" + ) + self.job_finished.send(self) + else: + logger.warning( + f"G-code streaming ended unexpectedly at " + f"line {sent_count}/{total}." + ) + self.job_finished.send(self) + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + self._start_job(on_command_done) + + mapping = encoded.op_map + gcode_lines = encoded.text.splitlines() + + command_times = ops.estimate_command_times( + default_feed_rate=self._machine.max_cut_speed, + default_rapid_rate=self._machine.max_travel_speed, + acceleration=self._machine.acceleration, + ) + + try: + await self._stream_gcode(gcode_lines, mapping, command_times) + except DeviceConnectionError as e: + # Catch the device error here to prevent it from propagating + # up and tearing down the connection task. The error has + # already been logged inside _stream_gcode. + logger.warning( + f"Job terminated due to device error: {e}. " + "Connection remains active." + ) + except Exception: + logger.exception("Job terminated with unexpected error") + + async def run_raw(self, machine_code: str) -> None: + """ + Executes a raw G-code string using the character-counting + streaming protocol. + """ + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + if not lines: + return + self._start_job() + try: + await self._stream_gcode(lines) + except DeviceConnectionError as e: + logger.warning( + f"Raw G-code terminated due to device error: {e}. " + "Connection remains active." + ) + except Exception: + logger.exception("Raw G-code terminated with unexpected error") + + async def cancel(self) -> None: + logger.debug("Cancel command initiated.") + job_was_running = self._job_running + self._is_cancelled = True + self._job_running = False + self._on_command_done = None + + # Unblock the run loop if it's waiting + if self.grbl_transport: + self.grbl_transport.signal_space_available() + + logger.info("Sending Soft Reset (Ctrl-X) to device.") + payload = b"\x18" + await self.grbl_transport.send_control(payload) + while not self._command_queue.empty(): + try: + request = self._command_queue.get_nowait() + self._set_request_finished(request) + self._command_queue.task_done() + except asyncio.QueueEmpty: + break + logger.debug("Command queue cleared after cancel.") + + # Clear the streaming queue and buffer state + self.grbl_transport.reset() + logger.debug("Streaming queue cleared after cancel.") + + if job_was_running: + self.job_finished.send(self) + else: + raise ConnectionError("Serial transport not initialized") + + async def _execute_command(self, command: str) -> list[str]: + self._is_cancelled = False + request = CommandRequest(command) + await self._command_queue.put(request) + try: + await asyncio.wait_for(request.finished.wait(), timeout=10.0) + except asyncio.TimeoutError: + logger.error( + f"Command '{command}' timed out after 10 seconds. " + "Unblocking command queue." + ) + self._set_request_finished(request) + raise + except asyncio.CancelledError: + logger.debug( + f"Command '{command}' was cancelled. Unblocking queue." + ) + self._set_request_finished(request) + raise + return request.response_lines + + async def execute_interactive_command(self, command: str) -> list[str]: + """ + Send a command and synchronously await its full response. + + Unlike ``_execute_command`` (which queues commands for + asynchronous processing by ``_process_command_queue``), this + method holds the command lock for the entire send-and-wait + cycle so that no other command can be interleaved: + + 1. Acquire ``_cmd_lock`` (blocks ``_process_command_queue`` + and status polling from sending anything). + 2. Drain the transport's pending-ack queue so that no stale + ``ok``/``error`` is in flight. + 3. Set ``_interactive_request``, send the command, and wait + for its response. + 4. Release the lock. + + ``_interactive_request`` is checked *before* + ``_current_request`` in ``_handle_ok`` / ``_handle_error``, + so an interactive command always claims the next + acknowledgement even if ``_process_command_queue`` has a + pending ``_current_request`` from before it blocked on the + lock. + """ + transport = self.grbl_transport + if not transport or not transport.is_connected: + raise ConnectionError("Serial transport not connected") + + request = CommandRequest(command) + + async with self._cmd_lock: + await transport.pending_queue.join() + + self._interactive_request = request + try: + logger.info( + command.strip(), + extra=self._log_extra("USER_COMMAND"), + ) + await transport.send_command(request.payload) + await asyncio.wait_for(request.finished.wait(), timeout=10.0) + except asyncio.TimeoutError: + logger.error( + f"Interactive command '{command}' timed out " + "after 10 seconds." + ) + raise + finally: + self._interactive_request = None + + return request.response_lines + + async def set_hold(self, hold: bool = True) -> None: + self._is_cancelled = False + await self._send_realtime("!" if hold else "~", add_newline=False) + + def can_home(self, axis: Axis | None = None) -> bool: + """GRBL supports homing for all axes.""" + return True + + async def home(self, axes: Axis | None = None) -> None: + """ + Homes the specified axes or all axes if none specified. + + Args: + axes: Optional axis or combination of axes to home. If None, + homes all axes. Can be a single Axis or multiple axes + using binary operators (e.g. Axis.X|Axis.Y) + """ + dialect = self.dialect + + # Execute the homing command(s) + if axes is None: + await self._execute_command(dialect.home_all) + else: + for axis in axes: + cmd = dialect.home_axis.format(axis_letter=axis.name) + await self._execute_command(cmd) + + # The following works around a quirk in some Grbl versions: + # After homing, the machine is still in G54, but forgets its + # offset. To re-activate the offset, we toggle to another + # WCS and then back. + # Just sending G54 is ignored if GRBL thinks it's already + # in G54. + active_wcs = self._machine.active_wcs + temp_wcs = "G55" if active_wcs == "G54" else "G54" + + # Flush planner buffer + await self._execute_command("G4 P0.01") + + # Toggle sequence + await self._execute_command(temp_wcs) + await self._execute_command(active_wcs) + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def move_to(self, pos_x, pos_y) -> None: + dialect = self.dialect + cmd = dialect.move_to.format( + speed=self._to_machine_speed(1500), + x=self._to_machine_length(float(pos_x)), + y=self._to_machine_length(float(pos_y)), + ) + await self._execute_command(cmd) + + async def select_tool(self, tool_number: int) -> None: + """Sends a tool change command for the given tool number.""" + dialect = self.dialect + cmd = dialect.tool_change.format(tool_number=tool_number) + await self._execute_command(cmd) + + async def clear_alarm(self) -> None: + dialect = self.dialect + response = await self._execute_command(dialect.clear_alarm) + has_error = any(line.startswith("error:") for line in response) + if not has_error: + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def set_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + # Get the dialect for power control commands + dialect = self.dialect + + if percent <= 0: + # Disable power + cmd = dialect.laser_off + else: + # Enable power with specified percentage + power_abs = percent * head.max_power + cmd = dialect.laser_on.format(power=power_abs) + + await self._execute_command(cmd) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power for focus mode using the focus_laser_on + command. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + dialect = self.dialect + + if percent <= 0: + await self._wait_for_idle() + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.focus_laser_on.format(power=power_abs) + + await self._execute_command(cmd) + + async def _wait_for_idle(self, timeout: float = 5.0): + deadline = asyncio.get_event_loop().time() + timeout + while self.state.status == DeviceStatus.JOG: + if asyncio.get_event_loop().time() > deadline: + logger.warning( + "Timed out waiting for JOG to finish before " + "sending laser-off command." + ) + break + await asyncio.sleep(0.05) + + def can_jog(self, axis: Axis | None = None) -> bool: + """GRBL supports jogging for all axes.""" + return True + + async def jog(self, speed: int, **deltas: float) -> None: + """ + Jogs the machine using GRBL's $J command. + + Args: + speed: The jog speed in mm/min + **deltas: Axis names and distances (e.g. x=10.0, y=5.0) + """ + # Build the command with all specified axes + dialect = self.dialect + cmd_parts = [dialect.jog.format(speed=self._to_machine_speed(speed))] + + for axis_name, distance in deltas.items(): + cmd_parts.append( + f"{axis_name.upper()}{self._to_machine_length(distance)}" + ) + + if len(cmd_parts) == 1: + return + + cmd = " ".join(cmd_parts) + await self._execute_command(cmd) + + def get_setting_vars(self) -> list["VarSet"]: + return get_grbl_setting_varsets() + + async def detect_unit_system(self) -> UnitSystem | None: + """ + Queries the device's ``$$`` settings and infers the unit + system from the ``$13`` (Report in inches) flag. + """ + try: + response_lines = await self.execute_interactive_command("$$") + except (ConnectionError, asyncio.TimeoutError) as e: + logger.warning(f"Unit system detection failed: {e}") + return None + self._report_in_inches = is_report_in_inches(response_lines) + return detect_unit_system_from_settings(response_lines) + + async def read_settings(self) -> None: + response_lines = await self.execute_interactive_command("$$") + self._report_in_inches = is_report_in_inches(response_lines) + # Get the list of VarSets, which serve as our template + known_varsets = self.get_setting_vars() + + # For efficient lookup, map each setting key to its parent VarSet + key_to_varset_map = { + var_key: varset + for varset in known_varsets + for var_key in varset.keys() # noqa: SIM118 + } + + unknown_vars = VarSet( + title=_("Unknown Settings"), + description=_( + "Settings reported by the device not in the standard list." + ), + ) + + for line in response_lines: + match = grbl_setting_re.match(line) + if match: + key, value_str = match.groups() + # Find which VarSet this key belongs to + target_varset = key_to_varset_map.get(key) + if target_varset: + # Update the value in the correct VarSet + target_varset[key] = value_str + else: + # This setting is not defined in our known VarSets + unknown_vars.add( + Var( + key=key, + label=f"${key}", + var_type=str, + value=value_str, + description=_("Unknown setting from device"), + ) + ) + + # The result is the list of known VarSets (now populated) + result = known_varsets + if len(unknown_vars) > 0: + # Append the VarSet of unknown settings if any were found + result.append(unknown_vars) + + num_settings = sum(len(vs) for vs in result) + logger.info( + f"Driver settings read with {num_settings} settings.", + extra={"log_category": "DRIVER_EVENT"}, + ) + self.settings_read.send(self, settings=result) + + async def write_setting(self, key: str, value: Any) -> None: + if isinstance(value, bool): + value = 1 if value else 0 + cmd = f"${key}={value}" + await self._execute_command(cmd) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + p_num = gcode_to_p_number(wcs_slot) + if p_num is None: + raise ValueError(f"Invalid WCS slot: {wcs_slot}") + dialect = self.dialect + cmd = dialect.set_wcs_offset.format( + p_num=p_num, + x=self._to_machine_length(x), + y=self._to_machine_length(y), + z=self._to_machine_length(z), + ) + await self._execute_command(cmd) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + response_lines = await self.execute_interactive_command("$#") + offsets = {} + for line in response_lines: + match = wcs_re.match(line) + if match: + slot, x_str, y_str, z_str = match.groups() + z_str = z_str or "0.000" + parsed: Pos = (float(x_str), float(y_str), float(z_str)) + offsets[slot] = ( + tuple(inches_to_mm(v) for v in parsed) + if self._report_in_inches + else parsed + ) + self.wcs_updated.send(self, offsets=offsets) + return offsets + + async def read_parser_state(self) -> str | None: + """Reads the $G parser state to determine the active WCS.""" + try: + response_lines = await self.execute_interactive_command("$G") + return parse_grbl_parser_state(response_lines) + except DeviceConnectionError as e: + logger.warning(f"Could not read parser state: {e}") + return None + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + assert axis.name, "Probing requires a single, named axis." + axis_letter = axis.name.upper() + dialect = self.dialect + cmd = dialect.probe_cycle.format( + axis_letter=axis_letter, + max_travel=self._to_machine_length(max_travel), + feed_rate=self._to_machine_speed(feed_rate), + ) + + self.probe_status_changed.send( + self, message=f"Probing {axis_letter}..." + ) + try: + response_lines = await self.execute_interactive_command(cmd) + except DeviceConnectionError: + self.probe_status_changed.send( + self, message="Probe failed: Timed out" + ) + return None + + for line in response_lines: + match = prb_re.match(line) + if match: + x_str, y_str, z_str, success = match.groups() + if int(success) == 1: + pos: Pos = ( + float(x_str), + float(y_str), + float(z_str), + ) + if self._report_in_inches: + pos = tuple(inches_to_mm(v) for v in pos) + self.probe_status_changed.send( + self, message=f"Probe triggered at {pos}" + ) + return pos + + self.probe_status_changed.send(self, message="Probe failed") + return None + + def on_serial_data_received(self, sender, data: bytes): + """ + Primary handler for incoming serial data. Delegates parsing + to the transport layer and processes structured responses. + """ + if not self.grbl_transport: + return + + responses = self.grbl_transport.parse_incoming(data) + + # Process LINE responses before OK/ERROR to ensure + # informational lines are collected before the command + # is marked as finished. _extract_acks_from_buffer + # returns OK/ERROR first, but _handle_ok schedules + # request.finished.set() via call_soon_threadsafe on a + # separate thread. If that thread executes before + # _handle_line runs, the lines would be lost. + lines = [] + acks = [] + for resp in responses: + if resp.type == GrblResponseType.LINE: + lines.append(resp) + else: + acks.append(resp) + for resp in lines + acks: + self._handle_response(resp) + + def _handle_response(self, resp): + """ + Route a parsed GrblResponse to the appropriate handler. + """ + if resp.type == GrblResponseType.OK: + self._handle_ok(resp) + elif resp.type == GrblResponseType.ERROR: + self._handle_error(resp.text) + else: + self._handle_line(resp.text) + + def _handle_status_report(self, report: str): + """ + Parses a GRBL status report (e.g., '') + and updates the device state. + """ + if self.grbl_transport: + count = self.grbl_transport.ack_status_report() + else: + count = 0 + buf_size = ( + self.grbl_transport._rx_buffer_size + if self.grbl_transport + else DEFAULT_GRBL_RX_BUFFER_SIZE + ) + buf_info = f"buf: {count}/{buf_size}" + logger.debug(f"Processing status report: {report} ({buf_info})") + logger.info(report, extra=self._log_extra("STATUS_POLL")) + + state = parse_state( + report, + self.state, + lambda message: logger.info(message), + report_in_inches=self._report_in_inches, + ) + + self._raw_grbl_status = state.status + + if ( + state.buffer_rx_available is not None + and state.status == DeviceStatus.IDLE + and self.grbl_transport + and self._rx_buffer_size_override <= 0 + ): + total = state.buffer_rx_available + cur = self.grbl_transport._rx_buffer_size + if total > 0 and total != cur: + logger.info( + f"Detected RX buffer size {total} from " + f"Bf: status field (device idle, " + f"available == total)" + ) + self.grbl_transport.set_rx_buffer_size(total) + self._cache_rx_buffer_size(total) + + # If a job is active, 'Idle' state between commands should be + # reported as 'Run' to the UI. + if self._job_running and state.status == DeviceStatus.IDLE: + state.status = DeviceStatus.RUN + + old_status = self.state.status + if state != self.state: + self.state = state + if state.status != old_status: + logger.info( + f"Device state changed: {self.state.status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) + + def _apply_cached_rx_buffer_size(self) -> None: + if not self.grbl_transport: + return + if self._rx_buffer_size_override > 0: + logger.info( + f"Applying RX buffer size override: " + f"{self._rx_buffer_size_override} bytes" + ) + self.grbl_transport.set_rx_buffer_size( + self._rx_buffer_size_override + ) + return + cached = self.config.get("rx_buffer_size") + if cached and cached > 0: + logger.info(f"Applying cached RX buffer size: {cached} bytes") + self.grbl_transport.set_rx_buffer_size(cached) + + def _cache_rx_buffer_size(self, size: int) -> None: + logger.info(f"Caching RX buffer size: {size} bytes") + self.config["rx_buffer_size"] = size + self.config_changed.send(self) + + def _warn_if_buffer_size_unknown(self) -> None: + if self._rx_buffer_size_override > 0: + return + if "rx_buffer_size" in self.config: + return + logger.warning( + "Device did not report RX buffer size via $I. " + f"Using default {DEFAULT_GRBL_RX_BUFFER_SIZE} bytes. " + "If you experience errors, the device may have a " + "smaller buffer than expected.", + extra=self._log_extra("ERROR"), + ) + + def _handle_ok(self, resp): + """Handle a parsed 'ok' response.""" + pending = resp.pending + logger.info("ok", extra=self._log_extra("MACHINE_RESPONSE")) + + if pending is not None: + transport = self.grbl_transport + assert transport is not None + logger.debug( + f"Processed 'ok', freed {pending.length} bytes " + f"for {pending.command!r} " + f"(buf: {transport.buffer_count}" + f"/{transport._rx_buffer_size}, " + f"op_index={pending.op_index})" + ) + + # Logic for single, interactive commands + request = self._interactive_request or self._current_request + if request and not request.finished.is_set(): + request.response_lines.append("ok") + self.command_status_changed.send(self, status=TransportStatus.IDLE) + logger.debug(f"Command '{request.command}' completed with 'ok'") + self._set_request_finished(request) + # Logic for streaming protocol during a job + if ( + self._job_running + and pending is not None + and self._on_command_done + and pending.op_index is not None + ): + for i in range( + self._last_reported_op_index + 1, + pending.op_index + 1, + ): + try: + logger.debug(f"Firing on_command_done for op_index {i}") + result = self._on_command_done(i) + if inspect.isawaitable(result): + asyncio.ensure_future(result) + except Exception as e: + logger.error( + "Error in on_command_done callback", + exc_info=e, + ) + self._last_reported_op_index = pending.op_index + + def _handle_error(self, text: str): + """Handle a parsed 'error:...' response.""" + logger.info(text, extra=self._log_extra("MACHINE_EVENT")) + error_code = text.split(":")[1].strip() if ":" in text else "" + self.state.error = error_code_to_device_error(error_code) + self.state_changed.send(self, state=self.state) + + request = self._interactive_request or self._current_request + if request and not request.finished.is_set(): + request.response_lines.append(text) + self.command_status_changed.send( + self, status=TransportStatus.ERROR, message=text + ) + self._set_request_finished(request) + + if self._job_running: + self.command_status_changed.send( + self, status=TransportStatus.ERROR, message=text + ) + logger.error( + f"GRBL error during job: {text}. Halting stream.", + extra={"log_category": "ERROR"}, + ) + self._job_exception = DeviceConnectionError(f"GRBL error: {text}") + if self.grbl_transport: + self.grbl_transport.signal_space_available() + + def _handle_line(self, line: str): + """Handle a parsed general line (status report, alarm, info).""" + if line.startswith("<") and not line.endswith(">"): + logger.debug(f"Ignoring fragmented status report: {line}") + return + + if "Pos:" in line and "|" in line and not line.startswith("<"): + logger.debug(f"Ignoring fragmented status report: {line}") + return + + if line.startswith("<") and line.endswith(">"): + self._handshake_received.set() + self._handle_status_report(line.strip()) + return + + logger.info(line, extra=self._log_extra("MACHINE_EVENT")) + + # Collect response lines for pending single commands + request = self._interactive_request or self._current_request + if request and not request.finished.is_set(): + request.response_lines.append(line) + + if line.startswith("ALARM:"): + alarm_code = line.split(":")[1].strip() + self.state.error = alarm_code_to_device_error(alarm_code) + self.state_changed.send(self, state=self.state) + self.command_status_changed.send( + self, status=TransportStatus.ERROR, message=line + ) + if self._job_running: + logger.error( + f"GRBL ALARM during job: {line}. Halting stream.", + extra={"log_category": "ERROR"}, + ) + self._job_exception = DeviceConnectionError( + f"GRBL ALARM: {line}" + ) + if self.grbl_transport: + self.grbl_transport.signal_space_available() + elif line.startswith("[VER:"): + ver = parse_version([line]) + if ver: + logger.info(f"Connected to GRBL version {ver}") + elif line.startswith("[OPT:"): + rx_buffer_size = parse_opt_info(line) + if rx_buffer_size: + self._cache_rx_buffer_size(rx_buffer_size) + if self._rx_buffer_size_override <= 0 and self.grbl_transport: + self.grbl_transport.set_rx_buffer_size(rx_buffer_size) + elif line.startswith("Grbl "): + self._handshake_received.set() + logger.debug(f"Received Grbl welcome message: {line}") + else: + logger.debug(f"Received informational line: {line}") + + def get_error(self, error_code: str) -> DeviceError | None: + """ + Returns error details for a given GRBL error code. + + Args: + error_code: The error code string from device (e.g., "1", + "2"). + + Returns: + An ErrorCode instance with title and description, or None + if the error code is not recognized. + """ + return error_code_to_device_error(error_code) + + def _update_connection_status( + self, status: TransportStatus, message: str | None = None + ): + log_data = f"Connection status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.connection_status_changed.send( + self, status=status, message=message + ) diff --git a/rayforge/machine/driver/grbl/grbl_serial_simple.py b/rayforge/machine/driver/grbl/grbl_serial_simple.py new file mode 100644 index 000000000..38200c248 --- /dev/null +++ b/rayforge/machine/driver/grbl/grbl_serial_simple.py @@ -0,0 +1,786 @@ +""" +GRBL Simple Serial Driver -- ping-pong protocol without buffer counting. + +This driver uses the simplest possible communication strategy: send one +G-code line, wait for the ``ok`` acknowledgement, then send the next. +There is no character-counting flow control, no deadlock detection, and +no buffer management. This makes it extremely robust at the cost of +lower throughput compared to the advanced ``GrblSerialDriver``. +""" + +import asyncio +import logging +import re +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, +) + +from ....context import RayforgeContext +from ....core.varset import ( + BaudrateVar, + SerialPortVar, + Var, + VarSet, +) +from ....pipeline.encoder.base import ( + EncodedOutput, + MachineCodeOpMap, + OpsEncoder, +) +from ....pipeline.encoder.gcode import GcodeEncoder +from ....shared.units.system import UnitSystem, inches_to_mm +from ...transport import SerialTransport, TransportStatus +from ...transport.serial import SerialPortPermissionError +from ..driver import ( + Axis, + DeviceConnectionError, + DeviceStatus, + Driver, + DriverMaturity, + DriverPrecheckError, + DriverSetupError, + Pos, +) +from .grbl_probe import probe_grbl_device +from .grbl_util import ( + alarm_code_to_device_error, + detect_unit_system_from_settings, + gcode_to_p_number, + get_grbl_setting_varsets, + grbl_setting_re, + is_report_in_inches, + parse_grbl_parser_state, + parse_state, + prb_re, + strip_gcode_comments, + wcs_re, +) + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ....core.doc import Doc + from ...device.profile import DeviceProfile + from ...models.laser import Laser + from ...models.machine import Machine + +logger = logging.getLogger(__name__) + + +class _PingPongPending: + """Tracks a single sent command awaiting its ``ok`` response.""" + + __slots__ = ("command", "error", "event", "response_lines") + + def __init__(self, command: str): + self.command = command + self.event = asyncio.Event() + self.response_lines: list[str] = [] + self.error: str | None = None + + def set_result(self, error: str | None = None): + self.error = error + self.event.set() + + async def wait(self, timeout: float = 30.0) -> list[str]: + await asyncio.wait_for(self.event.wait(), timeout=timeout) + if self.error is not None: + raise DeviceConnectionError(self.error) + return self.response_lines + + +class GrblSerialSimpleDriver(Driver): + """ + A minimal GRBL serial driver using ping-pong communication. + + Sends one G-code line at a time and waits for ``ok`` before + proceeding. No character-counting, no buffer management, no + deadlock detection. Ideal for users experiencing communication + issues with the advanced driver. + """ + + label = _("GRBL (Serial Simple)") + subtitle = _( + "GRBL serial with simple ping-pong protocol (no buffer counting)" + ) + supports_settings = True + reports_granular_progress = True + supports_probing = True + supports_unit_detection = True + maturity = DriverMaturity.EXPERIMENTAL + + _ok_re = re.compile(rb"ok\r*\n") + _error_re = re.compile(rb"error:(\d+)\r*\n") + _status_re = re.compile(rb"<([^>]+)>") + _line_re = re.compile(rb"([^\r\n]+)\r*\n") + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self._transport: SerialTransport | None = None + self.keep_running = False + self._connection_task: asyncio.Task | None = None + self._poll_task: asyncio.Task | None = None + self._cmd_lock = asyncio.Lock() + self._is_cancelled = False + self._job_running = False + self._raw_grbl_status: DeviceStatus = DeviceStatus.UNKNOWN + self._handshake_received = asyncio.Event() + self._on_command_done: ( + Callable[[int], None | Awaitable[None]] | None + ) = None + self._pending: _PingPongPending | None = None + self._line_buffer: bytearray = bytearray() + self._current_op_index: int = -1 + self._report_in_inches: bool = False + + @property + def machine_space_wcs(self) -> str: + return "G53" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates (G53)") + + @property + def resource_uri(self) -> str | None: + if self._transport and self._transport.port: + return f"serial://{self._transport.port}" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + try: + SerialTransport.check_serial_permissions_globally() + except SerialPortPermissionError as e: + raise DriverPrecheckError(str(e)) from e + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + SerialPortVar( + key="port", + label=_("Port"), + description=_("Serial port for the device"), + ), + BaudrateVar( + "baudrate", + choices=SerialTransport.list_baud_rates(), + ), + ] + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> OpsEncoder: + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + @classmethod + async def probe( + cls, context: "RayforgeContext", **kwargs: Any + ) -> tuple["DeviceProfile", list[str]]: + return await probe_grbl_device(cls, context, **kwargs) + + def _setup_implementation(self, **kwargs: Any) -> None: + port = kwargs.get("port", "") + baudrate = kwargs.get("baudrate", 115200) + + if not port: + raise DriverSetupError(_("Port must be configured.")) + if not baudrate: + raise DriverSetupError(_("Baudrate must be configured.")) + + self._transport = SerialTransport(str(port), int(baudrate)) + self._transport.received.connect(self._on_serial_data) + + async def cleanup(self) -> None: + self.keep_running = False + self._is_cancelled = False + self._job_running = False + if self._connection_task and not self._connection_task.done(): + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + self._connection_task = None + self._poll_task = None + if self._transport: + self._transport.received.disconnect(self._on_serial_data) + if self._transport.is_connected: + await self._transport.disconnect() + await super().cleanup() + + def _log_extra(self, category: str) -> dict[str, str | None]: + return { + "log_category": category, + "machine_id": self._machine.id if self._machine else None, + } + + async def _connect_implementation(self) -> None: + if self._connection_task and not self._connection_task.done(): + self._connection_task.cancel() + self.keep_running = True + self._connection_task = asyncio.ensure_future(self._connection_loop()) + + async def _connection_loop(self) -> None: + transport = self._transport + if not transport: + return + + while self.keep_running: + try: + await transport.connect() + self._update_connection_status( + TransportStatus.CONNECTED, "Connected" + ) + self._handshake_received.clear() + + await self._send_raw(b"?\n") + try: + await asyncio.wait_for( + self._handshake_received.wait(), timeout=3.0 + ) + except asyncio.TimeoutError: + logger.warning("Handshake timeout. Retrying connection.") + await transport.disconnect() + self._update_connection_status( + TransportStatus.SLEEPING, + "Handshake failed, retrying...", + ) + await asyncio.sleep(5) + continue + + await self._start_polling() + + while transport.is_connected and self.keep_running: + await asyncio.sleep(0.5) + + except asyncio.CancelledError: + break + except Exception as e: # noqa: BLE001 - connection loop boundary + logger.error(f"Connection error: {e}") + self._update_connection_status(TransportStatus.ERROR, str(e)) + + if self.keep_running: + await transport.disconnect() + self._update_connection_status( + TransportStatus.SLEEPING, "Reconnecting..." + ) + await asyncio.sleep(5) + + async def _start_polling(self) -> None: + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + self._poll_task = asyncio.ensure_future(self._poll_loop()) + + async def _poll_loop(self) -> None: + transport = self._transport + if not transport: + return + try: + while transport.is_connected and self.keep_running: + if not self._job_running: + async with self._cmd_lock: + if transport.is_connected: + await self._send_raw(b"?\n") + await asyncio.sleep(1.0) + except asyncio.CancelledError: + pass + + def _update_connection_status( + self, status: TransportStatus, message: str = "" + ) -> None: + if status == TransportStatus.CONNECTED: + self.state.status = DeviceStatus.IDLE + elif status in ( + TransportStatus.ERROR, + TransportStatus.SLEEPING, + ): + self.state.status = DeviceStatus.UNKNOWN + self.connection_status_changed.send( + self, status=status, message=message + ) + + async def _send_raw(self, data: bytes) -> None: + if self._transport and self._transport.is_connected: + await self._transport.send(data) + + async def _ping_pong( + self, command: str, timeout: float = 30.0 + ) -> list[str]: + """ + Send *command* and wait for ``ok`` / ``error:`` response. + Returns collected response lines. + """ + if not self._transport or not self._transport.is_connected: + raise ConnectionError("Serial transport not connected") + + pending = _PingPongPending(command) + self._pending = pending + payload = (command.strip() + "\n").encode("utf-8") + logger.info(command.strip(), extra=self._log_extra("USER_COMMAND")) + await self._transport.send(payload) + + try: + return await pending.wait(timeout=timeout) + except asyncio.TimeoutError: + logger.error(f"Ping-pong timeout on '{command}' after {timeout}s") + raise DeviceConnectionError( + f"Device did not respond to '{command}' within {timeout}s" + ) + except asyncio.CancelledError: + raise + finally: + self._pending = None + + async def _ping_pong_no_wait(self, command: str) -> None: + """Send command without waiting for response (fire and forget).""" + if not self._transport or not self._transport.is_connected: + raise ConnectionError("Serial transport not connected") + payload = (command.strip() + "\n").encode("utf-8") + logger.info(command.strip(), extra=self._log_extra("USER_COMMAND")) + await self._transport.send(payload) + + def _on_serial_data(self, sender, data: bytes) -> None: + """Parse incoming serial data and dispatch responses.""" + self._line_buffer.extend(data) + self._process_line_buffer() + + def _process_line_buffer(self) -> None: + """Process complete lines from the receive buffer.""" + while b"\n" in self._line_buffer: + idx = self._line_buffer.index(b"\n") + 1 + raw_line = bytes(self._line_buffer[:idx]) + del self._line_buffer[:idx] + + for line in ( + raw_line.decode("utf-8", errors="replace").strip().splitlines() + ): + if not line: + continue + self._handle_response_line(line) + + def _handle_response_line(self, line: str) -> None: + """Route a single decoded response line.""" + if line == "ok": + if self._pending: + self._pending.set_result() + return + + if line.startswith("error:"): + if self._pending: + self._pending.set_result(error=line) + return + + if line.startswith("<") and line.endswith(">"): + self._handle_status_report(line) + return + + if line.startswith("ALARM:"): + logger.warning(line, extra=self._log_extra("MACHINE_EVENT")) + alarm_code = line.split(":")[1].strip() + self.state.error = alarm_code_to_device_error(alarm_code) + self.state.status = DeviceStatus.ALARM + self.state_changed.send(self, state=self.state) + if self._pending: + self._pending.set_result(error=line) + return + + if line.startswith(("Grbl ", "grbl ")): + self._handshake_received.set() + return + + if line.startswith("["): + if line.startswith("[OPT:"): + logger.debug(f"Received OPT info: {line}") + elif line.startswith("[VER:"): + logger.debug(f"Received version: {line}") + if self._pending: + self._pending.response_lines.append(line) + return + + if self._pending: + self._pending.response_lines.append(line) + + def _handle_status_report(self, report: str) -> None: + """Process a GRBL status report like .""" + self._handshake_received.set() + state = parse_state( + report, + self.state, + lambda message: logger.info(message), + report_in_inches=self._report_in_inches, + ) + + self._raw_grbl_status = state.status + + if self._job_running and state.status == DeviceStatus.IDLE: + state.status = DeviceStatus.RUN + + self.state.status = state.status + if state.error is not None: + self.state.error = state.error + + self.state_changed.send(self, state=self.state) + + def _start_job( + self, + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + self._is_cancelled = False + self._job_running = True + self._job_exception: Exception | None = None + self._on_command_done = on_command_done + self._current_op_index = -1 + self.state.status = DeviceStatus.RUN + self.state_changed.send(self, state=self.state) + + async def _stream_gcode_ping_pong( + self, + gcode_lines: list[str], + op_map: MachineCodeOpMap | None = None, + ) -> None: + """ + Stream G-code using strict ping-pong: send one line, + wait for ok, repeat. + """ + total = len(gcode_lines) + logger.debug(f"Starting ping-pong streaming job with {total} lines.") + job_completed_successfully = False + sent_count = 0 + try: + for line_idx, line in enumerate(gcode_lines): + if ( + self._is_cancelled + or self.state.status == DeviceStatus.ALARM + ): + logger.info("Job cancelled or alarm. Stopping.") + break + + stripped = strip_gcode_comments(line).strip() + if not stripped: + continue + + new_op = op_map.op_for_line(line_idx) if op_map else None + if new_op is not None and new_op != self._current_op_index: + self._current_op_index = new_op + if self._on_command_done: + result = self._on_command_done(new_op) + if asyncio.iscoroutine(result): + await result + + await self._ping_pong(stripped) + sent_count += 1 + + if ( + sent_count == total + and not self._is_cancelled + and self.state.status != DeviceStatus.ALARM + ): + job_completed_successfully = True + except DeviceConnectionError as e: + logger.warning(f"Job interrupted: {e}") + except Exception: + logger.exception("Unexpected streaming error") + finally: + self._job_running = False + self._on_command_done = None + if job_completed_successfully: + self.job_finished.send(self) + logger.debug( + f"Ping-pong streaming finished ({sent_count}/{total})." + ) + elif self._is_cancelled: + logger.debug( + f"Ping-pong streaming cancelled at {sent_count}/{total}." + ) + else: + self.job_finished.send(self) + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + self._start_job(on_command_done) + mapping = encoded.op_map + gcode_lines = encoded.text.splitlines() + try: + await self._stream_gcode_ping_pong(gcode_lines, mapping) + except DeviceConnectionError as e: + logger.warning(f"Job terminated: {e}") + + async def run_raw(self, machine_code: str) -> None: + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + if not lines: + return + self._start_job() + try: + await self._stream_gcode_ping_pong(lines) + except DeviceConnectionError as e: + logger.warning(f"Raw G-code terminated: {e}") + + async def cancel(self) -> None: + logger.debug("Cancel command initiated.") + job_was_running = self._job_running + self._is_cancelled = True + self._job_running = False + self._on_command_done = None + + if self._transport and self._transport.is_connected: + logger.info("Sending Soft Reset (Ctrl-X) to device.") + await self._transport.send(b"\x18") + self._line_buffer.clear() + if self._pending: + self._pending.set_result() + self._pending = None + if job_was_running: + self.job_finished.send(self) + else: + raise ConnectionError("Serial transport not initialized") + + async def _execute_command(self, command: str) -> list[str]: + """Send a command using ping-pong and return response lines.""" + self._is_cancelled = False + async with self._cmd_lock: + return await self._ping_pong(command) + + async def execute_interactive_command(self, command: str) -> list[str]: + """Send a command and await its full response.""" + if not self._transport or not self._transport.is_connected: + raise ConnectionError("Serial transport not connected") + return await self._execute_command(command) + + async def set_hold(self, hold: bool = True) -> None: + self._is_cancelled = False + realtime = b"!" if hold else b"~" + if self._transport and self._transport.is_connected: + await self._transport.send(realtime) + + def can_home(self, axis: Axis | None = None) -> bool: + return True + + async def home(self, axes: Axis | None = None) -> None: + dialect = self.dialect + if axes is None: + await self._execute_command(dialect.home_all) + else: + for axis in axes: + cmd = dialect.home_axis.format(axis_letter=axis.name) + await self._execute_command(cmd) + + active_wcs = self._machine.active_wcs + temp_wcs = "G55" if active_wcs == "G54" else "G54" + await self._execute_command("G4 P0.01") + await self._execute_command(temp_wcs) + await self._execute_command(active_wcs) + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def move_to(self, pos_x, pos_y) -> None: + dialect = self.dialect + cmd = dialect.move_to.format( + speed=self._to_machine_speed(1500), + x=self._to_machine_length(float(pos_x)), + y=self._to_machine_length(float(pos_y)), + ) + await self._execute_command(cmd) + + async def select_tool(self, tool_number: int) -> None: + dialect = self.dialect + cmd = dialect.tool_change.format(tool_number=tool_number) + await self._execute_command(cmd) + + async def clear_alarm(self) -> None: + dialect = self.dialect + response = await self._execute_command(dialect.clear_alarm) + has_error = any(line.startswith("error:") for line in response) + if not has_error: + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def set_power(self, head: "Laser", percent: float) -> None: + dialect = self.dialect + if percent <= 0: + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.laser_on.format(power=power_abs) + await self._execute_command(cmd) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + dialect = self.dialect + if percent <= 0: + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.focus_laser_on.format(power=power_abs) + await self._execute_command(cmd) + + def can_jog(self, axis: Axis | None = None) -> bool: + return True + + async def jog(self, speed: int, **deltas: float) -> None: + dialect = self.dialect + cmd_parts = [dialect.jog.format(speed=self._to_machine_speed(speed))] + for axis_name, distance in deltas.items(): + cmd_parts.append( + f"{axis_name.upper()}{self._to_machine_length(distance)}" + ) + if len(cmd_parts) == 1: + return + cmd = " ".join(cmd_parts) + await self._execute_command(cmd) + + def get_setting_vars(self) -> list["VarSet"]: + return get_grbl_setting_varsets() + + async def detect_unit_system(self) -> UnitSystem | None: + """ + Queries the device's ``$$`` settings and infers the unit + system from the ``$13`` (Report in inches) flag. + """ + try: + response_lines = await self.execute_interactive_command("$$") + except (ConnectionError, asyncio.TimeoutError) as e: + logger.warning(f"Unit system detection failed: {e}") + return None + self._report_in_inches = is_report_in_inches(response_lines) + return detect_unit_system_from_settings(response_lines) + + async def read_settings(self) -> None: + response_lines = await self.execute_interactive_command("$$") + self._report_in_inches = is_report_in_inches(response_lines) + known_varsets = self.get_setting_vars() + key_to_varset_map = { + var.key: varset for varset in known_varsets for var in varset + } + unknown_vars = VarSet( + title=_("Unknown Settings"), + description=_( + "Settings reported by the device not in the standard list." + ), + ) + for line in response_lines: + match = grbl_setting_re.match(line) + if match: + key, value_str = match.groups() + target_varset = key_to_varset_map.get(key) + if target_varset: + target_varset[key] = value_str + else: + unknown_vars.add( + Var( + key=key, + label=f"${key}", + var_type=str, + value=value_str, + description=_("Unknown setting from device"), + ) + ) + result = known_varsets + if len(unknown_vars) > 0: + result.append(unknown_vars) + num_settings = sum(len(vs) for vs in result) + logger.info( + f"Driver settings read with {num_settings} settings.", + extra={"log_category": "DRIVER_EVENT"}, + ) + self.settings_read.send(self, settings=result) + + async def write_setting(self, key: str, value: Any) -> None: + if isinstance(value, bool): + value = 1 if value else 0 + cmd = f"${key}={value}" + await self._execute_command(cmd) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + p_num = gcode_to_p_number(wcs_slot) + if p_num is None: + raise ValueError(f"Invalid WCS slot: {wcs_slot}") + dialect = self.dialect + cmd = dialect.set_wcs_offset.format( + p_num=p_num, + x=self._to_machine_length(x), + y=self._to_machine_length(y), + z=self._to_machine_length(z), + ) + await self._execute_command(cmd) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + response_lines = await self.execute_interactive_command("$#") + offsets = {} + for line in response_lines: + match = wcs_re.match(line) + if match: + slot, x_str, y_str, z_str = match.groups() + z_str = z_str or "0.000" + parsed: Pos = (float(x_str), float(y_str), float(z_str)) + offsets[slot] = ( + tuple(inches_to_mm(v) for v in parsed) + if self._report_in_inches + else parsed + ) + self.wcs_updated.send(self, offsets=offsets) + return offsets + + async def read_parser_state(self) -> str | None: + try: + response_lines = await self.execute_interactive_command("$G") + return parse_grbl_parser_state(response_lines) + except DeviceConnectionError as e: + logger.warning(f"Could not read parser state: {e}") + return None + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + assert axis.name, "Probing requires a single, named axis." + axis_letter = axis.name.upper() + dialect = self.dialect + cmd = dialect.probe_cycle.format( + axis_letter=axis_letter, + max_travel=self._to_machine_length(max_travel), + feed_rate=self._to_machine_speed(feed_rate), + ) + self.probe_status_changed.send( + self, message=f"Probing {axis_letter}..." + ) + try: + response_lines = await self.execute_interactive_command(cmd) + except DeviceConnectionError: + self.probe_status_changed.send( + self, message="Probe failed: Timed out" + ) + return None + for line in response_lines: + match = prb_re.match(line) + if match: + x_str, y_str, z_str, success = match.groups() + if int(success) == 1: + pos: Pos = ( + float(x_str), + float(y_str), + float(z_str), + ) + if self._report_in_inches: + pos = tuple(inches_to_mm(v) for v in pos) + self.probe_status_changed.send( + self, + message=f"Probe triggered at {pos}", + ) + return pos + self.probe_status_changed.send(self, message="Probe failed") + return None diff --git a/rayforge/machine/driver/grbl/grbl_telnet.py b/rayforge/machine/driver/grbl/grbl_telnet.py new file mode 100644 index 000000000..ce3bec2d8 --- /dev/null +++ b/rayforge/machine/driver/grbl/grbl_telnet.py @@ -0,0 +1,112 @@ +import logging +from gettext import gettext as _ +from typing import Any, cast + +from ....core.varset import HostnameVar, PortVar, Var, VarSet +from ....core.varset.hostnamevar import is_valid_hostname_or_ip +from ...transport import TelnetTransport +from ...transport.grbl import GrblSerialTransport +from ..driver import DriverPrecheckError, DriverSetupError +from .grbl_serial import GrblSerialDriver + +logger = logging.getLogger(__name__) + + +class GrblTelnetDriver(GrblSerialDriver): + """ + GRBL-compatible controller connected over raw TCP (telnet). + + Intended for networked grblHAL controllers with the "raw" telnet + service enabled, and for ESP3D firmware's telnet bridge. Reuses the + full GRBL protocol stack from GrblSerialDriver, swapping only the + underlying byte transport from SerialTransport to TelnetTransport. + """ + + label = _("GRBL (Telnet)") + subtitle = _("GRBL-compatible controller over a raw TCP/telnet connection") + + def __init__(self, context, machine): + super().__init__(context, machine) + self._host: str | None = None + self._port: int | None = None + + @property + def resource_uri(self) -> str | None: + if self._host and self._port: + return f"tcp://{self._host}:{self._port}" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + if not is_valid_hostname_or_ip(host): + raise DriverPrecheckError( + _("Invalid hostname or IP address: '{host}'").format(host=host) + ) + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + HostnameVar( + key="host", + label=_("Hostname"), + description=_("The IP address or hostname of the device"), + ), + PortVar( + key="port", + label=_("Port"), + description=_("TCP port for the raw/telnet service"), + default=23, + ), + Var( + key="poll_status_while_running", + label=_("Poll device status during jobs"), + description=_( + "Periodically query the device for position and " + "status while a job is running. Warning: Some " + "devices have trouble maintaining a stable " + "connection if this is used!" + ), + var_type=bool, + default=False, + ), + Var( + key="deadlock_detection", + label=_("Deadlock detection"), + description=_( + "Detect and recover from serial communication " + "deadlocks during jobs. If disabled, the driver " + "will simply wait for the machine to respond. " + "Disable if you experience false ALARM:3 errors." + ), + var_type=bool, + default=False, + ), + ] + ) + + def _setup_implementation(self, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + port = cast(int, kwargs.get("port", 23)) + self._poll_status_while_running = bool( + kwargs.get("poll_status_while_running", False) + ) + self._deadlock_detection = bool( + kwargs.get("deadlock_detection", False) + ) + + if not host: + raise DriverSetupError(_("Hostname must be configured.")) + if not port: + raise DriverSetupError(_("Port must be configured.")) + + self._host = host + self._port = port + + telnet_transport = TelnetTransport(host, port) + self.grbl_transport = GrblSerialTransport(telnet_transport) + self.grbl_transport.received.connect(self.on_serial_data_received) + self.grbl_transport.status_changed.connect( + self.on_serial_status_changed + ) diff --git a/rayforge/machine/driver/grbl/grbl_util.py b/rayforge/machine/driver/grbl/grbl_util.py new file mode 100644 index 000000000..3d103937c --- /dev/null +++ b/rayforge/machine/driver/grbl/grbl_util.py @@ -0,0 +1,1325 @@ +import asyncio +import re +from collections.abc import Callable +from copy import copy, deepcopy +from dataclasses import dataclass, field +from gettext import gettext as _ +from typing import cast + +from ....core.varset import Var, VarSet +from ....shared.units.system import UnitSystem, inches_to_mm +from ..driver import DeviceError, DeviceState, DeviceStatus, Pos + +# GRBL $13 setting key: "Report in inches" (boolean). +GRBL_REPORT_INCHES_KEY = "13" + +_gcode_comment_re = re.compile(r"\([^)]*\)") + + +def _pos_from_inches(pos: Pos) -> Pos: + """ + Convert a position tuple reported in inches to millimeters. + + ``None`` entries are preserved so partially-known positions + remain valid. + """ + return tuple(None if v is None else inches_to_mm(v) for v in pos) + + +def strip_gcode_comments(line: str) -> str: + """ + Strip G-code comments from a line. + + Removes: + - Everything after ';' (semicolon comments) + - Content between '(' and ')' (parenthetical comments) + """ + line = _gcode_comment_re.sub("", line) + idx = line.find(";") + if idx >= 0: + line = line[:idx] + return line.strip() + + +# GRBL Next-gen command requests +@dataclass +class CommandRequest: + """A request to send a command and await its full response.""" + + command: str + op_index: int | None = None + response_lines: list[str] = field(default_factory=list) + finished: asyncio.Event = field(default_factory=asyncio.Event) + + @property + def payload(self) -> bytes: + return (self.command + "\n").encode("utf-8") + + +# GRBL Network URLs +hw_info_url = "/command?plain=%5BESP420%5D&PAGEID=" +fw_info_url = "/command?plain=%5BESP800%5D&PAGEID=" +eeprom_info_url = "/command?plain=%5BESP400%5D&PAGEID=" +command_url = "/command?commandText={command}&PAGEID=" +upload_url = "/upload" +execute_url = "/command?commandText=%5BESP220%5D/{filename}" +status_url = command_url.format(command="?") + + +# GRBL Regex Parsers +pos_re = re.compile( + r":(-?\d+\.?\d*),(-?\d+\.?\d*)(?:,(-?\d+\.?\d*))?(?:,(-?\d+\.?\d*))?" +) +fs_re = re.compile(r"FS:(\d+),(\d+)") +bf_re = re.compile(r"Bf:(\d+),(\d+)") +grbl_setting_re = re.compile(r"\$(\d+)=([\d\.-]+)") +wcs_re = re.compile(r"\[(G5[4-9]):([\d\.-]+),([\d\.-]+)(?:,([\d\.-]+))?\]") +prb_re = re.compile(r"\[PRB:([\d\.-]+),([\d\.-]+),([\d\.-]+):(\d)\]") +# Regex to find the active WCS (G54-G59) from a $G parser state report +grbl_parser_state_re = re.compile(r".*(G5[4-9]).*") +# Regex to extract compile options and buffer sizes from OPT line +# Format: [OPT:,,] +grbl_opt_re = re.compile(r"\[OPT:([A-Z]+),(\d+),(\d+)\]") + + +# GRBL Error Codes +# Source: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Interface#message-summary +GRBL_ERROR_CODES = { + 1: DeviceError( + 1, + _("Missing Command Letter"), + _( + "G-code commands need a letter followed by a value. " + "The command letter was not found." + ), + ), + 2: DeviceError( + 2, + _("Invalid Number Format"), + _( + "The value is missing or not in the correct numeric format. " + "Check your G-code syntax." + ), + ), + 3: DeviceError( + 3, + _("Unknown Command"), + _( + "This Grbl setting command is not recognized or supported. " + "Check the command syntax." + ), + ), + 4: DeviceError( + 4, + _("Negative Value"), + _( + "A positive number is required here, but a negative value was " + "received." + ), + ), + 5: DeviceError( + 5, + _("Homing Disabled"), + _( + "Homing is not enabled in settings. Enable homing ($22=1) to " + "use this feature." + ), + ), + 6: DeviceError( + 6, + _("Pulse Time Too Short"), + _( + "Minimum step pulse time must be greater than 3 microseconds. " + "Check setting $0." + ), + ), + 7: DeviceError( + 7, + _("Memory Error"), + _( + "Settings reset to defaults due to a memory read failure. " + "Reconfigure your settings if needed." + ), + ), + 8: DeviceError( + 8, + _("Machine Busy"), + _( + "This command can only be used when the machine is idle. " + "Wait for the current job to finish." + ), + ), + 9: DeviceError( + 9, + _("Commands Locked"), + _( + "Cannot send commands while in alarm or jog mode. " + "Clear the alarm state first." + ), + ), + 10: DeviceError( + 10, + _("Homing Required"), + _( + "Soft limits cannot be enabled without homing also enabled. " + "Enable homing first ($22=1)." + ), + ), + 11: DeviceError( + 11, + _("Line Too Long"), + _( + "The command line has too many characters and was ignored. " + "Check your file formatting." + ), + ), + 12: DeviceError( + 12, + _("Setting Too High"), + _( + "This setting exceeds the maximum step rate supported. " + "Use a lower value." + ), + ), + 13: DeviceError( + 13, + _("Door Open"), + _( + "The safety door was detected as open. Close the door and " + "resume operation." + ), + ), + 14: DeviceError( + 14, + _("Line Too Long"), + _( + "Build info or startup line exceeds storage limit. " + "Shorten the line." + ), + ), + 15: DeviceError( + 15, + _("Target Out of Range"), + _( + "Jog target is beyond the machine's travel limits. " + "Move to a position within range." + ), + ), + 16: DeviceError( + 16, + _("Invalid Jog Command"), + _( + "Jog command is missing '=' or contains prohibited G-code. " + "Check the jog syntax." + ), + ), + 17: DeviceError( + 17, + _("Laser Mode Error"), + _( + "Laser mode requires PWM output to work. " + "Check your hardware configuration." + ), + ), + 18: DeviceError( + 18, + _("Spindle Not Running"), + _( + "A motion command was issued but the spindle is not " + "running. Start the spindle before motion." + ), + ), + 19: DeviceError( + 19, + _("Spindle Speed Mismatch"), + _( + "The current spindle speed does not match the speed " + "required by the command. Wait for the spindle to " + "reach the target speed." + ), + ), + 20: DeviceError( + 20, + _("Unsupported Command"), + _( + "This G-code command is not supported by the machine. " + "Check your post-processor settings." + ), + ), + 21: DeviceError( + 21, + _("Conflicting Commands"), + _( + "Multiple commands from the same group found on one line. " + "Remove the duplicate command." + ), + ), + 22: DeviceError( + 22, + _("Feed Rate Missing"), + _( + "Set a feed rate before using motion commands. " + "Add an F command to specify speed." + ), + ), + 23: DeviceError( + 23, + _("Integer Required"), + _( + "This command requires a whole number value. " + "Remove any decimal points." + ), + ), + 24: DeviceError( + 24, + _("Axis Conflict"), + _( + "Multiple commands trying to use the same axis. " + "Simplify the command." + ), + ), + 25: DeviceError( + 25, + _("Duplicate Word"), + _( + "The same G-code word appears more than once. " + "Remove the duplicate." + ), + ), + 26: DeviceError( + 26, + _("Missing Axis"), + _( + "This command requires XYZ axis coordinates. " + "Add the missing axis values." + ), + ), + 27: DeviceError( + 27, + _("Line Number Out of Range"), + _( + "Line number must be between 1 and 9,999,999. " + "Use a valid line number." + ), + ), + 28: DeviceError( + 28, + _("Missing Value"), + _("This command requires a P or L value. Add the missing parameter."), + ), + 29: DeviceError( + 29, + _("Unsupported Coordinate"), + _( + "Only G54-G59 coordinate systems are supported. " + "Use one of these instead." + ), + ), + 30: DeviceError( + 30, + _("Wrong Motion Mode"), + _( + "G53 command requires G0 or G1 motion mode. " + "Set the correct motion mode first." + ), + ), + 31: DeviceError( + 31, + _("Unused Axis Words"), + _( + "Axis words present but G80 cancel is active. " + "Remove the unused axis words." + ), + ), + 32: DeviceError( + 32, + _("Missing Arc Data"), + _( + "G2/G3 arc command needs XYZ coordinates. " + "Add the axis values for the selected plane." + ), + ), + 33: DeviceError( + 33, + _("Invalid Target"), + _( + "Cannot create this arc or probe to current position. " + "Check the target coordinates." + ), + ), + 34: DeviceError( + 34, + _("Arc Geometry Error"), + _( + "Arc calculation failed. Try breaking the arc into smaller " + "pieces or use IJK offset instead." + ), + ), + 35: DeviceError( + 35, + _("Missing Arc Offset"), + _( + "G2/G3 arc command needs IJK offset values. " + "Add the missing offset for the selected plane." + ), + ), + 36: DeviceError( + 36, + _("Unused Words"), + _( + "Some G-code words in this line are not used by any command. " + "Remove the unused words." + ), + ), + 37: DeviceError( + 37, + _("Wrong Axis for Offset"), + _( + "Tool length offset only works on the configured axis " + "(usually Z-axis). Check your settings." + ), + ), + 38: DeviceError( + 38, + _("Tool Number Too High"), + _( + "Tool number exceeds the maximum supported value. " + "Use a valid tool number." + ), + ), +} + +GRBL_ALARM_CODES = { + 1: DeviceError( + 1, + _("Hard Limit"), + _( + "A hard limit switch was triggered. " + "The machine has stopped and needs to be reset. " + "Check for obstructions and verify your limit switches." + ), + ), + 2: DeviceError( + 2, + _("Soft Limit"), + _( + "The machine would move beyond its configured travel limits. " + "Check that your work area and coordinate offsets are correct." + ), + ), + 3: DeviceError( + 3, + _("Abort Cycle"), + _( + "The currently running job was cancelled while in motion. " + "Reset the machine to continue." + ), + ), + 4: DeviceError( + 4, + _("Probe Fail — Initial"), + _( + "The probe did not make contact before the maximum travel " + "distance was reached. Check the probe wiring and positioning." + ), + ), + 5: DeviceError( + 5, + _("Probe Fail — Final"), + _( + "The probe failed to retract to the target position after " + "contact. Check the probe configuration." + ), + ), + 6: DeviceError( + 6, + _("Homing Fail — Reset"), + _( + "Homing was not able to complete because the machine is " + "in an alarm state. Clear the alarm and try again." + ), + ), + 7: DeviceError( + 7, + _("Homing Fail — Approach"), + _( + "The homing cycle failed to find the switch within the " + "configured travel distance. Check your switch wiring and " + "pull-off settings." + ), + ), + 8: DeviceError( + 8, + _("Homing Fail — Pulloff"), + _( + "The homing cycle failed to successfully pull off the " + "switch after contact. Increase the pull-off distance " + "or check the switch." + ), + ), + 9: DeviceError( + 9, + _("Home Without Limits"), + _( + "Homing was commanded but limit switches are not " + "configured. Enable limit switches first." + ), + ), + 10: DeviceError( + 10, + _("Homing Fail — Dual Axis"), + _( + "Homing failed on a dual-axis configuration. " + "One or both axes did not reach their limit switches. " + "Check your limit switch wiring and configuration." + ), + ), +} + + +def alarm_code_to_device_error(alarm_code: str) -> DeviceError: + try: + code = int(alarm_code) + except (ValueError, TypeError): + return DeviceError( + -1, + _("Unknown Alarm"), + _("Invalid alarm code reported by machine."), + ) + try: + return GRBL_ALARM_CODES[code] + except KeyError: + return DeviceError( + code, + _("Unknown Alarm"), + _( + "The machine reported an unrecognized alarm code. " + "Check your machine and firmware documentation." + ), + ) + + +# GRBL WCS Helper +def gcode_to_p_number(wcs_slot: str) -> int | None: + """Converts a G-code WCS name (e.g., "G54") to its P-number.""" + try: + # Check format, e.g. "G54" + if not wcs_slot.startswith("G"): + return None + + # G54 is P1, G55 is P2, etc. + # Slice from index 1 to get the number "54", "55", etc. + p_num = int(wcs_slot[1:]) - 53 + if 1 <= p_num <= 6: # G54-G59 + return p_num + except (ValueError, IndexError): + pass + return None + + +# GRBL State Parsers +def parse_ver(line: str) -> tuple[str, str | None] | None: + """ + Parse a ``[VER:...]`` line into ``(version, build_name)``. + + Handles both standard Grbl format (``1.1h.ORTUR``) and comma- + separated format (``1.0.15,20240923``). Returns None if the + line is not a VER line. + """ + if not line.startswith("[VER:"): + return None + content = line[5:].rstrip(":]") + if not content: + return None + if "," in content: + return (content.split(",")[0], None) + parts = content.split(".") + if len(parts) >= 3: + return (parts[0] + "." + parts[1], parts[2]) + if len(parts) == 2: + return (parts[0] + "." + parts[1], None) + return (content, None) + + +def parse_version( + response_lines: list[str], +) -> str | None: + """ + Parses '$I' output to extract the GRBL firmware version string. + + Args: + response_lines: List of response lines from a '$I' command + + Returns: + The version string (e.g. ``"1.1h"``, ``"1.0.15"``), + or None if no VER line is found. + """ + for line in response_lines: + ver = parse_ver(line) + if ver is not None: + return ver[0] + return None + + +def parse_grbl_settings(lines: list[str]) -> dict[str, float]: + """Parse ``$$`` response lines into a ``{key: value}`` dict.""" + settings: dict[str, float] = {} + for line in lines: + match = grbl_setting_re.search(line) + if match: + settings[match.group(1)] = float(match.group(2)) + return settings + + +def detect_unit_system_from_settings( + settings_lines: list[str], +) -> UnitSystem | None: + """ + Inspect GRBL ``$$`` response lines and infer the device's unit + system from the ``$13`` (Report in inches) setting. + + Returns ``UnitSystem.IMPERIAL`` when ``$13`` is non-zero, + ``UnitSystem.METRIC`` when ``$13`` is zero, or ``None`` when the + ``$13`` setting is absent from the response. + """ + settings = parse_grbl_settings(settings_lines) + report_inches = settings.get(GRBL_REPORT_INCHES_KEY) + if report_inches is None: + return None + if int(report_inches): + return UnitSystem.IMPERIAL + return UnitSystem.METRIC + + +def is_report_in_inches(settings_lines: list[str]) -> bool: + """ + Return True when GRBL's ``$13`` (Report in inches) flag is set. + + When True, status reports, probe results and ``$#`` WCS offsets are + reported in inches and must be converted back to mm. + """ + report_inches = parse_grbl_settings(settings_lines).get( + GRBL_REPORT_INCHES_KEY + ) + return bool(report_inches) + + +def parse_msg(line: str) -> tuple[str, str] | None: + """ + Parse a ``[MSG:key:value]`` line into ``(key, value)``. + + Returns None if the line is not an MSG line or has no colon + separator. + """ + if not line.startswith("[MSG:"): + return None + content = line[5:].rstrip("]") + if ":" not in content: + return None + key, _, value = content.partition(":") + return (key.strip(), value.strip()) + + +def extract_device_name(build_info: list[str]) -> str: + """ + Extract a human-readable device name from build info lines. + + Checks ``[MSG:machine:...]`` / ``[MSG:mechine:...]`` lines first, + then falls back to the VER line's build-info field (e.g. + ``[VER:1.1h.ORTUR:]`` → ``"ORTUR"``). + """ + for line in build_info: + msg = parse_msg(line) + if msg is not None: + key, value = msg + if key.lower() in ("machine", "mechine"): + return value + for line in build_info: + ver = parse_ver(line) + if ver is not None: + _, build_name = ver + if build_name: + return build_name + return "Unknown Grbl Device" + + +def version_supports_single_axis_homing( + version_num: float, version_letter: str = "" +) -> bool: + """ + Determines if a GRBL version supports single-axis homing. + + Args: + version_num: Version number (e.g., 1.1, 2.0) + version_letter: Optional version letter (e.g., 'f', 'g') + + Returns: + True if the version supports single-axis homing, False otherwise. + Support is assumed for versions > 1.1 or for 1.1g and newer. + """ + if version_num > 1.1: + return True + if version_num == 1.1: + # 'f' and below do not support it. 'g' and above do. + return bool(version_letter and version_letter.lower() >= "g") + return False + + +def _parse_pos(pos: str) -> Pos | None: + match = pos_re.search(pos) + if not match: + return None + values = [float(g) if g else 0.0 for g in match.groups()] + while len(values) < 3: + values.append(0.0) + if match.group(4) is None: + values = values[:3] + return tuple(values) + + +def error_code_to_device_error(error_code: str) -> DeviceError: + try: + code = int(error_code) + except (ValueError, TypeError): + return DeviceError( + -1, + _("Unknown Error"), + _("Invalid error code reported by machine."), + ) + try: + return GRBL_ERROR_CODES[code] + except KeyError: + return DeviceError( + code, + _("Unknown Error"), + _( + "The machine reported an unrecognized error code. " + "Check your machine and firmware documentation." + ), + ) + + +def parse_opt_info(line: str) -> int | None: + """ + Extract the RX buffer size from an OPT response line. + + Args: + line: A single + ``[OPT:,,]`` + line. + + Returns: + The RX buffer size as an integer, or None if the line does + not match. + """ + match = grbl_opt_re.search(line) + if match: + try: + return int(match.group(3)) + except (ValueError, IndexError): + pass + return None + + +def parse_grbl_parser_state(response_lines: list[str]) -> str | None: + """ + Parses the response from a '$G' command to find the active WCS. + Example response: '[G54 G17 G21 G90 G94 M5 M9 T0 F0 S0]' + """ + for line in response_lines: + match = grbl_parser_state_re.match(line) + if match: + return match.group(1) # Return the found G-code (e.g., "G54") + return None + + +def _split_status_line(state_str: str) -> tuple[str, list[str]]: + """ + Split status line into status part and attribute parts. + + Args: + state_str: Status string like '' + + Returns: + Tuple of (status_part, list_of_attributes) + """ + status_parts = state_str[1:-1].split("|") + status = None + attribs = [] + for part in status_parts: + if not part: + continue + if not status: + status = part + else: + attribs.append(part) + return status or "", attribs + + +def _parse_status_part(status_part: str) -> tuple[DeviceStatus, str | None]: + """ + Parse status part into DeviceStatus and optional error code. + + Only ``Alarm`` status uses the ``:N`` suffix for an actual error/alarm + code. For ``Hold`` and ``Door`` the ``:N`` is a sub‑state indicator + (e.g. ``Hold:1`` = hold pending, ``Door:0`` = door closed) that must + NOT be treated as an error. + + Args: + status_part: Status part like ``'Idle'`` or ``'Alarm:1'`` + + Returns: + Tuple of (DeviceStatus, error_code or None) + """ + status_parts = status_part.split(":") + status_name = status_parts[0] + try: + status = DeviceStatus[status_name.upper()] + except KeyError: + return DeviceStatus.UNKNOWN, None + + error_code = ( + status_parts[1] + if (status == DeviceStatus.ALARM and len(status_parts) > 1) + else None + ) + return status, error_code + + +def _parse_position_attribute(attrib: str, pos_type: str) -> Pos | None: + """ + Parse a position attribute (MPos, WPos, or WCO). + + Args: + attrib: Attribute string like 'MPos:10.0,20.0,30.0' + pos_type: Type of position ('MPos', 'WPos', or 'WCO') + + Returns: + Position tuple or None if parsing fails + """ + if not attrib.startswith(f"{pos_type}:"): + return None + return _parse_pos(attrib) + + +def _parse_feed_rate(attrib: str) -> int | None: + """ + Parse feed rate from FS attribute. + + Args: + attrib: Attribute string like 'FS:1000,0' + + Returns: + Feed rate value or None if parsing fails + """ + if not attrib.startswith("FS:"): + return None + match = fs_re.match(attrib) + if not match: + return None + try: + fs = [int(i) for i in match.groups()] + return int(fs[0]) + except (ValueError, IndexError): + return None + + +def _parse_buffer_state(attrib: str) -> tuple[int, int] | None: + """ + Parse buffer state from Bf attribute. + + Args: + attrib: Attribute string like 'Bf:62,0' + + Returns: + Tuple of (available_buffer_bytes, available_rx_buffer_bytes) + or None if parsing fails + """ + if not attrib.startswith("Bf:"): + return None + match = bf_re.match(attrib) + if not match: + return None + try: + bf = [int(i) for i in match.groups()] + return (int(bf[0]), int(bf[1])) + except (ValueError, IndexError): + return None + + +def _recalculate_positions( + machine_pos: Pos, + work_pos: Pos, + wco: Pos, + mpos_found: bool, + wpos_found: bool, + wco_found: bool, +) -> tuple[Pos, Pos, Pos]: + """ + Recalculate positions based on GRBL equations for consistency. + Also infers WCO if missing but both MPos and WPos are present. + + Args: + machine_pos: Current machine position + work_pos: Current work position + wco: Work coordinate offset + mpos_found: Whether machine position was found in input + wpos_found: Whether work position was found in input + wco_found: Whether work coordinate offset was found in input + + Returns: + Tuple of (recalculated_machine_pos, recalculated_work_pos, + recalculated_wco) + """ + n = max(len(machine_pos), len(work_pos), len(wco)) + + def _pad(pos: Pos, default: float = 0.0) -> Pos: + result = list(pos) + while len(result) < n: + result.append(default) + return tuple(result) + + machine_pos = _pad(machine_pos) + work_pos = _pad(work_pos) + wco = _pad(wco) + + # 1. Infer WCO if explicitly missing but both MPos and WPos exist. + # WCO = MPos - WPos + if ( + mpos_found + and wpos_found + and not wco_found + and all(v is not None for v in machine_pos) + and all(v is not None for v in work_pos) + ): + m_float = cast(tuple[float, ...], machine_pos) + w_float = cast(tuple[float, ...], work_pos) + wco = tuple(m_float[i] - w_float[i] for i in range(n)) + + # 2. Recalculate missing positions based on what we have. + # If MPos is known, calculate WPos. + if ( + mpos_found + and all(v is not None for v in machine_pos) + and all(v is not None for v in wco) + ): + m_float = cast(tuple[float, ...], machine_pos) + wco_float = cast(tuple[float, ...], wco) + return ( + machine_pos, + tuple(m_float[i] - wco_float[i] for i in range(n)), + wco, + ) + + # If WPos is known (and MPos isn't), calculate MPos. + elif ( + wpos_found + and all(v is not None for v in work_pos) + and all(v is not None for v in wco) + ): + w_float = cast(tuple[float, ...], work_pos) + wco_float = cast(tuple[float, ...], wco) + return ( + tuple(w_float[i] + wco_float[i] for i in range(n)), + work_pos, + wco, + ) + + return machine_pos, work_pos, wco + + +def parse_state( + state_str: str, + default: DeviceState, + logger: Callable | None = None, + report_in_inches: bool = False, +) -> DeviceState: + """ + Parse GRBL status string into DeviceState. + + Args: + state_str: Status string like '' + default: Default DeviceState to use as base + logger: Optional logger function for debugging + report_in_inches: When True (GRBL $13 set), positions in the + status report are in inches and are converted back to mm. + + Returns: + Parsed DeviceState + """ + state = copy(default) + try: + status_part, attribs = _split_status_line(state_str) + + if status_part: + status, error_code = _parse_status_part(status_part) + state.status = status + if logger: + logger(message=f"Parsed status: {status.name}") + if error_code is not None: + if status == DeviceStatus.ALARM: + state.error = alarm_code_to_device_error(error_code) + else: + state.error = error_code_to_device_error(error_code) + if logger: + logger(message=f"Parsed error code: {error_code}") + + mpos_found = False + wpos_found = False + wco_found = False + for attrib in attribs: + if attrib.startswith("MPos:"): + parsed = _parse_position_attribute(attrib, "MPos") + if parsed: + state.machine_pos = parsed + mpos_found = parsed[0] is not None + elif attrib.startswith("WPos:"): + parsed = _parse_position_attribute(attrib, "WPos") + if parsed: + state.work_pos = parsed + wpos_found = parsed[0] is not None + elif attrib.startswith("WCO:"): + parsed = _parse_position_attribute(attrib, "WCO") + if parsed: + state.wco = parsed + wco_found = True + elif attrib.startswith("FS:"): + feed_rate = _parse_feed_rate(attrib) + if feed_rate is not None: + state.feed_rate = feed_rate + elif attrib.startswith("Bf:"): + buffer_state = _parse_buffer_state(attrib) + if buffer_state: + ( + state.buffer_available, + state.buffer_rx_available, + ) = buffer_state + + if report_in_inches: + if mpos_found: + state.machine_pos = _pos_from_inches(state.machine_pos) + if wpos_found: + state.work_pos = _pos_from_inches(state.work_pos) + if wco_found: + state.wco = _pos_from_inches(state.wco) + + state.machine_pos, state.work_pos, state.wco = _recalculate_positions( + state.machine_pos, + state.work_pos, + state.wco, + mpos_found, + wpos_found, + wco_found, + ) + + except (ValueError, TypeError) as e: + if logger: + logger( + message=f"Invalid status line format: {state_str}, error: {e}" + ) + return state + + +# GRBL Typed Settings Definitions +_STEPPER_CONFIG_VARS = [ + Var( + key="0", + label="$0", + var_type=int, + description="Step pulse time, microseconds", + ), + Var( + key="1", + label="$1", + var_type=int, + description="Step idle delay, milliseconds", + ), + Var( + key="2", + label="$2", + var_type=int, + description="Step pulse invert, mask", + ), + Var( + key="3", + label="$3", + var_type=int, + description="Step direction invert, mask", + ), + Var( + key="4", + label="$4", + var_type=bool, + description="Invert step enable pin, boolean", + ), + Var( + key="5", + label="$5", + var_type=bool, + description="Invert limit pins, boolean", + ), + Var( + key="6", + label="$6", + var_type=bool, + description="Invert probe pin, boolean", + ), +] + +_CONTROL_REPORTING_VARS = [ + Var( + key="10", + label="$10", + var_type=int, + description="Status report options, mask", + ), + Var( + key="11", + label="$11", + var_type=float, + description="Junction deviation, mm", + ), + Var( + key="12", label="$12", var_type=float, description="Arc tolerance, mm" + ), + Var( + key="13", + label="$13", + var_type=bool, + description="Report in inches, boolean", + ), +] + +_LIMITS_HOMING_VARS = [ + Var( + key="20", + label="$20", + var_type=bool, + description="Soft limits enable, boolean", + ), + Var( + key="21", + label="$21", + var_type=bool, + description="Hard limits enable, boolean", + ), + Var( + key="22", + label="$22", + var_type=bool, + description="Homing cycle enable, boolean", + ), + Var( + key="23", + label="$23", + var_type=int, + description="Homing direction invert, mask", + ), + Var( + key="24", + label="$24", + var_type=float, + description="Homing locate feed rate, mm/min", + ), + Var( + key="25", + label="$25", + var_type=float, + description="Homing search seek rate, mm/min", + ), + Var( + key="26", + label="$26", + var_type=int, + description="Homing switch debounce delay, milliseconds", + ), + Var( + key="27", + label="$27", + var_type=float, + description="Homing switch pull-off distance, mm", + ), +] + +_SPINDLE_LASER_VARS = [ + Var( + key="30", + label="$30", + var_type=float, + description="Maximum spindle speed, RPM", + ), + Var( + key="31", + label="$31", + var_type=float, + description="Minimum spindle speed, RPM", + ), + Var( + key="32", + label="$32", + var_type=bool, + description="Laser-mode enable, boolean", + ), +] + +_AXIS_CALIBRATION_VARS = [ + Var( + key="100", + label="$100", + var_type=float, + description="X-axis travel resolution, step/mm", + ), + Var( + key="101", + label="$101", + var_type=float, + description="Y-axis travel resolution, step/mm", + ), + Var( + key="102", + label="$102", + var_type=float, + description="Z-axis travel resolution, step/mm", + ), + Var( + key="103", + label="$103", + var_type=float, + description="A-axis travel resolution, step/degree", + ), +] + +_AXIS_KINEMATICS_VARS = [ + Var( + key="110", + label="$110", + var_type=float, + description="X-axis maximum rate, mm/min", + ), + Var( + key="111", + label="$111", + var_type=float, + description="Y-axis maximum rate, mm/min", + ), + Var( + key="112", + label="$112", + var_type=float, + description="Z-axis maximum rate, mm/min", + ), + Var( + key="113", + label="$113", + var_type=float, + description="A-axis maximum rate, degrees/min", + ), + Var( + key="120", + label="$120", + var_type=float, + description="X-axis acceleration, mm/sec^2", + ), + Var( + key="121", + label="$121", + var_type=float, + description="Y-axis acceleration, mm/sec^2", + ), + Var( + key="122", + label="$122", + var_type=float, + description="Z-axis acceleration, mm/sec^2", + ), + Var( + key="123", + label="$123", + var_type=float, + description="A-axis acceleration, degrees/sec^2", + ), +] + +_AXIS_TRAVEL_VARS = [ + Var( + key="130", + label="$130", + var_type=float, + description="X-axis maximum travel, mm", + ), + Var( + key="131", + label="$131", + var_type=float, + description="Y-axis maximum travel, mm", + ), + Var( + key="132", + label="$132", + var_type=float, + description="Z-axis maximum travel, mm", + ), + Var( + key="133", + label="$133", + var_type=float, + description="A-axis maximum travel, degrees", + ), +] + + +def get_grbl_setting_varsets() -> list["VarSet"]: + """ + Returns a list of VarSet instances populated with the standard GRBL setting + definitions, grouped into sensible categories. + """ + # Assuming `_` is a globally available translation function + return [ + VarSet( + vars=deepcopy(_STEPPER_CONFIG_VARS), + title=_("Stepper Configuration"), + description=_( + "Settings related to stepper motor timing and signal polarity." + ), + ), + VarSet( + vars=deepcopy(_CONTROL_REPORTING_VARS), + title=_("Control & Reporting"), + description=_( + "Settings for GRBL's motion control and status reporting." + ), + ), + VarSet( + vars=deepcopy(_LIMITS_HOMING_VARS), + title=_("Limits & Homing"), + description=_( + "Settings for soft/hard limits and the homing cycle." + ), + ), + VarSet( + vars=deepcopy(_SPINDLE_LASER_VARS), + title=_("Spindle & Laser"), + description=_( + "Settings for controlling the spindle or laser module." + ), + ), + VarSet( + vars=deepcopy(_AXIS_CALIBRATION_VARS), + title=_("Axis Calibration"), + description=_("Defines the steps-per-millimeter for each axis."), + ), + VarSet( + vars=deepcopy(_AXIS_KINEMATICS_VARS), + title=_("Axis Kinematics"), + description=_( + "Defines the maximum rate and acceleration for each axis." + ), + ), + VarSet( + vars=deepcopy(_AXIS_TRAVEL_VARS), + title=_("Axis Travel"), + description=_( + "Defines the maximum travel distance for each axis." + ), + ), + ] diff --git a/rayforge/machine/driver/marlin/__init__.py b/rayforge/machine/driver/marlin/__init__.py new file mode 100644 index 000000000..6393e3dae --- /dev/null +++ b/rayforge/machine/driver/marlin/__init__.py @@ -0,0 +1,5 @@ +from .marlin_serial import MarlinSerialDriver + +__all__ = [ + "MarlinSerialDriver", +] diff --git a/rayforge/machine/driver/marlin/marlin_probe.py b/rayforge/machine/driver/marlin/marlin_probe.py new file mode 100644 index 000000000..3cb83cde3 --- /dev/null +++ b/rayforge/machine/driver/marlin/marlin_probe.py @@ -0,0 +1,194 @@ +import asyncio +import logging +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Protocol, + runtime_checkable, +) + +from blinker import Signal + +from ....shared.units.system import UnitSystem +from ...transport import TransportStatus +from .marlin_util import ( + detect_unit_system_from_m149, + extract_marlin_device_name, + parse_m115_firmware_info, + parse_m211_endstops, + parse_m503_settings, + parse_marlin_version, +) + +if TYPE_CHECKING: + from ....context import RayforgeContext + from ...device.profile import DeviceProfile + from ...models.machine import Machine + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class _MarlinProbeDriver(Protocol): + """ + Protocol describing the interface ``probe_marlin_device`` needs + from any Marlin driver. + """ + + connection_status_changed: Signal + + def __init__( + self, + context: "RayforgeContext", + machine: "Machine", + ) -> None: ... + + def setup(self, **kwargs: Any) -> None: ... + + async def connect(self) -> None: ... + + async def cleanup(self) -> None: ... + + async def execute_interactive_command(self, command: str) -> list[str]: ... + + @property + def boot_lines(self) -> list[str]: ... + + +async def probe_marlin_device( + driver_cls: type[_MarlinProbeDriver], + context: "RayforgeContext", + **kwargs: Any, +) -> tuple["DeviceProfile", list[str]]: + """ + Shared probe orchestration for all Marlin drivers. + + Creates a temporary machine + driver, connects, queries M115, + M211, and M503, disconnects, and returns a + ``(DeviceProfile, warnings)`` tuple. + """ + from ...models.machine import Machine + + machine = Machine(context) + driver = driver_cls(context, machine) + driver.setup(**kwargs) + + connected = asyncio.Event() + + def _on_status(sender, status=None, message=None, **kw): + if status == TransportStatus.CONNECTED: + connected.set() + + driver.connection_status_changed.connect(_on_status) + try: + await driver.connect() + await asyncio.wait_for(connected.wait(), timeout=15.0) + m115_lines = await driver.execute_interactive_command("M115") + m211_lines = await driver.execute_interactive_command("M211") + m503_lines = await driver.execute_interactive_command("M503") + m149_lines = await driver.execute_interactive_command("M149") + finally: + driver.connection_status_changed.disconnect(_on_status) + await driver.cleanup() + context.dialect_mgr.dialects_changed.disconnect( + machine._on_dialects_changed + ) + + profile, warnings = build_marlin_profile( + m115_lines, m211_lines, m503_lines, m149_lines, driver.boot_lines + ) + profile.machine_config.driver = driver_cls.__name__ + profile.machine_config.driver_args = kwargs + return profile, warnings + + +def build_marlin_profile( + m115_lines: list[str], + m211_lines: list[str], + m503_lines: list[str], + m149_lines: list[str], + boot_lines: list[str] | None = None, +) -> tuple["DeviceProfile", list[str]]: + """ + Build a ``DeviceProfile`` from raw Marlin M115, M211, M503, and + M149 response lines. + + This is a pure data-transformation function with no I/O. + The caller is responsible for communicating with the device + and passing the collected response lines. + + Returns a ``(DeviceProfile, warnings)`` tuple where *warnings* + is a list of human-readable strings about potential issues + detected in the device configuration. + """ + from ...device.profile import ( + DeviceMeta, + DeviceProfile, + MachineConfig, + ) + + boot = boot_lines or [] + warnings: list[str] = [] + + name = extract_marlin_device_name(m115_lines, boot) + + fw_info = parse_m115_firmware_info(m115_lines) + driver_config: dict[str, Any] = {} + fw_name = fw_info.get("firmware_name", "") + if fw_name: + for line in boot: + ver = parse_marlin_version(line) + if ver: + fw_name = ver + break + driver_config["firmware_version"] = fw_name + + extents: tuple[float, float] | None = None + endstops = parse_m211_endstops(m211_lines) + if endstops is not None: + x_max, y_max = endstops + if x_max > 0 and y_max > 0: + extents = (x_max, y_max) + + m503 = parse_m503_settings(m503_lines) + + max_speed: int | None = None + max_feed_x = m503.get("max_feedrate_x") + max_feed_y = m503.get("max_feedrate_y") + if max_feed_x is not None and max_feed_y is not None: + max_speed_mm_s = min(max_feed_x, max_feed_y) + max_speed = int(max_speed_mm_s * 60) + + accel: int | None = None + accel_val = m503.get("acceleration") + if accel_val is not None: + accel = int(accel_val) + + detected = detect_unit_system_from_m149(m149_lines) + detected_unit_system = ( + detected if detected is not None else UnitSystem.METRIC + ) + + return ( + DeviceProfile( + meta=DeviceMeta( + name=name, + description=_("Auto-configured via probe wizard"), + ), + machine_config=MachineConfig( + driver_config=driver_config or None, + axis_extents=extents, + max_travel_speed=max_speed, + max_cut_speed=max_speed, + acceleration=accel, + home_on_start=None, + single_axis_homing_enabled=True, + supports_arcs=True, + unit_system=detected_unit_system, + heads=None, + ), + dialect_config={}, + ), + warnings, + ) diff --git a/rayforge/machine/driver/marlin/marlin_serial.py b/rayforge/machine/driver/marlin/marlin_serial.py new file mode 100644 index 000000000..a919a0358 --- /dev/null +++ b/rayforge/machine/driver/marlin/marlin_serial.py @@ -0,0 +1,714 @@ +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +import serial.serialutil + +from ....context import RayforgeContext +from ....core.varset import ( + BaudrateVar, + SerialPortVar, + VarSet, +) +from ....pipeline.encoder.base import ( + EncodedOutput, + MachineCodeOpMap, + OpsEncoder, +) +from ....pipeline.encoder.gcode import GcodeEncoder +from ....shared.units.system import UnitSystem +from ...transport import SerialTransport, TransportStatus +from ...transport.serial import SerialPortPermissionError +from ..driver import ( + Axis, + DeviceConnectionError, + DeviceState, + DeviceStatus, + Driver, + DriverMaturity, + DriverPrecheckError, + DriverSetupError, + Pos, +) +from .marlin_probe import probe_marlin_device +from .marlin_util import ( + detect_unit_system_from_m149, + gcode_to_p_number, + is_boot_message, + is_error_response, + is_ok_response, + m114_pos_re, +) + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ....core.doc import Doc + from ...device.profile import DeviceProfile + from ...models.laser import Laser + from ...models.machine import Machine + +logger = logging.getLogger(__name__) + + +class MarlinSerialDriver(Driver): + label = _("Marlin (Serial)") + subtitle = _("Marlin firmware via serial connection") + supports_settings = False + reports_granular_progress = True + maturity = DriverMaturity.EXPERIMENTAL + supports_probing = True + supports_unit_detection = True + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self._transport: SerialTransport | None = None + self._port: str = "" + self._baudrate: int = 115200 + self.keep_running = False + self._connection_task: asyncio.Task | None = None + self._ok_event = asyncio.Event() + self._handshake_event = asyncio.Event() + self._job_running = False + self._is_cancelled = False + self._on_command_done: ( + Callable[[int], None | Awaitable[None]] | None + ) = None + self._last_reported_op_index = -1 + self._response_lines: list[str] = [] + self._loop: asyncio.AbstractEventLoop | None = None + self._rx_buffer = "" + self._boot_lines: list[str] = [] + + @property + def machine_space_wcs(self) -> str: + return "MACHINE" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates") + + @property + def resource_uri(self) -> str | None: + if self._port: + return f"serial://{self._port}" + return None + + @property + def boot_lines(self) -> list[str]: + return self._boot_lines + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + try: + SerialTransport.check_serial_permissions_globally() + except SerialPortPermissionError as e: + raise DriverPrecheckError(str(e)) from e + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + SerialPortVar( + key="port", + label=_("Port"), + description=_("Serial port for the device"), + ), + BaudrateVar( + "baudrate", + choices=SerialTransport.list_baud_rates(), + ), + ] + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + def get_setting_vars(self) -> list["VarSet"]: + return [VarSet()] + + @classmethod + async def probe( + cls, context: "RayforgeContext", **kwargs: Any + ) -> tuple["DeviceProfile", list[str]]: + return await probe_marlin_device(cls, context, **kwargs) + + def _setup_implementation(self, **kwargs: Any) -> None: + port = cast(str, kwargs.get("port", "")) + baudrate = kwargs.get("baudrate", 115200) + + if not port: + raise DriverSetupError(_("Port must be configured.")) + if not baudrate: + raise DriverSetupError(_("Baud rate must be configured.")) + + self._port = port + self._baudrate = int(baudrate) + self._transport = SerialTransport(port, self._baudrate) + self._transport.received.connect(self.on_serial_data_received) + self._transport.status_changed.connect(self.on_serial_status_changed) + + def on_serial_status_changed( + self, sender, status: TransportStatus, message: str | None = None + ): + if status == TransportStatus.CONNECTED: + logger.debug( + "Suppressing transport-level CONNECTED. Driver will " + "emit CONNECTED after handshake verification." + ) + return + logger.debug( + f"Serial transport status changed: {status}, message: {message}" + ) + self._update_connection_status(status, message) + + async def cleanup(self): + logger.debug("Cleanup initiated.") + self.keep_running = False + self._is_cancelled = False + self._job_running = False + self._on_command_done = None + + if self._connection_task: + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + except Exception as e: # noqa: BLE001 - awaited task cleanup + logger.warning( + f"Ignored exception in connection task during cleanup: {e}" + ) + self._connection_task = None + + if self._transport: + self._transport.received.disconnect(self.on_serial_data_received) + self._transport.status_changed.disconnect( + self.on_serial_status_changed + ) + if self._transport.is_connected: + await self._transport.disconnect() + + await super().cleanup() + logger.debug("Cleanup completed.") + + async def _connect_implementation(self): + if self._connection_task and not self._connection_task.done(): + logger.warning( + "Connect called with active connection task. Cleaning up." + ) + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + + if not self._transport: + logger.error( + "Cannot connect: Transport not initialized " + "(check port settings)." + ) + self._update_connection_status( + TransportStatus.ERROR, _("Port not configured") + ) + return + + logger.debug("Connect initiated.") + self._loop = asyncio.get_running_loop() + self.keep_running = True + self._is_cancelled = False + self._job_running = False + self._on_command_done = None + self._connection_task = asyncio.create_task(self._connection_loop()) + + async def _connection_loop(self) -> None: + logger.debug("Entering _connection_loop.") + while self.keep_running: + logger.debug("Attempting connection...") + + try: + transport = self._transport + if not transport: + raise DriverSetupError("Transport not initialized") + + await transport.connect() + logger.debug("Serial port opened. Waiting for Marlin start...") + + self._handshake_event.clear() + try: + await asyncio.wait_for( + self._handshake_event.wait(), timeout=5.0 + ) + except asyncio.TimeoutError: + logger.warning( + "No 'start' received from Marlin. Port may " + "be a phantom COM port." + ) + await transport.disconnect() + self._update_connection_status( + TransportStatus.ERROR, + _("No response from device"), + ) + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(5) + continue + + logger.info("Connection established successfully.") + + await asyncio.sleep(1.0) + + self._update_connection_status(TransportStatus.CONNECTED) + + logger.debug("Connection verified. Starting M114 polling.") + while transport.is_connected and self.keep_running: + if not self._job_running: + try: + await self._send_and_wait( + "M114", wait_for_ok=False + ) + except ConnectionError as e: + logger.warning( + f"Connection lost during M114 poll: {e}" + ) + break + await asyncio.sleep(2.0) + + if not self.keep_running: + break + + except (serial.serialutil.SerialException, OSError) as e: + logger.error(f"Connection error: {e}") + except asyncio.CancelledError: + logger.info("Connection loop cancelled.") + break + except Exception as e: + logger.exception("Unexpected error in connection loop") + self._update_connection_status(TransportStatus.ERROR, str(e)) + finally: + if self._transport and self._transport.is_connected: + logger.debug("Disconnecting transport in finally block") + await self._transport.disconnect() + + if not self.keep_running: + break + + logger.debug("Connection lost. Reconnecting in 5s...") + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(5) + + logger.debug("Leaving _connection_loop.") + + async def _send_and_wait( + self, command: str, wait_for_ok: bool = True + ) -> list[str]: + if not self._transport or not self._transport.is_connected: + raise ConnectionError("Serial transport not connected") + + self._response_lines = [] + if wait_for_ok: + self._ok_event.clear() + + cmd_str = command.strip() + if cmd_str: + logger.info(cmd_str, extra=self._log_extra("USER_COMMAND")) + payload = (command + "\n").encode("utf-8") + logger.debug( + f"TX: {payload!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": payload, + }, + ) + await self._transport.send(payload) + + if wait_for_ok: + try: + await asyncio.wait_for(self._ok_event.wait(), 30.0) + except asyncio.TimeoutError as e: + raise ConnectionError( + f"Command '{command}' not confirmed" + ) from e + + return self._response_lines + + async def execute_interactive_command(self, command: str) -> list[str]: + """ + Send a command and return its response lines. + + Used during probing to query M115, M211, M503 etc. + Sets ``_job_running = True`` to suppress M114 status + polling while the command is in flight. + """ + if not self._transport or not self._transport.is_connected: + raise ConnectionError("Serial transport not connected") + + was_job_running = self._job_running + self._job_running = True + try: + return await self._send_and_wait(command) + finally: + self._job_running = was_job_running + + def _start_job( + self, + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ): + self._is_cancelled = False + self._job_running = True + self._on_command_done = on_command_done + self._last_reported_op_index = -1 + + async def _stream_gcode( + self, + gcode_lines: list[str], + op_map: MachineCodeOpMap | None = None, + ): + logger.debug( + f"Starting Marlin streaming job with {len(gcode_lines)} lines." + ) + try: + for line_idx, line in enumerate(gcode_lines): + if self._is_cancelled: + logger.info("Job cancelled. Stopping G-code.") + break + + line = line.strip() + if not line: + continue + + op_index = op_map.op_for_line(line_idx) if op_map else None + + await self._send_and_wait(line) + + if self._on_command_done and op_index is not None: + for i in range( + self._last_reported_op_index + 1, + op_index + 1, + ): + try: + result = self._on_command_done(i) + if inspect.isawaitable(result): + asyncio.ensure_future(result) + except Exception as e: + logger.error( + "Error in on_command_done callback", + exc_info=e, + ) + self._last_reported_op_index = op_index + + except ( + asyncio.CancelledError, + ConnectionError, + DeviceConnectionError, + ) as e: + logger.warning(f"Job interrupted: {e!r}") + if not self._is_cancelled: + logger.info(f"Calling cancel() due to interruption: {e!r}") + await self.cancel() + if isinstance(e, asyncio.CancelledError): + raise + finally: + self._job_running = False + self._on_command_done = None + self.job_finished.send(self) + logger.debug("G-code streaming finished.") + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + self._start_job(on_command_done) + + mapping = encoded.op_map + gcode_lines = encoded.text.splitlines() + + try: + await self._stream_gcode(gcode_lines, mapping) + except DeviceConnectionError as e: + logger.warning( + f"Job terminated due to device error: {e}. " + "Connection remains active." + ) + + async def run_raw(self, machine_code: str) -> None: + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + if not lines: + return + self._start_job() + try: + await self._stream_gcode(lines) + except DeviceConnectionError as e: + logger.warning( + f"Raw G-code terminated due to device error: {e}. " + "Connection remains active." + ) + + async def cancel(self) -> None: + logger.debug("Cancel command initiated.") + job_was_running = self._job_running + self._is_cancelled = True + self._job_running = False + self._on_command_done = None + + if self._transport and self._transport.is_connected: + logger.info("Sending M410 (Quick Stop) to device.") + try: + payload = b"M410\n" + logger.debug( + f"TX: {payload!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": payload, + }, + ) + await self._transport.send(payload) + except ConnectionError as e: + logger.warning(f"Failed to send M410: {e}") + + if not self._transport: + raise ConnectionError("Serial transport not initialized") + + if job_was_running: + self.job_finished.send(self) + + async def set_hold(self, hold: bool = True) -> None: + logger.warning( + "Marlin does not support reliable pause/resume " + "on 8-bit boards. set_hold is a best-effort no-op." + ) + + async def home(self, axes: Axis | None = None) -> None: + dialect = self.dialect + if axes is None: + await self._send_and_wait(dialect.home_all) + else: + for axis in axes: + cmd = dialect.home_axis.format(axis_letter=axis.name) + await self._send_and_wait(cmd) + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def move_to(self, pos_x, pos_y) -> None: + dialect = self.dialect + cmd = dialect.move_to.format( + speed=self._to_machine_speed(1500), + x=self._to_machine_length(float(pos_x)), + y=self._to_machine_length(float(pos_y)), + ) + await self._send_and_wait(cmd) + + async def jog(self, speed: int, **deltas: float) -> None: + dialect = self.dialect + parts = [dialect.jog.format(speed=self._to_machine_speed(speed))] + + for axis_name, distance in deltas.items(): + parts.append( + f"{axis_name.upper()}{self._to_machine_length(distance)}" + ) + + if len(parts) == 1: + return + + cmd = " ".join(parts) + await self._send_and_wait(cmd) + await self._send_and_wait("G90") + + async def select_tool(self, tool_number: int) -> None: + dialect = self.dialect + cmd = dialect.tool_change.format(tool_number=tool_number) + await self._send_and_wait(cmd) + + async def clear_alarm(self) -> None: + dialect = self.dialect + await self._send_and_wait(dialect.clear_alarm) + self.state.error = None + self.state_changed.send(self, state=self.state) + + async def set_power(self, head: "Laser", percent: float) -> None: + dialect = self.dialect + if percent <= 0: + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.laser_on.format(power=power_abs) + await self._send_and_wait(cmd) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + dialect = self.dialect + if percent <= 0: + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.focus_laser_on.format(power=power_abs) + await self._send_and_wait(cmd) + + def can_home(self, axis: Axis | None = None) -> bool: + return True + + def can_jog(self, axis: Axis | None = None) -> bool: + return True + + async def read_settings(self) -> None: + raise NotImplementedError( + "Device settings not implemented for this driver" + ) + + async def detect_unit_system(self) -> UnitSystem | None: + """ + Queries the device's active linear unit via ``M149`` and + maps the response to a ``UnitSystem``. + """ + try: + response_lines = await self.execute_interactive_command("M149") + except (ConnectionError, asyncio.TimeoutError) as e: + logger.warning(f"Unit system detection failed: {e}") + return None + return detect_unit_system_from_m149(response_lines) + + async def write_setting(self, key: str, value: Any) -> None: + raise NotImplementedError( + "Device settings not implemented for this driver" + ) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + p_num = gcode_to_p_number(wcs_slot) + if p_num is None: + raise ValueError(f"Invalid WCS slot: {wcs_slot}") + dialect = self.dialect + cmd = dialect.set_wcs_offset.format( + p_num=p_num, + x=self._to_machine_length(x), + y=self._to_machine_length(y), + z=self._to_machine_length(z), + ) + await self._send_and_wait(cmd) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + raise NotImplementedError( + "Reading all WCS offsets is not supported by Marlin." + ) + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + raise NotImplementedError( + "Probing is not implemented for the Marlin driver." + ) + + def on_serial_data_received(self, sender, data: bytes): + logger.debug( + f"RX: {data!r}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": data, + }, + ) + + self._rx_buffer += data.decode("utf-8", errors="replace") + while "\n" in self._rx_buffer: + line, self._rx_buffer = self._rx_buffer.split("\n", 1) + line = line.strip() + if not line: + continue + self._process_line(line) + + def _process_line(self, line: str): + if is_ok_response(line): + logger.info("ok", extra=self._log_extra("MACHINE_RESPONSE")) + self._ok_event.set() + return + + if is_error_response(line): + logger.error( + f"Marlin error: {line}", + extra=self._log_extra("ERROR"), + ) + self._response_lines.append(line) + self._ok_event.set() + return + + if line == "start": + logger.debug("Received 'start' handshake from Marlin.") + self._handshake_event.set() + return + + if is_boot_message(line): + self._boot_lines.append(line) + logger.info( + line, + extra=self._log_extra("MACHINE_EVENT"), + ) + return + + match = m114_pos_re.search(line) + if match: + x = self._from_machine_length(float(match.group(1))) + y = self._from_machine_length(float(match.group(2))) + z = self._from_machine_length(float(match.group(3))) + old_pos = self.state.machine_pos + new_pos = (x, y, z) + if new_pos != old_pos: + self.state.machine_pos = new_pos + self.state.work_pos = new_pos + old_status = self.state.status + if self.state.status == DeviceStatus.UNKNOWN: + self.state.status = DeviceStatus.IDLE + if self.state != DeviceState(): + if self.state.status != old_status: + logger.info( + f"Device state changed: {self.state.status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) + logger.info( + f"M114: X:{x} Y:{y} Z:{z}", + extra=self._log_extra("STATUS_POLL"), + ) + return + + logger.info(line, extra=self._log_extra("MACHINE_EVENT")) + self._response_lines.append(line) + + def _update_connection_status( + self, status: TransportStatus, message: str | None = None + ): + log_data = f"Connection status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.connection_status_changed.send( + self, status=status, message=message + ) + if ( + status + in [ + TransportStatus.DISCONNECTED, + TransportStatus.ERROR, + ] + and self.state.status != DeviceStatus.UNKNOWN + ): + self.state.status = DeviceStatus.UNKNOWN + logger.info( + f"Device state changed: {self.state.status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) diff --git a/rayforge/machine/driver/marlin/marlin_util.py b/rayforge/machine/driver/marlin/marlin_util.py new file mode 100644 index 000000000..f72328176 --- /dev/null +++ b/rayforge/machine/driver/marlin/marlin_util.py @@ -0,0 +1,253 @@ +"""Parsing utilities for the Marlin firmware driver.""" + +import re + +from ....shared.units.system import UnitSystem + +MARLIN_HANDSHAKE_TIMEOUT = 5.0 +MARLIN_COMMAND_TIMEOUT = 30.0 +MARLIN_RECONNECT_DELAY = 5.0 +MARLIN_STATUS_POLL_INTERVAL = 2.0 + +m114_pos_re = re.compile( + r"X:([+-]?\d+\.?\d*)\s+Y:([+-]?\d+\.?\d*)\s+Z:([+-]?\d+\.?\d*)" +) +marlin_version_re = re.compile(r"Marlin\s+([\d.]+)") +marlin_error_re = re.compile(r"Error:(.+)") +marlin_echo_re = re.compile(r"echo:(.+)") +m115_firmware_re = re.compile(r"FIRMWARE_NAME:\s*(\S+)") +m115_machine_type_re = re.compile(r"MACHINE_TYPE:(.+?)(?:\s+[A-Z_]+:|$)") +m203_feedrate_re = re.compile( + r"echo:\s*M203\s+" + r"X([+-]?\d+\.?\d*)\s+" + r"Y([+-]?\d+\.?\d*)" +) +m149_units_re = re.compile( + r"M149\s+Units\s+in\s+(inches|inch|in|mm|millimeters|millimetres)", + re.IGNORECASE, +) +m204_accel_re = re.compile(r"echo:\s*M204\s+S([+-]?\d+\.?\d*)") +m211_max_re = re.compile( + r"X:?\s*([+-]?\d+\.?\d*)\s+" + r"Y:?\s*([+-]?\d+\.?\d*)" +) + + +def parse_m114_position( + response_lines: list[str], +) -> tuple[float, float, float] | None: + """ + Parse M114 output to extract the (X, Y, Z) position. + + Args: + response_lines: List of response lines from an M114 command. + + Returns: + A (x, y, z) float tuple, or None if no position line is found. + """ + for line in response_lines: + match = m114_pos_re.search(line) + if match: + return ( + float(match.group(1)), + float(match.group(2)), + float(match.group(3)), + ) + return None + + +def parse_marlin_version(line: str) -> str | None: + """ + Extract the Marlin version string from a boot line. + + Args: + line: A single line containing the firmware identifier, + e.g. ``"Marlin 2.1.2.7"``. + + Returns: + The version string (e.g. ``"2.1.2.7"``), or None if not found. + """ + match = marlin_version_re.search(line) + if match: + return match.group(1) + return None + + +def is_ok_response(line: str) -> bool: + """ + Check whether a line is a Marlin ``ok`` acknowledgment. + + Handles plain ``ok`` as well as temperature-augmented forms such + as ``ok T:19.5 /200.0 B:60.0 /60.0`` and + ``ok P:15 B:3``. + """ + stripped = line.strip() + return stripped.startswith("ok") and ( + len(stripped) == 2 or not stripped[2].isalnum() + ) + + +def is_error_response(line: str) -> bool: + """ + Check whether a line is a Marlin error response. + + Args: + line: A single response line. + + Returns: + True if the line starts with ``Error:``. + """ + return line.strip().startswith("Error:") + + +def parse_error_message(line: str) -> str: + """ + Extract the error message from an ``Error:...`` line. + + Args: + line: A single response line starting with ``Error:``. + + Returns: + The message portion after ``Error:`` (whitespace-stripped). + """ + match = marlin_error_re.search(line) + if match: + return match.group(1).strip() + return "" + + +def is_boot_message(line: str) -> bool: + """ + Check whether a line is a Marlin boot/startup message. + + Recognised prefixes: ``start``, ``Marlin``, ``echo:``, + ``External Reset``. + """ + stripped = line.strip() + prefixes = ("start", "Marlin", "echo:", "External Reset") + return any(stripped.startswith(p) for p in prefixes) + + +def parse_m115_firmware_info( + response_lines: list[str], +) -> dict[str, str]: + """ + Parse M115 response to extract firmware name and machine type. + + Returns a dict with optional keys ``firmware_name`` and + ``machine_type``. + """ + info: dict[str, str] = {} + for line in response_lines: + match = m115_firmware_re.search(line) + if match: + info["firmware_name"] = match.group(1).strip() + match = m115_machine_type_re.search(line) + if match: + info["machine_type"] = match.group(1).strip() + return info + + +def parse_m211_endstops( + response_lines: list[str], +) -> tuple[float, float] | None: + """ + Parse M211 output to extract X/Y max travel from software endstops. + + Returns (x_max, y_max) or None if not found. + """ + for line in response_lines: + match = m211_max_re.search(line) + if match: + return (float(match.group(1)), float(match.group(2))) + return None + + +def parse_m503_settings( + response_lines: list[str], +) -> dict[str, float]: + """ + Parse M503 output to extract key motion settings. + + Returns a dict with optional keys: + - ``max_feedrate_x``, ``max_feedrate_y`` (mm/s from M203) + - ``acceleration`` (mm/s^2 from M204 S) + """ + settings: dict[str, float] = {} + for line in response_lines: + match = m203_feedrate_re.search(line) + if match: + settings["max_feedrate_x"] = float(match.group(1)) + settings["max_feedrate_y"] = float(match.group(2)) + match = m204_accel_re.search(line) + if match: + settings["acceleration"] = float(match.group(1)) + return settings + + +def detect_unit_system_from_m149( + response_lines: list[str], +) -> UnitSystem | None: + """ + Inspect Marlin ``M149`` response lines and infer the device's + unit system. + + Marlin reports the active linear unit with lines such as + ``echo: M149 Units in inches`` or ``echo: M149 Units in mm``. + + Returns ``UnitSystem.IMPERIAL`` when the unit is inches, + ``UnitSystem.METRIC`` when it is millimeters, or ``None`` when + no recognizable ``M149`` line is present. + """ + for line in response_lines: + match = m149_units_re.search(line) + if match: + unit = match.group(1).lower() + if unit in ("inches", "inch", "in"): + return UnitSystem.IMPERIAL + return UnitSystem.METRIC + return None + + +def extract_marlin_device_name( + m115_lines: list[str], + boot_lines: list[str] | None = None, +) -> str: + """ + Extract a human-readable device name from M115 and boot output. + + Uses MACHINE_TYPE from M115 first, then falls back to the + firmware name. + """ + info = parse_m115_firmware_info(m115_lines) + machine_type = info.get("machine_type") + if machine_type and machine_type.lower() not in ( + "3d printer", + "default", + ): + return machine_type + + fw_name = info.get("firmware_name", "") + if fw_name: + return fw_name + + if boot_lines: + for line in boot_lines: + ver = parse_marlin_version(line) + if ver: + return f"Marlin {ver}" + + return "Unknown Marlin Device" + + +def gcode_to_p_number(wcs_slot: str) -> int | None: + """Converts a G-code WCS name (e.g., "G54") to its P-number.""" + try: + if not wcs_slot.startswith("G"): + return None + p_num = int(wcs_slot[1:]) - 53 + if 1 <= p_num <= 6: + return p_num + except (ValueError, IndexError): + pass + return None diff --git a/rayforge/machine/driver/octoprint/__init__.py b/rayforge/machine/driver/octoprint/__init__.py new file mode 100644 index 000000000..7145932af --- /dev/null +++ b/rayforge/machine/driver/octoprint/__init__.py @@ -0,0 +1,3 @@ +from .octoprint_driver import OctoPrintDriver + +__all__ = ["OctoPrintDriver"] diff --git a/rayforge/machine/driver/octoprint/octoprint_driver.py b/rayforge/machine/driver/octoprint/octoprint_driver.py new file mode 100644 index 000000000..881f4ab15 --- /dev/null +++ b/rayforge/machine/driver/octoprint/octoprint_driver.py @@ -0,0 +1,874 @@ +import asyncio +import inspect +import json +import logging +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +import aiohttp +from raygeo.ops.axis import Axis + +from ....context import RayforgeContext +from ....core.varset import ( + AppKeyVar, + HostnameVar, + PortVar, + VarSet, +) +from ....core.varset.hostnamevar import is_valid_hostname_or_ip +from ....pipeline.encoder.base import EncodedOutput, OpsEncoder +from ....pipeline.encoder.gcode import GcodeEncoder +from ...transport import TransportStatus +from ..driver import ( + DeviceConnectionError, + DeviceError, + DeviceStatus, + Driver, + DriverMaturity, + DriverPrecheckError, + DriverSetupError, + Pos, +) + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ....core.doc import Doc + from ...models.laser import Laser + from ...models.machine import Machine + +logger = logging.getLogger(__name__) + +_STATE_MAP: dict[str, DeviceStatus] = { + "Operational": DeviceStatus.IDLE, + "Printing": DeviceStatus.RUN, + "Pausing": DeviceStatus.HOLD, + "Paused": DeviceStatus.HOLD, + "Cancelling": DeviceStatus.RUN, + "Starting": DeviceStatus.RUN, + "Error": DeviceStatus.ALARM, + "Offline": DeviceStatus.UNKNOWN, + "Offline after error": DeviceStatus.ALARM, + "Opening serial connection": DeviceStatus.UNKNOWN, + "Connecting": DeviceStatus.UNKNOWN, + "Closed": DeviceStatus.UNKNOWN, +} + +_POLL_INTERVAL = 2.0 +_RECONNECT_INTERVAL = 5.0 +_WS_PING_INTERVAL = 30.0 + + +class OctoPrintDriver(Driver): + """ + Submits G-code jobs to an OctoPrint server via its REST API and + monitors live printer state through SockJS WebSocket push updates + with a polling REST fallback. + """ + + label = _("OctoPrint") + subtitle = _("Submit G-code to an OctoPrint server") + supports_settings = False + reports_granular_progress = False + uses_gcode = True + maturity = DriverMaturity.UNTESTED + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self.host: str | None = None + self.port: int = 80 + self._api_key: str | None = None + self._base_url: str | None = None + self.keep_running: bool = False + self._connection_task: asyncio.Task | None = None + self._job_active: bool = False + self._job_done_event = asyncio.Event() + self._session_key: str | None = None + self._user_name: str | None = None + self._auth_retried: bool = False + + @property + def machine_space_wcs(self) -> str: + return "G53" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates (G53)") + + @property + def resource_uri(self) -> str | None: + if self.host: + return f"tcp://{self.host}:{self.port}" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + if host and not is_valid_hostname_or_ip(host): + raise DriverPrecheckError( + _("Invalid hostname or IP address: '{host}'").format(host=host) + ) + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + HostnameVar( + key="host", + label=_("Hostname"), + description=_( + "IP address or hostname of the OctoPrint server" + ), + ), + PortVar( + key="port", + label=_("Port"), + description=_("HTTP port of the OctoPrint server"), + default=80, + ), + AppKeyVar( + key="api_key", + label=_("API Key"), + app_name="RayForge", + probe_url="http://{host}:{port}/plugin/appkeys/probe", + request_url="http://{host}:{port}/plugin/appkeys/request", + poll_url="http://{host}:{port}" + "/plugin/appkeys/request" + "/{{app_token}}", + description=_( + "Enter an API key manually or click " + "'Request Access' to obtain one via " + "OctoPrint's Application Keys plugin." + ), + ), + ] + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + @staticmethod + def _extract_api_key(data: str | None) -> str | None: + if not data: + return None + try: + tokens = json.loads(data) + if isinstance(tokens, dict): + return tokens.get("api_key") or tokens.get("access_token") + return str(tokens) + except (json.JSONDecodeError, TypeError): + return data.strip() if data else None + + def _setup_implementation(self, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + port = cast(int, kwargs.get("port", 80)) + if not host: + raise DriverSetupError(_("Hostname must be configured.")) + + raw_key = kwargs.get("api_key", "") + api_key = self._extract_api_key(str(raw_key) if raw_key else "") + if not api_key: + raise DriverSetupError( + _( + "API key must be configured. Use the " + "'Request Access' button or enter an API " + "key manually." + ) + ) + + self.host = host + self.port = port + self._api_key = api_key + self._base_url = f"http://{host}:{port}" + + async def cleanup(self): + self.keep_running = False + self._job_active = False + self._job_done_event.set() + if self._connection_task: + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + self._connection_task = None + await super().cleanup() + + async def _api_request(self, method: str, path: str, **kwargs: Any) -> Any: + url = f"{self._base_url}{path}" + headers = kwargs.pop("headers", {}) + headers["X-Api-Key"] = self._api_key + + log_data = f"{method} {url}" + logger.debug( + log_data, + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": log_data, + }, + ) + + try: + async with ( + aiohttp.ClientSession() as session, + session.request( + method, url, headers=headers, **kwargs + ) as response, + ): + if response.status == 403: + await self._handle_auth_error() + raise DeviceConnectionError( + _( + "Authentication failed. " + "API key may be invalid or expired." + ) + ) + response.raise_for_status() + + if response.status == 204: + return None + + ct = response.content_type or "" + if "json" in ct: + data = await response.json() + else: + data = await response.text() + + logger.debug( + f"Response ({response.status}): {str(data)[:200]}" + if isinstance(data, str) + else f"Response ({response.status}): ", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": str(data)[:500].encode("utf-8", errors="replace"), + }, + ) + return data + except aiohttp.ClientError as e: + msg = _( + "Could not connect to OctoPrint at " + "'{host}:{port}'. Check the address and " + "network connection." + ).format(host=self.host, port=self.port) + raise DeviceConnectionError(msg) from e + + async def _handle_auth_error(self) -> None: + if not self._auth_retried: + self._auth_retried = True + try: + login = await self._api_request( + "POST", + "/api/login", + json={"passive": True}, + ) + if login and login.get("name"): + self._session_key = login.get("session") + self._user_name = login.get("name") + return + except Exception: + logger.debug("Passive login attempt failed", exc_info=True) + + self.state.error = DeviceError( + code=403, + title=_("Authentication Failed"), + description=_( + "The API key is invalid or has expired. " + "Please re-authenticate in device settings." + ), + ) + self.state.status = DeviceStatus.UNKNOWN + self.state_changed.send(self, state=self.state) + + async def _verify_connection(self) -> None: + login = await self._api_request( + "POST", "/api/login", json={"passive": True} + ) + if login is None: + raise DeviceConnectionError(_("OctoPrint returned no login data.")) + self._session_key = login.get("session") + self._user_name = login.get("name") + self._auth_retried = False + + async def _connect_implementation(self) -> None: + if not self.host: + self._update_connection_status( + TransportStatus.DISCONNECTED, + "No host configured", + ) + return + self.keep_running = True + self._connection_task = asyncio.create_task(self._connection_loop()) + + async def _connection_loop(self) -> None: + while self.keep_running: + self._update_connection_status(TransportStatus.CONNECTING) + try: + await self._verify_connection() + try: + await self._run_websocket() + except DeviceConnectionError as ws_err: + logger.info( + f"WebSocket unavailable, using polling: {ws_err}" + ) + await self._run_polling() + except asyncio.CancelledError: + return + except DeviceConnectionError as e: + self._update_connection_status(TransportStatus.ERROR, str(e)) + except Exception as e: # noqa: BLE001 - connection loop boundary + self._update_connection_status(TransportStatus.ERROR, str(e)) + + if self.keep_running: + self._update_connection_status(TransportStatus.SLEEPING) + try: + await asyncio.sleep(_RECONNECT_INTERVAL) + except asyncio.CancelledError: + return + + async def _run_websocket(self) -> None: + ws_url = f"ws://{self.host}:{self.port}/sockjs/websocket" + self._update_connection_status(TransportStatus.CONNECTING) + + async with aiohttp.ClientSession() as session: + try: + async with session.ws_connect( + ws_url, + heartbeat=_WS_PING_INTERVAL, + ) as ws: + msg = await ws.receive() + if msg.type != aiohttp.WSMsgType.TEXT: + raise DeviceConnectionError( + _("Unexpected WebSocket frame.") + ) + + if msg.data == "o": + if self._session_key and self._user_name: + auth_inner = json.dumps( + { + "auth": ( + f"{self._user_name}:" + f"{self._session_key}" + ) + } + ) + await ws.send_str(json.dumps([auth_inner])) + elif msg.data.startswith("c["): + raise DeviceConnectionError( + _("Server closed WebSocket connection.") + ) + + self._update_connection_status(TransportStatus.CONNECTED) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + self._process_sockjs_frame(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + ): + break + if not self.keep_running: + break + except aiohttp.ClientError as e: + raise DeviceConnectionError(str(e)) from e + + def _process_sockjs_frame(self, data: str) -> None: + if data == "o" or data == "h": + return + if data.startswith("c["): + logger.info("SockJS close frame received") + return + if not data.startswith("a["): + return + + try: + messages = json.loads(data[1:]) + except json.JSONDecodeError: + return + + for raw_msg in messages: + if not isinstance(raw_msg, str): + continue + try: + payload = json.loads(raw_msg) + except json.JSONDecodeError: + continue + self._process_push_message(payload) + + def _process_push_message(self, payload: dict[str, Any]) -> None: + if "current" in payload: + self._handle_current_update(payload["current"]) + elif "history" in payload: + self._handle_current_update(payload["history"]) + elif "event" in payload: + self._handle_event(payload["event"]) + + def _handle_current_update(self, data: dict[str, Any]) -> None: + state = data.get("state") + if state: + self._update_state_from_octoprint(state) + + progress = data.get("progress") + if progress and self._job_active: + completion = progress.get("completion") + if completion is not None: + self._update_job_progress(completion) + + def _handle_event(self, event: dict[str, Any]) -> None: + event_type = event.get("type", "") + if event_type == "PrintDone": + self._on_job_completed(success=True) + elif event_type == "PrintFailed": + self._on_job_completed(success=False) + elif event_type == "PrintCancelled": + self._on_job_completed(success=True) + elif event_type == "Connected": + logger.info( + "OctoPrint printer connected", + extra=self._log_extra("MACHINE_EVENT"), + ) + elif event_type == "Disconnected": + self.state.status = DeviceStatus.UNKNOWN + self.state_changed.send(self, state=self.state) + + def _on_job_completed(self, success: bool) -> None: + if not self._job_active: + return + if success: + self.state.status = DeviceStatus.IDLE + self.state.error = None + else: + self.state.status = DeviceStatus.ALARM + self.state.error = DeviceError( + code=1, + title=_("Print Failed"), + description=_( + "OctoPrint reported that the print job " + "failed. Check OctoPrint for details." + ), + ) + self.state_changed.send(self, state=self.state) + self._job_active = False + self._job_done_event.set() + + async def _run_polling(self) -> None: + self._update_connection_status(TransportStatus.CONNECTED) + while self.keep_running: + try: + data = await self._api_request( + "GET", + "/api/printer?exclude=temperature,sd", + ) + if data: + state = data.get("state") + if state: + self._update_state_from_octoprint(state) + + if self._job_active: + job = await self._api_request("GET", "/api/job") + if job: + progress = job.get("progress") + if progress: + completion = progress.get("completion") + if completion is not None: + self._update_job_progress(completion) + + job_state = job.get("state", "") + if job_state not in ( + "Printing", + "Pausing", + "Paused", + "Cancelling", + "Starting", + ): + self._on_job_completed( + success="Error" not in job_state + ) + + except DeviceConnectionError: + raise + except (aiohttp.ClientError, ValueError) as e: + logger.warning(f"Polling error: {e}") + + await asyncio.sleep(_POLL_INTERVAL) + + def _update_state_from_octoprint(self, state_data: dict[str, Any]) -> None: + flags = state_data.get("flags", {}) + text = state_data.get("text", "Unknown") + + if flags.get("error"): + new_status = DeviceStatus.ALARM + elif flags.get("closedOrError") and not flags.get("operational"): + new_status = DeviceStatus.UNKNOWN + elif flags.get("printing"): + new_status = DeviceStatus.RUN + elif flags.get("paused") or flags.get("pausing"): + new_status = DeviceStatus.HOLD + elif flags.get("cancelling"): + new_status = DeviceStatus.RUN + elif flags.get("operational"): + new_status = DeviceStatus.IDLE + else: + new_status = _STATE_MAP.get(text, DeviceStatus.UNKNOWN) + + if new_status != self.state.status: + old = self.state.status.name + self.state.status = new_status + logger.info( + f"State: {old} -> {new_status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) + + def _update_job_progress(self, completion: float) -> None: + logger.debug( + f"Job progress: {completion:.1f}%", + extra=self._log_extra("DRIVER_EVENT"), + ) + + async def run( + self, + encoded: "EncodedOutput", + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + if not self.host: + raise DeviceConnectionError( + _("Driver not configured with a host.") + ) + + gcode = encoded.text + + try: + if on_command_done is not None: + op_map = encoded.op_map + num_ops = op_map.op_count if op_map else 0 + for i in range(num_ops): + result = on_command_done(i) + if inspect.isawaitable(result): + await result + + await self._upload_and_print(gcode, "rayforge.gcode") + + self._job_active = True + self._job_done_event.clear() + + self._update_command_status(TransportStatus.IDLE) + + while self._job_active and self.keep_running: + await asyncio.sleep(1.0) + + except asyncio.CancelledError: + pass + except Exception as e: + self._update_connection_status(TransportStatus.ERROR, str(e)) + raise + finally: + self._job_active = False + self.job_finished.send(self) + + async def _upload_and_print(self, gcode: str, filename: str) -> None: + form = aiohttp.FormData() + form.add_field( + "file", + gcode.encode("utf-8"), + filename=filename, + content_type="application/octet-stream", + ) + form.add_field("select", "true") + form.add_field("print", "true") + + url = f"{self._base_url}/api/files/local" + assert self._api_key is not None + headers: dict[str, str] = {"X-Api-Key": self._api_key} + + log_data = f"POST {url} with file '{filename}' size {len(gcode)}" + logger.debug( + log_data, + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": log_data, + }, + ) + + try: + async with ( + aiohttp.ClientSession() as session, + session.post(url, data=form, headers=headers) as response, + ): + if response.status == 403: + await self._handle_auth_error() + raise DeviceConnectionError( + _("Authentication failed during upload.") + ) + if response.status == 409: + raise DeviceConnectionError( + _( + "Printer is busy or not operational. " + "Cannot start a new job." + ) + ) + response.raise_for_status() + data = await response.json() + + logger.debug( + f"Upload response: {data}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": str(data)[:500].encode("utf-8"), + }, + ) + + if not data.get("effectivePrint", False): + raise DeviceConnectionError( + _( + "OctoPrint accepted the file but could not " + "start printing. The printer may not be " + "operational or is already busy." + ) + ) + + except aiohttp.ClientError as e: + msg = _( + "Could not upload file to OctoPrint at '{host}:{port}'." + ).format(host=self.host, port=self.port) + raise DeviceConnectionError(msg) from e + + async def run_raw(self, machine_code: str) -> None: + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + if not lines: + return + + for line in lines: + logger.info(line, extra=self._log_extra("USER_COMMAND")) + + if len(lines) == 1: + await self._api_request( + "POST", + "/api/printer/command", + json={"command": lines[0]}, + ) + else: + await self._api_request( + "POST", + "/api/printer/command", + json={"commands": lines}, + ) + self.job_finished.send(self) + + async def set_hold(self, hold: bool = True) -> None: + if hold: + await self._api_request( + "POST", + "/api/job", + json={ + "command": "pause", + "action": "pause", + }, + ) + else: + await self._api_request( + "POST", + "/api/job", + json={ + "command": "pause", + "action": "resume", + }, + ) + + async def cancel(self) -> None: + await self._api_request( + "POST", + "/api/job", + json={"command": "cancel"}, + ) + self._job_active = False + self._job_done_event.set() + self.job_finished.send(self) + + def can_home(self, axis: Axis | None = None) -> bool: + return True + + async def home(self, axes: Axis | None = None) -> None: + if axes is None: + axis_list = ["x", "y", "z"] + else: + axis_list = [a.label.lower() for a in axes] + + await self._api_request( + "POST", + "/api/printer/printhead", + json={"command": "home", "axes": axis_list}, + ) + + async def move_to(self, pos_x: float, pos_y: float) -> None: + await self._api_request( + "POST", + "/api/printer/printhead", + json={ + "command": "jog", + "x": self._to_machine_length(pos_x), + "y": self._to_machine_length(pos_y), + "absolute": True, + }, + ) + + def can_jog(self, axis: Axis | None = None) -> bool: + return True + + async def jog(self, speed: int, **deltas: float) -> None: + params: dict[str, Any] = { + "command": "jog", + "speed": self._to_machine_speed(speed), + } + params.update( + {k: self._to_machine_length(v) for k, v in deltas.items()} + ) + await self._api_request( + "POST", + "/api/printer/printhead", + json=params, + ) + + async def select_tool(self, tool_number: int) -> None: + await self._api_request( + "POST", + "/api/printer/command", + json={"command": f"T{tool_number}"}, + ) + + async def read_settings(self) -> None: + self.settings_read.send(self, settings=[]) + + async def write_setting(self, key: str, value: Any) -> None: + raise NotImplementedError( + _( + "OctoPrint does not support writing " + "device firmware settings through its API." + ) + ) + + def get_setting_vars(self) -> list["VarSet"]: + return [] + + async def clear_alarm(self) -> None: + await self._api_request( + "POST", + "/api/printer/command", + json={"command": "M999"}, + ) + self.state.error = None + self.state.status = DeviceStatus.IDLE + self.state_changed.send(self, state=self.state) + + async def set_power(self, head: "Laser", percent: float) -> None: + if percent <= 0: + cmd = "M5" + else: + power_abs = percent * head.max_power + cmd = f"M3 S{power_abs:.0f}" + await self._api_request( + "POST", + "/api/printer/command", + json={"command": cmd}, + ) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + await self.set_power(head, percent) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + p_map = { + "G54": 1, + "G55": 2, + "G56": 3, + "G57": 4, + "G58": 5, + "G59": 6, + } + p_num = p_map.get(wcs_slot) + if p_num is None: + raise ValueError(f"Invalid WCS slot: {wcs_slot}") + cmd = ( + f"G10 L2 P{p_num} " + f"X{self._to_machine_length(x):.3f} " + f"Y{self._to_machine_length(y):.3f} " + f"Z{self._to_machine_length(z):.3f}" + ) + await self._api_request( + "POST", + "/api/printer/command", + json={"command": cmd}, + ) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + return {} + + async def run_probe_cycle( + self, + axis: Axis, + max_travel: float, + feed_rate: int, + ) -> Pos | None: + assert axis.name, "Probing requires a named axis." + axis_letter = axis.name.upper() + cmd = ( + f"G38.2 {axis_letter}" + f"{self._to_machine_length(max_travel):.3f} " + f"F{self._to_machine_speed(feed_rate)}" + ) + self.probe_status_changed.send( + self, message=f"Probing {axis_letter}..." + ) + await self._api_request( + "POST", + "/api/printer/command", + json={"command": cmd}, + ) + self.probe_status_changed.send( + self, + message=_( + "Probe command sent. OctoPrint does not " + "report probe results via its API." + ), + ) + return None + + def _update_command_status( + self, + status: TransportStatus, + message: str | None = None, + ) -> None: + log_data = f"Command status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.command_status_changed.send(self, status=status, message=message) + + def _update_connection_status( + self, + status: TransportStatus, + message: str | None = None, + ) -> None: + log_data = f"Connection status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.connection_status_changed.send( + self, status=status, message=message + ) diff --git a/rayforge/machine/driver/ruida/__init__.py b/rayforge/machine/driver/ruida/__init__.py new file mode 100644 index 000000000..db1d1d176 --- /dev/null +++ b/rayforge/machine/driver/ruida/__init__.py @@ -0,0 +1,9 @@ +""" +Ruida driver low-level protocol implementation (internal). + +Contains OSI layers 2-4 for Ruida protocol. +""" + +from .ruida_driver import RuidaDriver + +__all__ = ["RuidaDriver"] diff --git a/rayforge/machine/driver/ruida/ruida_client.py b/rayforge/machine/driver/ruida/ruida_client.py new file mode 100644 index 000000000..ce7780a2a --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_client.py @@ -0,0 +1,766 @@ +""" +Ruida Client Protocol - Client-side command generation and sending. + +Handles generation of commands to send to a Ruida laser controller, +sending them via transport, and parsing of responses. +""" + +import asyncio +import logging +from typing import TYPE_CHECKING, Optional + +from blinker import Signal + +from .ruida_maps import ( + CARD_ID_ADDRESS, + CARD_ID_TO_MODEL, + REF_POINT_COMMANDS, + REF_POINT_OFFSET_ADDRESSES, +) +from .ruida_protocol import RuidaResponse, RuidaState +from .ruida_util import decode35, encode14, encode35 + +if TYPE_CHECKING: + from .ruida_transport import RuidaTransport + +logger = logging.getLogger(__name__) + + +class RuidaClient: + """ + Ruida client-side protocol handler. + + Generates commands to send to a Ruida controller, sends them via + the transport layer, and parses responses. + + Usage: + transport = RuidaTransport(UdpTransport(host, port)) + client = RuidaClient(transport) + await client.connect() + await client.home_xy() + await client.move_abs(10000, 20000) # in micrometers + """ + + def __init__( + self, + transport: "RuidaTransport", + state: RuidaState | None = None, + jog_transport: Optional["RuidaTransport"] = None, + ): + self._transport = transport + self._jog_transport = jog_transport + self.state = state or RuidaState() + self._pending_mem_reads: dict[int, asyncio.Future] = {} + self._ref_point_mode: str | None = "MACHINE" + self.position_updated = Signal() + self.state_changed = Signal() + + self._transport.decoded_received.connect(self._handle_response) + + @property + def is_connected(self) -> bool: + return self._transport.is_connected + + def _handle_response(self, sender, data: bytes) -> None: + """ + Handle decoded data from the transport layer. + + Parses DA memory read responses and emits signals. + Also resolves any pending synchronous memory reads. + + Args: + sender: The signal sender (unused) + data: The decoded response data + """ + pending = list(self._pending_mem_reads.keys()) + logger.debug(f"handle_response: {data.hex()} (pending: {pending})") + if len(data) >= 9 and data[0] == 0xDA and data[1] == 0x01: + mem_address = (data[2] << 8) | data[3] + value = decode35(data[4:9]) + + if mem_address in self._pending_mem_reads: + future = self._pending_mem_reads.pop(mem_address) + if not future.done(): + future.set_result(value) + + axis = None + if mem_address == 0x0421: + axis = "x" + self.state.x = value + elif mem_address == 0x0431: + axis = "y" + self.state.y = value + elif mem_address == 0x0441: + axis = "z" + self.state.z = value + + if axis: + logger.debug( + f"Position response: {axis}={value}um " + f"(mem 0x{mem_address:04X})" + ) + self.position_updated.send(self, axis=axis, value_um=value) + + self.state_changed.send(self) + + async def connect(self) -> None: + """Establish connection to the Ruida controller.""" + await self._transport.connect() + if self._jog_transport: + await self._jog_transport.connect() + + async def disconnect(self) -> None: + """Close connection to the Ruida controller.""" + if self._jog_transport: + await self._jog_transport.disconnect() + await self._transport.disconnect() + + def parse_response(self, data: bytes) -> RuidaResponse: + return RuidaResponse.from_bytes(data) + + async def send_command(self, command: bytes) -> None: + """ + Send a raw command to the controller. + + Args: + command: Raw command bytes (will be swizzled and framed) + """ + await self._transport.send_command(command) + + async def send_jog_command(self, command: bytes) -> None: + """ + Send a jog command to the controller via the main channel. + + Jog commands are swizzled and framed with checksum, sent on + the main command channel (port 50200). + + Args: + command: Raw command bytes + """ + await self._transport.send_command(command) + + async def move_abs(self, x: int, y: int) -> None: + """ + Move to absolute position (traversal, laser off). + + Args: + x: X coordinate in micrometers + y: Y coordinate in micrometers + """ + await self.send_command(self._build_move_abs(x, y)) + + async def move_rel(self, dx: int, dy: int) -> None: + """ + Move by relative offset (traversal, laser off). + + Args: + dx: X offset in micrometers + dy: Y offset in micrometers + """ + await self.send_command(self._build_move_rel(dx, dy)) + + async def cut_abs(self, x: int, y: int) -> None: + """ + Move to absolute position (cutting, laser on). + + Args: + x: X coordinate in micrometers + y: Y coordinate in micrometers + """ + await self.send_command(self._build_cut_abs(x, y)) + + async def cut_rel(self, dx: int, dy: int) -> None: + """ + Move by relative offset (cutting, laser on). + + Args: + dx: X offset in micrometers + dy: Y offset in micrometers + """ + await self.send_command(self._build_cut_rel(dx, dy)) + + async def move_rel_x(self, dx: int) -> None: + """ + Move X axis by relative offset (traversal, laser off). + + Args: + dx: X offset in micrometers + """ + await self.send_command(self._build_move_rel_x(dx)) + + async def move_rel_y(self, dy: int) -> None: + """ + Move Y axis by relative offset (traversal, laser off). + + Args: + dy: Y offset in micrometers + """ + await self.send_command(self._build_move_rel_y(dy)) + + async def cut_rel_x(self, dx: int) -> None: + """ + Move X axis by relative offset (cutting, laser on). + + Args: + dx: X offset in micrometers + """ + await self.send_command(self._build_cut_rel_x(dx)) + + async def cut_rel_y(self, dy: int) -> None: + """ + Move Y axis by relative offset (cutting, laser on). + + Args: + dy: Y offset in micrometers + """ + await self.send_command(self._build_cut_rel_y(dy)) + + async def rapid_move_xy( + self, x: int, y: int, origin: bool = False, light: bool = False + ) -> None: + """ + Rapid move XY by relative offset. + + Args: + x: X delta in micrometers + y: Y delta in micrometers + origin: Move relative to stored origin point + light: Enable laser pointer during move + """ + await self.send_command(self._build_rapid_move_xy(x, y, origin, light)) + + async def rapid_move_axis( + self, + axis: int, + coord: int, + origin: bool = False, + light: bool = False, + ) -> None: + """ + Rapid move on a single axis. + + Args: + axis: Axis number (0x10=X, 0x11=Y, 0x12=Z, 0x13=U) + coord: Coordinate in micrometers + origin: Move relative to stored origin point + light: Enable laser pointer during move + """ + await self.send_command( + self._build_rapid_move_axis(axis, coord, origin, light) + ) + + async def home_xy(self) -> None: + """Home the X and Y axes.""" + await self.send_command(self._build_home_xy()) + + async def home_z(self) -> None: + """Home the Z axis.""" + await self.send_command(self._build_home_z()) + + async def home_u(self) -> None: + """Home the U axis.""" + await self.send_command(self._build_home_u()) + + async def start_process(self) -> None: + """Start the laser cutting process.""" + await self.send_command(self._build_start_process()) + + async def stop_process(self) -> None: + """Stop the laser cutting process.""" + await self.send_command(self._build_stop_process()) + + async def pause_process(self) -> None: + """Pause the laser cutting process.""" + await self.send_command(self._build_pause_process()) + + async def resume_process(self) -> None: + """Resume the paused laser cutting process.""" + await self.send_command(self._build_resume_process()) + + async def set_ref_point_0(self) -> None: + """Set reference point 0 (current position).""" + await self.send_command(self._build_ref_point_0()) + + async def set_ref_point_1(self) -> None: + """Set reference point 1 (current position).""" + await self.send_command(self._build_ref_point_1()) + + async def set_ref_point_2(self) -> None: + """Set reference point 2 (machine zero/absolute position).""" + await self.send_command(self._build_ref_point_2()) + + async def set_absolute_mode(self) -> None: + """Set absolute coordinate mode.""" + await self.send_command(b"\xe6\x01") + + async def commit_ref_point(self) -> None: + """Commit the current reference point setting.""" + await self.send_command(b"\xf0") + + async def jog_start(self, axis: str, direction: int) -> None: + """ + Start continuous jog on an axis. + + Args: + axis: Axis name ('x', 'y', 'z', or 'u') + direction: Direction (1 for positive, -1 for negative) + """ + await self.send_command(self._build_jog_keydown(axis, direction)) + + async def jog_stop(self, axis: str) -> None: + """ + Stop continuous jog on an axis. + + Args: + axis: Axis name ('x', 'y', 'z', or 'u') + """ + await self.send_command(self._build_jog_keyup(axis)) + + async def jog_move_x(self, target_x: int) -> None: + """ + Rapid move X axis to absolute target position. + + Args: + target_x: Absolute X target in micrometers + """ + await self.send_command(self._build_rapid_move_axis(0x00, target_x)) + + async def jog_move_y(self, target_y: int) -> None: + """ + Rapid move Y axis to absolute target position. + + Args: + target_y: Absolute Y target in micrometers + """ + await self.send_command(self._build_rapid_move_axis(0x01, target_y)) + + async def set_power_immediate( + self, laser: int, power_percent: float + ) -> None: + """ + Set laser power immediately (takes effect right away). + + Args: + laser: Laser number (1-4) + power_percent: Power level (0.0 to 100.0) + """ + await self.send_command( + self._build_power_immediate(laser, power_percent) + ) + + async def set_power_end(self, laser: int, power_percent: float) -> None: + """ + Set laser power at end of move (takes effect after current move). + + Args: + laser: Laser number (1-4) + power_percent: Power level (0.0 to 100.0) + """ + await self.send_command(self._build_power_end(laser, power_percent)) + + async def set_speed(self, speed_mm_s: float) -> None: + """ + Set movement speed. + + Args: + speed_mm_s: Speed in millimeters per second + """ + await self.send_command(self._build_speed(speed_mm_s)) + + async def set_axis_speed(self, speed_mm_s: float) -> None: + """ + Set axis-specific speed. + + Args: + speed_mm_s: Speed in millimeters per second + """ + await self.send_command(self._build_axis_speed(speed_mm_s)) + + async def end_of_file(self) -> None: + """Send end-of-file marker.""" + await self.send_command(self._build_end_of_file()) + + async def keep_alive(self) -> None: + """Send keep-alive packet to maintain connection.""" + await self.send_command(self._build_keep_alive()) + + def _build_move_abs(self, x: int, y: int) -> bytes: + return b"\x88" + encode35(x) + encode35(y) + + def _build_move_rel(self, dx: int, dy: int) -> bytes: + return b"\x89" + encode14(dx) + encode14(dy) + + def _build_cut_abs(self, x: int, y: int) -> bytes: + return b"\xa8" + encode35(x) + encode35(y) + + def _build_cut_rel(self, dx: int, dy: int) -> bytes: + return b"\xa9" + encode14(dx) + encode14(dy) + + def _build_move_rel_x(self, dx: int) -> bytes: + return b"\x8a" + encode14(dx) + + def _build_move_rel_y(self, dy: int) -> bytes: + return b"\x8b" + encode14(dy) + + def _build_cut_rel_x(self, dx: int) -> bytes: + return b"\xaa" + encode14(dx) + + def _build_cut_rel_y(self, dy: int) -> bytes: + return b"\xab" + encode14(dy) + + def _build_rapid_move_xy( + self, x: int, y: int, origin: bool = False, light: bool = False + ) -> bytes: + opts = self._build_move_opts(origin, light) + return b"\xd9\x10" + bytes([opts]) + encode35(x) + encode35(y) + + def _build_rapid_move_axis( + self, + axis: int, + coord: int, + origin: bool = False, + light: bool = False, + ) -> bytes: + opts = self._build_move_opts(origin, light) + return b"\xd9" + bytes([axis & 0x0F]) + bytes([opts]) + encode35(coord) + + def _build_move_opts(self, origin: bool, light: bool) -> int: + if origin and light: + return 0x01 + elif origin: + return 0x00 + elif light: + return 0x03 + return 0x02 + + def _build_home_xy(self) -> bytes: + return b"\xd8\x2a" + + def _build_home_z(self) -> bytes: + return b"\xd8\x2c" + + def _build_home_u(self) -> bytes: + return b"\xd8\x2d" + + def _build_start_process(self) -> bytes: + return b"\xd8\x00" + + def _build_stop_process(self) -> bytes: + return b"\xd8\x01" + + def _build_pause_process(self) -> bytes: + return b"\xd8\x02" + + def _build_resume_process(self) -> bytes: + return b"\xd8\x03" + + def _build_ref_point_0(self) -> bytes: + return b"\xd8\x12" + + def _build_ref_point_1(self) -> bytes: + return b"\xd8\x11" + + def _build_ref_point_2(self) -> bytes: + return b"\xd8\x10" + + def _build_jog_keydown(self, axis: str, direction: int) -> bytes: + axis_map = { + ("x", -1): 0x20, + ("x", 1): 0x21, + ("y", 1): 0x22, + ("y", -1): 0x23, + ("z", 1): 0x24, + ("z", -1): 0x25, + ("u", 1): 0x26, + ("u", -1): 0x27, + } + key = (axis.lower(), direction) + if key not in axis_map: + raise ValueError(f"Invalid axis/direction: {axis}, {direction}") + return b"\xd8" + bytes([axis_map[key]]) + + def _build_jog_keyup(self, axis: str) -> bytes: + axis_map = { + "x": 0x30, + "y": 0x32, + "z": 0x34, + "u": 0x36, + } + if axis.lower() not in axis_map: + raise ValueError(f"Invalid axis: {axis}") + return b"\xd8" + bytes([axis_map[axis.lower()]]) + + def _build_power_immediate( + self, laser: int, power_percent: float + ) -> bytes: + power_val = int(power_percent * 163.84) + laser_map = {1: 0xC7, 2: 0xC0, 3: 0xC2, 4: 0xC3} + if laser not in laser_map: + raise ValueError(f"Invalid laser: {laser}") + return bytes([laser_map[laser]]) + encode14(power_val) + + def _build_power_end(self, laser: int, power_percent: float) -> bytes: + power_val = int(power_percent * 163.84) + laser_map = {1: 0xC8, 2: 0xC1, 3: 0xC4, 4: 0xC5} + if laser not in laser_map: + raise ValueError(f"Invalid laser: {laser}") + return bytes([laser_map[laser]]) + encode14(power_val) + + def _build_speed(self, speed_mm_s: float) -> bytes: + speed_val = int(speed_mm_s * 1000) + return b"\xc9\x02" + encode35(speed_val) + + def _build_frequency(self, laser: int, frequency: int) -> bytes: + return b"\xc6\x60" + bytes([laser, 0]) + encode35(frequency) + + def _build_pulse_width(self, laser: int, pulse_width_us: int) -> bytes: + return b"\xc6\x10" + bytes([laser, 0]) + encode35(pulse_width_us) + + def _build_axis_speed(self, speed_mm_s: float) -> bytes: + speed_val = int(speed_mm_s * 1000) + return b"\xc9\x03" + encode35(speed_val) + + def _build_end_of_file(self) -> bytes: + return b"\xd7" + + def _build_keep_alive(self) -> bytes: + return b"\xce" + + def _build_ack(self) -> bytes: + return b"\xcc" + + def _build_error(self) -> bytes: + return b"\xcd" + + async def air_assist_on(self) -> None: + """Enable air assist.""" + await self.send_command(b"\xca\x13") + + async def air_assist_off(self) -> None: + """Disable air assist.""" + await self.send_command(b"\xca\x12") + + async def select_layer(self, layer_index: int) -> None: + """ + Select layer by index (0-15). + + Args: + layer_index: Layer index (0-15). + """ + if not 0 <= layer_index <= 15: + raise ValueError(f"Layer index must be 0-15, got {layer_index}") + await self.send_command(bytes([0xCA, layer_index])) + + async def send_raw(self, data: bytes) -> None: + """ + Send raw binary data (already framed/swizzled). + + Args: + data: Raw binary data to send. + """ + await self._transport.send(data) + + def _build_read_memory(self, mem_address: int) -> bytes: + """ + Build command to read from controller memory. + + Args: + mem_address: Memory address (e.g., 0x0421 for Current X) + + Returns: + Command bytes to send + """ + mem_high = (mem_address >> 8) & 0xFF + mem_low = mem_address & 0xFF + return bytes([0xDA, 0x00, mem_high, mem_low]) + + async def _read_memory(self, mem_address: int) -> None: + """ + Send a memory read request to the controller. + + Args: + mem_address: Memory address to read (e.g., 0x0421 for Current X) + """ + await self.send_command(self._build_read_memory(mem_address)) + + async def get_position(self) -> tuple[int, int, int]: + """ + Request current X, Y, Z position from controller. + + Sends memory read commands for position registers. + Position values will be returned asynchronously via the + decoded_received signal. + + Returns: + Tuple of (x, y, z) in micrometers + (may be stale until response received) + """ + await self._read_memory(0x0421) + await self._read_memory(0x0431) + await self._read_memory(0x0441) + return (self.state.x, self.state.y, self.state.z) + + def _build_write_memory(self, mem_address: int, value: int) -> bytes: + """ + Build command to write to controller memory. + + Args: + mem_address: Memory address (e.g., 0x0224 for Position Point 0 X) + value: Value to write in micrometers + + Returns: + Command bytes to send + """ + mem_high = (mem_address >> 8) & 0xFF + mem_low = mem_address & 0xFF + encoded_value = encode35(value) + return ( + bytes([0xDA, 0x01, mem_high, mem_low]) + + encoded_value + + encoded_value + ) + + async def _write_memory(self, mem_address: int, value: int) -> None: + """ + Write a value to controller memory. + + Args: + mem_address: Memory address to write + value: Value to write (will be encoded as 35-bit signed) + """ + await self.send_command(self._build_write_memory(mem_address, value)) + + async def _read_memory_wait( + self, mem_address: int, timeout: float = 2.0 + ) -> int | None: + """ + Read a value from controller memory and wait for response. + + Args: + mem_address: Memory address to read + timeout: Maximum time to wait for response in seconds + + Returns: + Decoded value or None if timeout + """ + loop = asyncio.get_event_loop() + future = loop.create_future() + self._pending_mem_reads[mem_address] = future + + try: + await self._read_memory(mem_address) + return await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + self._pending_mem_reads.pop(mem_address, None) + logger.warning(f"Timeout reading memory 0x{mem_address:04X}") + return None + + async def set_ref_point_offset( + self, ref_point: str, x_um: int, y_um: int + ) -> None: + """ + Set the offset for a reference point. + + Args: + ref_point: "REF0" or "REF1" + x_um: X offset in micrometers + y_um: Y offset in micrometers + """ + if ref_point not in REF_POINT_OFFSET_ADDRESSES: + raise ValueError(f"Unknown reference point: {ref_point}") + + x_addr, y_addr = REF_POINT_OFFSET_ADDRESSES[ref_point] + await self._write_memory(x_addr, x_um) + await self._write_memory(y_addr, y_um) + + async def get_ref_point_offset( + self, ref_point: str + ) -> tuple[int, int] | None: + """ + Get the offset for a reference point. + + Args: + ref_point: "REF0" or "REF1" + + Returns: + Tuple of (x_um, y_um) or None if read failed + """ + if ref_point not in REF_POINT_OFFSET_ADDRESSES: + raise ValueError(f"Unknown reference point: {ref_point}") + + x_addr, y_addr = REF_POINT_OFFSET_ADDRESSES[ref_point] + x_um = await self._read_memory_wait(x_addr) + y_um = await self._read_memory_wait(y_addr) + + if x_um is None or y_um is None: + return None + return (x_um, y_um) + + @property + def ref_points(self) -> tuple[str, ...]: + """Return tuple of valid reference point names including MACHINE.""" + return ("MACHINE",) + tuple(REF_POINT_OFFSET_ADDRESSES.keys()) + + async def select_ref_point(self, ref_point: str) -> None: + """ + Select a reference point mode on the controller. + + Args: + ref_point: "MACHINE", "REF0", or "REF1" + """ + if ref_point not in REF_POINT_COMMANDS: + raise ValueError(f"Unknown reference point: {ref_point}") + await self.send_command(REF_POINT_COMMANDS[ref_point]) + self._ref_point_mode = ref_point + + async def get_ref_point_mode(self) -> str | None: + """ + Get the current reference point mode. + + The ref point mode cannot be read back from the controller + (no valid DA memory address exists for it), so this returns + the locally tracked mode set via select_ref_point. + + Returns: + "MACHINE", "REF0", "REF1", or None if not yet set + """ + return self._ref_point_mode + + async def get_card_id(self) -> int | None: + """ + Get the card ID from the controller. + + Returns: + Card ID (e.g., 0x65106510) or None if read failed + """ + return await self._read_memory_wait(CARD_ID_ADDRESS) + + async def get_model_name(self) -> str | None: + """ + Get the controller model name. + + Returns: + Model name (e.g., "RDC6442S") or None if unknown/read failed + """ + card_id = await self.get_card_id() + if card_id is None: + return None + + return CARD_ID_TO_MODEL.get(card_id, f"Unknown (0x{card_id:08X})") + + async def get_card_info( + self, + ) -> tuple[int | None, str | None] | None: + """ + Get card ID and model name from the controller. + + Returns: + Tuple of (card_id, model_name) or None if read failed. + model_name may be None if card_id is unknown. + """ + card_id = await self.get_card_id() + if card_id is None: + return None + + model_name = CARD_ID_TO_MODEL.get(card_id) + return (card_id, model_name) diff --git a/rayforge/machine/driver/ruida/ruida_codec.py b/rayforge/machine/driver/ruida/ruida_codec.py new file mode 100644 index 000000000..794f49ae0 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_codec.py @@ -0,0 +1,85 @@ +""" +Ruida codec for swizzle encoding/decoding with magic key management. + +The magic key determines the swizzle transformation. It can be +detected from certain packets (card ID queries) or set explicitly. +""" + +import logging + +from .ruida_maps import CARD_ID_TO_MAGIC +from .ruida_util import build_swizzle_lut, parse_mem + +logger = logging.getLogger(__name__) + + +class RuidaCodec: + """ + Handles swizzle encoding/decoding with magic key management. + + The magic key determines the swizzle transformation. It can be + detected from certain packets (card ID queries) or set explicitly. + """ + + def __init__(self, magic: int = 0x88): + self.magic = magic + self._swizzle_lut, self._unswizzle_lut = build_swizzle_lut(magic) + self._magic_keys = self._build_magic_keys() + + def _build_magic_keys(self) -> dict[bytes, int]: + """Build lookup table for magic key detection from 4-byte packets.""" + keys = {} + for g in range(256): + swiz, _ = build_swizzle_lut(g) + keys[bytes([swiz[b] for b in b"\xda\x00\x05\x7e"])] = g + return keys + + def set_magic(self, magic: int) -> bool: + """ + Set the magic key for swizzle encoding. + + Returns True if magic changed. + """ + if magic != self.magic: + self.magic = magic + self._swizzle_lut, self._unswizzle_lut = build_swizzle_lut(magic) + logger.info(f"Magic key changed to 0x{magic:02X}") + return True + return False + + def swizzle(self, data: bytes) -> bytes: + """Encode data for transmission.""" + return bytes([self._swizzle_lut[b] for b in data]) + + def unswizzle(self, data: bytes) -> bytes: + """Decode received data.""" + return bytes([self._unswizzle_lut[b] for b in data]) + + def detect_magic_from_payload(self, payload: bytes) -> int | None: + """ + Try to detect magic key from a swizzled payload. + + Returns detected magic or None. + """ + if len(payload) == 4: + return self._magic_keys.get(payload) + return None + + def detect_magic_from_mem_request(self, unswizzled: bytes) -> int | None: + """ + Detect magic key from DA memory read requests. + + This is a secondary detection mechanism for certain edge cases + where the memory address itself encodes card ID information. + + Returns detected magic or None. + """ + if ( + len(unswizzled) >= 4 + and unswizzled[0] == 0xDA + and unswizzled[1] == 0x00 + ): + mem = parse_mem(unswizzled[2:4]) + if mem in CARD_ID_TO_MAGIC: + return CARD_ID_TO_MAGIC[mem] + return None diff --git a/rayforge/machine/driver/ruida/ruida_driver.py b/rayforge/machine/driver/ruida/ruida_driver.py new file mode 100644 index 000000000..261de80ce --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_driver.py @@ -0,0 +1,657 @@ +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from dataclasses import replace +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, +) + +from ....context import RayforgeContext +from ....core.varset import HostnameVar, PortVar, VarSet +from ....core.varset.hostnamevar import is_valid_hostname_or_ip +from ....pipeline.encoder.base import EncodedOutput, OpsEncoder +from ...models.coordinate_system import CoordinateSystem +from ...models.laser import LaserHead, LaserType +from ...transport import TransportStatus +from ...transport.udp import UdpTransport +from ..driver import ( + Axis, + DeviceStatus, + Driver, + DriverMaturity, + DriverPrecheckError, + DriverSetupError, + Pos, + PWMParams, +) +from .ruida_client import RuidaClient +from .ruida_encoder import RuidaEncoder +from .ruida_transport import RuidaTransport + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ....core.doc import Doc + from ...models.head import Head + from ...models.laser import Laser + from ...models.machine import Machine + + +logger = logging.getLogger(__name__) + + +class RuidaDriver(Driver): + """ + Driver for Ruida laser controllers using UDP protocol. + + Implements the Driver interface with unit conversion (mm ↔ µm) + and uses RuidaClient for communication with the controller. + """ + + label = _("Ruida (UDP)") + subtitle = _("Connect to a Ruida laser controller over UDP") + supports_settings = False + reports_granular_progress = False + uses_gcode = False + maturity = DriverMaturity.KNOWN_BUGGY + native_overscan = True + CONNECTION_TIMEOUT = 2.0 + RECONNECT_INTERVAL = 5.0 + KEEPALIVE_INTERVAL = 1.0 + POSITION_POLL_INTERVAL = 0.5 + RESPONSE_PORT = 40200 + CHUNK_SIZE = 1024 + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self.host = None + self.port = None + self.jog_port = None + self._udp_transport = None + self._ruida_transport = None + self._jog_udp_transport = None + self._client = None + self._response_received = asyncio.Event() + self._connection_task: asyncio.Task | None = None + self._keep_running = False + self._is_connected = False + + @property + def machine_space_wcs(self) -> str: + return "MACHINE" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates") + + @property + def supported_wcs(self) -> list[str]: + if not self._client: + return [self.machine_space_wcs] + return list(self._client.ref_points) + + @property + def resource_uri(self) -> str | None: + if self.host: + return f"udp://{self.host}:{self.port} (jog: {self.jog_port})" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + host = kwargs.get("host", "") + if not is_valid_hostname_or_ip(host): + raise DriverPrecheckError( + _("Invalid hostname or IP address: '{host}'").format(host=host) + ) + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + HostnameVar( + key="host", + label=_("Hostname"), + description=_( + "The IP address or hostname of the Ruida controller" + ), + ), + PortVar( + key="port", + label=_("Main Port"), + description=_( + "The UDP port for main commands (default: 50200)" + ), + default=50200, + ), + PortVar( + key="jog_port", + label=_("Jog Port"), + description=_( + "The UDP port for jog commands (default: 50207)" + ), + default=50207, + ), + ] + ) + + def supports_pwm(self, head: "Head") -> bool: + return ( + isinstance(head, LaserHead) and head.laser_type != LaserType.DIODE + ) + + def get_pwm_params(self, head: "Head") -> PWMParams | None: + if not isinstance(head, LaserHead) or not self.supports_pwm(head): + return None + return PWMParams( + frequency=head.pwm_frequency, + max_frequency=head.max_pwm_frequency, + pulse_width=head.pulse_width, + min_pulse_width=head.min_pulse_width, + max_pulse_width=head.max_pulse_width, + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + return RuidaEncoder() + + def _setup_implementation(self, **kwargs: Any) -> None: + host = kwargs.get("host", "") + port = kwargs.get("port", 50200) + jog_port = kwargs.get("jog_port", 50207) + response_port = kwargs.get("response_port", self.RESPONSE_PORT) + if not host: + raise DriverSetupError(_("Hostname must be configured.")) + + self.host = host + self.port = port + self.jog_port = jog_port + + self._udp_transport = UdpTransport( + host, port, local_port=response_port + ) + self._ruida_transport = RuidaTransport(self._udp_transport) + self._jog_udp_transport = UdpTransport(host, jog_port) + self._jog_ruida_transport = RuidaTransport(self._jog_udp_transport) + self._client = RuidaClient( + self._ruida_transport, + jog_transport=self._jog_ruida_transport, + ) + + self._client.state_changed.connect(self._on_state_changed) + self._ruida_transport.status_changed.connect(self._on_status_changed) + self._client.position_updated.connect(self._on_position_updated) + + self._init_coordinate_systems() + + def _init_coordinate_systems(self) -> None: + """ + Initialize the machine's coordinate systems to match the + Ruida controller's ref point model (MACHINE, REF0, REF1). + """ + m = self._machine + supported = self.supported_wcs + existing = m.coordinate_systems + + new_systems = {} + for name in supported: + if name in existing: + new_systems[name] = existing[name] + else: + new_systems[name] = CoordinateSystem(name=name) + + m.coordinate_systems = new_systems + if m.active_wcs not in new_systems: + m.active_wcs = supported[0] + + async def cleanup(self): + self._keep_running = False + self._is_connected = False + + if self._connection_task: + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + self._connection_task = None + + if self._ruida_transport: + self._ruida_transport.status_changed.disconnect( + self._on_status_changed + ) + if self._client: + self._client.state_changed.disconnect(self._on_state_changed) + self._client.position_updated.disconnect(self._on_position_updated) + await self._client.disconnect() + if self._jog_udp_transport: + await self._jog_udp_transport.disconnect() + if self._ruida_transport: + await self._ruida_transport.disconnect() + self._jog_udp_transport = None + self._ruida_transport = None + self._udp_transport = None + self._client = None + self._update_connection_status(TransportStatus.DISCONNECTED, "") + await super().cleanup() + + async def _connect_implementation(self) -> None: + if not self.host: + self._update_connection_status( + TransportStatus.DISCONNECTED, "No host configured" + ) + return + + if self._connection_task and not self._connection_task.done(): + logger.warning("Connect called with active connection task") + return + + self._keep_running = True + self._connection_task = asyncio.create_task(self._connection_loop()) + + async def _connection_loop(self) -> None: + logger.debug("Entering Ruida connection loop") + while self._keep_running: + self._update_connection_status(TransportStatus.CONNECTING) + self._is_connected = False + + try: + if not self._client: + raise DriverSetupError("Client not initialized") + + await self._client.connect() + + self._response_received.clear() + await self._client.keep_alive() + + try: + await asyncio.wait_for( + self._response_received.wait(), + timeout=self.CONNECTION_TIMEOUT, + ) + except asyncio.TimeoutError: + self._update_connection_status( + TransportStatus.ERROR, + _("No response from controller"), + ) + await self._disconnect_transports() + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(self.RECONNECT_INTERVAL) + continue + + self._is_connected = True + self._update_connection_status(TransportStatus.CONNECTED, "") + self.state.status = DeviceStatus.IDLE + self.state_changed.send(self, state=self.state) + + logger.info( + f"Connected to Ruida controller " + f"at {self.host}:{self.port}", + extra=self._log_extra("MACHINE_EVENT"), + ) + + asyncio.create_task( + self._fetch_card_info(), + name="ruida-fetch-card-info", + ) + + last_poll_time = 0.0 + last_ref_poll_time = 0.0 + + while self._keep_running and self._is_connected: + current_time = asyncio.get_event_loop().time() + + if ( + current_time - last_poll_time + >= self.POSITION_POLL_INTERVAL + ): + self._response_received.clear() + await self._poll_position() + last_poll_time = current_time + + try: + await asyncio.wait_for( + self._response_received.wait(), + timeout=self.CONNECTION_TIMEOUT, + ) + except asyncio.TimeoutError: + logger.warning( + "Controller stopped responding, reconnecting", + extra=self._log_extra("MACHINE_EVENT"), + ) + self._is_connected = False + await self._disconnect_transports() + break + + if current_time - last_ref_poll_time >= 2.0: + await self._poll_ref_point_mode() + last_ref_poll_time = current_time + + await asyncio.sleep(self.KEEPALIVE_INTERVAL) + + except asyncio.CancelledError: + logger.debug("Connection loop cancelled") + break + except Exception as e: # noqa: BLE001 - connection loop boundary + logger.error(f"Connection error: {e}") + self._update_connection_status(TransportStatus.ERROR, str(e)) + await self._disconnect_transports() + + if self._keep_running: + self._update_connection_status(TransportStatus.SLEEPING) + await asyncio.sleep(self.RECONNECT_INTERVAL) + + logger.debug("Exiting Ruida connection loop") + + async def _disconnect_transports(self) -> None: + if self._client: + try: + await self._client.disconnect() + except OSError as e: + logger.debug(f"Error disconnecting client: {e}") + if self._jog_udp_transport: + try: + await self._jog_udp_transport.disconnect() + except OSError as e: + logger.debug(f"Error disconnecting jog transport: {e}") + if self._ruida_transport: + try: + await self._ruida_transport.disconnect() + except OSError as e: + logger.debug(f"Error disconnecting ruida transport: {e}") + + async def _poll_position(self) -> None: + """Poll current position from controller.""" + if not self._client or not self._is_connected: + return + + try: + logger.debug("Polling position from controller") + await self._client.get_position() + except (OSError, asyncio.TimeoutError) as e: + logger.debug(f"Error polling position: {e}") + + async def _poll_ref_point_mode(self) -> None: + """Poll current ref point mode from controller.""" + if not self._client or not self._is_connected: + return + + try: + mode = await self._client.get_ref_point_mode() + if mode and mode != self._machine.active_wcs: + logger.debug(f"Ref point mode changed: {mode}") + self._machine.set_active_wcs(mode) + except (OSError, asyncio.TimeoutError) as e: + logger.debug(f"Error polling ref point mode: {e}") + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + binary_data = encoded.driver_data.get("binary", b"") + text_lines = [ + line.strip() for line in encoded.text.splitlines() if line.strip() + ] + op_map = encoded.op_map + + if on_command_done is not None: + num_ops = op_map.op_count if op_map else 0 + + for op_index in range(num_ops): + result = on_command_done(op_index) + if inspect.isawaitable(result): + await result + + logger.info( + f"Executing {len(text_lines)} commands", + extra=self._log_extra("USER_COMMAND"), + ) + + for line in text_lines: + logger.info(line, extra=self._log_extra("USER_COMMAND")) + + if binary_data and self._client: + for i in range(0, len(binary_data), self.CHUNK_SIZE): + chunk = binary_data[i : i + self.CHUNK_SIZE] + await self._client.send_command(chunk) + + self.job_finished.send(self) + + async def run_raw(self, machine_code: str) -> None: + """ + Ruida controllers use binary protocol, not text-based machine code. + + This method logs a warning and does nothing. Use run() with + properly encoded Ruida binary data instead. + """ + if machine_code and machine_code.strip(): + logger.warning( + "Ruida controllers do not support text-based machine code. " + "Use run() with EncodedOutput instead." + ) + self.job_finished.send(self) + + async def set_hold(self, hold: bool = True) -> None: + assert self._client + if hold: + await self._client.pause_process() + else: + await self._client.resume_process() + + async def cancel(self) -> None: + assert self._client + await self._client.stop_process() + + def can_home(self, axis: Axis | None = None) -> bool: + return True + + async def home(self, axes: Axis | None = None) -> None: + assert self._client + if axes is None: + logger.info("Home All", extra=self._log_extra("MACHINE_EVENT")) + else: + cmd_parts = [] + if axes & (Axis.X | Axis.Y): + cmd_parts.append("XY") + if axes & Axis.Z: + cmd_parts.append("Z") + cmd_name = f"Home {'/'.join(cmd_parts)}" + logger.info(cmd_name, extra=self._log_extra("MACHINE_EVENT")) + await self._rapid_move_to(0, 0) + + async def move_to(self, pos_x: float, pos_y: float) -> None: + assert self._client + logger.info( + f"move_to x={pos_x:.2f} y={pos_y:.2f}", + extra=self._log_extra("MACHINE_EVENT"), + ) + x_um = int(pos_x * 1000) + y_um = int(pos_y * 1000) + await self._rapid_move_to(x_um, y_um) + + async def _rapid_move_to(self, target_x: int, target_y: int) -> None: + assert self._client + cur_x = await self._client._read_memory_wait(0x0421) + cur_y = await self._client._read_memory_wait(0x0431) + dx = target_x - (cur_x or 0) + dy = target_y - (cur_y or 0) + if dx != 0: + await self._client.rapid_move_axis(0x00, dx) + if dy != 0: + await self._client.rapid_move_axis(0x01, dy) + + async def select_tool(self, tool_number: int) -> None: + pass + + async def read_settings(self) -> None: + await asyncio.sleep(0) + self.settings_read.send(self, settings=[]) + + def get_setting_vars(self) -> list["VarSet"]: + return [VarSet(title=_("No settings"))] + + async def write_setting(self, key: str, value: Any) -> None: + pass + + async def clear_alarm(self) -> None: + assert self._client + await self._client.stop_process() + + async def set_power(self, head: "Laser", percent: float) -> None: + assert self._client + power_percent = percent * 100 + laser_num = head.tool_number + 1 + await self._client.set_power_immediate(laser_num, power_percent) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + await self.set_power(head, percent) + + def can_jog(self, axis: Axis | None = None) -> bool: + return True + + async def jog(self, speed: int, **deltas: float) -> None: + assert self._client + for axis_name, delta in deltas.items(): + axis_lower = axis_name.lower() + delta_um = int(delta * 1000) + if axis_lower == "x": + await self._client.rapid_move_axis(0x00, delta_um) + elif axis_lower == "y": + await self._client.rapid_move_axis(0x01, delta_um) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + """ + Set a reference point offset on the controller. + + Args: + wcs_slot: "REF0" or "REF1" + x, y, z: Offset in mm (z ignored, Ruida is 2D) + """ + if wcs_slot == "MACHINE": + return + + if not self._client: + return + + if wcs_slot not in self._client.ref_points: + logger.warning(f"Unknown WCS slot: {wcs_slot}") + return + + x_um = int(x * 1000) + y_um = int(y * 1000) + + await self._client.set_ref_point_offset(wcs_slot, x_um, y_um) + self.wcs_updated.send(self, offsets={wcs_slot: (x, y, z)}) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + """ + Read reference point offsets from the controller. + + Returns offsets for REF0 and REF1 in mm. MACHINE is always zero. + """ + offsets: dict[str, Pos] = {"MACHINE": (0.0, 0.0, 0.0)} + + if not self._client or not self._is_connected: + self.wcs_updated.send(self, offsets=offsets) + return offsets + + for ref_point in self._client.ref_points: + if ref_point == "MACHINE": + continue + result = await self._client.get_ref_point_offset(ref_point) + if result is not None: + x_um, y_um = result + offsets[ref_point] = (x_um / 1000.0, y_um / 1000.0, 0.0) + + self.wcs_updated.send(self, offsets=offsets) + return offsets + + async def read_parser_state(self) -> str | None: + if not self._client or not self._is_connected: + return None + return await self._client.get_ref_point_mode() + + async def select_wcs(self, wcs: str) -> None: + """ + Select a reference point mode on the controller. + + Args: + wcs: "REF0", "REF1", or "MACHINE" + """ + if not self._client: + return + await self._client.select_ref_point(wcs) + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + self.probe_status_changed.send(self, message="Probe not supported") + return None + + async def _fetch_card_info(self) -> None: + if not self._client: + return + try: + card_info = await self._client.get_card_info() + if card_info: + card_id, model_name = card_info + device = ( + f"{model_name or 'Ruida controller'} " + f"(Card ID: 0x{card_id:08X})" + ) + else: + device = "Ruida controller" + logger.info( + f"Identified: {device}", + extra=self._log_extra("MACHINE_EVENT"), + ) + except (OSError, asyncio.TimeoutError) as e: + logger.debug(f"Could not fetch card info: {e}") + + def _on_state_changed(self, sender) -> None: + self._response_received.set() + + def _on_position_updated(self, sender, axis: str, value_um: int) -> None: + """Handle position update from client.""" + pos_mm = value_um / 1000.0 + current_pos = self.state.machine_pos + + if axis == "x": + new_pos = (pos_mm, current_pos[1], current_pos[2]) + elif axis == "y": + new_pos = (current_pos[0], pos_mm, current_pos[2]) + elif axis == "z": + new_pos = (current_pos[0], current_pos[1], pos_mm) + else: + return + + if new_pos != current_pos: + self.state = replace(self.state, machine_pos=new_pos) + logger.debug( + f"Position update: {axis}={pos_mm:.3f}mm, " + f"machine_pos={self.state.machine_pos}" + ) + self.state_changed.send(self, state=self.state) + + def _on_status_changed( + self, sender, status: TransportStatus, message: str = "" + ) -> None: + self._update_connection_status(status, message) + + def _update_connection_status( + self, status: TransportStatus, message: str = "" + ) -> None: + self.connection_status_changed.send( + self, status=status, message=message + ) + + @property + def is_connected(self) -> bool: + return self._is_connected diff --git a/rayforge/machine/driver/ruida/ruida_encoder.py b/rayforge/machine/driver/ruida/ruida_encoder.py new file mode 100644 index 000000000..a62132326 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_encoder.py @@ -0,0 +1,445 @@ +""" +Ruida Encoder - Converts Ops commands to Ruida binary protocol. + +Produces both binary output for the controller and human-readable +text representation for UI display. +""" + +import logging +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point3D +from raygeo.ops import Ops +from raygeo.ops.state import AirAssistMode +from raygeo.ops.types import CommandType + +from ....pipeline.encoder.base import ( + EncodedOutput, + MachineCodeOpMap, + OpsEncoder, +) +from .ruida_maps import REF_POINT_COMMANDS +from .ruida_util import encode14, encode35 + +if TYPE_CHECKING: + from ....core.doc import Doc + from ....machine.models.machine import Machine + +logger = logging.getLogger(__name__) + + +class RuidaEncoder(OpsEncoder): + """ + Converts Ops commands to Ruida binary protocol. + + This encoder produces: + - Binary data for transmission to Ruida controllers + - Human-readable text for UI display + + Coordinates are converted from mm to micrometers (µm) internally. + Power is converted from normalized (0.0-1.0) to percentage (0-100) + and then to the 14-bit value expected by Ruida (0-16384). + """ + + UM_PER_MM = 1000.0 + POWER_SCALE = 16384.0 + + def __init__(self): + self.power: float | None = None + self.cut_speed: float | None = None + self.travel_speed: float | None = None + self.air_assist: bool = False + self.current_pos: Point3D = (0.0, 0.0, 0.0) + self.active_laser: int = 1 + + def encode( + self, ops: Ops, machine: "Machine", doc: "Doc" + ) -> EncodedOutput: + """ + Encode Ops commands to Ruida binary format. + + Args: + ops: The Ops object containing commands to encode + machine: The machine configuration + doc: The document being processed + + Returns: + EncodedOutput with binary in driver_data["binary"], + text representation, and op_map + """ + self._reset_state() + + binary_chunks: list[bytes] = [] + text_lines: list[str] = [] + line_spans: list[tuple[int, int]] = [] + + for i in range(ops.len()): + start_line = len(text_lines) + self._handle_command(ops, i, machine, binary_chunks, text_lines) + end_line = len(text_lines) + line_spans.append((start_line, end_line - start_line)) + + binary_data = b"".join(binary_chunks) + + if text_lines and not text_lines[-1]: + text_lines = text_lines[:-1] + + machine_code_to_op = [-1] * len(text_lines) + for i, (start_line, line_count) in enumerate(line_spans): + for line_num in range(start_line, start_line + line_count): + if line_num < len(machine_code_to_op): + machine_code_to_op[line_num] = i + op_map = MachineCodeOpMap.from_lists(line_spans, machine_code_to_op) + + return EncodedOutput( + text="\n".join(text_lines), + op_map=op_map, + driver_data={"binary": binary_data}, + ) + + def _reset_state(self) -> None: + """Reset encoder state for a new encoding pass.""" + self.power = None + self.cut_speed = None + self.travel_speed = None + self.air_assist = False + self.current_pos = (0.0, 0.0, 0.0) + self.active_laser = 1 + + def _mm_to_um(self, mm: float) -> int: + """Convert millimeters to micrometers.""" + return int(mm * self.UM_PER_MM) + + def _power_to_ruida(self, power_normalized: float) -> int: + """Convert normalized power (0.0-1.0) to Ruida 14-bit value.""" + return int(power_normalized * self.POWER_SCALE) & 0x3FFF + + def _handle_command( + self, + ops: Ops, + idx: int, + machine: "Machine", + binary: list[bytes], + text: list[str], + ) -> None: + """Dispatch command to appropriate handler.""" + ct = ops.command_type(idx) + + if ct == CommandType.SET_POWER: + self._handle_set_power(ops, idx, binary, text) + elif ct == CommandType.SET_FEED_RATE: + self._handle_set_cut_speed(ops, idx, binary, text) + elif ct == CommandType.SET_RAPID_RATE: + self._handle_set_travel_speed(ops, idx, binary, text) + elif ct == CommandType.SET_FREQUENCY: + self._handle_set_frequency(ops, idx, binary, text) + elif ct == CommandType.SET_PULSE_WIDTH: + self._handle_set_pulse_width(ops, idx, binary, text) + elif ct == CommandType.SET_AIR_ASSIST: + self._handle_air_assist(ops, idx, binary, text) + elif ct == CommandType.SET_COOLANT: + self._handle_coolant(ops, idx, binary, text) + elif ct == CommandType.SET_HEAD: + self._handle_set_laser(ops, idx, machine, binary, text) + elif ct == CommandType.MOVE_TO: + self._handle_move_to(ops, idx, binary, text) + self.current_pos = ops.endpoint(idx) + elif ct == CommandType.LINE_TO: + self._handle_line_to(ops, idx, binary, text) + self.current_pos = ops.endpoint(idx) + elif ct == CommandType.ARC_TO: + self._handle_arc_to(ops, idx, binary, text) + self.current_pos = ops.endpoint(idx) + elif ct == CommandType.SCAN_LINE: + self._handle_scan_line(ops, idx, binary, text) + self.current_pos = ops.endpoint(idx) + elif ct == CommandType.JOB_START: + self._handle_job_start(machine, binary, text) + elif ct == CommandType.JOB_END: + self._handle_job_end(binary, text) + elif ct == CommandType.LAYER_START: + self._handle_layer_start(ops, idx, binary, text) + elif ct == CommandType.LAYER_END: + self._handle_layer_end(ops, idx, binary, text) + elif ct == CommandType.WORKPIECE_START: + self._handle_workpiece_start(ops, idx, text) + elif ct == CommandType.WORKPIECE_END: + self._handle_workpiece_end(ops, idx, text) + + def _handle_set_power( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetPowerCommand - set laser power percentage.""" + power = ops.power(idx) + self.power = power + power_val = self._power_to_ruida(power) + power_percent = power * 100.0 + + laser_cmd = {1: 0xC8, 2: 0xC1, 3: 0xC4, 4: 0xC5} + cmd_byte = laser_cmd.get(self.active_laser, 0xC8) + binary.append(bytes([cmd_byte]) + encode14(power_val)) + text.append(f"POWER {power_percent:.1f}") + + def _handle_set_cut_speed( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetCutSpeedCommand - set cutting speed in mm/s.""" + speed = ops.rate(idx) + self.cut_speed = speed + speed_um = self._mm_to_um(speed) + binary.append(b"\xc9\x02" + encode35(speed_um)) + text.append(f"SPEED {speed:.1f}") + + def _handle_set_travel_speed( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetTravelSpeedCommand - store for move operations.""" + speed = ops.rate(idx) + self.travel_speed = speed + if self.travel_speed is not None: + speed_um = self._mm_to_um(speed) + binary.append(b"\xc9\x02" + encode35(speed_um)) + text.append(f"TRAVEL_SPEED {speed:.1f}") + + def _handle_set_frequency( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetFrequencyCommand - emit 0xC6 0x60 frequency.""" + freq = ops.frequency(idx) + binary.append( + b"\xc6\x60" + bytes([self.active_laser, 0]) + encode35(freq) + ) + text.append(f"FREQUENCY {freq}") + + def _handle_set_pulse_width( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetPulseWidthCommand - emit 0xC6 0x10 interval.""" + pw = ops.pulse_width(idx) + pulse_us = int(pw) + binary.append( + b"\xc6\x10" + bytes([self.active_laser, 0]) + encode35(pulse_us) + ) + text.append(f"PULSE_WIDTH {pw:.1f}") + + def _handle_air_assist( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetAirAssistCommand - update air assist state.""" + mode = ops.air_assist(idx) + if mode == AirAssistMode.ON: + if not self.air_assist: + self.air_assist = True + binary.append(b"\xca\x13") + text.append("AIR_ASSIST ON") + else: + if self.air_assist: + self.air_assist = False + binary.append(b"\xca\x12") + text.append("AIR_ASSIST OFF") + + def _handle_coolant( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetCoolantCommand - coolant not used on laser cutters.""" + + def _handle_set_laser( + self, + ops: Ops, + idx: int, + machine: "Machine", + binary: list[bytes], + text: list[str], + ) -> None: + """Handle SetLaserCommand - select active laser head.""" + laser_uid = ops.head_uid(idx) + laser_head = next( + (head for head in machine.heads if head.uid == laser_uid), + None, + ) + + if laser_head is None: + logger.warning( + f"Could not find laser with UID '{laser_uid}'. " + "Using default laser 1." + ) + self.active_laser = 1 + else: + self.active_laser = laser_head.tool_number + + laser_select_cmd = 0xCA + self.active_laser - 1 + binary.append(bytes([0xCA, laser_select_cmd & 0x0F])) + text.append(f"LASER {self.active_laser}") + + def _handle_move_to( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle MoveToCommand - rapid move with laser off.""" + end = ops.endpoint(idx) + x_um = self._mm_to_um(end[0]) + y_um = self._mm_to_um(end[1]) + binary.append(b"\x88" + encode35(x_um) + encode35(y_um)) + text.append(f"MOVE_ABS X:{end[0]:.3f} Y:{end[1]:.3f}") + + def _handle_line_to( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle LineToCommand - cutting move with laser on.""" + end = ops.endpoint(idx) + x_um = self._mm_to_um(end[0]) + y_um = self._mm_to_um(end[1]) + binary.append(b"\xa8" + encode35(x_um) + encode35(y_um)) + text.append(f"CUT_ABS X:{end[0]:.3f} Y:{end[1]:.3f}") + + def _handle_arc_to( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle ArcToCommand - linearize arc to series of cuts.""" + end = ops.endpoint(idx) + _i_val, _j_val, cw = ops.arc_params(idx) + text.append( + f"; ARC to ({end[0]:.3f}, {end[1]:.3f}) {'CW' if cw else 'CCW'}" + ) + + sub_ops = ops.linearize(idx, self.current_pos) + for j in range(sub_ops.len()): + sub_ct = sub_ops.command_type(j) + if sub_ct == CommandType.LINE_TO: + self._handle_line_to(sub_ops, j, binary, text) + elif sub_ct == CommandType.SET_POWER: + self._handle_set_power(sub_ops, j, binary, text) + + def _handle_scan_line( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle ScanLinePowerCommand - linearize to power/line segments.""" + end = ops.endpoint(idx) + power_mv = ops.scanline_data(idx) + text.append( + f"; SCAN_LINE to ({end[0]:.3f}, {end[1]:.3f}) " + f"({len(power_mv)} samples)" + ) + + sub_ops = ops.linearize(idx, self.current_pos) + for j in range(sub_ops.len()): + sub_ct = sub_ops.command_type(j) + if sub_ct == CommandType.LINE_TO: + self._handle_line_to(sub_ops, j, binary, text) + elif sub_ct == CommandType.SET_POWER: + self._handle_set_power(sub_ops, j, binary, text) + + def _handle_job_start( + self, + machine: "Machine", + binary: list[bytes], + text: list[str], + ) -> None: + """ + Handle JobStartCommand - select reference point and mark job start. + + Raises: + ValueError: If active_wcs is not a valid Ruida reference point + """ + active_wcs = machine.active_wcs + if active_wcs not in REF_POINT_COMMANDS: + raise ValueError( + f"Unknown WCS slot '{active_wcs}'. " + f"Valid options: {', '.join(REF_POINT_COMMANDS.keys())}" + ) + binary.append(REF_POINT_COMMANDS[active_wcs]) + text.append(f"; Job Start - Ref Point: {active_wcs}") + + def _handle_job_end( + self, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle JobEndCommand - send end-of-file marker.""" + binary.append(b"\xd7") + text.append("; Job End") + + def _handle_layer_start( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle LayerStartCommand - mark layer beginning.""" + uid = ops.layer_uid(idx) + binary.append(b"\xca\x00") + text.append(f"; --- Layer {uid[:8]} ---") + + def _handle_layer_end( + self, + ops: Ops, + idx: int, + binary: list[bytes], + text: list[str], + ) -> None: + """Handle LayerEndCommand - mark layer end.""" + binary.append(b"\xca\x00") + text.append("; --- End Layer ---") + + def _handle_workpiece_start( + self, + ops: Ops, + idx: int, + text: list[str], + ) -> None: + """Handle WorkpieceStartCommand - mark workpiece beginning.""" + uid = ops.workpiece_uid(idx) + text.append(f"; --- Workpiece {uid[:8]} ---") + + def _handle_workpiece_end( + self, + ops: Ops, + idx: int, + text: list[str], + ) -> None: + """Handle WorkpieceEndCommand - mark workpiece end.""" + text.append("; --- End Workpiece ---") diff --git a/rayforge/machine/driver/ruida/ruida_maps.py b/rayforge/machine/driver/ruida/ruida_maps.py new file mode 100644 index 000000000..92a523f38 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_maps.py @@ -0,0 +1,681 @@ +""" +Ruida protocol static maps and constants. + +Based on: +- https://edutechwiki.unige.ch/en/Ruida +- https://github.com/meerk40t/meerk40t/tree/main/meerk40t/ruida +- https://github.com/StevenIsaacs/ruida-protocol-analyzer +""" + + +# ============================================================================= +# CARD/DEVICE IDENTIFICATION +# ============================================================================= + +CARD_ID_TO_MAGIC: dict[int, int] = { + 0x2210: 0x16, + 0x3835: 0x38, + 0x6300: 0x33, + 0x6301: 0x88, + 0x6302: 0x83, + 0x630B: 0x33, + 0x6320: 0x11, + 0x6425: 0x88, + 0x6501: 0x34, + 0x6511: 0x77, + 0x6512: 0x75, + 0x6513: 0x99, + 0x6514: 0x13, + 0x651E: 0x60, + 0x651F: 0x76, + 0x6530: 0x10, + 0x6531: 0x77, + 0x6532: 0x10, + 0x6533: 0x60, + 0x6811: 0x77, + 0x690F: 0xA1, + 0x6910: 0xA1, + 0x6911: 0xA1, + 0x6912: 0x88, + 0x6920: 0xA1, + 0x6930: 0x1A, + 0x693F: 0x1A, + 0x7230: 0x10, + 0x7235: 0x10, + 0x7236: 0x10, + 0x7430: 0x10, + 0x7930: 0x10, +} + +# ============================================================================= +# COMMAND DICTIONARIES (sorted by command byte) +# ============================================================================= + +# 0xA5 - Interface commands (also on jog port) +INTERFACE_COMMANDS: dict[int, str] = { + 0x01: "-X", + 0x02: "+X", + 0x03: "+Y", + 0x04: "-Y", + 0x05: "Pulse", + 0x06: "Start/Pause", + 0x07: "ESC", + 0x08: "Origin", + 0x09: "Stop", + 0x0A: "+Z", + 0x0B: "-Z", + 0x0C: "+U", + 0x0D: "-U", + 0x0F: "Trace", + 0x11: "Speed", + 0x12: "Laser Gate", + 0x30: "Frame", + 0x5A: "Reset", +} + +# 0xA7 - Keypress commands +A7_KEYPRESS_COMMANDS: dict[int, str] = { + 0x00: "KeyPress Origin", + 0x01: "KeyPress -X +Left", + 0x02: "KeyPress +X +Right", + 0x03: "KeyPress +Y +Top", + 0x04: "KeyPress -Y +Bottom", + 0x05: "KeyPress Pulse", + 0x06: "KeyPress Start/Pause", + 0x07: "KeyPress ESC", + 0x08: "KeyPress Origin", + 0x09: "KeyPress Stop", + 0x0A: "KeyPress +Z", + 0x0B: "KeyPress -Z", + 0x0C: "KeyPress +U", + 0x0D: "KeyPress -U", +} + +# 0xC6 - Power/delay commands +C6_POWER_COMMANDS: dict[int, str] = { + 0x01: "Power 1 min", + 0x02: "Power 1 max", + 0x05: "Power 3 min", + 0x06: "Power 3 max", + 0x07: "Power 4 min", + 0x08: "Power 4 max", + 0x21: "Power 2 min", + 0x22: "Power 2 max", + 0x50: "Through Power 1", + 0x51: "Through Power 2", + 0x55: "Through Power 3", + 0x56: "Through Power 4", +} + +C6_DELAY_COMMANDS: dict[int, str] = { + 0x10: "Laser Interval", + 0x11: "Add Delay", + 0x12: "Laser On Delay", + 0x13: "Laser Off Delay", + 0x15: "Laser On Delay 2", + 0x16: "Laser Off Delay 2", +} + +C6_PART_POWER_COMMANDS: dict[int, str] = { + 0x31: "Power 1 Min", + 0x32: "Power 1 Max", + 0x35: "Power 3 Min", + 0x36: "Power 3 Max", + 0x37: "Power 4 Min", + 0x38: "Power 4 Max", + 0x41: "Power 2 Min", + 0x42: "Power 2 Max", +} + +C6_FREQUENCY_COMMANDS: dict[int, str] = { + 0x60: "Part, Frequency", +} + +# 0xCA - Layer/mode commands +CA_MODE_COMMANDS: dict[int, str] = { + 0x00: "End Layer", + 0x01: "Work Mode 1", + 0x02: "Work Mode 2", + 0x03: "Work Mode 3", + 0x04: "Work Mode 4", + 0x05: "Work Mode 6", + 0x10: "Layer Device 0", + 0x11: "Layer Device 1", + 0x12: "Air Assist Off", + 0x13: "Air Assist On", + 0x14: "DbHead", + 0x30: "EnLaser2Offset 0", + 0x31: "EnLaser2Offset 1", + 0x55: "Work Mode 5", +} + +# 0xD8 - Realtime commands +D8_COMMANDS: dict[int, str] = { + 0x00: "Start Process", + 0x01: "Stop Process", + 0x02: "Pause Process", + 0x03: "Restore Process", + 0x10: "Ref Point Mode 2", + 0x11: "Ref Point Mode 1", + 0x12: "Ref Point Mode 0", + 0x20: "KeyDown -X +Left", + 0x21: "KeyDown +X +Right", + 0x22: "KeyDown +Y +Top", + 0x23: "KeyDown -Y +Bottom", + 0x24: "KeyDown +Z", + 0x25: "KeyDown -Z", + 0x26: "KeyDown +U", + 0x27: "KeyDown -U", + 0x28: "KeyDown 0x21", + 0x2A: "Home XY", + 0x2C: "Home Z", + 0x2D: "Home U", + 0x2E: "Focus Z", + 0x30: "KeyUp -X +Left", + 0x31: "KeyUp +X +Right", + 0x32: "KeyUp +Y +Top", + 0x33: "KeyUp -Y +Bottom", + 0x34: "KeyUp +Z", + 0x35: "KeyUp -Z", + 0x36: "KeyUp +U", + 0x37: "KeyUp -U", + 0x38: "KeyUp 0x20", + 0x39: "Home A", + 0x3A: "Home B", + 0x3B: "Home C", + 0x3C: "Home D", + 0x40: "KeyDown 0x18", + 0x41: "KeyDown 0x19", + 0x42: "KeyDown 0x1A", + 0x43: "KeyDown 0x1B", + 0x44: "KeyDown 0x1C", + 0x45: "KeyDown 0x1D", + 0x46: "KeyDown 0x1E", + 0x47: "KeyDown 0x1F", + 0x48: "KeyUp 0x08", + 0x49: "KeyUp 0x09", + 0x4A: "KeyUp 0x0A", + 0x4B: "KeyUp 0x0B", + 0x4C: "KeyUp 0x0C", + 0x4D: "KeyUp 0x0D", + 0x4E: "KeyUp 0x0E", + 0x4F: "KeyUp 0x0F", + 0x51: "Inhale On/Off", +} + +# 0xDA - Memory commands +DA_COMMANDS: dict[int, str] = { + 0x00: "Get Setting", + 0x01: "Set/Respond Setting", + 0x04: "OEM On/Off, CardIO On/Off", + 0x05: "Read Run Info", + 0x06: "Unknown/System Time", + 0x10: "Set System Time", + 0x30: "Upload Info 0x30", + 0x31: "Upload Info 0x31", + 0x52: "Unknown/System Time 2", + 0x53: "Set System Time 2", + 0x54: "Read Run Info 2", + 0x60: "RD-FUNCTION-UNK1", +} + +# 0xE5 - Document commands +E5_COMMANDS: dict[int, str] = { + 0x00: "Document Page Number", + 0x02: "Document Data End", + 0x03: "Is TblCor Usable", + 0x04: "Chunk Write Check", + 0x05: "Set File Sum", +} + +# 0xE8 - File actions +E8_FILE_ACTIONS: dict[int, str] = { + 0x00: "Delete", + 0x01: "Name", + 0x03: "Select", + 0x04: "Calc Time", +} + +# ============================================================================= +# MEMORY MAPS +# ============================================================================= + +DEFAULT_MEMORY_MAP: dict[int, tuple[str, int]] = { + 0x0002: ("Laser Info", 0), + 0x0003: ("Machine Def", 0), + 0x0004: ("IOEnable", 0), + 0x0005: ("G0 Velocity", 200000), + 0x000B: ("Eng Facula", 800), + 0x000C: ("Home Velocity", 20000), + 0x000E: ("Eng Vert Velocity", 100000), + 0x0010: ("System Control Mode", 0), + 0x0011: ("Laser PWM Frequency 1", 0), + 0x0012: ("Laser Min Power 1", 0), + 0x0013: ("Laser Max Power 1", 0), + 0x0016: ("Laser Attenuation", 0), + 0x0017: ("Laser PWM Frequency 2", 0), + 0x0018: ("Laser Min Power 2", 0), + 0x0019: ("Laser Max Power 2", 0), + 0x001A: ("Laser Standby Frequency 1", 0), + 0x001B: ("Laser Standby Pulse 1", 0), + 0x001C: ("Laser Standby Frequency 2", 0), + 0x001D: ("Laser Standby Pulse 2", 0), + 0x001E: ("Auto Type Space", 0), + 0x001F: ("TriColor", 0), + 0x0020: ("Axis Control Para 1", 0x0), + 0x0021: ("Axis Precision 1", 0), + 0x0023: ("Axis Max Velocity 1", 0), + 0x0024: ("Axis Start Velocity 1", 0), + 0x0025: ("Axis Max Acc 1", 0), + 0x0027: ("Axis Btn Start Velocity 1", 0), + 0x0028: ("Axis Btn Acc 1", 0), + 0x0029: ("Axis Estp Acc 1", 0), + 0x002A: ("Axis Home Offset 1", 0), + 0x002B: ("Axis Backlash 1", 0), + 0x0030: ("Axis Control Para 2", 0x4000), + 0x0031: ("Axis Precision 2", 0), + 0x0033: ("Axis Max Velocity 2", 0), + 0x0034: ("Axis Start Velocity 2", 0), + 0x0035: ("Axis Max Acc 2", 0), + 0x0037: ("Axis Btn Start Velocity 2", 0), + 0x0038: ("Axis Btn Acc 2", 0), + 0x0039: ("Axis Estp Acc 2", 0), + 0x003A: ("Axis Home Offset 2", 0), + 0x003B: ("Axis Backlash 2", 0), + 0x0040: ("Axis Control Para 3", 0), + 0x0041: ("Axis Precision 3", 0), + 0x0043: ("Axis Max Velocity 3", 0), + 0x0044: ("Axis Start Velocity 3", 0), + 0x0045: ("Axis Max Acc 3", 0), + 0x0047: ("Axis Btn Start Velocity 3", 0), + 0x0048: ("Axis Btn Acc 3", 0), + 0x0049: ("Axis Estp Acc 3", 0), + 0x004A: ("Axis Home Offset 3", 0), + 0x004B: ("Axis Backlash 3", 0), + 0x0050: ("Axis Control Para 4", 0), + 0x0051: ("Axis Precision 4", 0), + 0x0053: ("Axis Max Velocity 4", 0), + 0x0054: ("Axis Start Velocity 4", 0), + 0x0055: ("Axis Max Acc 4", 0), + 0x0057: ("Axis Btn Start Velocity 4", 0), + 0x0058: ("Axis Btn Acc 4", 0), + 0x0059: ("Axis Estp Acc 4", 0), + 0x005A: ("Axis Home Offset 4", 0), + 0x005B: ("Axis Backlash 4", 0), + 0x0060: ("Machine Type", 0), + 0x0063: ("Laser Min Power 3", 0), + 0x0064: ("Laser Max Power 3", 0), + 0x0065: ("Laser PWM Frequency 3", 0), + 0x0066: ("Laser Standby Frequency 3", 0), + 0x0067: ("Laser Standby Pulse 3", 0), + 0x0068: ("Laser Min Power 4", 0), + 0x0069: ("Laser Max Power 4", 0), + 0x006A: ("Laser PWM Frequency 4", 0), + 0x006B: ("Laser Standby Frequency 4", 0), + 0x006C: ("Laser Standby Pulse 4", 0), + 0x006D: ("Laser Min Power 5", 0), + 0x006E: ("Laser Max Power 5", 0), + 0x006F: ("Laser PWM Frequency 5", 0), + 0x0070: ("Laser Standby Frequency 5", 0), + 0x0071: ("Laser Standby Pulse 5", 0), + 0x0072: ("Laser Min Power 6", 0), + 0x0073: ("Laser Max Power 6", 0), + 0x0074: ("Laser PWM Frequency 6", 0), + 0x0075: ("Laser Standby Frequency 6", 0), + 0x0076: ("Laser Standby Pulse 6", 0), + 0x0077: ("Auto Type Space 2", 0), + 0x0078: ("Auto Type Space 4", 0), + 0x0079: ("Auto Type Space 5", 0), + 0x007A: ("Auto Type Space 6", 0), + 0x0080: ("RD-UNKNOWN 2", 0), + 0x0090: ("RD-UNKNOWN 3", 0), + 0x00A0: ("RD-UNKNOWN 4", 0), + 0x00B0: ("RD-UNKNOWN 5", 0), + 0x00C0: ("Offset 8 Start", 0), + 0x00C1: ("Offset 8 End", 0), + 0x00C2: ("Offset 9 Start", 0), + 0x00C3: ("Offset 9 End", 0), + 0x00C4: ("Offset 10 Start", 0), + 0x00C5: ("Offset 10 End", 0), + 0x00C6: ("Offset 7 Start", 0), + 0x00C7: ("Offset 7 End", 0), + 0x00C8: ("Axis Home Velocity 1", 0), + 0x00C9: ("Axis Home Velocity 2", 0), + 0x00CA: ("Margin 1", 0), + 0x00CB: ("Margin 2", 0), + 0x00CC: ("Margin 3", 0), + 0x00CD: ("Margin 4", 0), + 0x00CE: ("VWheelRatio", 0), + 0x00CF: ("VPunchRatio", 0), + 0x00D0: ("In Hale Zone", 0), + 0x00D8: ("VSlotRatio", 0), + 0x00D9: ("VSlot Share Home Offset", 0), + 0x00DA: ("VPunch Share Home Offset", 0), + 0x00E7: ("VWheel Share Home Offset", 0), + 0x0100: ("System Settings", 0), + 0x0101: ("Turn Velocity", 20000), + 0x0102: ("Syn Acc", 3000000), + 0x0103: ("G0 Delay", 0), + 0x0104: ("Scan Step Factor", 0), + 0x0105: ("User Para 5", 0), + 0x0107: ("Feed Delay After", 0), + 0x0108: ("User Key Fast Velocity", 0), + 0x0109: ("Turn Acc", 400000), + 0x010A: ("G0 Acc", 3000000), + 0x010B: ("Feed Delay Prior", 0), + 0x010C: ("Manual Distance", 0), + 0x010D: ("Shut Down Delay", 0), + 0x010E: ("Focus Depth", 5000), + 0x010F: ("Go Scale Blank", 0), + 0x0115: ("Dock Point X", 0), + 0x0116: ("Dock Point Y", 0), + 0x0117: ("Rotate On Delay", 0), + 0x0119: ("Rotate Off Delay", 0), + 0x011A: ("Acc Ratio", 100), + 0x011B: ("Turn Ratio", 100), + 0x011C: ("Acc G0 Ratio", 100), + 0x011F: ("Rotate Pulse", 0), + 0x0121: ("Rotate D", 0), + 0x0122: ("Eng Facula Replay", 0), + 0x0124: ("X Min Eng Velocity", 10000), + 0x0125: ("X Eng Acc", 10000000), + 0x0126: ("User Para 1", 0), + 0x0128: ("Z Home Velocity", 0), + 0x0129: ("Z Work Velocity", 0), + 0x012A: ("Z G0 Velocity", 0), + 0x012B: ("Union Home Distance", 0), + 0x012C: ("U Home Velocity", 0), + 0x012D: ("U Work Velocity", 0), + 0x012E: ("Feed Repay", 0), + 0x0131: ("Manual Fast Speed", 100000), + 0x0132: ("Manual Slow Speed", 10000), + 0x0134: ("Y Minimum Eng Velocity", 10000), + 0x0135: ("Y Eng Acc", 3000000), + 0x0137: ("Eng Acc Ratio", 100), + 0x0138: ("Sts Ahead Time", 0), + 0x0139: ("Repeat Delay", 0), + 0x013B: ("User Para 3", 0), + 0x013D: ("User Para 2", 0), + 0x013F: ("User Para 4", 0), + 0x0140: ("Axis Home Velocity 3", 0), + 0x0141: ("Axis Work Velocity 3", 0), + 0x0142: ("Axis Home Velocity 4", 0), + 0x0143: ("Axis Work Velocity 4", 0), + 0x0144: ("Axis Home Velocity 5", 0), + 0x0145: ("Axis Work Velocity 5", 0), + 0x0146: ("Axis Home Velocity 6", 0), + 0x0147: ("Axis Work Velocity 6", 0), + 0x0148: ("Axis Home Velocity 7", 0), + 0x0149: ("Axis Work Velocity 7", 0), + 0x014A: ("Axis Home Velocity 8", 0), + 0x014B: ("Axis Work Velocity 8", 0), + 0x014C: ("Laser Reset Time", 0), + 0x014D: ("Laser Start Distance", 0), + 0x014E: ("Z Pen Up Pos", 0), + 0x014F: ("Z Pen Down Pos", 0), + 0x0150: ("Offset 1 Start", 0), + 0x0151: ("Offset 1 End", 0), + 0x0152: ("Offset 2 Start", 0), + 0x0153: ("Offset 2 End", 0), + 0x0154: ("Offset 3 Start", 0), + 0x0155: ("Offset 3 End", 0), + 0x0156: ("Offset 6 Start", 0), + 0x0157: ("Offset 6 End", 0), + 0x0158: ("Offset 4 Start", 0), + 0x0159: ("Offset 4 End", 0), + 0x015A: ("Offset 5 Start", 0), + 0x015B: ("Offset 5 End", 0), + 0x015C: ("Delay 6 On", 0), + 0x015D: ("Delay 6 Off", 0), + 0x015E: ("Delay 7 On", 0), + 0x015F: ("Delay 7 Off", 0), + 0x0160: ("Inhale On Delay", 0), + 0x0161: ("Inhale Off Delay", 0), + 0x0162: ("Delay 5 On", 0), + 0x0163: ("Delay 5 Off", 0), + 0x0164: ("Delay 2 On", 0), + 0x0165: ("Delay 2 Off", 0), + 0x0166: ("VSample Distance", 0), + 0x0169: ("Offset 11 Start", 0), + 0x016A: ("Offset 11 End", 0), + 0x016B: ("Tool Up Pos 4", 0), + 0x016C: ("Tool Down Pos 4", 0), + 0x016D: ("VUp Angle", 0), + 0x016E: ("VRotatePulse", 0), + 0x0170: ("Delay 1 On", 0), + 0x0171: ("VCorner Precision", 0), + 0x0172: ("Delay 3 On", 0), + 0x0173: ("Delay 4 Off", 0), + 0x0174: ("Delay 4 On", 0), + 0x0175: ("Delay 1 Off", 0), + 0x0176: ("Delay 3 Off", 0), + 0x0177: ("Idle Long Distance", 0), + 0x0178: ("Tool Up Pos 3", 0), + 0x0179: ("Tool Down Pos 3", 0), + 0x017A: ("Tool Up Pos 2", 0), + 0x017B: ("Tool Down Pos 2", 0), + 0x017C: ("Punch Rotate Delay", 0), + 0x017D: ("VSlot Angle", 0), + 0x017E: ("VTool Rotate Limit", 0), + 0x017F: ("Tool Up Delay", 0), + 0x0180: ("Card Language", 0), + 0x0181: ("PC Lock 0", 0), + 0x0182: ("PC Lock 1", 0), + 0x0183: ("PC Lock 2", 0), + 0x0184: ("PC Lock 3", 0), + 0x0185: ("PC Lock 4", 0), + 0x0186: ("PC Lock 5", 0), + 0x0187: ("PC Lock 6", 0), + 0x0188: ("User Key Slow Velocity", 0), + 0x0189: ("MachineID 2", 0), + 0x018A: ("MachineID 3", 0), + 0x018B: ("MachineID 4", 0), + 0x018C: ("Blow On Delay", 0), + 0x018D: ("Blow Off Delay", 0), + 0x018F: ("User Para 6, Blower", 0), + 0x0190: ("Color Mark Head Max Distance", 0), + 0x0191: ("Color Mark Head Distance", 0), + 0x0192: ("Color Mark Mark Distance", 0), + 0x0193: ("Color Mark Camera Distance", 0), + 0x0194: ("Color Mark Sensor Offset2", 0), + 0x0195: ("Cylinder Down Delay", 0), + 0x0196: ("Cylinder Up Delay", 0), + 0x0197: ("Press Down Delay", 0), + 0x0198: ("Press Up Delay", 0), + 0x0199: ("Drop Position Start", 0), + 0x019A: ("Drop Position End", 0), + 0x019B: ("Drop Interval", 0), + 0x019C: ("Drop Time", 0), + 0x019D: ("Sharpen Delay On", 0), + 0x019E: ("Sharpen Delay Off", 0), + 0x019F: ("Sharpen Time Limit", 0), + 0x01A0: ("Sharpen Travel Limit", 0), + 0x01A1: ("Work End Time", 0), + 0x01A2: ("Color Mark Offset", 0), + 0x01A3: ("Color Mark Count", 0), + 0x01A4: ("Wheel Press Compensation", 0), + 0x01A5: ("Color Mark Filter Length", 0), + 0x01A6: ("VBlow Back On Delay", 0), + 0x01A7: ("VBlow Back Off Delay", 0), + 0x01AC: ("Y U Safe Distance", 0), + 0x01AD: ("Y U Home Distance", 0), + 0x01AE: ("VTool Preset Position X", 0), + 0x01AF: ("VTool Preset Position Y", 0), + 0x01B1: ("VTool Preset Compensation", 0), + 0x01B2: ("VTool Preset Cur Depth", 0), + 0x0200: ("Machine Status", 22), + 0x0201: ("Total Open Time (s)", 0), + 0x0202: ("Total Work Time (s)", 0), + 0x0203: ("Total Work Number", 0), + 0x0205: ("Total Doc Number", 0), + 0x0206: ("Flash Space", 0), + 0x0207: ("Flash Space", 0), + 0x0208: ("Previous Work Time", 0), + 0x0211: ("Total Laser Work Time", 0), + 0x0212: ("File Custom Flag / Feed Info", 0), + 0x0217: ("Total Laser Work Time 2", 0), + 0x0218: ("Total Laser Work Time 3", 0), + 0x0219: ("Total Laser Work Time 4", 0), + 0x021A: ("Total Laser Work Time 5", 0), + 0x021F: ("Ring Number", 0), + 0x0221: ("Axis Preferred Position 1, Pos X", 0), + 0x0223: ("X Total Travel (m)", 0), + 0x0224: ("Position Point 0", 0), + 0x0231: ("Axis Preferred Position 2, Pos Y", 0), + 0x0233: ("Y Total Travel (m)", 0), + 0x0234: ("Position Point 1", 0), + 0x0241: ("Axis Preferred Position 3, Pos Z", 0), + 0x0243: ("Z Total Travel (m)", 0), + 0x0251: ("Axis Preferred Position 4, Pos U", 0), + 0x0253: ("U Total Travel (m)", 0), + 0x025A: ("Axis Preferred Position 5, Pos A", 0), + 0x025B: ("Axis Preferred Position 6, Pos B", 0), + 0x025C: ("Axis Preferred Position 7, Pos C", 0), + 0x025D: ("Axis Preferred Position 8, Pos D", 0), + 0x0260: ("DocumentWorkNum", 0), + 0x0313: ("Material Thickness", 0), + 0x031C: ("File Fault", 0), + 0x0320: ("File Total Length", 0), + 0x0321: ("File Progress Len", 0), + 0x033B: ("Read Process Feed Length", 0), + 0x0340: ("Stop Time", 0), + 0x0591: ("Card Lock", 0), + 0x05C0: ("Laser Life", 0), +} + +DYNAMIC_MEMORY_KEYS: dict[int, tuple[str, str]] = { + 0x0026: ("Axis Range 1, Get Frame X", "bed_x"), + 0x0036: ("Axis Range 2, Get Frame Y", "bed_y"), + 0x0046: ("Axis Range 3, Get Frame Z", "z_range"), + 0x0056: ("Axis Range 4, Get Frame U", "u_range"), + 0x0400: ("Machine Status", "machine_status"), + 0x0421: ("Current X", "x"), + 0x0431: ("Current Y", "y"), + 0x0441: ("Current Z", "z"), + 0x0451: ("Current U", "u"), + 0x0461: ("Current A", "a"), + 0x0471: ("Current B", "b"), + 0x0481: ("Current C", "c"), + 0x0491: ("Current D", "d"), +} + +# ============================================================================= +# PROTOCOL HELPER CONSTANTS +# ============================================================================= + +# Commands that accumulate into file checksum +CHECKSUM_COMMANDS: set = { + 0x80, + 0x88, + 0x89, + 0x8A, + 0x8B, + 0xA0, + 0xA8, + 0xA9, + 0xAA, + 0xAB, + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC4, + 0xC5, + 0xC6, + 0xC7, + 0xC8, + 0xC9, + 0xCA, +} + +# D8 jog keydown -> (axis, direction) +D8_KEYDOWN_AXIS_MAP: dict[int, tuple[str, int]] = { + 0x20: ("x", -1), + 0x21: ("x", 1), + 0x22: ("y", 1), + 0x23: ("y", -1), + 0x24: ("z", 1), + 0x25: ("z", -1), + 0x26: ("u", 1), + 0x27: ("u", -1), +} + +# D8 jog keyup -> axis +D8_KEYUP_AXIS_MAP: dict[int, str] = { + 0x30: "x", + 0x31: "x", + 0x32: "y", + 0x33: "y", + 0x34: "z", + 0x35: "z", + 0x36: "u", + 0x37: "u", +} + +# DA subcommands with 4-byte response +DA_4_BYTE_RESPONSE_SUBCOMMANDS: set = { + 0x13, + 0x17, + 0x23, + 0x24, + 0x35, + 0x45, +} + +# DA subcommands with variable 4-byte encoding +DA_VARIABLE_4_BYTE_SUBCOMMANDS: set = { + 0x56, + 0x57, + 0x58, + 0x59, + 0x60, + 0x61, + 0x62, + 0x63, + 0x64, + 0x65, + 0x66, + 0x67, + 0x68, + 0x69, + 0x70, + 0x71, + 0x72, + 0x73, + 0x74, + 0x75, + 0x76, + 0x77, + 0x78, + 0x79, + 0x7A, + 0x7B, + 0x7C, +} + +REF_POINT_OFFSET_ADDRESSES: dict[str, tuple[int, int]] = { + "REF0": (0x0224, 0x0234), + "REF1": (0x0228, 0x0238), +} + +REF_POINT_COMMANDS: dict[str, bytes] = { + "MACHINE": b"\xd8\x10", + "REF0": b"\xd8\x12", + "REF1": b"\xd8\x11", +} + +REF_POINT_MODE_TO_NAME: dict[int, str] = { + 0: "REF0", + 1: "REF1", + 2: "MACHINE", +} + +REF_POINT_NAME_TO_MODE: dict[str, int] = { + v: k for k, v in REF_POINT_MODE_TO_NAME.items() +} + +CARD_ID_ADDRESS = 0x057E + +CARD_ID_TO_MODEL: dict[int, str] = { + 0x65106510: "RDC6442S", +} diff --git a/rayforge/machine/driver/ruida/ruida_protocol.py b/rayforge/machine/driver/ruida/ruida_protocol.py new file mode 100644 index 000000000..8bb127355 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_protocol.py @@ -0,0 +1,133 @@ +""" +Layer 4 (Application/Protocol) shared structures for Ruida protocol. + +Contains shared state model and command/response definitions used by both +server and client implementations. +""" + +from dataclasses import dataclass + +from .ruida_maps import DEFAULT_MEMORY_MAP, DYNAMIC_MEMORY_KEYS + + +class RuidaState: + """ + Machine state for Ruida controller. + """ + + CARD_ID = 0x65106510 + DEFAULT_BED_X = 320000 + DEFAULT_BED_Y = 220000 + + def __init__(self): + self.x = 0 + self.y = 0 + self.z = 0 + self.u = 0 + self.a = 0 + self.b = 0 + self.c = 0 + self.d = 0 + self.bed_x = self.DEFAULT_BED_X + self.bed_y = self.DEFAULT_BED_Y + self.z_range = 0 + self.u_range = 0 + self.machine_status = 22 + self.filename: str | None = None + self.program_mode = False + self.file_checksum = 0 + self.file_checksum_accumulator = 0 + self.checksum_enabled = True + self.jog_speed = 10000 + self.jog_active: dict[str, int] = {"x": 0, "y": 0, "z": 0, "u": 0} + self.memory_values: dict[int, int] = {} + self.ref_point_mode = 2 + + def mem_lookup(self, mem: int) -> tuple[str, int]: + """Look up memory address and return (name, value).""" + if mem in self.memory_values: + name = "Written Value" + if mem in DEFAULT_MEMORY_MAP: + name = DEFAULT_MEMORY_MAP[mem][0] + elif mem in DYNAMIC_MEMORY_KEYS: + name = DYNAMIC_MEMORY_KEYS[mem][0] + return name, self.memory_values[mem] + + if mem == 0x057E: + return "Card ID", self.CARD_ID + + if mem in DYNAMIC_MEMORY_KEYS: + entry = DYNAMIC_MEMORY_KEYS[mem] + name = entry[0] + attr = entry[1] + value = getattr(self, attr, 0) + return name, value + + if mem in DEFAULT_MEMORY_MAP: + entry = DEFAULT_MEMORY_MAP[mem] + return entry[0], entry[1] + + return f"Unknown mem 0x{mem:04X}", 0 + + +@dataclass +class RuidaCommand: + """ + Represents a parsed Ruida command. + + Attributes: + cmd: Primary command byte + subcmd: Secondary command byte (if applicable) + data: Raw command data + name: Human-readable command name + params: Parsed parameters + """ + + cmd: int + subcmd: int | None = None + data: bytes = b"" + name: str = "" + params: dict | None = None + + @property + def length(self) -> int: + """Return the length of the raw command data.""" + return len(self.data) + + +@dataclass +class RuidaResponse: + """ + Represents a Ruida response. + + Attributes: + data: Raw response bytes + success: Whether the command was successful + ack: True for ACK (0xCC), False for error (0xCD) + """ + + data: bytes = b"" + success: bool = True + + @property + def ack(self) -> bool: + return self.success + + @classmethod + def ack_response(cls) -> "RuidaResponse": + """Create a standard ACK response.""" + return cls(data=b"\xcc", success=True) + + @classmethod + def error_response(cls) -> "RuidaResponse": + """Create a standard error response.""" + return cls(data=b"\xcd", success=False) + + @classmethod + def from_bytes(cls, data: bytes) -> "RuidaResponse": + """Create response from raw bytes.""" + if not data: + return cls.ack_response() + if data[0] == 0xCD: + return cls(data=data, success=False) + return cls(data=data, success=True) diff --git a/rayforge/machine/driver/ruida/ruida_server.py b/rayforge/machine/driver/ruida/ruida_server.py new file mode 100644 index 000000000..596430c00 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_server.py @@ -0,0 +1,1323 @@ +""" +Ruida Server Protocol - Server-side command handling. + +Handles parsing of incoming commands and generation of responses +for the Ruida laser controller simulator. +""" + +import logging +from collections.abc import Callable + +from .ruida_maps import ( + A7_KEYPRESS_COMMANDS, + C6_DELAY_COMMANDS, + C6_PART_POWER_COMMANDS, + C6_POWER_COMMANDS, + CA_MODE_COMMANDS, + CHECKSUM_COMMANDS, + D8_COMMANDS, + D8_KEYDOWN_AXIS_MAP, + D8_KEYUP_AXIS_MAP, + DA_COMMANDS, + E5_COMMANDS, + E8_FILE_ACTIONS, + INTERFACE_COMMANDS, +) +from .ruida_protocol import RuidaState +from .ruida_util import ( + decode14, + decode35, + decodeu14, + decodeu35, + encode35, + parse_mem, +) + +logger = logging.getLogger(__name__) + + +class RuidaServer: + """ + Ruida server-side protocol handler. + + Parses incoming commands, updates machine state, and generates responses. + Use this for implementing a Ruida controller simulator. + """ + + def __init__( + self, + state: RuidaState | None = None, + on_command: Callable[[str, bytes], None] | None = None, + model: str = "644XG", + ): + self.state = state or RuidaState() + self.on_command = on_command + self.model = model + + def process_commands(self, data: bytes) -> bytes: + """Process unswizzled commands and return response.""" + response = b"" + pos = 0 + + while pos < len(data): + cmd = data[pos] + if cmd < 0x80: + pos += 1 + continue + + cmd_response, cmd_len = self._process_single_command(data[pos:]) + if cmd_response: + response += cmd_response + pos += cmd_len if cmd_len > 0 else 1 + + return response + + def _process_single_command(self, data: bytes) -> tuple[bytes, int]: + """ + Process a single command and return (response, length consumed). + + Returns: + Tuple of (response_bytes, bytes_consumed) + """ + if len(data) < 1: + return b"", 0 + + cmd = data[0] + s = self.state + + self._accumulate_checksum(data) + + if cmd == 0xCC: + self._log_command("ACK from machine", data[:1]) + return b"", 1 + + if cmd == 0xCD: + self._log_command("ERR from machine", data[:1]) + return b"", 1 + + if cmd == 0xCE: + self._log_command("Keep Alive", data[:1]) + return b"\xcc", 1 + + if cmd == 0xD0: + return self._handle_d0_command(data) + + if cmd == 0xD7: + self._log_command("End Of File", data[:1]) + s.program_mode = False + return b"", 1 + + if cmd == 0xD8: + return self._handle_d8_command(data) + + if cmd == 0xD9: + return self._handle_d9_command(data) + + if cmd == 0xDA: + return self._handle_da_command(data) + + if cmd == 0xA5: + return self._handle_a5_command(data) + + if cmd == 0xA7: + return self._handle_a7_command(data) + + if cmd == 0xE5: + return self._handle_e5_command(data) + + if cmd == 0xE7: + return self._handle_e7_command(data) + + if cmd == 0xE8: + return self._handle_e8_command(data) + + if cmd == 0x88: + return self._handle_move_abs(data) + + if cmd == 0x89: + return self._handle_move_rel(data) + + if cmd == 0xA8: + return self._handle_cut_abs(data) + + if cmd == 0xA9: + return self._handle_cut_rel(data) + + if cmd == 0xC6: + return self._handle_c6_command(data) + + if cmd == 0xC7: + return self._handle_power_command("Imd Power 1", data, 3) + + if cmd == 0xC0: + return self._handle_power_command("Imd Power 2", data, 3) + + if cmd == 0xC2: + return self._handle_power_command("Imd Power 3", data, 3) + + if cmd == 0xC3: + return self._handle_power_command("Imd Power 4", data, 3) + + if cmd == 0xC8: + return self._handle_power_command("End Power 1", data, 3) + + if cmd == 0xC1: + return self._handle_power_command("End Power 2", data, 3) + + if cmd == 0xC4: + return self._handle_power_command("End Power 3", data, 3) + + if cmd == 0xC5: + return self._handle_power_command("End Power 4", data, 3) + + if cmd == 0xC9: + return self._handle_c9_command(data) + + if cmd == 0xCA: + return self._handle_ca_command(data) + + if cmd == 0x8A: + return self._handle_move_rel_x(data) + + if cmd == 0x8B: + return self._handle_move_rel_y(data) + + if cmd == 0xAA: + return self._handle_cut_rel_x(data) + + if cmd == 0xAB: + return self._handle_cut_rel_y(data) + + if cmd == 0x80: + return self._handle_axis_move(data) + + if cmd == 0xA0: + return self._handle_axis_move_a0(data) + + if cmd == 0xEA: + index = data[1] if len(data) > 1 else 0 + self._log_command(f"Array Start ({index})", data[:2]) + return b"", 2 + + if cmd == 0xEB: + self._log_command("Array End", data[:1]) + return b"", 1 + + if cmd == 0xF0: + self._log_command("Ref Point Set", data[:1]) + return b"", 1 + + if cmd == 0xF1: + return self._handle_f1_command(data) + + if cmd == 0xF2: + return self._handle_f2_command(data) + + if cmd == 0xE6: + if len(data) > 1 and data[1] == 0x01: + self._log_command("Set Absolute", data[:2]) + return b"", 2 + return b"", 1 + + self._log_command(f"Unknown command 0x{cmd:02X}", data[:1]) + return b"", 1 + + def _handle_d0_command(self, data: bytes) -> tuple[bytes, int]: + """Handle D0 set inhale zone command.""" + if len(data) < 2: + return b"", 1 + + zone = data[1] + self._log_command(f"Set Inhale Zone: {zone}", data[:2]) + return b"", 2 + + def _handle_d8_command(self, data: bytes) -> tuple[bytes, int]: + """Handle D8 realtime commands.""" + if len(data) < 2: + return b"", 1 + + s = self.state + subcmd = data[1] + desc = D8_COMMANDS.get(subcmd, f"Unknown D8 subcommand 0x{subcmd:02X}") + self._log_command(desc, data[:2]) + + if subcmd == 0x00: + s.program_mode = True + s.machine_status = 21 + elif subcmd == 0x01: + s.program_mode = False + s.machine_status = 22 + elif subcmd == 0x02: + s.machine_status = 23 + elif subcmd == 0x03: + if s.program_mode: + s.machine_status = 21 + else: + s.machine_status = 22 + elif subcmd in (0x10, 0x11, 0x12): + s.ref_point_mode = {0x10: 2, 0x11: 1, 0x12: 0}.get(subcmd, 0) + elif subcmd in range(0x20, 0x28): + if subcmd in D8_KEYDOWN_AXIS_MAP: + axis, direction = D8_KEYDOWN_AXIS_MAP[subcmd] + s.jog_active[axis] = direction * s.jog_speed + elif subcmd == 0x2A: + s.x = 0 + s.y = 0 + elif subcmd == 0x2C: + s.z = 0 + elif subcmd == 0x2D: + s.u = 0 + elif subcmd == 0x2E: + pass + elif subcmd in range(0x30, 0x38): + if subcmd in D8_KEYUP_AXIS_MAP: + s.jog_active[D8_KEYUP_AXIS_MAP[subcmd]] = 0 + elif subcmd in range(0x40, 0x48) or subcmd in range(0x48, 0x50): + pass + elif subcmd == 0x39: + s.a = 0 + elif subcmd == 0x3A: + s.b = 0 + elif subcmd == 0x3B: + s.c = 0 + elif subcmd == 0x3C: + s.d = 0 + elif subcmd == 0x51: + pass + + return b"", 2 + + def _handle_d9_command(self, data: bytes) -> tuple[bytes, int]: + """Handle D9 rapid move commands.""" + if len(data) < 2: + return b"", 1 + + s = self.state + subcmd = data[1] + + def get_opt_desc(opts: int) -> str: + if opts == 0x00: + return "Origin" + elif opts == 0x01: + return "Light/Origin" + elif opts == 0x02: + return "" + elif opts == 0x03: + return "Light" + return f"opts={opts}" + + if subcmd in (0x00, 0x01, 0x02, 0x03, 0x50, 0x51, 0x52, 0x53): + if len(data) < 8: + return b"", 1 + opts = data[2] + coord = decode35(data[3:8]) + base_axis = subcmd & 0x0F + axis = {0x00: "X", 0x01: "Y", 0x02: "Z", 0x03: "U"}.get( + base_axis, "?" + ) + opt_desc = get_opt_desc(opts) + self._log_command( + f"Rapid move {opt_desc} {axis}: {coord:+d}um (rel)", data[:8] + ) + if base_axis == 0x00: + s.x += coord + elif base_axis == 0x01: + s.y += coord + elif base_axis == 0x02: + s.z += coord + elif base_axis == 0x03: + s.u += coord + return b"", 8 + + if subcmd == 0x0F: + if len(data) < 8: + return b"", 1 + opts = data[2] + opt_desc = get_opt_desc(opts) + self._log_command(f"Rapid Feed Axis Move {opt_desc}", data[:8]) + return b"", 8 + + if subcmd == 0x10: + if len(data) < 13: + return b"", 1 + opts = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + opt_desc = get_opt_desc(opts) + self._log_command( + f"Rapid move {opt_desc} XY: ({x:+d}um, {y:+d}um) (rel)", + data[:13], + ) + s.x += x + s.y += y + return b"", 13 + + if subcmd == 0x11: + if len(data) < 8: + return b"", 1 + opts = data[2] + coord = decode35(data[3:8]) + opt_desc = get_opt_desc(opts) + self._log_command( + f"Rapid move {opt_desc} Y: {coord:+d}um (rel)", data[:8] + ) + s.y += coord + return b"", 8 + + if subcmd == 0x60: + if len(data) < 13: + return b"", 1 + opts = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + opt_desc = get_opt_desc(opts) + self._log_command( + f"Rapid move {opt_desc} XY: ({x:+d}um, {y:+d}um) (rel)", + data[:13], + ) + s.x += x + s.y += y + return b"", 13 + + if subcmd in (0x30, 0x70): + if len(data) < 18: + return b"", 1 + opts = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + u = decode35(data[13:18]) + opt_desc = get_opt_desc(opts) + self._log_command( + f"Rapid move {opt_desc} XYU: ({x}um, {y}um, {u}um)", + data[:18], + ) + s.x = x + s.y = y + s.u = u + return b"", 18 + + return b"", 2 + + def _handle_da_command(self, data: bytes) -> tuple[bytes, int]: + """Handle DA memory commands.""" + if len(data) < 4: + return b"", 1 + + s = self.state + subcmd = data[1] + mem = parse_mem(data[2:4]) + + if subcmd == 0x00: + name, value = s.mem_lookup(mem) + desc = DA_COMMANDS.get(subcmd, f"Unknown DA 0x{subcmd:02X}") + self._log_command(f"{desc} {name} (mem: 0x{mem:04X})", data[:4]) + if isinstance(value, bytes): + encoded = value + else: + encoded = encode35(value) + response = b"\xda\x01" + data[2:4] + encoded + return response, 4 + + if subcmd == 0x01: + if len(data) < 14: + return b"", 1 + v0 = decodeu35(data[4:9]) + v1 = decodeu35(data[9:14]) + name, _ = s.mem_lookup(mem) + s.memory_values[mem] = v0 + desc = DA_COMMANDS.get(subcmd, f"Unknown DA 0x{subcmd:02X}") + self._log_command( + f"{desc} {name} (mem: 0x{mem:04X}) = {v0} (0x{v0:08x}) " + f"{v1} (0x{v1:08x})", + data[:14], + ) + return b"", 14 + + if subcmd == 0x04: + self._log_command("OEM On/Off, CardIO On/Off", data[:4]) + return b"\xda\x04" + b"\x00" * 10, 4 + + if subcmd in (0x05, 0x54): + desc = DA_COMMANDS.get(subcmd, f"Unknown DA 0x{subcmd:02X}") + self._log_command(desc, data[:4]) + return b"\xda" + bytes([subcmd]) + b"\x00" * 20, 4 + + if subcmd in (0x06, 0x52): + desc = DA_COMMANDS.get(subcmd, f"Unknown DA 0x{subcmd:02X}") + self._log_command(desc, data[:4]) + return b"", 4 + + if subcmd in (0x10, 0x53): + desc = DA_COMMANDS.get(subcmd, f"Unknown DA 0x{subcmd:02X}") + self._log_command(desc, data[:4]) + return b"", 4 + + if subcmd in (0x30, 0x31): + desc = DA_COMMANDS.get(subcmd, f"Unknown DA 0x{subcmd:02X}") + self._log_command(desc, data[:4]) + return b"\xda" + bytes([subcmd]) + b"\x00" * 20, 4 + + if subcmd == 0x60: + if len(data) < 4: + return b"", 1 + v = decode14(data[2:4]) + self._log_command(f"RD-FUNCTION-UNK1 {v}", data[:4]) + return b"", 4 + + return b"", 4 + + def _handle_a5_command(self, data: bytes) -> tuple[bytes, int]: + """Handle A5 interface commands (also on jog port).""" + if len(data) < 3: + return b"", 1 + + if data[1] in (0x50, 0x51): + key_type = "Down" if data[1] == 0x50 else "Up" + desc = INTERFACE_COMMANDS.get(data[2], f"Unknown(0x{data[2]:02X})") + self._log_command(f"Interface {key_type}: {desc}", data[:3]) + return b"", 3 + + if data[1] == 0x53: + self._log_command("Interface Frame", data[:3]) + return b"", 3 + + return b"", 3 + + def _handle_a7_command(self, data: bytes) -> tuple[bytes, int]: + """Handle A7 keypress commands.""" + if len(data) < 2: + return b"", 1 + + key = data[1] + desc = A7_KEYPRESS_COMMANDS.get(key, f"KeyPress Unknown(0x{key:02X})") + self._log_command(desc, data[:2]) + return b"", 2 + + def _handle_e5_command(self, data: bytes) -> tuple[bytes, int]: + """Handle E5 document commands.""" + s = self.state + if len(data) < 1: + return b"", 0 + + if len(data) == 1: + self._log_command("Lightburn Swizzle Modulation E5", data[:1]) + return b"", 1 + + subcmd = data[1] + + if subcmd == 0x00: + if len(data) < 4: + return b"", 2 + filenumber = decodeu14(data[2:4]) + desc = E5_COMMANDS.get(subcmd, f"Unknown E5 0x{subcmd:02X}") + self._log_command(f"{desc} {filenumber}", data[:4]) + return b"\xe5\x00" + encode35(0) + encode35(0), 4 + + if subcmd == 0x02: + desc = E5_COMMANDS.get(subcmd, f"Unknown E5 0x{subcmd:02X}") + self._log_command(desc, data[:2]) + return b"", 2 + + if subcmd == 0x03: + desc = E5_COMMANDS.get(subcmd, f"Unknown E5 0x{subcmd:02X}") + self._log_command(desc, data[:2]) + return b"", 2 + + if subcmd == 0x04: + self._log_command("Chunk Write Check", data[:2]) + return b"", 2 + + if subcmd == 0x05: + if len(data) < 7: + return b"", 2 + checksum = decodeu35(data[2:7]) + desc = E5_COMMANDS.get(subcmd, f"Unknown E5 0x{subcmd:02X}") + self._log_command(f"{desc}: {checksum}", data[:7]) + if s.checksum_enabled: + if checksum != s.file_checksum_accumulator: + logger.warning( + f"File checksum mismatch: received {checksum}, " + f"calculated {s.file_checksum_accumulator}" + ) + else: + logger.debug(f"File checksum verified: {checksum}") + s.file_checksum = checksum + s.file_checksum_accumulator = 0 + return b"", 7 + + return b"", 2 + + def _handle_e7_command(self, data: bytes) -> tuple[bytes, int]: + """Handle E7 file layout commands.""" + if len(data) < 2: + return b"", 1 + + s = self.state + subcmd = data[1] + + if subcmd == 0x00: + self._log_command("Block End", data[:2]) + return b"", 2 + + if subcmd == 0x01: + name_end = data.find(b"\x00", 2) + if name_end == -1: + name_end = min(len(data), 12) + filename = data[2:name_end].decode("ascii", errors="replace") + s.filename = filename + self._log_command(f"Filename: {filename}", data[:name_end]) + return b"", name_end + 1 + + if subcmd == 0x03: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Process TopLeft ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x04: + if len(data) < 16: + return b"", 2 + v0 = decode14(data[2:4]) + v1 = decode14(data[4:6]) + v2 = decode14(data[6:8]) + v3 = decode14(data[8:10]) + v4 = decode14(data[10:12]) + v5 = decode14(data[12:14]) + v6 = decode14(data[14:16]) + self._log_command( + f"Process Repeat ({v0}, {v1}, {v2}, {v3}, {v4}, {v5}, {v6})", + data[:16], + ) + return b"", 16 + + if subcmd == 0x05: + if len(data) < 3: + return b"", 2 + direction = data[2] + self._log_command(f"Array Direction: {direction}", data[:3]) + return b"", 3 + + if subcmd == 0x06: + if len(data) < 12: + return b"", 2 + v0 = decode35(data[2:7]) + v1 = decode35(data[7:12]) + self._log_command(f"Feed Repeat ({v0}, {v1})", data[:12]) + return b"", 12 + + if subcmd == 0x07: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Process BottomRight ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x08: + if len(data) < 16: + return b"", 2 + v0 = decode14(data[2:4]) + v1 = decode14(data[4:6]) + v2 = decode14(data[6:8]) + v3 = decode14(data[8:10]) + v4 = decode14(data[10:12]) + v5 = decode14(data[12:14]) + v6 = decode14(data[14:16]) + self._log_command( + f"Array Repeat ({v0}, {v1}, {v2}, {v3}, {v4}, {v5}, {v6})", + data[:16], + ) + return b"", 16 + + if subcmd == 0x09: + if len(data) < 7: + return b"", 2 + length = decode35(data[2:7]) + self._log_command(f"Feed Length: {length}um", data[:7]) + return b"", 7 + + if subcmd == 0x0A: + if len(data) < 7: + return b"", 2 + v = decodeu35(data[2:7]) + self._log_command(f"Feed Info: {v}", data[:7]) + return b"", 7 + + if subcmd == 0x0B: + if len(data) < 3: + return b"", 2 + v = data[2] + self._log_command(f"Array En Mirror Cut: {v}", data[:3]) + return b"", 3 + + if subcmd == 0x0C: + if len(data) < 3: + return b"", 2 + v = data[2] + self._log_command(f"Array Mirror Cut Distance: {v}", data[:3]) + return b"", 3 + + if subcmd == 0x13: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Array TopLeft ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x17: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Array BottomRight ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x23: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Array Add ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x24: + if len(data) < 3: + return b"", 2 + v = data[2] + self._log_command(f"Array Mirror: {v}", data[:3]) + return b"", 3 + + if subcmd == 0x32: + if len(data) < 7: + return b"", 2 + v = decodeu35(data[2:7]) + self._log_command(f"Set Tick Count: {v}", data[:7]) + return b"", 7 + + if subcmd == 0x35: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Block X Size ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x36: + if len(data) < 3: + return b"", 2 + v = data[2] + self._log_command(f"Set File Empty: {v}", data[:3]) + return b"", 3 + + if subcmd == 0x37: + if len(data) < 12: + return b"", 2 + v0 = decodeu35(data[2:7]) + v1 = decodeu35(data[7:12]) + self._log_command(f"Array Even Distance ({v0}, {v1})", data[:12]) + return b"", 12 + + if subcmd == 0x38: + if len(data) < 3: + return b"", 2 + v = data[2] + self._log_command(f"Set Feed Auto Pause: {v}", data[:3]) + return b"", 3 + + if subcmd == 0x3A: + self._log_command("Union Block Property", data[:2]) + return b"", 2 + + if subcmd == 0x3B: + if len(data) < 3: + return b"", 2 + v = data[2] + self._log_command(f"Set File Property: {v}", data[:3]) + return b"", 3 + + if subcmd == 0x46: + if len(data) < 7: + return b"", 2 + v = decodeu35(data[2:7]) + self._log_command(f"BY Test: 0x{v:08X}", data[:7]) + return b"", 7 + + if subcmd == 0x50: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Document Min Point ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x51: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Document Max Point ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x52: + if len(data) < 13: + return b"", 2 + part = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + self._log_command(f"Part {part} TopLeft ({x}um, {y}um)", data[:13]) + return b"", 13 + + if subcmd == 0x53: + if len(data) < 13: + return b"", 2 + part = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + self._log_command( + f"Part {part} BottomRight ({x}um, {y}um)", data[:13] + ) + return b"", 13 + + if subcmd == 0x54: + if len(data) < 8: + return b"", 2 + axis_id = data[2] + coord = decode35(data[3:8]) + self._log_command( + f"Pen Offset Axis={axis_id}: {coord}um", data[:8] + ) + return b"", 8 + + if subcmd == 0x55: + if len(data) < 8: + return b"", 2 + axis_id = data[2] + coord = decode35(data[3:8]) + self._log_command( + f"Layer Offset Axis={axis_id}: {coord}um", data[:8] + ) + return b"", 8 + + if subcmd == 0x57: + self._log_command("PList Feed", data[:2]) + return b"", 2 + + if subcmd == 0x60: + if len(data) < 3: + return b"", 2 + index = data[2] + self._log_command(f"Set Current Element Index: {index}", data[:3]) + return b"", 3 + + if subcmd == 0x61: + if len(data) < 13: + return b"", 2 + part = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + self._log_command( + f"Part {part} Ex TopLeft ({x}um, {y}um)", data[:13] + ) + return b"", 13 + + if subcmd == 0x62: + if len(data) < 13: + return b"", 2 + part = data[2] + x = decode35(data[3:8]) + y = decode35(data[8:13]) + self._log_command( + f"Part {part} Ex BottomRight ({x}um, {y}um)", data[:13] + ) + return b"", 13 + + return b"", 2 + + def _handle_e8_command(self, data: bytes) -> tuple[bytes, int]: + """Handle E8 file interaction commands.""" + if len(data) < 2: + return b"", 1 + + subcmd = data[1] + + if subcmd == 0x00: + if len(data) < 4: + return b"", 2 + filenumber = parse_mem(data[2:4]) + self._log_command( + f"{E8_FILE_ACTIONS.get(subcmd, '?')} Document {filenumber}", + data[:4], + ) + return b"\xe8\x00" + b"\x00" * 10, 4 + + if subcmd == 0x02: + self._log_command("File transfer", data[:2]) + return b"", 2 + + if subcmd in (0x01, 0x03, 0x04): + if len(data) < 4: + return b"", 2 + filenumber = parse_mem(data[2:4]) + self._log_command( + f"{E8_FILE_ACTIONS.get(subcmd, '?')} Document {filenumber}", + data[:4], + ) + if subcmd == 0x01: + name = f"FILE{filenumber:04d}" + return data[:4] + name.encode()[:8] + b"\x00", 4 + return b"", 4 + + return b"", 2 + + def _handle_move_abs(self, data: bytes) -> tuple[bytes, int]: + """Handle absolute move command (0x88).""" + s = self.state + if len(data) < 11: + return b"", 1 + x = decode35(data[1:6]) + y = decode35(data[6:11]) + self._log_command(f"Move Abs ({x}um, {y}um)", data[:11]) + s.x = x + s.y = y + return b"", 11 + + def _handle_move_rel(self, data: bytes) -> tuple[bytes, int]: + """Handle relative move command (0x89).""" + s = self.state + if len(data) < 5: + return b"", 1 + dx = decode14(data[1:3]) + dy = decode14(data[3:5]) + self._log_command(f"Move Rel ({dx:+d}um, {dy:+d}um)", data[:5]) + s.x += dx + s.y += dy + return b"", 5 + + def _handle_move_rel_x(self, data: bytes) -> tuple[bytes, int]: + """Handle relative X move command (0x8A).""" + s = self.state + if len(data) < 3: + return b"", 1 + dx = decode14(data[1:3]) + self._log_command(f"Move Rel X ({dx:+d}um)", data[:3]) + s.x += dx + return b"", 3 + + def _handle_move_rel_y(self, data: bytes) -> tuple[bytes, int]: + """Handle relative Y move command (0x8B).""" + s = self.state + if len(data) < 3: + return b"", 1 + dy = decode14(data[1:3]) + self._log_command(f"Move Rel Y ({dy:+d}um)", data[:3]) + s.y += dy + return b"", 3 + + def _handle_cut_abs(self, data: bytes) -> tuple[bytes, int]: + """Handle absolute cut command (0xA8).""" + s = self.state + if len(data) < 11: + return b"", 1 + x = decode35(data[1:6]) + y = decode35(data[6:11]) + self._log_command(f"Cut Abs ({x}um, {y}um)", data[:11]) + s.x = x + s.y = y + return b"", 11 + + def _handle_cut_rel(self, data: bytes) -> tuple[bytes, int]: + """Handle relative cut command (0xA9).""" + s = self.state + if len(data) < 5: + return b"", 1 + dx = decode14(data[1:3]) + dy = decode14(data[3:5]) + self._log_command(f"Cut Rel ({dx:+d}um, {dy:+d}um)", data[:5]) + s.x += dx + s.y += dy + return b"", 5 + + def _handle_cut_rel_x(self, data: bytes) -> tuple[bytes, int]: + """Handle relative X cut command (0xAA).""" + s = self.state + if len(data) < 3: + return b"", 1 + dx = decode14(data[1:3]) + self._log_command(f"Cut Rel X ({dx:+d}um)", data[:3]) + s.x += dx + return b"", 3 + + def _handle_cut_rel_y(self, data: bytes) -> tuple[bytes, int]: + """Handle relative Y cut command (0xAB).""" + s = self.state + if len(data) < 3: + return b"", 1 + dy = decode14(data[1:3]) + self._log_command(f"Cut Rel Y ({dy:+d}um)", data[:3]) + s.y += dy + return b"", 3 + + def _handle_c6_command(self, data: bytes) -> tuple[bytes, int]: + """Handle C6 power/delay commands.""" + if len(data) < 2: + return b"", 1 + + subcmd = data[1] + + if subcmd in C6_POWER_COMMANDS: + if len(data) < 4: + return b"", 2 + power = decodeu14(data[2:4]) / 163.84 + self._log_command( + f"{C6_POWER_COMMANDS[subcmd]}: {power:.1f}%", data[:4] + ) + return b"", 4 + + if subcmd in C6_DELAY_COMMANDS: + if len(data) < 7: + return b"", 2 + delay = decodeu35(data[2:7]) / 1000.0 + self._log_command( + f"{C6_DELAY_COMMANDS[subcmd]}: {delay}ms", data[:7] + ) + return b"", 7 + + if subcmd in C6_PART_POWER_COMMANDS: + if len(data) < 5: + return b"", 2 + part = data[2] + power = decodeu14(data[3:5]) / 163.84 + self._log_command( + f"Part {part} {C6_PART_POWER_COMMANDS[subcmd]}: {power:.1f}%", + data[:5], + ) + return b"", 5 + + if subcmd == 0x60: + if len(data) < 9: + return b"", 2 + laser = data[2] + part = data[3] + freq = decodeu35(data[4:9]) + self._log_command( + f"Part {part} Laser {laser} Frequency: {freq}Hz", data[:9] + ) + return b"", 9 + + return b"", 2 + + def _handle_power_command( + self, name: str, data: bytes, length: int + ) -> tuple[bytes, int]: + """Handle immediate/end power commands.""" + if len(data) < length: + return b"", 1 + power = decodeu14(data[1:3]) / 163.84 if len(data) >= 3 else 0 + self._log_command(f"{name}: {power:.1f}%", data[:length]) + return b"", length + + def _handle_c9_command(self, data: bytes) -> tuple[bytes, int]: + """Handle C9 speed commands.""" + if len(data) < 2: + return b"", 1 + + subcmd = data[1] + + if subcmd == 0x02: + if len(data) < 7: + return b"", 2 + speed = decode35(data[2:7]) / 1000.0 + self._log_command(f"Speed Laser 1: {speed}mm/s", data[:7]) + return b"", 7 + + if subcmd == 0x03: + if len(data) < 7: + return b"", 2 + speed = decode35(data[2:7]) / 1000.0 + self._log_command(f"Axis Speed: {speed}mm/s", data[:7]) + return b"", 7 + + if subcmd == 0x04: + if len(data) < 8: + return b"", 2 + part = data[2] + speed = decode35(data[3:8]) / 1000.0 + self._log_command(f"Part {part} Speed: {speed}mm/s", data[:8]) + return b"", 8 + + if subcmd in (0x05, 0x06): + if len(data) < 7: + return b"", 2 + speed = decode35(data[2:7]) / 1000.0 + desc = "Force Eng Speed" if subcmd == 0x05 else "Axis Move Speed" + self._log_command(f"{desc}: {speed}mm/s", data[:7]) + return b"", 7 + + return b"", 2 + + def _handle_ca_command(self, data: bytes) -> tuple[bytes, int]: + """Handle CA layer/mode commands.""" + if len(data) < 2: + return b"", 1 + + subcmd = data[1] + + if subcmd == 0x01: + if len(data) < 3: + return b"", 2 + desc = CA_MODE_COMMANDS.get(data[2], f"Mode 0x{data[2]:02X}") + self._log_command(desc, data[:3]) + return b"", 3 + + if subcmd == 0x02: + if len(data) < 3: + return b"", 2 + part = data[2] + self._log_command(f"Part {part} Layer Number", data[:3]) + return b"", 3 + + if subcmd == 0x03: + if len(data) < 3: + return b"", 2 + self._log_command(f"EnLaserTube Start: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x04: + if len(data) < 3: + return b"", 2 + self._log_command(f"X Sign Map: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x05: + if len(data) < 7: + return b"", 2 + c = decodeu35(data[2:7]) + r = c & 0xFF + g = (c >> 8) & 0xFF + b = (c >> 16) & 0xFF + self._log_command(f"Layer Color: #{b:02X}{g:02X}{r:02X}", data[:7]) + return b"", 7 + + if subcmd == 0x06: + if len(data) < 8: + return b"", 2 + part = data[2] + c = decodeu35(data[3:8]) + r = c & 0xFF + g = (c >> 8) & 0xFF + b = (c >> 16) & 0xFF + self._log_command( + f"Part {part} Color: #{b:02X}{g:02X}{r:02X}", data[:8] + ) + return b"", 8 + + if subcmd == 0x10: + if len(data) < 3: + return b"", 2 + self._log_command(f"EnExIO Start: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x22: + if len(data) < 3: + return b"", 2 + self._log_command(f"Max Layer Part: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x30: + if len(data) < 4: + return b"", 2 + file_id = decodeu14(data[2:4]) + self._log_command(f"U File ID: {file_id}", data[:4]) + return b"", 4 + + if subcmd == 0x40: + if len(data) < 3: + return b"", 2 + self._log_command(f"ZU Map: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x41: + if len(data) < 4: + return b"", 2 + part = data[2] + mode = data[3] + self._log_command( + f"Layer Select Part {part} Mode {mode}", data[:4] + ) + return b"", 4 + + return b"", 2 + + def _handle_axis_move(self, data: bytes) -> tuple[bytes, int]: + """Handle 0x80 axis move commands.""" + s = self.state + if len(data) < 2: + return b"", 1 + + if data[1] == 0x00: + if len(data) < 7: + return b"", 2 + coord = decode35(data[2:7]) + self._log_command(f"Axis X Move: {coord}um", data[:7]) + s.x = coord + return b"", 7 + + if data[1] == 0x08: + if len(data) < 7: + return b"", 2 + coord = decode35(data[2:7]) + self._log_command(f"Axis Z Move: {coord}um", data[:7]) + s.z = coord + return b"", 7 + + return b"", 2 + + def _handle_axis_move_a0(self, data: bytes) -> tuple[bytes, int]: + """Handle 0xA0 axis move commands.""" + s = self.state + if len(data) < 2: + return b"", 1 + + if data[1] == 0x00: + if len(data) < 7: + return b"", 2 + coord = decode35(data[2:7]) + self._log_command(f"Axis Y Move: {coord}um", data[:7]) + s.y = coord + return b"", 7 + + if data[1] == 0x08: + if len(data) < 7: + return b"", 2 + coord = decode35(data[2:7]) + self._log_command(f"Axis U Move: {coord}um", data[:7]) + s.u = coord + return b"", 7 + + return b"", 2 + + def _handle_f1_command(self, data: bytes) -> tuple[bytes, int]: + """Handle F1 commands.""" + if len(data) < 2: + return b"", 1 + + subcmd = data[1] + + if subcmd == 0x00: + if len(data) < 3: + return b"", 2 + self._log_command(f"Element Max Index: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x01: + if len(data) < 3: + return b"", 2 + self._log_command(f"Element Name Max Index: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x02: + if len(data) < 3: + return b"", 2 + self._log_command(f"Enable Block Cutting: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x03: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Display Offset ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x04: + if len(data) < 7: + return b"", 2 + v = decodeu35(data[2:7]) + self._log_command(f"Feed Auto Calc: {v}", data[:7]) + return b"", 7 + + if subcmd == 0x10: + if len(data) < 4: + return b"", 2 + v0 = data[2] + v1 = data[3] + self._log_command(f"Unknown Common ({v0}, {v1})", data[:4]) + return b"", 4 + + if subcmd == 0x20: + if len(data) < 4: + return b"", 2 + v0 = data[2] + v1 = data[3] + self._log_command(f"Unknown F1 0x20 ({v0}, {v1})", data[:4]) + return b"", 4 + + return b"", 2 + + def _handle_f2_command(self, data: bytes) -> tuple[bytes, int]: + """Handle F2 element commands.""" + if len(data) < 2: + return b"", 1 + + subcmd = data[1] + + if subcmd in (0x00, 0x01): + if len(data) < 3: + return b"", 2 + label = "Index" if subcmd == 0 else "Name Index" + self._log_command(f"Element {label}: {data[2]}", data[:3]) + return b"", 3 + + if subcmd == 0x02: + name_end = min(len(data), 12) + self._log_command("Element Name", data[:name_end]) + return b"", name_end + + if subcmd in (0x03, 0x04, 0x06): + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + desc = {0x03: "Min Point", 0x04: "Max Point", 0x06: "Add"}.get( + subcmd, "?" + ) + self._log_command( + f"Element Array {desc} ({x}um, {y}um)", data[:12] + ) + return b"", 12 + + if subcmd == 0x05: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command(f"Element Array ({x}um, {y}um)", data[:12]) + return b"", 12 + + if subcmd == 0x07: + if len(data) < 12: + return b"", 2 + x = decode35(data[2:7]) + y = decode35(data[7:12]) + self._log_command( + f"Element Array Mirror ({x}um, {y}um)", data[:12] + ) + return b"", 12 + + return b"", 2 + + def _log_command(self, desc: str, data: bytes) -> None: + """Log a command description.""" + hex_data = data.hex() if data else "" + logger.debug(f"--> {hex_data}\t({desc})") + if self.on_command: + self.on_command(desc, data) + + def _accumulate_checksum(self, data: bytes) -> None: + """Accumulate bytes into the file checksum for relevant commands.""" + s = self.state + if s.checksum_enabled and data and data[0] in CHECKSUM_COMMANDS: + s.file_checksum_accumulator += sum(data) diff --git a/rayforge/machine/driver/ruida/ruida_simulator.py b/rayforge/machine/driver/ruida/ruida_simulator.py new file mode 100644 index 000000000..944dec6bd --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_simulator.py @@ -0,0 +1,340 @@ +""" +Ruida Simulator - Emulates a Ruida laser controller. + +This module provides a high-level simulator that wraps RuidaServer (L3) +for command processing. Transport layer (L2) responsibilities like +swizzle encoding and packet framing are handled separately by +RuidaTransport / RuidaServerTransport. + +Layer architecture: +- L2: RuidaTransport / RuidaServerTransport (framing + swizzle) +- L3: RuidaServer (command parsing + state management) +- L4: RuidaSimulator (this class - convenience wrapper) + +Based on: +- https://edutechwiki.unige.ch/en/Ruida +- https://github.com/meerk40t/meerk40t/tree/main/meerk40t/ruida +- https://github.com/StevenIsaacs/ruida-protocol-analyzer +""" + +import logging +from collections.abc import Callable + +from blinker import Signal + +from .ruida_server import RuidaServer + +logger = logging.getLogger(__name__) + + +class RuidaSimulator: + """ + Ruida controller simulator. + + This is a thin wrapper around RuidaServer (L3) that provides a + convenient interface. All transport-layer concerns (swizzle, + framing) are handled by the caller using RuidaServerTransport. + + For the main data channel: + - Receive already-decoded (unswizzled) commands + - Call process_commands() to handle them + - Send unswizzled responses back via transport + + For the jog channel: + - Jog packets are NOT swizzled + - Call handle_jog_packet() to handle raw jog data + """ + + CARD_ID = 0x65106510 + DEFAULT_BED_X = 320000 + DEFAULT_BED_Y = 220000 + + def __init__( + self, + on_command: Callable[[str, bytes], None] | None = None, + model: str = "644XG", + ): + self._server = RuidaServer(on_command=on_command, model=model) + + self.command_received = Signal() + self.response_ready = Signal() + + @property + def state(self): + """Access to the server state.""" + return self._server.state + + @property + def x(self): + return self.state.x + + @x.setter + def x(self, value): + self.state.x = value + + @property + def y(self): + return self.state.y + + @y.setter + def y(self, value): + self.state.y = value + + @property + def z(self): + return self.state.z + + @z.setter + def z(self, value): + self.state.z = value + + @property + def u(self): + return self.state.u + + @u.setter + def u(self, value): + self.state.u = value + + @property + def program_mode(self): + return self.state.program_mode + + @program_mode.setter + def program_mode(self, value): + self.state.program_mode = value + + @property + def machine_status(self): + return self.state.machine_status + + @machine_status.setter + def machine_status(self, value): + self.state.machine_status = value + + @property + def ref_point_mode(self): + return self.state.ref_point_mode + + @property + def filename(self): + return self.state.filename + + @property + def jog_speed(self): + return self.state.jog_speed + + @jog_speed.setter + def jog_speed(self, value): + self.state.jog_speed = value + + @property + def jog_active(self): + return self.state.jog_active + + @property + def bed_x(self): + return self.state.bed_x + + @property + def bed_y(self): + return self.state.bed_y + + @property + def file_checksum(self): + return self.state.file_checksum + + @property + def file_checksum_accumulator(self): + return self.state.file_checksum_accumulator + + @file_checksum_accumulator.setter + def file_checksum_accumulator(self, value): + self.state.file_checksum_accumulator = value + + @property + def checksum_enabled(self): + return self.state.checksum_enabled + + @checksum_enabled.setter + def checksum_enabled(self, value): + self.state.checksum_enabled = value + + def process_commands(self, data: bytes) -> bytes: + """ + Process unswizzled commands and return unswizzled response. + + This is the main entry point for the main data channel. + The caller is responsible for swizzle/framing via RuidaServerTransport. + + Args: + data: Unswizzled command bytes + + Returns: + Unswizzled response bytes (may be empty for ACK-only) + """ + return self._server.process_commands(data) + + def _process_single_command(self, data: bytes): + """Delegate to server for backward compatibility with tests.""" + return self._server._process_single_command(data) + + def handle_jog_packet(self, data: bytes) -> bytes: + """ + Handle a packet on the jog control channel. + + Jog packets are NOT swizzled. This method processes raw jog data + and returns the raw response. + + Args: + data: Raw jog packet bytes (not swizzled) + + Returns: + Raw response bytes (not swizzled) + """ + if len(data) < 1: + return b"" + + if data[0] == 0xCC: + return b"\xcc" + + if data[0] == 0xCE: + return b"\xcc" + + if len(data) < 3: + return b"" + + if data[0] == 0xA5: + self._server.process_commands(data) + return b"\xcc" + + if data[0] == 0xD9: + self._server.process_commands(data) + return b"\xcc" + + return b"\xcc" + + +async def run_udp_simulator( + simulator: RuidaSimulator, + host: str = "0.0.0.0", + port: int = 50200, + jog_port: int = 50207, + magic: int = 0x88, +) -> None: + """ + Run the simulator with UDP transport (async). + + Uses proper layering: + - L1: UdpServerTransport (raw UDP) + - L2: RuidaServerTransport (framing + swizzle) + - L3: RuidaServer via simulator.process_commands() + - L4: RuidaSimulator + """ + import asyncio + + from rayforge.machine.transport.udp_server import UdpServerTransport + + from .ruida_transport import RuidaServerTransport + + main_udp = UdpServerTransport(host, port) + main_transport = RuidaServerTransport(main_udp, magic=magic) + jog_transport = UdpServerTransport(host, jog_port) + + async def handle_main_decoded(sender, data: bytes, addr): + logger.debug(f"Main decoded from {addr}: {data.hex()}") + response = simulator.process_commands(data) + if response == b"\xcc" or not response: + logger.debug("Response: cc (ack)") + await main_transport.send_response(b"\xcc", addr) + else: + logger.debug(f"Response: cc + {response.hex()}") + await main_transport.send_response(b"\xcc", addr) + await main_transport.send_response(response, addr) + + async def handle_jog(sender, data: bytes, addr): + logger.debug(f"Jog packet from {addr}: {data.hex()}") + response = simulator.handle_jog_packet(data) + if response: + await jog_transport.send_to(response, addr) + + main_transport.decoded_received.connect( + lambda self, data, addr: asyncio.create_task( + handle_main_decoded(self, data, addr) + ) + ) + jog_transport.received.connect( + lambda self, data, addr: asyncio.create_task( + handle_jog(self, data, addr) + ) + ) + + await main_transport.connect() + await jog_transport.connect() + + logger.info(f"Ruida simulator running on {host}:{port} (jog: {jog_port})") + logger.info("Press Ctrl+C to stop") + + try: + while True: + await asyncio.sleep(1) + finally: + await main_transport.disconnect() + await jog_transport.disconnect() + + +def run_simulator( + host: str = "0.0.0.0", + port: int = 50200, + jog_port: int = 50207, + magic: int = 0x88, +) -> None: + """Run the Ruida simulator (blocking).""" + import asyncio + + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + simulator = RuidaSimulator() + asyncio.run(run_udp_simulator(simulator, host, port, jog_port, magic)) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Ruida laser controller simulator" + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="UDP host to bind to (default: 0.0.0.0)", + ) + parser.add_argument( + "--port", + type=int, + default=50200, + help="UDP port for main channel (default: 50200)", + ) + parser.add_argument( + "--jog-port", + type=int, + default=50207, + help="UDP port for jog control (default: 50207)", + ) + parser.add_argument( + "--magic", + type=lambda x: int(x, 0), + default=0x88, + help="Swizzle magic number (default: 0x88)", + ) + + args = parser.parse_args() + run_simulator( + host=args.host, + port=args.port, + jog_port=args.jog_port, + magic=args.magic, + ) diff --git a/rayforge/machine/driver/ruida/ruida_transport.py b/rayforge/machine/driver/ruida/ruida_transport.py new file mode 100644 index 000000000..2b6112349 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_transport.py @@ -0,0 +1,298 @@ +""" +Layer 2 (Data Link/Transport) for Ruida protocol. + +Handles framing (checksums) and swizzle encoding/decoding. +Wraps a generic transport (UDP, serial) to provide Ruida-specific encoding. +""" + +import logging + +from blinker import Signal + +from rayforge.machine.transport.transport import Transport, TransportStatus +from rayforge.machine.transport.udp_server import UdpServerTransport + +from .ruida_codec import RuidaCodec +from .ruida_util import frame_packet, validate_packet + +logger = logging.getLogger(__name__) + + +class RuidaTransport(Transport): + """ + Ruida L2 transport that wraps a generic transport. + + Adds framing (checksum prefix) and swizzle encoding to all data. + Emits decoded (unswizzled) payloads via the decoded_received signal. + + Usage: + udp = UdpTransport("192.168.1.100", 50200) + ruida = RuidaTransport(udp) + ruida.decoded_received.connect(handler) + await ruida.connect() + await ruida.send_command(b"\\xda\\x00...") # gets swizzled + framed + """ + + def __init__(self, transport: Transport, magic: int = 0x88): + super().__init__() + self._transport = transport + self._codec = RuidaCodec(magic) + + self.decoded_received = Signal() + self.magic_changed = Signal() + + self._transport.received.connect(self._on_raw_received) + self._transport.status_changed.connect(self._on_status_changed) + + @property + def magic(self) -> int: + """Current swizzle magic key.""" + return self._codec.magic + + @magic.setter + def magic(self, value: int) -> None: + if self._codec.set_magic(value): + self.magic_changed.send(self, magic=value) + + @property + def is_connected(self) -> bool: + return self._transport.is_connected + + async def connect(self) -> None: + await self._transport.connect() + + async def disconnect(self) -> None: + await self._transport.disconnect() + + async def send(self, data: bytes) -> None: + """ + Send raw data without framing/swizzling. + + Use send_command() for normal Ruida communication. + """ + logger.debug( + f"TX (raw): {data!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": data, + }, + ) + await self._transport.send(data) + + async def send_command(self, command: bytes) -> None: + """ + Send a Ruida command with swizzle encoding and framing. + + Args: + command: Unswizzled command bytes + """ + logger.debug( + f"TX: {command!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": command, + }, + ) + swizzled = self._codec.swizzle(command) + framed = frame_packet(swizzled) + await self._transport.send(framed) + + async def send_response(self, response: bytes) -> None: + """ + Send a response (swizzled but not framed with checksum). + + For UDP responses to MeerK40t, responses are sent raw swizzled + without the checksum prefix. + """ + logger.debug( + f"TX (response): {response!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": response, + }, + ) + swizzled = self._codec.swizzle(response) + await self._transport.send(swizzled) + + async def purge(self) -> None: + await self._transport.purge() + + def _on_raw_received(self, sender, data: bytes) -> None: + """Handle raw data from underlying transport. + + Note: Server responses are NOT framed with checksums - they are + just swizzled bytes. Only client->server packets have checksums. + """ + unswizzled = self._codec.unswizzle(data) + + logger.debug( + f"RX: {unswizzled!r}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": unswizzled, + }, + ) + + if len(data) == 1 and unswizzled[0] in (0xCC, 0xCD, 0xCE): + self.decoded_received.send(self, data=unswizzled) + return + + detected = self._codec.detect_magic_from_payload(data) + if detected is not None: + if self._codec.set_magic(detected): + self.magic_changed.send(self, magic=detected) + else: + detected = self._codec.detect_magic_from_mem_request(unswizzled) + if detected is not None and self._codec.set_magic(detected): + self.magic_changed.send(self, magic=detected) + + self.decoded_received.send(self, data=unswizzled) + + def _on_status_changed( + self, sender, status: TransportStatus, message: str = "" + ) -> None: + self.status_changed.send(self, status=status, message=message) + + +class RuidaServerTransport: + """ + Ruida L2 server transport for UDP server mode. + + Unlike RuidaTransport (which wraps a client transport), this wraps + a UdpServerTransport and handles responses to multiple clients. + + Usage: + udp = UdpServerTransport("0.0.0.0", 50200) + ruida = RuidaServerTransport(udp) + ruida.decoded_received.connect(handler) # handler(data, addr) + await ruida.connect() + await ruida.send_response(data, addr) + """ + + def __init__(self, transport: UdpServerTransport, magic: int = 0x88): + self._transport: UdpServerTransport = transport + self._codec = RuidaCodec(magic) + + self.decoded_received = Signal() + self.magic_changed = Signal() + + self._transport.received.connect(self._on_raw_received) + self._transport.status_changed.connect(self._on_status_changed) + + @property + def magic(self) -> int: + return self._codec.magic + + @magic.setter + def magic(self, value: int) -> None: + if self._codec.set_magic(value): + self.magic_changed.send(self, magic=value) + + async def connect(self) -> None: + await self._transport.connect() + + async def disconnect(self) -> None: + await self._transport.disconnect() + + async def send_to(self, data: bytes, addr: tuple[str, int]) -> None: + """ + Send raw data to a specific client. + + Use send_response() for normal Ruida responses. + """ + await self._transport.send_to(data, addr) + + async def send_response( + self, response: bytes, addr: tuple[str, int] + ) -> None: + """ + Send a Ruida response (swizzled, no checksum prefix). + + Args: + response: Unswizzled response bytes + addr: Client address (host, port) + """ + logger.debug( + f"TX (response -> {addr}): {response!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": response, + }, + ) + swizzled = self._codec.swizzle(response) + await self._transport.send_to(swizzled, addr) + + async def send_command( + self, command: bytes, addr: tuple[str, int] + ) -> None: + """ + Send a framed command to a specific client. + + Args: + command: Unswizzled command bytes + addr: Client address (host, port) + """ + logger.debug( + f"TX (command -> {addr}): {command!r}", + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": command, + }, + ) + swizzled = self._codec.swizzle(command) + framed = frame_packet(swizzled) + await self._transport.send_to(framed, addr) + + def _on_raw_received( + self, sender, data: bytes, addr: tuple[str, int] + ) -> None: + """Handle raw data from underlying transport.""" + is_valid, payload, recv_cksum, calc_cksum = validate_packet(data) + + if not is_valid: + logger.warning( + f"Checksum mismatch from {addr}: received {recv_cksum:04X}, " + f"calculated {calc_cksum:04X}" + ) + return + + magic_detected = None + + if len(payload) == 4: + detected = self._codec.detect_magic_from_payload(payload) + if detected is not None: + magic_detected = detected + + unswizzled = self._codec.unswizzle(payload) + + logger.debug( + f"RX (from {addr}): {unswizzled!r}", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": unswizzled, + }, + ) + + if magic_detected is None: + detected = self._codec.detect_magic_from_mem_request(unswizzled) + if detected is not None: + magic_detected = detected + + if magic_detected is not None and self._codec.set_magic( + magic_detected + ): + self.magic_changed.send(self, magic=magic_detected) + + self.decoded_received.send(self, data=unswizzled, addr=addr) + + def _on_status_changed( + self, sender, status: TransportStatus, message: str = "" + ) -> None: + pass diff --git a/rayforge/machine/driver/ruida/ruida_util.py b/rayforge/machine/driver/ruida/ruida_util.py new file mode 100644 index 000000000..6f46a56d9 --- /dev/null +++ b/rayforge/machine/driver/ruida/ruida_util.py @@ -0,0 +1,299 @@ +""" +Ruida protocol utility functions for encoding, decoding, and swizzling. + +Based on: +- https://edutechwiki.unige.ch/en/Ruida +- https://github.com/meerk40t/meerk40t/tree/main/meerk40t/ruida +- https://github.com/StevenIsaacs/ruida-protocol-analyzer +""" + +from rayforge.machine.driver.ruida.ruida_maps import ( + DA_4_BYTE_RESPONSE_SUBCOMMANDS, + DA_VARIABLE_4_BYTE_SUBCOMMANDS, +) + +UM_PER_MM = 1000.0 + + +def swizzle_byte(b: int, magic: int = 0x88) -> int: + """Swizzle a single byte for transmission.""" + b ^= (b >> 7) & 0xFF + b ^= (b << 7) & 0xFF + b ^= (b >> 7) & 0xFF + b ^= magic + b = (b + 1) & 0xFF + return b + + +def unswizzle_byte(b: int, magic: int = 0x88) -> int: + """Unswizzle a single byte after reception.""" + b = (b - 1) & 0xFF + b ^= magic + b ^= (b >> 7) & 0xFF + b ^= (b << 7) & 0xFF + b ^= (b >> 7) & 0xFF + return b + + +def build_swizzle_lut(magic: int) -> tuple[bytes, bytes]: + """Build lookup tables for swizzling and unswizzling.""" + swizzle = bytes([swizzle_byte(i, magic) for i in range(256)]) + unswizzle = bytes([unswizzle_byte(i, magic) for i in range(256)]) + return swizzle, unswizzle + + +def encode14(v: int) -> bytes: + """Encode a 14-bit value.""" + v = int(v) & 0x3FFF + return bytes([(v >> 7) & 0x7F, v & 0x7F]) + + +def encode35(v: int) -> bytes: + """Encode a signed 35-bit coordinate as 5 bytes.""" + v = int(v) & 0x7FFFFFFFF + return bytes( + [ + (v >> 28) & 0x7F, + (v >> 21) & 0x7F, + (v >> 14) & 0x7F, + (v >> 7) & 0x7F, + v & 0x7F, + ] + ) + + +def decode14(data: bytes) -> int: + """Decode a 14-bit value from 2 bytes.""" + val = ((data[0] & 0x7F) << 7) | (data[1] & 0x7F) + if val & 0x2000: + val -= 0x4000 + return val + + +def decodeu14(data: bytes) -> int: + """Decode an unsigned 14-bit value from 2 bytes.""" + return ((data[0] & 0x7F) << 7) | (data[1] & 0x7F) + + +def decode35(data: bytes) -> int: + """Decode a signed 35-bit coordinate from 5 bytes.""" + val = ( + ((data[0] & 0x7F) << 28) + | ((data[1] & 0x7F) << 21) + | ((data[2] & 0x7F) << 14) + | ((data[3] & 0x7F) << 7) + | (data[4] & 0x7F) + ) + if val & 0x400000000: + val -= 0x800000000 + return val + + +def decodeu35(data: bytes) -> int: + """Decode an unsigned 35-bit value from 5 bytes.""" + return ( + ((data[0] & 0x7F) << 28) + | ((data[1] & 0x7F) << 21) + | ((data[2] & 0x7F) << 14) + | ((data[3] & 0x7F) << 7) + | (data[4] & 0x7F) + ) + + +def parse_mem(data: bytes) -> int: + """Parse memory address from 2 bytes (big-endian).""" + return (data[0] << 8) | data[1] + + +def calculate_checksum(data: bytes) -> int: + """Calculate 16-bit checksum (sum of all bytes).""" + return sum(data) & 0xFFFF + + +def decode_abs_coords(data: bytes) -> tuple[float, float]: + """ + Decode absolute X,Y coordinates from 10 bytes. + Returns coordinates in millimeters. + """ + x_um = decode35(data[:5]) + y_um = decode35(data[5:10]) + return x_um / UM_PER_MM, y_um / UM_PER_MM + + +def decode_rel_coords(data: bytes) -> tuple[float, float]: + """ + Decode relative X,Y coordinates from 4 bytes. + Returns coordinates in millimeters. + """ + dx_um = decode14(data[:2]) + dy_um = decode14(data[2:4]) + return dx_um / UM_PER_MM, dy_um / UM_PER_MM + + +def frame_packet(payload: bytes) -> bytes: + """ + Create a framed packet with checksum prefix. + + Args: + payload: The payload bytes to frame + + Returns: + Complete packet with 2-byte checksum prefix + payload + """ + checksum = calculate_checksum(payload) + return bytes([checksum >> 8, checksum & 0xFF]) + payload + + +def validate_packet(data: bytes) -> tuple[bool, bytes, int, int]: + """ + Validate a complete packet and extract payload. + + Args: + data: Complete packet with checksum prefix + + Returns: + Tuple of (is_valid, payload, expected_checksum, actual_checksum) + """ + if len(data) < 2: + return False, b"", 0, 0 + + checksum_received = (data[0] << 8) | data[1] + payload = data[2:] + checksum_calculated = calculate_checksum(payload) + + return ( + checksum_received == checksum_calculated, + payload, + checksum_received, + checksum_calculated, + ) + + +def estimate_packet_length(payload: bytes) -> int: + """ + Estimate the expected packet length from the payload. + + Args: + payload: Payload bytes (without checksum prefix) + + Returns: + Expected payload length, or -1 if unknown/insufficient data + """ + if len(payload) < 1: + return -1 + + cmd = payload[0] + + if cmd == 0xCC or cmd == 0xCD or cmd == 0xCE: + return 1 + + if cmd == 0xD0: + if len(payload) < 2: + return -1 + return 2 + + if cmd == 0xD7: + return 1 + + if cmd == 0xD8: + if len(payload) < 2: + return -1 + return 2 + + if cmd == 0xD9: + if len(payload) < 2: + return -1 + return 2 + + if cmd == 0xD9: + if len(payload) < 2: + return -1 + sub = payload[1] + if sub in (0x00, 0x01, 0x02, 0x03, 0x50, 0x51, 0x52, 0x53): + return 8 + if sub == 0x0F: + return 8 + if sub in (0x10, 0x60): + return 13 + if sub in (0x30, 0x70): + return 18 + return 8 + + if cmd == 0xDA: + if len(payload) < 4: + return -1 + sub = payload[1] + if sub == 0x00: + return 4 + if sub == 0x01: + return 4 + if sub == 0x04: + if len(payload) < 7: + return -1 + return 7 + if sub == 0x05: + if len(payload) < 8: + return -1 + return 8 + if sub == 0x06: + if len(payload) < 6: + return -1 + return 6 + if sub == 0x07: + if len(payload) < 7: + return -1 + return 7 + if sub == 0x10: + if len(payload) < 7: + return -1 + extra = decode14(payload[4:]) + return 7 + extra + if sub in DA_4_BYTE_RESPONSE_SUBCOMMANDS: + return 4 + if sub == 0x54: + if len(payload) < 6: + return -1 + return 6 + if sub == 0x55: + if len(payload) < 5: + return -1 + return 5 + if sub in DA_VARIABLE_4_BYTE_SUBCOMMANDS: + return 4 + return 4 + + if cmd == 0xA5: + if len(payload) < 3: + return -1 + return 3 + + if cmd == 0xA7: + return 2 + + if cmd in (0xC3, 0xC6, 0xC7): + if len(payload) < 5: + return -1 + extra = decode14(payload[3:]) + return 5 + extra + + if cmd == 0xCA: + if len(payload) < 11: + return -1 + return 11 + + if cmd in (0xE5, 0xE7, 0xE8): + if len(payload) < 2: + return -1 + return 2 + + if cmd == 0x88: + if len(payload) < 11: + return -1 + return 11 + + if cmd == 0x89: + if len(payload) < 5: + return -1 + return 5 + + return len(payload) diff --git a/rayforge/machine/driver/smoothie.py b/rayforge/machine/driver/smoothie.py new file mode 100644 index 000000000..6279df730 --- /dev/null +++ b/rayforge/machine/driver/smoothie.py @@ -0,0 +1,489 @@ +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + cast, +) + +from ...context import RayforgeContext +from ...core.varset import HostnameVar, PortVar, VarSet +from ...core.varset.hostnamevar import is_valid_hostname_or_ip +from ...pipeline.encoder.base import EncodedOutput, OpsEncoder +from ...pipeline.encoder.gcode import GcodeEncoder +from ..transport import TelnetTransport, TransportStatus +from .driver import ( + Axis, + DeviceStatus, + Driver, + DriverPrecheckError, + DriverSetupError, + Pos, +) +from .grbl.grbl_util import parse_state + +if TYPE_CHECKING: + from raygeo.ops import Ops + + from ...core.doc import Doc + from ..models.laser import Laser + from ..models.machine import Machine + + +logger = logging.getLogger(__name__) + + +# Smoothie uses P1 for G54, P2 for G55, etc. +_wcs_to_p_map = { + "G54": 1, + "G55": 2, + "G56": 3, + "G57": 4, + "G58": 5, + "G59": 6, +} + + +class SmoothieDriver(Driver): + """ + Handles Smoothie-based devices via Telnet + """ + + label = _("Smoothie") + subtitle = _("Smoothieware via a Telnet connection") + supports_settings = False + reports_granular_progress = True + + def __init__(self, context: RayforgeContext, machine: "Machine"): + super().__init__(context, machine) + self.telnet: TelnetTransport | None = None + self.host: str | None = None + self.port: int | None = None + self.keep_running = False + self._connection_task: asyncio.Task | None = None + self._ok_event = asyncio.Event() + + @property + def machine_space_wcs(self) -> str: + return "G53" + + @property + def machine_space_wcs_display_name(self) -> str: + return _("Machine Coordinates (G53)") + + @property + def resource_uri(self) -> str | None: + if self.host: + return f"tcp://{self.host}:{self.port}" + return None + + @classmethod + def precheck(cls, **kwargs: Any) -> None: + """Checks if the hostname is a valid format.""" + host = cast(str, kwargs.get("host", "")) + if not is_valid_hostname_or_ip(host): + raise DriverPrecheckError( + _("Invalid hostname or IP address: '{host}'").format(host=host) + ) + + @classmethod + def get_setup_vars(cls) -> "VarSet": + return VarSet( + vars=[ + HostnameVar( + key="host", + label=_("Hostname"), + description=_("The IP address or hostname of the device"), + ), + PortVar( + key="port", + label=_("Port"), + description=_("The Telnet port number"), + default=23, + ), + ] + ) + + @classmethod + def create_encoder(cls, machine: "Machine") -> "OpsEncoder": + """Returns a GcodeEncoder configured for the machine's dialect.""" + assert machine.dialect is not None + return GcodeEncoder(machine.dialect) + + def get_setting_vars(self) -> list["VarSet"]: + return [VarSet()] + + def _setup_implementation(self, **kwargs: Any) -> None: + host = cast(str, kwargs.get("host", "")) + port = kwargs.get("port", 23) + + if not host: + raise DriverSetupError(_("Hostname must be configured.")) + + self.host = host + self.port = port + + # Initialize transports + self.telnet = TelnetTransport(host, port) + self.telnet.received.connect(self.on_telnet_data_received) + self.telnet.status_changed.connect(self.on_telnet_status_changed) + + async def cleanup(self): + self.keep_running = False + if self._connection_task: + self._connection_task.cancel() + if self.telnet: + await self.telnet.disconnect() + self.telnet.received.disconnect(self.on_telnet_data_received) + self.telnet.status_changed.disconnect( + self.on_telnet_status_changed + ) + self.telnet = None + await super().cleanup() + + async def _connect_implementation(self): + self.keep_running = True + self._connection_task = asyncio.create_task(self._connection_loop()) + + async def _connection_loop(self) -> None: + while self.keep_running: + if not self.telnet: + self.on_telnet_status_changed( + self, TransportStatus.ERROR, "Driver not configured" + ) + await asyncio.sleep(5) + continue + + self.on_telnet_status_changed(self, TransportStatus.CONNECTING) + try: + await self.telnet.connect() + # The transport handles the connection loop. + # We just need to wait here until cleanup. + while self.keep_running: + await self._send_and_wait(b"?", wait_for_ok=False) + await asyncio.sleep(1) + + except asyncio.CancelledError: + break # cleanup is called + except Exception as e: # noqa: BLE001 - connection loop boundary + self.on_telnet_status_changed( + self, TransportStatus.ERROR, str(e) + ) + finally: + if self.telnet: + await self.telnet.disconnect() + + if not self.keep_running: + break + + self.on_telnet_status_changed(self, TransportStatus.SLEEPING) + await asyncio.sleep(5) + + async def _send_and_wait(self, cmd: bytes, wait_for_ok: bool = True): + if not self.telnet: + return + if wait_for_ok: + self._ok_event.clear() + + cmd_str = cmd.decode().strip() + if cmd_str: + logger.info(cmd_str, extra=self._log_extra("USER_COMMAND")) + logger.debug( + f"TX: {cmd!r}", + extra={"log_category": "RAW_IO", "direction": "TX", "data": cmd}, + ) + await self.telnet.send(cmd) + + if wait_for_ok: + try: + # Set a 10s timeout to avoid deadlocks + await asyncio.wait_for(self._ok_event.wait(), 10.0) + except asyncio.TimeoutError as e: + raise ConnectionError( + f"Command '{cmd.decode()}' not confirmed" + ) from e + + async def run( + self, + encoded: EncodedOutput, + doc: "Doc", + ops: "Ops", + on_command_done: Callable[[int], None | Awaitable[None]] | None = None, + ) -> None: + gcode_lines = encoded.text.splitlines() + op_map = encoded.op_map + + # We assume ops are indexed 0..N-1. + num_ops = op_map.op_count if op_map else 0 + + try: + for op_index in range(num_ops): + # Find all g-code lines for this specific op_index + line_start, line_count = 0, 0 + if op_map and op_index < op_map.op_count: + line_start, line_count = op_map.span_for_op(op_index) + + if not line_count: + # If an op generates no g-code, still report it as done. + if on_command_done: + result = on_command_done(op_index) + if inspect.isawaitable(result): + await result + continue + + for line_idx in range(line_start, line_start + line_count): + line = gcode_lines[line_idx].strip() + if line: + await self._send_and_wait(line.encode()) + + # After all lines for this op are sent and confirmed, + # fire the callback. + if on_command_done: + result = on_command_done(op_index) + if inspect.isawaitable(result): + await result + + except Exception as e: + self.on_telnet_status_changed(self, TransportStatus.ERROR, str(e)) + raise + finally: + self.job_finished.send(self) + + async def run_raw(self, machine_code: str) -> None: + """ + Executes a raw G-code string by sending it line-by-line to the + device and waiting for an 'ok' after each line. + """ + lines = [ + line.strip() for line in machine_code.splitlines() if line.strip() + ] + if not lines: + return + try: + for line in lines: + await self._send_and_wait(line.encode()) + except Exception as e: + self.on_telnet_status_changed(self, TransportStatus.ERROR, str(e)) + raise + finally: + self.job_finished.send(self) + + async def set_hold(self, hold: bool = True) -> None: + if hold: + await self._send_and_wait(b"!") + else: + await self._send_and_wait(b"~") + + async def cancel(self) -> None: + # Send Ctrl+C + await self._send_and_wait(b"\x03") + + def can_home(self, axis: Axis | None = None) -> bool: + """Smoothie supports homing for all axes.""" + return True + + async def home(self, axes: Axis | None = None) -> None: + """ + Homes the specified axes or all axes if none specified. + + Args: + axes: Optional axis or combination of axes to home. If None, + homes all axes. Can be a single Axis or multiple axes + using binary operators (e.g. Axis.X|Axis.Y) + """ + dialect = self.dialect + if axes is None: + await self._send_and_wait(dialect.home_all.encode()) + return + + # Handle multiple axes - home them one by one + for axis in axes: + cmd = dialect.home_axis.format(axis_letter=axis.name) + await self._send_and_wait(cmd.encode()) + + async def move_to(self, pos_x, pos_y) -> None: + dialect = self.dialect + cmd = dialect.move_to.format( + x=self._to_machine_length(float(pos_x)), + y=self._to_machine_length(float(pos_y)), + ) + await self._send_and_wait(cmd.encode()) + + def can_jog(self, axis: Axis | None = None) -> bool: + """Smoothie supports jogging for all axes.""" + return True + + async def jog(self, speed: int, **deltas: float) -> None: + """ + Jogs the machine using G91 incremental mode. + + Args: + speed: The jog speed in mm/min + **deltas: Axis names and distances (e.g. x=10.0, y=5.0) + """ + dialect = self.dialect + parts = [dialect.jog.format(speed=self._to_machine_speed(speed))] + + for axis_name, distance in deltas.items(): + parts.append( + f"{axis_name.upper()}{self._to_machine_length(distance)}" + ) + + if len(parts) == 1: + return + + cmd = " ".join(parts) + await self._send_and_wait(cmd.encode()) + + async def select_tool(self, tool_number: int) -> None: + """Sends a tool change command for the given tool number.""" + dialect = self.dialect + cmd = dialect.tool_change.format(tool_number=tool_number) + await self._send_and_wait(cmd.encode()) + + async def clear_alarm(self) -> None: + dialect = self.dialect + await self._send_and_wait(dialect.clear_alarm.encode()) + + async def set_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + # Get the dialect for power control commands + dialect = self.dialect + + if percent <= 0: + # Disable power + cmd = dialect.laser_off + else: + # Enable power with specified percentage + power_abs = percent * head.max_power + cmd = dialect.laser_on.format(power=power_abs) + + await self._send_and_wait(cmd.encode("utf-8")) + + async def set_focus_power(self, head: "Laser", percent: float) -> None: + """ + Sets the laser power for focus mode using the focus_laser_on command. + + Args: + head: The laser head to control. + percent: Power percentage (0.0-1.0). 0 disables power. + """ + dialect = self.dialect + + if percent <= 0: + cmd = dialect.laser_off + else: + power_abs = percent * head.max_power + cmd = dialect.focus_laser_on.format(power=power_abs) + + await self._send_and_wait(cmd.encode("utf-8")) + + def on_telnet_data_received(self, sender, data: bytes): + logger.debug( + f"RX: {data!r}", + extra={"log_category": "RAW_IO", "direction": "RX", "data": data}, + ) + data_str = data.decode("utf-8") + for line in data_str.splitlines(): + is_status_report = line.startswith("<") and line.endswith(">") + log_category = ( + "STATUS_POLL" if is_status_report else "MACHINE_EVENT" + ) + logger.info(line, extra={"log_category": log_category}) + if "ok" in line: + self._ok_event.set() + self.command_status_changed.send( + self, status=TransportStatus.IDLE + ) + + if not is_status_report: + continue + state = parse_state( + line, self.state, lambda message: logger.info(message) + ) + if state != self.state: + self.state = state + logger.info( + f"Device state changed: {self.state.status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) + + def on_telnet_status_changed( + self, sender, status: TransportStatus, message: str | None = None + ): + log_data = f"Connection status: {status.name}" + if message: + log_data += f" - {message}" + logger.info(log_data, extra=self._log_extra("MACHINE_EVENT")) + self.connection_status_changed.send( + self, status=status, message=message + ) + if ( + status + in [ + TransportStatus.DISCONNECTED, + TransportStatus.ERROR, + ] + and self.state.status != DeviceStatus.UNKNOWN + ): + self.state.status = DeviceStatus.UNKNOWN + logger.info( + f"Device state changed: {self.state.status.name}", + extra=self._log_extra("STATE_CHANGE"), + ) + self.state_changed.send(self, state=self.state) + + async def read_settings(self) -> None: + raise NotImplementedError( + "Device settings not implemented for this driver" + ) + + async def write_setting(self, key: str, value: Any) -> None: + raise NotImplementedError( + "Device settings not implemented for this driver" + ) + + async def set_wcs_offset( + self, wcs_slot: str, x: float, y: float, z: float + ) -> None: + """Sets a WCS offset using Smoothie's G10 L20 command.""" + if wcs_slot not in _wcs_to_p_map: + raise ValueError(f"Invalid WCS slot: {wcs_slot}") + + p_num = _wcs_to_p_map[wcs_slot] + dialect = self.dialect + cmd = dialect.set_wcs_offset.format( + p_num=p_num, + x=self._to_machine_length(x), + y=self._to_machine_length(y), + z=self._to_machine_length(z), + ) + await self._send_and_wait(cmd.encode("utf-8")) + + async def read_wcs_offsets(self) -> dict[str, Pos]: + """Reading all WCS offsets is not supported by Smoothie.""" + raise NotImplementedError( + "Reading all WCS offsets is not reliably supported " + "by Smoothieware." + ) + + async def run_probe_cycle( + self, axis: Axis, max_travel: float, feed_rate: int + ) -> Pos | None: + """ + Probing is not implemented due to difficulty in reliably capturing + real-time probe position feedback over the standard Telnet protocol. + """ + raise NotImplementedError( + "Probing is not implemented for the Smoothie driver via Telnet." + ) diff --git a/rayforge/machine/job_monitor.py b/rayforge/machine/job_monitor.py new file mode 100644 index 000000000..e295a7797 --- /dev/null +++ b/rayforge/machine/job_monitor.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import logging +import time +from collections import deque +from typing import TYPE_CHECKING, Any + +from blinker import Signal +from raygeo.geo.types import Point3D +from raygeo.ops.types import CommandCategory + +if TYPE_CHECKING: + from raygeo.ops import Ops + + +logger = logging.getLogger(__name__) + + +class JobMonitor: + """ + Tracks and reports the progress of a machine job based on Ops data. + + This class calculates the total distance of a job from an Ops object and + updates the progress as individual operations complete. It emits a signal + with detailed metrics whenever the progress changes. + """ + + def __init__(self, ops: Ops): + """ + Initializes the JobMonitor. + + Args: + ops: The Ops object representing the job to be monitored. + """ + self.ops = ops + self.total_distance = ops.distance() + self.traveled_distance = 0.0 + self.start_time = time.monotonic() + + # Create a map from op_index to the distance of that op + self._distance_map: dict[int, float] = {} + last_point: Point3D | None = None + for i in range(ops.len()): + dist = ops.distance_at(i, last_point) + self._distance_map[i] = dist + if ops.category(i) == CommandCategory.MOVING: + last_point = ops.endpoint(i) + + # Deque for calculating recent average speed. + # Stores (timestamp, distance). + # A larger maxlen provides more smoothing but is slower to react to + # speed changes. 20 is a reasonable starting point. + self._samples = deque(maxlen=200) + + self.progress_updated = Signal() + + @property + def metrics(self) -> dict[str, Any]: + """Returns the current progress metrics as a dictionary.""" + progress_fraction = ( + self.traveled_distance / self.total_distance + if self.total_distance > 0 + else 1.0 + ) + + eta_seconds = None + # Calculate ETA based on recent average speed to avoid fluctuations + # caused by pauses or non-moving commands. + if len(self._samples) > 1: + start_time, start_dist = self._samples[0] + end_time, end_dist = self._samples[-1] + + delta_time = end_time - start_time + delta_dist = end_dist - start_dist + + if delta_time > 0.01 and delta_dist > 0: + recent_average_speed = delta_dist / delta_time + distance_remaining = ( + self.total_distance - self.traveled_distance + ) + if recent_average_speed > 0: + eta_seconds = distance_remaining / recent_average_speed + + return { + "total_distance": self.total_distance, + "traveled_distance": self.traveled_distance, + "progress_fraction": progress_fraction, + "eta_seconds": eta_seconds, + } + + def update_progress(self, op_index: int) -> None: + """ + Updates the progress based on a completed operation. + + Args: + op_index: The index of the Ops command that has finished. + """ + logger.debug(f"JobMonitor: progress updated for op_index {op_index}.") + + distance_for_op = self._distance_map.get(op_index, 0.0) + + # Always update traveled_distance (even if distance is 0) + self.traveled_distance += distance_for_op + + # Clamp to ensure we don't exceed total_distance due to float errors + self.traveled_distance = min( + self.traveled_distance, self.total_distance + ) + + # Only add a sample for ETA calculation if there's actual distance + if distance_for_op > 0.0: + # Add a new sample for the ETA calculation + self._samples.append((time.monotonic(), self.traveled_distance)) + + logger.debug( + f" -> New progress: {self.metrics['progress_fraction']:.2f}" + ) + # Always emit the signal, even for operations with 0 distance + self.progress_updated.send(self, metrics=self.metrics) + + def mark_as_complete(self) -> None: + """ + Marks the job as fully complete, setting progress to 100%. + """ + self.traveled_distance = self.total_distance + logger.debug("JobMonitor: marked as complete.") + self.progress_updated.send(self, metrics=self.metrics) diff --git a/rayforge/machine/kinematic_mapping.py b/rayforge/machine/kinematic_mapping.py new file mode 100644 index 000000000..d0b38acc1 --- /dev/null +++ b/rayforge/machine/kinematic_mapping.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +import numpy as np +from raygeo.ops import Ops +from raygeo.ops.axis import Axis + +from ..core.layer import Layer +from .kinematic_math import KinematicMath +from .models.rotary_module import RotaryMode, RotaryType + +if TYPE_CHECKING: + from ..core.doc import Doc + from .assembly import Assembly + from .models.machine import Machine + from .models.rotary_module import RotaryModule + + +def _resolve_rotary_layer_by_uid(layer_uid: str, doc): + """Return the Layer if it has rotary enabled.""" + descendant = doc.find_descendant_by_uid(layer_uid) + if not isinstance(descendant, Layer): + return None + if not descendant.rotary_enabled: + return None + return descendant + + +def _is_valid_replacement_module(module): + """Check whether an AXIS_REPLACEMENT module is valid for mapping. + + Modules in AXIS_REPLACEMENT mode are only valid when they have a + positive ``mm_per_rotation`` *or* their target axis is one of the + standard XYZ axes (which can accept degree values directly). + """ + if module.mode != RotaryMode.AXIS_REPLACEMENT: + return True + if module.mm_per_rotation > 0: + return True + return module.axis in KinematicMapping._AXIS_TO_INDEX + + +@dataclass(frozen=True) +class RotaryAxisConfig: + """Resolved rotary configuration for a layer. + + ``source_axis`` is the world-space axis whose movement is mapped + onto ``rotary_axis``. ``module`` is the effective rotary module + resolved for the layer (``None`` when rotary is disabled or no + module applies). + """ + + source_axis: Axis + rotary_axis: Axis | None + module: RotaryModule | None = None + + +def resolve_layer_rotary( + layer: Layer | None, machine: Machine +) -> RotaryAxisConfig: + """Resolve the rotary axis configuration for *layer*. + + Single source of truth shared by ``OpPlayer``, ``SnapshotBuilder``, + and the 3D canvas. Rotary is only active for layers with + ``rotary_enabled``; the effective module is resolved through + ``machine.get_rotary_module_for_layer`` (including default-module + fallback). TRUE_4TH_AXIS modules map the source axis onto + ``module.axis``; all other modes use ``Axis.Y`` as the rotary axis. + """ + if not isinstance(layer, Layer) or not layer.rotary_enabled: + return RotaryAxisConfig(Axis.Y, None, None) + module = machine.get_rotary_module_for_layer(layer) + if module is None: + return RotaryAxisConfig(Axis.Y, None, None) + if module.mode == RotaryMode.TRUE_4TH_AXIS: + rotary_axis = module.axis + else: + rotary_axis = Axis.Y + return RotaryAxisConfig(Axis.Y, rotary_axis, module) + + +def build_layer_assembly( + machine: Machine, + layer: Layer | None = None, +) -> Assembly: + """Build a throwaway assembly for *layer*'s rotary config. + + Reads only: it resolves the layer's rotary module via + ``machine.get_rotary_module_for_layer`` and builds a fresh assembly + without mutating the machine. When *layer* is None or flat, a flat + assembly (no rotary) is returned. + """ + modules = None + if layer is not None and layer.rotary_enabled: + module = machine.get_rotary_module_for_layer(layer) + if module is not None: + modules = {module.uid: module} + return machine.build_assembly_for_rotary(modules) + + +class KinematicMapping: + """Applies rotary kinematic mapping to world-space ops. + + Converts Y-axis mu values to degrees and stores them in + extra_axes. The source axis is always Y. + + Operates on world-space ops, before the world→machine transform. + """ + + def __init__( + self, + rotary_axis: Axis, + diameter: float, + gear_ratio: float = 1.0, + reverse: bool = False, + axis_position: float = 0.0, + axis_position_3d: np.ndarray | None = None, + cylinder_dir: np.ndarray | None = None, + replaced_axis: Axis | None = None, + ): + self.rotary_axis = rotary_axis + self.diameter = diameter + self.gear_ratio = gear_ratio + self.reverse = reverse + self.replaced_axis = replaced_axis + self.axis_position = axis_position + if axis_position_3d is not None: + self.axis_position_3d = axis_position_3d.astype(np.float64) + else: + v = np.zeros(3, dtype=np.float64) + v[1] = axis_position + self.axis_position_3d = v + if cylinder_dir is not None: + self.cylinder_dir = cylinder_dir.astype(np.float64) + else: + self.cylinder_dir = np.array([1.0, 0.0, 0.0]) + + @classmethod + def from_rotary_module( + cls, + module: RotaryModule, + diameter: float, + apply_gear_ratio: bool = True, + ) -> KinematicMapping | None: + if module.mode == RotaryMode.TRUE_4TH_AXIS: + rotary_axis = module.axis + replaced_axis = None + else: + rotary_axis = Axis.Y + replaced_axis = module.axis + + if apply_gear_ratio: + ratio = KinematicMath.gear_ratio( + module.rotary_type == RotaryType.ROLLERS, + diameter, + module.roller_diameter, + ) + else: + ratio = 1.0 + + rot3 = module.transform[:3, :3].astype(np.float64).copy() + for col in range(3): + norm = np.linalg.norm(rot3[:, col]) + if norm > 1e-12: + rot3[:, col] /= norm + + mod_pos = module.transform[:3, 3].astype(np.float64) + axis_position_3d = mod_pos + rot3 @ module.axis_position + cylinder_dir = rot3[:, 0].copy() + norm = np.linalg.norm(cylinder_dir) + if norm > 1e-12: + cylinder_dir /= norm + + axis_position = float(axis_position_3d[1]) + + return cls( + rotary_axis=rotary_axis, + diameter=diameter, + gear_ratio=ratio, + reverse=module.reverse_axis, + axis_position=axis_position, + axis_position_3d=axis_position_3d, + cylinder_dir=cylinder_dir, + replaced_axis=replaced_axis, + ) + + def _mm_to_degrees(self, mm: float) -> float: + return KinematicMath.mm_to_degrees( + mm, + self.diameter, + gear_ratio=self.gear_ratio, + reverse=self.reverse, + ) + + _AXIS_TO_INDEX: ClassVar[dict[Axis, int]] = { + Axis.X: 0, + Axis.Y: 1, + Axis.Z: 2, + } + + def apply(self, ops: Ops) -> None: + replaced_idx = ( + self._AXIS_TO_INDEX.get(self.replaced_axis) + if self.replaced_axis is not None + else None + ) + + def on_endpoint( + end: list[float], + extra_axes: dict[Axis, float], + ) -> None: + degrees = self._mm_to_degrees(end[1]) + extra_axes[self.rotary_axis] = degrees + end[1] = float( + self.axis_position_3d[1] + end[0] * self.cylinder_dir[1] + ) + if replaced_idx is not None: + end[replaced_idx] = 0.0 + + def on_aux_point(point: list[float]) -> None: + point[1] = self._mm_to_degrees(point[1]) + + ops.transform_moving(on_endpoint, on_aux_point) + + @staticmethod + def apply_to_job_ops( + ops: Ops, + doc: Doc, + machine: Machine, + apply_scaled_mu: bool = False, + apply_gear_ratio: bool = True, + ) -> None: + """Apply per-layer rotary axis mapping to a full job's ops. + + Walks the command stream looking for ``LayerStartCommand`` + markers. For each layer that has rotary enabled, resolves the + layer's ``RotaryModule`` and applies a ``KinematicMapping`` + (Y→degrees) to that layer's movement commands in-place. + + This is the single entry point for both the UI path (3D canvas / + OpPlayer via ``job_compute.py``) and the G-code encoding path + (via ``Machine.encode_ops()``). + + Args: + ops: Assembled ops in world-space coordinates. Modified + in-place. + doc: The document that owns the layers (used to look up + per-layer rotary configuration). + machine: The machine whose ``rotary_modules`` dict is used + to resolve module UIDs. + apply_scaled_mu: When *True*, also convert degrees to + scaled machine units for ``AXIS_REPLACEMENT`` layers. + The G-code path passes ``False`` and defers this step + until *after* the world→machine coordinate transform + (see ``Machine._apply_replacement_downstream()``). + The UI path passes ``False`` because the 3D canvas + works in world-space degrees. + apply_gear_ratio: When *True*, the roller gear ratio is + included in the degrees-to-mu conversion. Pass + ``False`` for visualisation paths that should represent + the object geometry directly, without the roller + encoding. + """ + + if not machine.rotary_modules or not doc.has_rotary_layer: + return + + def _on_layer(layer_uid: str, layer_ops: Ops) -> None: + layer = _resolve_rotary_layer_by_uid(layer_uid, doc) + if layer is None: + return + module = machine.get_rotary_module_for_layer(layer) + if module is None or not _is_valid_replacement_module(module): + return + mapping = KinematicMapping.from_rotary_module( + module, + layer.rotary_diameter, + apply_gear_ratio=apply_gear_ratio, + ) + if mapping is None: + return + mapping.apply(layer_ops) + if apply_scaled_mu and module.mode == RotaryMode.AXIS_REPLACEMENT: + KinematicMapping.degrees_to_mm_pass( + layer_ops, + module.mm_per_rotation, + target_axis=module.axis, + ) + + ops.transform_layers(_on_layer) + + @staticmethod + def degrees_to_mm_pass( + ops: Ops, + mm_per_rotation: float, + target_axis: Axis = Axis.Y, + ) -> None: + idx = KinematicMapping._AXIS_TO_INDEX.get(target_axis, 1) + null_source = ( + target_axis != Axis.Y + and target_axis in KinematicMapping._AXIS_TO_INDEX + ) + + def on_endpoint( + end: list[float], + extra_axes: dict[Axis, float], + ) -> None: + degrees = extra_axes.pop(Axis.Y, None) + if degrees is None: + return + end[idx] = KinematicMath.degrees_to_mm(degrees, mm_per_rotation) + if null_source: + end[1] = 0.0 + + def on_aux_point(point: list[float]) -> None: + if idx < len(point): + point[idx] = KinematicMath.degrees_to_mm( + point[idx], mm_per_rotation + ) + if null_source: + point[1] = 0.0 + + ops.transform_moving(on_endpoint, on_aux_point) diff --git a/rayforge/machine/kinematic_math.py b/rayforge/machine/kinematic_math.py new file mode 100644 index 000000000..d6d62145d --- /dev/null +++ b/rayforge/machine/kinematic_math.py @@ -0,0 +1,83 @@ +import math + + +class KinematicMath: + @staticmethod + def effective_diameter(diameter: float, z: float) -> float: + return diameter + 2.0 * z + + @staticmethod + def gear_ratio( + is_rollers: bool, + rotary_diameter: float, + roller_diameter: float, + ) -> float: + if is_rollers and roller_diameter > 0 and rotary_diameter > 0: + return rotary_diameter / roller_diameter + return 1.0 + + @staticmethod + def mm_to_degrees(mm, effective_diameter, gear_ratio=1.0, reverse=False): + """Convert a surface distance (mm) on the cylinder to degrees.""" + if effective_diameter <= 0: + return 0.0 + circumference = effective_diameter * math.pi + degrees = (mm / circumference) * 360.0 * gear_ratio + if reverse: + degrees = -degrees + return degrees + + @staticmethod + def degrees_to_mm(degrees, mm_per_rotation, gear_ratio=1.0, reverse=False): + """Convert degrees to linear mm via the firmware travel per + rotation (mm).""" + if mm_per_rotation <= 0: + return degrees + mm = degrees * mm_per_rotation / 360.0 / gear_ratio + if reverse: + mm = -mm + return mm + + @staticmethod + def surface_mm_to_rotation_mm( + mm, + effective_diameter, + mm_per_rotation, + gear_ratio=1.0, + reverse=False, + ): + """Convert cylinder-surface mm to rotation-axis mm via the + firmware travel per rotation.""" + if mm_per_rotation <= 0: + return mm + if effective_diameter <= 0: + return 0.0 + scaled = ( + mm * mm_per_rotation / (math.pi * effective_diameter) * gear_ratio + ) + if reverse: + scaled = -scaled + return scaled + + @staticmethod + def rotation_mm_to_surface_mm( + rotation_mm, + effective_diameter, + mm_per_rotation, + gear_ratio=1.0, + reverse=False, + ): + """Convert rotation-axis mm back to cylinder-surface mm.""" + if mm_per_rotation <= 0: + return rotation_mm + if effective_diameter <= 0: + return 0.0 + mm = ( + rotation_mm + * (math.pi * effective_diameter) + / mm_per_rotation + / gear_ratio + ) + if reverse: + mm = -mm + return mm diff --git a/rayforge/machine/kinematics.py b/rayforge/machine/kinematics.py new file mode 100644 index 000000000..9c272cf1a --- /dev/null +++ b/rayforge/machine/kinematics.py @@ -0,0 +1,225 @@ +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +from raygeo.geo.types import Point3D +from raygeo.ops.axis import Axis + +from ..core.model import Model +from .assembly import Assembly, JointType, Link, LinkRole +from .models.axis import AxisSet + +if TYPE_CHECKING: + from ..simulator.machine_state import MachineState + +RotarySpec = tuple[Axis, float, np.ndarray, Model | None] +HeadSpec = tuple[Model | None, np.ndarray] + + +def _axis_direction(axis_letter: Axis) -> tuple[float, float, float]: + mapping = { + Axis.X: (1.0, 0.0, 0.0), + Axis.Y: (0.0, 1.0, 0.0), + Axis.Z: (0.0, 0.0, 1.0), + } + return mapping.get(axis_letter, (0.0, 0.0, 0.0)) + + +def _joint_axis_for_rotary(axis_letter: Axis) -> tuple[float, float, float]: + mapping = { + Axis.A: (1.0, 0.0, 0.0), + Axis.B: (0.0, 1.0, 0.0), + Axis.C: (0.0, 0.0, 1.0), + } + return mapping.get(axis_letter, (1.0, 0.0, 0.0)) + + +def build_assembly( + axis_set: AxisSet, + head_specs: list[HeadSpec] | None = None, + rotary_modules=None, +) -> Assembly: + if head_specs is None: + head_specs = [(None, np.eye(4, dtype=np.float64))] + links = [ + Link("base", parent=None, joint_type=JointType.FIXED), + ] + + _gantry_axes = {Axis.X, Axis.Y} + + parent = "base" + for axis_letter in _gantry_axes: + cfg = axis_set.get(axis_letter) + if cfg is None: + continue + name = f"gantry_{axis_letter.label}" + links.append( + Link( + name, + parent=parent, + joint_type=JointType.PRISMATIC, + joint_axis=_axis_direction(axis_letter), + driver_axis=axis_letter, + ) + ) + parent = name + + for i, (model, transform) in enumerate(head_specs): + links.append( + Link( + f"head_{i}", + parent=parent, + joint_type=JointType.PRISMATIC, + joint_axis=(0.0, 0.0, 1.0), + driver_axis=Axis.Z, + role=LinkRole.HEAD, + model=model, + model_transform=transform.copy(), + ) + ) + + if rotary_modules is not None: + active_axes = {rm.axis for rm in rotary_modules.values()} + else: + active_axes = set() + + rotary_list = [ + ac for ac in axis_set.rotary_axes if ac.letter in active_axes + ] + rotary_modules_list = [] + for i, axis_config in enumerate(rotary_list): + module = None + if rotary_modules is not None: + for rm in rotary_modules.values(): + if rm.axis == axis_config.letter: + module = rm + break + rotary_modules_list.append(module) + base_name = f"rotary_base_{i}" + chuck_name = f"rotary_chuck_{i}" + links.append( + Link( + base_name, + parent="base", + joint_type=JointType.FIXED, + local_transform=( + module.transform.copy() + if module + else np.eye(4, dtype=np.float64) + ), + ) + ) + links.append( + Link( + chuck_name, + parent=base_name, + joint_type=JointType.REVOLUTE, + joint_axis=(1.0, 0.0, 0.0), + driver_axis=axis_config.letter, + role=LinkRole.CHUCK, + model=( + Model(name="", path=Path(module.model_path)) + if module and module.model_path + else None + ), + ) + ) + + from .models.rotary_module import RotaryMode + + replacement_modules = [] + if rotary_modules is not None: + for rm in rotary_modules.values(): + if rm.mode == RotaryMode.AXIS_REPLACEMENT and not any( + rm.axis == ac.letter for ac in rotary_list + ): + replacement_modules.append(rm) + + for i, module in enumerate(replacement_modules): + idx = len(rotary_list) + i + base_name = f"rotary_base_{idx}" + chuck_name = f"rotary_chuck_{idx}" + joint_ax = (1.0, 0.0, 0.0) + links.append( + Link( + base_name, + parent="base", + joint_type=JointType.FIXED, + local_transform=module.transform.copy(), + ) + ) + links.append( + Link( + chuck_name, + parent=base_name, + joint_type=JointType.REVOLUTE, + joint_axis=joint_ax, + driver_axis=Axis.Y, + role=LinkRole.CHUCK, + model=( + Model(name="", path=Path(module.model_path)) + if module.model_path + else None + ), + ) + ) + rotary_modules_list.append(module) + + asm = Assembly(links) + for i, mod in enumerate(rotary_modules_list): + if mod is not None: + asm.set_chuck_axis_offset( + f"rotary_chuck_{i}", mod.axis_position.copy() + ) + for i, mod in enumerate(replacement_modules): + idx = len(rotary_list) + i + asm.set_chuck_axis_offset( + f"rotary_chuck_{idx}", mod.axis_position.copy() + ) + return asm + + +class Kinematics: + """Translates MachineState into spatial data via an Assembly.""" + + def __init__(self, assembly: Assembly): + self._assembly = assembly + + @property + def has_rotary(self) -> bool: + return self._assembly.has_rotary + + @property + def rotary_diameter(self) -> float | None: + return self._assembly.rotary_diameter + + @property + def cylinder_axis_index(self) -> int: + return self._assembly.cylinder_axis_index + + def cylinder_base_transform(self) -> np.ndarray: + return self._assembly.cylinder_base_transform() + + def head_rotary_positions( + self, + state: "MachineState", + diameter: float, + focal_distance: float = 0.0, + ) -> dict[str, np.ndarray]: + return self._assembly.head_rotary_positions( + state, diameter, focal_distance + ) + + def head_positions(self, state: "MachineState") -> dict[str, Point3D]: + return self._assembly.head_positions(state) + + def chuck_angles(self, state: "MachineState") -> dict[str, float]: + return self._assembly.chuck_angles(state) + + +def create_kinematics( + axis_set: AxisSet, + head_specs: list[HeadSpec] | None = None, + rotary_modules=None, +) -> Kinematics: + return Kinematics(build_assembly(axis_set, head_specs, rotary_modules)) diff --git a/rayforge/machine/models/__init__.py b/rayforge/machine/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/machine/models/axis.py b/rayforge/machine/models/axis.py new file mode 100644 index 000000000..0960741cf --- /dev/null +++ b/rayforge/machine/models/axis.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from raygeo.ops.axis import Axis + + +class AxisType(Enum): + LINEAR = "linear" + ROTARY = "rotary" + + +class AxisDirection(Enum): + NORMAL = "normal" + REVERSED = "reversed" + + +@dataclass +class AxisConfig: + letter: Axis + axis_type: AxisType + extents: tuple[float, float] + direction: AxisDirection = AxisDirection.NORMAL + gcode_letter: str | None = None + resolution: float = 0.01 + rotary_diameter: float | None = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "letter": self.letter.name, + "axis_type": self.axis_type.value, + "extents": list(self.extents), + "direction": self.direction.value, + "resolution": self.resolution, + } + if self.gcode_letter is not None: + result["gcode_letter"] = self.gcode_letter + if self.rotary_diameter is not None: + result["rotary_diameter"] = self.rotary_diameter + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AxisConfig": + return cls( + letter=Axis.from_name(data["letter"]), + axis_type=AxisType(data["axis_type"]), + extents=tuple(data["extents"]), + direction=AxisDirection( + data.get("direction", AxisDirection.NORMAL.value) + ), + gcode_letter=data.get("gcode_letter"), + resolution=data.get("resolution", 0.01), + rotary_diameter=data.get("rotary_diameter"), + ) + + +class AxisSet: + def __init__(self, configs: list[AxisConfig]): + self.configs = configs + self._rebuild_filters() + + def _rebuild_filters(self) -> None: + self.linear_axes = [ + c for c in self.configs if c.axis_type == AxisType.LINEAR + ] + self.rotary_axes = [ + c for c in self.configs if c.axis_type == AxisType.ROTARY + ] + + def add_config(self, config: AxisConfig) -> None: + self.configs.append(config) + self._rebuild_filters() + + def remove_config(self, axis: Axis) -> None: + self.configs = [c for c in self.configs if c.letter != axis] + self._rebuild_filters() + + def get(self, axis: Axis) -> AxisConfig | None: + for c in self.configs: + if c.letter == axis: + return c + return None + + def to_dict(self) -> dict[str, Any]: + return { + "configs": [c.to_dict() for c in self.configs], + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AxisSet": + configs = [AxisConfig.from_dict(c) for c in data["configs"]] + return cls(configs) + + @classmethod + def from_legacy( + cls, + axis_extents: tuple[float, float], + reverse_x: bool, + reverse_y: bool, + reverse_z: bool, + rotary_modules=None, + ) -> "AxisSet": + width, height = axis_extents + configs: list[AxisConfig] = [ + AxisConfig( + letter=Axis.X, + axis_type=AxisType.LINEAR, + extents=(0, width), + direction=( + AxisDirection.REVERSED + if reverse_x + else AxisDirection.NORMAL + ), + ), + AxisConfig( + letter=Axis.Y, + axis_type=AxisType.LINEAR, + extents=(0, height), + direction=( + AxisDirection.REVERSED + if reverse_y + else AxisDirection.NORMAL + ), + ), + AxisConfig( + letter=Axis.Z, + axis_type=AxisType.LINEAR, + extents=(-50, 50), + direction=( + AxisDirection.REVERSED + if reverse_z + else AxisDirection.NORMAL + ), + ), + ] + if rotary_modules: + for module in rotary_modules.values(): + configs.append( + AxisConfig( + letter=module.axis, + axis_type=AxisType.ROTARY, + extents=(0, 360), + rotary_diameter=module.default_diameter, + ) + ) + return cls(configs) diff --git a/rayforge/machine/models/colors.py b/rayforge/machine/models/colors.py new file mode 100644 index 000000000..43ae2afbc --- /dev/null +++ b/rayforge/machine/models/colors.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...core.color import ( + ColorRGBA, + ColorSet, + hex_to_rgba, +) +from ...image.util.srgb import create_lut_from_color + +if TYPE_CHECKING: + from .laser import Laser + + +@dataclass(frozen=True) +class OpsColorSet: + """ + ColorSet for a specific laser's operations. + + This class bridges the Laser model's color settings with the rendering + pipeline's ColorSet format. + """ + + laser_uid: str + cut_lut: np.ndarray + engrave_lut: np.ndarray + travel_rgba: ColorRGBA + zero_power_rgba: ColorRGBA + + @classmethod + def from_laser( + cls, + laser: "Laser", + theme_colors: ColorSet, + ) -> "OpsColorSet": + """ + Create an OpsColorSet from a Laser model and theme colors. + + Args: + laser: The Laser model with cut_color and raster_color properties + theme_colors: The theme's ColorSet for travel and zero_power colors + + Returns: + An OpsColorSet with laser-specific cut/raster LUTs + """ + cut_rgba = hex_to_rgba(laser.cut_color) + raster_rgba = hex_to_rgba(laser.raster_color) + + cut_lut = create_lut_from_color(cut_rgba) + engrave_lut = create_lut_from_color(raster_rgba) + + travel_rgba = theme_colors.get_rgba("travel") + zero_power_rgba = theme_colors.get_rgba("zero_power") + + return cls( + laser_uid=laser.uid, + cut_lut=cut_lut, + engrave_lut=engrave_lut, + travel_rgba=travel_rgba, + zero_power_rgba=zero_power_rgba, + ) + + def to_color_set(self) -> ColorSet: + """ + Convert to a standard ColorSet for use in the rendering pipeline. + + Returns: + A ColorSet with cut, engrave, travel, and zero_power entries + """ + data: dict[str, Any] = { + "cut": self.cut_lut, + "engrave": self.engrave_lut, + "travel": self.travel_rgba, + "zero_power": self.zero_power_rgba, + } + return ColorSet(_data=data) + + def to_dict(self) -> dict[str, Any]: + """Serialize the OpsColorSet to a dictionary.""" + return { + "laser_uid": self.laser_uid, + "cut_lut": { + "__type__": "numpy", + "data": self.cut_lut.tolist(), + "dtype": str(self.cut_lut.dtype), + }, + "engrave_lut": { + "__type__": "numpy", + "data": self.engrave_lut.tolist(), + "dtype": str(self.engrave_lut.dtype), + }, + "travel_rgba": { + "__type__": "tuple", + "data": self.travel_rgba, + }, + "zero_power_rgba": { + "__type__": "tuple", + "data": self.zero_power_rgba, + }, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "OpsColorSet": + """Deserialize an OpsColorSet from a dictionary.""" + return cls( + laser_uid=data["laser_uid"], + cut_lut=np.array( + data["cut_lut"]["data"], dtype=data["cut_lut"]["dtype"] + ), + engrave_lut=np.array( + data["engrave_lut"]["data"], dtype=data["engrave_lut"]["dtype"] + ), + travel_rgba=tuple(data["travel_rgba"]["data"]), + zero_power_rgba=tuple(data["zero_power_rgba"]["data"]), + ) diff --git a/rayforge/machine/models/controller.py b/rayforge/machine/models/controller.py new file mode 100644 index 000000000..1d6fe7326 --- /dev/null +++ b/rayforge/machine/models/controller.py @@ -0,0 +1,742 @@ +import asyncio +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, Optional, cast + +from blinker import Signal +from raygeo.geo.types import Point3D + +from ...core.varset import ValidationError +from ...shared.tasker import task_mgr +from ..driver import get_driver_cls +from ..driver.driver import ( + Axis, + DeviceConnectionError, + DeviceState, + DeviceStatus, + Driver, + DriverPrecheckError, +) +from ..driver.dummy import NoDeviceDriver +from ..transport import TransportStatus + +if TYPE_CHECKING: + from ...context import RayforgeContext + from ...core.varset import VarSet + from ...shared.tasker.context import ExecutionContext + from .laser import Laser + from .machine import Machine + + +logger = logging.getLogger(__name__) + + +class MachineController: + """ + Controller for machine logic and driver ownership. + Manages the driver lifecycle and emits signals that the Machine + re-emits to maintain backward compatibility. + """ + + def __init__( + self, + machine: "Machine", + context: "RayforgeContext", + scheduler, + ): + self.machine = machine + self.context = context + self._scheduler = scheduler + + # Controller signals - Machine will connect to these and re-emit + self.connection_status_changed = Signal() + self.state_changed = Signal() + self.job_finished = Signal() + self.command_status_changed = Signal() + self.wcs_updated = Signal() + self.laser_power_changed = Signal() + + self.driver: Driver = NoDeviceDriver(context, machine) + self._connect_driver_signals() + + # Track the last driver configuration to detect changes + self._last_driver_name = self.machine.driver_name + self._last_driver_args = self.machine.driver_args.copy() + + # The WCS that the device has actually confirmed via $G query. + # Used to guard _sync_wcs_offset_from_wco against stale WCO + # from status reports after a UI-initiated WCS switch. + self._confirmed_active_wcs: str | None = None + + # Listen to machine's changed signal to rebuild driver when + # driver configuration changes + self.machine.changed.connect(self._on_machine_changed) + + # If the machine already has a driver_name configured, rebuild + # the driver instance to match + if self.machine.driver_name: + task_mgr.add_coroutine( + self.rebuild_driver, + key=(self.machine.id, "rebuild-driver-on-init"), + ) + + async def connect(self): + """Public method to connect the driver.""" + if self.driver is not None: + await self.driver.connect() + + async def disconnect(self): + """Public method to disconnect the driver.""" + task_mgr.cancel_task((self.machine.id, "driver-connect")) + if self.driver is not None: + await self.driver.cleanup() + task_mgr.add_coroutine( + self.rebuild_driver, + key=(self.machine.id, "rebuild-driver"), + ) + + async def shutdown(self): + """ + Gracefully shuts down the machine's active driver and resources. + """ + logger.info( + f"Shutting down controller for machine '{self.machine.name}' " + f"(id:{self.machine.id})" + ) + task_mgr.cancel_task((self.machine.id, "driver-connect")) + task_mgr.cancel_task((self.machine.id, "rebuild-driver")) + task_mgr.cancel_task((self.machine.id, "rebuild-driver-on-change")) + if self.driver is not None: + await self.driver.cleanup() + self._disconnect_driver_signals() + self.machine.changed.disconnect(self._on_machine_changed) + self.context.dialect_mgr.dialects_changed.disconnect( + self._on_dialects_changed + ) + + def _on_machine_changed(self, sender=None, **kwargs): + """ + Callback when the machine's configuration changes. + Triggers driver rebuild only if the driver configuration has changed. + """ + current_driver_name = self.machine.driver_name + current_driver_args = self.machine.driver_args + + if ( + current_driver_name != self._last_driver_name + or current_driver_args != self._last_driver_args + ): + self._last_driver_name = current_driver_name + self._last_driver_args = current_driver_args.copy() + task_mgr.add_coroutine( + self.rebuild_driver, + key=(self.machine.id, "rebuild-driver-on-change"), + ) + + def _connect_driver_signals(self): + if self.driver is None: + return + self.driver.connection_status_changed.connect( + self._on_driver_connection_status_changed + ) + self.driver.state_changed.connect(self._on_driver_state_changed) + self.driver.command_status_changed.connect( + self._on_driver_command_status_changed + ) + self.driver.job_finished.connect(self._on_driver_job_finished) + self.driver.wcs_updated.connect(self._on_driver_wcs_updated) + self.driver.config_changed.connect(self._on_driver_config_changed) + self._on_driver_state_changed(self.driver, self.driver.state) + self._reset_status() + + def _disconnect_driver_signals(self): + if self.driver is None: + return + self.driver.connection_status_changed.disconnect( + self._on_driver_connection_status_changed + ) + self.driver.state_changed.disconnect(self._on_driver_state_changed) + self.driver.command_status_changed.disconnect( + self._on_driver_command_status_changed + ) + self.driver.job_finished.disconnect(self._on_driver_job_finished) + self.driver.wcs_updated.disconnect(self._on_driver_wcs_updated) + self.driver.config_changed.disconnect(self._on_driver_config_changed) + + def _on_dialects_changed(self, sender=None, **kwargs): + """ + Callback when dialects are updated. + Sends machine's changed signal to trigger recalculation. + """ + self.machine.changed.send(self.machine) + + async def rebuild_driver(self, ctx: Optional["ExecutionContext"] = None): + """ + Instantiates and sets up the driver based on the machine's current + configuration. Connects if auto_connect is enabled and the new driver + is not NoDeviceDriver. + """ + logger.info( + f"Machine '{self.machine.name}' (id:{self.machine.id}) rebuilding " + f"driver to '{self.machine.driver_name}'" + ) + + old_driver = self.driver + self._disconnect_driver_signals() + self.machine.set_precheck_error(None) + + if self.machine.driver_name: + driver_cls = get_driver_cls(self.machine.driver_name) + else: + driver_cls = NoDeviceDriver + + try: + driver_cls.precheck(**self.machine.driver_args) + except DriverPrecheckError as e: + logger.warning( + f"Precheck failed for driver {self.machine.driver_name}: {e}" + ) + self.machine.set_precheck_error(str(e)) + + new_driver = driver_cls(self.context, self.machine) + new_driver.setup(**self.machine.driver_args) + new_driver.config = self.machine.driver_config.copy() + + self.driver = new_driver + self._connect_driver_signals() + + self._last_driver_name = self.machine.driver_name + self._last_driver_args = self.machine.driver_args.copy() + + self._scheduler(self.machine.changed.send, self.machine) + + if old_driver: + await old_driver.cleanup() + + if self.machine.auto_connect and not isinstance( + new_driver, NoDeviceDriver + ): + logger.info( + f"Machine '{self.machine.name}' (id:{self.machine.id}) " + f"connecting after driver rebuild" + ) + await self.driver.connect() + + def _reset_status(self): + """Resets status to a disconnected/unknown state and signals it.""" + state_actually_changed = ( + self.machine.device_state.status != DeviceStatus.UNKNOWN + ) + conn_actually_changed = ( + self.machine.connection_status != TransportStatus.DISCONNECTED + ) + + self.machine.set_device_state(DeviceState()) + self.machine.set_connection_status(TransportStatus.DISCONNECTED) + + if state_actually_changed: + self._scheduler( + self.state_changed.send, + self.machine, + state=self.machine.device_state, + ) + if conn_actually_changed: + self._scheduler( + self.connection_status_changed.send, + self.machine, + status=self.machine.connection_status, + message="Driver inactive", + ) + + def _on_driver_connection_status_changed( + self, + driver: Driver, + status: TransportStatus, + message: str | None = None, + ): + """Proxies the connection status signal from the active driver.""" + if self.machine.connection_status != status: + self.machine.set_connection_status(status) + self._scheduler( + self.connection_status_changed.send, + self.machine, + status=status, + message=message, + ) + if status == TransportStatus.CONNECTED: + if self.machine.precheck_error: + self.machine.set_precheck_error(None) + task_mgr.add_coroutine( + lambda ctx: self.machine.sync_wcs_from_device(), + key=(self.machine.id, "sync-wcs-offsets"), + ) + task_mgr.add_coroutine( + lambda ctx: self.machine.sync_active_wcs_from_device(), + key=(self.machine.id, "sync-active-wcs"), + ) + + def _on_driver_state_changed(self, driver: Driver, state: DeviceState): + """Proxies the state changed signal from the active driver.""" + if self.machine.device_state != state: + self.machine.set_device_state(state) + self._sync_wcs_offset_from_wco(state) + self._scheduler(self.state_changed.send, self.machine, state=state) + + def _sync_wcs_offset_from_wco(self, state: DeviceState): + """ + Updates wcs_offsets for the active WCS from the WCO reported + in the device status report. This ensures wcs_offsets is current + even when the separate $# query hasn't completed yet. + + Regression fix for issue #190: only sync when the device has + confirmed the active WCS via $G. After a UI-initiated WCS switch, + status reports still carry the old WCS's WCO until the device + actually switches. Without this guard, stale WCO would corrupt + the newly-selected WCS offset. + """ + active_wcs = self.machine.active_wcs + if not active_wcs: + return + if active_wcs != self._confirmed_active_wcs: + return + if not all(v is not None for v in state.wco): + return + wco = cast(tuple[float, float, float], state.wco) + new_offset = (wco[0], wco[1], wco[2]) + current = self.machine.get_wcs_offset(active_wcs) + if current != new_offset: + self.machine.update_wcs_offset(active_wcs, new_offset) + self._scheduler(self.wcs_updated.send, self.machine) + + def _on_driver_config_changed(self, driver: Driver): + """Syncs the driver's runtime config to the machine.""" + self.machine.driver_config = driver.config.copy() + + def _on_driver_job_finished(self, driver: Driver): + """Proxies the job finished signal from the active driver.""" + self._scheduler(self.job_finished.send, self.machine) + + def _on_driver_command_status_changed( + self, + driver: Driver, + status: TransportStatus, + message: str | None = None, + ): + """Proxies the command status changed signal from the active driver.""" + self._scheduler( + self.command_status_changed.send, + self.machine, + status=status, + message=message, + ) + + def _on_driver_wcs_updated( + self, driver: Driver, offsets: dict[str, Point3D] + ): + """Updates internal WCS state from driver updates.""" + if not offsets: + logger.warning( + "Driver reported empty WCS offsets. " + "Skipping update to avoid clearing coordinate systems." + ) + return + changed = self.machine.update_wcs_offsets_batch(offsets) + logger.debug( + f"MachineController: Emitting wcs_updated for machine " + f"{self.machine.id}. Sender: {self.machine}" + ) + self._scheduler(self.wcs_updated.send, self.machine) + if changed: + self._scheduler(self.machine.changed.send, self.machine) + + async def home(self, axes=None): + """Homes the specified axes or all axes if none specified.""" + if self.driver is None: + return + await self.driver.home(axes) + + async def jog(self, deltas: dict[Axis, float], speed: int): + """ + Jogs the machine along specified axes. + + Args: + deltas: Dictionary mapping Axis enum members to distances in mm. + speed: Speed in mm/min. + """ + if self.driver is None: + return + + driver_kwargs = {} + + for axis, distance in deltas.items(): + if distance == 0: + continue + + if self.machine.soft_limits_enabled: + distance = self.machine._adjust_jog_distance_for_limits( + axis, distance + ) + + if distance != 0 and axis.name: + driver_kwargs[axis.name.lower()] = distance + + if not driver_kwargs: + return + + await self.driver.jog(speed=speed, **driver_kwargs) + + async def run_raw(self, gcode: str): + """Executes a raw G-code string on the machine.""" + if self.driver is None: + logger.warning("run_raw called but no driver is available.") + return + await self.driver.run_raw(gcode) + + async def select_tool(self, index: int): + """Sends a command to the driver to select a tool.""" + if self.driver is None: + return + await self.driver.select_tool(index) + + async def set_power( + self, head: Optional["Laser"] = None, percent: float = 0.0 + ) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. If None, uses the default head. + percent: Power percentage (0-1.0). 0 disables power. + """ + logger.debug( + f"Head {head.uid if head else None} power to {percent * 100}%" + ) + if not self.driver: + raise ValueError("No driver configured for this machine.") + + if head is None: + head = self.machine.get_default_laser_head() + if head is None: + raise ValueError("Machine has no laser heads configured.") + + await self.driver.set_power(head, percent) + self.laser_power_changed.send(self, head=head, percent=percent) + + async def set_focus_power( + self, head: Optional["Laser"] = None, percent: float = 0.0 + ) -> None: + """ + Sets the laser power for focus mode. + + Args: + head: The laser head to control. If None, uses the default head. + percent: Power percentage (0-1.0). 0 disables power. + """ + logger.debug( + f"Head {head.uid if head else None} focus power " + f"to {percent * 100}%" + ) + if not self.driver: + raise ValueError("No driver configured for this machine.") + + if head is None: + head = self.machine.get_default_laser_head() + if head is None: + raise ValueError("Machine has no laser heads configured.") + + await self.driver.set_focus_power(head, percent) + self.laser_power_changed.send(self, head=head, percent=percent) + + async def set_work_origin( + self, x: float, y: float, z: float, wcs_slot: str | None = None + ): + """ + Sets the work origin at the specified machine coordinates. + + Args: + x: X-coordinate in machine space. + y: Y-coordinate in machine space. + z: Z-coordinate in machine space. + wcs_slot: The WCS slot to update (e.g. "G54"). Defaults to active. + """ + slot = wcs_slot or self.machine.active_wcs + if slot not in self.machine.coordinate_systems: + logger.warning( + f"Cannot set offset for immutable WCS '{slot}' " + "(e.g. Machine Coordinates)." + ) + return + + if not self.machine.is_connected(): + self.machine.update_wcs_offset(slot, (x, y, z)) + self._scheduler(self.wcs_updated.send, self.machine) + self._scheduler(self.machine.changed.send, self.machine) + return + + await self.driver.set_wcs_offset(slot, x, y, z) + await self.driver.read_wcs_offsets() + + async def select_wcs(self, wcs: str) -> None: + """ + Selects the active Work Coordinate System. + + If connected, sends the selection to the controller. + Updates the machine's active_wcs state. + + Args: + wcs: The WCS slot to select (e.g., "G54", "REF0") + """ + if self.machine.is_connected(): + await self.driver.select_wcs(wcs) + self.machine.set_active_wcs(wcs) + + async def set_work_origin_here( + self, axes: Axis, wcs_slot: str | None = None + ): + """ + Sets the work origin for the specified axes to the current machine + position. + + Args: + axes: Flag combination of axes to set (e.g. Axis.X | Axis.Y). + wcs_slot: The WCS slot to update (e.g. "G54"). Defaults to active. + """ + if not self.machine.is_connected(): + return + + slot = wcs_slot or self.machine.active_wcs + if slot not in self.machine.coordinate_systems: + logger.warning( + f"Cannot set offset for immutable WCS '{slot}' " + "(e.g. Machine Coordinates)." + ) + return + + m_pos = self.machine.device_state.machine_pos + if any(v is None for v in m_pos): + logger.warning("Cannot set work origin: Unknown machine position.") + return + + current_offsets = self.machine.get_wcs_offset(slot) + + new_x, new_y, new_z = current_offsets + + if axes & Axis.X and m_pos[0] is not None: + new_x = m_pos[0] + if axes & Axis.Y and m_pos[1] is not None: + new_y = m_pos[1] + if axes & Axis.Z and m_pos[2] is not None: + new_z = m_pos[2] + + await self.set_work_origin(new_x, new_y, new_z, slot) + + async def sync_wcs_from_device(self): + """Queries the device for current WCS offsets and updates state.""" + if self.machine.is_connected(): + try: + await self.driver.read_wcs_offsets() + except asyncio.TimeoutError: + logger.error( + "Failed to sync WCS offsets: device timed out " + "while responding to $# command." + ) + except asyncio.CancelledError: + logger.debug("WCS offset sync cancelled by task manager.") + raise + except DeviceConnectionError as e: + logger.error( + f"Failed to sync WCS offsets: connection error: {e}" + ) + + async def sync_active_wcs_from_device(self): + """Queries the device for its active WCS and updates state.""" + if self.machine.is_connected(): + try: + active_wcs = await self.driver.read_parser_state() + if active_wcs: + logger.info( + f"Synced active WCS from device: '{active_wcs}'" + ) + self.machine.set_active_wcs(active_wcs) + self._confirmed_active_wcs = active_wcs + except asyncio.TimeoutError: + logger.error( + "Failed to sync active WCS: device timed out " + "while responding to $G command." + ) + except asyncio.CancelledError: + logger.debug("Active WCS sync cancelled by task manager.") + raise + except DeviceConnectionError as e: + logger.error( + f"Failed to sync active WCS: connection error: {e}" + ) + + async def switch_active_wcs(self, wcs: str): + """ + Switches the active WCS on both the model and the device. + + Sends the G-code WCS command (e.g. G54), then queries $G + to confirm the switch before updating _confirmed_active_wcs. + This prevents stale WCO from status reports corrupting the + newly-selected WCS offset. + """ + self.machine.active_wcs = wcs + self._scheduler(self.machine.changed.send, self.machine) + self._confirmed_active_wcs = None + + if self.machine.is_connected(): + try: + await self.driver.run_raw(wcs) + confirmed = await self.driver.read_parser_state() + if confirmed == wcs: + self._confirmed_active_wcs = wcs + await self.driver.read_wcs_offsets() + else: + logger.warning( + f"Device did not confirm WCS switch to {wcs}, " + f"got {confirmed}" + ) + except (DeviceConnectionError, asyncio.TimeoutError) as e: + logger.warning(f"Failed to confirm WCS switch to {wcs}: {e}") + else: + self._confirmed_active_wcs = wcs + + @property + def reports_granular_progress(self) -> bool: + """Check if the machine's driver reports granular progress.""" + if self.driver is None: + return False + return self.driver.reports_granular_progress + + def can_home(self, axis: Axis | None = None) -> bool: + """Check if the machine's driver supports homing for the given axis.""" + if self.driver is None: + return False + return self.driver.can_home(axis) + + def can_jog(self, axis: Axis | None = None) -> bool: + """Check if machine's supports jogging for the given axis.""" + if self.driver is None: + return False + return self.driver.can_jog(axis) + + @property + def machine_space_wcs(self) -> str: + """ + Returns the identifier for the machine space coordinate system. + Delegates to the driver's machine_space_wcs property. + """ + return self.driver.machine_space_wcs + + @property + def machine_space_wcs_display_name(self) -> str: + """ + Returns the display name for the machine space coordinate system. + Delegates to the driver's machine_space_wcs_display_name property. + """ + return self.driver.machine_space_wcs_display_name + + @property + def supported_wcs(self) -> list[str]: + """ + Returns the list of supported Work Coordinate Systems. + Delegates to the driver's supported_wcs property. + """ + return self.driver.supported_wcs + + def get_setting_vars(self) -> list["VarSet"]: + """ + Gets the setting definitions from the machine's active driver + as a VarSet. + """ + if self.driver is None: + return [] + return self.driver.get_setting_vars() + + async def read_settings(self): + """ + Task entry point for reading settings. This handles locking and + all errors. + """ + logger.debug("Machine.read_settings: Acquiring lock.") + async with self.machine._settings_lock: + logger.debug("read_settings: Lock acquired.") + if self.driver is None: + err = ConnectionError("No driver instance for this machine.") + self.machine.settings_error.send(self, error=err) + return + + def on_settings_read(sender, settings: list["VarSet"]): + logger.debug("on_settings_read: Handler called.") + sender.settings_read.disconnect(on_settings_read) + self._scheduler( + self.machine.settings_updated.send, + self.machine, + var_sets=settings, + ) + logger.debug("on_settings_read: Handler finished.") + + self.driver.settings_read.connect(on_settings_read) + try: + await self.driver.read_settings() + except (DeviceConnectionError, ConnectionError) as e: + logger.error(f"Failed to read settings from device: {e}") + self.driver.settings_read.disconnect(on_settings_read) + self._scheduler( + self.machine.settings_error.send, self, error=e + ) + finally: + logger.debug("read_settings: Read operation finished.") + logger.debug("read_settings: Lock released.") + + async def write_setting(self, key: str, value: Any): + """ + Writes a single setting to the device and signals success or failure. + """ + logger.debug(f"write_setting(key={key}): Acquiring lock.") + if self.driver is None: + err = ConnectionError("No driver instance for this machine.") + self.machine.settings_error.send(self, error=err) + return + + try: + async with self.machine._settings_lock: + logger.debug(f"write_setting(key={key}): Lock acquired.") + await self.driver.write_setting(key, value) + self._scheduler(self.machine.setting_applied.send, self) + except (DeviceConnectionError, ConnectionError) as e: + logger.error(f"Failed to write setting to device: {e}") + self._scheduler(self.machine.settings_error.send, self, error=e) + finally: + logger.debug(f"write_setting(key={key}): Done.") + + def validate_driver_setup(self) -> tuple[bool, str | None]: + """ + Validates the machine's driver arguments against the driver's setup + VarSet. + + Returns: + A tuple of (is_valid, error_message). + """ + if not self.machine.driver_name: + return False, _("No driver selected for this machine.") + + driver_cls = get_driver_cls(self.machine.driver_name) + if not driver_cls: + return False, _("Driver '{driver}' not found.").format( + driver=self.machine.driver_name + ) + + try: + setup_vars = driver_cls.get_setup_vars() + setup_vars.set_values(self.machine.driver_args) + setup_vars.validate() + except ValidationError as e: + return False, str(e) + except (ValueError, KeyError, TypeError) as e: + return False, _( + "An unexpected error occurred during validation: {error}" + ).format(error=str(e)) + + return True, None diff --git a/rayforge/machine/models/coordinate_system.py b/rayforge/machine/models/coordinate_system.py new file mode 100644 index 000000000..4916d1ec3 --- /dev/null +++ b/rayforge/machine/models/coordinate_system.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Any + +from raygeo.geo.types import Point3D + +_DEFAULT_WCS = ["G54", "G55", "G56", "G57", "G58", "G59"] +ZERO_OFFSET: Point3D = (0.0, 0.0, 0.0) + + +@dataclass +class CoordinateSystem: + name: str + label: str = "" + offset: Point3D = ZERO_OFFSET + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"name": self.name} + if self.label: + d["label"] = self.label + d["offset"] = list(self.offset) + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CoordinateSystem": + offset = tuple(data.get("offset", list(ZERO_OFFSET))) + return cls( + name=data["name"], + label=data.get("label", ""), + offset=offset, + ) + + @staticmethod + def defaults() -> dict[str, "CoordinateSystem"]: + return {n: CoordinateSystem(name=n) for n in _DEFAULT_WCS} diff --git a/rayforge/machine/models/coordspace.py b/rayforge/machine/models/coordspace.py new file mode 100644 index 000000000..d9c9145af --- /dev/null +++ b/rayforge/machine/models/coordspace.py @@ -0,0 +1,523 @@ +""" +Coordinate Space Classes. + +This module defines explicit coordinate space types for handling +coordinate transformations throughout Rayforge. + +Coordinate Spaces: +- WORLD: Canonical internal space (bottom-left origin, Y-up, X-right) +- MACHINE: Physical machine bed (origin and axis directions vary by config) +- WORKAREA: Usable area within machine bed (defined by margins) +- PIXEL: Raster images (top-left origin, Y-down) +- COMMAND: G-code output (relative to active WCS or workarea origin) +""" + +from abc import ABC +from dataclasses import dataclass +from enum import Enum, auto +from typing import TYPE_CHECKING + +import numpy as np +from raygeo.geo.types import Point, Point3D, Rect + +if TYPE_CHECKING: + from rayforge.machine.models.machine import Machine + + +class OriginCorner(Enum): + """Origin corner for a coordinate system.""" + + BOTTOM_LEFT = "bottom_left" + BOTTOM_RIGHT = "bottom_right" + TOP_LEFT = "top_left" + TOP_RIGHT = "top_right" + + +class AxisDirection(Enum): + """Direction of axis positive movement.""" + + POSITIVE_RIGHT = auto() + POSITIVE_LEFT = auto() + POSITIVE_UP = auto() + POSITIVE_DOWN = auto() + + +@dataclass(frozen=True) +class CoordinateSpace(ABC): + """ + Base class for coordinate spaces. + + Defines the geometric properties of a coordinate system and provides + transformation methods to convert between spaces. + """ + + origin: OriginCorner + x_positive_direction: AxisDirection + y_positive_direction: AxisDirection + reverse_x: bool = False + reverse_y: bool = False + + @property + def x_reversed(self) -> bool: + """True if X axis positive direction is left.""" + return self.x_positive_direction == AxisDirection.POSITIVE_LEFT + + @property + def y_reversed(self) -> bool: + """True if Y axis positive direction is down.""" + return self.y_positive_direction == AxisDirection.POSITIVE_DOWN + + def get_transform_to_world( + self, extents: tuple[float, float] + ) -> np.ndarray: + """ + Returns the 4x4 transformation matrix to convert from this space + to world space (BOTTOM_LEFT origin, Y-up, X-right). + + This handles origin corner transformation based on axis directions, + plus reverse_x/reverse_y sign flipping for machine coordinates. + + Args: + extents: The (width, height) of the coordinate space. + + Returns: + A 4x4 numpy array representing the transformation matrix. + """ + width, height = extents + + origin_is_top = self.origin in ( + OriginCorner.TOP_LEFT, + OriginCorner.TOP_RIGHT, + ) + origin_is_right = self.origin in ( + OriginCorner.TOP_RIGHT, + OriginCorner.BOTTOM_RIGHT, + ) + + # Build origin corner transformation + origin_transform = np.identity(4, dtype=np.float64) + + # Y-axis transformation + if origin_is_top: + if self.y_reversed: + # Top origin with Y-down + origin_transform[1, 1] = -1.0 + origin_transform[1, 3] = height + else: + # Top origin with Y-up + origin_transform[1, 3] = -height + elif self.y_reversed: + # Bottom origin with Y-down + origin_transform[1, 1] = -1.0 + + # X-axis transformation + if origin_is_right: + if self.x_reversed: + # Right origin with X-left: x' = -x + width + origin_transform[0, 0] = -1.0 + origin_transform[0, 3] = width + else: + # Right origin with X-right: x' = width - x + origin_transform[0, 0] = -1.0 + origin_transform[0, 3] = width + elif self.x_reversed: + # Left origin with X-left + origin_transform[0, 0] = -1.0 + + return origin_transform + + def transform_point_to_world( + self, x: float, y: float, extents: tuple[float, float] + ) -> Point: + """ + Transform a point from this space to world space. + + Args: + x: X coordinate in this space. + y: Y coordinate in this space. + extents: The (width, height) of the coordinate space. + + Returns: + Tuple of (x, y) in world space. + """ + matrix = self.get_transform_to_world(extents) + point = np.array([x, y, 0.0, 1.0]) + result = matrix @ point + return float(result[0]), float(result[1]) + + +@dataclass(frozen=True) +class MachineSpace(CoordinateSpace): + """ + The machine's native coordinate system. + + Configured based on machine settings (origin corner, axis directions). + Used for G-code generation and machine communication. + + Attributes: + extents: The (width, height) of the machine bed in mm. + margins: The (left, top, right, bottom) margins in mm. + """ + + extents: tuple[float, float] = (200.0, 200.0) + margins: Rect = (0.0, 0.0, 0.0, 0.0) + + @classmethod + def from_machine(cls, machine: "Machine") -> "MachineSpace": + """ + Create a MachineSpace from a Machine configuration. + + Args: + machine: The machine to create the space from. + + Returns: + A MachineSpace instance matching the machine's configuration. + """ + from rayforge.machine.models.machine import Origin + + origin_map = { + Origin.BOTTOM_LEFT: OriginCorner.BOTTOM_LEFT, + Origin.BOTTOM_RIGHT: OriginCorner.BOTTOM_RIGHT, + Origin.TOP_LEFT: OriginCorner.TOP_LEFT, + Origin.TOP_RIGHT: OriginCorner.TOP_RIGHT, + } + + origin_corner = origin_map.get( + machine.origin, OriginCorner.BOTTOM_LEFT + ) + + y_down = machine.origin in (Origin.TOP_LEFT, Origin.TOP_RIGHT) + x_right = machine.origin in (Origin.TOP_RIGHT, Origin.BOTTOM_RIGHT) + + # Axis direction is based on origin position only. + # reverse_x/reverse_y are stored separately for controller + # sign flip handling in _machine_coords_to_canvas() and encoder. + x_dir = ( + AxisDirection.POSITIVE_LEFT + if x_right + else AxisDirection.POSITIVE_RIGHT + ) + + y_dir = ( + AxisDirection.POSITIVE_DOWN + if y_down + else AxisDirection.POSITIVE_UP + ) + + return cls( + origin=origin_corner, + x_positive_direction=x_dir, + y_positive_direction=y_dir, + extents=machine.axis_extents, + margins=machine.work_margins, + reverse_x=machine.reverse_x_axis, + reverse_y=machine.reverse_y_axis, + ) + + def get_world_to_machine_matrix(self) -> np.ndarray: + """ + Returns the 4x4 transformation matrix to convert from world space + to machine space for the encoding pipeline. + + This composes the origin corner transformation and the axis + reversal sign flips. + """ + matrix = self.get_transform_to_world(self.extents) + + if self.reverse_x or self.reverse_y: + sign_flip = np.identity(4, dtype=np.float64) + if self.reverse_x: + sign_flip[0, 0] = -1.0 + if self.reverse_y: + sign_flip[1, 1] = -1.0 + matrix = sign_flip @ matrix + + return matrix + + def get_machine_to_world_matrix(self) -> np.ndarray: + """ + Returns the inverse of get_world_to_machine_matrix(). + + Used to convert points from machine space back to world space. + """ + return np.linalg.inv(self.get_world_to_machine_matrix()) + + def get_command_offset( + self, + wcs_offset: Point3D = (0.0, 0.0, 0.0), + wcs_is_workarea_origin: bool = False, + ) -> Point3D: + """ + Calculates the offset to subtract from machine coordinates to obtain + command coordinates (G-code output). + """ + if wcs_is_workarea_origin: + ml, mt, mr, mb = self.margins + + origin_is_right = self.origin in ( + OriginCorner.TOP_RIGHT, + OriginCorner.BOTTOM_RIGHT, + ) + origin_is_top = self.origin in ( + OriginCorner.TOP_LEFT, + OriginCorner.TOP_RIGHT, + ) + + if origin_is_right: + x_offset = -mr if self.reverse_x else mr + else: + x_offset = -ml if self.reverse_x else ml + + if origin_is_top: + y_offset = -mt if self.reverse_y else mt + else: + y_offset = -mb if self.reverse_y else mb + + return (float(x_offset), float(y_offset), 0.0) + else: + return (wcs_offset[0], wcs_offset[1], 0.0) + + @property + def workarea_size(self) -> tuple[float, float]: + """Returns the (width, height) of the workarea in mm.""" + ml, mt, mr, mb = self.margins + width, height = self.extents + return width - ml - mr, height - mt - mb + + def get_workarea_world_rect(self) -> Rect: + """ + Returns the work area boundary as a Rect in world space. + """ + pos = self.get_workarea_origin_in_machine() + w, h = self.workarea_size + wx, wy = self.machine_item_to_world(pos, (w, h)) + return (wx, wy, w, h) + + def world_position_from_origin( + self, ref_x: float, ref_y: float, size: tuple[float, float] + ) -> Point: + """ + Convert a reference position at the origin corner to world coords. + + Given a reference point at the machine's origin corner and an item + size, returns the bottom-left position in world coordinates. + This is useful for positioning items in world space when you have + a reference point at the origin corner. + + Args: + ref_x: X coordinate of reference point at origin corner (world). + ref_y: Y coordinate of reference point at origin corner (world). + size: (width, height) of the item. + + Returns: + Tuple of (x, y) for bottom-left position in world coordinates. + """ + width, height = size + + if self.origin == OriginCorner.BOTTOM_LEFT: + return ref_x, ref_y + elif self.origin == OriginCorner.TOP_LEFT: + return ref_x, ref_y - height + elif self.origin == OriginCorner.BOTTOM_RIGHT: + return ref_x - width, ref_y + else: # TOP_RIGHT + return ref_x - width, ref_y - height + + def get_workarea_origin_in_machine( + self, + ) -> Point: + """ + Returns the position of the workarea origin in machine coordinates. + + The workarea origin is at the corner specified by the machine's + origin setting, offset by the margins. + """ + ml, mt, mr, mb = self.margins + _width, _height = self.extents + + origin_is_top = self.origin in ( + OriginCorner.TOP_LEFT, + OriginCorner.TOP_RIGHT, + ) + origin_is_right = self.origin in ( + OriginCorner.TOP_RIGHT, + OriginCorner.BOTTOM_RIGHT, + ) + + if origin_is_right: + x = mr + else: + x = ml + + if origin_is_top: + y = mt + else: + y = mb + + return x, y + + def get_axis_label_origin( + self, + wcs_offset: Point3D = (0.0, 0.0, 0.0), + wcs_is_workarea_origin: bool = False, + ) -> Point3D: + """ + Get the origin offset for axis labels. + + This computes the (x, y, z) offset that should be passed to the + axis renderer for drawing grid labels. + + Args: + wcs_offset: The (x, y, z) WCS offset. + wcs_is_workarea_origin: If True, workarea origin is coordinate + zero. + + Returns: + Tuple of (x, y, z) origin offset for axis labels. + """ + if wcs_is_workarea_origin: + ml, mt, mr, mb = self.margins + _width, _height = self.extents + + origin_is_right = self.origin in ( + OriginCorner.TOP_RIGHT, + OriginCorner.BOTTOM_RIGHT, + ) + origin_is_top = self.origin in ( + OriginCorner.TOP_LEFT, + OriginCorner.TOP_RIGHT, + ) + + if origin_is_right: + origin_x = mr + else: + origin_x = ml + + if origin_is_top: + origin_y = mt + else: + origin_y = mb + + if self.reverse_x: + origin_x = -origin_x + if self.reverse_y: + origin_y = -origin_y + + return (origin_x, origin_y, 0.0) + else: + return wcs_offset + + def world_point_to_machine(self, x: float, y: float) -> Point: + """ + Transform a point from world space to machine space. + + WORLD space: Bottom-Left (0,0), Y-up, X-right + MACHINE space: Based on origin corner and axis reversal settings. + + Delegates to get_world_to_machine_matrix() so the scalar UI path + and the matrix encoder path share a single source of truth. + + Args: + x: X coordinate in world space. + y: Y coordinate in world space. + + Returns: + Tuple of (x, y) in machine space. + """ + matrix = self.get_world_to_machine_matrix() + result = matrix @ np.array([x, y, 0.0, 1.0]) + return float(result[0]), float(result[1]) + + def machine_point_to_world(self, x: float, y: float) -> Point: + """ + Transform a point from machine space to world space. + + Inverse of world_point_to_machine(). + + Delegates to get_machine_to_world_matrix() so the scalar UI path + and the matrix encoder path share a single source of truth. + + Args: + x: X coordinate in machine space. + y: Y coordinate in machine space. + + Returns: + Tuple of (x, y) in world space. + """ + matrix = self.get_machine_to_world_matrix() + result = matrix @ np.array([x, y, 0.0, 1.0]) + return float(result[0]), float(result[1]) + + def world_item_to_machine( + self, + pos: Point, + size: tuple[float, float], + ) -> Point: + """ + Convert item position from world space to machine space. + + Transforms the item's four bounding-box corners through + world_point_to_machine and selects the corner nearest the machine + origin (min, or max when the axis is reversed). Only this corner + selection depends on the bounding box; the point transform itself + delegates to the single matrix path. + + Args: + pos: (x, y) position in world coordinates (top-left corner). + size: (width, height) of the item. + + Returns: + (x, y) position in machine coordinates. + """ + wx, wy = pos + w, h = size + corners = ( + self.world_point_to_machine(wx, wy), + self.world_point_to_machine(wx + w, wy), + self.world_point_to_machine(wx, wy + h), + self.world_point_to_machine(wx + w, wy + h), + ) + xs = [c[0] for c in corners] + ys = [c[1] for c in corners] + mx = max(xs) if self.reverse_x else min(xs) + my = max(ys) if self.reverse_y else min(ys) + return mx, my + + def machine_item_to_world( + self, + pos: Point, + size: tuple[float, float], + ) -> Point: + """ + Convert item position from machine space to world space. + + The machine position refers to the corner nearest the machine + origin; the opposite corner is reached by adding (or, when the + axis is reversed, subtracting) the item size. All four bounding- + box corners are then transformed through machine_point_to_world, + and the world-space top-left is the per-axis minimum. + + Args: + pos: (x, y) position in machine coordinates. + size: (width, height) of the item in world space. + + Returns: + (x, y) position in world coordinates. + """ + mx, my = pos + w, h = size + if self.reverse_x: + x_min, x_max = mx - w, mx + else: + x_min, x_max = mx, mx + w + if self.reverse_y: + y_min, y_max = my - h, my + else: + y_min, y_max = my, my + h + corners = ( + self.machine_point_to_world(x_min, y_min), + self.machine_point_to_world(x_max, y_min), + self.machine_point_to_world(x_min, y_max), + self.machine_point_to_world(x_max, y_max), + ) + return min(c[0] for c in corners), min(c[1] for c in corners) diff --git a/rayforge/machine/models/dialect/__init__.py b/rayforge/machine/models/dialect/__init__.py new file mode 100644 index 000000000..ddcf4ada0 --- /dev/null +++ b/rayforge/machine/models/dialect/__init__.py @@ -0,0 +1,33 @@ +from dataclasses import replace + +from .base import GcodeDialect +from .grbl import GRBL_DIALECT +from .grbl_dynamic import GRBL_DYNAMIC_DIALECT +from .grbl_raster import GRBL_RASTER_DIALECT +from .linuxcnc import LINUXCNC_DIALECT +from .mach4_m67 import MACH4_M67_DIALECT +from .marlin import MARLIN_DIALECT +from .smoothieware import SMOOTHIEWARE_DIALECT + +BUILTIN_DIALECTS = [ + GRBL_DIALECT, + GRBL_DYNAMIC_DIALECT, + GRBL_RASTER_DIALECT, + LINUXCNC_DIALECT, + MACH4_M67_DIALECT, + MARLIN_DIALECT, + SMOOTHIEWARE_DIALECT, +] + +__all__ = [ + "BUILTIN_DIALECTS", + "GRBL_DIALECT", + "GRBL_DYNAMIC_DIALECT", + "GRBL_RASTER_DIALECT", + "LINUXCNC_DIALECT", + "MACH4_M67_DIALECT", + "MARLIN_DIALECT", + "SMOOTHIEWARE_DIALECT", + "GcodeDialect", + "replace", +] diff --git a/rayforge/machine/models/dialect/base.py b/rayforge/machine/models/dialect/base.py new file mode 100644 index 000000000..84b0d733b --- /dev/null +++ b/rayforge/machine/models/dialect/base.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import logging +import uuid +from dataclasses import ( + MISSING, + asdict, + dataclass, + field, + fields, + replace, +) +from gettext import gettext as _ +from typing import Any + +from ....core.varset import BoolVar, TextAreaVar, Var, VarSet + +logger = logging.getLogger(__name__) + + +@dataclass +class GcodeDialect: + """ + A container for G-code command templates and formatting logic for a + specific hardware dialect (e.g., GRBL, Marlin, Smoothieware). + """ + + label: str = field(metadata={"template_meta": True}) + description: str = field(metadata={"template_meta": True}) + + laser_on: str + laser_off: str + focus_laser_on: str + tool_change: str + set_speed: str + travel_move: str + linear_move: str + arc_cw: str + arc_ccw: str + bezier_cubic: str + + air_assist_on: str + air_assist_off: str + + home_all: str + home_axis: str + move_to: str + jog: str + clear_alarm: str + set_wcs_offset: str + probe_cycle: str + dwell: str = "G4 P{seconds:.3f}" + + spindle_on_cw: str = "M3 S{rpm}" + spindle_on_ccw: str = "M4 S{rpm}" + spindle_off: str = "M5" + coolant_flood: str = "M8" + coolant_mist: str = "M7" + coolant_off: str = "M9" + + preamble: list[str] = field(default_factory=list) + postscript: list[str] = field(default_factory=list) + + inject_wcs_after_preamble: bool = True + can_g0_with_speed: bool = False + omit_unchanged_coords: bool = True + continuous_laser_mode: bool = False + modal_feedrate: bool = False + + uid: str = field( + default_factory=lambda: str(uuid.uuid4()), + metadata={"template_meta": True}, + ) + is_custom: bool = field(default=False, metadata={"template_meta": True}) + parent_uid: str | None = field( + default=None, metadata={"template_meta": True} + ) + extra: dict[str, Any] = field( + default_factory=dict, metadata={"template_meta": True} + ) + + def get_editor_varsets(self) -> dict[str, VarSet]: + """ + Returns a dictionary of VarSets that define the editable fields for + this dialect, serving as the single source of truth for the UI. + """ + info_vs = VarSet(title=_("General Information")) + info_vs.add( + Var( + "label", + _("Label"), + str, + _("User-facing name"), + value=self.label, + ) + ) + info_vs.add( + Var( + "description", + _("Description"), + str, + _("Short description"), + value=self.description, + ) + ) + + settings_vs = VarSet(title=_("Settings")) + settings_vs.add( + BoolVar( + "omit_unchanged_coords", + default=self.omit_unchanged_coords, + label=_("Omit unchanged coordinates"), + description=_( + "When enabled, axis letters that haven't changed " + "are omitted from G0/G1 commands" + ), + ) + ) + settings_vs.add( + BoolVar( + "continuous_laser_mode", + default=self.continuous_laser_mode, + label=_("Continuous laser mode"), + description=_( + "Keeps M4 dynamic power mode continuously active " + "during raster engraving instead of toggling " + "M4/M5 between each segment" + ), + ) + ) + settings_vs.add( + BoolVar( + "modal_feedrate", + default=self.modal_feedrate, + label=_("Modal feedrate"), + description=_( + "Only include the F feedrate parameter in motion " + "commands when it changes from the previous value" + ), + ) + ) + + templates_vs = VarSet(title=_("Command Templates")) + template_fields = [ + ("laser_on", _("Laser On")), + ("laser_off", _("Laser Off")), + ("focus_laser_on", _("Focus Laser On")), + ("travel_move", _("Travel Move")), + ("linear_move", _("Linear Move")), + ("arc_cw", _("Arc (CW)")), + ("arc_ccw", _("Arc (CCW)")), + ("bezier_cubic", _("Bezier Cubic")), + ("tool_change", _("Tool Change")), + ("set_speed", _("Set Speed")), + ("air_assist_on", _("Air On")), + ("air_assist_off", _("Air Off")), + ("home_all", _("Home All")), + ("home_axis", _("Home Axis")), + ("move_to", _("Move To")), + ("jog", _("Jog")), + ("clear_alarm", _("Clear Alarm")), + ("set_wcs_offset", _("Set WCS Offset")), + ("probe_cycle", _("Probe Cycle")), + ("dwell", _("Dwell")), + ("spindle_on_cw", _("Spindle On (CW)")), + ("spindle_on_ccw", _("Spindle On (CCW)")), + ("spindle_off", _("Spindle Off")), + ("coolant_flood", _("Coolant Flood")), + ("coolant_mist", _("Coolant Mist")), + ("coolant_off", _("Coolant Off")), + ] + for key, label in template_fields: + templates_vs.add(Var(key, label, str, value=getattr(self, key))) + + scripts_vs = VarSet(title=_("Scripts")) + scripts_vs.add( + BoolVar( + "inject_wcs_after_preamble", + default=self.inject_wcs_after_preamble, + label=_("Inject WCS after Preamble"), + description=_( + "Inject the active WCS command (e.g., G54) after " + "the preamble script. When disabled, you can use " + "{machine.active_wcs} in the preamble instead." + ), + ) + ) + scripts_vs.add( + TextAreaVar( + "preamble", + _("Preamble"), + description=_("Preamble script"), + value="\n".join(self.preamble), + ) + ) + scripts_vs.add( + TextAreaVar( + "postscript", + _("Postscript"), + description=_("Postscript script"), + value="\n".join(self.postscript), + ) + ) + + return { + "info": info_vs, + "settings": settings_vs, + "templates": templates_vs, + "scripts": scripts_vs, + } + + def copy_as_custom(self, new_label: str) -> GcodeDialect: + """ + Creates a new, custom dialect instance from this one, generating a + new UID. + """ + return replace( + self, + uid=str(uuid.uuid4()), + is_custom=True, + parent_uid=self.uid, + label=new_label, + ) + + def to_dict(self) -> dict[str, Any]: + """Serializes the dialect to a dictionary.""" + result = asdict(self) + result.update(self.extra) + return result + + @classmethod + def from_dict( + cls, + data: dict[str, Any], + registry: dict[str, GcodeDialect] | None = None, + ) -> GcodeDialect: + """ + Creates a dialect instance from a dictionary, correctly handling + missing fields by inheriting from parent dialect. + """ + parent_uid = data.get("parent_uid") + base_dialect = None + + if parent_uid and registry: + base_dialect = registry.get(parent_uid.lower()) + + if not base_dialect and registry: + base_dialect = registry.get("grbl") + + if not base_dialect: + from .grbl import GRBL_DIALECT + + base_dialect = GRBL_DIALECT + + defaults = asdict(base_dialect) + + merged_data = defaults.copy() + merged_data.update(data) + + merged_data["uid"] = data.get("uid", str(uuid.uuid4())) + merged_data["is_custom"] = data.get("is_custom", False) + merged_data["parent_uid"] = data.get("parent_uid") + + valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered_data = { + k: v for k, v in merged_data.items() if k in valid_fields + } + + extra = {k: v for k, v in merged_data.items() if k not in valid_fields} + + instance = cls(**filtered_data) + instance.extra = extra + return instance + + @staticmethod + def _template_meta_fields() -> frozenset: + """Meta fields excluded from template serialization.""" + return frozenset( + f.name + for f in fields(GcodeDialect) + if f.metadata.get("template_meta") + ) + + @classmethod + def _template_field_sets( + cls, + ) -> tuple[frozenset, frozenset, frozenset]: + meta = cls._template_meta_fields() + required = set() + optional = set() + for f in fields(cls): + if f.name in meta: + continue + if f.default is not MISSING: + optional.add(f.name) + else: + required.add(f.name) + return ( + frozenset(required), + frozenset(optional), + frozenset(required | optional), + ) + + def to_template_dict(self) -> dict[str, Any]: + """ + Serialize template fields for device profile export. + + Excludes meta fields (marked with ``template_meta`` metadata) + like ``uid``, ``label``, ``is_custom``, etc. + """ + meta = self._template_meta_fields() + result: dict[str, Any] = {} + for f in fields(self): + if f.name not in meta: + result[f.name] = getattr(self, f.name) + return result + + @classmethod + def validate_template_dict( + cls, + data: dict[str, Any], + source: str = "", + ): + """ + Validate that *data* contains all required template fields. + + Raises :class:`ValueError` on missing required fields. + Logs warnings for unknown keys. + """ + if not isinstance(data, dict): + raise TypeError(f"Invalid dialect data: {source}") + required, _, all_fields = cls._template_field_sets() + missing = required - set(data.keys()) + if missing: + raise ValueError( + f"Missing required dialect fields in {source}: " + f"{sorted(missing)}" + ) + for key in data: + if key not in all_fields: + logger.warning(f"Unknown dialect field '{key}' in {source}") + + @classmethod + def from_template_dict( + cls, data: dict[str, Any], **overrides + ) -> GcodeDialect: + """ + Create a dialect from a device profile template dict. + + Filters out meta fields from *data*, applies *overrides*, + then constructs with only valid field names. + """ + filtered = { + k: v + for k, v in data.items() + if k not in cls._template_meta_fields() + } + filtered.update(overrides) + valid = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in filtered.items() if k in valid}) diff --git a/rayforge/machine/models/dialect/grbl.py b/rayforge/machine/models/dialect/grbl.py new file mode 100644 index 000000000..bafac7091 --- /dev/null +++ b/rayforge/machine/models/dialect/grbl.py @@ -0,0 +1,38 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +GRBL_DIALECT = GcodeDialect( + uid="grbl", + label=_("Grbl (Compat)"), + description=_( + "Grbl dialect with highest compatibility for most diode lasers " + "and hobby CNCs" + ), + can_g0_with_speed=False, + omit_unchanged_coords=True, + 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="", # not supported by Grbl + 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}", + preamble=["G21 ;Set units to mm", "G90 ;Absolute positioning"], + postscript=[ + "M5 ;Ensure laser is off", + "G0 X0 Y0 ;Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect/grbl_dynamic.py b/rayforge/machine/models/dialect/grbl_dynamic.py new file mode 100644 index 000000000..c4a454e34 --- /dev/null +++ b/rayforge/machine/models/dialect/grbl_dynamic.py @@ -0,0 +1,42 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +GRBL_DYNAMIC_DIALECT = GcodeDialect( + uid="grbl_dynamic", + label=_("GRBL Dynamic"), + description=_( + "GRBL with M4 dynamic power (Depth-Aware) mode. " + "S parameter is included in motion commands" + ), + can_g0_with_speed=False, + omit_unchanged_coords=True, + laser_on="M4 S0", + 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}{s_command}", + arc_cw=( + "G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command}" + ), + arc_ccw=( + "G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command}" + ), + bezier_cubic="", # not supported by Grbl + 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}", + preamble=["G21 ;Set units to mm", "G90 ;Absolute positioning"], + postscript=[ + "M5 ;Ensure laser is off", + "G0 X0 Y0 ;Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect/grbl_raster.py b/rayforge/machine/models/dialect/grbl_raster.py new file mode 100644 index 000000000..0e352f3d2 --- /dev/null +++ b/rayforge/machine/models/dialect/grbl_raster.py @@ -0,0 +1,45 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +GRBL_RASTER_DIALECT = GcodeDialect( + uid="grbl_raster", + label=_("GRBL Raster"), + description=_( + "Optimized for GRBL raster engraving. Keeps M4 dynamic power " + "mode continuously active and uses modal feedrate to minimize " + "command overhead during scan lines" + ), + can_g0_with_speed=True, + omit_unchanged_coords=True, + continuous_laser_mode=True, + modal_feedrate=True, + laser_on="M4 S0", + 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}{f_command}{s_command}", + linear_move="G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command}", + arc_cw=( + "G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command}" + ), + arc_ccw=( + "G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command}" + ), + bezier_cubic="", # not supported by Grbl + 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}", + preamble=["G21 ;Set units to mm", "G90 ;Absolute positioning"], + postscript=[ + "M5 ;Ensure laser is off", + "G0 X0 Y0 ;Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect/linuxcnc.py b/rayforge/machine/models/dialect/linuxcnc.py new file mode 100644 index 000000000..f4d3fa39a --- /dev/null +++ b/rayforge/machine/models/dialect/linuxcnc.py @@ -0,0 +1,35 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +LINUXCNC_DIALECT = GcodeDialect( + uid="linuxcnc", + label=_("LinuxCNC"), + description=_("G-code for LinuxCNC, supporting native cubic bezier (G5)"), + can_g0_with_speed=True, + omit_unchanged_coords=True, + laser_on="M3 S{power:.0f}", + laser_off="M5", + focus_laser_on="M3 S{power:.0f}", + tool_change="M6 T{tool_number}", + set_speed="", + travel_move="G0{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}", + 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="G5{x_cmd}{y_cmd}{extra_cmd} I{i} J{j} P{p} Q{q}{f_command}", + air_assist_on="M8", + air_assist_off="M9", + home_all="G28", + home_axis="G28 {axis_letter}0", + move_to="G0 X{x} Y{y}", + jog="G91 G0 F{speed}", + clear_alarm="M999", + 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}", + preamble=["G21 ; Set units to mm", "G90 ; Absolute positioning"], + postscript=[ + "M5 ; Ensure laser is off", + "G0 X0 Y0 ; Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect/mach4_m67.py b/rayforge/machine/models/dialect/mach4_m67.py new file mode 100644 index 000000000..b9a3facf0 --- /dev/null +++ b/rayforge/machine/models/dialect/mach4_m67.py @@ -0,0 +1,39 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +MACH4_M67_DIALECT = GcodeDialect( + uid="mach4_m67", + label=_("Mach4 (M67 Analog)"), + description=_( + "Mach4 with M67 analog output for high-speed raster engraving. " + "Uses M67 E0 Q<0-255> for laser power instead of inline S commands, " + "reducing buffer pressure on the controller." + ), + can_g0_with_speed=True, + omit_unchanged_coords=True, + laser_on="M67 E0 Q{power:.0f}", + laser_off="M67 E0 Q0", + focus_laser_on="M67 E0 Q{power:.0f}", + tool_change="M6 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="", # not supported by Mach4 + air_assist_on="M8", + air_assist_off="M9", + home_all="G28", + home_axis="G28 {axis_letter}0", + move_to="G0 X{x} Y{y}", + jog="G91 G0 F{speed}", + clear_alarm="M999", + 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}", + preamble=["G21 ; Set units to mm", "G90 ; Absolute positioning"], + postscript=[ + "M67 E0 Q0 ; Ensure laser is off", + "G0 X0 Y0 ; Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect/marlin.py b/rayforge/machine/models/dialect/marlin.py new file mode 100644 index 000000000..36c7d5df2 --- /dev/null +++ b/rayforge/machine/models/dialect/marlin.py @@ -0,0 +1,37 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +MARLIN_DIALECT = GcodeDialect( + uid="marlin", + label=_("Marlin"), + description=_( + "G-code for Marlin-based controllers, common in 3D printers" + ), + can_g0_with_speed=True, + omit_unchanged_coords=True, + 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}{f_command}", + 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="G5{x_cmd}{y_cmd}{extra_cmd} I{i} J{j} P{p} Q{q}{f_command}", + air_assist_on="M8", + air_assist_off="M9", + home_all="G28", + home_axis="G28 {axis_letter}", + move_to="G0 X{x} Y{y} F{speed}", + jog="G91 G0 F{speed}", + clear_alarm="M999", + 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}", + preamble=["G21 ; Set units to mm", "G90 ; Absolute positioning"], + postscript=[ + "M5 ; Ensure laser is off", + "G0 X0 Y0 ; Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect/smoothieware.py b/rayforge/machine/models/dialect/smoothieware.py new file mode 100644 index 000000000..9afd466e9 --- /dev/null +++ b/rayforge/machine/models/dialect/smoothieware.py @@ -0,0 +1,35 @@ +from gettext import gettext as _ + +from .base import GcodeDialect + +SMOOTHIEWARE_DIALECT = GcodeDialect( + uid="smoothieware", + label=_("Smoothieware"), + description=_("G-code dialect for Smoothieware-based controllers"), + can_g0_with_speed=True, + omit_unchanged_coords=True, + laser_on="M3 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}{f_command}", + 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="", # not supported by Smoothieware + air_assist_on="M8", + air_assist_off="M9", + home_all="$H", + home_axis="G28 {axis_letter}0", + move_to="G90 G0 X{x} Y{y}", + jog="G91 G0 F{speed}", + clear_alarm="M999", + set_wcs_offset="G10 L20 P{p_num} X{x} Y{y} Z{z}", + probe_cycle="G38.2 {axis_letter}{max_travel} F{feed_rate}", + preamble=["G21 ; Set units to mm", "G90 ; Absolute positioning"], + postscript=[ + "M5 ; Ensure laser is off", + "G0 X0 Y0 ; Return to origin", + ], +) diff --git a/rayforge/machine/models/dialect_manager.py b/rayforge/machine/models/dialect_manager.py new file mode 100644 index 000000000..95145aed9 --- /dev/null +++ b/rayforge/machine/models/dialect_manager.py @@ -0,0 +1,190 @@ +import logging +from dataclasses import replace +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml +from blinker import Signal + +from .dialect import ( + BUILTIN_DIALECTS, + GcodeDialect, +) + +if TYPE_CHECKING: + from .machine import Machine + + +logger = logging.getLogger(__name__) + + +class DialectManager: + def __init__(self, base_dir: Path): + self.base_dir = base_dir + self.base_dir.mkdir(parents=True, exist_ok=True) + self._registry: dict[str, GcodeDialect] = {} + self.dialects_changed = Signal() + self.load_all() + + def get(self, uid: str) -> GcodeDialect: + """ + Retrieves a GcodeDialect instance from the registry by its + case-insensitive UID. + """ + uid_key = uid.lower() + dialect = self._registry.get(uid_key) + if not dialect: + raise ValueError( + f"Unknown or unsupported G-code dialect UID: '{uid}'" + ) + return dialect + + def get_all(self) -> list[GcodeDialect]: + """Returns a list of all registered GcodeDialect instances.""" + return sorted(self._registry.values(), key=lambda d: d.label) + + def register(self, dialect: GcodeDialect): + """ + Adds a dialect to the registry, keyed by its case-insensitive + unique `uid`. + """ + uid_key = dialect.uid.lower() + if uid_key in self._registry: + raise ValueError( + f"Dialect with UID '{dialect.uid}' is already registered." + ) + self._registry[uid_key] = dialect + + def migrate_builtin_dialect_to_copy( + self, dialect_uid: str | None, machine_name: str + ) -> tuple[str | None, bool]: + """ + If dialect_uid references a built-in dialect, creates an isolated + copy and returns the new UID with migrated=True. Otherwise returns + the original UID with migrated=False. + + This ensures user configurations are isolated from built-in dialect + changes during app upgrades. + """ + if dialect_uid is None: + return None, False + try: + dialect = self.get(dialect_uid) + except ValueError: + logger.warning( + f"Dialect '{dialect_uid}' not found, falling back to 'grbl'." + ) + dialect = self.get("grbl") + + if not dialect.is_custom: + new_label = _("{label} (for {machine_name})").format( + label=dialect.label, + machine_name=machine_name, + ) + new_dialect = dialect.copy_as_custom(new_label=new_label) + new_dialect = replace(new_dialect, parent_uid=None) + self.add_dialect(new_dialect) + logger.info( + f"Migrated built-in dialect '{dialect_uid}' to isolated " + f"copy '{new_dialect.uid}' for machine '{machine_name}'." + ) + return new_dialect.uid, True + + return dialect_uid, False + + def _load_builtins(self): + """Loads the hardcoded, built-in dialects into the registry.""" + for dialect in BUILTIN_DIALECTS: + dialect.is_custom = False + try: + self.register(dialect) + except ValueError as e: + logger.error(f"Failed to register built-in dialect: {e}") + + def _load_custom_dialects(self): + """Loads user-defined dialects from individual YAML files.""" + for f in self.base_dir.glob("*.yaml"): + try: + with open(f, "r") as stream: + data = yaml.safe_load(stream) + dialect = GcodeDialect.from_dict( + data, registry=self._registry + ) + dialect.is_custom = True + self.register(dialect) + except (yaml.YAMLError, ValueError, TypeError) as e: + logger.error(f"Failed to load custom dialect from {f}: {e}") + + def load_all(self): + """Clears the registry and reloads all dialects.""" + self._registry.clear() + self._load_builtins() + self._load_custom_dialects() + self.dialects_changed.send(self) + + def _save_dialect_to_file(self, dialect: GcodeDialect): + """Saves a single custom dialect to its own YAML file.""" + if not dialect.is_custom: + return + file_path = self.base_dir / f"{dialect.uid}.yaml" + try: + with open(file_path, "w") as f: + yaml.safe_dump(dialect.to_dict(), f, sort_keys=False) + except (OSError, yaml.YAMLError) as e: + logger.error(f"Failed to save custom dialect to {file_path}: {e}") + + def _delete_dialect_file(self, dialect: GcodeDialect): + """Deletes the file for a single custom dialect.""" + if not dialect.is_custom: + return + file_path = self.base_dir / f"{dialect.uid}.yaml" + try: + if file_path.exists(): + file_path.unlink() + except OSError as e: + logger.error(f"Error removing dialect file {file_path}: {e}") + + def add_dialect(self, dialect: GcodeDialect): + """Adds a new custom dialect, saves, and signals.""" + if not dialect.is_custom: + raise ValueError("Cannot add a non-custom dialect.") + self.register(dialect) + self._save_dialect_to_file(dialect) + self.dialects_changed.send(self) + + def update_dialect(self, dialect: GcodeDialect): + """Updates an existing custom dialect, saves, and signals.""" + if not dialect.is_custom: + raise ValueError("Cannot update a built-in dialect.") + + uid_key = dialect.uid.lower() + if uid_key not in self._registry: + raise ValueError(f"Dialect with UID '{dialect.uid}' not found.") + + self._registry[uid_key] = dialect + self._save_dialect_to_file(dialect) + self.dialects_changed.send(self) + + def get_machines_using_dialect( + self, dialect: GcodeDialect, machines: list["Machine"] + ) -> list["Machine"]: + """Returns a list of machines that use the given dialect.""" + return [m for m in machines if m.dialect_uid == dialect.uid] + + def delete_dialect(self, dialect: GcodeDialect, machines: list["Machine"]): + """Deletes a custom dialect, saves, and signals.""" + if not dialect.is_custom: + raise ValueError("Cannot delete a built-in dialect.") + + uid_key = dialect.uid.lower() + if uid_key not in self._registry: + return + + machines_using = self.get_machines_using_dialect(dialect, machines) + if machines_using: + raise ValueError("Dialect is in use by one or more machines.") + + del self._registry[uid_key] + self._delete_dialect_file(dialect) + self.dialects_changed.send(self) diff --git a/rayforge/machine/models/head.py b/rayforge/machine/models/head.py new file mode 100644 index 000000000..27283123a --- /dev/null +++ b/rayforge/machine/models/head.py @@ -0,0 +1,149 @@ +import uuid +from abc import ABC +from gettext import gettext as _ +from typing import Any, Self + +import numpy as np +from blinker import Signal + +from ...core.capability import MachineCapability +from ...core.matrix import euler_rotation_matrix + +HEAD_TYPE_KEY = "type" + +_HEAD_SERIALIZED_KEYS = frozenset( + {HEAD_TYPE_KEY, "uid", "name", "tool_number", "model_path", "transform"} +) + + +class Head(ABC): + """ + Base class for machine heads. + + Concrete head types (:class:`LaserHead`, :class:`SpindleHead`, ...) + add their type-specific attributes and declare which machine + capability they imply via :attr:`machine_capability`. + """ + + # Serialization key identifying the concrete head type. + HEAD_TYPE: str = "Head" + + def __init__(self): + self.uid: str = str(uuid.uuid4()) + self.name: str = _("Head") + self.tool_number: int = 0 + self.model_path: str | None = None + self.transform: np.ndarray = np.eye(4, dtype=np.float64) + self.changed = Signal() + self.extra: dict[str, Any] = {} + + @property + def machine_capability(self) -> MachineCapability | None: + """ + The machine capability this head type implies, or ``None`` for + passive heads that contribute no capability. + """ + return None + + def set_name(self, name: str): + self.name = name + self.changed.send(self) + + def set_tool_number(self, tool_number: int): + self.tool_number = tool_number + self.changed.send(self) + + def set_model_path(self, model_path: str | None): + if self.model_path == model_path: + return + self.model_path = model_path + self.changed.send(self) + + def get_rotation(self): + t = self.transform + sx = float(np.linalg.norm(t[0, :3])) + sy = float(np.linalg.norm(t[1, :3])) + sz = float(np.linalg.norm(t[2, :3])) + rx = np.degrees(np.arctan2(t[2, 1] / sy, t[2, 2] / sz)) + ry = np.degrees( + np.arctan2(-t[2, 0] / sx, np.sqrt(t[2, 1] ** 2 + t[2, 2] ** 2)) + ) + rz = np.degrees(np.arctan2(t[1, 0] / sx, t[0, 0] / sx)) + return rx, ry, rz + + def set_rotation(self, rx: float, ry: float, rz: float): + cur = self.get_rotation() + if cur[0] == rx and cur[1] == ry and cur[2] == rz: + return + pos = self.transform[:3, 3].copy() + scale = self.get_scale() + self.transform[:3, :3] = euler_rotation_matrix(rx, ry, rz) * scale + self.transform[:3, 3] = pos + self.changed.send(self) + + def get_scale(self) -> float: + return float(np.linalg.norm(self.transform[0, :3])) + + def set_scale(self, scale: float): + if self.get_scale() == scale: + return + pos = self.transform[:3, 3].copy() + rx, ry, rz = self.get_rotation() + self.transform[:3, :3] = euler_rotation_matrix(rx, ry, rz) * scale + self.transform[:3, 3] = pos + self.changed.send(self) + + def to_dict(self) -> dict[str, Any]: + result = { + HEAD_TYPE_KEY: self.HEAD_TYPE, + "uid": self.uid, + "name": self.name, + "tool_number": self.tool_number, + "model_path": self.model_path, + "transform": self.transform.flatten().tolist(), + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + head = cls() + head.uid = data.get("uid", str(uuid.uuid4())) + head.name = data.get("name", head.name) + head.tool_number = data.get("tool_number", head.tool_number) + head.model_path = data.get("model_path") + raw_transform = data.get("transform") + if raw_transform is not None: + head.transform = np.array(raw_transform, dtype=np.float64).reshape( + 4, 4 + ) + return head + + def __getstate__(self): + """Prepare the object for pickling. Removes unpickleable Signal.""" + state = self.__dict__.copy() + state.pop("changed", None) + return state + + def __setstate__(self, state): + """Restore the object after unpickling. Recreates the Signal.""" + vars(self).update(state) + self.changed = Signal() + + +def head_from_dict( + data: dict[str, Any], +) -> "Head": + """ + Deserialize a head dict, dispatching on the ``type`` key. + + Old machine files without a ``type`` key map to :class:`LaserHead` + for backward compatibility. + """ + from .laser import LaserHead + from .spindle import SpindleHead + + head_type = data.get(HEAD_TYPE_KEY) + if head_type == SpindleHead.HEAD_TYPE: + return SpindleHead.from_dict(data) + return LaserHead.from_dict(data) diff --git a/rayforge/machine/models/laser.py b/rayforge/machine/models/laser.py new file mode 100644 index 000000000..c9ae08f7c --- /dev/null +++ b/rayforge/machine/models/laser.py @@ -0,0 +1,276 @@ +from enum import Enum +from gettext import gettext as _ +from typing import Any, Optional + +from ...core.capability import MachineCapability +from .head import _HEAD_SERIALIZED_KEYS, Head + + +class LaserType(Enum): + DIODE = "diode" + CO2 = "co2" + FIBER = "fiber" + + @property + def supports_pwm(self) -> bool: + return self in (LaserType.CO2, LaserType.FIBER) + + +# Minimum sane laser spot size in mm. Guards against unconfigured +# (zero) spot data reaching the raster resolution code, which divides +# by the spot size. +MIN_SPOT_SIZE_MM = 0.1 + + +class LaserHead(Head): + """A laser head, implying the LASER machine capability.""" + + HEAD_TYPE: str = "LaserHead" + + def __init__(self): + super().__init__() + self.name: str = _("Laser Head") + self.max_power: int = 1000 # Max power (0-1000 for GRBL) + self.frame_power_percent: float = 0 # in percent (0-1.0) + self.focus_power_percent: float = 0 # in percent (0-1.0) + self.frame_speed: int = 0 # mm/min, 0 = use machine max travel speed + self.frame_repeat_count: int = 20 + self.frame_corner_pause: float = 0 # seconds + self.spot_size_mm: tuple[float, float] = 0.1, 0.1 # millimeters + self.cut_color: str = "#ff00ff" # Magenta for cut + self.raster_color: str = "#000000" # Black for raster + self.focal_distance: float = 0.0 + self.laser_type: LaserType = LaserType.DIODE + self.pwm_frequency: int = 500 + self.max_pwm_frequency: int = 5000 + self.pulse_width: int = 50 + self.min_pulse_width: int = 5 + self.max_pulse_width: int = 500 + + @property + def machine_capability(self) -> MachineCapability: + return MachineCapability.LASER + + @property + def kerf_mm(self) -> float: + """The kerf-compensation displacement for this head, in mm. + + Derived from the beam spot size: the path is shifted by half + the spot width so the cut part comes out dimensionally + accurate. Read-only; change ``spot_size_mm`` to alter it. + """ + spot_x = self.spot_size_mm[0] + return spot_x / 2.0 + + @staticmethod + def get_spot_size(head: Optional["LaserHead"]) -> tuple[float, float]: + """The effective spot size ``(x, y)`` in mm for a laser head. + + Falls back to a sane minimum when no laser head is available. + A missing or zero spot size (an unconfigured head) is clamped so + raster resolution code never divides by zero. + """ + if head is None: + return MIN_SPOT_SIZE_MM, MIN_SPOT_SIZE_MM + spot_x, spot_y = head.spot_size_mm + if not spot_x or spot_x <= 0: + spot_x = MIN_SPOT_SIZE_MM + if not spot_y or spot_y <= 0: + spot_y = MIN_SPOT_SIZE_MM + return spot_x, spot_y + + def set_max_power(self, power): + self.max_power = power + self.changed.send(self) + + def set_frame_power(self, power: float): + """Set frame power in percent (0.0 - 1.0).""" + self.frame_power_percent = power + self.changed.send(self) + + def set_focus_power(self, power): + """Set focus power in percent (0.0 - 1.0).""" + self.focus_power_percent = power + self.changed.send(self) + + def set_frame_speed(self, speed: int): + self.frame_speed = speed + self.changed.send(self) + + def set_frame_repeat_count(self, count: int): + self.frame_repeat_count = count + self.changed.send(self) + + def set_frame_corner_pause(self, duration: float): + self.frame_corner_pause = duration + self.changed.send(self) + + def _gcode_to_percent(self, gcode_value) -> float: + """Convert gcode power value (0-max_power) to percentage (0-100).""" + if self.max_power <= 0: + return 0 + return gcode_value / self.max_power + + def _percent_to_gcode(self, percent): + """Convert percentage (0-100) to gcode power value (0-max_power).""" + return round((percent / 100) * self.max_power) + + def set_spot_size(self, spot_size_x_mm, spot_size_y_mm): + self.spot_size_mm = spot_size_x_mm, spot_size_y_mm + self.changed.send(self) + + def set_cut_color(self, color: str): + self.cut_color = color + self.changed.send(self) + + def set_raster_color(self, color: str): + self.raster_color = color + self.changed.send(self) + + def set_focal_distance(self, distance: float): + if self.focal_distance == distance: + return + self.focal_distance = distance + self.changed.send(self) + + def set_laser_type(self, laser_type: LaserType): + if self.laser_type == laser_type: + return + self.laser_type = laser_type + self.changed.send(self) + + def set_pwm_frequency(self, frequency: int): + frequency = max(1, min(frequency, self.max_pwm_frequency)) + if self.pwm_frequency == frequency: + return + self.pwm_frequency = frequency + self.changed.send(self) + + def set_max_pwm_frequency(self, max_frequency: int): + max_frequency = max(1, max_frequency) + if self.max_pwm_frequency == max_frequency: + return + self.max_pwm_frequency = max_frequency + self.pwm_frequency = min(self.pwm_frequency, max_frequency) + self.changed.send(self) + + def set_pulse_width(self, width: int): + width = max(self.min_pulse_width, min(width, self.max_pulse_width)) + if self.pulse_width == width: + return + self.pulse_width = width + self.changed.send(self) + + def set_min_pulse_width(self, min_width: int): + min_width = max(1, min_width) + if self.min_pulse_width == min_width: + return + self.min_pulse_width = min_width + self.max_pulse_width = max(self.max_pulse_width, min_width) + self.pulse_width = max(self.pulse_width, min_width) + self.changed.send(self) + + def set_max_pulse_width(self, max_width: int): + max_width = max(1, max_width) + if self.max_pulse_width == max_width: + return + self.max_pulse_width = max_width + self.min_pulse_width = min(self.min_pulse_width, max_width) + self.pulse_width = min(self.pulse_width, max_width) + self.changed.send(self) + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "max_power": self.max_power, + "frame_power_percent": self.frame_power_percent * 100, + "focus_power_percent": self.focus_power_percent * 100, + "frame_speed": self.frame_speed, + "frame_repeat_count": self.frame_repeat_count, + "frame_corner_pause": self.frame_corner_pause, + "spot_size_mm": self.spot_size_mm, + "cut_color": self.cut_color, + "raster_color": self.raster_color, + "focal_distance": self.focal_distance, + "laser_type": self.laser_type.value, + "pwm_frequency": self.pwm_frequency, + "max_pwm_frequency": self.max_pwm_frequency, + "pulse_width": self.pulse_width, + "min_pulse_width": self.min_pulse_width, + "max_pulse_width": self.max_pulse_width, + } + ) + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "LaserHead": + known_keys = _HEAD_SERIALIZED_KEYS | { + "max_power", + "frame_power_percent", + "focus_power_percent", + "frame_power", + "focus_power", + "frame_speed", + "frame_repeat_count", + "frame_corner_pause", + "spot_size_mm", + "cut_color", + "raster_color", + "focal_distance", + "laser_type", + "pwm_frequency", + "max_pwm_frequency", + "pulse_width", + "min_pulse_width", + "max_pulse_width", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + lh = super().from_dict(data) + lh.max_power = data.get("max_power", lh.max_power) + + # Handle backward compatibility for frame power + if "frame_power_percent" in data: + lh.frame_power_percent = data["frame_power_percent"] / 100.0 + else: + # Old format: convert from gcode units to percentage + frame_power = data.get("frame_power", 0) + lh.frame_power_percent = frame_power / lh.max_power + + # Handle backward compatibility for focus power + if "focus_power_percent" in data: + lh.focus_power_percent = data["focus_power_percent"] / 100.0 + else: + # Old format: convert from gcode units to percentage + focus_power = data.get("focus_power", 0) + lh.focus_power_percent = focus_power / lh.max_power + + lh.spot_size_mm = data.get("spot_size_mm", lh.spot_size_mm) + lh.cut_color = data.get("cut_color", lh.cut_color) + lh.raster_color = data.get("raster_color", lh.raster_color) + lh.frame_speed = data.get("frame_speed", lh.frame_speed) + lh.frame_repeat_count = data.get( + "frame_repeat_count", lh.frame_repeat_count + ) + lh.frame_corner_pause = data.get( + "frame_corner_pause", lh.frame_corner_pause + ) + lh.focal_distance = data.get("focal_distance", 0.0) + lh.laser_type = LaserType( + data.get("laser_type", LaserType.DIODE.value) + ) + lh.pwm_frequency = data.get("pwm_frequency", lh.pwm_frequency) + lh.max_pwm_frequency = data.get( + "max_pwm_frequency", lh.max_pwm_frequency + ) + lh.pulse_width = data.get("pulse_width", lh.pulse_width) + lh.min_pulse_width = data.get("min_pulse_width", lh.min_pulse_width) + lh.max_pulse_width = data.get("max_pulse_width", lh.max_pulse_width) + lh.extra = extra + return lh + + +# Backward-compatible alias for code that still imports `Laser`. +Laser = LaserHead diff --git a/rayforge/machine/models/machine.py b/rayforge/machine/models/machine.py new file mode 100644 index 000000000..9e8587176 --- /dev/null +++ b/rayforge/machine/models/machine.py @@ -0,0 +1,1788 @@ +import asyncio +import logging +import multiprocessing +import uuid +from enum import Enum +from gettext import gettext as _ +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Optional, +) + +from blinker import Signal +from raygeo.geo.types import Point3D, Rect +from raygeo.ops.axis import Axis + +from ...camera.models.camera import Camera +from ...camera.v4l import migrate_camera_data +from ...context import RayforgeContext, get_context +from ...core.capability import MachineCapability +from ...core.layer import Layer +from ...core.model import Model +from ...shared.tasker import task_mgr +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 ..kinematics import HeadSpec, Kinematics, build_assembly +from ..models.axis import AxisConfig, AxisDirection, AxisSet, AxisType +from ..transport import TransportStatus +from .coordspace import MachineSpace +from .dialect import GcodeDialect +from .head import Head, head_from_dict +from .laser import Laser, LaserHead +from .machine_hours import MachineHours +from .machine_panel import MachinePanel, PanelOrientation +from .macro import Macro, MacroTrigger +from .rotary_module import RotaryMode, RotaryModule +from .zone import Zone + +if TYPE_CHECKING: + from ...core.varset import VarSet + from ..driver.driver import Driver + from .controller import MachineController +from .coordinate_system import CoordinateSystem + + +class Origin(Enum): + TOP_LEFT = "top_left" + BOTTOM_LEFT = "bottom_left" + TOP_RIGHT = "top_right" + BOTTOM_RIGHT = "bottom_right" + + +class JogDirection(Enum): + """Visual direction for jog operations.""" + + EAST = "east" + WEST = "west" + NORTH = "north" + SOUTH = "south" + UP = "up" + DOWN = "down" + + +logger = logging.getLogger(__name__) + +MACHINE_SPACE_WCS = "MACHINE" + +_MARGIN_EPSILON = 0.1 # mm — minimum work-area dimension after clamping + + +def _clamp_margins(margins: Rect, extents: tuple[float, float]) -> Rect: + """Clamp margins so work-area dimensions stay >= _MARGIN_EPSILON.""" + ml, mt, mr, mb = margins + w, h = extents + ml = min(ml, w - _MARGIN_EPSILON) + mr = min(mr, w - ml - _MARGIN_EPSILON) + mt = min(mt, h - _MARGIN_EPSILON) + mb = min(mb, h - mt - _MARGIN_EPSILON) + return (ml, mt, mr, mb) + + +def _raise_error(*args, **kwargs): + raise RuntimeError("Cannot schedule from worker process") + + +class Machine: + def __init__(self, context: RayforgeContext): + logger.debug("Machine.__init__") + self.id = str(uuid.uuid4()) + self.name: str = _("Default Machine") + self.context = context + + if multiprocessing.current_process().daemon: + # This is the worker process, do not allow scheduling signals. + self._scheduler = _raise_error + else: + # This is the main process, use the real scheduler. + self._scheduler = task_mgr.schedule_on_main_thread + + # Signals + self.changed = Signal() + self.settings_error = Signal() + self.settings_updated = Signal() + self.setting_applied = Signal() + self.connection_status_changed = Signal() + self.state_changed = Signal() + self.job_finished = Signal() + self.command_status_changed = Signal() + self.wcs_updated = Signal() + + self.connection_status: TransportStatus = TransportStatus.DISCONNECTED + self.device_state: DeviceState = DeviceState() + + self.driver_name: str | None = None + self.driver_args: dict[str, Any] = {} + self.driver_config: dict[str, Any] = {} + self.precheck_error: str | None = None + + self.auto_connect: bool = True + self.home_on_start: bool = False + self.clear_alarm_on_connect: bool = False + self.single_axis_homing_enabled: bool = True + self.dialect_uid: str | None = "grbl" + self.dialect_migrated: bool = False + self._hydrated_dialect: GcodeDialect | None = None + self.gcode_precision: int = 3 + self.supports_arcs: bool = True + self.supports_curves: bool = False + self.arc_tolerance: float = 0.03 + self.unit_system: UnitSystem = UnitSystem.METRIC + self.hookmacros: dict[MacroTrigger, Macro] = {} + self.macros: dict[str, Macro] = {} + self.heads: list[Head] = [] + self._explicit_capabilities: frozenset[MachineCapability] | None = None + self.cameras: list[Camera] = [] + 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² + self.axes: AxisSet = AxisSet( + [ + AxisConfig( + letter=Axis.X, + axis_type=AxisType.LINEAR, + extents=(0, 200), + ), + AxisConfig( + letter=Axis.Y, + axis_type=AxisType.LINEAR, + extents=(0, 200), + ), + AxisConfig( + letter=Axis.Z, + axis_type=AxisType.LINEAR, + extents=(-50, 50), + ), + ] + ) + self._work_margins: Rect = ( + 0.0, + 0.0, + 0.0, + 0.0, + ) + self._soft_limits: Rect | None = None + self.origin: Origin = Origin.BOTTOM_LEFT + self.panel = MachinePanel(self) + self.rotary_enabled_default: bool = False + self.default_rotary_module_uid: str | None = None + self.soft_limits_enabled: bool = True + self.wcs_origin_is_workarea_origin: bool = False + self._settings_lock = asyncio.Lock() + + # Work Coordinate System (WCS) State + # We default to standard G-code names for convenience, but the logic + # is agnostic. Any key in wcs_offsets is considered a mutable WCS. + # Any key NOT in wcs_offsets is considered an immutable/absolute system + # with (0,0,0) offset. + self.active_wcs: str = "G54" + self.coordinate_systems: dict[str, CoordinateSystem] = ( + CoordinateSystem.defaults() + ) + + self.machine_hours: MachineHours = MachineHours() + self.machine_hours.changed.connect(self._on_machine_hours_changed) + + # Connect to dialect manager to detect dialect changes + self.context.dialect_mgr.dialects_changed.connect( + self._on_dialects_changed + ) + + self.add_head(LaserHead()) + + self.rotary_modules: dict[str, RotaryModule] = {} + self.nogo_zones: dict[str, Zone] = {} + + self._assembly: Assembly | None = None + self._assembly_dirty: bool = True + self._mounted_rotaries: list[RotaryModule] = [] + self._layer_configured: bool = False + + @property + def controller(self) -> "MachineController": + """ + Dynamically retrieves the controller for this machine from the + MachineManager. This enables lazy instantiation. + """ + return self.context.machine_mgr.get_controller(self.id) + + @property + def has_controller(self) -> bool: + """ + Returns whether a controller currently exists for this machine + without lazily creating one. This is ``False`` once the machine has + been removed and its controller torn down, allowing callers to skip + controller access that would otherwise raise. + """ + return self.context.machine_mgr.has_controller(self.id) + + @property + def driver(self) -> "Driver": + """Property to access the driver through the controller.""" + return self.controller.driver + + def supports_pwm(self, head: Head | None = None) -> bool: + """Whether the machine's driver supports PWM for the given head.""" + if head is None: + if not self.heads: + return False + head = self.heads[0] + return bool(self.driver.supports_pwm(head)) + + def get_pwm_params(self, head: Head | None = None) -> PWMParams | None: + """ + Returns the driver-reported PWM parameters for the given head, or + None when the driver reports no PWM support. + """ + if head is None: + if not self.heads: + return None + head = self.heads[0] + return self.driver.get_pwm_params(head) + + def get_pwm_settings(self, head: Head | None = None) -> Optional["VarSet"]: + """ + Returns the PWM settings VarSet for the given head, or None when + the driver reports no PWM support. + """ + params = self.get_pwm_params(head) + if params is None: + return None + return pwm_varset(params) + + def get_capabilities(self) -> frozenset[MachineCapability]: + """ + Returns the machine capabilities as the union of the explicitly + declared capabilities, the capabilities inferred from the + configured heads (e.g. a LaserHead implies LASER), and the + capabilities inferred from the driver (e.g. PWM on Ruida CO2 + lasers). + """ + caps: set = set(self._explicit_capabilities or ()) + for head in self.heads: + if head.machine_capability: + caps.add(head.machine_capability) + if self.supports_pwm(): + caps.add(MachineCapability.PWM) + if self.rotary_modules: + caps.add(MachineCapability.ROTARY) + return frozenset(caps) + + def set_explicit_capabilities( + self, capabilities: frozenset[MachineCapability] | None + ): + """ + Sets the explicitly declared capabilities. ``None`` means + "not set", so capabilities are inferred from the heads. + """ + if self._explicit_capabilities != capabilities: + self._explicit_capabilities = capabilities + self.changed.send(self) + + def _connect_controller_signals(self, controller: "MachineController"): + """ + Connects this machine's signal proxies to the controller's signals. + This is now called by the MachineManager when the controller is + created. + """ + controller.connection_status_changed.connect( + self.connection_status_changed.send + ) + controller.state_changed.connect(self.state_changed.send) + controller.job_finished.connect(self.job_finished.send) + controller.command_status_changed.connect( + self.command_status_changed.send + ) + controller.wcs_updated.connect(self.wcs_updated.send) + + def set_device_state(self, state: DeviceState): + self.device_state = state + + def set_connection_status(self, status: TransportStatus): + self.connection_status = status + + def set_precheck_error(self, error: str | None): + self.precheck_error = error + + def set_unit_system(self, unit_system: UnitSystem) -> None: + """ + Updates the machine's unit system and emits ``changed`` when + it actually changed. Set by the probe wizard during device + setup or by the user from the machine settings. + """ + if self.unit_system != unit_system: + self.unit_system = unit_system + self.changed.send(self) + + def update_wcs_offset(self, slot: str, offset: Point3D): + cs = self.coordinate_systems.get(slot) + if cs: + cs.offset = offset + + def update_wcs_offsets_batch(self, offsets: dict[str, Point3D]) -> bool: + new_systems = { + name: CoordinateSystem(name=name, label="", offset=offset) + for name, offset in offsets.items() + } + changed = False + for name, cs in new_systems.items(): + old = self.coordinate_systems.get(name) + if old is None or old.offset != cs.offset: + changed = True + break + self.coordinate_systems = new_systems + return changed + + def get_wcs_offset(self, name: str) -> Point3D: + cs = self.coordinate_systems.get(name) + return cs.offset if cs else (0.0, 0.0, 0.0) + + @property + def supported_wcs(self) -> list[str]: + """ + Returns the list of supported Work Coordinate Systems from the driver. + """ + return sorted(self.coordinate_systems.keys()) + + def get_wcs_list(self) -> list[CoordinateSystem]: + """Returns a sorted list of CoordinateSystem objects.""" + return [self.coordinate_systems[k] for k in self.supported_wcs] + + def get_wcs_label(self, name: str) -> str: + cs = self.coordinate_systems.get(name) + return cs.label if cs else "" + + def set_wcs_label(self, name: str, label: str): + cs = self.coordinate_systems.get(name) + if not cs: + return + if cs.label == label: + return + cs.label = label + self.changed.send(self) + + @property + def kinematics(self) -> Kinematics: + return Kinematics(self.assembly) + + @property + def assembly(self) -> Assembly: + if self._assembly_dirty or self._assembly is None: + self._assembly = self._build_assembly() + self._assembly_dirty = False + return self._assembly + + def invalidate_assembly(self): + self._assembly_dirty = True + + def configure_for_layer(self, layer: Optional["Layer"]) -> None: + required_rotaries: list[RotaryModule] = [] + if layer and layer.rotary_enabled: + module = self.get_rotary_module_for_layer(layer) + if module: + required_rotaries.append(module) + if self._assembly_needs_rebuild(required_rotaries): + self._mounted_rotaries = required_rotaries + self._layer_configured = True + self._assembly_dirty = True + + def _assembly_needs_rebuild( + self, + rotaries: list[RotaryModule], + ) -> bool: + if self._assembly_dirty: + return True + if len(rotaries) != len(self._mounted_rotaries): + return True + current_uids = {r.uid for r in self._mounted_rotaries} + new_uids = {r.uid for r in rotaries} + return current_uids != new_uids + + def get_head_specs(self) -> list[HeadSpec]: + """Return the head specs used to build an assembly. + + Does not build or mutate anything. Each spec is a ``(model, + transform)`` pair with the focal distance folded into the + transform's Z translation. + """ + head_specs: list[HeadSpec] = [] + for h in self.heads: + t = h.transform.copy() + focal_distance = getattr(h, "focal_distance", 0.0) + if focal_distance > 0: + t[2, 3] += focal_distance + model = ( + Model.from_path(Path(h.model_path)) if h.model_path else None + ) + head_specs.append((model, t)) + return head_specs + + def build_assembly_for_rotary( + self, + rotary_modules: dict[str, RotaryModule] | None = None, + ) -> Assembly: + """Build a throwaway assembly for the given rotary modules. + + Unlike ``configure_for_layer`` + ``assembly``, this never mutates + machine state. When *rotary_modules* is None or empty, a flat + assembly (no rotary) is built. + """ + return build_assembly( + axis_set=self.axes, + head_specs=self.get_head_specs(), + rotary_modules=rotary_modules or None, + ) + + def _build_assembly(self) -> Assembly: + rotaries = self._mounted_rotaries + if not rotaries and not self._layer_configured and self.rotary_modules: + rotaries = list(self.rotary_modules.values())[:1] + rotary_modules_for_build: dict[str, RotaryModule] = {} + if rotaries: + for r in rotaries: + rotary_modules_for_build[r.uid] = r + return self.build_assembly_for_rotary(rotary_modules_for_build or None) + + @property + def machine_space_wcs(self) -> str: + """ + Returns the identifier for the machine space coordinate system. + Delegates to the controller's driver property. + """ + return self.controller.machine_space_wcs + + @property + def machine_space_wcs_display_name(self) -> str: + """ + Returns the display name for the machine space coordinate system. + Delegates to the controller's driver property. + """ + return self.controller.machine_space_wcs_display_name + + async def connect(self): + """Public method to connect the driver.""" + await self.controller.connect() + + async def disconnect(self): + """Public method to disconnect the driver.""" + await self.controller.disconnect() + + async def shutdown(self): + """ + Gracefully shuts down the machine's active driver and resources. + """ + logger.info(f"Shutting down machine '{self.name}' (id:{self.id})") + # We only shut down the controller if it exists to avoid creating + # it during shutdown if it wasn't used. + try: + # Check for existence via manager without triggering creation + # if possible, or just call the manager's shutdown for this ID. + # But simpler here is to let the manager handle bulk shutdown. + # If we are shutting down a specific machine instance: + if self.id in self.context.machine_mgr.controllers: + await self.controller.shutdown() + except Exception as e: # noqa: BLE001 - best-effort shutdown cleanup + logger.warning(f"Error shutting down controller: {e}") + + self.context.dialect_mgr.dialects_changed.disconnect( + self._on_dialects_changed + ) + + def _on_dialects_changed(self, sender=None, **kwargs): + """ + Callback when dialects are updated. + Sends machine's changed signal to trigger recalculation. + """ + self._hydrated_dialect = None + self.changed.send(self) + + def is_connected(self) -> bool: + """ + Checks if the machine's driver is currently connected to the device. + + Returns: + True if connected, False otherwise. + """ + return self.connection_status == TransportStatus.CONNECTED + + async def select_tool(self, index: int): + """Sends a command to the driver to select a tool.""" + await self.controller.select_tool(index) + + def set_name(self, name: str): + self.name = str(name) + self.changed.send(self) + + def set_driver(self, driver_cls: type["Driver"], args=None): + new_driver_name = driver_cls.__name__ + new_args = args or {} + if ( + self.driver_name == new_driver_name + and self.driver_args == new_args + ): + return + + self.driver_name = new_driver_name + self.driver_args = new_args + self.changed.send(self) + task_mgr.add_coroutine( + self.controller.rebuild_driver, + key=(self.id, "rebuild-driver"), + ) + + def set_driver_args(self, args=None): + new_args = args or {} + if self.driver_args == new_args: + return + + self.driver_args = new_args + self.changed.send(self) + task_mgr.add_coroutine( + self.controller.rebuild_driver, + key=(self.id, "rebuild-driver"), + ) + + @property + def dialect(self) -> Optional["GcodeDialect"]: + """Get the current dialect instance for this machine.""" + if self._hydrated_dialect: + return self._hydrated_dialect + if self.dialect_uid is None: + return None + try: + return self.context.dialect_mgr.get(self.dialect_uid) + except ValueError: + logger.warning( + f"Dialect '{self.dialect_uid}' not found for machine " + f"'{self.name}'. Falling back to 'grbl'." + ) + self.dialect_uid = "grbl" + return self.context.dialect_mgr.get("grbl") + + def hydrate(self): + """ + Fetches the current dialect from the registry and stores it internally. + This ensures that when serialized, the machine carries the full + dialect definition. + """ + if self.dialect_uid is None: + return + try: + self._hydrated_dialect = self.context.dialect_mgr.get( + self.dialect_uid + ) + except ValueError: + logger.warning( + f"Dialect '{self.dialect_uid}' not found for machine " + f"'{self.name}'. Falling back to 'grbl'." + ) + self.dialect_uid = "grbl" + self._hydrated_dialect = self.context.dialect_mgr.get("grbl") + + def set_dialect_uid(self, dialect_uid: str | None): + if self.dialect_uid == dialect_uid: + return + self.dialect_uid = dialect_uid + self._hydrated_dialect = None + self.changed.send(self) + + def set_gcode_precision(self, precision: int): + if self.gcode_precision == precision: + return + self.gcode_precision = precision + self.changed.send(self) + + def set_arc_tolerance(self, tolerance: float): + if self.arc_tolerance == tolerance: + return + self.arc_tolerance = tolerance + self.changed.send(self) + + def set_supports_curves(self, supports: bool): + if self.supports_curves == supports: + return + self.supports_curves = supports + self.changed.send(self) + + def set_supports_arcs(self, supports: bool): + if self.supports_arcs == supports: + return + self.supports_arcs = supports + self.changed.send(self) + + def set_home_on_start(self, home_on_start: bool = True): + if self.home_on_start == home_on_start: + return + self.home_on_start = home_on_start + self.changed.send(self) + + def set_clear_alarm_on_connect(self, clear_alarm: bool = True): + if self.clear_alarm_on_connect == clear_alarm: + return + self.clear_alarm_on_connect = clear_alarm + self.changed.send(self) + + def set_single_axis_homing_enabled(self, enabled: bool = True): + if self.single_axis_homing_enabled == enabled: + return + self.single_axis_homing_enabled = enabled + self.changed.send(self) + + def set_max_travel_speed(self, speed: int): + if self.max_travel_speed == speed: + return + self.max_travel_speed = speed + self.changed.send(self) + + def set_max_cut_speed(self, speed: int): + if self.max_cut_speed == speed: + return + self.max_cut_speed = speed + self.changed.send(self) + + def set_acceleration(self, acceleration: int): + if self.acceleration == acceleration: + return + self.acceleration = acceleration + self.changed.send(self) + + @property + def axis_extents(self) -> tuple[float, float]: + """The full range of machine axis movement (width, height).""" + x_cfg = self.axes.get(Axis.X) + y_cfg = self.axes.get(Axis.Y) + return ( + x_cfg.extents[1] if x_cfg else 200.0, + y_cfg.extents[1] if y_cfg else 200.0, + ) + + @property + def reverse_x_axis(self) -> bool: + cfg = self.axes.get(Axis.X) + return cfg.direction == AxisDirection.REVERSED if cfg else False + + @property + def reverse_y_axis(self) -> bool: + cfg = self.axes.get(Axis.Y) + return cfg.direction == AxisDirection.REVERSED if cfg else False + + @property + def reverse_z_axis(self) -> bool: + cfg = self.axes.get(Axis.Z) + return cfg.direction == AxisDirection.REVERSED if cfg else False + + def _clamp_soft_limits(self): + """Clamp soft limits to axis extents. Returns True if clamped.""" + if self._soft_limits is None: + return False + w, h = self.axis_extents + x_min, y_min, x_max, y_max = self._soft_limits + clamped = ( + max(0.0, min(x_min, w)), + max(0.0, min(y_min, h)), + max(0.0, min(x_max, w)), + max(0.0, min(y_max, h)), + ) + if clamped != self._soft_limits: + self._soft_limits = clamped + return True + return False + + def set_axis_extents(self, width: float, height: float): + if self.axis_extents == (width, height): + return + x_cfg = self.axes.get(Axis.X) + y_cfg = self.axes.get(Axis.Y) + if x_cfg: + x_cfg.extents = (0, width) + if y_cfg: + y_cfg.extents = (0, height) + clamped = _clamp_margins(self._work_margins, (width, height)) + if clamped != self._work_margins: + logger.warning( + "Work margins exceed new bed extents (%.0f x %.0f); clamped.", + width, + height, + ) + self._work_margins = clamped + self._clamp_soft_limits() + self.changed.send(self) + + @property + def work_margins(self) -> Rect: + """ + The margins around the work area (left, top, right, bottom). + These are positive distances from the axis extents edges. + """ + return self._work_margins + + def set_work_margins( + self, left: float, top: float, right: float, bottom: float + ): + new_margins = (left, top, right, bottom) + if self._work_margins == new_margins: + return + clamped = _clamp_margins(new_margins, self.axis_extents) + if clamped != new_margins: + logger.warning( + "Work margins (%.1f, %.1f, %.1f, %.1f) exceed bed " + "extents (%.0f x %.0f); clamped.", + left, + top, + right, + bottom, + *self.axis_extents, + ) + self._work_margins = clamped + self._soft_limits = None + self.changed.send(self) + + @property + def work_area(self) -> Rect: + """ + The usable work area within the axis extents (x, y, w, h). + Computed from axis_extents and work_margins. + """ + ml, mt, mr, mb = self._work_margins + w, h = self.axis_extents + return (ml, mt, w - ml - mr, h - mt - mb) + + @property + def soft_limits(self) -> Rect | None: + """ + Configurable safety bounds for jogging (x_min, y_min, x_max, y_max). + None means use work_area bounds. + """ + return self._soft_limits + + def set_soft_limits( + self, x_min: float, y_min: float, x_max: float, y_max: float + ): + w, h = self.axis_extents + clamped = ( + max(0.0, min(x_min, w)), + max(0.0, min(y_min, h)), + max(0.0, min(x_max, w)), + max(0.0, min(y_max, h)), + ) + if self._soft_limits == clamped: + return + self._soft_limits = clamped + self.changed.send(self) + + def clear_soft_limits(self): + if self._soft_limits is None: + return + self._soft_limits = None + self.changed.send(self) + + def set_origin(self, origin: Origin): + if self.origin == origin: + return + self.origin = origin + self.changed.send(self) + + @property + def panel_orientation(self) -> PanelOrientation: + """How the native bed is presented on screen.""" + return self.panel.orientation + + def set_panel_orientation(self, orientation: PanelOrientation) -> None: + """Set how the native bed is presented on screen. + + See :meth:`MachinePanel.set_orientation` for details. + """ + self.panel.set_orientation(orientation) + + def set_reverse_x_axis(self, is_reversed: bool): + """Sets if the X-axis coordinate display is inverted.""" + if self.reverse_x_axis == is_reversed: + return + cfg = self.axes.get(Axis.X) + if cfg: + cfg.direction = ( + AxisDirection.REVERSED if is_reversed else AxisDirection.NORMAL + ) + self.changed.send(self) + + def set_reverse_y_axis(self, is_reversed: bool): + """Sets if the Y-axis coordinate display is inverted.""" + if self.reverse_y_axis == is_reversed: + return + cfg = self.axes.get(Axis.Y) + if cfg: + cfg.direction = ( + AxisDirection.REVERSED if is_reversed else AxisDirection.NORMAL + ) + self.changed.send(self) + + def set_reverse_z_axis(self, is_reversed: bool): + """Sets if the Z-axis direction is reversed.""" + if self.reverse_z_axis == is_reversed: + return + cfg = self.axes.get(Axis.Z) + if cfg: + cfg.direction = ( + AxisDirection.REVERSED if is_reversed else AxisDirection.NORMAL + ) + self.changed.send(self) + + def set_rotary_enabled_default(self, enabled: bool): + if self.rotary_enabled_default == enabled: + return + self.rotary_enabled_default = enabled + self.changed.send(self) + + def set_default_rotary_module_uid(self, uid: str | None): + if self.default_rotary_module_uid == uid: + return + self.default_rotary_module_uid = uid + self.changed.send(self) + + def set_wcs_origin_is_workarea_origin(self, value: bool): + """Sets if the workarea origin should be treated as coordinate zero.""" + if self.wcs_origin_is_workarea_origin == value: + return + self.wcs_origin_is_workarea_origin = value + self.changed.send(self) + + @property + def y_axis_down(self) -> bool: + """ + True if the Y coordinate decreases as the head moves away from the + user (i.e., origin is at the top). Used for G-code generation. + """ + return self.origin in (Origin.TOP_LEFT, Origin.TOP_RIGHT) + + @property + def x_axis_right(self) -> bool: + """ + True if the X coordinate decreases as the head moves left + (i.e., origin is on the right). Used for G-code generation. + """ + return self.origin in (Origin.TOP_RIGHT, Origin.BOTTOM_RIGHT) + + def get_coordinate_space(self) -> "MachineSpace": + """ + Get the machine's coordinate space configuration. + + Returns: + A MachineSpace instance representing this machine's + coordinate system configuration. + """ + return MachineSpace.from_machine(self) + + def calculate_jog(self, direction: JogDirection, distance: float) -> float: + """ + Calculate the signed coordinate delta for a jog operation based on a + visual direction. + + Args: + direction: The visual direction for the jog. + distance: The positive distance for the jog. + + Returns: + The signed delta for the specified direction, taking into account + origin position and reverse axis settings. + """ + if direction == JogDirection.EAST: + delta = -distance if self.x_axis_right else distance + return -delta if self.reverse_x_axis else delta + if direction == JogDirection.WEST: + delta = distance if self.x_axis_right else -distance + return -delta if self.reverse_x_axis else delta + if direction == JogDirection.NORTH: + delta = -distance if self.y_axis_down else distance + return -delta if self.reverse_y_axis else delta + if direction == JogDirection.SOUTH: + delta = distance if self.y_axis_down else -distance + return -delta if self.reverse_y_axis else delta + if direction == JogDirection.UP: + return -distance if self.reverse_z_axis else distance + if direction == JogDirection.DOWN: + return distance if self.reverse_z_axis else -distance + return 0.0 + + def set_soft_limits_enabled(self, enabled: bool): + """Enable or disable soft limits for jog operations.""" + if self.soft_limits_enabled == enabled: + return + self.soft_limits_enabled = enabled + self.changed.send(self) + + def get_current_position(self) -> Pos: + """Get the current work position of the machine.""" + return self.device_state.work_pos + + def get_soft_limits(self) -> Rect: + """Get the soft limits as (x_min, y_min, x_max, y_max).""" + if self._soft_limits is not None: + x_min, y_min, x_max, y_max = self._soft_limits + if self.reverse_x_axis: + x_min, x_max = -x_max, -x_min + if self.reverse_y_axis: + y_min, y_max = -y_max, -y_min + return (float(x_min), float(y_min), float(x_max), float(y_max)) + + w, h = float(self.axis_extents[0]), float(self.axis_extents[1]) + + x_min = -w if self.reverse_x_axis else 0.0 + x_max = 0.0 if self.reverse_x_axis else w + y_min = -h if self.reverse_y_axis else 0.0 + y_max = 0.0 if self.reverse_y_axis else h + + return (x_min, y_min, x_max, y_max) + + def would_jog_exceed_limits(self, axis: Axis, distance: float) -> bool: + """ + Check if a jog operation would exceed soft limits. + + Note: The `distance` argument must be the final, signed coordinate + delta that will be sent to the machine. + """ + if not self.soft_limits_enabled: + return False + + current_pos = self.device_state.machine_pos + x_pos, y_pos = current_pos[0], current_pos[1] + x_min, y_min, x_max, y_max = self.get_soft_limits() + + # Check X axis + if axis & Axis.X: + if x_pos is None: + return False # Cannot check limits if position is unknown + new_x = x_pos + distance + if new_x < x_min or new_x > x_max: + return True + + # Check Y axis + if axis & Axis.Y: + if y_pos is None: + return False # Cannot check limits if position is unknown + new_y = y_pos + distance + if new_y < y_min or new_y > y_max: + return True + + # Note: Z-axis soft limits are not currently implemented + + return False + + def _adjust_jog_distance_for_limits( + self, axis: Axis, distance: float + ) -> float: + """Adjust jog distance to stay within soft limits.""" + if not self.soft_limits_enabled: + return distance + + current_pos = self.device_state.machine_pos + x_pos, y_pos = current_pos[0], current_pos[1] + x_min, y_min, x_max, y_max = self.get_soft_limits() + adjusted_distance = distance + + # Check X axis + if axis & Axis.X: + if x_pos is None: + return distance # Cannot adjust if position is unknown + new_x = x_pos + distance + if new_x < x_min: + adjusted_distance = x_min - x_pos + elif new_x > x_max: + adjusted_distance = x_max - x_pos + + # Check Y axis + if axis & Axis.Y: + if y_pos is None: + return distance # Cannot adjust if position is unknown + new_y = y_pos + distance + if new_y < y_min: + adjusted_distance = y_min - y_pos + elif new_y > y_max: + adjusted_distance = y_max - y_pos + + return adjusted_distance + + @property + def reports_granular_progress(self) -> bool: + """Check if the machine's driver reports granular progress.""" + return self.controller.reports_granular_progress + + def can_home(self, axis: Axis | None = None) -> bool: + """Check if the machine's driver supports homing for the given axis.""" + return self.controller.can_home(axis) + + async def home(self, axes=None): + """Homes the specified axes or all axes if none specified.""" + await self.controller.home(axes) + + async def jog(self, deltas: dict[Axis, float], speed: int): + """ + Jogs the machine along specified axes. + + Args: + deltas: Dictionary mapping Axis enum members to distances in mm. + speed: Speed in mm/min. + """ + await self.controller.jog(deltas, speed) + + async def run_raw(self, gcode: str): + """Executes a raw G-code string on the machine.""" + await self.controller.run_raw(gcode) + + def can_jog(self, axis: Axis | None = None) -> bool: + """Check if machine's supports jogging for the given axis.""" + return self.controller.can_jog(axis) + + def add_head(self, head: Head): + self.heads.append(head) + head.changed.connect(self._on_head_changed) + self.invalidate_assembly() + self.changed.send(self) + + def get_head_by_uid(self, uid: str) -> Head | None: + for head in self.heads: + if head.uid == uid: + return head + return None + + def get_default_head(self) -> Head: + """Returns the first head, or raises an error if none exist.""" + if not self.heads: + raise ValueError("Machine has no heads configured.") + return self.heads[0] + + def get_default_laser_head(self) -> LaserHead | None: + """Returns the first laser head, or None if none exist.""" + for head in self.heads: + if isinstance(head, LaserHead): + return head + return None + + def remove_head(self, head: Head): + head.changed.disconnect(self._on_head_changed) + self.heads.remove(head) + self.invalidate_assembly() + self.changed.send(self) + + def _on_head_changed(self, head, *args): + self.invalidate_assembly() + self.changed.send(self) + + def add_camera(self, camera: Camera): + self.cameras.append(camera) + camera.changed.connect(self._on_camera_changed) + self.changed.send(self) + + def remove_camera(self, camera: Camera): + camera.changed.disconnect(self._on_camera_changed) + self.cameras.remove(camera) + self.changed.send(self) + + def _on_camera_changed(self, camera, *args): + self.changed.send(self) + + def add_rotary_module(self, module: RotaryModule): + self.rotary_modules[module.uid] = module + module.changed.connect(self._on_rotary_module_changed) + self._sync_rotary_axis_config(module) + self.invalidate_assembly() + self.changed.send(self) + + def get_rotary_module_by_uid(self, uid: str) -> RotaryModule | None: + return self.rotary_modules.get(uid) + + def get_default_rotary_module(self) -> RotaryModule | None: + if self.default_rotary_module_uid: + return self.get_rotary_module_by_uid( + self.default_rotary_module_uid + ) + return None + + def get_rotary_module_for_layer( + self, layer: "Layer" + ) -> RotaryModule | None: + """Resolve the effective rotary module for *layer*. + + Returns the module referenced by + :attr:`layer.rotary_module_uid` when it exists on this + machine. When the layer has rotary enabled but its module + UID is missing or invalid, falls back to the machine's + default module (or the first available module when no + default is set) so that rotary mapping is still applied. + """ + if not layer.rotary_enabled: + return None + if layer.rotary_module_uid: + module = self.rotary_modules.get(layer.rotary_module_uid) + if module is not None: + return module + default = self.get_default_rotary_module() + if default is not None: + return default + if self.rotary_modules: + return next(iter(self.rotary_modules.values())) + return None + + def get_rotary_axis_for_layer(self, layer: "Layer") -> Axis | None: + if not layer.rotary_enabled: + return None + module = self.get_rotary_module_for_layer(layer) + return module.axis if module else None + + def remove_rotary_module(self, module: RotaryModule): + module.changed.disconnect(self._on_rotary_module_changed) + del self.rotary_modules[module.uid] + if self._manages_axis_config(module): + self.axes.remove_config(module.axis) + if self.default_rotary_module_uid == module.uid: + remaining = list(self.rotary_modules.keys()) + self.default_rotary_module_uid = ( + remaining[0] if remaining else None + ) + self._mounted_rotaries = [ + r for r in self._mounted_rotaries if r.uid != module.uid + ] + self.invalidate_assembly() + self.changed.send(self) + + def _on_rotary_module_changed(self, module, *args): + self._sync_rotary_axis_config(module) + self.invalidate_assembly() + self.changed.send(self) + + @staticmethod + def _manages_axis_config(module: RotaryModule) -> bool: + return module.mode == RotaryMode.TRUE_4TH_AXIS and module.axis in { + Axis.A, + Axis.B, + Axis.C, + Axis.U, + } + + def _sync_rotary_axis_config(self, module: RotaryModule) -> None: + if not self._manages_axis_config(module): + return + existing = self.axes.get(module.axis) + if existing is None: + self.axes.add_config( + AxisConfig( + letter=module.axis, + axis_type=AxisType.ROTARY, + extents=(0, 360), + rotary_diameter=module.default_diameter, + ) + ) + elif existing.axis_type != AxisType.ROTARY: + self.axes.remove_config(module.axis) + self.axes.add_config( + AxisConfig( + letter=module.axis, + axis_type=AxisType.ROTARY, + extents=(0, 360), + rotary_diameter=module.default_diameter, + ) + ) + else: + existing.rotary_diameter = module.default_diameter + + def add_nogo_zone(self, zone: Zone): + self.nogo_zones[zone.uid] = zone + zone.changed.connect(self._on_nogo_zone_changed) + self.changed.send(self) + + def get_nogo_zone_by_uid(self, uid: str) -> Zone | None: + return self.nogo_zones.get(uid) + + def remove_nogo_zone(self, zone: Zone): + zone.changed.disconnect(self._on_nogo_zone_changed) + del self.nogo_zones[zone.uid] + self.changed.send(self) + + def _on_nogo_zone_changed(self, zone, *args): + self.changed.send(self) + + def _on_machine_hours_changed(self, machine_hours, *args): + """ + Handle machine hours changes and propagate to machine changed + signal. + """ + self._scheduler(self.changed.send, self) + + def add_machine_hours(self, hours: float) -> None: + """ + Add hours to the machine's total hours and all counters. + + Args: + hours: Hours to add (can be fractional). + """ + self.machine_hours.add_hours(hours) + + def get_machine_hours(self) -> MachineHours: + """Get the machine hours tracker.""" + return self.machine_hours + + def add_macro(self, macro: Macro): + """Adds a macro and notifies listeners.""" + if macro.uid in self.macros: + return + self.macros[macro.uid] = macro + self.changed.send(self) + + def remove_macro(self, macro_uid: str): + """Removes a macro and notifies listeners.""" + if macro_uid not in self.macros: + return + del self.macros[macro_uid] + self.changed.send(self) + + def can_frame(self): + return any( + h.frame_power_percent + for h in self.heads + if isinstance(h, LaserHead) + ) + + def can_focus(self): + return any( + h.focus_power_percent + for h in self.heads + if isinstance(h, LaserHead) + ) + + def validate_driver_setup(self) -> tuple[bool, str | None]: + """ + Validates the machine's driver arguments against the driver's setup + VarSet. Delegates to the controller. + + Returns: + A tuple of (is_valid, error_message). + """ + return self.controller.validate_driver_setup() + + async def set_power( + self, head: Optional["Laser"] = None, percent: float = 0.0 + ) -> None: + """ + Sets the laser power to the specified percentage of max power. + + Args: + head: The laser head to control. If None, uses the default head. + percent: Power percentage (0-1.0). 0 disables power. + """ + await self.controller.set_power(head, percent) + + async def set_focus_power( + self, head: Optional["Laser"] = None, percent: float = 0.0 + ) -> None: + """ + Sets the laser power for focus mode. + + Args: + head: The laser head to control. If None, uses the default head. + percent: Power percentage (0-1.0). 0 disables power. + """ + await self.controller.set_focus_power(head, percent) + + def get_active_wcs_offset(self) -> Point3D: + """ + Returns the (x, y, z) offset for the currently active WCS. + If the active_wcs is not in the known offsets dictionary, it assumes + an absolute coordinate system with zero offset. + """ + cs = self.coordinate_systems.get(self.active_wcs) + return cs.offset if cs else (0.0, 0.0, 0.0) + + def get_workarea_origin_offset(self) -> tuple[float, float]: + """ + Returns the position of the workarea origin in WORLD space. + + The workarea origin is at the corner specified by the machine's origin + setting. This is used to convert from MACHINE coordinates to + workarea-relative coordinates. + + Margins are: (left, top, right, bottom) + + Returns: + Tuple of (x, y) in WORLD space. + """ + ml, mt, mr, mb = self._work_margins + width, height = self.axis_extents + + if self.origin == Origin.BOTTOM_LEFT: + return (ml, mb) + elif self.origin == Origin.TOP_LEFT: + return (ml, height - mt) + elif self.origin == Origin.BOTTOM_RIGHT: + return (width - mr, mb) + else: # TOP_RIGHT + return (width - mr, height - mt) + + def get_reference_offset(self) -> Point3D: + """ + Returns the offset for converting from MACHINE to REFERENCE coords. + + REFERENCE coordinates are what the user sees in the UI. When + wcs_origin_is_workarea_origin is False, this returns the active WCS + offset. When True, this returns the workarea origin offset. + + Returns: + Tuple of (x, y, z) offset in MACHINE space. + """ + if self.wcs_origin_is_workarea_origin: + x, y = self.get_workarea_origin_offset() + return (x, y, 0.0) + else: + return self.get_active_wcs_offset() + + def get_visual_extent_frame(self) -> Rect: + """ + Returns the extent frame rectangle (x, y, width, height) in visual + coordinates relative to the work area origin. + + The work area is at (0, 0) in its own coordinate system. + The extent frame is positioned at (-margin_left, -margin_bottom) + relative to the work area origin. + + Returns: + Tuple of (x, y, width, height) where x,y is the frame position + relative to the work area origin (0,0). + """ + ml, mb = self._work_margins[0], self._work_margins[3] + extent_w, extent_h = self.axis_extents + return (float(-ml), float(-mb), float(extent_w), float(extent_h)) + + def has_custom_work_area(self) -> bool: + """ + Returns True if any margin is non-zero. + """ + ml, mt, mr, mb = self._work_margins + return ml != 0 or mt != 0 or mr != 0 or mb != 0 + + def set_active_wcs(self, wcs: str): + """ + Sets the active WCS on the model and notifies listeners. + + This updates the model state immediately. When called from the UI + (e.g. WCS dropdown), use switch_active_wcs() on the controller + instead, which also sends the G-code command to the device and + confirms the switch. + """ + if wcs != self.active_wcs: + self.active_wcs = wcs + self.changed.send(self) + + async def switch_active_wcs(self, wcs: str): + """ + Switches the active WCS on both model and device. + + Sends the G-code WCS command, confirms via $G, and re-reads + WCS offsets. Use this for UI-initiated WCS switches. + """ + await self.controller.switch_active_wcs(wcs) + + async def set_work_origin( + self, x: float, y: float, z: float, wcs_slot: str | None = None + ): + """ + Sets the work origin at the specified machine coordinates. + + Args: + x: X-coordinate in machine space. + y: Y-coordinate in machine space. + z: Z-coordinate in machine space. + wcs_slot: The WCS slot to update (e.g. "G54"). Defaults to active. + """ + await self.controller.set_work_origin(x, y, z, wcs_slot) + + async def set_work_origin_here( + self, axes: Axis, wcs_slot: str | None = None + ): + """ + Sets the work origin for the specified axes to the current machine + position. + + Args: + axes: Flag combination of axes to set (e.g. Axis.X | Axis.Y). + wcs_slot: The WCS slot to update (e.g. "G54"). Defaults to active. + """ + await self.controller.set_work_origin_here(axes, wcs_slot) + + async def sync_wcs_from_device(self): + """Queries the device for current WCS offsets and updates state.""" + await self.controller.sync_wcs_from_device() + + async def sync_active_wcs_from_device(self): + """Queries the device for its active WCS and updates state.""" + await self.controller.sync_active_wcs_from_device() + + def refresh_settings(self): + """Public API for the UI to request a settings refresh.""" + task_mgr.add_coroutine( + lambda ctx: self.controller.read_settings(), + key=(self.id, "device-settings-read"), + ) + + def apply_setting(self, key: str, value: Any): + """Public API for the UI to apply a single setting.""" + task_mgr.add_coroutine( + lambda ctx: self.controller.write_setting(key, value), + key=( + self.id, + "device-settings-write", + key, + ), # Key includes setting key for uniqueness + ) + + def get_setting_vars(self) -> list["VarSet"]: + """ + Gets the setting definitions from the machine's active driver + as a VarSet. + """ + return self.controller.get_setting_vars() + + def to_dict(self, include_frozen_dialect: bool = True) -> dict[str, Any]: + data = { + "machine": { + "name": self.name, + "driver": self.driver_name, + "driver_args": self.driver_args, + "driver_config": self.driver_config, + "auto_connect": self.auto_connect, + "clear_alarm_on_connect": self.clear_alarm_on_connect, + "home_on_start": self.home_on_start, + "single_axis_homing_enabled": self.single_axis_homing_enabled, + "dialect_uid": self.dialect_uid, + "active_wcs": self.active_wcs, + "coordinate_systems": [ + cs.to_dict() for cs in self.coordinate_systems.values() + ], + "supports_arcs": self.supports_arcs, + "supports_curves": self.supports_curves, + "arc_tolerance": self.arc_tolerance, + "axes": self.axes.to_dict(), + "axis_extents": list(self.axis_extents), + "work_margins": list(self._work_margins), + "soft_limits": list(self._soft_limits) + if self._soft_limits + else None, + "origin": self.origin.value, + "panel_orientation": self.panel.orientation.value, + "reverse_x_axis": self.reverse_x_axis, + "reverse_y_axis": self.reverse_y_axis, + "reverse_z_axis": self.reverse_z_axis, + "rotary_enabled_default": self.rotary_enabled_default, + "default_rotary_module_uid": (self.default_rotary_module_uid), + "wcs_origin_is_workarea_origin": ( + self.wcs_origin_is_workarea_origin + ), + "heads": [head.to_dict() for head in self.heads], + "cameras": [camera.to_dict() for camera in self.cameras], + "rotary_modules": [ + rm.to_dict() for rm in self.rotary_modules.values() + ], + "nogo_zones": [z.to_dict() for z in self.nogo_zones.values()], + "capabilities": ( + [ + c.value + for c in sorted( + self._explicit_capabilities, key=lambda c: c.value + ) + ] + if self._explicit_capabilities + else None + ), + "hookmacros": { + trigger.name: macro.to_dict() + for trigger, macro in self.hookmacros.items() + }, + "macros": { + uid: macro.to_dict() for uid, macro in self.macros.items() + }, + "speeds": { + "max_cut_speed": self.max_cut_speed, + "max_travel_speed": self.max_travel_speed, + "acceleration": self.acceleration, + }, + "gcode": { + "gcode_precision": self.gcode_precision, + }, + "units": { + "unit_system": self.unit_system.value, + }, + "machine_hours": self.machine_hours.to_dict(), + } + } + if include_frozen_dialect and self._hydrated_dialect: + data["machine"]["frozen_dialect"] = ( + self._hydrated_dialect.to_dict() + ) + return data + + @staticmethod + def _migrate_legacy_hooks_to_dialect( + hook_data: dict[str, Any], + current_dialect_uid: str | None, + machine_name: str, + context: RayforgeContext, + ) -> tuple[str | None, dict[str, Any]]: + """ + Checks for legacy JOB_START/JOB_END hooks and migrates them to a + new custom dialect. + + Returns: + A tuple containing the (potentially new) dialect UID and the + cleaned hook_data dictionary. + """ + if current_dialect_uid is None: + return None, hook_data + + job_start_hook_data = hook_data.get("JOB_START") + job_end_hook_data = hook_data.get("JOB_END") + + if not job_start_hook_data and not job_end_hook_data: + # No migration needed + return current_dialect_uid, hook_data + + logger.info( + f"Migrating JOB_START/JOB_END hooks to a new custom dialect " + f"for machine '{machine_name}'." + ) + + try: + base_dialect = context.dialect_mgr.get(current_dialect_uid) + except ValueError: + logger.warning( + f"Could not find base dialect '{current_dialect_uid}' for " + f"migration. Using 'grbl' as a fallback." + ) + base_dialect = context.dialect_mgr.get("grbl") + + new_label = _("{label} (for {machine_name})").format( + label=base_dialect.label, + machine_name=machine_name, + ) + new_dialect = base_dialect.copy_as_custom(new_label=new_label) + + if job_start_hook_data: + new_dialect.preamble = job_start_hook_data.get("code", []) + if job_end_hook_data: + new_dialect.postscript = job_end_hook_data.get("code", []) + + # Add the new dialect to the manager (registers and saves it) + context.dialect_mgr.add_dialect(new_dialect) + + # Clean up the old hook data so it isn't loaded or re-saved + new_hook_data = hook_data.copy() + new_hook_data.pop("JOB_START", None) + new_hook_data.pop("JOB_END", None) + + # Return the new dialect's UID and the cleaned hook data + return new_dialect.uid, new_hook_data + + @staticmethod + def _parse_capabilities( + raw: list[Any] | None, + ) -> frozenset[MachineCapability] | None: + """ + Parses a list of capability strings into a frozenset of + MachineCapability. Returns None when the list is absent, + and skips unknown values with a warning. + """ + if raw is None: + return None + caps = set() + for value in raw: + try: + caps.add(MachineCapability(value)) + except ValueError: + logger.warning(f"Unknown machine capability '{value}'") + return frozenset(caps) + + @classmethod + def from_dict( + cls, + data: dict[str, Any], + context: Optional["RayforgeContext"] = None, + ) -> "Machine": + if context is None: + context = get_context() + ma = cls(context) + ma_data = data.get("machine", {}) + ma.id = ma_data.get("id", ma.id) + ma.name = ma_data.get("name", ma.name) + ma.driver_name = ma_data.get("driver") + ma.driver_args = ma_data.get("driver_args", {}) + ma.driver_config = ma_data.get("driver_config", {}) + ma.auto_connect = ma_data.get("auto_connect", ma.auto_connect) + ma.clear_alarm_on_connect = ma_data.get( + "clear_alarm_on_connect", + ma.clear_alarm_on_connect, + ) + ma.home_on_start = ma_data.get("home_on_start", ma.home_on_start) + ma.single_axis_homing_enabled = ma_data.get( + "single_axis_homing_enabled", + ma.single_axis_homing_enabled, + ) + + dialect_uid = ma_data.get("dialect_uid") + if dialect_uid is None: + driver_cls = get_driver_cls( + ma.driver_name if ma.driver_name else "" + ) + if not driver_cls.uses_gcode: + dialect_uid = None + else: + dialect_uid = ma_data.get("dialect", "grbl").lower() + + hook_data = ma_data.get("hookmacros", {}) + + # Run the migration logic, which may update the dialect_uid and + # hook_data + dialect_uid, hook_data = cls._migrate_legacy_hooks_to_dialect( + hook_data, dialect_uid, ma.name, context + ) + + dialect_uid, migrated = ( + context.dialect_mgr.migrate_builtin_dialect_to_copy( + dialect_uid, ma.name + ) + ) + ma.dialect_migrated = migrated + ma.dialect_uid = dialect_uid + ma.active_wcs = ma_data.get("active_wcs", ma.active_wcs) + if "coordinate_systems" in ma_data: + ma.coordinate_systems = {} + for cs_data in ma_data["coordinate_systems"]: + cs = CoordinateSystem.from_dict(cs_data) + ma.coordinate_systems[cs.name] = cs + elif "wcs_offsets" in ma_data: + for name, offset in ma_data["wcs_offsets"].items(): + cs = ma.coordinate_systems.get(name) + if cs: + cs.offset = tuple(offset) + + if "axes" in ma_data: + ma.axes = AxisSet.from_dict(ma_data["axes"]) + else: + legacy_extents = tuple(ma_data.get("dimensions", ma.axis_extents)) + if "axis_extents" in ma_data: + legacy_extents = tuple(ma_data["axis_extents"]) + legacy_reverse_x = ma_data.get("reverse_x_axis", False) + legacy_reverse_y = ma_data.get("reverse_y_axis", False) + legacy_reverse_z = ma_data.get("reverse_z_axis", False) + if "x_axis_negative" in ma_data: + logger.info("Migrating legacy 'x_axis_negative' setting.") + legacy_reverse_x = ma_data["x_axis_negative"] + if "y_axis_negative" in ma_data: + logger.info("Migrating legacy 'y_axis_negative' setting.") + legacy_reverse_y = ma_data["y_axis_negative"] + ma.axes = AxisSet.from_legacy( + axis_extents=legacy_extents, + reverse_x=legacy_reverse_x, + reverse_y=legacy_reverse_y, + reverse_z=legacy_reverse_z, + rotary_modules=ma.rotary_modules, + ) + + if "work_margins" in ma_data: + ma._work_margins = tuple(ma_data["work_margins"]) + elif "offsets" in ma_data: + ox, oy = ma_data["offsets"] + ma._work_margins = (ox, 0, 0, oy) + + if "soft_limits" in ma_data and ma_data["soft_limits"] is not None: + ma._soft_limits = tuple(ma_data["soft_limits"]) + + origin_value = ma_data.get("origin", None) + if origin_value is not None: + ma.origin = Origin(origin_value) + else: # Legacy support for y_axis_down + ma.origin = ( + Origin.BOTTOM_LEFT + if ma_data.get("y_axis_down", False) is False + else Origin.TOP_LEFT + ) + + ma.rotary_enabled_default = ma_data.get( + "rotary_enabled_default", False + ) + ma.default_rotary_module_uid = ma_data.get("default_rotary_module_uid") + + orientation_value = ma_data.get( + "panel_orientation", PanelOrientation.NATIVE.value + ) + try: + ma.panel._orientation = PanelOrientation(orientation_value) + except ValueError: + logger.warning( + "Unknown panel orientation '%s'; using native", + orientation_value, + ) + ma.panel._orientation = PanelOrientation.NATIVE + + ma.soft_limits_enabled = ma_data.get( + "soft_limits_enabled", ma.soft_limits_enabled + ) + + ma.wcs_origin_is_workarea_origin = ma_data.get( + "wcs_origin_is_workarea_origin", False + ) + + # Deserialize remaining hookmacros from the (potentially cleaned) data + for trigger_name, macro_data in hook_data.items(): + try: + trigger = MacroTrigger[trigger_name] + ma.hookmacros[trigger] = Macro.from_dict(macro_data) + except KeyError: + logger.warning( + f"Skipping unknown hook trigger '{trigger_name}'" + ) + + macros_data = ma_data.get("macros", {}) + for uid, macro_data in macros_data.items(): + macro_data["uid"] = uid # Ensure UID is consistent with key + ma.macros[uid] = Macro.from_dict(macro_data) + + ma.heads = [] + for obj in ma_data.get("heads", {}): + ma.add_head(head_from_dict(obj)) + ma._explicit_capabilities = cls._parse_capabilities( + ma_data.get("capabilities") + ) + ma.cameras = [] + for obj in ma_data.get("cameras", {}): + ma.add_camera(Camera.from_dict(migrate_camera_data(obj))) + for obj in ma_data.get("rotary_modules", []): + ma.add_rotary_module(RotaryModule.from_dict(obj)) + for obj in ma_data.get("nogo_zones", []): + ma.add_nogo_zone(Zone.from_dict(obj)) + speeds = ma_data.get("speeds", {}) + ma.max_cut_speed = speeds.get("max_cut_speed", ma.max_cut_speed) + ma.max_travel_speed = speeds.get( + "max_travel_speed", ma.max_travel_speed + ) + ma.acceleration = speeds.get("acceleration", ma.acceleration) + gcode = ma_data.get("gcode", {}) + ma.gcode_precision = gcode.get("gcode_precision", ma.gcode_precision) + ma.supports_arcs = ma_data.get("supports_arcs", ma.supports_arcs) + ma.supports_curves = ma_data.get("supports_curves", ma.supports_curves) + ma.arc_tolerance = ma_data.get("arc_tolerance", ma.arc_tolerance) + + units = ma_data.get("units", {}) + unit_system_value = units.get("unit_system", "metric") + try: + ma.unit_system = UnitSystem(unit_system_value) + except ValueError: + logger.warning( + f"Unknown unit_system '{unit_system_value}' in " + f"machine config. Defaulting to metric." + ) + ma.unit_system = UnitSystem.METRIC + + hours_data = ma_data.get("machine_hours", {}) + ma.machine_hours = MachineHours.from_dict(hours_data) + ma.machine_hours.changed.connect(ma._on_machine_hours_changed) + + return ma diff --git a/rayforge/machine/models/machine_hours.py b/rayforge/machine/models/machine_hours.py new file mode 100644 index 000000000..95373bcf7 --- /dev/null +++ b/rayforge/machine/models/machine_hours.py @@ -0,0 +1,218 @@ +import logging +import uuid +from dataclasses import dataclass, field +from typing import Any + +from blinker import Signal + +logger = logging.getLogger(__name__) + + +@dataclass +class ResettableCounter: + """ + A resettable counter for tracking maintenance intervals. + + Attributes: + uid: Unique identifier for this counter. + name: Display name for the counter (e.g., "Laser Tube", "Lubrication"). + value: Current counter value in hours. + notify_at: Optional threshold value (hours) for notification. + notification_sent: True if notification has already been triggered. + """ + + uid: str = field(default_factory=lambda: str(uuid.uuid4())) + name: str = "Counter" + value: float = 0.0 + notify_at: float | None = None + notification_sent: bool = False + extra: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Serialize counter to dictionary.""" + result = { + "uid": self.uid, + "name": self.name, + "value": self.value, + "notify_at": self.notify_at, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ResettableCounter": + """Deserialize counter from dictionary.""" + known_keys = {"uid", "name", "value", "notify_at"} + extra = {k: v for k, v in data.items() if k not in known_keys} + instance = cls( + uid=data.get("uid", str(uuid.uuid4())), + name=data.get("name", "Counter"), + value=data.get("value", 0.0), + notify_at=data.get("notify_at"), + ) + instance.extra = extra + return instance + + def add_hours(self, hours: float) -> None: + """Add hours to the counter value.""" + self.value += hours + logger.debug( + f"Counter '{self.name}' added {hours}h, now {self.value}h" + ) + + def reset(self) -> None: + """Reset the counter value to 0.""" + self.value = 0.0 + self.notification_sent = False + logger.debug(f"Counter '{self.name}' reset to {self.value}h") + + def is_due_for_notification(self) -> bool: + """Check if counter has reached notification threshold.""" + if self.notify_at is None: + return False + return self.value >= self.notify_at + + +class MachineHours: + """ + Tracks machine operating hours for maintenance purposes. + + This class maintains a cumulative total of machine hours and provides + resettable counters for tracking specific maintenance intervals. + """ + + def __init__(self): + self.total_hours: float = 0.0 + self.counters: dict[str, ResettableCounter] = {} + self.changed = Signal() + self.extra: dict[str, Any] = {} + + def add_hours(self, hours: float) -> None: + """ + Add hours to total and all counters. + + Args: + hours: Hours to add (can be fractional). + """ + if hours <= 0: + return + + self.total_hours += hours + for counter in self.counters.values(): + counter.add_hours(hours) + + logger.info( + f"Added {hours}h to machine hours (total: {self.total_hours}h)" + ) + self.changed.send(self) + + def add_counter(self, counter: ResettableCounter) -> None: + """Add a new resettable counter.""" + if counter.uid in self.counters: + logger.warning(f"Counter with uid {counter.uid} already exists") + return + self.counters[counter.uid] = counter + self.changed.send(self) + + def update_counter(self, counter: ResettableCounter) -> None: + """ + Notify that a counter has been modified. + This signals listeners (like the UI) to refresh. + """ + if counter.uid in self.counters: + self.changed.send(self) + else: + logger.warning( + f"Attempted to update counter {counter.uid} which does not " + "exist." + ) + + def consume_due_notifications(self) -> list[ResettableCounter]: + """ + Identifies counters that have reached their limit but haven't been + notified yet. Marks them as notified and returns the list. + """ + due = [] + state_changed = False + + for counter in self.counters.values(): + if ( + counter.is_due_for_notification() + and not counter.notification_sent + ): + counter.notification_sent = True + due.append(counter) + state_changed = True + + if state_changed: + # Emit changed signal so the updated 'notification_sent' flags + # are persisted to disk. + self.changed.send(self) + + return due + + def remove_counter(self, counter_uid: str) -> None: + """Remove a counter by its UID.""" + if counter_uid in self.counters: + del self.counters[counter_uid] + self.changed.send(self) + + def get_counter(self, counter_uid: str) -> ResettableCounter | None: + """Get a counter by its UID.""" + return self.counters.get(counter_uid) + + def reset_counter(self, counter_uid: str) -> None: + """Reset a specific counter.""" + counter = self.get_counter(counter_uid) + if counter: + counter.reset() + self.changed.send(self) + + def reset_total_hours(self) -> None: + """Reset the total accumulated machine hours to zero.""" + self.total_hours = 0.0 + logger.info("Reset total machine hours to 0") + self.changed.send(self) + + def reset_all_counters(self) -> None: + """Reset all counters.""" + for counter in self.counters.values(): + counter.reset() + self.changed.send(self) + + def to_dict(self) -> dict[str, Any]: + """Serialize to dictionary.""" + result = { + "total_hours": self.total_hours, + "counters": { + uid: counter.to_dict() + for uid, counter in self.counters.items() + }, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "MachineHours": + """Deserialize from dictionary.""" + known_keys = {"total_hours", "counters"} + extra = {k: v for k, v in data.items() if k not in known_keys} + + machine_hours = cls() + machine_hours.total_hours = data.get("total_hours", 0.0) + + counters_data = data.get("counters", {}) + for uid, counter_data in counters_data.items(): + counter = ResettableCounter.from_dict(counter_data) + machine_hours.counters[uid] = counter + + machine_hours.extra = extra + return machine_hours + + def get_counters_due_for_notification(self) -> list[ResettableCounter]: + """Get list of counters that are due for notification.""" + return [ + counter + for counter in self.counters.values() + if counter.is_due_for_notification() + ] diff --git a/rayforge/machine/models/machine_panel.py b/rayforge/machine/models/machine_panel.py new file mode 100644 index 000000000..cb5a62491 --- /dev/null +++ b/rayforge/machine/models/machine_panel.py @@ -0,0 +1,532 @@ +""" +Display-facing projection of a machine's coordinate space. + +MachineSpace (in rayforge.machine.models.coordspace) describes the +machine in native coordinates -- how the machine "speaks". +MachinePanel describes how the bed is *shown* after an optional +90-degree rotation. Keeping these view-facing properties here avoids +mixing presentation concerns into the coordinate model itself. +""" + +from enum import Enum +from typing import TYPE_CHECKING + +import numpy as np +from raygeo.geo.types import Point, Point3D, Rect +from raygeo.ops.axis import Axis + +from .coordspace import ( + MachineSpace, + OriginCorner, +) +from .zone import Zone, ZoneShape + +if TYPE_CHECKING: + from .machine import JogDirection, Machine + +_DELTA_EPSILON = 1e-9 # mm — filter near-zero jog deltas + + +class PanelOrientation(Enum): + """How the native machine bed is presented on screen. + + The machine always reads and writes native coordinates; this only + controls whether the bed is rotated 90 degrees when projected into + world space (e.g. so a physically portrait bed can be edited in + landscape). NATIVE leaves the mapping untouched. + """ + + NATIVE = "native" + ROTATED_LEFT = "rotated_left" + ROTATED_RIGHT = "rotated_right" + + +class MachinePanel: + """Display-facing projection of a machine's coordinate space. + + The machine is the source of truth in machine coordinates. This + panel holds a reference to the :class:`Machine` and derives a + :class:`MachineSpace` from it on demand, then composes the panel + orientation rotation on top of the native transform. + """ + + def __init__(self, machine: "Machine"): + self._machine = machine + self._orientation: PanelOrientation = PanelOrientation.NATIVE + self._cached_extents = machine.axis_extents + machine.changed.connect(self._on_machine_changed) + + @property + def machine(self) -> "Machine": + return self._machine + + @property + def space(self) -> MachineSpace: + """Native coordinate-space projection for the machine.""" + return self._machine.get_coordinate_space() + + # -- Orientation state -------------------------------------------- + + @property + def orientation(self) -> PanelOrientation: + """The panel orientation (NATIVE / ROTATED_LEFT / ROTATED_RIGHT).""" + return self._orientation + + def set_orientation(self, orientation: PanelOrientation) -> None: + """Set how the native machine bed is presented on screen. + + All interactive changes of the orientation MUST go through this + setter rather than assigning ``_orientation`` directly. + + Camera calibration (``camera.image_to_world``) is stored in + presented coordinates because the camera pipeline consumes it + directly in that space. Changing the presentation therefore + re-projects the stored calibration points through the old and + new transforms so an existing physical alignment stays valid. + The rotation matrices contain only 0/+/-1 entries and exact + translations, so repeated re-projection does not accumulate + floating-point error. + + Deserialization (``Machine.from_dict``) assigns ``_orientation`` + directly instead, because persisted camera calibration was + already saved in the matching orientation. + """ + if self._orientation == orientation: + return + old_matrix = self._panel_to_native_matrix + self._orientation = orientation + new_inverse = np.linalg.inv(self._panel_to_native_matrix) + self._reproject_cameras(old_matrix, new_inverse) + self._machine.changed.send(self._machine) + + @property + def supports_rotary(self) -> bool: + """Whether rotary mapping can compose with this panel setup.""" + return self._orientation is PanelOrientation.NATIVE + + def _on_machine_changed(self, sender=None, **kwargs) -> None: + """Watch for bed-dimension changes that require camera + reprojection. + + A rotated presentation's translation depends on the native bed + dimensions, so resizing the bed shifts where presented coordinates + land. Camera calibration (stored in presented coordinates) is + re-projected so the physical alignment stays valid. + """ + current = self._machine.axis_extents + if current == self._cached_extents: + return + old_p2n = self._compute_p2n(self._orientation, self._cached_extents) + new_n2p = np.linalg.inv(self._panel_to_native_matrix) + self._cached_extents = current + self._reproject_cameras(old_p2n, new_n2p) + + def _reproject_cameras( + self, + old_p2n: np.ndarray, + new_n2p: np.ndarray, + ) -> None: + """Preserve physical camera calibration across orientation changes.""" + for camera in self._machine.cameras: + if camera.image_to_world is None: + continue + image_points, world_points = camera.image_to_world + reprojected = [] + for wx, wy in world_points: + native = old_p2n @ np.array([wx, wy, 0.0, 1.0]) + new_world = new_n2p @ native + reprojected.append((float(new_world[0]), float(new_world[1]))) + alignment_date = camera.alignment_date + camera.image_to_world = (image_points, reprojected) + camera.alignment_date = alignment_date + + # -- Rotation matrix ---------------------------------------------- + + @staticmethod + def _compute_p2n( + orientation: PanelOrientation, + extents: tuple[float, float], + ) -> np.ndarray: + """Rigid 90-degree rotation from the presented bed to the native + machine bed. + + Identity for NATIVE. ROTATED_LEFT maps presented (x, y) to + native (y, height - x); ROTATED_RIGHT maps it to native + (width - y, x). Entries are only 0 / +/-1 plus exact + translations. + """ + width, height = extents + matrix = np.identity(4, dtype=np.float64) + if orientation == PanelOrientation.ROTATED_LEFT: + matrix[0, 0] = 0.0 + matrix[0, 1] = 1.0 + matrix[1, 0] = -1.0 + matrix[1, 1] = 0.0 + matrix[1, 3] = height + elif orientation == PanelOrientation.ROTATED_RIGHT: + matrix[0, 0] = 0.0 + matrix[0, 1] = -1.0 + matrix[0, 3] = width + matrix[1, 0] = 1.0 + matrix[1, 1] = 0.0 + return matrix + + @property + def _panel_to_native_matrix(self) -> np.ndarray: + return self._compute_p2n(self._orientation, self.space.extents) + + @property + def machine_to_panel(self) -> np.ndarray: + """The 90-degree bed rotation from MACHINE-bed geometry to the + panel presentation. + + Inverse of the panel-to-native rotation. Origin-corner and + axis-reversal sign flips are not included; the 3D pipeline + applies those separately via its model matrix and axis flags. + """ + return np.linalg.inv(self._panel_to_native_matrix) + + def _machine_point_to_panel(self, x: float, y: float) -> Point: + """Project a MACHINE-bed point into panel coordinates. + + Applies only the presentation rotation; the point is physical + bed geometry that does not depend on the configured origin or + axis direction. + """ + result = self.machine_to_panel @ np.array([x, y, 0.0, 1.0]) + return float(result[0]), float(result[1]) + + def _machine_item_to_panel( + self, + pos: Point, + size: tuple[float, float], + ) -> tuple[Point, tuple[float, float]]: + """Project an axis-aligned MACHINE-bed rectangle into panel + coordinates.""" + x, y = pos + width, height = size + corners = ( + self._machine_point_to_panel(x, y), + self._machine_point_to_panel(x + width, y), + self._machine_point_to_panel(x, y + height), + self._machine_point_to_panel(x + width, y + height), + ) + xs = [point[0] for point in corners] + ys = [point[1] for point in corners] + min_x, min_y = min(xs), min(ys) + return (min_x, min_y), (max(xs) - min_x, max(ys) - min_y) + + # -- Display properties ------------------------------------------- + + @property + def origin(self) -> OriginCorner: + """The native origin corner as it appears after the rotation.""" + if self._orientation == PanelOrientation.NATIVE: + return self.space.origin + if self._orientation == PanelOrientation.ROTATED_LEFT: + return { + OriginCorner.BOTTOM_LEFT: OriginCorner.BOTTOM_RIGHT, + OriginCorner.TOP_LEFT: OriginCorner.BOTTOM_LEFT, + OriginCorner.TOP_RIGHT: OriginCorner.TOP_LEFT, + OriginCorner.BOTTOM_RIGHT: OriginCorner.TOP_RIGHT, + }[self.space.origin] + return { + OriginCorner.BOTTOM_LEFT: OriginCorner.TOP_LEFT, + OriginCorner.TOP_LEFT: OriginCorner.TOP_RIGHT, + OriginCorner.TOP_RIGHT: OriginCorner.BOTTOM_RIGHT, + OriginCorner.BOTTOM_RIGHT: OriginCorner.BOTTOM_LEFT, + }[self.space.origin] + + @property + def x_axis_right(self) -> bool: + """True when the displayed origin sits on the right (X increases + toward the left).""" + return self.origin in ( + OriginCorner.TOP_RIGHT, + OriginCorner.BOTTOM_RIGHT, + ) + + @property + def y_axis_down(self) -> bool: + """True when the displayed origin sits at the top (Y increases + downward).""" + return self.origin in ( + OriginCorner.TOP_LEFT, + OriginCorner.TOP_RIGHT, + ) + + @property + def x_axis_negative(self) -> bool: + """Whether the displayed X axis reflects a reversed native axis. + + Rotation swaps which native axis the displayed X corresponds to, + so under rotation this tracks the native Y reversal rather than + the native X reversal. + """ + if self._orientation == PanelOrientation.NATIVE: + return self.space.reverse_x + return self.space.reverse_y + + @property + def y_axis_negative(self) -> bool: + """Whether the displayed Y axis reflects a reversed native axis.""" + if self._orientation == PanelOrientation.NATIVE: + return self.space.reverse_y + return self.space.reverse_x + + # -- Presented geometry ------------------------------------------- + + @property + def extents(self) -> tuple[float, float]: + """The bed dimensions as presented on screen.""" + if self._orientation == PanelOrientation.NATIVE: + return self.space.extents + return self.space.extents[1], self.space.extents[0] + + @property + def margins(self) -> Rect: + """Native edge margins rotated into presented-edge order.""" + left, top, right, bottom = self.space.margins + if self._orientation == PanelOrientation.ROTATED_LEFT: + return top, right, bottom, left + if self._orientation == PanelOrientation.ROTATED_RIGHT: + return bottom, left, top, right + return self.space.margins + + @property + def workarea_size(self) -> tuple[float, float]: + """The (width, height) of the workarea in presented space.""" + ml, mt, mr, mb = self.margins + width, height = self.extents + return width - ml - mr, height - mt - mb + + @property + def extent_frame(self) -> Rect: + """The full bed extent frame in presented coordinates. + + Positioned at (-margin_left, -margin_bottom) relative to the + work-area origin, using the presented (rotated) margins and + extents. + """ + ml, mb = self.margins[0], self.margins[3] + extent_w, extent_h = self.extents + return (float(-ml), float(-mb), float(extent_w), float(extent_h)) + + @property + def has_custom_work_area(self) -> bool: + """True when any edge margin is non-zero (rotation invariant).""" + return self._machine.has_custom_work_area() + + @property + def nogo_zones(self) -> dict[str, Zone]: + """Read-only no-go-zone projections in panel coordinates. + + A detached copy is returned for every orientation, including + NATIVE, so callers never receive an object whose mutation + behavior changes when the panel rotates. Edits must go through + ``machine.nogo_zones`` in MACHINE-bed coordinates. + """ + projected: dict[str, Zone] = {} + for uid, zone in self._machine.nogo_zones.items(): + panel_zone = Zone.from_dict(zone.to_dict()) + params = panel_zone.params + x = params.get("x", 0.0) + y = params.get("y", 0.0) + if panel_zone.shape == ZoneShape.CYLINDER: + params["x"], params["y"] = self._machine_point_to_panel(x, y) + else: + pos, size = self._machine_item_to_panel( + (x, y), + (params.get("w", 10.0), params.get("h", 10.0)), + ) + params["x"], params["y"] = pos + params["w"], params["h"] = size + projected[uid] = panel_zone + return projected + + # -- Composed transforms ------------------------------------------ + + def get_world_to_machine_matrix(self) -> np.ndarray: + """Full world-to-machine matrix, including panel rotation.""" + return ( + self.space.get_world_to_machine_matrix() + @ self._panel_to_native_matrix + ) + + def get_machine_to_world_matrix(self) -> np.ndarray: + """Inverse of get_world_to_machine_matrix().""" + return np.linalg.inv(self.get_world_to_machine_matrix()) + + def world_point_to_machine(self, x: float, y: float) -> Point: + """Transform a point from world space to machine space.""" + matrix = self.get_world_to_machine_matrix() + result = matrix @ np.array([x, y, 0.0, 1.0]) + return float(result[0]), float(result[1]) + + def machine_point_to_world(self, x: float, y: float) -> Point: + """Transform a point from machine space to world space.""" + matrix = self.get_machine_to_world_matrix() + result = matrix @ np.array([x, y, 0.0, 1.0]) + return float(result[0]), float(result[1]) + + def world_item_to_machine( + self, + pos: Point, + size: tuple[float, float], + ) -> Point: + """Convert item position from world space to machine space.""" + wx, wy = pos + w, h = size + corners = ( + self.world_point_to_machine(wx, wy), + self.world_point_to_machine(wx + w, wy), + self.world_point_to_machine(wx, wy + h), + self.world_point_to_machine(wx + w, wy + h), + ) + xs = [c[0] for c in corners] + ys = [c[1] for c in corners] + mx = max(xs) if self.space.reverse_x else min(xs) + my = max(ys) if self.space.reverse_y else min(ys) + return mx, my + + def machine_item_to_world( + self, + pos: Point, + size: tuple[float, float], + ) -> Point: + """Convert item position from machine space to world space.""" + mx, my = pos + w, h = size + if self._orientation != PanelOrientation.NATIVE: + w, h = h, w + if self.space.reverse_x: + x_min, x_max = mx - w, mx + else: + x_min, x_max = mx, mx + w + if self.space.reverse_y: + y_min, y_max = my - h, my + else: + y_min, y_max = my, my + h + corners = ( + self.machine_point_to_world(x_min, y_min), + self.machine_point_to_world(x_max, y_min), + self.machine_point_to_world(x_min, y_max), + self.machine_point_to_world(x_max, y_max), + ) + return min(c[0] for c in corners), min(c[1] for c in corners) + + def calculate_jog( + self, direction: "JogDirection", distance: float + ) -> dict[Axis, float]: + """Translate a visual jog direction into native axis deltas. + + Visual directions are expressed in presented (rotated) + coordinates, so the composed world-to-machine matrix rotates the + delta vector: on a ROTATED_RIGHT bed a visual east jog drives + the native Y axis. UP/DOWN are pinned to Axis.Z and delegated to + the machine, which has no rotation knowledge. + """ + # Local import: machine.py imports this module before JogDirection + # is defined, so a module-level import would be circular. + from .machine import JogDirection + + if direction in (JogDirection.UP, JogDirection.DOWN): + return {Axis.Z: self._machine.calculate_jog(direction, distance)} + presented_deltas = { + JogDirection.EAST: (distance, 0.0), + JogDirection.WEST: (-distance, 0.0), + JogDirection.NORTH: (0.0, distance), + JogDirection.SOUTH: (0.0, -distance), + } + dx, dy = presented_deltas[direction] + machine_delta = self.get_world_to_machine_matrix() @ np.array( + [dx, dy, 0.0, 0.0] + ) + result: dict[Axis, float] = {} + if abs(machine_delta[0]) > _DELTA_EPSILON: + result[Axis.X] = float(machine_delta[0]) + if abs(machine_delta[1]) > _DELTA_EPSILON: + result[Axis.Y] = float(machine_delta[1]) + return result + + # -- Rect / position / label helpers ------------------------------ + + def get_workarea_world_rect(self) -> Rect: + """Work area boundary as a Rect in world space.""" + pos = self.space.get_workarea_origin_in_machine() + w, h = self.workarea_size + wx, wy = self.machine_item_to_world(pos, (w, h)) + return (wx, wy, w, h) + + def world_position_from_origin( + self, + ref_x: float, + ref_y: float, + size: tuple[float, float], + ) -> Point: + """Convert a reference position at the origin corner to world + coordinates.""" + width, height = size + + origin = self.origin + if origin == OriginCorner.BOTTOM_LEFT: + return ref_x, ref_y + elif origin == OriginCorner.TOP_LEFT: + return ref_x, ref_y - height + elif origin == OriginCorner.BOTTOM_RIGHT: + return ref_x - width, ref_y + else: # TOP_RIGHT + return ref_x - width, ref_y - height + + def get_axis_label_origin( + self, + wcs_offset: Point3D = (0.0, 0.0, 0.0), + wcs_is_workarea_origin: bool = False, + ) -> Point3D: + """Origin offset for axis labels.""" + native = self.space.get_axis_label_origin( + wcs_offset, wcs_is_workarea_origin + ) + if self._orientation == PanelOrientation.NATIVE: + return native + return (native[1], native[0], native[2]) + + # -- Native delegates (no rotation) ------------------------------- + + def get_workarea_origin_in_machine(self) -> Point: + """Position of the workarea origin in machine coordinates.""" + return self.space.get_workarea_origin_in_machine() + + def get_command_offset( + self, + wcs_offset: Point3D = (0.0, 0.0, 0.0), + wcs_is_workarea_origin: bool = False, + ) -> Point3D: + """Offset to subtract from machine coordinates to obtain command + coordinates (G-code output).""" + return self.space.get_command_offset( + wcs_offset, wcs_is_workarea_origin + ) + + @property + def reference_position_world(self) -> Point: + """The reference origin position in world coordinates. + + Combines the machine's reference offset (WCS or workarea origin, + in machine coordinates) with the machine→world transform. + """ + offset_x, offset_y, _ = self._machine.get_reference_offset() + return self.machine_point_to_world(offset_x, offset_y) + + def work_area_center(self) -> Point: + """Center of the work-area-sized box anchored at the reference + origin, in world coordinates. + + When the WCS is not the workarea origin, the reference anchor is + the active WCS position; the returned point is the center of a + work-area-sized box placed with its origin corner there. + """ + ref_x, ref_y = self.reference_position_world + w, h = self.workarea_size + ox, oy = self.world_position_from_origin(ref_x, ref_y, (w, h)) + return (ox + w / 2.0, oy + h / 2.0) diff --git a/rayforge/machine/models/macro.py b/rayforge/machine/models/macro.py new file mode 100644 index 000000000..5bfe116bc --- /dev/null +++ b/rayforge/machine/models/macro.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from enum import Enum +from gettext import gettext as _ +from typing import Any + + +class MacroTrigger(Enum): + """Defines events in the job lifecycle where G-code can be injected.""" + + LAYER_START = "Before processing a layer" + LAYER_END = "After processing a layer" + WORKPIECE_START = "Before processing a workpiece" + WORKPIECE_END = "After processing a workpiece" + + def label(self) -> str: + """Return a translatable label for this trigger.""" + labels = { + self.LAYER_START: _("Layer Start"), + self.LAYER_END: _("Layer End"), + self.WORKPIECE_START: _("Workpiece Start"), + self.WORKPIECE_END: _("Workpiece End"), + } + return labels[self] + + def description(self) -> str: + """Return a translatable description for this trigger.""" + labels = { + self.LAYER_START: _("Before processing a layer"), + self.LAYER_END: _("After processing a layer"), + self.WORKPIECE_START: _("Before processing a workpiece"), + self.WORKPIECE_END: _("After processing a workpiece"), + } + return labels[self] + + +@dataclass +class Macro: + """A generic, named block of G-code with an enabled state.""" + + name: str = "" + code: list[str] = field(default_factory=list) + enabled: bool = True + uid: str = field(default_factory=lambda: str(uuid.uuid4())) + extra: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Serializes the macro to a dictionary.""" + result = { + "uid": self.uid, + "name": self.name, + "code": self.code, + "enabled": self.enabled, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Macro: + """Creates a macro instance from a dictionary.""" + known_keys = {"uid", "name", "code", "enabled"} + extra = {k: v for k, v in data.items() if k not in known_keys} + instance = cls( + uid=data.get("uid", str(uuid.uuid4())), + name=data.get("name", _("Unnamed Macro")), + code=data.get("code", []), + enabled=data.get("enabled", True), + ) + instance.extra = extra + return instance diff --git a/rayforge/machine/models/manager.py b/rayforge/machine/models/manager.py new file mode 100644 index 000000000..7f6b74c3d --- /dev/null +++ b/rayforge/machine/models/manager.py @@ -0,0 +1,258 @@ +import asyncio +import logging +from pathlib import Path +from typing import Optional + +import yaml +from blinker import Signal + +from ...context import get_context +from ...shared.tasker import task_mgr +from ..driver.driver import ResourceBusyError +from .controller import MachineController +from .machine import Machine + +logger = logging.getLogger(__name__) + + +class MachineManager: + def __init__(self, base_dir: Path): + base_dir.mkdir(parents=True, exist_ok=True) + self.base_dir = base_dir + self.controllers: dict[str, MachineController] = {} + self.machines: dict[str, Machine] = {} + self.machine_added = Signal() + self.machine_removed = Signal() + self.machine_updated = Signal() + self.load() + + def initialize_connections(self): + """ + Triggers initial connection for all machines with auto_connect enabled. + This is called after the UI is fully initialized to ensure proper + signal handling during connection attempts. + """ + for machine in self.machines.values(): + if machine.auto_connect and not machine.is_connected(): + task_mgr.add_coroutine( + lambda ctx, m=machine: self._rebuild_and_connect_machine( + m + ), + key=(machine.id, "initial-connect"), + ) + + async def shutdown(self): + """ + Shuts down all managed machine controllers and their drivers + gracefully. + """ + logger.info("Shutting down all machine controllers.") + tasks = [ + controller.shutdown() for controller in self.controllers.values() + ] + if tasks: + await asyncio.gather(*tasks) + logger.info("All machine controllers shut down.") + + def get_controller(self, machine_id: str) -> "MachineController": + """ + Gets the controller for a machine, creating it if it doesn't exist. + This enables lazy instantiation of controllers. + """ + if machine_id in self.controllers: + return self.controllers[machine_id] + + machine = self.get_machine_by_id(machine_id) + if not machine: + raise ValueError(f"No machine found with ID {machine_id}") + + logger.debug( + f"Creating controller for machine '{machine.name}' on first use." + ) + controller = MachineController( + machine, get_context(), task_mgr.schedule_on_main_thread + ) + + # Wire up the machine's signal proxies to the new controller + machine._connect_controller_signals(controller) + + self.controllers[machine_id] = controller + return controller + + def has_controller(self, machine_id: str) -> bool: + """ + Returns whether a controller has already been instantiated for the + given machine. Unlike ``get_controller``, this never lazily creates + a controller and never raises, so it is safe to call for machines + that have already been removed. + """ + return machine_id in self.controllers + + async def _rebuild_and_connect_machine(self, machine: "Machine"): + """ + A single, sequenced task that rebuilds a machine's driver and then + connects if auto_connect is on. + """ + controller = self.get_controller(machine.id) + # Only rebuild if not already connected to avoid disconnecting + if not machine.is_connected(): + await controller.rebuild_driver() + if machine.auto_connect and not machine.is_connected(): + await self._safe_connect(machine) + + async def _safe_connect(self, machine: "Machine"): + """ + Attempts to connect a machine, suppressing ResourceBusyErrors. + """ + try: + await machine.connect() + except ResourceBusyError: + context = get_context() + if machine is context.config.machine: + logger.warning( + f"Active machine '{machine.name}' could not connect " + "because resource is busy." + ) + else: + logger.debug( + f"Inactive machine '{machine.name}' deferred connection: " + "resource busy." + ) + except Exception as e: # noqa: BLE001 - async auto-connect task + logger.error( + f"Failed to auto-connect machine '{machine.name}': {e}" + ) + + def set_active_machine(self, new_machine: Machine): + """ + Sets the active machine, handling the connection lifecycle for + shared resources. + """ + context = get_context() + old_machine = context.config.machine + + if old_machine and old_machine.id == new_machine.id: + return # No change + + logger.info(f"Switching active machine to '{new_machine.name}'") + + async def switch_routine(ctx): + # 1. Disconnect the old machine if it's connected + if old_machine and old_machine.is_connected(): + logger.info( + f"Disconnecting previous machine '{old_machine.name}'" + ) + await old_machine.disconnect() + # Add a small delay for the OS to release the port + await asyncio.sleep(0.2) + + # 2. Update the global config. This triggers UI updates, so it + # must run on the main thread (GTK is not thread-safe). + await task_mgr.run_on_main_thread( + context.config.set_machine, new_machine + ) + + # 3. Connect the new machine if it's set to auto-connect + if new_machine.auto_connect: + logger.info( + f"Connecting to new active machine '{new_machine.name}'" + ) + await self._safe_connect(new_machine) + + task_mgr.add_coroutine(switch_routine) + + def filename_from_id(self, machine_id: str) -> Path: + return self.base_dir / f"{machine_id}.yaml" + + def add_machine(self, machine: Machine): + if machine.id in self.machines: + return + self.machines[machine.id] = machine + machine.changed.connect(self.on_machine_changed) + self.save_machine(machine) + self.machine_added.send(self, machine_id=machine.id) + + def remove_machine(self, machine_id: str): + machine = self.machines.get(machine_id) + if not machine: + return + + # Shut down and remove the associated controller if it exists + if machine_id in self.controllers: + controller = self.controllers.pop(machine_id) + # Shutdown is async, so schedule it + task_mgr.add_coroutine(lambda ctx: controller.shutdown()) + + machine.changed.disconnect(self.on_machine_changed) + machine.context.dialect_mgr.dialects_changed.disconnect( + machine._on_dialects_changed + ) + del self.machines[machine_id] + + machine_file = self.filename_from_id(machine_id) + try: + machine_file.unlink() + logger.info(f"Removed machine file: {machine_file}") + except OSError as e: + logger.error(f"Error removing machine file {machine_file}: {e}") + + self.machine_removed.send(self, machine_id=machine_id) + + def get_machine_by_id(self, machine_id): + return self.machines.get(machine_id) + + def get_machines(self) -> list["Machine"]: + """Returns a list of all managed machines, sorted by name.""" + return sorted(self.machines.values(), key=lambda m: m.name) + + def create_default_machine(self): + machine = Machine(get_context()) + self.add_machine(machine) + return machine + + def save_machine(self, machine): + logger.debug(f"Saving machine {machine.id}") + machine_file = self.filename_from_id(machine.id) + try: + data = machine.to_dict(include_frozen_dialect=False) + content = yaml.safe_dump(data) + except Exception as e: + logger.error(f"Failed to serialize machine {machine.id}: {e}") + raise + with open(machine_file, "w") as f: + f.write(content) + + def load_machine(self, machine_id: str) -> Optional["Machine"]: + machine_file = self.filename_from_id(machine_id) + if not machine_file.exists(): + raise FileNotFoundError(f"Machine file {machine_file} not found") + with open(machine_file, "r") as f: + data = yaml.safe_load(f) + if not data: + msg = f"skipping invalid machine file {f.name}" + logger.warning(msg) + return None + machine = Machine.from_dict(data, context=get_context()) + machine.id = machine_id + self.machines[machine.id] = machine + machine.changed.connect(self.on_machine_changed) + + if machine.dialect_migrated: + logger.info( + f"Saving machine '{machine.name}' after dialect migration." + ) + self.save_machine(machine) + machine.dialect_migrated = False + + return machine + + def on_machine_changed(self, machine, **kwargs): + self.save_machine(machine) + self.machine_updated.send(self, machine_id=machine.id) + + def load(self): + for file in self.base_dir.glob("*.yaml"): + try: + self.load_machine(file.stem) + except (OSError, ValueError, TypeError, yaml.YAMLError) as e: + logger.error(f"Failed to load machine from {file}: {e}") diff --git a/rayforge/machine/models/rotary_module.py b/rayforge/machine/models/rotary_module.py new file mode 100644 index 000000000..2440c134c --- /dev/null +++ b/rayforge/machine/models/rotary_module.py @@ -0,0 +1,259 @@ +import uuid +from enum import Enum +from gettext import gettext as _ +from typing import Any + +import numpy as np +from blinker import Signal +from raygeo.geo.types import Rect3D +from raygeo.ops.axis import Axis + +from ...core.matrix import euler_rotation_matrix + + +class RotaryMode(Enum): + TRUE_4TH_AXIS = "true_4th_axis" + AXIS_REPLACEMENT = "axis_replacement" + + +class RotaryType(Enum): + JAWS = "jaws" + ROLLERS = "rollers" + + +class RotaryModule: + def __init__(self): + self.uid: str = str(uuid.uuid4()) + self.name: str = _("Rotary Module") + self.axis: Axis = Axis.A + self.mode: RotaryMode = RotaryMode.TRUE_4TH_AXIS + self.mm_per_rotation: float = 0.0 + self.default_diameter: float = 25.0 + self.max_workpiece_length: float = 300.0 + self.rotary_type: RotaryType = RotaryType.JAWS + self.roller_diameter: float = 0.0 + self.reverse_axis: bool = False + self.axis_position: np.ndarray = np.zeros(3, dtype=np.float64) + self.model_path: str | None = None + self.transform: np.ndarray = np.eye(4, dtype=np.float64) + self.changed = Signal() + self.extra: dict[str, Any] = {} + + def set_name(self, name: str): + if self.name == name: + return + self.name = name + self.changed.send(self) + + def set_axis(self, axis: Axis): + axis.assert_single_axis() + if self.axis == axis: + return + self.axis = axis + self.changed.send(self) + + def set_mode(self, mode: RotaryMode): + if self.mode == mode: + return + self.mode = mode + self.changed.send(self) + + def set_mm_per_rotation(self, value: float): + if self.mm_per_rotation == value: + return + self.mm_per_rotation = value + self.changed.send(self) + + def set_position(self, x: float, y: float, z: float): + if ( + self.transform[0, 3] == x + and self.transform[1, 3] == y + and self.transform[2, 3] == z + ): + return + self.transform[0, 3] = x + self.transform[1, 3] = y + self.transform[2, 3] = z + self.changed.send(self) + + def get_rotation(self): + t = self.transform + sx = float(np.linalg.norm(t[0, :3])) + sy = float(np.linalg.norm(t[1, :3])) + sz = float(np.linalg.norm(t[2, :3])) + rx = np.degrees(np.arctan2(t[2, 1] / sy, t[2, 2] / sz)) + ry = np.degrees( + np.arctan2(-t[2, 0] / sx, np.sqrt(t[2, 1] ** 2 + t[2, 2] ** 2)) + ) + rz = np.degrees(np.arctan2(t[1, 0] / sx, t[0, 0] / sx)) + return rx, ry, rz + + def set_rotation(self, rx: float, ry: float, rz: float): + cur = self.get_rotation() + if cur[0] == rx and cur[1] == ry and cur[2] == rz: + return + pos = self.transform[:3, 3].copy() + scale = self.get_scale() + self.transform[:3, :3] = euler_rotation_matrix(rx, ry, rz) * scale + self.transform[:3, 3] = pos + self.changed.send(self) + + def get_scale(self) -> float: + return float(np.linalg.norm(self.transform[0, :3])) + + def set_scale(self, scale: float): + if self.get_scale() == scale: + return + pos = self.transform[:3, 3].copy() + rx, ry, rz = self.get_rotation() + self.transform[:3, :3] = euler_rotation_matrix(rx, ry, rz) * scale + self.transform[:3, 3] = pos + self.changed.send(self) + + def set_default_diameter(self, diameter: float): + if self.default_diameter == diameter: + return + self.default_diameter = diameter + self.changed.send(self) + + def set_max_workpiece_length(self, length: float): + if self.max_workpiece_length == length: + return + self.max_workpiece_length = length + self.changed.send(self) + + def set_rotary_type(self, rotary_type: RotaryType): + if self.rotary_type == rotary_type: + return + self.rotary_type = rotary_type + self.changed.send(self) + + def set_roller_diameter(self, diameter: float): + if self.roller_diameter == diameter: + return + self.roller_diameter = diameter + self.changed.send(self) + + def set_reverse_axis(self, reverse: bool): + if self.reverse_axis == reverse: + return + self.reverse_axis = reverse + self.changed.send(self) + + def world_axis_position(self) -> np.ndarray: + """Return the axis position in world space. + + The axis position is the module's mounting position + (transform[:3, 3]) plus the local axis offset (axis_position). + """ + return self.transform[:3, 3] + self.axis_position + + def set_axis_position(self, x: float, y: float, z: float): + new = np.array([x, y, z], dtype=np.float64) + if np.array_equal(self.axis_position, new): + return + self.axis_position = new + self.changed.send(self) + + def set_model_path(self, model_path: str | None): + if self.model_path == model_path: + return + self.model_path = model_path + self.changed.send(self) + + def get_collision_bbox(self) -> Rect3D | None: + return None + + def to_dict(self) -> dict[str, Any]: + result = { + "uid": self.uid, + "name": self.name, + "axis": self.axis.name, + "mode": self.mode.value, + "default_diameter": self.default_diameter, + "max_workpiece_length": self.max_workpiece_length, + "rotary_type": self.rotary_type.value, + "model_path": self.model_path, + "transform": self.transform.flatten().tolist(), + } + if self.mm_per_rotation > 0: + result["mm_per_rotation"] = self.mm_per_rotation + if self.roller_diameter > 0: + result["roller_diameter"] = self.roller_diameter + if self.reverse_axis: + result["reverse_axis"] = self.reverse_axis + if not np.allclose(self.axis_position, 0): + result["axis_position"] = self.axis_position.tolist() + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "RotaryModule": + known_keys = { + "uid", + "name", + "axis", + "mode", + "mm_per_rotation", + "default_diameter", + "max_workpiece_length", + "rotary_type", + "roller_diameter", + "reverse_axis", + "axis_position", + "model_path", + "transform", + "x", + "y", + "z", + "length", + "chuck_diameter", + "tailstock_diameter", + "max_height", + "viz_mode", + "viz_scale", + "viz_rotation", + "chuck_height", + "tailstock_height", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + rm = cls() + rm.uid = data.get("uid", str(uuid.uuid4())) + rm.name = data.get("name", _("Rotary Module")) + rm.axis = Axis.from_name(data.get("axis", "A")) + rm.mode = RotaryMode(data.get("mode", "true_4th_axis")) + rm.mm_per_rotation = data.get("mm_per_rotation", 0.0) + rm.default_diameter = data.get("default_diameter", 25.0) + rm.max_workpiece_length = data.get("max_workpiece_length", 300.0) + rm.rotary_type = RotaryType(data.get("rotary_type", "jaws")) + rm.roller_diameter = data.get("roller_diameter", 0.0) + rm.reverse_axis = data.get("reverse_axis", False) + raw_ap = data.get("axis_position", [0.0, 0.0, 0.0]) + if isinstance(raw_ap, (int, float)): + raw_ap = [0.0, 0.0, 0.0] + rm.axis_position = np.array(raw_ap, dtype=np.float64) + + rm.model_path = data.get("model_path") + + raw_transform = data.get("transform") + if raw_transform is not None: + rm.transform = np.array(raw_transform, dtype=np.float64).reshape( + 4, 4 + ) + elif any(k in data for k in ("x", "y", "z")): + rm.transform[0, 3] = data.get("x", 0.0) + rm.transform[1, 3] = data.get("y", 0.0) + rm.transform[2, 3] = data.get("z", 0.0) + + rm.extra = extra + return rm + + def __getstate__(self): + state = self.__dict__.copy() + state.pop("changed", None) + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self.changed = Signal() diff --git a/rayforge/machine/models/spindle.py b/rayforge/machine/models/spindle.py new file mode 100644 index 000000000..a63ef0a46 --- /dev/null +++ b/rayforge/machine/models/spindle.py @@ -0,0 +1,103 @@ +from collections.abc import Iterable +from gettext import gettext as _ +from typing import Any + +from raygeo.ops.state import CoolantMode + +from ...core.capability import MachineCapability +from .head import _HEAD_SERIALIZED_KEYS, Head + +_COOLANT_MODE_BY_NAME = { + mode.name: mode for mode in (CoolantMode.FLOOD, CoolantMode.MIST) +} + + +def _normalize_cooling_methods( + methods: Iterable[CoolantMode], +) -> tuple[CoolantMode, ...]: + """Keep only real coolant methods, in a stable order. + + ``CoolantMode.OFF`` is always available and therefore not a + supported method; it is stripped along with any non-``CoolantMode`` + values. + """ + return tuple( + m + for m in methods + if isinstance(m, CoolantMode) and m is not CoolantMode.OFF + ) + + +class SpindleHead(Head): + """A motorized spindle head, implying the MILL machine capability.""" + + HEAD_TYPE: str = "SpindleHead" + + def __init__(self): + super().__init__() + self.name: str = _("Spindle Head") + self.max_rpm: int = 20000 + self.min_rpm: int = 1000 + self.cooling_methods: tuple[CoolantMode, ...] = () + + @property + def machine_capability(self) -> MachineCapability: + return MachineCapability.MILL + + def set_max_rpm(self, rpm: int): + if self.max_rpm == rpm: + return + self.max_rpm = int(rpm) + self.min_rpm = min(self.min_rpm, self.max_rpm) + self.changed.send(self) + + def set_min_rpm(self, rpm: int): + if self.min_rpm == rpm: + return + self.min_rpm = int(rpm) + self.max_rpm = max(self.max_rpm, self.min_rpm) + self.changed.send(self) + + def set_cooling_methods(self, methods: Iterable[CoolantMode]): + """Sets the coolant methods this head supports.""" + normalized = _normalize_cooling_methods(methods) + if self.cooling_methods == normalized: + return + self.cooling_methods = normalized + self.changed.send(self) + + def to_dict(self) -> dict[str, Any]: + result = super().to_dict() + result.update( + { + "max_rpm": self.max_rpm, + "min_rpm": self.min_rpm, + "cooling_methods": [m.name for m in self.cooling_methods], + } + ) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "SpindleHead": + known_keys = _HEAD_SERIALIZED_KEYS | { + "max_rpm", + "min_rpm", + "cooling_methods", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + sh = super().from_dict(data) + sh.max_rpm = data.get("max_rpm", sh.max_rpm) + sh.min_rpm = data.get("min_rpm", sh.min_rpm) + raw_methods = data.get("cooling_methods", ()) + methods: tuple[CoolantMode, ...] = () + if isinstance(raw_methods, (list, tuple)): + parsed = [ + _COOLANT_MODE_BY_NAME[name] + for name in raw_methods + if name in _COOLANT_MODE_BY_NAME + ] + methods = _normalize_cooling_methods(parsed) + sh.cooling_methods = methods + sh.extra = extra + return sh diff --git a/rayforge/machine/models/zone.py b/rayforge/machine/models/zone.py new file mode 100644 index 000000000..2f07206e0 --- /dev/null +++ b/rayforge/machine/models/zone.py @@ -0,0 +1,196 @@ +import uuid +from enum import Enum +from gettext import gettext as _ +from typing import Any + +from blinker import Signal +from raygeo.geo.shape.arc import ( + does_arc_intersect_circle as arc_intersects_circle, +) +from raygeo.geo.shape.arc import ( + does_arc_intersect_rect as arc_intersects_rect, +) +from raygeo.geo.shape.line import ( + does_line_segment_intersect_circle as line_segment_intersects_circle, +) +from raygeo.geo.shape.line import ( + does_line_segment_intersect_rect as line_segment_intersects_rect, +) +from raygeo.geo.types import Point +from raygeo.ops.types import CommandCategory, CommandType + + +class ZoneShape(Enum): + RECT = "rect" + BOX = "box" + CYLINDER = "cylinder" + + +class Zone: + def __init__(self): + self.uid: str = str(uuid.uuid4()) + self.name: str = _("No-Go Zone") + self.shape: ZoneShape = ZoneShape.RECT + self.params: dict[str, float] = { + "x": 0.0, + "y": 0.0, + "w": 10.0, + "h": 10.0, + } + self.enabled: bool = True + self.changed = Signal() + self.extra: dict[str, Any] = {} + + def set_name(self, name: str): + if self.name == name: + return + self.name = name + self.changed.send(self) + + def set_shape(self, shape: ZoneShape): + if self.shape == shape: + return + self.shape = shape + if shape == ZoneShape.BOX: + self.params.setdefault("z", 0.0) + self.params.setdefault("d", 10.0) + self.params.setdefault("w", self.params.get("w", 10.0)) + self.params.setdefault("h", self.params.get("h", 10.0)) + elif shape == ZoneShape.CYLINDER: + self.params.setdefault("z", 0.0) + self.params.setdefault("radius", 5.0) + self.params.setdefault("height", 10.0) + self.changed.send(self) + + def set_param(self, key: str, value: float): + if self.params.get(key) == value: + return + self.params[key] = value + self.changed.send(self) + + def set_enabled(self, enabled: bool): + if self.enabled == enabled: + return + self.enabled = enabled + self.changed.send(self) + + def _get_rect(self): + x = self.params.get("x", 0.0) + y = self.params.get("y", 0.0) + w = self.params.get("w", 0.0) + h = self.params.get("h", 0.0) + return (x, y, x + w, y + h) + + def _get_circle(self): + cx = self.params.get("x", 0.0) + cy = self.params.get("y", 0.0) + r = self.params.get("radius", 5.0) + return (cx, cy), r + + def collides_with_line(self, start: Point, end: Point) -> bool: + colliders = { + ZoneShape.RECT: self._collide_line_rect, + ZoneShape.BOX: self._collide_line_rect, + ZoneShape.CYLINDER: self._collide_line_circle, + } + collider = colliders.get(self.shape) + return collider(start, end) if collider else False + + def collides_with_arc(self, start, end, center, clockwise): + colliders = { + ZoneShape.RECT: self._collide_arc_rect, + ZoneShape.BOX: self._collide_arc_rect, + ZoneShape.CYLINDER: self._collide_arc_circle, + } + collider = colliders.get(self.shape) + if collider is None: + return False + return collider(start, end, center, clockwise) + + def _collide_line_rect(self, start, end): + return line_segment_intersects_rect(start, end, self._get_rect()) + + def _collide_line_circle(self, start, end): + center, radius = self._get_circle() + return line_segment_intersects_circle(start, end, center, radius) + + def _collide_arc_rect(self, start, end, center, clockwise): + return arc_intersects_rect( + start, end, center, clockwise, self._get_rect() + ) + + def _collide_arc_circle(self, start, end, center, clockwise): + cc, cr = self._get_circle() + return arc_intersects_circle(start, end, center, clockwise, cc, cr) + + def to_dict(self) -> dict[str, Any]: + result = { + "uid": self.uid, + "name": self.name, + "shape": self.shape.value, + "params": dict(self.params), + "enabled": self.enabled, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Zone": + known_keys = { + "uid", + "name", + "shape", + "params", + "enabled", + } + extra = {k: v for k, v in data.items() if k not in known_keys} + + zone = cls() + zone.uid = data.get("uid", str(uuid.uuid4())) + zone.name = data.get("name", _("No-Go Zone")) + zone.shape = ZoneShape(data.get("shape", "rect")) + zone.params = dict(data.get("params", zone.params)) + zone.enabled = data.get("enabled", True) + zone.extra = extra + return zone + + def __getstate__(self): + state = self.__dict__.copy() + state.pop("changed", None) + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self.changed = Signal() + + +def check_ops_collides_with_zones(ops, zones): + pos = None + for i in range(ops.len()): + if ops.category(i) != CommandCategory.MOVING: + continue + end = ops.endpoint(i) + ct = ops.command_type(i) + if end is not None and pos is not None: + start_2d = (pos[0], pos[1]) + end_2d = (end[0], end[1]) + for zone in zones.values(): + if ct == CommandType.ARC_TO: + arc_i, arc_j, arc_cw = ops.arc_params(i) + arc_center = ( + start_2d[0] + arc_i, + start_2d[1] + arc_j, + ) + if zone.collides_with_arc( + start_2d, + end_2d, + arc_center, + arc_cw, + ): + return True + else: + if zone.collides_with_line(start_2d, end_2d): + return True + if end is not None: + pos = end + return False diff --git a/rayforge/machine/sanity/__init__.py b/rayforge/machine/sanity/__init__.py new file mode 100644 index 000000000..a346e4dff --- /dev/null +++ b/rayforge/machine/sanity/__init__.py @@ -0,0 +1,18 @@ +from .checker import SanityChecker, SanityContext +from .result import ( + CheckMode, + IssueCategory, + IssueSeverity, + SanityIssue, + SanityReport, +) + +__all__ = [ + "CheckMode", + "IssueCategory", + "IssueSeverity", + "SanityChecker", + "SanityContext", + "SanityIssue", + "SanityReport", +] diff --git a/rayforge/machine/sanity/checker.py b/rayforge/machine/sanity/checker.py new file mode 100644 index 000000000..d04e2e0db --- /dev/null +++ b/rayforge/machine/sanity/checker.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +from raygeo.geo.types import Rect +from raygeo.ops import Ops + +from ..models.zone import Zone +from .result import CheckMode, SanityReport + +if TYPE_CHECKING: + from ..models.machine import Machine + +from .checks.extent_2d import ExtentCheck2D +from .checks.nogo_zones_2d import NoGoZoneCheck2D +from .checks.workarea_2d import WorkareaCheck2D + + +@dataclass +class SanityContext: + ops: Ops + machine: Machine + work_area: Rect + axis_extents: tuple[float, float] + enabled_zones: dict[str, Zone] + + +class SanityChecker: + FAST_CHECKS: ClassVar[list[type]] = [ + WorkareaCheck2D, + ExtentCheck2D, + NoGoZoneCheck2D, + ] + COMPLETE_CHECKS: ClassVar[list[type]] = [ + WorkareaCheck2D, + ExtentCheck2D, + NoGoZoneCheck2D, + ] + + def __init__(self, machine: Machine): + self._machine = machine + + def check( + self, ops: Ops, mode: CheckMode = CheckMode.FAST + ) -> SanityReport: + ctx = self._build_context(ops) + classes = ( + self.FAST_CHECKS + if mode == CheckMode.FAST + else self.COMPLETE_CHECKS + ) + report = SanityReport(mode=mode) + for cls in classes: + report.issues.extend(cls().run(ctx)) + return report + + def _build_context(self, ops: Ops) -> SanityContext: + return SanityContext( + ops=ops, + machine=self._machine, + work_area=self._machine.work_area, + axis_extents=self._machine.axis_extents, + enabled_zones={ + k: v for k, v in self._machine.nogo_zones.items() if v.enabled + }, + ) diff --git a/rayforge/machine/sanity/checks/__init__.py b/rayforge/machine/sanity/checks/__init__.py new file mode 100644 index 000000000..b755d3fec --- /dev/null +++ b/rayforge/machine/sanity/checks/__init__.py @@ -0,0 +1,9 @@ +from .extent_2d import ExtentCheck2D +from .nogo_zones_2d import NoGoZoneCheck2D +from .workarea_2d import WorkareaCheck2D + +__all__ = [ + "ExtentCheck2D", + "NoGoZoneCheck2D", + "WorkareaCheck2D", +] diff --git a/rayforge/machine/sanity/checks/base.py b/rayforge/machine/sanity/checks/base.py new file mode 100644 index 000000000..872241964 --- /dev/null +++ b/rayforge/machine/sanity/checks/base.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from ..result import IssueCategory, SanityIssue + +if TYPE_CHECKING: + from ..checker import SanityContext + + +class BaseCheck(ABC): + @property + @abstractmethod + def category(self) -> IssueCategory: ... + + @abstractmethod + def run(self, context: "SanityContext") -> list[SanityIssue]: ... diff --git a/rayforge/machine/sanity/checks/extent_2d.py b/rayforge/machine/sanity/checks/extent_2d.py new file mode 100644 index 000000000..2470908ed --- /dev/null +++ b/rayforge/machine/sanity/checks/extent_2d.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING + +from raygeo.ops.types import CommandCategory + +from ..result import IssueCategory, IssueSeverity, SanityIssue +from .base import BaseCheck + +if TYPE_CHECKING: + from ..checker import SanityContext + +_BOUNDARY_TOLERANCE = 1e-6 + + +class ExtentCheck2D(BaseCheck): + @property + def category(self) -> IssueCategory: + return IssueCategory.MACHINE_EXTENT + + def run(self, context: "SanityContext") -> list[SanityIssue]: + max_x, max_y = context.axis_extents + seen: set[str] = set() + issues: list[SanityIssue] = [] + ops = context.ops + for i in range(ops.len()): + if ops.category(i) != CommandCategory.MOVING: + continue + end = ops.endpoint(i) + for point in (end,): + self._check_point(issues, seen, point, max_x, max_y) + return issues + + @staticmethod + def _check_point( + issues: list[SanityIssue], + seen: set[str], + point, + max_x: float, + max_y: float, + ) -> None: + for axis, val, limit in ( + ("X-", point[0], 0), + ("X+", point[0], max_x), + ("Y-", point[1], 0), + ("Y+", point[1], max_y), + ): + if axis.endswith("-") and val >= limit - _BOUNDARY_TOLERANCE: + continue + if axis.endswith("+") and val <= limit + _BOUNDARY_TOLERANCE: + continue + if axis in seen: + continue + seen.add(axis) + issues.append( + SanityIssue( + category=IssueCategory.MACHINE_EXTENT, + severity=IssueSeverity.ERROR, + message=_extent_msg(axis, val, limit), + ) + ) + + +def _extent_msg(axis: str, val: float, limit: float) -> str: + axis_name = axis[0] + if axis[1] == "-": + return f"{axis_name}={val:.1f} < 0" + return f"{axis_name}={val:.1f} > {limit:.1f}" diff --git a/rayforge/machine/sanity/checks/nogo_zones_2d.py b/rayforge/machine/sanity/checks/nogo_zones_2d.py new file mode 100644 index 000000000..72ee1754e --- /dev/null +++ b/rayforge/machine/sanity/checks/nogo_zones_2d.py @@ -0,0 +1,60 @@ +from typing import TYPE_CHECKING + +from raygeo.ops.types import CommandCategory, CommandType + +from ..result import IssueCategory, IssueSeverity, SanityIssue +from .base import BaseCheck + +if TYPE_CHECKING: + from ..checker import SanityContext + + +class NoGoZoneCheck2D(BaseCheck): + @property + def category(self) -> IssueCategory: + return IssueCategory.NOGO_ZONE + + def run(self, context: "SanityContext") -> list[SanityIssue]: + if not context.enabled_zones: + return [] + + hit_zones: set[str] = set() + issues: list[SanityIssue] = [] + pos = None + ops = context.ops + for i in range(ops.len()): + if ops.category(i) != CommandCategory.MOVING: + continue + end = ops.endpoint(i) + if pos is not None: + start_2d = (pos[0], pos[1]) + end_2d = (end[0], end[1]) + for uid, zone in context.enabled_zones.items(): + if uid in hit_zones: + continue + if self._check_segment(zone, start_2d, end_2d, i, ops): + hit_zones.add(uid) + issues.append( + SanityIssue( + category=IssueCategory.NOGO_ZONE, + severity=IssueSeverity.ERROR, + message=f'No-Go Zone "{zone.name}" entered', + zone_uid=uid, + zone_name=zone.name, + ) + ) + pos = end + return issues + + @staticmethod + def _check_segment(zone, start_2d, end_2d, idx, ops) -> bool: + if ops.command_type(idx) == CommandType.ARC_TO: + i, j, cw = ops.arc_params(idx) + arc_center = (start_2d[0] + i, start_2d[1] + j) + return zone.collides_with_arc( + start_2d, + end_2d, + arc_center, + cw, + ) + return zone.collides_with_line(start_2d, end_2d) diff --git a/rayforge/machine/sanity/checks/workarea_2d.py b/rayforge/machine/sanity/checks/workarea_2d.py new file mode 100644 index 000000000..2a591469e --- /dev/null +++ b/rayforge/machine/sanity/checks/workarea_2d.py @@ -0,0 +1,69 @@ +from typing import TYPE_CHECKING + +from raygeo.ops.types import CommandCategory + +from ..result import IssueCategory, IssueSeverity, SanityIssue +from .base import BaseCheck + +if TYPE_CHECKING: + from ..checker import SanityContext + +_BOUNDARY_TOLERANCE = 1e-6 + + +class WorkareaCheck2D(BaseCheck): + @property + def category(self) -> IssueCategory: + return IssueCategory.WORKAREA + + def run(self, context: "SanityContext") -> list[SanityIssue]: + wa = context.work_area + x_min, y_min = wa[0], wa[1] + x_max, y_max = wa[0] + wa[2], wa[1] + wa[3] + seen: set[str] = set() + issues: list[SanityIssue] = [] + ops = context.ops + for i in range(ops.len()): + if ops.category(i) != CommandCategory.MOVING: + continue + end = ops.endpoint(i) + self._check_point(issues, seen, end, x_min, y_min, x_max, y_max) + return issues + + @staticmethod + def _check_point( + issues: list[SanityIssue], + seen: set[str], + point, + x_min: float, + y_min: float, + x_max: float, + y_max: float, + ) -> None: + for axis, val, limit in ( + ("X-", point[0], x_min), + ("X+", point[0], x_max), + ("Y-", point[1], y_min), + ("Y+", point[1], y_max), + ): + if axis.endswith("-") and val >= limit - _BOUNDARY_TOLERANCE: + continue + if axis.endswith("+") and val <= limit + _BOUNDARY_TOLERANCE: + continue + if axis in seen: + continue + seen.add(axis) + issues.append( + SanityIssue( + category=IssueCategory.WORKAREA, + severity=IssueSeverity.WARNING, + message=_workarea_msg(axis, val, limit), + ) + ) + + +def _workarea_msg(axis: str, val: float, limit: float) -> str: + axis_name = axis[0] + if axis[1] == "-": + return f"{axis_name}={val:.1f} < {limit:.1f} (work area)" + return f"{axis_name}={val:.1f} > {limit:.1f} (work area)" diff --git a/rayforge/machine/sanity/result.py b/rayforge/machine/sanity/result.py new file mode 100644 index 000000000..b33670d05 --- /dev/null +++ b/rayforge/machine/sanity/result.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass, field +from enum import Enum +from gettext import gettext as _ + +from raygeo.geo.types import Point + + +class CheckMode(Enum): + FAST = "fast" + COMPLETE = "complete" + + +class IssueSeverity(Enum): + ERROR = "error" + WARNING = "warning" + + +class IssueCategory(Enum): + NOGO_ZONE = "nogo_zone" + WORKAREA = "workarea" + MACHINE_EXTENT = "machine_extent" + + +ISSUE_CATEGORY_LABELS = { + IssueCategory.NOGO_ZONE: _("No-Go Zone"), + IssueCategory.WORKAREA: _("Outside Work Area"), + IssueCategory.MACHINE_EXTENT: _("Machine Extent"), +} + + +@dataclass +class SanityIssue: + category: IssueCategory + severity: IssueSeverity + message: str + zone_uid: str | None = None + zone_name: str | None = None + segment_start: Point | None = None + segment_end: Point | None = None + command_index: int | None = None + + +@dataclass +class SanityReport: + mode: CheckMode + issues: list[SanityIssue] = field(default_factory=list) + + @property + def has_errors(self) -> bool: + return any(i.severity == IssueSeverity.ERROR for i in self.issues) + + @property + def has_warnings(self) -> bool: + return any(i.severity == IssueSeverity.WARNING for i in self.issues) + + @property + def is_clean(self) -> bool: + return len(self.issues) == 0 diff --git a/rayforge/machine/transport/__init__.py b/rayforge/machine/transport/__init__.py new file mode 100644 index 000000000..879eeceec --- /dev/null +++ b/rayforge/machine/transport/__init__.py @@ -0,0 +1,29 @@ +import sys + +from .grbl import GrblSerialTransport +from .http import HttpTransport +from .serial import SerialTransport +from .telnet import TelnetTransport +from .transport import Transport, TransportStatus +from .udp import UdpTransport +from .udp_server import UdpServerTransport +from .websocket import WebSocketTransport + +if sys.platform != "win32": + from .serial_server import SerialServerTransport +else: + SerialServerTransport = None + + +__all__ = [ + "GrblSerialTransport", + "HttpTransport", + "SerialServerTransport", + "SerialTransport", + "TelnetTransport", + "Transport", + "TransportStatus", + "UdpServerTransport", + "UdpTransport", + "WebSocketTransport", +] diff --git a/rayforge/machine/transport/grbl.py b/rayforge/machine/transport/grbl.py new file mode 100644 index 000000000..bfc1866a8 --- /dev/null +++ b/rayforge/machine/transport/grbl.py @@ -0,0 +1,501 @@ +import asyncio +import logging +import re +import threading +from collections.abc import Awaitable, Callable +from enum import Enum, auto +from typing import NamedTuple + +from .serial import SerialTransport +from .transport import Transport + +logger = logging.getLogger(__name__) + +# Default RX buffer size for standard Grbl 1.1 (128-byte buffer, +# safe limit 127). Custom firmwares may report a smaller size via +# the $I OPT line. +DEFAULT_GRBL_RX_BUFFER_SIZE = 127 + + +class BufferStallError(Exception): + """Raised when buffer space wait times out after recovery.""" + + +class PendingCommand(NamedTuple): + length: int + op_index: int | None + command: str = "" + + +class GrblResponseType(Enum): + OK = auto() + ERROR = auto() + LINE = auto() + + +class GrblResponse(NamedTuple): + type: GrblResponseType + text: str + pending: PendingCommand | None = None + + +class GrblSerialTransport: + """ + Wraps a SerialTransport with GRBL's character-counting flow control + protocol. + + GRBL devices have a serial receive buffer whose size is reported + in the $I OPT line (e.g. ``[OPT:VMPH,63,511]``). Standard Grbl + 1.1 uses 127 bytes (128-byte hardware buffer minus one). Custom + firmwares may use smaller buffers. + + This transport tracks how many bytes are outstanding and provides + backpressure so callers never overflow the device's RX buffer. + + Also handles low-level GRBL response parsing: extracts 'ok' and + 'error:' from the raw byte stream for buffer accounting, even when + they are interleaved with status reports by buggy firmware. + Thread-safe: the buffer count is mutated from both async tasks and + the serial receive callback. + """ + + _ok_ack_re = re.compile(rb"ok\r*\n") + + def __init__(self, transport: Transport): + self._transport = transport + self._rx_buffer_count = 0 + self._rx_buffer_size = DEFAULT_GRBL_RX_BUFFER_SIZE + self._lock = threading.Lock() + self._pending: asyncio.Queue[PendingCommand] = asyncio.Queue() + self._space_available = asyncio.Event() + self._space_available.set() + self._status_buffer = bytearray() + self._loop: asyncio.AbstractEventLoop | None = None + + @property + def is_connected(self) -> bool: + return self._transport.is_connected + + @property + def port(self) -> str: + if isinstance(self._transport, SerialTransport): + return self._transport.port + return "" + + async def connect(self) -> None: + self._loop = asyncio.get_running_loop() + await self._transport.connect() + + async def disconnect(self) -> None: + await self._transport.disconnect() + + @property + def received(self): + return self._transport.received + + @property + def status_changed(self): + return self._transport.status_changed + + def parse_incoming(self, data: bytes) -> list[GrblResponse]: + """ + Parse raw serial bytes into GRBL responses. + + Scans for 'ok' and 'error:' responses in the byte stream, + handling buffer accounting for them, before line-splitting. + This prevents lost 'ok' acknowledgements when firmware + interleaves them with status reports. + + Returns a list of GrblResponse for non-ok/error lines + (status reports, alarms, info messages, etc.). + """ + self._log_rx(data) + self._status_buffer.extend(data) + responses: list[GrblResponse] = [] + + self._extract_acks_from_buffer(responses) + + while b"\n" in self._status_buffer: + end_idx = self._status_buffer.find(b"\n") + 1 + message_bytes = self._status_buffer[:end_idx] + self._status_buffer = self._status_buffer[end_idx:] + + try: + message = message_bytes.decode("utf-8") + except UnicodeDecodeError: + logger.warning( + f"Dropped invalid UTF-8 bytes: {message_bytes!r}" + ) + continue + + for line in message.strip().splitlines(): + if not line: + continue + resp = self._parse_line(line) + if resp: + responses.append(resp) + + return responses + + def _extract_acks_from_buffer(self, responses): + """ + Scan _status_buffer for 'ok\\r*\\n' and 'error:...\\n' + patterns and extract them before line-based processing. + + This ensures buffer accounting is always correct, even when + the firmware interleaves these responses into other messages. + Also detects NULL-byte corrupted 'ok' responses as a hardware + fault indicator. + + Handles \\n or \\r\\n line endings robustness. + """ + while True: + m = self._ok_ack_re.search(self._status_buffer) + if m is None: + break + self._status_buffer = ( + self._status_buffer[: m.start()] + + self._status_buffer[m.end() :] + ) + pending = self._ack_ok() + responses.append(GrblResponse(GrblResponseType.OK, "ok", pending)) + + # Detect NULL-byte corrupted 'ok' (hardware fault) + null_ok = self._find_null_corrupted_ok() + while null_ok is not None: + start, end = null_ok + self._status_buffer = ( + self._status_buffer[:start] + self._status_buffer[end:] + ) + logger.critical( + "HARDWARE FAULT DETECTED: A corrupted 'ok' " + "acknowledgement with NULL bytes was received. " + "This indicates a critical problem with the " + "USB cable, electrical noise (EMI), or power " + "supply. The hardware connection MUST be fixed " + "for reliable operation." + ) + pending = self._ack_ok() + responses.append(GrblResponse(GrblResponseType.OK, "ok", pending)) + null_ok = self._find_null_corrupted_ok() + + error_marker = b"error:" + error_end = b"\n" + while error_marker in self._status_buffer: + start = self._status_buffer.index(error_marker) + end = self._status_buffer.find(error_end, start) + if end == -1: + break + end += 1 + error_bytes = self._status_buffer[start:end] + self._status_buffer = ( + self._status_buffer[:start] + self._status_buffer[end:] + ) + try: + error_text = error_bytes.decode("utf-8").strip() + except UnicodeDecodeError: + continue + logger.debug( + f"Extracted '{error_text}' from raw buffer " + f"(interleaved recovery)" + ) + self._ack_ok() + responses.append(GrblResponse(GrblResponseType.ERROR, error_text)) + + def _find_null_corrupted_ok(self): + """ + Search _status_buffer for a pattern like + b'o\\x00k\\r*\\n' where NULL bytes are interspersed + in 'ok'. Returns (start, end) indices or None. + """ + buf = self._status_buffer + i = 0 + while i < len(buf): + if buf[i] == ord(b"o"): + j = i + 1 + while j < len(buf) and buf[j] == 0: + j += 1 + if j < len(buf) and buf[j] == ord(b"k") and j > i + 1: + k = j + 1 + while k < len(buf) and buf[k] == ord(b"\r"): + k += 1 + if k < len(buf) and buf[k] == ord(b"\n"): + return (i, k + 1) + i += 1 + return None + + def _parse_line(self, line: str) -> GrblResponse | None: + """ + Parse a single decoded line into a GrblResponse. + Handles 'ok', 'error:', and returns everything else + as a LINE type. + """ + if line == "ok": + pending = self._ack_ok() + return GrblResponse(GrblResponseType.OK, "ok", pending) + if line.startswith("error:"): + self._ack_ok() + return GrblResponse(GrblResponseType.ERROR, line) + return GrblResponse(GrblResponseType.LINE, line) + + def clear_buffer(self) -> None: + """Clear the internal line buffer.""" + self._status_buffer = bytearray() + + # --- Flow-controlled sending --- + + def _log_tx(self, data: bytes, buf_count: int | None = None): + """Log a RAW_IO TX event with optional buffer info.""" + if buf_count is not None: + buf_info = f"buf: {buf_count}/{self._rx_buffer_size}" + msg = f"TX: {data!r} ({buf_info})" + else: + msg = f"TX: {data!r}" + logger.debug( + msg, + extra={ + "log_category": "RAW_IO", + "direction": "TX", + "data": data, + }, + ) + + def _log_rx(self, data: bytes): + """Log a RAW_IO RX event with buffer info.""" + buf_info = f"buf: {self.buffer_count}/{self._rx_buffer_size}" + logger.debug( + f"RX: {data!r} ({buf_info})", + extra={ + "log_category": "RAW_IO", + "direction": "RX", + "data": data, + }, + ) + + async def send_gcode( + self, + data: bytes, + op_index: int | None = None, + *, + timeout: float = 10.0, + on_stall: Callable[["GrblSerialTransport", int], Awaitable[bool]] + | None = None, + ) -> int: + """ + Send G-code with buffer accounting. + + Waits for buffer space if needed. If the wait times out, + calls *on_stall(transport, command_len)* which may perform + deadlock recovery and return True to retry. Raises + ``BufferStallError`` if recovery fails or no callback is + provided. Returns the buffer fill level after sending. + """ + command_len = len(data) + waited = False + while self.needs_space(command_len): + if not waited: + logger.info( + f"Buffer full ({self.buffer_count}/" + f"{self._rx_buffer_size}), waiting for " + f"{command_len} bytes " + f"(pending: {self._pending.qsize()})" + ) + waited = True + try: + await asyncio.wait_for(self.wait_for_space(), timeout=timeout) + except asyncio.TimeoutError: + if on_stall: + logger.warning( + f"Buffer stall: timed out after " + f"{timeout}s waiting for " + f"{command_len} bytes " + f"(buf: {self.buffer_count}/" + f"{self._rx_buffer_size}, " + f"pending: {self._pending.qsize()}). " + f"Invoking recovery callback." + ) + handled = await on_stall(self, command_len) + if handled: + logger.info( + f"Recovery successful, retrying send " + f"(buf: {self.buffer_count}/" + f"{self._rx_buffer_size})" + ) + continue + logger.error( + f"Recovery failed " + f"(buf: {self.buffer_count}/" + f"{self._rx_buffer_size})" + ) + raise BufferStallError( + f"Buffer stall: cannot get {command_len} " + f"bytes of space " + f"(buf: {self.buffer_count}/" + f"{self._rx_buffer_size})" + ) + if waited: + logger.info( + f"Buffer space available, resuming " + f"(buf: {self.buffer_count}/" + f"{self._rx_buffer_size})" + ) + cmd_text = data.decode("ascii", "replace") + self._pending.put_nowait( + PendingCommand(command_len, op_index, cmd_text) + ) + count = self._add(command_len) + self._log_tx(data, count) + await self._transport.send(data) + return count + + async def send_poll(self, data: bytes) -> int: + """ + Send a status poll without buffer accounting. + Real-time commands (?) bypass the GRBL RX buffer. + """ + # From the GRBL docs: "Like all real-time commands, the '?' + # character is intercepted and never enters the serial buffer" + # https://github.com/gnea/grbl/blob/master/doc/markdown/interface.md + count = self.buffer_count + self._log_tx(data, count) + await self._transport.send(data) + return count + + async def send_command(self, data: bytes) -> int: + """ + Send an interactive command ($$, $G, etc.) with buffer + accounting. Waits for buffer space if needed (up to 1 s), + then sends regardless. Returns the buffer fill level after + sending. + """ + command_len = len(data) + while self.needs_space(command_len): + try: + await asyncio.wait_for(self.wait_for_space(), timeout=1.0) + except asyncio.TimeoutError: + logger.warning( + "Timed out waiting for buffer space for " + "command. Sending anyway." + ) + break + self._pending.put_nowait( + PendingCommand(command_len, None, data.decode("ascii", "replace")) + ) + count = self._add(command_len) + self._log_tx(data, count) + await self._transport.send(data) + return count + + async def send_control(self, data: bytes) -> None: + """ + Send a realtime control character (\\x18, !, ~) without + buffer accounting. Realtime commands are intercepted by + GRBL before entering the RX buffer, so no space check is + needed. + """ + self._log_tx(data) + await self._transport.send(data) + + @property + def buffer_count(self) -> int: + with self._lock: + return self._rx_buffer_count + + def needs_space(self, needed: int) -> bool: + """Return True if *needed* bytes would overflow the RX buffer.""" + return self.buffer_count + needed > self._rx_buffer_size + + def set_rx_buffer_size(self, size: int) -> None: + """ + Update the device's RX buffer size (as reported by the $I OPT + line). Must be called before streaming begins. + """ + if size > 0: + old_size = self._rx_buffer_size + with self._lock: + self._rx_buffer_size = size + logger.info( + f"GRBL RX buffer size set to {size} bytes (was {old_size})" + ) + + async def wait_for_space(self) -> None: + """Clear and wait for one space-available signal.""" + self._space_available.clear() + await self._space_available.wait() + + def _ack_ok(self) -> PendingCommand | None: + """ + Process an ``ok`` or ``error:`` acknowledgement. Pops the + corresponding pending command, frees buffer space, and + signals waiters. + + Returns None when no pending command exists. + """ + try: + pending = self._pending.get_nowait() + except asyncio.QueueEmpty: + logger.warning("Received ack but sent gcode queue was empty.") + return None + logger.debug( + f"Buffer ack: freeing {pending.length} bytes for " + f"{pending.command!r} " + f"(op_index={pending.op_index}, " + f"remaining: {self._pending.qsize()})" + ) + self._sub(pending.length) + self._pending.task_done() + self.signal_space_available() + return pending + + def ack_status_report(self) -> int: + """ + Process a status report. + Since polls bypass the RX buffer, we do not decrement the + buffer count here. + """ + return self.buffer_count + + def reset_flow_control(self) -> None: + """Reset flow-control state without clearing the parse buffer.""" + with self._lock: + self._rx_buffer_count = 0 + self._pending = asyncio.Queue() + self._space_available = asyncio.Event() + self._space_available.set() + + def reset(self) -> None: + """Reset all buffer state (cancel, reconnect, cleanup).""" + self.reset_flow_control() + self._status_buffer = bytearray() + + def signal_space_available(self) -> None: + """Unblock any waiters (e.g. on cancel).""" + if self._loop is not None: + self._loop.call_soon_threadsafe(self._space_available.set) + else: + self._space_available.set() + + @property + def pending_queue(self) -> asyncio.Queue[PendingCommand]: + return self._pending + + async def wait_all_pending(self, timeout: float = 10.0) -> None: + """Wait for all sent commands to be acknowledged.""" + await asyncio.wait_for(self._pending.join(), timeout=timeout) + + def _add(self, n: int) -> int: + with self._lock: + self._rx_buffer_count += n + return self._rx_buffer_count + + def _sub(self, n: int) -> int: + with self._lock: + self._rx_buffer_count -= n + if self._rx_buffer_count < 0: + logger.warning( + f"Buffer underflow: count went to " + f"{self._rx_buffer_count} after freeing {n} " + f"bytes. Clamping to 0." + ) + self._rx_buffer_count = 0 + return self._rx_buffer_count diff --git a/rayforge/machine/transport/http.py b/rayforge/machine/transport/http.py new file mode 100644 index 000000000..9eca896ae --- /dev/null +++ b/rayforge/machine/transport/http.py @@ -0,0 +1,109 @@ +import asyncio + +import aiohttp + +from .transport import Transport, TransportStatus + + +class HttpTransport(Transport): + """ + HTTP transport using persistent connection with auto-reconnect. + """ + + def __init__(self, base_url: str, receive_interval: float): + """ + Initialize HTTP transport. + + Args: + base_url: Server endpoint URL (schema://host:port) + """ + super().__init__() + self.base_url = base_url + self._running = False + self._reconnect_interval = 5 + self._receive_interval = receive_interval + self._connection_task: asyncio.Task | None = None + self._connected = False + + @property + def is_connected(self) -> bool: + return self._connected + + async def connect(self) -> None: + """ + Maintain persistent connection with reconnect logic. + """ + self._running = True + self._connection_task = asyncio.create_task(self._connection_loop()) + + async def _connection_loop(self) -> None: + while self._running: + try: + self.status_changed.send( + self, status=TransportStatus.CONNECTING + ) + async with aiohttp.ClientSession() as session: + await self._receive_loop(session) + except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e: + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + finally: + self.status_changed.send( + self, status=TransportStatus.DISCONNECTED + ) + + if self._running: + self.status_changed.send(self, status=TransportStatus.SLEEPING) + await asyncio.sleep(self._reconnect_interval) + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + + async def disconnect(self) -> None: + """ + Terminate connection and cancel background tasks. + """ + self._running = False + if self._connection_task: + self._connection_task.cancel() + + async def send(self, data: bytes) -> None: + """ + Send data to HTTP endpoint via POST request. + """ + async with ( + aiohttp.ClientSession() as session, + session.post( + f"{self.base_url}", + data=data, + timeout=aiohttp.ClientTimeout(total=5), + ) as response, + ): + if response.status != 200: + err = f"Send failed: {await response.text()}" + self.status_changed.send(self, message=err) + raise OSError(err) + + async def purge(self) -> None: + """ + Clear any buffered data in the HTTP transport. + + HTTP transport uses a new session for each request and does not + maintain a persistent receive buffer. This method is a no-op. + """ + + async def _receive_loop(self, session) -> None: + """ + Listen for server-sent events from streaming endpoint. + """ + while self._running: + async with session.get( + f"{self.base_url}", + timeout=aiohttp.ClientTimeout(total=30), + ) as response: + if response.status == 200: + data = await response.read() + if data: + self.received.send(self, data=data) + + if self._running and self._receive_interval: + await asyncio.sleep(self._receive_interval) diff --git a/rayforge/machine/transport/serial.py b/rayforge/machine/transport/serial.py new file mode 100644 index 000000000..13d56081d --- /dev/null +++ b/rayforge/machine/transport/serial.py @@ -0,0 +1,367 @@ +import asyncio +import glob +import logging +import os +import threading +import time +from gettext import gettext as _ + +import serial +from serial.tools import list_ports + +from .transport import Transport, TransportStatus + +logger = logging.getLogger(__name__) + + +class SerialPort(str): + """A string subclass for identifying serial ports, for UI generation.""" + + +class SerialPortPermissionError(Exception): + """Custom exception for systemic serial port permission issues.""" + + +def safe_list_ports_linux() -> list[str]: + """ + A non-crashing implementation of list_ports for sandboxed Linux envs. + + pyserial's default list_ports.comports() tries to access /dev/ttyS* + ports, which is forbidden by the snap sandbox. This leads to a + permission error that causes a TypeError in the pyserial code. + + This function avoids that by only looking for common USB-to-serial + device patterns that are permitted by the serial-port interface. + """ + ports = [] + # Use glob to find all devices matching the common patterns + for pattern in [ + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/serial/by-id/*", + "/dev/serial/by-path/*", + ]: + try: + ports.extend(glob.glob(pattern)) + except OSError as e: + logger.warning( + f"Error scanning for serial ports. Pattern '{pattern}': {e}" + ) + return sorted(ports) + + +class SerialTransport(Transport): + """ + Asynchronous serial port transport. + """ + + @staticmethod + def list_ports() -> list[str]: + """Lists available serial ports.""" + # If we're on Linux (posix) and running in a Snap, use our + # safe scanner, as list_ports.comports() fails with permission errors. + if os.name == "posix" and "SNAP" in os.environ: + return safe_list_ports_linux() + + # On other systems or outside a Snap, the default is fine. + try: + return sorted([p.device for p in list_ports.comports()]) + except (OSError, serial.SerialException, TypeError) as e: + # Fallback for any other unexpected errors + logger.error(f"Failed to list serial ports with pyserial: {e}") + return [] + + @staticmethod + def list_usb_ports() -> list[str]: + """Like list_ports, but only returns USB serial ports.""" + + all_ports = SerialTransport.list_ports() + if os.name != "posix": + # On non-POSIX systems, we can't reliably filter, so return all. + return all_ports + + return [p for p in all_ports if "ttyUSB" in p or "ttyACM" in p] + + @staticmethod + def check_serial_permissions_globally() -> None: + """ + On POSIX systems, checks if there are visible serial ports that the + user cannot access. This is a strong indicator that the user is not + in the correct group (e.g., 'dialout') or, in a Snap, lacks the + necessary permissions. + + Raises: + SerialPortPermissionError: If systemic permission issues are + detected. + """ + if os.name != "posix": + return # This check is only for POSIX-like systems (Linux, macOS) + + # Retrieve a list of all relevant serial ports. + all_ports = SerialTransport.list_usb_ports() + snap_name = os.environ.get("SNAP_NAME", "rayforge") + + # First, handle the case where no ports are found and + # provide environment-specific guidance if applicable. + if not all_ports and "SNAP" in os.environ: + msg = _( + "Failed to list serial ports due to a Snap confinement!" + " Please ensure the device is connected via USB and run:" + "\n\n" + "sudo snap set system experimental.hotplug=true\n" + "sudo snap connect {snap_name}:serial-port" + ).format(snap_name=snap_name) + raise SerialPortPermissionError(msg) + + elif not all_ports: + msg = "No USB serial ports found." + raise SerialPortPermissionError(msg) + + # Next, check if any of the found ports are accessible. + if any(os.access(p, os.R_OK | os.W_OK) for p in all_ports): + return # At least one port is accessible; no systemic issue. + + if "SNAP" in os.environ: + msg = _( + "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 {snap_name}:serial-port" + ).format(snap_name=snap_name) + raise SerialPortPermissionError(msg) + else: + msg = ( + "Could not access any serial ports. On Linux, ensure " + "your user is in the 'dialout' group." + ) + raise SerialPortPermissionError(msg) + + @staticmethod + def list_baud_rates() -> list[int]: + """Returns a list of common serial baud rates.""" + return [ + 9600, + 19200, + 38400, + 57600, + 115200, + 230400, + 460800, + 921600, + 1000000, + 1843200, + ] + + # Non-blocking read to prevent holding OS driver locks. + _READ_TIMEOUT = 0 + + def __init__(self, port: str, baudrate: int): + """ + Initialize serial transport. + + Args: + port: Device path (e.g., '/dev/ttyUSB0') + baudrate: Communication speed in bits per second + """ + super().__init__() + self.port = port + self.baudrate = baudrate + self._serial: serial.Serial | None = None + self._running = False + self._stop_event = threading.Event() + self._reader_thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + @property + def is_connected(self) -> bool: + """Check if the transport is actively connected.""" + return self._serial is not None and self._running + + async def connect(self) -> None: + logger.debug("Attempting to connect serial port...") + self.status_changed.send(self, status=TransportStatus.CONNECTING) + try: + self._serial = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=self._READ_TIMEOUT, + exclusive=True, + ) + logger.debug("serial.Serial opened successfully.") + self._running = True + self._loop = asyncio.get_running_loop() + self._stop_event.clear() + self.status_changed.send(self, status=TransportStatus.CONNECTED) + self._reader_thread = threading.Thread( + target=self._reader_thread_func, + name="serial-reader", + daemon=True, + ) + self._reader_thread.start() + logger.debug("Serial port connected successfully.") + except Exception as e: + logger.error(f"Failed to connect serial port: {e}") + self._serial = None + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + raise + + async def disconnect(self) -> None: + """ + Gracefully terminate the serial connection and cleanup resources. + """ + logger.debug("Attempting to disconnect serial port...") + self.status_changed.send(self, status=TransportStatus.CLOSING) + self._running = False + + self._stop_event.set() + + # Close the serial port; this will cause the blocking read() + # in the reader thread to raise SerialException or return b"". + if self._serial: + try: + self._serial.close() + except serial.SerialException as e: + logger.warning(f"Error closing serial port: {e}") + + # Wait for the reader thread to finish. + if self._reader_thread and self._reader_thread.is_alive(): + logger.debug("Waiting for reader thread to finish...") + self._reader_thread.join(timeout=2.0) + if self._reader_thread.is_alive(): + logger.warning("Reader thread did not stop in time.") + self._reader_thread = None + self._serial = None + self._loop = None + + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + logger.debug("Serial port disconnected.") + + async def send(self, data: bytes) -> None: + """ + Write data to serial port and flush to ensure physical + transmission. + + Without flush, data may sit in the kernel TTY buffer + indefinitely. This causes GRBL to never receive commands + while the host believes they were sent, leading to false + deadlock detection. When the deadlock recovery eventually + writes more data, the entire buffered payload is flushed at + once, overflowing the device's small RX buffer and causing + error responses. + """ + if not self._serial: + raise ConnectionError("Serial port not open") + assert self._loop is not None + logger.debug(f"Sending data: {data!r}") + + try: + # Offloading to an executor prevents blocking C-level calls + # from tying up the asyncio event loop thread, which can cause + # deferred OS/kernel execution of the actual transmission. + await self._loop.run_in_executor(None, self._sync_send, data) + except (serial.SerialException, OSError) as e: + # Wrap low-level serial errors as ConnectionError so drivers + # can handle them gracefully + raise ConnectionError( + f"Failed to write to serial port: {e}" + ) from e + + def _sync_send(self, data: bytes) -> None: + """Synchronous wrapper for blocking write and flush operations.""" + if self._serial: + self._serial.write(data) + self._serial.flush() + + async def purge(self) -> None: + """ + Clear any buffered data in the serial transport. + + Discards any pending data in the receive buffer to resync + communications. Does not affect the connection state. + """ + if not self._serial: + return + + try: + self._serial.reset_input_buffer() + logger.debug("Input buffer purged.") + except serial.SerialException as e: + logger.warning(f"Error during purge: {e}") + + def _dispatch_received(self, data: bytes) -> None: + """Emit received signal on the event loop thread.""" + self.received.send(self, data=data) + + def _dispatch_error(self, message: str) -> None: + """Emit error status on the event loop thread.""" + self.status_changed.send( + self, status=TransportStatus.ERROR, message=message + ) + + def _reader_thread_func(self) -> None: + """ + Dedicated reader thread that continuously reads from the serial + port and dispatches received data to the event loop. + + Uses a non-blocking read to allow the OS to manage the hardware + transmit locks freely, paired with a tiny CPU yield to prevent + busy-looping. + """ + assert self._serial is not None + ser = self._serial + logger.debug("Reader thread started.") + while not self._stop_event.is_set(): + try: + data = ser.read(1024) + except serial.SerialException as e: + if self._stop_event.is_set(): + break + msg = str(e) + if ( + "device reports readiness to read but returned no data" + in msg + ): + logger.warning( + f"Serial connection lost (device disconnected?): {e}" + ) + else: + logger.error(f"Serial error in reader thread: {e}") + if self._loop and not self._loop.is_closed(): + self._loop.call_soon_threadsafe(self._dispatch_error, msg) + break + except OSError as e: + if self._stop_event.is_set(): + break + logger.error(f"OS error in reader thread: {e}") + if self._loop and not self._loop.is_closed(): + self._loop.call_soon_threadsafe( + self._dispatch_error, str(e) + ) + break + except Exception as e: # noqa: BLE001 - reader thread boundary + if self._stop_event.is_set(): + break + logger.error(f"Unexpected error in reader thread: {e}") + if self._loop and not self._loop.is_closed(): + self._loop.call_soon_threadsafe( + self._dispatch_error, str(e) + ) + break + + if not data: + # With timeout=0, this microscopic sleep prevents a 100% CPU + # busy-loop. Crucially, it ensures the Python thread spends + # almost all of its time OUTSIDE the kernel, keeping the OS + # serial lock free so that concurrent writes from the asyncio + # thread can physically transmit immediately. + time.sleep(0.005) + continue + + logger.debug(f"Received data: {data!r}") + if self._loop and not self._loop.is_closed(): + self._loop.call_soon_threadsafe(self._dispatch_received, data) + + logger.debug("Reader thread exiting.") diff --git a/rayforge/machine/transport/serial_server.py b/rayforge/machine/transport/serial_server.py new file mode 100644 index 000000000..9628fd45a --- /dev/null +++ b/rayforge/machine/transport/serial_server.py @@ -0,0 +1,157 @@ +import asyncio +import fcntl +import logging +import os +import pty +import termios + +from .transport import Transport, TransportStatus + +logger = logging.getLogger(__name__) + + +class SerialServerTransport(Transport): + """ + Serial server transport that creates a PTY pair. + + Creates a pseudo-terminal pair where the master end is used by this + transport and the slave path is exposed for clients to connect to. + Useful for simulating serial devices without hardware. + """ + + def __init__(self, baudrate: int = 115200): + super().__init__() + self.baudrate = baudrate + self._master_fd: int | None = None + self._slave_fd: int | None = None + self._slave_path: str | None = None + self._running = False + + @property + def slave_path(self) -> str | None: + """Returns the path clients should connect to.""" + return self._slave_path + + @property + def is_connected(self) -> bool: + return self._master_fd is not None and self._running + + async def connect(self) -> None: + if self.is_connected: + return + + self._running = True + self.status_changed.send(self, status=TransportStatus.CONNECTING) + logger.info("Creating PTY for serial server...") + + try: + master_fd, slave_fd = pty.openpty() + self._master_fd = master_fd + self._slave_path = os.ttyname(slave_fd) + self._slave_fd = slave_fd + # Keep the slave fd open: macOS's PTY driver raises EIO on the + # master once no process holds the slave end. + + attrs = termios.tcgetattr(master_fd) + attrs[4] = termios.B115200 + attrs[5] = termios.B115200 + attrs[0] &= ~( + termios.IGNBRK + | termios.BRKINT + | termios.PARMRK + | termios.ISTRIP + | termios.INLCR + | termios.IGNCR + | termios.ICRNL + | termios.IXON + ) + attrs[1] &= ~termios.OPOST + attrs[2] &= ~(termios.CSIZE | termios.PARENB) + attrs[2] |= termios.CS8 + attrs[3] &= ~( + termios.ECHO + | termios.ECHONL + | termios.ICANON + | termios.ISIG + | termios.IEXTEN + ) + termios.tcsetattr(master_fd, termios.TCSANOW, attrs) + + flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) + fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + loop = asyncio.get_event_loop() + loop.add_reader(master_fd, self._on_readable) + + self.status_changed.send(self, status=TransportStatus.CONNECTED) + logger.info(f"Serial server PTY created: {self._slave_path}") + except Exception as e: + logger.error(f"Failed to create PTY: {e}") + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + raise + + async def disconnect(self) -> None: + logger.info("Stopping serial server...") + self._running = False + + if self._master_fd is not None: + loop = asyncio.get_event_loop() + loop.remove_reader(self._master_fd) + + try: + os.close(self._master_fd) + except OSError: + pass + self._master_fd = None + + if self._slave_fd is not None: + try: + os.close(self._slave_fd) + except OSError: + pass + self._slave_fd = None + + self._slave_path = None + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + logger.info("Serial server stopped") + + async def send(self, data: bytes) -> None: + if self._master_fd is None: + raise ConnectionError("Serial server not started") + os.write(self._master_fd, data) + + async def purge(self) -> None: + pass + + def _on_readable(self) -> None: + """Called when the master FD is readable (data from slave).""" + if not self._running or self._master_fd is None: + return + + try: + data = os.read(self._master_fd, 4096) + if data: + logger.debug(f"Received data: {data!r}") + self.received.send(self, data=data) + else: + logger.info("PTY closed by peer") + asyncio.create_task(self._handle_peer_close()) + except BlockingIOError: + pass + except OSError as e: + logger.error(f"Error reading from PTY: {e}") + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + asyncio.create_task(self._handle_error()) + + async def _handle_peer_close(self) -> None: + """Handle PTY peer close asynchronously.""" + self._running = False + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + + async def _handle_error(self) -> None: + """Handle read error asynchronously.""" + self._running = False diff --git a/rayforge/machine/transport/telnet.py b/rayforge/machine/transport/telnet.py new file mode 100644 index 000000000..44fb4ca2c --- /dev/null +++ b/rayforge/machine/transport/telnet.py @@ -0,0 +1,145 @@ +import asyncio +import logging + +from .transport import Transport, TransportStatus + +logger = logging.getLogger(__name__) + + +class TelnetTransport(Transport): + def __init__(self, host: str, port: int): + super().__init__() + self.host = host + self.port = port + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + self._running = False + self._reconnect_interval = 5 + self._connection_task: asyncio.Task | None = None + + @property + def is_connected(self) -> bool: + """Check if the transport is actively connected.""" + return self.writer is not None and not self.writer.is_closing() + + async def connect(self) -> None: + if self.is_connected: + return + + self._running = True + self.status_changed.send(self, status=TransportStatus.CONNECTING) + logger.info( + f"Connecting to telnet server at {self.host}:{self.port}..." + ) + try: + self.reader, self.writer = await asyncio.open_connection( + self.host, self.port + ) + self.status_changed.send(self, status=TransportStatus.CONNECTED) + logger.info(f"Successfully connected to {self.host}:{self.port}.") + # Connection is successful, start the management task + self._connection_task = asyncio.create_task( + self._manage_connection() + ) + except OSError as e: + # Failed to connect, report error and re-raise so caller knows. + logger.error(f"Failed to connect to {self.host}:{self.port}: {e}") + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + raise + + async def _manage_connection(self) -> None: + """ + Manages an active connection: receives data and handles disconnects. + """ + try: + await self._receive_loop() + except OSError as e: + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + finally: + # Connection was lost or an error occurred. + if self.writer: + self.writer.close() + self.writer = None + self.reader = None + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + + async def disconnect(self) -> None: + logger.info( + f"Disconnecting from telnet server at {self.host}:{self.port}..." + ) + self._running = False + if self._connection_task: + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass # Expected + self._connection_task = None + + if self.writer is None: + # Already cleaned up by _manage_connection's finally block, + # unless the task never got to run before being cancelled. + return + + self.writer.close() + try: + await self.writer.wait_closed() + except (ConnectionResetError, OSError): + pass # The other end might have already closed it. + self.writer = None + self.reader = None + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + logger.info(f"Disconnected from {self.host}:{self.port}.") + + async def send(self, data: bytes) -> None: + if not self.is_connected or self.writer is None: + raise ConnectionError("Not connected") + self.writer.write(data) + await self.writer.drain() + + async def purge(self) -> None: + """ + Clear any buffered data in the telnet transport. + + Discards any pending data in the receive buffer to resync + communications. Does not affect the connection state. + """ + if not self.reader: + return + + try: + while True: + data = await asyncio.wait_for( + self.reader.read(1024), timeout=0.1 + ) + if not data: + break + logger.debug(f"Purged data: {data!r}") + except asyncio.TimeoutError: + pass + except (OSError, RuntimeError) as e: + logger.warning(f"Error during purge: {e}") + + async def _receive_loop(self) -> None: + while self.reader: + try: + data = await self.reader.read(1024) + if data: + self.received.send(self, data=data) + else: + logger.info( + f"Telnet connection to {self.host}:{self.port} " + "closed by peer." + ) + break + except asyncio.CancelledError: + break + except OSError as e: + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + break diff --git a/rayforge/machine/transport/transport.py b/rayforge/machine/transport/transport.py new file mode 100644 index 000000000..3580852cd --- /dev/null +++ b/rayforge/machine/transport/transport.py @@ -0,0 +1,83 @@ +from abc import ABC, abstractmethod +from enum import Enum, auto +from gettext import gettext as _ + +from blinker import Signal + + +class TransportStatus(Enum): + UNKNOWN = auto() + IDLE = auto() + CONNECTING = auto() + CONNECTED = auto() + ERROR = auto() + CLOSING = auto() + DISCONNECTED = auto() + SLEEPING = auto() + + +TRANSPORT_STATUS_LABELS = { + TransportStatus.UNKNOWN: _("Unknown"), + TransportStatus.IDLE: _("Idle"), + TransportStatus.CONNECTING: _("Connecting"), + TransportStatus.CONNECTED: _("Connected"), + TransportStatus.ERROR: _("Error"), + TransportStatus.CLOSING: _("Closing"), + TransportStatus.DISCONNECTED: _("Disconnected"), + TransportStatus.SLEEPING: _("Sleeping"), +} + + +class Transport(ABC): + """ + Abstract base class for asynchronous data transports. + """ + + def __init__(self): + """ + Initialize transport with callbacks and notification handler. + + Signals: + received: Function to handle received data + status_changed: Function to handle connection status changes + """ + self.received = Signal() + self.status_changed = Signal() + + @property + @abstractmethod + def is_connected(self) -> bool: + """ + Whether the transport is actively connected. + """ + + @abstractmethod + async def connect(self) -> None: + """ + Establish connection and start data flow. + """ + + @abstractmethod + async def disconnect(self) -> None: + """ + Gracefully terminate connection and cleanup resources. + """ + + @abstractmethod + async def send(self, data: bytes) -> None: + """ + Send binary data through the transport. + + Raises: + ConnectionError: If transport is not connected + """ + + @abstractmethod + async def purge(self) -> None: + """ + Clear any buffered data in the transport. + + This method is used to resync communications by discarding any + pending data in the receive buffer. It should not affect the + connection state. + """ diff --git a/rayforge/machine/transport/udp.py b/rayforge/machine/transport/udp.py new file mode 100644 index 000000000..51893d6e8 --- /dev/null +++ b/rayforge/machine/transport/udp.py @@ -0,0 +1,148 @@ +import asyncio +import logging +import socket + +import asyncudp + +from .transport import Transport, TransportStatus + +logger = logging.getLogger(__name__) + + +class UdpTransport(Transport): + def __init__(self, host: str, port: int, local_port: int | None = None): + super().__init__() + self.host = host + self.host_ip = socket.gethostbyname(host) + self.port = port + self.local_port = local_port + self.reader: asyncudp.Socket | None = None + self.writer: asyncudp.Socket | None = None + self._running = False + self._reconnect_interval = 5 + self._connection_task: asyncio.Task | None = None + + @property + def is_connected(self) -> bool: + """Check if the transport is actively connected.""" + return self.writer is not None + + async def connect(self) -> None: + if self.is_connected: + return + + self._running = True + self.status_changed.send(self, status=TransportStatus.CONNECTING) + logger.info(f"Connecting to server at {self.host}:{self.port}...") + try: + local_addr = None + if self.local_port is not None: + local_addr = ("0.0.0.0", self.local_port) + reuse_port = ( + hasattr(socket, "SO_REUSEPORT") if local_addr else None + ) + self.reader = await asyncudp.create_socket( + local_addr=local_addr, + remote_addr=(self.host_ip, self.port), + reuse_port=reuse_port, + ) + self.writer = self.reader + + self.status_changed.send(self, status=TransportStatus.CONNECTED) + logger.info(f"Successfully connected to {self.host}:{self.port}.") + # Connection is successful, start the management task + self._connection_task = asyncio.create_task( + self._manage_connection() + ) + except OSError as e: + # Failed to connect, report error and re-raise so caller knows. + logger.error(f"Failed to connect to {self.host}:{self.port}: {e}") + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + raise + + async def _manage_connection(self) -> None: + """ + Manages an active connection: receives data and handles disconnects. + """ + try: + await self._receive_loop() + except OSError as e: + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + finally: + # Connection was lost or an error occurred. + if self.writer: + self.writer.close() + self.writer = None + self.reader = None + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + + async def disconnect(self) -> None: + logger.info(f"Disconnecting from server at {self.host}:{self.port}...") + self._running = False + if self._connection_task: + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass # Expected + if self.writer: + try: + self.writer.close() + except ConnectionResetError: + pass # The other end might have already closed it. + self.writer = None + self.reader = None + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + logger.info(f"Disconnected from {self.host}:{self.port}.") + + async def send(self, data: bytes) -> None: + if not self.writer: + raise ConnectionError("Not connected") + self.writer.sendto(data) + + async def purge(self) -> None: + """ + Clear any buffered data in the UDP transport. + + Discards any pending data in the receive buffer to resync + communications. Does not affect the connection state. + """ + if not self.reader: + return + + try: + while True: + data, _ = await asyncio.wait_for( + self.reader.recvfrom(), timeout=0.1 + ) + if not data: + break + logger.debug(f"Purged data: {data!r}") + except asyncio.TimeoutError: + pass + except OSError as e: + logger.warning(f"Error during purge: {e}") + + async def _receive_loop(self) -> None: + while self.reader: + try: + data, _ = await self.reader.recvfrom() + if data: + self.received.send(self, data=data) + else: + logger.info( + f"Connection to {self.host}:{self.port} " + "closed by peer." + ) + break + except asyncio.CancelledError: + break + except OSError as e: + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + break diff --git a/rayforge/machine/transport/udp_server.py b/rayforge/machine/transport/udp_server.py new file mode 100644 index 000000000..72abd6030 --- /dev/null +++ b/rayforge/machine/transport/udp_server.py @@ -0,0 +1,101 @@ +import asyncio +import logging + +from .transport import Transport, TransportStatus + +logger = logging.getLogger(__name__) + + +class UdpServerProtocol(asyncio.DatagramProtocol): + """Protocol handler for UDP server.""" + + def __init__(self, transport: "UdpServerTransport"): + self.transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + self.transport._on_datagram_received(data, addr) + + def error_received(self, exc: Exception) -> None: + logger.error(f"UDP server error: {exc}") + self.transport.status_changed.send( + self.transport, status=TransportStatus.ERROR, message=str(exc) + ) + + +class UdpServerTransport(Transport): + """ + UDP server transport that listens for incoming packets. + + Unlike UdpTransport (client), this binds to a local address + and responds to any client that sends data. + """ + + def __init__(self, host: str = "0.0.0.0", port: int = 50200): + super().__init__() + self.host = host + self.port = port + self._transport: asyncio.DatagramTransport | None = None + self._running = False + + @property + def is_connected(self) -> bool: + return self._transport is not None + + async def connect(self) -> None: + if self.is_connected: + return + + self._running = True + self.status_changed.send(self, status=TransportStatus.CONNECTING) + logger.info(f"Starting UDP server on {self.host}:{self.port}...") + + try: + loop = asyncio.get_event_loop() + + transport, _ = await loop.create_datagram_endpoint( + lambda: UdpServerProtocol(self), + local_addr=(self.host, self.port), + ) + self._transport = transport + + sock = transport.get_extra_info("socket") + if sock: + self.port = sock.getsockname()[1] + + self.status_changed.send(self, status=TransportStatus.CONNECTED) + logger.info(f"UDP server listening on {self.host}:{self.port}") + except Exception as e: + logger.error(f"Failed to start UDP server: {e}") + self.status_changed.send( + self, status=TransportStatus.ERROR, message=str(e) + ) + raise + + async def disconnect(self) -> None: + logger.info(f"Stopping UDP server on {self.host}:{self.port}...") + self._running = False + + if self._transport: + self._transport.close() + self._transport = None + + self.status_changed.send(self, status=TransportStatus.DISCONNECTED) + logger.info("UDP server stopped") + + async def send(self, data: bytes) -> None: + raise NotImplementedError( + "Use send_to(data, addr) for UDP server transport" + ) + + async def send_to(self, data: bytes, addr: tuple[str, int]) -> None: + if not self._transport: + raise ConnectionError("UDP server not started") + self._transport.sendto(data, addr) + + async def purge(self) -> None: + pass + + def _on_datagram_received( + self, data: bytes, addr: tuple[str, int] + ) -> None: + self.received.send(self, data=data, addr=addr) diff --git a/rayforge/machine/transport/websocket.py b/rayforge/machine/transport/websocket.py new file mode 100644 index 000000000..1579366bf --- /dev/null +++ b/rayforge/machine/transport/websocket.py @@ -0,0 +1,166 @@ +import asyncio +import logging + +import websockets +from websockets.exceptions import ConnectionClosed + +from .transport import Transport, TransportStatus + +logger = logging.getLogger(__name__) + + +class WebSocketTransport(Transport): + """ + WebSocket transport with robust state management. + """ + + def __init__(self, uri: str, origin=None): + super().__init__() + self.uri = uri + self._websocket: websockets.ClientConnection | None = None + self._origin = origin + self._running = False + self._reconnect_interval = 5 + self._lock = asyncio.Lock() + self._receive_task: asyncio.Task | None = None + self._status = TransportStatus.DISCONNECTED + + @property + def is_connected(self) -> bool: + """Check if the transport's status is CONNECTED.""" + return self._status == TransportStatus.CONNECTED + + def _set_status( + self, status: TransportStatus, message: str | None = None + ) -> None: + """ + Internal helper to set status and send signal, avoiding duplicates. + """ + if self._status == status: + return + self._status = status + self.status_changed.send(self, status=status, message=message) + + async def connect(self) -> None: + """ + Establish and maintain a connection, reconnecting on failure. + """ + async with self._lock: + if self._running: + return + self._running = True + + while self._running: + try: + self._set_status(TransportStatus.CONNECTING) + self._websocket = await websockets.connect( + self.uri, + origin=self._origin, + ping_interval=None, + additional_headers=( + ("Connection", "Upgrade"), + ("Upgrade", "websocket"), + ), + ) + self._set_status(TransportStatus.CONNECTED) + self._receive_task = asyncio.create_task(self._receive_loop()) + await self._receive_task + + except (asyncio.CancelledError, ConnectionClosed): + # This is an expected part of a clean shutdown or reconnect + # cycle. + pass + except (websockets.exceptions.WebSocketException, OSError) as e: + self._set_status(TransportStatus.ERROR, message=str(e)) + finally: + # Always clean up the connection before the next step. + await self._safe_close() + # If we are still supposed to be running, wait and reconnect. + if self._running: + self._set_status(TransportStatus.SLEEPING) + await asyncio.sleep(self._reconnect_interval) + + # When the loop is fully stopped, we are disconnected. + self._set_status(TransportStatus.DISCONNECTED) + + async def disconnect(self) -> None: + """ + Terminate the connection immediately and permanently. + """ + self._set_status(TransportStatus.CLOSING) + async with self._lock: + if not self._running: + return + self._running = False + if self._receive_task: + self._receive_task.cancel() + await self._safe_close() + self._set_status(TransportStatus.DISCONNECTED) + + async def send(self, data: bytes) -> None: + """ + Send data through the active connection. + """ + if not self.is_connected or self._websocket is None: + raise ConnectionError("Not connected") + try: + await self._websocket.send(data) + except ConnectionClosed: + # The main `connect` loop will detect the closure via the + # `_receive_loop` and handle the reconnect automatically. + # We just need to signal that this specific send operation failed. + raise ConnectionError("Connection lost while sending") + + async def purge(self) -> None: + """ + Clear any buffered data in the WebSocket transport. + + Discards any pending data in the receive buffer to resync + communications. Does not affect the connection state. + """ + if self._websocket is None: + return + + try: + while True: + message = await asyncio.wait_for( + self._websocket.recv(), timeout=0.1 + ) + if not message: + break + logger.debug(f"Purged data: {message!r}") + except asyncio.TimeoutError: + pass + except ConnectionClosed: + pass + except websockets.exceptions.WebSocketException as e: + logger.warning(f"Error during purge: {e}") + + async def _receive_loop(self) -> None: + """ + Receive messages and handle connection state internally. + """ + if self._websocket is None: + return + try: + async for message in self._websocket: + if isinstance(message, bytes): + self.received.send(self, data=message) + except ConnectionClosed: + pass # The outer connect() loop will handle this. + except websockets.exceptions.WebSocketException as e: + self._set_status(TransportStatus.ERROR, message=str(e)) + + async def _safe_close(self) -> None: + """ + Safely close connection and reset the internal websocket object. + """ + if self._websocket is not None: + try: + await self._websocket.close() + except Exception: + # Ignore errors on close, as we are tearing down the + # connection. + logger.debug("Error closing websocket", exc_info=True) + finally: + self._websocket = None diff --git a/rayforge/models/config.py b/rayforge/models/config.py deleted file mode 100644 index a16493cb7..000000000 --- a/rayforge/models/config.py +++ /dev/null @@ -1,76 +0,0 @@ -import yaml -import logging -from typing import Dict, Any -from blinker import Signal -from .machine import Machine - - -logger = logging.getLogger(__name__) - - -class Config: - def __init__(self): - self.machine: Machine = None - self.paned_position = 60 # in percent - self.changed = Signal() - - def set_machine(self, machine: Machine): - if self.machine == machine: - return - if self.machine: - self.machine.changed.disconnect() - self.machine = machine - self.changed.send(self) - self.machine.changed.connect(self.changed.send) - - def to_dict(self) -> Dict[str, Any]: - return { - "machine": self.machine.id, - "paned_position": self.paned_position - } - - @classmethod - def from_dict(cls, data: Dict[str, Any], get_machine_by_id) -> 'Config': - config = cls() - - # Get the machine by ID. add fallbacks in case the machines - # no longer exist. - machine_id = data.get("machine") - machine = None - if machine_id is not None: - machine = get_machine_by_id(machine_id) - if machine is None: - msg = f"config references unknown machine {machine_id}" - logger.error(msg) - if machine: - config.set_machine(machine) - - config.paned_position = data.get("paned_position", - config.paned_position) - return config - - -class ConfigManager: - def __init__(self, filepath, machine_mgr): - self.filepath = filepath - self.machine_mgr = machine_mgr - self.config: Config = None - - self.load_config() - - def save(self): - with open(self.filepath, 'w') as f: - yaml.safe_dump(self.config.to_dict(), f) - - def load_config(self) -> 'Config': - if not self.filepath.exists(): - self.config = Config() # Return a default config - return self.config - - with open(self.filepath, 'r') as f: - data = yaml.safe_load(f) - if not data: - return Config() - config = Config.from_dict(data, self.machine_mgr.get_machine_by_id) - self.config = config - return config diff --git a/rayforge/models/doc.py b/rayforge/models/doc.py deleted file mode 100644 index 6a5d15648..000000000 --- a/rayforge/models/doc.py +++ /dev/null @@ -1,76 +0,0 @@ -import cairo -from typing import List -from blinker import Signal -from ..config import config -from .workpiece import WorkPiece -from .workplan import WorkPlan - - -class Doc: - """ - Represents a loaded Rayforge document. - """ - workpieces: List[WorkPiece] - workplan: WorkPlan - - def __init__(self): - self.workpieces: List[WorkPiece] = [] - self._workpiece_ref_for_pyreverse: WorkPiece - self.workplan: WorkPlan = WorkPlan(self, "Default plan") - self.surface: cairo.Surface = None - self.changed = Signal() - self.workplan.changed.connect(self.changed.send) - - def __iter__(self): - return iter(self.workpieces) - - def add_workpiece(self, workpiece): - self.workpieces.append(workpiece) - self.workplan.set_workpieces(self.workpieces) - self.changed.send(self) - - def remove_workpiece(self, workpiece): - if workpiece not in self.workpieces: - return - self.workpieces.remove(workpiece) - self.workplan.set_workpieces(self.workpieces) - self.changed.send(self) - - def has_workpiece(self): - return bool(self.workpieces) - - def has_result(self): - return self.workplan.has_steps() and len(self.workpieces) > 0 - - def render(self, - pixels_per_mm_x: int, - pixels_per_mm_y: int, - force: bool = False): - surface_width_mm, surface_height_mm = config.machine.dimensions - width = surface_width_mm * pixels_per_mm_x - height = surface_height_mm * pixels_per_mm_y - - if self.surface \ - and self.surface.get_width() == width \ - and self.surface.get_height() == height \ - and not force: - return self.surface, False - - self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - for workpiece in self.workpieces: - surface, changed = workpiece.render(pixels_per_mm_x, - pixels_per_mm_y, - force) - if changed: - pos_x_mm, pos_y_mm = workpiece.pos - pos_x = pos_x_mm * pixels_per_mm_x - pos_y = pos_y_mm * pixels_per_mm_y - ctx = cairo.Context(self.surface) - ctx.set_source_surface(surface, pos_x, pos_y) - ctx.paint() - - return self.surface, True - - def save_bitmap(self, filename, pixels_per_mm_x, pixels_per_mm_y): - surface, changed = self.render(pixels_per_mm_x, pixels_per_mm_y) - surface.write_to_png(filename) diff --git a/rayforge/models/machine.py b/rayforge/models/machine.py deleted file mode 100644 index 5463f2a64..000000000 --- a/rayforge/models/machine.py +++ /dev/null @@ -1,225 +0,0 @@ -import yaml -import uuid -import logging -from typing import List, Dict, Any, Optional -from blinker import Signal - - -logger = logging.getLogger(__name__) - - -class Laser: - def __init__(self): - self.max_power: int = 1000 # Max power (0-1000 for GRBL) - self.frame_power: int = 0 # 0 = framing not supported - self.spot_size_mm: tuple[float, float] = 0.1, 0.1 # millimeters - self.changed = Signal() - - def set_max_power(self, power): - self.max_power = power - self.changed.send(self) - - def set_frame_power(self, power): - self.frame_power = power - self.changed.send(self) - - def set_spot_size(self, spot_size_x_mm, spot_size_y_mm): - self.spot_size_mm = spot_size_x_mm, spot_size_y_mm - self.changed.send(self) - - def to_dict(self) -> Dict[str, Any]: - return { - "max_power": self.max_power, - "frame_power": self.frame_power, - "spot_size_mm": self.spot_size_mm, - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Laser': - lh = cls() - lh.max_power = data.get("max_power", lh.max_power) - lh.frame_power = data.get("frame_power", lh.frame_power) - lh.spot_size_mm = data.get("spot_size_mm", lh.spot_size_mm) - return lh - - -class Machine: - def __init__(self): - self.id = str(uuid.uuid4()) - self.name: str = 'Default Machine' - self.driver: str = None - self.driver_args: Dict[str, Any] = {} - self.home_on_start: bool = False - self.preamble: List[str] = ["G21 ; Set units to mm", - "G90 ; Absolute positioning"] - self.postscript: List[str] = ["G0 X0 Y0 ; Return to origin"] - self.air_assist_on = "M8 ; Enable air assist" - self.air_assist_off = "M9 ; Disable air assist" - self.heads: List[Laser] = [] - self._heads_ref_for_pyreverse: Laser - self.max_travel_speed: int = 3000 # in mm/min - self.max_cut_speed: int = 1000 # in mm/min - self.dimensions: tuple[int, int] = 200, 200 - self.changed = Signal() - self.add_head(Laser()) - - def set_driver(self, driver_cls: type, args=None): - self.driver = driver_cls.__name__ - self.driver_args = args or {} - self.changed.send(self) - - def set_driver_args(self, args=None): - self.driver_args = args or {} - self.changed.send(self) - - def set_home_on_start(self, home_on_start: bool = True): - self.home_on_start = home_on_start - self.changed.send(self) - - def set_preamble(self, preamble: List[str]): - self.preamble = preamble - self.changed.send(self) - - def set_postscript(self, postscript: List[str]): - self.postscript = postscript - self.changed.send(self) - - def set_air_assist_on(self, gcode: Optional[str]): - self.air_assist_on = gcode - self.changed.send(self) - - def set_air_assist_off(self, gcode: Optional[str]): - self.air_assist_off = gcode - self.changed.send(self) - - def set_max_travel_speed(self, speed: int): - self.max_travel_speed = speed - self.changed.send(self) - - def set_max_cut_speed(self, speed: int): - self.max_cut_speed = speed - self.changed.send(self) - - def set_dimensions(self, width: int, height: int): - self.dimensions = (width, height) - self.changed.send(self) - - def add_head(self, head: Laser): - self.heads.append(head) - head.changed.connect(self._on_head_changed) - self.changed.send(self) - - def remove_head(self, head: Laser): - head.changed.disconnect(self._on_head_changed) - self.heads.remove(head) - self.changed.send(self) - - def _on_head_changed(self, head, *args): - self.changed.send(self) - - def can_frame(self): - for head in self.heads: - if head.frame_power: - return True - return False - - def to_dict(self) -> Dict[str, Any]: - return { - "machine": { - "name": self.name, - "driver": self.driver, - "driver_args": self.driver_args, - "home_on_start": self.home_on_start, - "dimensions": list(self.dimensions), - "heads": [head.to_dict() for head in self.heads], - "speeds": { - "max_cut_speed": self.max_cut_speed, - "max_travel_speed": self.max_travel_speed, - }, - "gcode": { - "preamble": self.preamble, - "postscript": self.postscript, - "air_assist_on": self.air_assist_on, - "air_assist_off": self.air_assist_off, - }, - } - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Machine': - ma = cls() - ma_data = data.get("machine", {}) - ma.name = ma_data.get("name", ma.name) - ma.driver = ma_data.get("driver") - ma.driver_args = ma_data.get("driver_args", {}) - ma.home_on_start = ma_data.get("home_on_start", ma.home_on_start) - ma.dimensions = tuple(ma_data.get("dimensions", ma.dimensions)) - ma.heads = [] - for obj in ma_data.get("heads", {}): - ma.add_head(Laser.from_dict(obj)) - speeds = ma_data.get("speeds", {}) - ma.max_cut_speed = speeds.get("max_cut_speed", ma.max_cut_speed) - ma.max_travel_speed = speeds.get("max_travel_speed", - ma.max_travel_speed) - gcode = ma_data.get("gcode", {}) - ma.preamble = gcode.get("preamble", ma.preamble) - ma.postscript = gcode.get("postscript", ma.postscript) - ma.air_assist_on = gcode.get("air_assist_on", ma.air_assist_on) - ma.air_assist_off = gcode.get("air_assist_off", ma.air_assist_off) - return ma - - -class MachineManager: - def __init__(self, base_dir): - base_dir.mkdir(parents=True, exist_ok=True) - self.base_dir = base_dir - self.machines: Dict[Machine] = dict() - self._machine_ref_for_pyreverse: Machine - self.load() - - def filename_from_id(self, machine_id: str) -> 'Machine': - return self.base_dir / f"{machine_id}.yaml" - - def add_machine(self, machine): - if machine.id in self.machines: - return - self.machines[machine.id] = machine - machine.changed.connect(self.on_machine_changed) - - def get_machine_by_id(self, machine_id): - return self.machines.get(machine_id) - - def create_default_machine(self): - machine = Machine() - self.add_machine(machine) - self.save_machine(machine) - return machine - - def save_machine(self, machine): - machine_file = self.filename_from_id(machine.id) - with open(machine_file, 'w') as f: - yaml.safe_dump(machine.to_dict(), f) - - def load_machine(self, machine_id: str) -> 'Machine': - machine_file = self.filename_from_id(machine_id) - if not machine_file.exists(): - raise FileNotFoundError(f"Machine file {machine_file} not found") - with open(machine_file, 'r') as f: - data = yaml.safe_load(f) - if not data: - msg = f"skipping invalid machine file {f.name}" - logger.warning(msg) - return None - machine = Machine.from_dict(data) - machine.id = machine_id - self.add_machine(machine) - return machine - - def on_machine_changed(self, machine, **kwargs): - self.save_machine(machine) - - def load(self): - for file in self.base_dir.glob("*.yaml"): - machine = self.load_machine(file.stem) - if machine: - self.add_machine(machine) diff --git a/rayforge/models/ops.py b/rayforge/models/ops.py deleted file mode 100644 index 42caaa933..000000000 --- a/rayforge/models/ops.py +++ /dev/null @@ -1,366 +0,0 @@ -from __future__ import annotations -import math -from copy import copy -from typing import List -from dataclasses import dataclass - - -@dataclass -class State: - power: int = 0 - air_assist: bool = False - cut_speed: int = None - travel_speed: int = None - - def allow_rapid_change(self, target_state): - """ - Returns True if a change to the target state should be allowed - in a rapid manner, i.e. for each gcode instruction. For example, - changing air-assist should not be done too frequently, because - it could damage the air pump. - - Changing the laser power rapidly is unproblematic. - """ - return self.air_assist == target_state.air_assist - - -class Command: - """ - Note that the state attribute is not set by default. It is later - filled during the pre-processing stage, where state commands are - removed. - """ - def __init__(self, end=None, state=None): - self.end: tuple = end # x, y of end position, None for state command - self.state: State = state # Intended state during execution - self._state_ref_for_pyreverse: State - - def __repr__(self): - return f"<{super().__repr__()} {self.__dict__}" - - def apply_to_state(self, state): - pass - - def is_state_command(self): - return False - - def is_cutting_command(self): - """Whether it is a cutting movement""" - return False - - def is_travel_command(self): - """Whether it is a non-cutting movement""" - return False - - -class MoveToCommand(Command): - def is_travel_command(self): - return True - - -class LineToCommand(Command): - def is_cutting_command(self): - return True - - -class ArcToCommand(Command): - def __init__(self, end, center_offset, clockwise): - super().__init__(end) - self.center_offset: tuple = center_offset - self.clockwise: bool = clockwise - - def is_cutting_command(self): - return True - - -class SetPowerCommand(Command): - def __init__(self, power): - super().__init__() - self.power: int = power - - def is_state_command(self): - return True - - def apply_to_state(self, state): - state.power = self.power - - -class SetCutSpeedCommand(Command): - def __init__(self, speed): - super().__init__() - self.speed: int = speed - - def is_state_command(self): - return True - - def apply_to_state(self, state): - state.cut_speed = self.speed - - -class SetTravelSpeedCommand(Command): - def __init__(self, speed): - super().__init__() - self.speed: int = speed - - def is_state_command(self): - return True - - def apply_to_state(self, state): - state.travel_speed = self.speed - - -class EnableAirAssistCommand(Command): - def is_state_command(self): - return True - - def apply_to_state(self, state): - state.air_assist = True - - -class DisableAirAssistCommand(Command): - def is_state_command(self): - return True - - def apply_to_state(self, state): - state.air_assist = False - - -class Ops: - """ - Represents a set of generated path segments and instructions that - are used for making gcode, but also to generate vector graphics - for display. - """ - def __init__(self): - self.commands: List[Command] = [] - self._commands_ref_for_pyreverse: Command - self.last_move_to = 0.0, 0.0 - - def __iter__(self): - return iter(self.commands) - - def __add__(self, ops): - result = Ops() - result.commands = self.commands + ops.commands - return result - - def __mul__(self, count): - result = Ops() - result.commands = count*self.commands - return result - - def __len__(self): - return len(self.commands) - - def preload_state(self): - """ - Walks through all commands, enriching each by the indended - state of the machine. The state is useful for some post-processors - that need to re-order commands without changing the intended - state during each command. - - Returns a list of Command objects. Any state commands are wipe out, - as the state is now included in every operation. - """ - state = State() - for cmd in self.commands: - if cmd.is_state_command(): - cmd.apply_to_state(state) - else: - cmd.state = copy(state) - - def clear(self): - self.commands = [] - - def add(self, command): - self.commands.append(command) - - def move_to(self, x, y): - self.last_move_to = float(x), float(y) - cmd = MoveToCommand(self.last_move_to) - self.commands.append(cmd) - - def line_to(self, x, y): - cmd = LineToCommand((float(x), float(y))) - self.commands.append(cmd) - - def close_path(self): - """ - Convenience method that wraps line_to(). Makes a line to - the last move_to point. - """ - self.line_to(*self.last_move_to) - - def arc_to(self, x, y, i, j, clockwise=True): - """ - Adds an arc command with specified endpoint, center offsets, - and direction (cw/ccw). - """ - self.commands.append(ArcToCommand( - (float(x), float(y)), (float(i), float(j)), bool(clockwise) - )) - - def set_power(self, power: float): - """Laser power (0-1000 for GRBL)""" - cmd = SetPowerCommand(float(power)) - self.commands.append(cmd) - - def set_cut_speed(self, speed: float): - """Cutting speed (mm/min)""" - cmd = SetCutSpeedCommand(float(speed)) - self.commands.append(cmd) - - def set_travel_speed(self, speed: float): - """Rapid movement speed (mm/min)""" - cmd = SetTravelSpeedCommand(float(speed)) - self.commands.append(cmd) - - def enable_air_assist(self, enable=True): - if enable: - self.commands.append(EnableAirAssistCommand()) - else: - self.disable_air_assist() - - def disable_air_assist(self): - self.commands.append(DisableAirAssistCommand()) - - def rect(self): - """ - Returns a rectangle (x1, y1, x2, y2) that encloses the - occupied area. - """ - occupied_points = [] - last_point = None - for cmd in self.commands: - if cmd.is_travel_command(): - last_point = cmd.end - elif cmd.is_cutting_command(): - occupied_points.append(last_point) - occupied_points.append(cmd.end) - last_point = cmd.end - - if not occupied_points: - return 0, 0, 0, 0 - - xs = [p[0] for p in occupied_points] - ys = [p[1] for p in occupied_points] - min_x, max_x = min(xs), max(xs) - min_y, max_y = min(ys), max(ys) - return min_x, min_y, max_x, max_y - - def get_frame(self, power=None, speed=None): - """ - Returns a new Ops object containing four move_to operations forming - a frame around the occupied area of the original Ops. The occupied - area includes all points from line_to and close_path commands. - """ - min_x, min_y, max_x, max_y = self.rect() - if (min_x, min_y, max_x, max_y) == (0, 0, 0, 0): - return Ops() - - frame_ops = Ops() - if power is not None: - frame_ops.set_power(power) - if speed is not None: - frame_ops.set_cut_speed(speed) - frame_ops.move_to(min_x, min_y) - frame_ops.line_to(min_x, max_y) - frame_ops.line_to(max_x, max_y) - frame_ops.line_to(max_x, min_y) - frame_ops.line_to(min_x, min_y) - return frame_ops - - def distance(self): - """ - Calculates the total distance of all moves. Mostly exists to help - debug the optimize() method. - """ - total = 0.0 - - last = None - for cmd in self.commands: - if cmd.is_travel_command(): - if last is not None: - total += math.dist(cmd.end, last) - last = cmd.end - elif cmd.is_cutting_command(): - # treating arcs as lines is probably good enough - if last is not None: - total += math.dist(cmd.end, last) - last = cmd.end - return total - - def cut_distance(self): - """ - Like distance(), but only counts cut distance. - """ - total = 0.0 - - last = None - for cmd in self.commands: - if cmd.is_travel_command(): - last = cmd.end - elif cmd.is_cutting_command(): - # treating arcs as lines is probably good enough - if last is not None: - total += math.dist(cmd.end, last) - last = cmd.end - return total - - def segments(self): - segment = [] - for command in self.commands: - if not segment: - segment.append(command) - continue - - if command.is_travel_command(): - yield segment - segment = [command] - - elif command.is_cutting_command(): - segment.append(command) - - elif command.is_state_command(): - yield segment - yield [command] - segment = [] - - if segment: - yield segment - - def translate(self, dx: float, dy: float) -> Ops: - """Translate geometric commands while preserving relative offsets""" - for cmd in self.commands: - if cmd.end is not None: - # Translate endpoint only. - # Arcs need no offset adjustment needed because - # I/J are relative to start point - x, y = cmd.end - cmd.end = (x + dx, y + dy) - - # Update last known position - last_x, last_y = self.last_move_to - self.last_move_to = (last_x + dx, last_y + dy) - return self - - def scale(self, sx: float, sy: float) -> Ops: - """Scale both absolute positions and relative offsets""" - for cmd in self.commands: - if cmd.end is not None: - x, y = cmd.end - cmd.end = (x * sx, y * sy) - - if isinstance(cmd, ArcToCommand): - # Scale relative offsets - i, j = cmd.center_offset - cmd.center_offset = (i * sx, j * sy) - - # Scale last known position - last_x, last_y = self.last_move_to - self.last_move_to = last_x * sx, last_y * sy - return self - - def dump(self): - for segment in self.segments(): - print(segment) diff --git a/rayforge/models/workpiece.py b/rayforge/models/workpiece.py deleted file mode 100644 index 0305b6b4f..000000000 --- a/rayforge/models/workpiece.py +++ /dev/null @@ -1,82 +0,0 @@ -import cairo -from typing import Optional -from blinker import Signal -from ..config import config -from ..render import Renderer - - -class WorkPiece: - """ - A WorkPiece represents a real world work piece, It is usually - loaded from an image file and serves as input for all other - operations. - """ - def __init__(self, name): - self.name = name - self.data: bytes = None - self.renderer: Optional[Renderer] = None - self._renderer_ref_for_pyreverse: Renderer - self.pos: tuple[float, float] = None, None # in mm - self.size: tuple[float, float] = None, None # in mm - self.surface: cairo.Surface = None - self.changed: Signal = Signal() - self.size_changed: Signal = Signal() - - def set_pos(self, x_mm: float, y_mm: float): - self.pos = float(x_mm), float(y_mm) - self.changed.send(self) - - def set_size(self, width_mm: float, height_mm: float): - self.size = float(width_mm), float(height_mm) - self.changed.send(self) - self.size_changed.send(self) - - def get_default_size(self): - size = self.renderer.get_natural_size(self.data) - if None not in size: - return size - - aspect = self.get_aspect_ratio() - width_mm = config.machine.dimensions[0] - height_mm = width_mm/aspect - if height_mm > config.machine.dimensions[1]: - height_mm = config.machine.dimensions[1] - width_mm = height_mm*aspect - - return width_mm, height_mm - - def get_aspect_ratio(self): - return self.renderer.get_aspect_ratio(self.data) - - @classmethod - def from_file(cls, filename, renderer): - wp = cls(filename) - with open(filename, 'rb') as fp: - wp.data = renderer.prepare(fp.read()) - wp.renderer = renderer - wp.size = wp.get_default_size() - return wp - - def render(self, - pixels_per_mm_x: int, - pixels_per_mm_y: int, - size: tuple[float, float] = None, - force: bool = False): - size = self.get_natural_size() if size is None else size - width = size[0] * pixels_per_mm_x - height = size[1] * pixels_per_mm_y - - if self.surface \ - and self.surface.get_width() == width \ - and self.surface.get_height() == height \ - and not force: - return self.surface, False - - self.surface = self.renderer.render_workpiece(self.data, - width, - height) - - return self.surface, True - - def dump(self, indent=0): - print(" "*indent, self.name, self.renderer.label) diff --git a/rayforge/models/workplan.py b/rayforge/models/workplan.py deleted file mode 100644 index d2e4a04f1..000000000 --- a/rayforge/models/workplan.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations -from typing import List, Dict -from copy import deepcopy -from ..asyncloop import run_async -from ..config import config, getflag -from ..modifier import Modifier, MakeTransparent, ToGrayscale -from ..opsproducer import OpsProducer, OutlineTracer, EdgeTracer, Rasterizer -from ..opstransformer import OpsTransformer, Optimize, Smooth, ArcWeld -from .workpiece import WorkPiece -from .machine import Laser -from .ops import Ops -from blinker import Signal - - -DEBUG_OPTIMIZE = getflag('DEBUG_OPTIMIZE') -DEBUG_SMOOTH = getflag('DEBUG_SMOOTH') -DEBUG_ARCWELD = getflag('DEBUG_ARCWELD') - - -class WorkStep: - """ - A WorkStep is a set of Modifiers that operate on a set of - WorkPieces. It normally generates a Ops in the end, but - may also include modifiers that manipulate the input image. - """ - typelabel = None - - def __init__(self, opsproducer: OpsProducer, name=None): - self.workplan: WorkPlan = None - self.name: str = name or self.typelabel - self.visible: bool = True - self.modifiers: List[Modifier] = [ - MakeTransparent(), - ToGrayscale(), - ] - self._modifier_ref_for_pyreverse: Modifier - self.opsproducer: OpsProducer = opsproducer - self.opstransformers: List[OpsTransformer] = [] - self._opstransformer_ref_for_pyreverse: OpsTransformer - - # Map WorkPieces to Ops and size - self.workpiece_to_ops: Dict[WorkPiece, [Ops, [float, float]]] = {} - self._workpiece_ref_for_pyreverse: WorkPiece - self._ops_ref_for_pyreverse: Ops - - self.passes: int = 1 - self.pixels_per_mm = 25, 25 - self.laser: Laser = None - - self.changed = Signal() - self.ops_changed: Signal = Signal() - self.set_laser(config.machine.heads[0]) - - self.power: int = self.laser.max_power - self.cut_speed: int = config.machine.max_cut_speed - self.travel_speed: int = config.machine.max_travel_speed - self.air_assist: bool = False - - if DEBUG_OPTIMIZE: - self.opstransformers.append(Optimize()) - if DEBUG_SMOOTH: - self.opstransformers.append(Smooth()) - if DEBUG_ARCWELD: - self.opstransformers.append(ArcWeld()) - - def set_passes(self, passes=True): - self.passes = int(passes) - self.changed.send(self) - - def set_visible(self, visible=True): - self.visible = visible - self.changed.send(self) - - def set_laser(self, laser): - if laser == self.laser: - return - if self.laser: - self.laser.changed.disconnect(self._on_laser_changed) - self.laser = laser - laser.changed.connect(self._on_laser_changed) - self.update_all_workpieces() - self.changed.send(self) - - def _on_laser_changed(self, sender, **kwargs): - self.update_all_workpieces() - self.changed.send(self) - - def set_power(self, power): - self.power = power - self.update_all_workpieces() - self.changed.send(self) - - def set_workpieces(self, workpieces: List[WorkPiece]): - for workpiece in list(self.workpiece_to_ops.keys()): - if workpiece in workpieces: - continue - workpiece.size_changed.disconnect(self._on_workpiece_size_changed) - del self.workpiece_to_ops[workpiece] - for workpiece in workpieces: - self.add_workpiece(workpiece) - self.changed.send(self) - - def add_workpiece(self, workpiece: WorkPiece): - if workpiece in self.workpiece_to_ops: - return - self.workpiece_to_ops[workpiece] = None, None - workpiece.size_changed.connect(self._on_workpiece_size_changed) - self.update_workpiece(workpiece) - self.changed.send(self) - - def remove_workpiece(self, workpiece: WorkPiece): - workpiece.size_changed.disconnect(self._on_workpiece_size_changed) - del self.workpiece_to_ops[workpiece] - self.changed.send(self) - - def _on_workpiece_size_changed(self, workpiece): - if not self.can_scale(): - self.update_workpiece(workpiece) - - def workpieces(self): - return self.workpiece_to_ops.keys() - - def execute(self, workpiece) -> [Ops, [float, float]]: - """ - workpiece: the input workpiece to generate Ops for. - """ - if self.can_scale(): - # Size does not matter unless it is so small that rounding - # errors become relevant. So to be able to handle very small - # images gracefull, we just assume a fixed size for the off - # screen rendering. Later it is scaled for display anyway. - size = 50, 50 # in mm - else: # Render at current size in canvas - size = workpiece.size - surface, _ = workpiece.render(*self.pixels_per_mm, - size=size, - force=True) - - # There is no guarantee that the renderer was able to delivered - # the size we asked for. Check the actual size. - width, height = surface.get_width(), surface.get_height() - width_mm = width / self.pixels_per_mm[0] - height_mm = height / self.pixels_per_mm[1] - size = width_mm, height_mm - - ops = Ops() - ops.set_power(self.power) - ops.set_cut_speed(self.cut_speed) - ops.set_travel_speed(self.travel_speed) - ops.enable_air_assist(self.air_assist) - - # Apply bitmap modifiers. - for modifier in self.modifiers: - modifier.run(surface) - - # Produce an Ops object from the resulting surface. - ops += self.opsproducer.run( - config.machine, - self.laser, - surface, - self.pixels_per_mm - ) - - # Apply Ops object transformations. - for transformer in self.opstransformers: - transformer.run(ops) - - ops.disable_air_assist() - self.workpiece_to_ops[workpiece] = ops, size - return ops, size - - async def execute_async(self, workpiece: WorkPiece) -> [ - WorkPiece, Ops, [float, float]]: - ops, size = self.execute(workpiece) - return workpiece, ops, size - - def update_workpiece(self, workpiece): - key = id(self), id(workpiece) - run_async(self.execute_async(workpiece), - self._on_ops_created, - key=key) - - def update_all_workpieces(self): - for workpiece in self.workpiece_to_ops.keys(): - self.update_workpiece() - - def _on_ops_created(self, result): - workpiece, ops, size = result - self.ops_changed.send(self, workpiece=workpiece) - return False - - def get_ops(self, workpiece): - """ - Returns Ops for the given workpiece, scaled to the size of - the workpiece. - Returns None if no Ops were made yet. - """ - ops, size = self.workpiece_to_ops.get(workpiece, (None, None)) - if ops is None: - return None - orig_width_mm, orig_height_mm = size - width_mm, height_mm = workpiece.size - ops = deepcopy(ops) - ops.scale(width_mm/orig_width_mm, height_mm/orig_height_mm) - return ops - - def get_summary(self): - power = int(self.power/self.laser.max_power*100) - speed = int(self.cut_speed) - return f"{power}% power, {speed} mm/min" - - def can_scale(self): - return self.opsproducer.can_scale() - - def dump(self, indent=0): - print(" "*indent, self.name) - for workpiece in self.workpieces: - workpiece.dump(1) - - -class Outline(WorkStep): - typelabel = "External Outline" - - def __init__(self, name=None, **kwargs): - super().__init__(OutlineTracer(), name, **kwargs) - - -class Contour(WorkStep): - typelabel = "Contour" - - def __init__(self, name=None, **kwargs): - super().__init__(EdgeTracer(), name, **kwargs) - - -class Rasterize(WorkStep): - typelabel = "Raster Engrave" - - def __init__(self, name=None, **kwargs): - super().__init__(Rasterizer(), name, **kwargs) - - -class WorkPlan: - """ - Represents a sequence of worksteps. - """ - def __init__(self, doc, name): - self.doc = doc - self.name: str = name - self.worksteps: List[WorkStep] = [] - self._workstep_ref_for_pyreverse: WorkStep - self.changed = Signal() - self.add_workstep(Contour()) - - def __iter__(self): - return iter(self.worksteps) - - def set_workpieces(self, workpieces): - for step in self.worksteps: - step.set_workpieces(workpieces) - - def add_workstep(self, step): - step.workplan = self - self.worksteps.append(step) - step.set_workpieces(self.doc.workpieces) - self.changed.send(self) - - def remove_workstep(self, workstep): - self.worksteps.remove(workstep) - workstep.workplan = None - self.changed.send(self) - - def set_worksteps(self, worksteps): - """ - Replace all worksteps. - """ - self.worksteps = worksteps - for step in worksteps: - step.workplan = self - self.changed.send(self) - - def has_steps(self): - return len(self.worksteps) > 0 - - def execute(self, optimize=True): - ops = Ops() - for step in self.worksteps: - for workpiece in step.workpieces(): - step.execute(workpiece) - step_ops = step.get_ops(workpiece) - x, y = workpiece.pos - ymax = config.machine.dimensions[1] - translate_y = ymax - y - workpiece.size[1] - step_ops.translate(x, translate_y) - if optimize: - Optimize().run(step_ops) - ops += step_ops*step.passes - return ops diff --git a/rayforge/modifier/__init__.py b/rayforge/modifier/__init__.py deleted file mode 100644 index 34588b451..000000000 --- a/rayforge/modifier/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# flake8: noqa:F401 -import inspect -from .colorfilter import KeepColor -from .grayscale import ToGrayscale -from .modifier import Modifier -from .transparency import MakeTransparent - -modifier_by_name = dict( - [(name, obj) for name, obj in list(locals().items()) - if not name.startswith('_') or inspect.ismodule(obj)] -) diff --git a/rayforge/modifier/colorfilter.py b/rayforge/modifier/colorfilter.py deleted file mode 100644 index 697cb4f0c..000000000 --- a/rayforge/modifier/colorfilter.py +++ /dev/null @@ -1,42 +0,0 @@ -import cairo -import numpy as np -from .modifier import Modifier - - -def make_transparent_except_color(surface, target_r, target_g, target_b): - if surface.get_format() != cairo.FORMAT_ARGB32: - raise ValueError("Surface must be in ARGB32 format.") - - width, height = surface.get_width(), surface.get_height() - stride = surface.get_stride() - - # Get pixel data as a NumPy array - data = surface.get_data() - buf = np.frombuffer(data, dtype=np.uint8).reshape((height, stride)) - - # Convert to 32-bit ARGB view - argb = buf.view(dtype=np.uint32)[:, :width] - - # Extract color channels - r = (argb >> 16) & 0xFF - g = (argb >> 8) & 0xFF - b_channel = argb & 0xFF - - # Create mask for pixels not matching the target color - mask = ~((r == target_r) & (g == target_g) & (b_channel == target_b)) - - # Set alpha to 0 for non-matching pixels - argb[mask] = ((0x00 << 24) | (r[mask] << 16) | - (g[mask] << 8) | b_channel[mask]) - - -class KeepColor(Modifier): - """ - Makes everything except for a selected RGB color transparent. - """ - def __init__(self, r, g, b): - super().__init__() - self.color = r, g, b - - def run(self, surface): - make_transparent_except_color(surface, *self.color) diff --git a/rayforge/modifier/grayscale.py b/rayforge/modifier/grayscale.py deleted file mode 100644 index f6fd4f6d0..000000000 --- a/rayforge/modifier/grayscale.py +++ /dev/null @@ -1,10 +0,0 @@ -from ..util.cairoutil import convert_surface_to_grayscale -from .modifier import Modifier - - -class ToGrayscale(Modifier): - """ - Removes colors from input surface. - """ - def run(self, surface): - convert_surface_to_grayscale(surface) diff --git a/rayforge/modifier/modifier.py b/rayforge/modifier/modifier.py deleted file mode 100644 index a3f019126..000000000 --- a/rayforge/modifier/modifier.py +++ /dev/null @@ -1,16 +0,0 @@ -class Modifier: - """ - Modifies a Cairo surface. - """ - def __init__(self): - self.label = self.__class__.__name__ - - def run(self, surface): - """ - - workstep: the WorkStep that the process is a part of - - surface: an input surface. Can be manipulated in-place, - or alternatively a new surface may be returned. - - pixels_per_mm: tuple: pixels_per_mm_x, pixels_per_mm_y - - ymax: machine max in y direction - """ - pass diff --git a/rayforge/modifier/transparency.py b/rayforge/modifier/transparency.py deleted file mode 100644 index 55c3b4e55..000000000 --- a/rayforge/modifier/transparency.py +++ /dev/null @@ -1,10 +0,0 @@ -from ..util.cairoutil import make_transparent -from .modifier import Modifier - - -class MakeTransparent(Modifier): - """ - Makes white pixels transparent. - """ - def run(self, surface): - make_transparent(surface) diff --git a/rayforge/opsencoder/cairoencoder.py b/rayforge/opsencoder/cairoencoder.py deleted file mode 100644 index 9c03a4280..000000000 --- a/rayforge/opsencoder/cairoencoder.py +++ /dev/null @@ -1,102 +0,0 @@ -import math -import cairo -from ..config import getflag -from ..models.ops import Ops, MoveToCommand, LineToCommand, ArcToCommand -from ..models.machine import Machine -from .encoder import OpsEncoder - - -SHOW_TRAVEL_MOVES = getflag('SHOW_TRAVEL_MOVES') - - -class CairoEncoder(OpsEncoder): - """ - Encodes a Ops onto a Cairo surface, respecting embedded state commands - (color, geometry) and machine dimensions for coordinate adjustments. - """ - def encode(self, - ops: Ops, - machine: Machine, - surface: cairo.Surface, - scale: tuple[float, float]) -> None: - # Set up Cairo context and scaling - ctx = cairo.Context(surface) - ctx.set_source_rgb(1, 0, 1) - - # Calculate scaling factors from surface and machine dimensions - # The Ops are in machine coordinates, i.e. zero point - # at the bottom left, and units are mm. - # Since Cairo coordinates put the zero point at the top left, we must - # subtract Y from the machine's Y axis maximum. - scale_x, scale_y = scale - ymax = surface.get_height()/scale_y # For Y-axis inversion - - # Apply coordinate scaling and line width - ctx.scale(scale_x, scale_y) - ctx.set_hairline(True) - ctx.move_to(0, ymax) - - prev_point = 0, ymax - for segment in ops.segments(): - for cmd in segment: - match cmd, cmd.end: - case MoveToCommand(), (x, y): - adjusted_y = ymax - y - - # Paint the travel move. We do not have to worry that - # there may be any unpainted path before it, because - # Ops.segments() ensures that each travel move opens - # a new segment. - if SHOW_TRAVEL_MOVES: - ctx.set_source_rgb(.8, .8, .8) - ctx.move_to(*prev_point) - ctx.line_to(x, adjusted_y) - ctx.stroke() - - ctx.move_to(x, adjusted_y) - - case LineToCommand(), (x, y): - adjusted_y = ymax-y - ctx.line_to(x, adjusted_y) - prev_point = x, adjusted_y - - case ArcToCommand(), (x, y): - # Start point is the x, y of the previous operation. - start_x, start_y = ctx.get_current_point() - ctx.set_source_rgb(1, 0, 1) - ctx.stroke() - - # Draw the arc in the correct direction - # x, y: absolute values - # i, j: relative pos of arc center from start point. - i, j = cmd.center_offset - center_x = start_x+i - center_y = start_y+j - adjusted_y = ymax-y - radius = math.dist((start_x, start_y), - (center_x, center_y)) - angle1 = math.atan2(start_y - center_y, - start_x - center_x) - angle2 = math.atan2(adjusted_y - center_y, - x - center_x) - if cmd.clockwise: - ctx.arc(center_x, center_y, radius, angle1, angle2) - else: - ctx.arc_negative( - center_x, - center_y, - radius, - angle1, - angle2 - ) - ctx.set_source_rgb(0, 0, 1) - ctx.stroke() - ctx.move_to(x, adjusted_y) - prev_point = x, adjusted_y - - case _: - pass # ignore unsupported operations - - # Draw the segment. - ctx.set_source_rgb(1, 0, 1) - ctx.stroke() diff --git a/rayforge/opsencoder/encoder.py b/rayforge/opsencoder/encoder.py deleted file mode 100644 index 701c8cbcb..000000000 --- a/rayforge/opsencoder/encoder.py +++ /dev/null @@ -1,16 +0,0 @@ -from abc import ABC, abstractmethod -from ..models.ops import Ops -from ..models.machine import Machine - - -class OpsEncoder(ABC): - """ - Transforms an Ops object into something else. - Examples: - - - Ops to image (a cairo surface) - - Ops to a G-code string - """ - @abstractmethod - def encode(self, pos: Ops, machine: Machine) -> object: - pass diff --git a/rayforge/opsencoder/gcode.py b/rayforge/opsencoder/gcode.py deleted file mode 100644 index 6aa305687..000000000 --- a/rayforge/opsencoder/gcode.py +++ /dev/null @@ -1,125 +0,0 @@ -from ..models.ops import Ops, \ - Command, \ - SetPowerCommand, \ - SetCutSpeedCommand, \ - SetTravelSpeedCommand, \ - EnableAirAssistCommand, \ - DisableAirAssistCommand, \ - MoveToCommand, \ - LineToCommand, \ - ArcToCommand -from ..models.machine import Machine -from .encoder import OpsEncoder - - -class GcodeEncoder(OpsEncoder): - """Converts Ops commands to G-code using instance state tracking""" - def __init__(self): - self.power = None # Current laser power (None = off) - self.cut_speed = None # Current cutting speed (mm/min) - self.travel_speed = None # Current travel speed (mm/min) - self.air_assist = False # Air assist state - self.laser_active = False # Laser on/off state - - def encode(self, ops: Ops, machine: Machine) -> str: - """Main encoding workflow""" - gcode = []+machine.preamble - for cmd in ops: - self._handle_command(gcode, cmd, machine) - self._finalize(gcode, machine) - return '\n'.join(gcode) - - def _handle_command(self, gcode: list, cmd: Command, machine: Machine): - """Dispatch command to appropriate handler""" - match cmd: - case SetPowerCommand(): - self._update_power(gcode, cmd.power, machine) - case SetCutSpeedCommand(): - # We limit to max travel speed, not max cut speed, to - # allow framing operations to go faster. Cut limits should - # should be kept by ensuring an Ops object is created - # with limits in mind. - self.cut_speed = min(cmd.speed, machine.max_travel_speed) - case SetTravelSpeedCommand(): - self.travel_speed = min(cmd.speed, machine.max_travel_speed) - case EnableAirAssistCommand(): - self._set_air_assist(gcode, True, machine) - case DisableAirAssistCommand(): - self._set_air_assist(gcode, False, machine) - case MoveToCommand(): - self._handle_move_to(gcode, *cmd.end) - case LineToCommand(): - self._handle_line_to(gcode, *cmd.end) - case ArcToCommand(): - self._handle_arc_to(gcode, - *cmd.end, - *cmd.center_offset, - cmd.clockwise) - - def _update_power(self, gcode: list, power: float, machine: Machine): - """Update laser power and toggle state if needed""" - self.power = min(power, machine.heads[0].max_power) - if self.laser_active and self.power <= 0: - self._laser_off(gcode) - elif not self.laser_active and self.power > 0: - self._laser_on(gcode) - - def _set_air_assist(self, gcode: list, state: bool, machine: Machine): - """Update air assist state with machine commands""" - if self.air_assist == state: - return - self.air_assist = state - cmd = machine.air_assist_on if state else machine.air_assist_off - if cmd: - gcode.append(cmd) - - def _handle_move_to(self, gcode: list, x: float, y: float): - """Rapid movement with laser safety""" - self._laser_off(gcode) - cmd = f"G0 X{x:.3f} Y{y:.3f}" - if self.travel_speed: - cmd += f" F{self.travel_speed}" - gcode.append(cmd) - - def _handle_line_to(self, gcode: list, x: float, y: float): - """Cutting movement with laser activation""" - self._laser_on(gcode) - cmd = f"G1 X{x:.3f} Y{y:.3f}" - if self.cut_speed: - cmd += f" F{self.cut_speed}" - gcode.append(cmd) - - def _handle_arc_to(self, - gcode: list, - x: float, - y: float, - i: float, - j: float, - clockwise: bool): - """Cutting movement with laser activation""" - self._laser_on(gcode) - cmd = "G2" if clockwise else "G3" - cmd += f" X{x:.3f} Y{y:.3f} I{i:.3f} J{j:.3f}" - if self.cut_speed: - cmd += f" F{self.cut_speed}" - gcode.append(cmd) - - def _laser_on(self, gcode: list): - """Activate laser if not already on""" - if not self.laser_active and self.power: - gcode.append(f"M4 S{self.power:.0f}") - self.laser_active = True - - def _laser_off(self, gcode: list): - """Deactivate laser if active""" - if self.laser_active: - gcode.append("M5") - self.laser_active = False - - def _finalize(self, gcode: list, machine: Machine): - """Cleanup at end of file""" - self._laser_off(gcode) - if self.air_assist: - gcode.append(machine.air_assist_off or "") - gcode.extend(machine.postscript) - gcode.append('') diff --git a/rayforge/opsproducer/__init__.py b/rayforge/opsproducer/__init__.py deleted file mode 100644 index 39e596fd0..000000000 --- a/rayforge/opsproducer/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# flake8: noqa:F401 -import inspect -from .producer import OpsProducer -from .outline import OutlineTracer, EdgeTracer -from .rasterize import Rasterizer - -producer_by_name = dict( - [(name, obj) for name, obj in list(locals().items()) - if not name.startswith('_') or inspect.ismodule(obj)] -) diff --git a/rayforge/opsproducer/outline.py b/rayforge/opsproducer/outline.py deleted file mode 100644 index 6ce23aa53..000000000 --- a/rayforge/opsproducer/outline.py +++ /dev/null @@ -1,96 +0,0 @@ -import cairo -import numpy as np -import cv2 -from ..models.ops import Ops -from .producer import OpsProducer - - -def prepare_surface_for_tracing(surface): - # Get the surface format - surface_format = surface.get_format() - - # Determine the number of channels based on the format - if surface_format == cairo.FORMAT_ARGB32: - channels = 4 # ARGB or RGBA - target_fmt = cv2.COLOR_BGRA2GRAY - elif surface_format == cairo.FORMAT_RGB24: - channels = 3 # RGB - target_fmt = cv2.COLOR_BGR2GRAY - else: - raise ValueError("Unsupported Cairo surface format") - - # Make a copy of the image. - width, height = surface.get_width(), surface.get_height() - buf = surface.get_data() - img = np.frombuffer(buf, dtype=np.uint8) - img = img.reshape(height, width, channels).copy() - - # Replace transparent pixels with white - if channels == 4: - alpha = img[:, :, 3] # Extract the alpha channel - img[alpha == 0] = 255, 255, 255, 255 - - # Convert to binary image (thresholding) - return cv2.cvtColor(img, target_fmt) - - -def contours2ops(contours, pixels_per_mm, ymax): - """ - The resulting Ops needs to be in machine coordinates, i.e. zero - point must be at the bottom left, and units need to be mm. - Since Cairo coordinates put the zero point at the top left, we must - subtract Y from the machine's Y axis maximum. - """ - ops = Ops() - scale_x, scale_y = pixels_per_mm - for contour in contours: - # Smooth contour - peri = cv2.arcLength(contour, True) - contour = cv2.approxPolyDP(contour, 0.00015*peri, True) - - # Append (scaled to mm) - if len(contour) > 0: - ops.move_to(contour[0][0][0]/scale_x, - ymax-contour[0][0][1]/scale_y) - for point in contour: - x, y = point[0] - ops.line_to(x/scale_x, ymax-y/scale_y) - ops.close_path() - return ops - - -class OutlineTracer(OpsProducer): - """ - Find external outlines for laser cutting. - """ - def run(self, machine, laser, surface, pixels_per_mm): - # Find contours of the black areas - binary = prepare_surface_for_tracing(surface) - _, binary = cv2.threshold(binary, 10, 255, cv2.THRESH_BINARY_INV) - contours, _ = cv2.findContours(binary, - cv2.RETR_EXTERNAL, - cv2.CHAIN_APPROX_NONE) - ymax = surface.get_height()/pixels_per_mm[1] - return contours2ops(contours, pixels_per_mm, ymax) - - -class EdgeTracer(OpsProducer): - """ - Find all edges (including holes) for laser cutting. - """ - def run(self, machine, laser, surface, pixels_per_mm): - binary = prepare_surface_for_tracing(surface) - binary = cv2.GaussianBlur(binary, (5, 5), 0) - binary = cv2.morphologyEx( - binary, - cv2.MORPH_CLOSE, - np.ones((3, 3), np.uint8) - ) - - # Retrieve all contours (including holes) - edges = cv2.Canny(binary, 10, 250) - contours, _ = cv2.findContours(edges, - cv2.RETR_LIST, - cv2.CHAIN_APPROX_NONE) - ymax = surface.get_height()/pixels_per_mm[1] - return contours2ops(contours, pixels_per_mm, ymax) diff --git a/rayforge/opsproducer/producer.py b/rayforge/opsproducer/producer.py deleted file mode 100644 index 8f580b7b5..000000000 --- a/rayforge/opsproducer/producer.py +++ /dev/null @@ -1,23 +0,0 @@ -from abc import ABC, abstractmethod -from ..models.ops import Ops - - -class OpsProducer(ABC): - """ - Given a Cairo surface, an OpsProducer outputs an Ops object. - Examples may include: - - - Tracing a bitmap to produce a path (Ops object). - - Reading vector data from an image to turn it into Ops. - """ - @abstractmethod - def run(self, machine, laser, surface, pixels_per_mm) -> Ops: - pass - - def can_scale(self) -> bool: - """ - Returns True if the produced Ops object is scalable. This allows - the consumer to cache the Ops object more often, as it does not - need to be re-made just because the input image was resized. - """ - return True diff --git a/rayforge/opsproducer/rasterize.py b/rayforge/opsproducer/rasterize.py deleted file mode 100644 index 5e4ea2ae8..000000000 --- a/rayforge/opsproducer/rasterize.py +++ /dev/null @@ -1,127 +0,0 @@ -import cairo -import numpy as np -from ..models.ops import Ops -from .producer import OpsProducer - - -def rasterize_horizontally(surface, - ymax, - pixels_per_mm=10, - raster_size_mm=0.1): - """ - Generate an engraving path for a Cairo surface, focusing on horizontal - movement. - - Args: - surface: A Cairo surface containing a black and white image. - pixels_per_mm: Resolution of the image in pixels per millimeter. - raster_size_mm: Distance between horizontal engraving lines in - millimeters. - - Returns: - A Ops object containing the optimized engraving path. - """ - surface_format = surface.get_format() - if surface_format != cairo.FORMAT_ARGB32: - raise ValueError("Unsupported Cairo surface format") - - # Convert surface to a NumPy array - width = surface.get_width() - height = surface.get_height() - data = np.frombuffer(surface.get_data(), dtype=np.uint8) - data = data.reshape((height, width, 4)) - - # Extract BGRA channels - blue = data[:, :, 0] # Blue channel - green = data[:, :, 1] # Green channel - red = data[:, :, 2] # Red channel - alpha = data[:, :, 3] # Alpha channel - - # Convert to grayscale (weighted average of RGB channels) - bw_image = 0.2989 * red + 0.5870 * green + 0.1140 * blue - - # Threshold to black and white - bw_image = (bw_image < 128).astype(np.uint8) - - # Optionally handle transparency (e.g., treat fully transparent - # pixels as white) - bw_image[alpha == 0] = 0 # Set fully transparent pixels to white (0) - - # Find the bounding box of the occupied area - occupied_rows = np.any(bw_image, axis=1) - occupied_cols = np.any(bw_image, axis=0) - - if not np.any(occupied_rows) or not np.any(occupied_cols): - return Ops() # No occupied area, return an empty path - - y_min, y_max = np.where(occupied_rows)[0][[0, -1]] - x_min, x_max = np.where(occupied_cols)[0][[0, -1]] - - # Calculate dimensions in millimeters - pixels_per_mm_x, pixels_per_mm_y = pixels_per_mm - - # Convert bounding box to millimeters - x_min_mm = x_min / pixels_per_mm_x - y_min_mm = y_min / pixels_per_mm_y - y_max_mm = y_max / pixels_per_mm_y - - ops = Ops() - - # Iterate over rows in millimeters (floating-point) within the bounding box - y_mm = y_min_mm - while y_mm <= y_max_mm: - # Convert y_mm to pixel coordinates (floating-point) - y_px = y_mm * pixels_per_mm_y - - # Interpolate between the two nearest rows for Y direction - y1 = int(np.floor(y_px)) - y2 = int(np.ceil(y_px)) - if y2 >= height: - y2 = height - 1 - - # Blend the two rows if y1 != y2 - if y1 == y2: - row = bw_image[y1, x_min:x_max + 1] - else: - alpha_y = y_px - y1 - row = (1 - alpha_y) * bw_image[y1, x_min:x_max + 1] \ - + alpha_y * bw_image[y2, x_min:x_max + 1] - row = (row > 0.5).astype(np.uint8) # Threshold the blended row - - # Find the start and end of black segments in the current row - black_segments = np.where(np.diff( - np.hstack(([0], row, [0])) - ))[0].reshape(-1, 2) - for start, end in black_segments: - if row[start] == 1: # Only process black segments - # Convert segment start and end to millimeters - start_mm = x_min_mm + (start / pixels_per_mm_x) - end_mm = x_min_mm + ((end - 1) / pixels_per_mm_x) - - # Move to the start of the black segment - ops.move_to(start_mm, ymax-y_mm) - # Draw a line to the end of the black segment - ops.line_to(end_mm, ymax-y_mm) - - # Move to the next raster line - y_mm += raster_size_mm - - return ops - - -class Rasterizer(OpsProducer): - """ - Generates rastered movements (using only straight lines) - across filled pixels in the surface. - """ - def run(self, machine, laser, surface, pixels_per_mm): - ymax = surface.get_height()/pixels_per_mm[1] - return rasterize_horizontally( - surface, - ymax, # y max for axis inversion - pixels_per_mm, - laser.spot_size_mm[1] - ) - - def can_scale(self) -> bool: - return False diff --git a/rayforge/opstransformer/__init__.py b/rayforge/opstransformer/__init__.py deleted file mode 100644 index e68d4905d..000000000 --- a/rayforge/opstransformer/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# flake8: noqa:F401 -import inspect -from .transformer import OpsTransformer -from .arcwelder import ArcWeld -from .optimize import Optimize -from .smooth import Smooth - -transformer_by_name = dict( - [(name, obj) for name, obj in list(locals().items()) - if not name.startswith('_') or inspect.ismodule(obj)] -) diff --git a/rayforge/opstransformer/arcwelder/__init__.py b/rayforge/opstransformer/arcwelder/__init__.py deleted file mode 100644 index 537463035..000000000 --- a/rayforge/opstransformer/arcwelder/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# flake8: noqa:F401 -from .arcwelder import ArcWeld diff --git a/rayforge/opstransformer/arcwelder/arcwelder.py b/rayforge/opstransformer/arcwelder/arcwelder.py deleted file mode 100644 index f6fd69a00..000000000 --- a/rayforge/opstransformer/arcwelder/arcwelder.py +++ /dev/null @@ -1,205 +0,0 @@ -import math -from ...models.ops import Ops, \ - LineToCommand, \ - ArcToCommand, \ - MoveToCommand -from ..transformer import OpsTransformer -from .points import remove_duplicates, \ - are_colinear, \ - arc_direction, \ - fit_circle, \ - arc_to_polyline_deviation - - -def contains_command(segment, cmdcls): - return any(isinstance(cmd, cmdcls) for cmd in segment) - - -def split_into_segments(commands): - """ - Splits commands into logical segments while tracking current position. - - Segments with arc_to are preceded by explicit or implicit move_to. - - State commands are standalone segments. - """ - segments = [] - current_segment = [] - current_pos = None # Track current position - - for cmd in commands: - if cmd.is_travel_command(): - # Start new segment - if current_segment: - segments.append(current_segment) - current_segment = [cmd] - current_pos = cmd.end - - elif isinstance(cmd, ArcToCommand): - # Start new segment - if contains_command(current_segment, LineToCommand): - segments.append(current_segment) - current_segment = [cmd] - else: - current_segment.append(cmd) - current_pos = cmd.end - - elif isinstance(cmd, LineToCommand): - # Add to current segment and track position - if contains_command(current_segment, ArcToCommand): - segments.append(current_segment) - current_segment = [] - if not current_segment: - if current_pos is None: - raise ValueError("line_to requires a starting position") - current_segment.append(MoveToCommand(current_pos)) - current_segment.append(cmd) - current_pos = cmd.end - - elif cmd.is_state_command(): - # All other commands are standalone - if current_segment: - segments.append(current_segment) - current_segment = [] - segments.append([cmd]) - - else: - raise ValueError(f"Unsupported command: {cmd}") - - if current_segment: - segments.append(current_segment) - - return segments - - -class ArcWeld(OpsTransformer): - """ - Converts line sequences into arcs using pre-validated geometric utilities. - - tolerance: Max allowed deviation from arc - min_points: Minimum number of points to attempt arc fitting - max_points: Maximum number of points to attempt arc fitting - max_angular_step: Max angle between points on the arc - """ - def __init__(self, - tolerance=0.049, - min_points=6, - max_points=15, - max_angular_step=75): - self.tolerance = tolerance - self.min_points = min_points - self.max_points = max_points - self.max_step = math.radians(max_angular_step) - - def run(self, ops: Ops): - segments = split_into_segments(ops.commands) - ops.clear() - - for segment in segments: - if contains_command(segment, LineToCommand): - self.process_segment([cmd.end for cmd in segment], ops) - else: - for command in segment: - ops.add(command) - - def process_segment(self, segment, ops): - if not segment: - return - - # Bail out early for short segments. - segment = remove_duplicates(segment) - length = len(segment) - if length < self.min_points: - ops.move_to(*segment[0]) - for point in segment[1:]: - ops.line_to(*point) - return - - # Walk along the segment trying to find arcs that may fit. - ops.move_to(*segment[0]) - index = 1 - while index < length: - # Consume colinear points first - colinear_points = self._count_colinear_points(segment, index-1) - - if colinear_points: - ops.line_to(*segment[index+colinear_points-2]) - index += colinear_points - continue - - # Try to find an arc that fits the points starting at index. - # fit_segment already performs a fast deviation calculation, - # but it only checks deviation from original points and not - # from the lines that connect the points. - arc, arc_end = self._find_longest_valid_arc(segment, index-1) - if arc: - # Perform better, but more expensive, deviation calculation. - deviation = arc_to_polyline_deviation(segment[index-1:arc_end], - *arc[:2]) - if deviation <= self.tolerance: - self._add_arc_command(segment, index-1, arc_end, arc, ops) - index = arc_end # Move to the last point of the arc - continue - - # Ending up here, no fitting arc was found at the current index. - ops.line_to(*segment[index]) - index += 1 - - def _count_colinear_points(self, segment, start): - """Advance index past colinear points, returning the end index.""" - length = len(segment) - if length-start < 3: - return 0 - - end = start+3 - found = None - while end < length and are_colinear(segment[start:end+1]): - end += 1 - found = end-start - - return found - - def _add_arc_command(self, segment, start, end, arc, ops): - center, radius, _ = arc - start_point = segment[start] - end_point = segment[end-1] - - # Calculate I and J offsets - i = center[0] - start_point[0] - j = start_point[1] - center[1] # Inverted Y-axis - - clockwise = arc_direction(segment[start:end], center) - - ops.arc_to(end_point[0], end_point[1], i, j, clockwise) - - def _find_longest_valid_arc(self, segment, start_index): - max_search = min(len(segment)-start_index, self.max_points) - - for length in range(max_search, self.min_points-1, -1): - end = start_index+length - assert end - start_index >= self.min_points - subsegment = segment[start_index:end] - arc = fit_circle(subsegment) - if self._is_valid_arc(subsegment, arc): - return arc, end - - return None, start_index - - def _is_valid_arc(self, subsegment, arc): - if arc is None: - return False - center, radius, error = arc - if error > self.tolerance or radius < 1 or radius > 100: - return False - - # Angular continuity checks - prev_angle = None - for x, y in subsegment: - dx = x - center[0] - dy = y - center[1] - angle = math.atan2(dy, dx) - if prev_angle is not None: - delta = abs(angle - prev_angle) - delta = min(delta, 2 * math.pi - delta) - if delta > self.max_step: - return False - prev_angle = angle - return True diff --git a/rayforge/opstransformer/arcwelder/points.py b/rayforge/opstransformer/arcwelder/points.py deleted file mode 100644 index 1bdd88763..000000000 --- a/rayforge/opstransformer/arcwelder/points.py +++ /dev/null @@ -1,173 +0,0 @@ -import math -import numpy as np -from scipy.optimize import least_squares -from itertools import groupby - - -def remove_duplicates(segment): - """ - Removes *consecutive* duplicates from a list of points. - """ - return [k for (k, v) in groupby(segment)] - - -def are_colinear(points, tolerance=0.01): - """ - Check if all points are colinear within a given tolerance. - - Args: - points: List of (x, y) tuples. - tolerance: Max perpendicular distance from the line (default 0.01). - - Returns: - bool: True if all points are colinear within tolerance. - """ - if len(points) < 2: - return True # Fewer than 2 points are trivially colinear - if len(points) == 2: - return True # Two points define a line - - # Define line by first and last points - p1, p2 = points[0], points[-1] - dx = p2[0] - p1[0] - dy = p2[1] - p1[1] - line_length = math.hypot(dx, dy) - - if line_length == 0: - # Points are coincident, check if all are within tolerance - return all(math.hypot(p[0] - p1[0], p[1] - p1[1]) < tolerance - for p in points) - - # Check perpendicular distance of each point to the line p1-p2 - for p in points[1:-1]: # Skip endpoints as they define the line - # Vector from p1 to p - vx = p[0] - p1[0] - vy = p[1] - p1[1] - # Perpendicular distance = |ax + by + c| / sqrt(a^2 + b^2) - # Line equation: ax + by + c = 0, where - # a=dy, b=-dx, c=-(dy*p1x - dx*p1y) - dist = abs(dy * vx - dx * vy) / line_length - if dist > tolerance: - return False - return True - - -def is_clockwise(points): - """ - Determines direction using cross product. - """ - if len(points) < 3: - return False - - p1, p2, p3 = points[0], points[1], points[2] - cross = ((p2[0]-p1[0])*(p3[1]-p2[1]) - - (p2[1]-p1[1])*(p3[0]-p2[0])) - return cross < 0 - - -def arc_direction(points, center): - xc, yc = center - cross_sum = 0.0 - for i in range(len(points) - 1): - x0, y0 = points[i] - x1, y1 = points[i + 1] - dx0 = x0 - xc - dy0 = y0 - yc - dx1 = x1 - xc - dy1 = y1 - yc - cross = dx0 * dy1 - dy0 * dx1 - cross_sum += cross - return cross_sum < 0 # True for clockwise - - -def fit_circle(points): - """ - Fit a circle to points, return (center, radius, error) or None. - Error is max of point-to-arc deviation. - """ - if len(points) < 3 or are_colinear(points): - return None - - x = np.array([p[0] for p in points]) - y = np.array([p[1] for p in points]) - - # Initial guess: mean center and average radius - x0, y0 = np.mean(x), np.mean(y) - r0 = np.mean(np.sqrt((x-x0)**2 + (y-y0)**2)) - - # Fit circle using least squares - result = least_squares( - lambda p: np.sqrt((x-p[0])**2 + (y-p[1])**2) - p[2], - [x0, y0, r0], - method='lm' - ) - xc, yc, r = result.x - center = (xc, yc) - - # Point-to-arc error: max deviation of points from circle - distances = np.sqrt((x-xc)**2 + (y-yc)**2) - point_error = np.max(np.abs(distances - r)) - - # Total error: max of point fit and arc deviation - return center, r, point_error - - -def arc_to_polyline_deviation(points, center, radius): - """ - Compute max deviation of an arc from the original polyline. - Args: - points: List of (x, y) tuples forming the polyline. - center: (xc, yc) tuple, center of the fitted circle. - radius: Radius of the fitted circle. - Returns: - float: Max perpendicular distance from arc to polyline segments. - """ - if len(points) < 2: - return 0.0 - xc, yc = center - max_deviation = 0.0 - - for i in range(len(points) - 1): - p1, p2 = points[i], points[i + 1] - x1, y1 = p1 - x2, y2 = p2 - dx = x2 - x1 - dy = y2 - y1 - segment_length = math.hypot(dx, dy) - - if segment_length == 0: - distance = math.hypot(x1 - xc, y1 - yc) - deviation = abs(distance - radius) - max_deviation = max(max_deviation, deviation) - continue - - # Distances from center to endpoints - d1 = math.hypot(x1 - xc, y1 - yc) - d2 = math.hypot(x2 - xc, y2 - yc) - - # If segment exceeds diameter, use endpoint deviations - if segment_length > 2 * radius: - deviation = max(abs(d1 - radius), abs(d2 - radius)) - else: - # Vectors from center to points - v1x, v1y = x1 - xc, y1 - yc - v2x, v2y = x2 - xc, y2 - yc - - # Dot product to find angle - dot = v1x * v2x + v1y * v2y - mag1 = math.hypot(v1x, v1y) - mag2 = math.hypot(v2x, v2y) - if mag1 < 1e-6 or mag2 < 1e-6: - deviation = abs(d1 - radius) if mag1 < 1e-6 \ - else abs(d2 - radius) - else: - cos_theta = min(1.0, max(-1.0, dot / (mag1 * mag2))) - theta = math.acos(cos_theta) - # Sagitta based on actual arc angle - sagitta = radius * (1 - math.cos(theta / 2)) - # Endpoint deviations if off-arc - endpoint_dev = max(abs(d1 - radius), abs(d2 - radius)) - deviation = max(sagitta, endpoint_dev) - - max_deviation = max(max_deviation, deviation) - return max_deviation diff --git a/rayforge/opstransformer/optimize.py b/rayforge/opstransformer/optimize.py deleted file mode 100644 index 17a3c7c9b..000000000 --- a/rayforge/opstransformer/optimize.py +++ /dev/null @@ -1,304 +0,0 @@ -import numpy as np -import math -from copy import copy -from ..models.ops import Ops, State, ArcToCommand -from .transformer import OpsTransformer - - -def split_long_segments(operations): - """ - Split a list of operations such that segments where air assist - is enabled are separated from segments where it is not. We - need this because these segments must remain in order, - so we need to separate them and run the path optimizer on - each segment individually. - - The result is a list of Command lists. - """ - if len(operations) <= 1: - return [operations] - - segments = [[operations[0]]] - last_state = operations[0].state - for op in operations: - if last_state.allow_rapid_change(op.state): - segments[-1].append(op) - else: - # If rapid state change is not allowed, add - # it to a new long segment. - segments.append([op]) - return segments - - -def split_segments(commands): - """ - Split a list of commands into segments. We use it to prepare - for reordering the segments for travel distance minimization. - - Returns a list of segments. In other words, a list of list[Command]. - """ - segments = [] - current_segment = [] - for cmd in commands: - if cmd.is_travel_command(): - if current_segment: - segments.append(current_segment) - current_segment = [cmd] - elif cmd.is_cutting_command(): - current_segment.append(cmd) - else: - raise ValueError(f'unexpected Command {cmd}') - - if current_segment: - segments.append(current_segment) - return segments - - -def flip_segment(segment): - """ - The states attached to each point descibe the intended - machine state while traveling TO the point. - - Example: - state: A B C D - points: -> move_to 1 -> line_to 2 -> arc_to 3 -> line_to 4 - - After flipping this sequence, the state is in the wrong position: - - state: D C B A - points: -> line_to 4 -> arc_to 3 -> line_to 2 -> move_to 1 - - Note that for example the edge between point 3 and 2 no longer has - state C, it is B instead. 4 -> 3 should be D, but is C. - So we have to shift the state and the command to the next point. - Correct: - - state: A D C B - points: -> move_to 4 -> line_to 3 -> arc_to 2 -> line_to 1 - """ - length = len(segment) - if length <= 1: - return segment - - new_segment = [] - for i in range(length-1, -1, -1): - cmd = segment[i] - prev_cmd = segment[(i+1) % length] - new_cmd = copy(prev_cmd) - new_cmd.end = cmd.end - - # Fix arc_to parameters - if isinstance(new_cmd, ArcToCommand) and i > 0: - # Get original arc (prev op in original segment) - orig_cmd = segment[i+1] - x_end, y_end = orig_cmd.end - i_orig, j_orig = orig_cmd.center_offset - - # Calculate center and new offsets - x_start, y_start = new_cmd.end - center_x = x_start + i_orig - center_y = y_start + j_orig - new_i = center_x - x_end - new_j = center_y - y_end - - # Update arc parameters - new_cmd.end = x_start, y_start - new_cmd.center_offset = new_i, new_j - new_cmd.clockwise = not orig_cmd.clockwise - - new_segment.append(new_cmd) - - return new_segment - - -def greedy_order_segments(segments): - """ - Greedy ordering using vectorized math.dist computations. - Part of the path optimization algorithm. - - It is assumed that the input segments contain only Command objects - that are NOT state commands (such as 'set_power'), so it is - ensured that each Command performs a position change (i.e. it has - x,y coordinates). - """ - if not segments: - return [] - - ordered = [] - current_seg = segments[0] - ordered.append(current_seg) - current_pos = np.array(current_seg[-1].end) - remaining = segments[1:] - while remaining: - # Find the index of the best next path to take, i.e. the - # Command that adds the smalles amount of travel distance. - starts = np.array([seg[0].end for seg in remaining]) - ends = np.array([seg[-1].end for seg in remaining]) - d_starts = np.linalg.norm(starts - current_pos, axis=1) - d_ends = np.linalg.norm(ends - current_pos, axis=1) - candidate_dists = np.minimum(d_starts, d_ends) - best_idx = int(np.argmin(candidate_dists)) - best_seg = remaining.pop(best_idx) - - # Flip candidate if its end is closer. - if d_ends[best_idx] < d_starts[best_idx]: - best_seg = flip_segment(best_seg) - - start_cmd = best_seg[0] - if not start_cmd.is_travel_command(): - end_cmd = best_seg[-1] - best_seg[0], best_seg[-1] = best_seg[-1], best_seg[0] - start_cmd.end, end_cmd.end = end_cmd.end, start_cmd.end - - ordered.append(best_seg) - current_pos = np.array(best_seg[-1].end) - - return ordered - - -def flip_segments(ordered): - """ - Flip each segment if doing so lowers the sum of the incoming - and outgoing travel. - """ - improved = True - while improved: - improved = False - for i in range(1, len(ordered)): - # Calculate cost of travel (=travel distance from last segment - # +travel distance to next segment) - prev_segment_end = ordered[i-1][-1].end - segment = ordered[i] - cost = math.dist(prev_segment_end, segment[0].end) - if i < len(ordered)-1: - cost += math.dist(segment[-1].end, ordered[i+1][0].end) - - # Flip and calculate the flipped cost. - flipped = flip_segment(segment) - flipped_cost = math.dist(prev_segment_end, flipped[0].end) - if i < len(ordered)-1: - flipped_cost += math.dist(flipped[-1].end, - ordered[i+1][0].end) - - # Choose the shorter one. - if flipped_cost < cost: - ordered[i] = flipped - improved = True - - return ordered - - -def two_opt(ordered, max_iter=1000): - """ - 2-opt: try reversing entire sub-sequences if that lowers the travel cost. - """ - n = len(ordered) - if n < 3: - return ordered - iter_count = 0 - improved = True - while improved and iter_count < max_iter: - improved = False - for i in range(n-2): - for j in range(i+2, n): - a_end = ordered[i][-1] - b_start = ordered[i+1][0] - e_end = ordered[j][-1] - if j < n - 1: - f_start = ordered[j+1][0] - curr_cost = math.dist(a_end.end, b_start.end) \ - + math.dist(e_end.end, f_start.end) - new_cost = math.dist(a_end.end, e_end.end) \ - + math.dist(b_start.end, f_start.end) - else: - curr_cost = math.dist(a_end.end, b_start.end) - new_cost = math.dist(a_end.end, e_end.end) - if new_cost < curr_cost: - sub = ordered[i+1:j+1] - # Reverse order and flip each segment. - for n in range(len(sub)): - sub[n] = flip_segment(sub[n]) - ordered[i+1:j+1] = sub[::-1] - improved = True - iter_count += 1 - return ordered - - -class Optimize(OpsTransformer): - """ - Uses the 2-opt swap algorithm to address the Traveline Salesman Problem - to minimize travel moves in the commands. - - This is made harder by the fact that some commands cannot be - reordered. For example, if the Ops contains multiple commands - to toggle air-assist, we cannot reorder the operations without - ensuring that air-assist remains on for the sections that need it. - Ops optimization may lead to a situation where the number of - air assist toggles is multiplied, which could be detrimental - to the health of the air pump. - - To avoid these problems, we implement the following process: - - 1. Preprocess the command list, duplicating the intended - state (e.g. cutting, power, ...) and attaching it to each - command. Here we also drop all state commands. - - 2. Split the command list into non-reorderable segments. Segment in - this step means an "as long as possible" sequence that may still - include sub-segments, as long as those sub-segments are - reorderable. - - 3. Split the long segments into short, re-orderable sub sequences. - - 4. Re-order the sub sequences to minimize travel distance. - - 5. Re-assemble the Ops object. - """ - def run(self, ops: Ops): - # 1. Preprocess such that each operation has a state. - # This also causes all state commands to be dropped - we - # need to re-add them later. - ops.preload_state() - commands = [c for c in ops if not c.is_state_command()] - - # 2. Split the operations into long segments where - # the state stays more or less the same, i.e. no switching - # of states that we should be careful with, such as toggling - # air assist. - long_segments = split_long_segments(commands) - - # 3. Split the long segments into small, re-orderable - # segments. - result = [] - for long_segment in long_segments: - # 4. Reorder to minimize the distance. - segments = split_segments(long_segment) - segments = greedy_order_segments(segments) - segments = flip_segments(segments) - result += two_opt(segments, max_iter=1000) - - # 5. Reassemble the ops, reintroducing state change commands. - ops.commands = [] - prev_state = State() - for segment in result: - if not segment: - continue # skip empty segments - - for cmd in segment: - if cmd.state.air_assist != prev_state.air_assist: - ops.enable_air_assist(cmd.state.air_assist) - prev_state.air_assist = cmd.state.air_assist - if cmd.state.power != prev_state.power: - ops.set_power(cmd.state.power) - prev_state.power = cmd.state.power - if cmd.state.cut_speed != prev_state.cut_speed: - ops.set_cut_speed(cmd.state.cut_speed) - prev_state.cut_speed = cmd.state.cut_speed - if cmd.state.travel_speed != prev_state.travel_speed: - ops.set_travel_speed(cmd.state.travel_speed) - prev_state.travel_speed = cmd.state.travel_speed - - if not cmd.is_state_command(): - ops.add(cmd) - else: - raise ValueError(f'unexpected command {cmd}') diff --git a/rayforge/opstransformer/smooth.py b/rayforge/opstransformer/smooth.py deleted file mode 100644 index f608ae9c1..000000000 --- a/rayforge/opstransformer/smooth.py +++ /dev/null @@ -1,78 +0,0 @@ -import math -from ..models.ops import Ops, LineToCommand, MoveToCommand -from .transformer import OpsTransformer -from .arcwelder.points import remove_duplicates - - -class Smooth(OpsTransformer): - """Smooths Ops points with a moving average, keeping sharp corners.""" - def __init__(self, smooth_window=7, corner_angle_threshold=45): - """Initialize with window size and corner angle threshold.""" - self.smooth_window = max(1, smooth_window) - self.corner_threshold = math.radians(corner_angle_threshold) - - def run(self, ops: Ops): - segments = list(ops.segments()) - ops.clear() - for segment in segments: - if self._is_line_only_segment(segment): - points = [cmd.end for cmd in segment] - smoothed = self._smooth_segment(points) - ops.move_to(*smoothed[0]) - for point in smoothed[1:]: - ops.line_to(*point) - else: - for command in segment: - ops.add(command) - - def _is_line_only_segment(self, segment): - """Check if segment is MoveTo followed by LineToCommands only.""" - if len(segment) <= 1 or not isinstance(segment[0], MoveToCommand): - return False - return all(isinstance(cmd, LineToCommand) for cmd in segment[1:]) - - def _smooth_segment(self, points): - """Smooth points, preserving sharp corners.""" - if len(points) < 3 or self.smooth_window <= 1: - return remove_duplicates(points) - half_window = (self.smooth_window - 1) // 2 - smoothed = [] - is_corner = [False] * len(points) - for i in range(1, len(points) - 1): - p0, p1, p2 = points[i-1], points[i], points[i+1] - angle = self._angle_between(p0, p1, p2) - if abs(math.pi - angle) > self.corner_threshold: - is_corner[i] = True - for i in range(len(points)): - if is_corner[i]: - smoothed.append(points[i]) - else: - start = i - while (start > 0 and i - start < half_window and - not is_corner[start]): - start -= 1 - if is_corner[start]: - start += 1 - end = i - while (end < len(points) - 1 and end - i < half_window and - not is_corner[end]): - end += 1 - if end < len(points) and is_corner[end]: - end -= 1 - window = points[start:end + 1] - avg_x = sum(p[0] for p in window) / len(window) - avg_y = sum(p[1] for p in window) / len(window) - smoothed.append((avg_x, avg_y)) - return smoothed - - def _angle_between(self, p0, p1, p2): - """Calculate angle (radians) between vectors p0-p1 and p1-p2.""" - v1x, v1y = p1[0] - p0[0], p1[1] - p0[1] - v2x, v2y = p2[0] - p1[0], p2[1] - p1[1] - dot = v1x * v2x + v1y * v2y - mag1 = math.hypot(v1x, v1y) - mag2 = math.hypot(v2x, v2y) - if mag1 == 0 or mag2 == 0: - return 0 - cos_theta = min(1.0, max(-1.0, dot / (mag1 * mag2))) - return math.acos(cos_theta) diff --git a/rayforge/opstransformer/transformer.py b/rayforge/opstransformer/transformer.py deleted file mode 100644 index d1fab00df..000000000 --- a/rayforge/opstransformer/transformer.py +++ /dev/null @@ -1,15 +0,0 @@ -from abc import ABC, abstractmethod -from ..models.ops import Ops - - -class OpsTransformer(ABC): - """ - Transforms an Ops object in-place. - Examples may include: - - - Applying travel path optimizations - - Applying arc welding - """ - @abstractmethod - def run(self, pos: Ops) -> None: - pass diff --git a/rayforge/pipeline/__init__.py b/rayforge/pipeline/__init__.py new file mode 100644 index 000000000..3cc14b404 --- /dev/null +++ b/rayforge/pipeline/__init__.py @@ -0,0 +1,7 @@ +from .artifact import BaseArtifactHandle, JobArtifact, WorkPieceArtifact + +__all__ = [ + "BaseArtifactHandle", + "JobArtifact", + "WorkPieceArtifact", +] diff --git a/rayforge/pipeline/artifact/__init__.py b/rayforge/pipeline/artifact/__init__.py new file mode 100644 index 000000000..f76b286f0 --- /dev/null +++ b/rayforge/pipeline/artifact/__init__.py @@ -0,0 +1,26 @@ +from .base import BaseArtifact, TextureData +from .handle import BaseArtifactHandle, create_handle_from_dict +from .job import JobArtifact +from .step_ops import StepOpsArtifact +from .store import ArtifactStore +from .workpiece import WorkPieceArtifact, WorkPieceArtifactHandle +from .workpiece_view import ( + RenderContext, + WorkPieceViewArtifact, + WorkPieceViewArtifactHandle, +) + +__all__ = [ + "ArtifactStore", + "BaseArtifact", + "BaseArtifactHandle", + "JobArtifact", + "RenderContext", + "StepOpsArtifact", + "TextureData", + "WorkPieceArtifact", + "WorkPieceArtifactHandle", + "WorkPieceViewArtifact", + "WorkPieceViewArtifactHandle", + "create_handle_from_dict", +] diff --git a/rayforge/pipeline/artifact/base.py b/rayforge/pipeline/artifact/base.py new file mode 100644 index 000000000..057b78695 --- /dev/null +++ b/rayforge/pipeline/artifact/base.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + +from .handle import BaseArtifactHandle + + +@dataclass +class TextureData: + """A container for texture-based raster data.""" + + power_texture_data: np.ndarray + dimensions_mm: tuple[float, float] + position_mm: tuple[float, float] + + +class BaseArtifact(ABC): + @property + def artifact_type(self) -> str: + return self.__class__.__name__ + + @abstractmethod + def build_handle(self, key: str) -> BaseArtifactHandle: + pass diff --git a/rayforge/pipeline/artifact/handle.py b/rayforge/pipeline/artifact/handle.py new file mode 100644 index 000000000..e7565c722 --- /dev/null +++ b/rayforge/pipeline/artifact/handle.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from abc import ABC +from typing import Any + +_handle_registry: dict[str, type[BaseArtifactHandle]] = {} + + +class BaseArtifactHandle(ABC): + def __init__( + self, + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + self.key = key + self.handle_class_name = handle_class_name + self.artifact_type_name = artifact_type_name + self.generation_id = generation_id + self.array_metadata = ( + array_metadata if array_metadata is not None else {} + ) + self.refcount: int = 1 + self.holders: list[str] = [] + + def __init_subclass__(cls, **kwargs): + """ + This special method is called whenever a class inherits from + BaseArtifactHandle. It automatically registers the new handle type. + """ + super().__init_subclass__(**kwargs) + _handle_registry[cls.__name__] = cls + + def to_dict(self) -> dict[str, Any]: + """ + Serializes the handle to a dictionary. Subclasses will be handled + correctly. + """ + return vars(self) + + def __eq__(self, other: object) -> bool: + """ + Provides value-based equality comparison for handle objects. + """ + if not isinstance(other, self.__class__): + return NotImplemented + return self.__dict__ == other.__dict__ + + def __hash__(self) -> int: + """ + Provides a hash based on the handle key. + """ + return hash(self.key) + + @classmethod + def from_dict(cls: type[Any], data: dict[str, Any]) -> BaseArtifactHandle: + # This simple deserialization works for direct instantiation, but the + # factory function below should be used for polymorphic + # deserialization. + return cls(**data) + + +def create_handle_from_dict(data: dict[str, Any]) -> BaseArtifactHandle: + """ + Factory function to reconstruct the correct, typed handle subclass from a + dictionary. + """ + class_name = data.get("handle_class_name") + if not class_name: + raise ValueError( + "Cannot reconstruct handle: dictionary is missing " + "'handle_class_name'." + ) + + handle_class = _handle_registry.get(class_name) + if not handle_class: + raise TypeError( + f"Unknown handle type '{class_name}'. Was its module imported?" + ) + + return handle_class.from_dict(data) diff --git a/rayforge/pipeline/artifact/job.py b/rayforge/pipeline/artifact/job.py new file mode 100644 index 000000000..66dda24ef --- /dev/null +++ b/rayforge/pipeline/artifact/job.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from raygeo.ops import Ops + +from .base import BaseArtifact +from .handle import BaseArtifactHandle + +if TYPE_CHECKING: + from ..encoder.base import EncodedOutput, MachineCodeOpMap + + +class JobArtifactHandle(BaseArtifactHandle): + def __init__( + self, + time_estimate: float | None, + distance: float, + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + super().__init__( + key=key, + handle_class_name=handle_class_name, + artifact_type_name=artifact_type_name, + generation_id=generation_id, + array_metadata=array_metadata, + ) + self.time_estimate = time_estimate + self.distance = distance + + +class JobArtifact(BaseArtifact): + """ + Represents a final job artifact containing G-code and operation data + for machine execution. + + Coordinate conventions: + ops: Raw assembled operations in world-space coordinates. No + rotary mapping applied. Used as input to Machine.encode_ops() + which handles the full transform pipeline (rotary mapping + + world→machine + WCS + Z-flip) internally. + mapped_ops: Same operations with rotary axis mapping applied + (Y→degrees for rotary layers). Suitable for 3D preview and + playback (scene compiler, OpPlayer). Not suitable for G-code + encoding (lacks machine-coordinate transforms). + """ + + def __init__( + self, + ops: Ops, + distance: float, + generation_id: int, + time_estimate: float | None = None, + mapped_ops: Ops | None = None, + encoded_output: EncodedOutput | None = None, + ): + super().__init__() + self.ops = ops + self.distance = distance + self.generation_id = generation_id + self.time_estimate = time_estimate + self.mapped_ops: Ops | None = mapped_ops + + self._encoded_output: EncodedOutput | None = encoded_output + + @property + def preview_ops(self) -> Ops | None: + """Returns the ops suitable for 3D preview/playback. + + Prefers the rotary-mapped ops (``mapped_ops``) when available, + falling back to the raw assembled ``ops``. + """ + return self.mapped_ops if self.mapped_ops is not None else self.ops + + @property + def machine_code(self) -> str | None: + """ + Lazily decodes and caches the G-code string from encoded_output. + """ + encoded = self.encoded_output + return encoded.text if encoded else None + + @property + def op_map(self) -> MachineCodeOpMap | None: + """ + Lazily decodes and caches the MachineCodeOpMap from encoded_output. + """ + encoded = self.encoded_output + return encoded.op_map if encoded else None + + @property + def encoded_output(self) -> EncodedOutput | None: + """Returns the cached EncodedOutput, if any.""" + return self._encoded_output + + def to_dict(self) -> dict[str, Any]: + """Converts the artifact to a dictionary for serialization.""" + result = { + "ops": self.ops.to_dict(), + "time_estimate": self.time_estimate, + "distance": self.distance, + "generation_id": self.generation_id, + } + if self.mapped_ops is not None: + result["mapped_ops"] = self.mapped_ops.to_dict() + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> JobArtifact: + """Creates an artifact from a dictionary.""" + ops = Ops.from_dict(data["ops"]) + common_args = { + "ops": ops, + "time_estimate": data.get("time_estimate"), + "distance": data.get("distance", 0.0), + "generation_id": data["generation_id"], + } + if "mapped_ops" in data: + common_args["mapped_ops"] = Ops.from_dict(data["mapped_ops"]) + return cls(**common_args) + + def build_handle(self, key: str) -> JobArtifactHandle: + return JobArtifactHandle( + key=key, + handle_class_name=JobArtifactHandle.__name__, + artifact_type_name=self.__class__.__name__, + generation_id=self.generation_id, + time_estimate=self.time_estimate, + distance=self.distance, + ) diff --git a/rayforge/pipeline/artifact/step_ops.py b/rayforge/pipeline/artifact/step_ops.py new file mode 100644 index 000000000..7b44638ab --- /dev/null +++ b/rayforge/pipeline/artifact/step_ops.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .base import BaseArtifact +from .handle import BaseArtifactHandle + +if TYPE_CHECKING: + from raygeo.ops import Ops + + +class StepOpsArtifactHandle(BaseArtifactHandle): + def __init__( + self, + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + super().__init__( + key=key, + handle_class_name=handle_class_name, + artifact_type_name=artifact_type_name, + generation_id=generation_id, + array_metadata=array_metadata, + ) + + +class StepOpsArtifact(BaseArtifact): + """ + Represents an artifact containing only the final, transformed operations + for a Step. This is consumed by the JobPipelineStage. + """ + + def __init__( + self, + ops: Ops, + generation_id: int, + ): + super().__init__() + self.ops = ops + self.generation_id = generation_id + + def build_handle(self, key: str) -> StepOpsArtifactHandle: + return StepOpsArtifactHandle( + key=key, + handle_class_name=StepOpsArtifactHandle.__name__, + artifact_type_name=self.__class__.__name__, + generation_id=self.generation_id, + ) diff --git a/rayforge/pipeline/artifact/store.py b/rayforge/pipeline/artifact/store.py new file mode 100644 index 000000000..78b4ee856 --- /dev/null +++ b/rayforge/pipeline/artifact/store.py @@ -0,0 +1,99 @@ +""" +In-process artifact storage with reference counting. + +Replaces the former shared-memory (SHM) store. All artifacts live as +plain Python objects in a dict keyed by a UUID. Handles carry the UUID +in their ``key`` field plus any metadata the artifact type needs. + +Lifecycle is managed through reference counting via +:meth:`retain` / :meth:`release`. +""" + +from __future__ import annotations + +import logging +import uuid +from collections.abc import Generator +from contextlib import contextmanager + +from .base import BaseArtifact +from .handle import BaseArtifactHandle + +logger = logging.getLogger(__name__) + + +class ArtifactStore: + """In-process artifact store with reference-counted handles.""" + + def __init__(self): + self._artifacts: dict[str, BaseArtifact] = {} + self._handles: dict[str, BaseArtifactHandle] = {} + + def shutdown(self): + for key in list(self._handles): + self._handles.pop(key, None) + self._artifacts.clear() + + def _get_or_create_handle( + self, proto_handle: BaseArtifactHandle + ) -> BaseArtifactHandle: + key = proto_handle.key + canonical = self._handles.get(key) + if canonical is not None: + canonical.refcount += 1 + return canonical + proto_handle.refcount = 1 + self._handles[key] = proto_handle + return proto_handle + + def put( + self, + artifact: BaseArtifact, + creator_tag: str = "unknown", + ) -> BaseArtifactHandle: + """Store *artifact* and return a lightweight handle.""" + key = f"rf_{creator_tag}_{uuid.uuid4().hex[:16]}" + handle = artifact.build_handle(key) + handle.refcount = 1 + self._handles[key] = handle + self._artifacts[key] = artifact + return handle + + def get(self, handle: BaseArtifactHandle) -> BaseArtifact: + """Return the artifact referenced by *handle*.""" + try: + return self._artifacts[handle.key] + except KeyError: + raise RuntimeError(f"Artifact '{handle.key}' not found in store.") + + def release(self, handle: BaseArtifactHandle) -> None: + """Decrement refcount; delete artifact when it reaches zero.""" + key = handle.key + canonical = self._handles.get(key, handle) + if canonical.refcount > 1: + canonical.refcount -= 1 + return + self._handles.pop(key, None) + self._artifacts.pop(key, None) + + def retain(self, handle: BaseArtifactHandle) -> bool: + key = handle.key + canonical = self._handles.get(key) + if canonical: + canonical.refcount += 1 + return True + return False + + @contextmanager + def checkout_handle( + self, handle: BaseArtifactHandle | None + ) -> Generator[BaseArtifact | None, None, None]: + """Retain *handle*, yield its artifact, then release it.""" + if handle is None: + yield None + return + self.retain(handle) + try: + yield self.get(handle) + finally: + self.release(handle) diff --git a/rayforge/pipeline/artifact/workpiece.py b/rayforge/pipeline/artifact/workpiece.py new file mode 100644 index 000000000..d295925d0 --- /dev/null +++ b/rayforge/pipeline/artifact/workpiece.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from .base import BaseArtifact +from .handle import BaseArtifactHandle + +if TYPE_CHECKING: + from raygeo.ops import Ops + + +class WorkPieceArtifactHandle(BaseArtifactHandle): + logger = logging.getLogger(__name__) + + def __init__( + self, + is_scalable: bool, + generation_size: tuple[float, float], + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + source_dimensions: tuple[float, float] | None = None, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + super().__init__( + key=key, + handle_class_name=handle_class_name, + artifact_type_name=artifact_type_name, + generation_id=generation_id, + array_metadata=array_metadata, + ) + self.is_scalable = is_scalable + self.source_dimensions = source_dimensions + self.generation_size = generation_size + + +class WorkPieceArtifact(BaseArtifact): + """ + Represents an intermediate artifact produced during the pipeline, + containing vertex and texture data for visualization. + """ + + logger = logging.getLogger(__name__) + + def __init__( + self, + ops: Ops, + is_scalable: bool, + generation_size: tuple[float, float], + generation_id: int, + source_dimensions: tuple[float, float] | None = None, + ): + super().__init__() + self.ops = ops + self.is_scalable = is_scalable + self.source_dimensions = source_dimensions + self.generation_size = generation_size + self.generation_id = generation_id + + def build_handle(self, key: str) -> WorkPieceArtifactHandle: + return WorkPieceArtifactHandle( + key=key, + handle_class_name=WorkPieceArtifactHandle.__name__, + artifact_type_name=self.__class__.__name__, + generation_id=self.generation_id, + is_scalable=self.is_scalable, + source_dimensions=self.source_dimensions, + generation_size=self.generation_size, + ) diff --git a/rayforge/pipeline/artifact/workpiece_view.py b/rayforge/pipeline/artifact/workpiece_view.py new file mode 100644 index 000000000..9cab8f7e6 --- /dev/null +++ b/rayforge/pipeline/artifact/workpiece_view.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +import numpy as np +from raygeo.geo.types import Rect + +from .base import BaseArtifact +from .handle import BaseArtifactHandle + + +def _get_ops_color_mode_enum(): + from ...core.config import OpsColorMode + + return OpsColorMode + + +@dataclass +class RenderContext: + """ + An immutable contract describing all parameters from the UI required to + perform a render of a workpiece view. + """ + + pixels_per_mm: tuple[float, float] + show_travel_moves: bool + margin_px: int + color_set_dict: dict[str, Any] + laser_color_sets: dict[str, dict[str, Any]] = field(default_factory=dict) + layer_color_sets: dict[str, dict[str, Any]] = field(default_factory=dict) + ops_color_mode: Any = None + + def __post_init__(self): + if self.ops_color_mode is None: + self.ops_color_mode = _get_ops_color_mode_enum().LASER + + def to_dict(self) -> dict[str, Any]: + """Serializes the context to a dictionary.""" + d = asdict(self) + d["ops_color_mode"] = self.ops_color_mode.value + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RenderContext: + """Deserializes a RenderContext from a dictionary.""" + OpsColorMode = _get_ops_color_mode_enum() + mode = data.get("ops_color_mode", OpsColorMode.LASER.value) + if isinstance(mode, str): + try: + mode = OpsColorMode(mode) + except ValueError: + mode = OpsColorMode.LASER + data = {k: v for k, v in data.items() if k != "ops_color_mode"} + return cls(ops_color_mode=mode, **data) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, RenderContext): + return False + return ( + self.pixels_per_mm == other.pixels_per_mm + and self.show_travel_moves == other.show_travel_moves + and self.margin_px == other.margin_px + and self._compare_color_sets( + self.color_set_dict, other.color_set_dict + ) + and self._compare_color_sets( + self.laser_color_sets, other.laser_color_sets + ) + and self._compare_color_sets( + self.layer_color_sets, other.layer_color_sets + ) + and self.ops_color_mode == other.ops_color_mode + ) + + def _compare_color_sets( + self, dict1: dict[str, Any], dict2: dict[str, Any] + ) -> bool: + """Compare two color set dictionaries for equality.""" + if dict1.keys() != dict2.keys(): + return False + for key, val1 in dict1.items(): + val2 = dict2.get(key) + if isinstance(val1, dict) and isinstance(val2, dict): + if not self._compare_color_sets(val1, val2): + return False + elif val1 != val2: + return False + return True + + +class WorkPieceViewArtifactHandle(BaseArtifactHandle): + def __init__( + self, + bbox_mm: Rect, + workpiece_size_mm: tuple[float, float], + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + super().__init__( + key=key, + handle_class_name=handle_class_name, + artifact_type_name=artifact_type_name, + generation_id=generation_id, + array_metadata=array_metadata, + ) + self.bbox_mm = bbox_mm + self.workpiece_size_mm = workpiece_size_mm + + +class WorkPieceViewArtifact(BaseArtifact): + """ + An artifact containing a pre-rendered bitmap of a workpiece for fast + display on the 2D canvas. + """ + + def __init__( + self, + bitmap_data: np.ndarray, + bbox_mm: Rect, + workpiece_size_mm: tuple[float, float], + generation_id: int, + ): + super().__init__() + self.bitmap_data = bitmap_data + self.bbox_mm = bbox_mm + self.workpiece_size_mm = workpiece_size_mm + self.generation_id = generation_id + + def build_handle(self, key: str) -> WorkPieceViewArtifactHandle: + return WorkPieceViewArtifactHandle( + key=key, + handle_class_name=WorkPieceViewArtifactHandle.__name__, + artifact_type_name=self.__class__.__name__, + generation_id=self.generation_id, + bbox_mm=self.bbox_mm, + workpiece_size_mm=self.workpiece_size_mm, + ) diff --git a/rayforge/pipeline/assembly_warnings.py b/rayforge/pipeline/assembly_warnings.py new file mode 100644 index 000000000..5917649fd --- /dev/null +++ b/rayforge/pipeline/assembly_warnings.py @@ -0,0 +1,31 @@ +"""Translation helpers for assembler warnings emitted by raygeo. + +raygeo produces typed ``AssemblyWarning`` objects (kind + structured +fields); this module turns them into human-readable, translatable +strings for the UI. ``gettext`` ``_()`` is applied here, so the +templates are picked up by ``xgettext``. +""" + +from gettext import gettext as _ + +from raygeo.ops.assembly import AssemblyWarningKind + + +def translate_assembly_warning(w) -> str: + """Translate an ``AssemblyWarning`` into a user-facing string. + + :param w: A raygeo ``AssemblyWarning`` (or any object exposing + ``kind``, ``face_id``, ``region`` and ``detail``). + :returns: A ``_()``-marked, formatted message. + """ + label = w.face_id if w.face_id else _("default face") + if w.kind == AssemblyWarningKind.FACE_FAILED: + return _("Face '{face}' could not be machined: {detail}").format( + face=label, detail=w.detail + ) + if w.kind == AssemblyWarningKind.REGION_FAILED: + idx = w.region if w.region is not None else "?" + return _( + "Region {region} of face '{face}' could not be machined: {detail}" + ).format(region=idx, face=label, detail=w.detail) + return _("Machining warning: {detail}").format(detail=w.detail) diff --git a/rayforge/pipeline/encoder/__init__.py b/rayforge/pipeline/encoder/__init__.py new file mode 100644 index 000000000..204b43e61 --- /dev/null +++ b/rayforge/pipeline/encoder/__init__.py @@ -0,0 +1,10 @@ +from .base import MachineCodeOpMap, OpsEncoder +from .context import GcodeContext +from .gcode import GcodeEncoder + +__all__ = [ + "GcodeContext", + "GcodeEncoder", + "MachineCodeOpMap", + "OpsEncoder", +] diff --git a/rayforge/pipeline/encoder/base.py b/rayforge/pipeline/encoder/base.py new file mode 100644 index 000000000..2b78754b7 --- /dev/null +++ b/rayforge/pipeline/encoder/base.py @@ -0,0 +1,152 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from raygeo.ops import Ops + + +class MachineCodeOpMap: + """ + A bidirectional mapping between Ops command indices and Machine + language (e.g. G-code) line numbers. + + Data is stored as compact numpy ``int32`` arrays (4 bytes/element) + instead of Python lists of ints/tuples (~28-56 bytes/element). When + constructed from raygeo's ``bytearray`` getters, the arrays are + zero-copy ``np.frombuffer`` views over the bytearray, so no + per-element Python objects are ever materialized + (1.6 M ops: 196 MB list-of-tuples → 13 MB). + + Access is through the :meth:`span_for_op` and :meth:`op_for_line` + methods (plus :attr:`op_count` / :attr:`line_count`) rather than + raw list indexing. + """ + + def __init__( + self, + op_to_machine_code: bytearray | None = None, + machine_code_to_op: bytearray | None = None, + ) -> None: + self._spans: np.ndarray = np.empty((0, 2), dtype=np.int32) + self._line_to_op: np.ndarray = np.empty(0, dtype=np.int32) + self._spans_bytes: bytearray | None = None + self._line_to_op_bytes: bytearray | None = None + if op_to_machine_code is not None: + self._set_spans(op_to_machine_code) + if machine_code_to_op is not None: + self._set_line_to_op(machine_code_to_op) + + @classmethod + def from_lists( + cls, + op_to_machine_code: list[tuple[int, int]], + machine_code_to_op: list[int], + ) -> "MachineCodeOpMap": + """Build a map from the legacy list-of-tuples / list-of-ints + representation (used by encoders that construct the spans + incrementally, e.g. the Ruida encoder).""" + spans_bytes = bytearray() + for start, count in op_to_machine_code: + spans_bytes.extend(start.to_bytes(4, "little", signed=True)) + spans_bytes.extend(count.to_bytes(4, "little", signed=True)) + line_to_op_bytes = bytearray() + for v in machine_code_to_op: + line_to_op_bytes.extend(v.to_bytes(4, "little", signed=True)) + return cls( + op_to_machine_code=spans_bytes, + machine_code_to_op=line_to_op_bytes, + ) + + def _set_spans(self, value: bytearray) -> None: + if len(value) % 8 != 0: + raise ValueError( + "op_to_machine_code bytearray length must be a multiple of 8" + ) + self._spans = np.frombuffer(value, dtype=np.int32).reshape(-1, 2) + self._spans_bytes = value + + def _set_line_to_op(self, value: bytearray) -> None: + if len(value) % 4 != 0: + raise ValueError( + "machine_code_to_op bytearray length must be a multiple of 4" + ) + self._line_to_op = np.frombuffer(value, dtype=np.int32) + self._line_to_op_bytes = value + + @property + def op_count(self) -> int: + """Number of Ops commands in the map.""" + return self._spans.shape[0] + + @property + def line_count(self) -> int: + """Number of G-code lines in the map.""" + return len(self._line_to_op) + + def span_for_op(self, op_index: int) -> tuple[int, int]: + """Return ``(start_line, line_count)`` for *op_index*. + + A ``line_count`` of zero means the op produced no G-code. + Raises :class:`IndexError` when *op_index* is out of range. + """ + if not 0 <= op_index < self._spans.shape[0]: + raise IndexError(f"op index out of range: {op_index}") + start, count = self._spans[op_index] + return (int(start), int(count)) + + def op_for_line(self, line_idx: int) -> int | None: + """Op index for a machine-code line, or ``None`` if the line is + out of range or has no owning op.""" + if 0 <= line_idx < len(self._line_to_op): + mapped = self._line_to_op[line_idx] + if mapped != -1: + return int(mapped) + return None + + @property + def op_to_machine_code_bytes(self) -> bytearray: + """The interleaved ``(start, count)`` i32 payload as a + bytearray, for passing directly to raygeo's + ``EncodeOutput.MachineCode`` constructor.""" + if self._spans_bytes is None: + self._spans_bytes = bytearray(self._spans.tobytes()) + return self._spans_bytes + + @property + def machine_code_to_op_bytes(self) -> bytearray: + """The line→op i32 payload as a bytearray, for passing directly + to raygeo's ``EncodeOutput.MachineCode`` constructor.""" + if self._line_to_op_bytes is None: + self._line_to_op_bytes = bytearray(self._line_to_op.tobytes()) + return self._line_to_op_bytes + + +@dataclass +class EncodedOutput: + """ + Base class for encoder output. + + Attributes: + text: Human-readable machine code representation for UI display. + op_map: Bidirectional mapping between ops indices and line numbers. + driver_data: Optional driver-specific data (e.g., binary for Ruida). + """ + + text: str + op_map: MachineCodeOpMap + driver_data: dict[str, Any] = field(default_factory=dict) + + +class OpsEncoder(ABC): + """ + Transforms an Ops object into something else. + Examples: + + - Ops to image (a cairo surface) + - Ops to a G-code string + """ + + @abstractmethod + def encode(self, ops: Ops, *args, **kwargs) -> Any: + pass diff --git a/rayforge/pipeline/encoder/context.py b/rayforge/pipeline/encoder/context.py new file mode 100644 index 000000000..6ec33f5f8 --- /dev/null +++ b/rayforge/pipeline/encoder/context.py @@ -0,0 +1,165 @@ +from dataclasses import dataclass +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar, Optional + +from raygeo.geo.types import Point3D, Rect + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...core.layer import Layer + from ...core.workpiece import WorkPiece + from ...machine.models.machine import Machine + + +@dataclass +class JobInfo: + """Information about the entire job.""" + + extents: Rect + + +@dataclass +class GcodeContext: + """A container for variables available during G-code generation.""" + + machine: "Machine" + doc: "Doc" + job: JobInfo + # Assigning default values makes these fields optional in the constructor + layer: Optional["Layer"] = None + workpiece: Optional["WorkPiece"] = None + + @property + def wcs_offset(self) -> Point3D: + """The (x, y, z) offset for the current layer's effective WCS.""" + if self.layer: + effective_wcs = self.layer.get_effective_wcs(self.machine) + return self.machine.get_wcs_offset(effective_wcs) + return self.machine.get_active_wcs_offset() + + @property + def wcs_name(self) -> str: + """The name of the current layer's effective WCS (e.g., 'G54').""" + if self.layer: + return self.layer.get_effective_wcs(self.machine) + return self.machine.active_wcs + + # --- Static Variable Documentation --- + _DOCS: ClassVar[dict[str, list[tuple[str, str]]]] = { + "job": [ + ( + "machine.active_wcs", + _( + "The name of the currently active coordinate system " + "(e.g. 'G54')." + ), + ), + ("machine.name", _("The name of the current machine profile.")), + ( + "machine.axis_extents[0]", + _("The width (X-axis) of the machine work area."), + ), + ( + "machine.axis_extents[1]", + _("The height (Y-axis) of the machine work area."), + ), + ( + "doc.name", + _("The name of the current document file (if saved)."), + ), + ( + "job.extents[0]", + _("The minimum X coordinate of the entire job."), + ), + ( + "job.extents[1]", + _("The minimum Y coordinate of the entire job."), + ), + ( + "job.extents[2]", + _("The maximum X coordinate of the entire job."), + ), + ( + "job.extents[3]", + _("The maximum Y coordinate of the entire job."), + ), + ("wcs_offset[0]", _("The X offset of the currently active WCS.")), + ("wcs_offset[1]", _("The Y offset of the currently active WCS.")), + ("wcs_offset[2]", _("The Z offset of the currently active WCS.")), + ], + "layer": [ + ( + "layer.name", + _("The name of the current layer being processed."), + ), + ], + "workpiece": [ + ( + "workpiece.name", + _("The name of the current workpiece being processed."), + ), + ("workpiece.pos[0]", _("The X position of the workpiece.")), + ("workpiece.pos[1]", _("The Y position of the workpiece.")), + ("workpiece.size[0]", _("The width of the workpiece.")), + ("workpiece.size[1]", _("The height of the workpiece.")), + ], + } + + @classmethod + def get_docs(cls, level: str) -> list[tuple[str, str]]: + """ + Gets all variables available up to a certain context level from the + static documentation dictionary. + """ + docs = cls._DOCS["job"] + if level in ("layer", "workpiece"): + docs = docs + cls._DOCS["layer"] + if level == "workpiece": + docs = docs + cls._DOCS["workpiece"] + return sorted(docs, key=lambda item: item[0]) + + @staticmethod + def get_template_variable_docs() -> dict[str, set[str]]: + """ + Returns a dictionary mapping G-code template keys to the set of + variables they support. This is the single source of truth for + dialect validation. + """ + # Variables for movement commands + move_vars = { + "x", + "y", + "z", + "x_cmd", + "y_cmd", + "z_cmd", + "extra_cmd", + "f_command", + } + # Variables for cutting commands (inherits movement) + cut_vars = move_vars.union({"i", "j", "s_command", "power"}) + + return { + # Machine Control + "laser_on": {"power"}, + "focus_laser_on": {"power"}, + "laser_off": set(), + "tool_change": {"tool_number"}, + "set_speed": {"speed"}, + "air_assist_on": set(), + "air_assist_off": set(), + "home_all": set(), + "home_axis": {"axis_letter"}, + "move_to": {"speed", "x", "y", "z"}, + "jog": {"speed"}, + "clear_alarm": set(), + "set_wcs_offset": {"p_num", "x", "y", "z"}, + "probe_cycle": {"axis_letter", "max_travel", "feed_rate"}, + "dwell": {"seconds", "milliseconds"}, + # Movement + "travel_move": move_vars | {"s_command"}, + "linear_move": cut_vars, + "arc_cw": cut_vars, + "arc_ccw": cut_vars, + "bezier_cubic": cut_vars | {"p", "q"}, + } diff --git a/rayforge/pipeline/encoder/gcode.py b/rayforge/pipeline/encoder/gcode.py new file mode 100644 index 000000000..d18a1dc0d --- /dev/null +++ b/rayforge/pipeline/encoder/gcode.py @@ -0,0 +1,32 @@ +from typing import TYPE_CHECKING + +from raygeo.ops import Ops + +from ...machine.models.dialect import GcodeDialect +from .base import EncodedOutput, MachineCodeOpMap, OpsEncoder +from .rust_helpers import build_encode_context, dialect_to_spec + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...machine.models.machine import Machine + + +class GcodeEncoder(OpsEncoder): + """Converts Ops commands to G-code via the Rust encoder in raygeo.""" + + def __init__(self, dialect: GcodeDialect): + self.dialect: GcodeDialect = dialect + + def encode( + self, ops: Ops, machine: "Machine", doc: "Doc" + ) -> EncodedOutput: + dialect_spec = dialect_to_spec(self.dialect, machine) + context = build_encode_context(ops, machine, doc) + result = ops.to_gcode(dialect_spec, context) + return EncodedOutput( + text=result["text"], + op_map=MachineCodeOpMap( + op_to_machine_code=result["op_to_machine_code"], + machine_code_to_op=result["machine_code_to_op"], + ), + ) diff --git a/rayforge/pipeline/encoder/rust_helpers.py b/rayforge/pipeline/encoder/rust_helpers.py new file mode 100644 index 000000000..0de10ae94 --- /dev/null +++ b/rayforge/pipeline/encoder/rust_helpers.py @@ -0,0 +1,224 @@ +""" +Helper functions to convert Rayforge domain models (Machine, Doc, Dialect) +into plain dicts that the Rust G-code encoder (`raygeo.ops.encode_gcode`) +accepts via serde. + +No domain model objects cross the Python/Rust boundary — only primitive +types in JSON-serialisable dicts. +""" + +import logging +from typing import TYPE_CHECKING + +from raygeo.ops import Ops +from raygeo.ops.convert import GcodeDialectSpec + +from ...machine.models.laser import LaserHead +from ...machine.models.macro import MacroTrigger + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...machine.models.dialect import GcodeDialect + from ...machine.models.machine import Machine + +logger = logging.getLogger(__name__) + + +def _repr_val(v: float) -> str: + """Format a float the same way Python's TemplateFormatter does + (using repr) so path_vars match the old behaviour exactly.""" + return repr(v) + + +# ── Dialect conversion ─────────────────────────────────────────── + + +def dialect_to_spec( + dialect: "GcodeDialect", machine: "Machine" +) -> GcodeDialectSpec: + """Convert a `GcodeDialect` dataclass to a typed + ``GcodeDialectSpec`` pyclass instance.""" + return GcodeDialectSpec( + laser_on=dialect.laser_on, + laser_off=dialect.laser_off, + tool_change=dialect.tool_change, + set_speed=dialect.set_speed, + travel_move=dialect.travel_move, + linear_move=dialect.linear_move, + arc_cw=dialect.arc_cw, + arc_ccw=dialect.arc_ccw, + bezier_cubic=dialect.bezier_cubic or "", + air_assist_on=dialect.air_assist_on, + air_assist_off=dialect.air_assist_off, + spindle_on_cw=dialect.spindle_on_cw, + spindle_on_ccw=dialect.spindle_on_ccw, + spindle_off=dialect.spindle_off, + coolant_flood=dialect.coolant_flood, + coolant_mist=dialect.coolant_mist, + coolant_off=dialect.coolant_off, + dwell=dialect.dwell, + preamble=dialect.preamble, + postscript=dialect.postscript, + inject_wcs_after_preamble=dialect.inject_wcs_after_preamble, + can_g0_with_speed=dialect.can_g0_with_speed, + omit_unchanged_coords=dialect.omit_unchanged_coords, + continuous_laser_mode=dialect.continuous_laser_mode, + modal_feedrate=dialect.modal_feedrate, + gcode_precision=machine.gcode_precision, + ) + + +# ── Path variable resolution ───────────────────────────────────── + + +def _build_machine_path_vars(machine: "Machine") -> dict[str, str]: + """Static path variables that never change during encoding.""" + wcs_offset = machine.get_active_wcs_offset() + ax_w, ax_h = machine.axis_extents + return { + "machine.name": machine.name, + "machine.active_wcs": machine.active_wcs, + "machine.axis_extents[0]": _repr_val(ax_w), + "machine.axis_extents[1]": _repr_val(ax_h), + "wcs_offset[0]": _repr_val(wcs_offset[0]), + "wcs_offset[1]": _repr_val(wcs_offset[1]), + "wcs_offset[2]": _repr_val(wcs_offset[2]), + } + + +def _build_job_path_vars(ops: Ops, doc: "Doc") -> dict[str, str]: + """Job-level path variables (depend on ops extents and doc name).""" + xmin, ymin, xmax, ymax = ops.rect() + return { + "doc.name": doc.name if doc else "", + "job.extents[0]": _repr_val(xmin), + "job.extents[1]": _repr_val(ymin), + "job.extents[2]": _repr_val(xmax), + "job.extents[3]": _repr_val(ymax), + } + + +def _build_layer_path_vars_for_doc( + doc: "Doc", machine: "Machine" +) -> dict[str, dict[str, str]]: + result: dict[str, dict[str, str]] = {} + if not doc: + return result + for layer in doc.layers: + wcs = layer.get_effective_wcs(machine) + wcs_offset = machine.get_wcs_offset(wcs) + result[layer.uid] = { + "layer.name": layer.name, + "wcs_offset[0]": _repr_val(wcs_offset[0]), + "wcs_offset[1]": _repr_val(wcs_offset[1]), + "wcs_offset[2]": _repr_val(wcs_offset[2]), + } + return result + + +def _build_workpiece_path_vars_for_doc( + doc: "Doc", +) -> dict[str, dict[str, str]]: + result: dict[str, dict[str, str]] = {} + if not doc: + return result + for layer in doc.layers: + for wp in layer.all_workpieces: + pos = wp.pos + size = wp.size + result[wp.uid] = { + "workpiece.name": wp.name, + "workpiece.pos[0]": _repr_val(pos[0]), + "workpiece.pos[1]": _repr_val(pos[1]), + "workpiece.size[0]": _repr_val(size[0]), + "workpiece.size[1]": _repr_val(size[1]), + } + return result + + +# ── Macro table ─────────────────────────────────────────────────── + + +def _build_macro_table(machine: "Machine") -> dict: + """Build the MacroTable dict for the Rust encoder.""" + hooks = machine.hookmacros + macros = machine.macros # Dict[str, Macro] + + def _macro_dict(m) -> dict | None: + if m is None: + return None + return {"name": m.name, "code": m.code, "enabled": m.enabled} + + trigger_map = { + MacroTrigger.LAYER_START: "layer_start", + MacroTrigger.LAYER_END: "layer_end", + MacroTrigger.WORKPIECE_START: "workpiece_start", + MacroTrigger.WORKPIECE_END: "workpiece_end", + } + table: dict = {} + for trigger, key in trigger_map.items(): + table[key] = _macro_dict(hooks.get(trigger)) + + # All macros by name for @include resolution + all_macros: dict[str, dict] = {} + for name, m in macros.items(): + all_macros[name] = { + "name": m.name, + "code": m.code, + "enabled": m.enabled, + } + table["all_macros"] = all_macros + + return table + + +# ── Laser heads ─────────────────────────────────────────────────── + + +def _build_heads(machine: "Machine") -> list[dict]: + return [ + { + "uid": head.uid, + "tool_number": head.tool_number, + "max_power": float(head.max_power), + } + for head in machine.heads + if isinstance(head, LaserHead) + ] + + +# ── Layer WCS ───────────────────────────────────────────────────── + + +def _build_layer_wcs(doc: "Doc", machine: "Machine") -> dict[str, str]: + """Per-layer WCS command, keyed by layer UID.""" + result: dict[str, str] = {} + if not doc: + return result + for layer in doc.layers: + result[layer.uid] = layer.get_effective_wcs(machine) + return result + + +# ── Public entry points ────────────────────────────────────────── + + +def build_encode_context(ops: Ops, machine: "Machine", doc: "Doc") -> dict: + """Build the EncodeContext dict for raygeo.ops.encode_gcode.""" + path_vars: dict[str, str] = {} + path_vars.update(_build_machine_path_vars(machine)) + path_vars.update(_build_job_path_vars(ops, doc)) + + return { + "gcode_precision": machine.gcode_precision, + "max_travel_speed": float(machine.max_travel_speed), + "unit_scale": machine.unit_system.scale_from_mm, + "default_head_uid": machine.get_default_head().uid, + "heads": _build_heads(machine), + "active_wcs": machine.active_wcs, + "layer_wcs": _build_layer_wcs(doc, machine), + "macros": _build_macro_table(machine), + "path_vars": path_vars, + "layer_path_vars": _build_layer_path_vars_for_doc(doc, machine), + "workpiece_path_vars": _build_workpiece_path_vars_for_doc(doc), + } diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py new file mode 100644 index 000000000..d75b22747 --- /dev/null +++ b/rayforge/pipeline/intent_builder.py @@ -0,0 +1,1166 @@ +""" +Intent construction for the raygeo-backed pipeline. + +The :class:`IntentBuilder` walks a :class:`~rayforge.core.doc.Doc` and +produces a flat list of :class:`~raygeo.pipeline.request.NodeRequest` +objects with **stable keys** and **deterministic version tokens**. + +Stable keys +----------- +* ``workpiece:{wp_uid}:{step_uid}`` — one compute node per + workpiece / step pair. +* ``step:{step_uid}`` — one aggregate node per step that concatenates + the workpiece compute outputs and applies per-step transformers. +* ``job`` — one final aggregate node linking all step outputs with + job-level markers and machine parameters. + +Version tokens +-------------- +raygeo's cache is keyed by node key only; the ``version_token`` is the +sole invalidation signal. Tokens are SHA-1 digests of a canonical +representation of the inputs that affect a node's output: + +* **Compute tokens** hash + ``(geometry_revision, step.params, transformer_params)``. + For step scopes declaring a position-sensitive transformer (see + :meth:`Step.is_position_sensitive`), + ``transform_revision`` of the workpiece is folded into the token; + otherwise it is omitted so pure moves do not invalidate workpiece + compute results. + +* **Aggregate tokens** hash + ``(upstream compute tokens, placement, markers, + transformer_params + position_sensitive())``. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import math +from collections.abc import Callable, Mapping, Sequence +from typing import ( + TYPE_CHECKING, + Any, +) + +import numpy as np +from raygeo.cnc.execution.specs import ( + AggregateGroup, + AggregateInput, + AggregateSpec, + EncodeSpec, + LinkMode, + MachineParams, + MachineTransformSpec, + Marker, + RotaryMappingSpec, +) +from raygeo.geo import Geometry, Matrix +from raygeo.ops import Ops +from raygeo.ops.convert import ( + EncodeOutput, + Encoder, + GcodeSpec, + PythonEncoder, +) +from raygeo.pipeline.request import NodeRequest +from raygeo.pipeline.stage import StageSpec + +from ..machine.driver import get_driver_cls +from ..machine.driver.dummy import NoDeviceDriver +from ..machine.kinematic_math import KinematicMath +from ..machine.models.coordspace import MachineSpace +from ..machine.models.dialect import GRBL_DIALECT +from ..machine.models.rotary_module import RotaryMode, RotaryType +from .encoder.base import EncodedOutput +from .encoder.rust_helpers import build_encode_context, dialect_to_spec +from .transformer import OpsTransformer +from .transformer.registry import transformer_registry + +if TYPE_CHECKING: + from ..core.doc import Doc + from ..core.layer import Layer + from ..core.step import Step + from ..core.workpiece import WorkPiece + from ..machine.models.dialect import GcodeDialect + from ..machine.models.machine import Machine + +logger = logging.getLogger(__name__) + + +# Stable key formats. Centralised here so the producer and the DOM +# reattachment map (see IntentController) always agree. +WORKPIECE_KEY_FMT = "workpiece:{wp_uid}:{step_uid}" +STEP_KEY_FMT = "step:{step_uid}" +JOB_KEY = "job" +JOB_ENCODE_KEY = "job:encode" +JOB_MACHINEXFORM_KEY = "job:machinexform" + + +def workpiece_key(wp_uid: str, step_uid: str) -> str: + return WORKPIECE_KEY_FMT.format(wp_uid=wp_uid, step_uid=step_uid) + + +def parse_workpiece_key(key: str) -> tuple[str, str] | None: + """Parse a ``workpiece:{wp_uid}:{step_uid}`` key. + + Returns ``(wp_uid, step_uid)`` or ``None`` if the key does not + match the expected format. + """ + if not key.startswith("workpiece:"): + return None + rest = key[len("workpiece:") :] + idx = rest.find(":") + if idx == -1 or rest.find(":", idx + 1) != -1: + return None + return (rest[:idx], rest[idx + 1 :]) + + +def step_key(step_uid: str) -> str: + return STEP_KEY_FMT.format(step_uid=step_uid) + + +def job_key() -> str: + return JOB_KEY + + +def job_encode_key() -> str: + return JOB_ENCODE_KEY + + +def job_machinexform_key() -> str: + return JOB_MACHINEXFORM_KEY + + +class IntentBuilder: + """ + Builds a flat :class:`NodeRequest` list from a :class:`Doc`. + + The builder is stateless: each call to :meth:`build` produces a + fresh, self-contained list suitable for wrapping in a raygeo + :class:`Intent`. + """ + + def __init__( + self, + machine: Machine, + generation_id: int = 0, + loop: asyncio.AbstractEventLoop | None = None, + ): + self._machine = machine + self._generation_id = generation_id + self._loop = loop + self._doc: Doc | None = None + + @property + def generation_id(self) -> int: + return self._generation_id + + def build(self, doc: Doc) -> list[NodeRequest]: + """ + Walk *doc* and produce one NodeRequest per workpiece-step pair, + one per step, and one final job aggregate. + """ + self._doc = doc + nodes: list[NodeRequest] = [] + # Map each step's key to the list of upstream workpiece compute + # inputs — the step aggregate token and placement depend on all + # of them. + step_compute_inputs: dict[str, list[tuple[str, int, WorkPiece]]] = {} + # Per-step aggregate version tokens, used by the job aggregate + # token so a position change that invalidates one step's + # aggregate also invalidates the job aggregate (and encode). + step_tokens: dict[str, int] = {} + + for layer in doc.layers: + if not layer.workflow or not layer.workflow.steps: + continue + workpieces = list(layer.all_workpieces) + if not workpieces: + continue + for step in layer.workflow.steps: + if not step.visible: + continue + inputs = self._build_workpiece_nodes(step, workpieces, nodes) + step_compute_inputs[step.uid] = inputs + + for layer in doc.layers: + if not layer.workflow: + continue + for step in layer.workflow.steps: + if not step.visible: + continue + upstream = step_compute_inputs.get(step.uid, []) + if not upstream: + continue + self._build_step_node(step, layer, upstream, nodes) + step_tokens[step.uid] = self._aggregate_token( + step, layer, upstream + ) + + if step_tokens: + self._build_job_node(doc, nodes, step_tokens) + self._build_machine_transform_node(doc, nodes, step_tokens) + self._build_encoder_node(doc, nodes, step_tokens) + return nodes + + # ------------------------------------------------------------------ + # Workpiece compute nodes + # ------------------------------------------------------------------ + + def _build_workpiece_nodes( + self, + step: Step, + workpieces: Sequence[WorkPiece], + out: list[NodeRequest], + ) -> list[tuple[str, int, WorkPiece]]: + """ + Append one compute NodeRequest per workpiece for *step* and + return the list of ``(node_key, version_token, workpiece)`` + triples the step aggregate consumes. + """ + pos_sensitive = step.is_position_sensitive() + inputs: list[tuple[str, int, WorkPiece]] = [] + + # Parallelise per-workpiece Part construction (rendering + image + # preprocessing) when we have a reference to the TaskManager's + # event loop and more than one workpiece. The heavy work + # (dithering, grayscale, auto-levels) delegates to + # raygeo Rust code which releases the GIL, so threads yield + # real parallelism. + stages: list[StageSpec.Compute] + loop = self._loop + if loop is not None and len(workpieces) > 1: + + async def _gather(): + coros = [ + loop.run_in_executor( + None, + self._wp_stage, + step, + wp, + ) + for wp in workpieces + ] + return await asyncio.gather(*coros) + + future = asyncio.run_coroutine_threadsafe(_gather(), loop) + stages = future.result() + else: + stages = [self._wp_stage(step, wp) for wp in workpieces] + + for wp, stage in zip(workpieces, stages): + key = workpiece_key(wp.uid, step.uid) + token = self._compute_token(step, wp, pos_sensitive) + inputs.append((key, token, wp)) + out.append(self._make_request(key, token, stage)) + return inputs + + def _build_step_node( + self, + step: Step, + layer: Layer, + upstream: list[tuple[str, int, WorkPiece]], + out: list[NodeRequest], + ) -> None: + key = step_key(step.uid) + token = self._aggregate_token(step, layer, upstream) + stage = self._step_stage(step, upstream) + # The step aggregate output is only consumed by the job + # aggregate during a single run, so it is not worth caching: + # caching would retain a full extra copy of the step's command + # buffer between builds. + out.append(self._make_request(key, token, stage, cacheable=False)) + + def _build_job_node( + self, + doc: Doc, + out: list[NodeRequest], + step_tokens: dict[str, int], + ) -> None: + key = job_key() + token = self._job_token(doc, step_tokens) + stage = self._job_stage(doc, step_tokens) + # The job ops are the final pipeline output: the caller holds + # them in the JobArtifact, so a cached copy would only retain + # a duplicate of the job's command buffer between builds. + out.append(self._make_request(key, token, stage, cacheable=False)) + + def _build_machine_transform_node( + self, + doc: Doc, + out: list[NodeRequest], + step_tokens: dict[str, int], + ) -> None: + """Append the machine-transform compute node between the job + aggregate and the encoder. + + This node consumes the job aggregate's world-space Ops and + produces machine-space Ops by applying curve linearization, + rotary axis mapping, world→machine coordinate transforms, + WCS offsets, Z-flip, and AXIS_REPLACEMENT downstream. + The encoder then reads from this node instead of directly + from the job aggregate. + """ + if self._machine is None: + return + key = job_machinexform_key() + token = self._machine_transform_token(doc, step_tokens) + stage = self._build_machine_transform_stage(doc) + # The machine-space ops are consumed solely by the encoder + # during the same run, so they are not cached: caching would + # retain a full extra copy of the job's command buffer between + # builds. + out.append(self._make_request(key, token, stage, cacheable=False)) + + def _build_encoder_node( + self, + doc: Doc, + out: list[NodeRequest], + step_tokens: dict[str, int], + ) -> None: + """Append the encoder compute node that consumes the + machine-transform node's machine-space Ops and produces + the machine code (G-code / vertex / texture). + + The encoder runs through raygeo's ``EncoderCompute`` stage. + For Grbl the native Rust ``GcodeSpec`` is used directly; for + any other machine the driver-specific encoder is wrapped in a + :class:`PythonEncoder` so it runs under the GIL on a rayon + worker thread — off the GTK main thread. + """ + if self._machine is None: + return + key = job_encode_key() + token = self._encode_token(doc, step_tokens) + stage = self._encode_stage(doc) + out.append(self._make_request(key, token, stage)) + + # ------------------------------------------------------------------ + # Token computation + # ------------------------------------------------------------------ + + def _stock_revision(self) -> int: + """Hash of visible stock items' world transforms and asset UIDs. + + Ensures that moving, adding, or removing a stock item + invalidates crop-dependent compute caches. + """ + if self._doc is None: + return 0 + payload = [] + for item in self._doc.stock_items: + if not item.visible: + continue + payload.append( + { + "uid": item.uid, + "matrix": item.matrix.to_list(), + "asset_uid": item.stock_asset_uid, + } + ) + return _hash_int({"kind": "stock", "items": payload}) + + def _compute_token( + self, step: Step, wp: WorkPiece, pos_sensitive: bool + ) -> int: + payload = { + "kind": "compute", + "step_uid": step.uid, + "wp_uid": wp.uid, + "geo_rev": wp.geometry_revision, + "wp_size": list(wp.size) if wp.size else [0, 0], + "step_params": step.get_cache_params(), + "assembler_params": _canonical(self._assembler_params(step, wp)), + "wpxf": _canonical(step.per_workpiece_transformers_dicts), + } + if pos_sensitive: + payload["xf_rev"] = wp.transform_revision + payload["stock_rev"] = self._stock_revision() + if step.uses_global_state and step.layer and step.layer.workflow: + chain = [ + s for s in step.layer.workflow.steps if s.uses_global_state + ] + if step in chain: + idx = chain.index(step) + if idx > 0: + prev = chain[idx - 1] + payload["predecessor_token"] = self._compute_token( + prev, wp, prev.is_position_sensitive() + ) + return _hash_int(payload) + + def _aggregate_token( + self, + step: Step, + layer: Layer, + upstream: list[tuple[str, int, WorkPiece]], + ) -> int: + # Fold the per-workpiece placement matrix and target dimensions + # into the token. The aggregate applies the placement matrix + # to the (possibly cached) workpiece compute output, so a move + # that leaves the compute cache untouched must still invalidate + # the aggregate — otherwise the cached step ops are displayed + # at their previous world position. + placements: list[Any] = [] + for _k, _t, wp in upstream: + placements.append( + { + "matrix": _workpiece_placement_matrix(wp), + "size": list(wp.size) if wp.size else [0, 0], + } + ) + payload = { + "kind": "step_aggregate", + "step_uid": step.uid, + "upstream": [[k, t] for k, t, _wp in upstream], + "step_params": step.get_cache_params(), + "spxf": _canonical(step.per_step_transformers_dicts), + "wpxf": _canonical(step.per_workpiece_transformers_dicts), + "position_sensitive": step.is_position_sensitive(), + "placements": placements, + } + if step.is_position_sensitive(): + payload["stock_rev"] = self._stock_revision() + return _hash_int(payload) + + def _job_token(self, doc: Doc, step_tokens: dict[str, int]) -> int: + # The job aggregate concatenates the step aggregates' outputs + # verbatim (identity placement at the job level). Its token + # therefore folds in the per-step aggregate tokens so that any + # upstream change (workpiece move, transformer edit, step + # param change) propagates through to the job/encode cache. + payloads = [] + for layer in doc.layers: + if not layer.workflow: + continue + for step in layer.workflow.steps: + if not step.visible: + continue + if step.uid not in step_tokens: + continue + payloads.append( + { + "step_uid": step.uid, + "step_token": step_tokens.get(step.uid, 0), + "step_params": step.get_cache_params(), + "spxf": _canonical(step.per_step_transformers_dicts), + } + ) + payload: dict[str, Any] = {"kind": "job", "steps": payloads} + return _hash_int(payload) + + # ------------------------------------------------------------------ + # Node construction + # ------------------------------------------------------------------ + + def _make_request( + self, key: str, token: int, stage: Any, cacheable: bool = True + ) -> NodeRequest: + return NodeRequest( + key=key, + generation_id=self._generation_id, + stage=stage, + version_token=token, + cacheable=cacheable, + ) + + # ------------------------------------------------------------------ + # Compute stage construction + # ------------------------------------------------------------------ + + def _wp_stage(self, step: Step, wp: WorkPiece) -> StageSpec.Compute: + """ + Build a compute :class:`StageSpec.Compute` for the workpiece + node by delegating to :meth:`Step.build_compute_payload`. + + Step kinds that wire a real raygeo assembler override + ``build_compute_payload`` (e.g. :class:`ContourStep`, + :class:`EngraveStep`) to return both the :class:`Part` + (carrying vector geometry or an image source) and the + :class:`ComputePayload` (carrying the assembler spec). + + Per-workpiece transformers (e.g. ``OverscanTransformer``, + ``BidirScanOffsetTransformer``) are resolved into typed Rust + specs and attached to the payload so the Rust compute stage + applies them after assembly. + """ + part, payload = step.build_compute_payload(self._machine, wp) + step.populate_payload(payload, self._machine) + payload.transformers = self._build_transformer_specs( + step.per_workpiece_transformers_dicts, + workpiece=wp, + ) + + if step.uses_global_state and step.layer: + workflow = step.layer.workflow + if workflow: + chain = [s for s in workflow.steps if s.uses_global_state] + if step in chain: + idx = chain.index(step) + if idx > 0: + prev = chain[idx - 1] + payload.state_source_keys = [ + workpiece_key(wp.uid, prev.uid) + ] + return StageSpec.Compute(part=part, params=payload) + + def _assembler_params(self, step: Step, wp: WorkPiece) -> Any: + """ + Return a JSON-serialisable representation of the assembler spec + parameters that the step resolves for its machine. + + Delegates to :meth:`Step.assembler_token_params`. Returns + :data:`None` when the step exposes no assembler params; the + compute token is unaffected in that case. + """ + try: + return step.assembler_token_params(self._machine, wp) + except Exception: + logger.debug( + "Step %s has no assembler token params", + step.uid, + exc_info=True, + ) + return None + + # ------------------------------------------------------------------ + # Transformer spec construction + # ------------------------------------------------------------------ + + def _build_transformer_specs( + self, + transformer_dicts: list[dict[str, Any]], + *, + workpiece: WorkPiece | None = None, + ) -> list[Any]: + """Build typed Rust ``*Spec`` pyclasses from a list of + serialised transformer dicts. + + Instantiates each enabled transformer via the registry and + 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. + """ + transformers: list[OpsTransformer] = [] + for t_dict in transformer_dicts: + if not t_dict.get("enabled", True): + continue + name = t_dict.get("name") + if not name or not isinstance(name, str): + continue + cls = transformer_registry.get(name) + if cls is None: + logger.warning( + "Transformer %r not found in registry; skipping", + name, + ) + continue + try: + transformers.append(cls.from_dict(t_dict)) + except Exception: + logger.exception( + "Failed to instantiate transformer %r; skipping", + name, + ) + if not transformers: + return [] + stock = self._resolve_stock_geometries() + settings = self._transformer_settings() + + specs: list = [] + for t in transformers: + if not t.enabled: + continue + specs.append(t.to_spec(workpiece, stock, settings)) + return specs + + def _transformer_settings(self) -> 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. + """ + if self._machine is None: + return None + try: + native = bool(self._machine.driver.native_overscan) + except AttributeError: + native = False + return {"driver_native_overscan": native} + + def _resolve_stock_geometries(self) -> list[Any] | None: + """Return the world-space stock boundary geometries. + + Transformers such as CropTransformer use this to clip + per-workpiece ops to the machine's work area or to explicit + StockItems. + + Doc-owned :class:`StockItem` entries take precedence. The + machine workarea rectangle is used as a fallback only when + no doc stock exists. + """ + geos: list[Any] = [] + + if self._doc is not None: + for item in self._doc.stock_items: + if not item.visible: + continue + try: + geo = item.get_world_rect_geometry() + except Exception: + logger.debug( + "Failed to resolve stock geometry for %s", + item.uid, + exc_info=True, + ) + continue + if geo is not None and not geo.is_empty(): + geos.append(geo) + + if self._machine is not None and not geos: + try: + space = MachineSpace.from_machine(self._machine) + wx, wy, w, h = space.get_workarea_world_rect() + geo = Geometry() + geo.move_to(wx, wy) + geo.line_to(wx + w, wy) + geo.line_to(wx + w, wy + h) + geo.line_to(wx, wy + h) + geo.close_path() + geos.append(geo) + except Exception: + logger.debug( + "Failed to resolve machine workarea for stock", + exc_info=True, + ) + + return geos + + # ------------------------------------------------------------------ + # Step aggregate stage + # ------------------------------------------------------------------ + + def _step_stage( + self, + step: Step, + upstream: list[tuple[str, int, WorkPiece]], + ) -> StageSpec.Aggregate: + """ + Build an aggregate :class:`StageSpec.Aggregate` for the step + node. + + One :class:`AggregateGroup` per upstream workpiece compute node, + wrapped by that workpiece's start / end markers. Each input + carries the workpiece's world placement matrix (scale normalised + to ±1, sign preserved — absolute scale is handled via + ``target_dimensions`` for scalable artifacts) and the + workpiece's physical size as ``target_dimensions``. + + Per-step transformers (e.g. ``MultiPassTransformer``, + ``Optimize``) are resolved into typed Rust specs and attached + to :attr:`AggregateSpec.transformers` so the Rust aggregate + stage applies them after concatenation. ``MachineParams`` is + populated from the resolved machine so the aggregate's time + estimate is correct. + """ + groups: list[AggregateGroup] = [] + for wp_key, _token, wp in upstream: + placement = _workpiece_placement_matrix(wp) + target = wp.size + inp = AggregateInput( + source_key=wp_key, + placement_matrix=placement, + uid=wp.uid, + target_dimensions=target, + ) + start = Marker.WorkpieceStart(uid=wp.uid, _tag=True) + end = Marker.WorkpieceEnd(uid=wp.uid, _tag=True) + link_mode = LinkMode.none() + if step.uses_global_state: + link_mode = LinkMode.sequential( + safe_z=getattr(step, "safe_z", 2.0) + ) + groups.append( + AggregateGroup( + start_markers=[start], + inputs=[inp], + end_markers=[end], + link_mode=link_mode, + ) + ) + spec = AggregateSpec( + wrap_start=[], + groups=groups, + wrap_end=[], + machine=self._machine_params(), + transformers=self._build_transformer_specs( + step.per_step_transformers_dicts + ), + ) + return StageSpec.Aggregate(spec=spec) + + def _machine_params(self) -> MachineParams: + """Build :class:`MachineParams` from the resolved machine.""" + return MachineParams( + default_feed_rate=float(self._machine.max_cut_speed), + default_rapid_rate=float(self._machine.max_travel_speed), + acceleration=float(self._machine.acceleration), + ) + + # ------------------------------------------------------------------ + # Job aggregate stage + # ------------------------------------------------------------------ + + def _job_stage( + self, doc: Doc, step_tokens: dict[str, int] + ) -> StageSpec.Aggregate: + """ + Build the final job aggregate :class:`StageSpec.Aggregate`. + + One :class:`AggregateGroup` per layer, wrapped by + ``LayerStart`` / ``LayerEnd`` markers, containing one + :class:`AggregateInput` per visible step in that layer that has + workpiece compute nodes upstream (i.e. is present in + *step_tokens*). The whole aggregate is wrapped by + ``JobStart`` / ``JobEnd`` markers. ``MachineParams`` is + populated from the resolved machine so the aggregate's time + estimate is correct. + """ + groups: list[AggregateGroup] = [] + for layer in doc.layers: + if not layer.workflow: + continue + step_inputs: list[AggregateInput] = [] + for step in layer.workflow.steps: + if not step.visible: + continue + if step.uid not in step_tokens: + continue + sk = step_key(step.uid) + step_inputs.append( + AggregateInput( + source_key=sk, + placement_matrix=_IDENTITY_4X4, + uid=step.uid, + ) + ) + if not step_inputs: + continue + groups.append( + AggregateGroup( + start_markers=[ + Marker.LayerStart(uid=layer.uid, _tag=True) + ], + inputs=step_inputs, + end_markers=[Marker.LayerEnd(uid=layer.uid, _tag=True)], + ) + ) + spec = AggregateSpec( + wrap_start=[Marker.JobStart(_tag=True)], + groups=groups, + wrap_end=[Marker.JobEnd(_tag=True)], + machine=self._machine_params(), + ) + return StageSpec.Aggregate(spec=spec) + + # ------------------------------------------------------------------ + # Encoder stage + # ------------------------------------------------------------------ + + def _encode_stage(self, doc: Doc) -> EncodeSpec: + """Build the encoder :class:`EncodeSpec` for the job encode + node. + + The encoder receives machine-space ops from the upstream + ``job:machinexform`` node (the machine transform stage). + For Grbl machines the native Rust ``GcodeSpec`` is used + directly; for any other machine a + :class:`PythonEncoder` wraps the driver-specific encoder + callable. + """ + encoder = self._build_encoder(doc) + return EncodeSpec( + source_key=job_machinexform_key(), encoder=Encoder(encoder) + ) + + def _build_encoder(self, doc: Doc) -> Any: + """Resolve the encoder for the configured machine. + + Routes Grbl machines to the native Rust ``GcodeSpec`` and + every other machine to a :class:`PythonEncoder` wrapping the + driver-specific encoder callable. The pre-processing + transforms are handled by the upstream machine-transform + stage. + """ + machine = self._machine + assert machine is not None + + dialect = machine.dialect + if dialect is not None and _is_grbl(dialect): + return self._grbl_encoder_spec(doc) + + return PythonEncoder( + self._make_python_encoder_callable(machine, doc), + "driver.encode", + ) + + def _grbl_encoder_spec(self, doc: Doc) -> GcodeSpec: + """Build a native ``GcodeSpec`` for a Grbl machine. + + Receives machine-space ops from the upstream + ``job:machinexform`` node and encodes them directly on a + rayon thread without crossing the GIL. + """ + machine = self._machine + assert machine is not None + dialect = machine.dialect + assert dialect is not None + # Build a minimal Ops with estimated extents so path variables + # like ``job.extents[0..3]`` used in dialect templates are + # populated with reasonable values. The exact extents are + # computed later from the real ops at encode time (the + # machine-transform stage preserves bounding-box metadata). + approx_ops = _approximate_job_ops(doc) + context = build_encode_context(approx_ops, machine, doc) + return GcodeSpec( + dialect=dialect_to_spec(dialect, machine), + context_json=json.dumps(context), + ) + + def _build_machine_transform_stage(self, doc: Doc) -> MachineTransformSpec: + """Build the :class:`MachineTransformSpec` for the machine- + transform pipeline node. + + Collects the world→machine matrix, WCS offsets, and per-layer + rotary mapping config from the machine and document and + packages them into a serialisable spec that the Rust + ``MachineTransformCompute`` stage consumes. + """ + machine = self._machine + assert machine is not None + + space = MachineSpace.from_machine(machine) + + # World→machine 4x4 matrix. + w2m = space.get_world_to_machine_matrix() + + # Default WCS command offset. + default_wcs_offset = list( + space.get_command_offset( + wcs_offset=machine.get_active_wcs_offset(), + wcs_is_workarea_origin=machine.wcs_origin_is_workarea_origin, + ) + ) + + # Per-layer WCS offsets. + layer_wcs_offsets: list[tuple[str, list[float]]] = [] + for layer in doc.layers: + effective_wcs = layer.get_effective_wcs(machine) + wcs_off = machine.get_wcs_offset(effective_wcs) + cmd_offset = space.get_command_offset( + wcs_offset=wcs_off, + wcs_is_workarea_origin=machine.wcs_origin_is_workarea_origin, + ) + layer_wcs_offsets.append((layer.uid, list(cmd_offset))) + + # Per-layer rotary mappings. + rotary_mappings = self._build_rotary_mappings(doc, machine) + + return MachineTransformSpec( + source_key=job_key(), + linearize_curves=not machine.supports_curves, + world_to_machine=w2m.tolist(), + default_wcs_offset=default_wcs_offset, + layer_wcs_offsets=layer_wcs_offsets, + reverse_z=machine.reverse_z_axis, + rotary_mappings=rotary_mappings, + ) + + @staticmethod + def _build_rotary_mappings( + doc: Doc, + machine: Machine, + ) -> list: + """Build per-layer :class:`RotaryMappingSpec` entries.""" + mappings: list = [] + for layer in doc.layers: + if not layer.rotary_enabled: + continue + module = machine.get_rotary_module_for_layer(layer) + if module is None: + continue + + diameter = layer.rotary_diameter + gear_ratio = KinematicMath.gear_ratio( + module.rotary_type == RotaryType.ROLLERS, + diameter, + module.roller_diameter, + ) + + # Extract axis position and cylinder direction. + rot3 = module.transform[:3, :3].astype(np.float64).copy() + for col in range(3): + norm = np.linalg.norm(rot3[:, col]) + if norm > 1e-12: + rot3[:, col] /= norm + + mod_pos = module.transform[:3, 3].astype(np.float64) + axis_position_3d = mod_pos + rot3 @ module.axis_position + cylinder_dir = rot3[:, 0].copy() + norm = np.linalg.norm(cylinder_dir) + if norm > 1e-12: + cylinder_dir /= norm + + if module.mode == RotaryMode.TRUE_4TH_AXIS: + rotary_axis = module.axis.name + replaced_axis = None + else: + rotary_axis = "Y" + replaced_axis = module.axis.name + mappings.append( + RotaryMappingSpec( + layer_uid=layer.uid, + diameter=diameter, + gear_ratio=gear_ratio, + reverse=module.reverse_axis, + axis_position_3d=axis_position_3d.tolist(), + cylinder_dir=cylinder_dir.tolist(), + rotary_axis=rotary_axis, + replaced_axis=replaced_axis, + mm_per_rotation=module.mm_per_rotation, + ) + ) + return mappings + + def _make_python_encoder_callable( + self, machine: Machine, doc: Doc + ) -> Callable[[Any], Any]: + """Build a Python callable ``(ops) -> EncodeOutput`` that + invokes the driver-specific encoder directly on + machine-space ops. + + The pre-processing transforms (linearization, rotary mapping, + world→machine, WCS offset, Z-flip, AXIS_REPLACEMENT) are + handled by the upstream machine-transform stage, so this + callable only applies the final driver encoding step. + """ + if machine.driver_name: + try: + driver_cls = get_driver_cls(machine.driver_name) + except (ValueError, ImportError): + driver_cls = NoDeviceDriver + else: + driver_cls = NoDeviceDriver + + driver_encoder = driver_cls.create_encoder(machine) + + def encode(ops: Any) -> EncodeOutput: + encoded = driver_encoder.encode(ops, machine, doc) + if not isinstance(encoded, EncodedOutput): + raise TypeError( + "encoder must return EncodedOutput, " + f"got {type(encoded).__name__}" + ) + return EncodeOutput.MachineCode( + text=encoded.text, + op_to_machine_code=encoded.op_map.op_to_machine_code_bytes, + machine_code_to_op=encoded.op_map.machine_code_to_op_bytes, + ) + + return encode + + # ------------------------------------------------------------------ + # Encoder token + # ------------------------------------------------------------------ + + def _encode_token(self, doc: Doc, step_tokens: dict[str, int]) -> int: + """Compute the version token for the job encode node. + + Folds in the machine-transform node's token plus the encoder + identity so the cache invalidates when either the machine + transforms or the encoder config change. + """ + payload = { + "kind": "encode", + "mxform_token": self._machine_transform_token(doc, step_tokens), + "machine": _machine_token_payload(self._machine), + } + return _hash_int(payload) + + def _machine_transform_token( + self, doc: Doc, step_tokens: dict[str, int] + ) -> int: + """Compute the version token for the machine-transform node. + + Folds in the job aggregate's token plus the machine identity + (supports_curves, reverse_z, WCS config, rotary module config) + so any change to the machine or job invalidates the cache. + """ + payload = { + "kind": "machine_transform", + "job_token": self._job_token(doc, step_tokens), + "machine": _machine_token_payload(self._machine), + } + if self._machine is not None: + cfg = _machine_transform_config_payload(self._machine, doc) + payload.update(cfg) + return _hash_int(payload) + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + + +def _workpiece_placement_matrix(wp: WorkPiece) -> list[list[float]]: + """ + Build the 4×4 placement matrix for a workpiece's aggregate input. + + The workpiece's world transform is decomposed and re-composed with + the absolute scale normalised to ``1.0`` (the sign of the Y scale + is preserved to keep flips). Absolute scaling is handled by the + aggregate via ``target_dimensions`` for scalable artifacts, so the + placement matrix only carries translation, rotation, flip, and skew. + """ + world = wp.get_world_transform() + tx, ty, angle, _sx, sy, skew = world.decompose() + placement = Matrix.compose( + tx, ty, angle, 1.0, math.copysign(1.0, sy), skew + ) + return placement.to_4x4_list() + + +def _canonical(obj: Any) -> Any: + """ + Return *obj* in a form suitable for JSON round-tripping so that + structurally-equal inputs produce identical serialisations. + """ + try: + return json.loads(json.dumps(obj, sort_keys=True, default=str)) + except (TypeError, ValueError): + return str(obj) + + +def _hash_int(payload: Mapping[str, Any]) -> int: + """ + Produce a 63-bit positive integer hash of *payload*. + + Uses SHA-1 of a canonical JSON encoding so the value is stable + across Python processes (unlike :func:`hash`, which is randomised + per process for strings). + """ + blob = json.dumps(payload, sort_keys=True, default=str).encode("utf-8") + digest = hashlib.sha1(blob).digest() + # Take the first 8 bytes, mask the sign bit. + value = int.from_bytes(digest[:8], "big") + return value & 0x7FFFFFFFFFFFFFFF + + +# ---------------------------------------------------------------------- +# Stage construction +# ---------------------------------------------------------------------- +# Build raygeo :class:`StageSpec` instances for the step aggregate and +# job aggregate nodes. The per-workpiece compute stage is built by +# :meth:`IntentBuilder._wp_stage`, which selects the assembler spec +# from the step's ``ASSEMBLER_NAME``. + + +_IDENTITY_4X4: list[list[float]] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], +] + + +def _is_grbl(dialect: GcodeDialect) -> bool: + """Return True if *dialect* is the Grbl G-code dialect.""" + return dialect.uid == GRBL_DIALECT.uid + + +def _machine_token_payload(machine: Machine | None) -> Any: + """Build a JSON-serialisable representation of the machine + identity for the encode token.""" + if machine is None: + return None + return { + "driver_name": machine.driver_name, + "active_wcs": machine.active_wcs, + "gcode_precision": machine.gcode_precision, + "supports_curves": machine.supports_curves, + "supports_arcs": machine.supports_arcs, + "reverse_z_axis": machine.reverse_z_axis, + "max_cut_speed": machine.max_cut_speed, + "max_travel_speed": machine.max_travel_speed, + "acceleration": machine.acceleration, + "axis_extents": list(machine.axis_extents), + } + + +def _machine_transform_config_payload( + machine: Machine, doc: Doc +) -> dict[str, Any]: + """Build a JSON-serialisable payload of machine transform config + for the machine-transform token.""" + + payload: dict[str, Any] = { + "wcs_origin_is_workarea_origin": machine.wcs_origin_is_workarea_origin, + } + # Rotary module UIDs per layer (to detect rotary config changes). + for layer in doc.layers: + uid = layer.uid + if layer.rotary_enabled: + module = machine.get_rotary_module_for_layer(layer) + if module is not None: + payload[f"rotary:{uid}"] = { + "module_uid": module.uid, + "mode": module.mode.value, + "axis": module.axis.name, + "mm_per_rotation": module.mm_per_rotation, + "diameter": layer.rotary_diameter, + "roller_diameter": module.roller_diameter, + "rotary_type": module.rotary_type.value, + "reverse_axis": module.reverse_axis, + } + return payload + + +def _approximate_job_ops(doc: Doc) -> Ops: + """Build a minimal Ops spanning the estimated job extents. + + Used by :meth:`IntentBuilder._grbl_encoder_spec` so that + path variables like ``job.extents[0..3]`` are populated with + reasonable values before the real ops are available from the + pipeline. + + The extents are estimated from workpiece positions and sizes + in world space. + """ + xmin = ymin = float("inf") + xmax = ymax = float("-inf") + + for layer in doc.layers: + for wp in layer.all_workpieces: + tx, ty = wp.pos + sx, sy = wp.size if wp.size else (0, 0) + if sx > 0 and sy > 0: + xmin = min(xmin, tx) + ymin = min(ymin, ty) + xmax = max(xmax, tx + sx) + ymax = max(ymax, ty + sy) + + if xmin == float("inf"): + return Ops() + + ops = Ops() + ops.job_start() + ops.move_to(xmin, ymin, 0.0) + ops.line_to(xmax, ymax, 0.0) + ops.job_end() + return ops diff --git a/rayforge/pipeline/intent_controller.py b/rayforge/pipeline/intent_controller.py new file mode 100644 index 000000000..83db44c1f --- /dev/null +++ b/rayforge/pipeline/intent_controller.py @@ -0,0 +1,631 @@ +""" +Intent controller for the raygeo-backed pipeline. + +The :class:`IntentController` listens to the same Doc signals that +:class:`~rayforge.pipeline.pipeline.Pipeline` already listens to +(``descendant_updated``, ``descendant_transform_changed``, +``descendant_added``, ``descendant_removed``, ``job_assembly_invalidated``) +and rebuilds a raygeo :class:`Intent` whenever the document changes. + +On each debounced rebuild: + +1. :class:`~rayforge.pipeline.intent_builder.IntentBuilder` is called + to produce a fresh list of :class:`NodeRequest` objects from the + current :class:`Doc`. +2. The new list is wrapped into a raygeo :class:`Intent` via + :func:`create_intent_from_nodes`. +3. :meth:`Intent.update` diffs the previous intent against the new one + using the ``version_token`` values and evicts any stale cache entries + on the shared :class:`~raygeo.pipeline.execute.Pipeline`. +4. The new intent is executed via :func:`run_intent`; the + ``on_completed`` callback performs the epoch filter (discarding + results whose ``generation_id`` is older than the controller's + current generation) and then marshals a DOM reattachment back to the + application main thread via the shared task manager. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Protocol, + runtime_checkable, +) + +from blinker import Signal +from raygeo.cnc.execution.intent import ( + Intent, + create_intent_from_nodes, + run_intent, +) +from raygeo.pipeline.execute import Pipeline as RaygeoPipeline +from raygeo.pipeline.request import NodeRequest + +from .intent_builder import IntentBuilder, parse_workpiece_key +from .status_messages import status_message_for_key + +if TYPE_CHECKING: + from ..core.doc import Doc + from ..core.item import DocItem + from ..core.step import Step + from ..core.workpiece import WorkPiece + from ..machine.models.machine import Machine + +from raygeo.pipeline.completed import ErrorKind + +logger = logging.getLogger(__name__) + + +# Debounce window for signal-driven intent rebuilds (milliseconds). +REBUILD_DEBOUNCE_MS = 200 + +# Upper bound on node keys kept in the active-progress window. +MAX_ACTIVE_PROGRESS_KEYS = 8 + +# How many active statuses are shown before collapsing into (+N more). +ACTIVE_PROGRESS_DISPLAY_LIMIT = 3 + + +@runtime_checkable +class _DelayedScheduler(Protocol): + """The subset of :class:`TaskManager` the controller depends on. + + Decoupling from the concrete :class:`TaskManager` lets tests supply + a minimal fake without needing the asyncio loop / worker pool the + real one wires up. + """ + + def schedule_delayed_on_main_thread( + self, + delay_ms: int, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: ... + + def schedule_on_main_thread( + self, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: ... + + def run_thread( + self, + func: Callable[..., Any], + *args: Any, + key: Any | None = None, + when_done: Callable[[Any], None] | None = None, + **kwargs: Any, + ) -> Any: ... + + +class IntentController: + """ + Owns a raygeo :class:`Intent` and the surrounding rebuild lifecycle. + + Pairs with the existing :class:`~rayforge.pipeline.pipeline.Pipeline` + instance; it consumes the same signals but generates a parallel, + cache-aware Intent that the future pipeline cutover will use. + """ + + def __init__( + self, + doc: Doc | None, + task_manager: _DelayedScheduler, + machine: Machine | None = None, + raygeo_pipeline: RaygeoPipeline | None = None, + ): + self._doc: Doc | None = doc + self._task_manager = task_manager + self._machine = machine + self._raygeo_pipeline: RaygeoPipeline = ( + raygeo_pipeline or RaygeoPipeline() + ) + self._intent: Intent | None = None + self._generation_id: int = 0 + self._rebuild_timer: Any | None = None + self._rebuilding: bool = False + self._rebuild_pending: bool = False + self._rebuild_task: Any | None = None + self._pause_count: int = 0 + self._auto_rebuild: bool = True + self._data_stale_flag: bool = False + # Node keys currently reported as active by ``on_batch_progress`` + # mapped to their translated status message, in first-seen order. + # Completed nodes are removed via the ``\t{key}`` completion + # payload so a few parallel tasks can be shown at once. + self._active_progress: dict[str, str] = {} + # Flat map from node key back to the originating :class:`DocItem` + # for DOM reattachment. Rebuilt on every successful + # ``IntentBuilder.build`` call. + self._key_to_item: dict[str, DocItem] = {} + self._workpieces_by_uid: dict[str, WorkPiece] = {} + self._steps_by_uid: dict[str, Step] = {} + + # Signals for notifying the UI of generation progress. + self.workpiece_artifact_ready = Signal() + self.step_artifact_ready = Signal() + self.job_aggregate_ready = Signal() + self.job_generation_finished = Signal() + self.job_time_updated = Signal() + self.progress_changed = Signal() + self.rebuild_started = Signal() + self.rebuild_finished = Signal() + self.data_stale = Signal() + self.pipeline_error = Signal() + self.pipeline_warnings = Signal() + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def raygeo_pipeline(self) -> RaygeoPipeline: + return self._raygeo_pipeline + + @property + def intent(self) -> Intent | None: + return self._intent + + @property + def generation_id(self) -> int: + return self._generation_id + + @property + def is_paused(self) -> bool: + return self._pause_count > 0 + + @property + def is_rebuild_pending(self) -> bool: + return self._rebuild_timer is not None or self._rebuilding + + @property + def is_data_stale(self) -> bool: + return self._data_stale_flag + + @property + def auto_rebuild(self) -> bool: + return self._auto_rebuild + + @auto_rebuild.setter + def auto_rebuild(self, value: bool) -> None: + if self._auto_rebuild == value: + return + self._auto_rebuild = value + if value and self._data_stale_flag: + self._data_stale_flag = False + self._schedule_rebuild() + + def pause(self) -> None: + self._pause_count += 1 + + def resume(self) -> None: + if self._pause_count == 0: + return + self._pause_count -= 1 + if self._pause_count == 0 and self._data_stale_flag: + self._data_stale_flag = False + if self._auto_rebuild: + self._schedule_rebuild() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Connect to the document's bubbled signals.""" + if self._doc is None: + return + doc = self._doc + doc.descendant_updated.connect(self._on_doc_changed) + doc.descendant_transform_changed.connect(self._on_doc_changed) + doc.descendant_added.connect(self._on_doc_changed) + doc.descendant_removed.connect(self._on_doc_changed) + doc.job_assembly_invalidated.connect(self._on_doc_changed) + + def disconnect(self) -> None: + """Disconnect from the document's signals.""" + if self._doc is None: + return + doc = self._doc + doc.descendant_updated.disconnect(self._on_doc_changed) + doc.descendant_transform_changed.disconnect(self._on_doc_changed) + doc.descendant_added.disconnect(self._on_doc_changed) + doc.descendant_removed.disconnect(self._on_doc_changed) + doc.job_assembly_invalidated.disconnect(self._on_doc_changed) + + # ------------------------------------------------------------------ + # Signal handling (debounced) + # ------------------------------------------------------------------ + + def _on_doc_changed(self, *args: Any, **kwargs: Any) -> None: + """Trigger a debounced intent rebuild on any doc change.""" + if self._pause_count > 0 or not self._auto_rebuild: + if not self._data_stale_flag: + self._data_stale_flag = True + self.data_stale.send(self) + return + self._schedule_rebuild() + + def set_doc(self, doc: Doc | None) -> None: + """Replace the document and trigger a rebuild. + + Preserves the existing :class:`RaygeoPipeline` and + :class:`~raygeo.Intent` so cache entries survive the doc + swap. + """ + self.disconnect() + self._doc = doc + if doc is not None: + self.connect() + self.force_rebuild() + + def set_machine(self, machine: Machine | None) -> None: + """Replace the machine and trigger a rebuild. + + Preserves the existing :class:`RaygeoPipeline` and + :class:`~raygeo.Intent` so cache entries survive the machine + swap. + """ + self._machine = machine + self.force_rebuild() + + def force_rebuild(self) -> None: + """Cancel any pending debounce and rebuild immediately. + + If a rebuild is already running on the background thread, the + in-flight run is signalled to cancel so the next generation + starts as soon as possible. + """ + if self._rebuild_timer is not None: + self._rebuild_timer.cancel() + self._rebuild_timer = None + if self._rebuilding: + self._rebuild_pending = True + if self._intent is not None: + self._intent.cancel() + return + self._rebuild() + + def _schedule_rebuild(self) -> None: + if self._rebuild_timer is not None: + self._rebuild_timer.cancel() + if self._rebuilding: + self._rebuild_pending = True + if self._intent is not None: + self._intent.cancel() + return + self._rebuild_timer = ( + self._task_manager.schedule_delayed_on_main_thread( + REBUILD_DEBOUNCE_MS, + self._rebuild, + ) + ) + + def _rebuild(self) -> None: + """Build a fresh intent from the doc and execute it. + + The heavy work (intent construction including raster rendering, + and pipeline execution) runs on a background thread via the + task manager so the GTK main loop stays responsive. + ``rebuild_started`` fires before the thread starts; + ``rebuild_finished`` fires on the main thread after the thread + completes. + """ + if self._rebuilding: + return + self._rebuild_timer = None + self._generation_id += 1 + self._rebuilding = True + self._active_progress = {} + gen = self._generation_id + self.rebuild_started.send(self) + + def _worker() -> None: + if self._doc is None or self._machine is None: + return + builder = IntentBuilder( + machine=self._machine, + generation_id=gen, + loop=getattr(self._task_manager, "loop", None), + ) + nodes = builder.build(self._doc) + self._refresh_key_to_item_map(nodes) + new_intent = create_intent_from_nodes(nodes) + if self._intent is None: + self._intent = new_intent + else: + self._intent.update(new_intent, pipeline=self._raygeo_pipeline) + if nodes: + try: + run_intent( + self._intent, + on_completed=self._on_completed, + on_batch_progress=self._on_batch_progress, + pipeline=self._raygeo_pipeline, + ) + except RuntimeError as exc: + logger.debug("run_intent failed: %s", exc) + + def _on_done(_task: Any) -> None: + self._rebuild_task = None + self._rebuilding = False + if self._rebuild_pending: + self._rebuild_pending = False + self._rebuild() + else: + self._task_manager.schedule_on_main_thread( + self._emit_rebuild_finished + ) + + self._rebuild_task = self._task_manager.run_thread( + _worker, when_done=_on_done, key="intent-rebuild" + ) + + def _emit_rebuild_finished(self) -> None: + """Emit ``rebuild_finished`` on the main thread.""" + self.rebuild_finished.send(self) + + def _emit_pipeline_error(self, error_kind: ErrorKind) -> None: + """Emit ``pipeline_error`` on the main thread.""" + self.pipeline_error.send(self, error_kind=error_kind) + + def _emit_pipeline_warnings(self, warnings: list) -> None: + """Emit ``pipeline_warnings`` on the main thread.""" + self.pipeline_warnings.send(self, warnings=warnings) + + # ------------------------------------------------------------------ + # on_completed → epoch filter → DOM reattachment via main-thread + # schedule + # ------------------------------------------------------------------ + + def _on_completed(self, node: Any) -> None: + """ + raygeo ``on_completed`` callback. + + Invoked on a rayon worker thread with the GIL held. We check + the node's ``generation_id`` against the controller's current + generation (epoch filter) and, if still current, schedule a + DOM reattachment onto the application main thread via the + shared task manager. + """ + gen = node.generation_id + if gen < self._generation_id: + logger.debug( + "Discarding superseded result for %s (gen %s < %s)", + node.key, + gen, + self._generation_id, + ) + return + if node.error is not None: + kind = node.error_kind + if kind == ErrorKind.CANCELLED: + logger.debug("Node %s was cancelled", node.key) + return + if kind == ErrorKind.UPSTREAM_FAILED: + logger.debug("Node %s: upstream failed", node.key) + return + if kind == ErrorKind.CACHE_BUDGET_EXCEEDED: + logger.error("Node %s failed: %s", node.key, node.error) + self._task_manager.schedule_on_main_thread( + self._emit_pipeline_error, kind + ) + return + # Internal errors (cache type mismatch, etc.) — log only. + logger.error("Node %s failed: %s", node.key, node.error) + return + key = node.key + item = self._key_to_item.get(key) + if item is None: + logger.debug( + "No DocItem mapped for completed node %s; skipping", + key, + ) + return + output = node.output + warnings = getattr(output, "warnings", None) or [] + if warnings: + self._task_manager.schedule_on_main_thread( + self._emit_pipeline_warnings, warnings + ) + self._task_manager.schedule_on_main_thread( + self._reattach, key, item, output + ) + + def _on_batch_progress(self, fraction: float, message: str) -> None: + """raygeo ``on_batch_progress`` callback. + + Invoked on a rayon worker thread with the GIL held. Relays + the aggregate progress fraction and node key to the main + thread, where the key is translated into a user-facing status + message. + """ + self._task_manager.schedule_on_main_thread( + self._update_rebuild_progress, fraction, message + ) + + def _update_rebuild_progress(self, fraction: float, key: str) -> None: + """Update the ``intent-rebuild`` task with progress and status. + + Runs on the application main thread. The batch progress + payload is folded into a small window of currently-active node + keys so that a few parallel tasks can be shown at once: + + * ``{key}`` or ``{key}\\t{activity}`` marks a node as active; + * ``\\t{key}`` (a completion marker) removes that node; + * ``""`` (the final tick) clears the whole window. + + Each key is translated into a translatable status message and + pushed into the rebuild :class:`Task` so the UI can display it. + The :attr:`progress_changed` signal is still emitted with the + bare node key for backward compatibility with existing + listeners. + """ + task = self._rebuild_task + if not key: + self._active_progress.clear() + node_key = "" + elif key.startswith("\t"): + self._active_progress.pop(key[1:], None) + node_key = "" + else: + node_key, _sep, _detail = key.partition("\t") + if node_key: + self._active_progress[node_key] = status_message_for_key( + key, self._workpieces_by_uid, self._steps_by_uid + ) + if len(self._active_progress) > MAX_ACTIVE_PROGRESS_KEYS: + self._active_progress.pop( + next(iter(self._active_progress)) + ) + if task is not None: + task.update( + progress=fraction, + message=self._format_active_progress(), + ) + self.progress_changed.send(self, fraction=fraction, message=node_key) + + def _format_active_progress(self) -> str: + """Join the currently-active node statuses for the progress bar. + + At most three statuses are shown on separate lines; further + active nodes are summarised as a ``(+N more)`` suffix so the + overlay stays compact while still hinting at parallel work. + """ + items = list(self._active_progress.values()) + if not items: + return "" + text = "\n".join(items[:ACTIVE_PROGRESS_DISPLAY_LIMIT]) + if len(items) > ACTIVE_PROGRESS_DISPLAY_LIMIT: + text += "\n" + _("(+{n} more)").format( + n=len(items) - ACTIVE_PROGRESS_DISPLAY_LIMIT + ) + return text + + def _reattach(self, key: str, item: DocItem, output: Any) -> None: + """ + Reattach a completed node's output onto the owning DocItem and + emit the corresponding signal so the UI can update. + + Runs on the application main thread. Dispatches on the node + key shape: + + * ``workpiece:{wp_uid}:{step_uid}`` → + :attr:`workpiece_artifact_ready` + * ``step:{step_uid}`` → :attr:`step_artifact_ready` + * ``job`` → :attr:`job_aggregate_ready` + * ``job:encode`` → :attr:`job_generation_finished` (and + :attr:`job_time_updated` when a time estimate is available) + """ + gen = self._generation_id + if key.startswith("workpiece:"): + parsed = parse_workpiece_key(key) + if parsed is None: + logger.warning("Malformed workpiece key: %s", key) + return + wp_uid, step_uid = parsed + workpiece = self._find_workpiece(wp_uid) + step = self._find_step(step_uid) + if workpiece is not None and step is not None: + self.workpiece_artifact_ready.send( + self, + step=step, + workpiece=workpiece, + output=output, + generation_id=gen, + ) + elif key.startswith("step:"): + step = self._find_step(key.split(":", 1)[1]) + if step is not None: + self.step_artifact_ready.send( + self, step=step, output=output, generation_id=gen + ) + elif key == "job": + self.job_aggregate_ready.send( + self, output=output, generation_id=gen + ) + time_estimate = ( + output.time_estimate if output is not None else None + ) + self.job_time_updated.send(self, total_seconds=time_estimate) + elif key == "job:encode": + self.job_generation_finished.send( + self, handle=output, task_status="completed" + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _refresh_key_to_item_map(self, nodes: list[NodeRequest]) -> None: + """ + Build a flat ``key -> DocItem`` map from the freshly built + ``NodeRequest`` list so the ``on_completed`` epoch-filtered + callback can reattach outputs onto the originating WorkPiece or + Step without needing to re-walk the Doc. + """ + + self._key_to_item = {} + if self._doc is None: + return + # Index workpieces and steps by uid for fast lookup. Kept on + # the instance so :meth:`_reattach` can resolve the owning + # DocItem for a node key without re-walking the doc. + workpieces: dict[str, WorkPiece] = {} + steps: dict[str, Step] = {} + for layer in self._doc.layers: + for wp in layer.all_workpieces: + workpieces[wp.uid] = wp + if layer.workflow: + for step in layer.workflow.steps: + steps[step.uid] = step + self._workpieces_by_uid = workpieces + self._steps_by_uid = steps + + for n in nodes: + key = n.key + # ``workpiece:{wp_uid}:{step_uid}`` + if key.startswith("workpiece:"): + parsed = parse_workpiece_key(key) + if parsed is None: + raise ValueError( + f"Malformed workpiece key in node key map: {key!r}" + ) + wp_uid, _step_uid = parsed + wp = workpieces.get(wp_uid) + if wp is not None: + self._key_to_item[key] = wp + # ``step:{step_uid}`` + elif key.startswith("step:"): + _, s_uid = key.split(":") + step = steps.get(s_uid) + if step is not None: + self._key_to_item[key] = step + # ``job`` or ``job:encode`` + elif key == "job" or key == "job:encode": + self._key_to_item[key] = self._doc + + def _find_workpiece(self, uid: str) -> WorkPiece | None: + return self._workpieces_by_uid.get(uid) + + def _find_step(self, uid: str) -> Step | None: + return self._steps_by_uid.get(uid) + + def shutdown(self) -> None: + """Cancel any pending rebuild timer and disconnect signals.""" + if self._rebuild_timer is not None: + self._rebuild_timer.cancel() + self._rebuild_timer = None + try: + self.disconnect() + except Exception: + logger.warning( + "Error during IntentController shutdown", + exc_info=True, + ) diff --git a/rayforge/pipeline/pipeline.py b/rayforge/pipeline/pipeline.py new file mode 100644 index 000000000..b79b57aeb --- /dev/null +++ b/rayforge/pipeline/pipeline.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable, Generator +from contextlib import contextmanager +from typing import ( + TYPE_CHECKING, + Any, +) + +from blinker import Signal +from raygeo.pipeline.completed import ErrorKind +from raygeo.pipeline.execute import Pipeline as RaygeoPipeline + +from ..core.capability import MachineCapability +from ..core.doc import Doc +from ..core.workpiece import WorkPiece +from ..machine.kinematic_mapping import KinematicMapping +from .artifact import BaseArtifactHandle, JobArtifact, WorkPieceArtifact +from .artifact.store import ArtifactStore +from .encoder.base import EncodedOutput, MachineCodeOpMap +from .intent_controller import IntentController + +if TYPE_CHECKING: + from ..core.step import Step + from ..machine.models.machine import Machine + from ..shared.tasker.manager import TaskManager + + +logger = logging.getLogger(__name__) + + +class Pipeline: + """ + Public facade over the raygeo-backed intent pipeline. + + Owns the :class:`ArtifactStore` integration: translates the raw + raygeo outputs emitted by its internal :class:`IntentController` + into refcounted artifact handles that the UI and export paths + consume, and exposes the signal/property surface the rest of the + application expects. + + Consumers (:class:`~rayforge.doceditor.editor.DocEditor`, + :class:`ViewManager`, UI widgets, test code) should depend on this + class only. :class:`IntentController` and + :class:`~rayforge.pipeline.intent_builder.IntentBuilder` are + implementation details of the facade and may change without + notice. + """ + + def __init__( + self, + doc: Doc | None, + task_manager: TaskManager, + artifact_store: ArtifactStore, + machine: Machine | None, + cache_budget_bytes: int = 2 * 1024 * 1024 * 1024, + ): + if machine is None: + raise RuntimeError("Machine is not configured in context") + + self._doc: Doc | None = doc + self._task_manager = task_manager + self._store = artifact_store + self._machine = machine + self._is_shutting_down = False + self._last_known_busy = False + + self._wp_handles: dict[tuple[str, str], BaseArtifactHandle] = {} + self._last_aggregate_output: Any = None + self._last_job_handle: BaseArtifactHandle | None = None + + self.processing_state_changed = Signal() + self.workpiece_starting = Signal() + self.workpiece_artifact_ready = Signal() + self.workpiece_artifact_adopted = Signal() + self.step_assembly_starting = Signal() + self.job_generation_finished = Signal() + self.job_time_updated = Signal() + self.visual_chunk_available = Signal() + self.data_stale = Signal() + self.pipeline_error = Signal() + self.assembly_warnings = Signal() + + self._raygeo_pipeline = RaygeoPipeline(budget_bytes=cache_budget_bytes) + self._intent_ctl = IntentController( + doc=doc, + task_manager=task_manager, + machine=machine, + raygeo_pipeline=self._raygeo_pipeline, + ) + self._connect_ctl_signals() + machine.changed.connect(self._on_machine_changed) + + if doc: + self._intent_ctl.connect() + if self._doc and self._has_workflow_content(): + self._intent_ctl._schedule_rebuild() + + def _has_workflow_content(self) -> bool: + if not self._doc: + return False + for layer in self._doc.layers: + if ( + layer.workflow + and layer.workflow.steps + and layer.all_workpieces + ): + return True + return False + + def _can_generate_job(self) -> bool: + """True when the current doc can produce a job aggregate. + + Mirrors the intent builder's criteria: a visible step with at + least one workpiece in its layer. Without these the builder + emits no job node, so any rebuild would be a no-op and asking + for a job artifact would spin forever. + """ + if not self._doc: + return False + for layer in self._doc.layers: + if not layer.workflow: + continue + if not layer.all_workpieces: + continue + if any(step.visible for step in layer.workflow.steps): + return True + return False + + def _connect_ctl_signals(self) -> None: + ctl = self._intent_ctl + ctl.workpiece_artifact_ready.connect(self._on_wp_output) + ctl.job_aggregate_ready.connect(self._on_job_aggregate) + ctl.job_generation_finished.connect(self._on_job_encoded) + ctl.job_time_updated.connect(self._job_time_relay) + ctl.rebuild_started.connect(self._on_rebuild_started) + ctl.rebuild_finished.connect(self._on_rebuild_finished) + ctl.data_stale.connect(self._on_data_stale) + ctl.pipeline_error.connect(self._on_pipeline_error) + ctl.pipeline_warnings.connect(self._on_pipeline_warnings) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def doc(self) -> Doc | None: + return self._doc + + @doc.setter + def doc(self, new_doc: Doc | None) -> None: + if self._doc is new_doc: + return + self._doc = new_doc + self._wp_handles.clear() + self._last_job_handle = None + self._last_aggregate_output = None + self._intent_ctl.set_doc(new_doc) + + @property + def machine(self) -> Machine | None: + return self._machine + + @property + def task_manager(self) -> TaskManager: + return self._task_manager + + @property + def artifact_store(self) -> ArtifactStore: + return self._store + + @property + def data_generation_id(self) -> int: + return self._intent_ctl.generation_id + + @property + def last_completed_handle(self) -> BaseArtifactHandle | None: + return self._last_job_handle + + @property + def auto_pipeline(self) -> bool: + return self._intent_ctl.auto_rebuild + + @auto_pipeline.setter + def auto_pipeline(self, value: bool) -> None: + self._intent_ctl.auto_rebuild = value + + @property + def is_paused(self) -> bool: + return self._intent_ctl.is_paused + + @property + def is_data_stale(self) -> bool: + return self._intent_ctl.is_data_stale + + @property + def is_busy(self) -> bool: + return ( + self._intent_ctl.is_rebuild_pending + or self._task_manager.has_tasks() + ) + + # ------------------------------------------------------------------ + # Pause / resume + # ------------------------------------------------------------------ + + def pause(self) -> None: + self._intent_ctl.pause() + + def resume(self) -> None: + self._intent_ctl.resume() + + @contextmanager + def paused(self) -> Generator[None, None, None]: + self.pause() + try: + yield + finally: + self.resume() + + # ------------------------------------------------------------------ + # Machine + # ------------------------------------------------------------------ + + def set_machine(self, machine: Machine) -> None: + if self._machine is machine: + return + if self._machine is not None: + self._machine.changed.disconnect(self._on_machine_changed) + self._machine = machine + self._intent_ctl.set_machine(machine) + machine.changed.connect(self._on_machine_changed) + + # ------------------------------------------------------------------ + # Recalculate + # ------------------------------------------------------------------ + + def recalculate(self, force: bool = False) -> None: + self._intent_ctl.force_rebuild() + + def set_cache_budget_bytes(self, budget: int) -> None: + """Update the pipeline cache byte budget dynamically.""" + self._raygeo_pipeline.set_cache_budget_bytes(budget) + + # ------------------------------------------------------------------ + # Shutdown + # ------------------------------------------------------------------ + + def shutdown(self) -> None: + self._is_shutting_down = True + if self._machine is not None: + self._machine.changed.disconnect(self._on_machine_changed) + self._intent_ctl.shutdown() + self._wp_handles.clear() + self._last_job_handle = None + self._last_aggregate_output = None + + # ------------------------------------------------------------------ + # IntentController signal handlers + # ------------------------------------------------------------------ + + def _on_machine_changed(self, sender, **kwargs) -> None: + """Trigger a rebuild when the machine config changes (e.g. + rotary mode, supports_curves, axis settings).""" + self._intent_ctl._schedule_rebuild() + + def _on_rebuild_started(self, sender) -> None: + self._set_busy(True) + + def _on_rebuild_finished(self, sender) -> None: + self._task_manager.schedule_on_main_thread(self._check_busy) + + def _on_data_stale(self, sender) -> None: + self.data_stale.send(self) + + def _on_pipeline_error(self, sender, *, error_kind) -> None: + if error_kind == ErrorKind.CACHE_BUDGET_EXCEEDED: + message = ( + "Scene too complex for the current cache budget. " + "Reduce the number of layers or increase the cache budget." + ) + else: + message = f"Pipeline error: {error_kind.value}" + logger.error("Pipeline execution error: %s", message) + self.pipeline_error.send(self, message=message) + + def _on_pipeline_warnings(self, sender, *, warnings) -> None: + """Forward assembler warnings to the UI for translation.""" + if not warnings: + return + self.assembly_warnings.send(self, warnings=warnings) + + def _check_busy(self) -> None: + self._set_busy(self.is_busy) + + def _set_busy(self, busy: bool) -> None: + if self._last_known_busy != busy: + self._last_known_busy = busy + self.processing_state_changed.send(self, is_processing=busy) + + def _on_wp_output( + self, sender, *, step, workpiece, output, generation_id + ) -> None: + 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) + artifact = WorkPieceArtifact( + ops=output.ops, + is_scalable=output.is_scalable, + generation_size=gen_size, + generation_id=generation_id, + source_dimensions=source_dims, + ) + old = self._wp_handles.pop((workpiece.uid, step.uid), None) + if old is not None: + self._store.release(old) + handle = self._store.put(artifact, "wp") + self._wp_handles[(workpiece.uid, step.uid)] = handle + self.workpiece_artifact_ready.send( + self, + step=step, + workpiece=workpiece, + handle=handle, + generation_id=generation_id, + ) + + def _on_job_aggregate(self, sender, *, output, generation_id) -> None: + if self._is_shutting_down: + return + if output is not None: + self._last_aggregate_output = output + time_est = output.time_estimate + self.job_time_updated.send(self, total_seconds=time_est) + + def _on_job_encoded(self, sender, *, handle, task_status) -> None: + if self._is_shutting_down: + return + if handle is None: + self.job_generation_finished.send( + self, handle=None, task_status=task_status + ) + return + agg = self._last_aggregate_output + if agg is None: + logger.debug("Encode finished but no aggregate output cached") + return + + text = handle.text or "" + op_to_mc = handle.op_to_machine_code + mc_to_op = handle.machine_code_to_op + encoded = EncodedOutput( + text=text, + op_map=MachineCodeOpMap( + op_to_machine_code=op_to_mc, + machine_code_to_op=mc_to_op, + ), + ) + + ops = agg.ops + distance = ops.distance() if ops else 0.0 + + mapped_ops = None + if ( + ops + and self._doc + and self._machine + and self._doc.has_rotary_layer + and MachineCapability.ROTARY in self._machine.get_capabilities() + ): + mapped_ops = ops.copy() + KinematicMapping.apply_to_job_ops( + mapped_ops, + self._doc, + self._machine, + apply_gear_ratio=False, + ) + + artifact = JobArtifact( + ops=ops, + distance=distance, + generation_id=self._intent_ctl.generation_id, + time_estimate=agg.time_estimate, + encoded_output=encoded, + mapped_ops=mapped_ops, + ) + if self._last_job_handle is not None: + self._store.release(self._last_job_handle) + job_handle = self._store.put(artifact, "job") + self._last_job_handle = job_handle + self.job_generation_finished.send( + self, handle=job_handle, task_status=task_status + ) + + def _job_time_relay(self, sender, *, total_seconds) -> None: + self.job_time_updated.send(self, total_seconds=total_seconds) + + # ------------------------------------------------------------------ + # Public API for artifact access + # ------------------------------------------------------------------ + + def get_artifact_handle( + self, step_uid: str, workpiece_uid: str + ) -> BaseArtifactHandle | None: + return self._wp_handles.get((workpiece_uid, step_uid)) + + def get_artifact(self, step: Step, workpiece: WorkPiece) -> Any: + handle = self._wp_handles.get((workpiece.uid, step.uid)) + if handle is None: + return None + return self._store.get(handle) + + def get_existing_job_handle(self) -> BaseArtifactHandle | None: + return self._last_job_handle + + # ------------------------------------------------------------------ + # Job generation + # ------------------------------------------------------------------ + + def generate_job(self) -> None: + def no_op(handle, error): + if error: + logger.error(f"Fire-and-forget job generation failed: {error}") + + self.generate_job_artifact(when_done=no_op) + + def generate_job_artifact( + self, + when_done: Callable[ + [BaseArtifactHandle | None, Exception | None], None + ], + ): + if not self._doc: + when_done(None, RuntimeError("No document is loaded.")) + return + + if self._last_job_handle is not None: + when_done(self._last_job_handle, None) + return + + if not self._can_generate_job(): + when_done( + None, + RuntimeError( + "The document has no visible steps with workpieces " + "to assemble." + ), + ) + return + + def _on_finished(sender, *, handle, task_status): + self.job_generation_finished.disconnect(_on_finished) + when_done(handle, None) + + self.job_generation_finished.connect(_on_finished, weak=False) + self._intent_ctl.force_rebuild() + + async def generate_job_artifact_async( + self, + ) -> BaseArtifactHandle | None: + future = asyncio.get_running_loop().create_future() + + def _when_done(handle, error): + if not future.done(): + if error: + future.set_exception(error) + else: + future.set_result(handle) + + self.generate_job_artifact(when_done=_when_done) + return await future diff --git a/rayforge/pipeline/stage/__init__.py b/rayforge/pipeline/stage/__init__.py new file mode 100644 index 000000000..cb8b7f276 --- /dev/null +++ b/rayforge/pipeline/stage/__init__.py @@ -0,0 +1,4 @@ +# assembler_helpers is the only remaining module from the old +# stage package. It is kept here because Step subclasses, +# IntentBuilder, and tests import MachineDefaults and other +# helper types from it. diff --git a/rayforge/pipeline/stage/assembler_helpers.py b/rayforge/pipeline/stage/assembler_helpers.py new file mode 100644 index 000000000..4f177aca2 --- /dev/null +++ b/rayforge/pipeline/stage/assembler_helpers.py @@ -0,0 +1,450 @@ +""" +Helper functions for the assembler-based pipeline. + +These functions absorb the Part-construction, image-preprocessing, +and result-wrapping logic that currently lives inside each producer's +``run()`` method, so that the stage can call raygeo assemblers +directly without needing producer class instances. +""" + +from __future__ import annotations + +import logging +from enum import Enum, auto +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, +) + +import numpy as np +from raygeo.geo import Geometry, Matrix +from raygeo.image.grayscale import ( + compute_auto_levels, + normalize_grayscale, +) +from raygeo.ops.part import Part +from raygeo.ops.types import RasterMode + +from ...core.vectorization_spec import TraceSpec +from ...image.dither import DitherAlgorithm, surface_to_dithered_array +from ...image.tracing import trace_surface +from ...image.util.grayscale import surface_to_binary, surface_to_grayscale + +if TYPE_CHECKING: + import cairo + + from ...core.workpiece import WorkPiece + +logger = logging.getLogger(__name__) + + +class DepthMode(Enum): + """Rasterisation depth mode. + + Each mode controls how pixel intensity maps to laser output: + + * ``POWER_MODULATION`` — variable power proportional to darkness. + * ``CONSTANT_POWER`` — binary mask, constant-power scan lines. + * ``DITHER`` — Floyd-Steinberg / ordered dither to binary. + * ``MULTI_PASS`` — repeated Z-stepped passes through the depth. + """ + + POWER_MODULATION = auto() + CONSTANT_POWER = auto() + DITHER = auto() + MULTI_PASS = auto() + + @property + def display_name(self) -> str: + names = { + DepthMode.POWER_MODULATION: _("Variable Power"), + DepthMode.CONSTANT_POWER: _("Constant Power"), + DepthMode.DITHER: _("Dither"), + DepthMode.MULTI_PASS: _("Multiple Depths"), + } + return names[self] + + @property + def short_name(self) -> str: + names = { + DepthMode.POWER_MODULATION: _("Variable"), + DepthMode.CONSTANT_POWER: _("Constant"), + DepthMode.DITHER: _("Dither"), + DepthMode.MULTI_PASS: _("Multi-Pass"), + } + return names[self] + + @property + def raygeo_name(self) -> str: + """Return the string expected by the raygeo ``raster()`` call.""" + names = { + DepthMode.POWER_MODULATION: "power_modulated", + DepthMode.CONSTANT_POWER: "mask_scan", + DepthMode.DITHER: "dither", + DepthMode.MULTI_PASS: "multi_pass", + } + return names[self] + + @property + def raster_mode(self) -> RasterMode: + """Return the :class:`RasterMode` for this depth mode.""" + _raster_mode_map = { + DepthMode.POWER_MODULATION: RasterMode.VARIABLE_POWER, + DepthMode.CONSTANT_POWER: RasterMode.CONSTANT_POWER, + DepthMode.DITHER: RasterMode.CONSTANT_POWER, + DepthMode.MULTI_PASS: RasterMode.DEPTH_MAP, + } + return _raster_mode_map[self] + + +def _trace_surface_to_mm_geometry( + surface: cairo.ImageSurface, + workpiece: WorkPiece, + threshold: float = 0.5, + auto_threshold: bool = True, + invert: bool = False, +) -> Geometry | None: + """Trace a rendered surface into a single Geometry in mm-space. + + The traced contours come back in pixel space (Y-down, origin + top-left). They are transformed to mm-space (Y-up, origin + bottom-left) at the workpiece's physical size. + + Returns ``None`` if tracing yields no contours. + """ + spec = TraceSpec( + threshold=threshold, + auto_threshold=auto_threshold, + invert=invert, + ) + traced = trace_surface(surface, vectorization_spec=spec) + if not traced: + return None + + width_mm, height_mm = workpiece.size + px_w = surface.get_width() + px_h = surface.get_height() + if px_w <= 0 or px_h <= 0: + return None + + scale_x = width_mm / px_w + scale_y = height_mm / px_h + transform = Matrix.translation(0, height_mm) @ Matrix.scale( + scale_x, -scale_y + ) + + merged = Geometry() + for geo in traced: + geo.transform(transform) + merged.extend(geo) + return merged + + +def build_part_vector( + workpiece: WorkPiece, + surface: cairo.ImageSurface | None = None, + *, + override_threshold: bool = False, + threshold: float = 0.5, + normalize_windings: bool = False, +) -> Part | None: + """Build a ``Part`` carrying vector geometry for an assembler. + + This absorbs the Part-construction logic shared by + ``ContourProducer``, ``FrameProducer``, ``ShrinkWrapProducer``, + and ``WavefrontProducer``. + + Resolution order: + + 1. **Vector source** — if the workpiece has boundaries and + ``override_threshold`` is False, use ``workpiece.to_part()``. + When ``normalize_windings`` is True (e.g. WavefrontStep), + the geometry is re-scaled manually so that + ``normalize_winding_orders()`` can be applied before + constructing the Part. + + 2. **Raster fallback** — if a ``surface`` is available (either + because there are no boundaries or because + ``override_threshold`` is True), trace the surface into + mm-space geometry and build a Part from that. + + 3. Returns ``None`` if no geometry could be obtained. + + Args: + workpiece: The WorkPiece to derive geometry from. + surface: Optional rendered Cairo surface for raster tracing. + override_threshold: If True, ignore vector boundaries and + trace the surface instead. + threshold: Brightness threshold (0–1) for raster tracing. + normalize_windings: If True, normalize winding orders + (outer CCW, inner CW) on the scaled geometry. Required + by wavefront/pocketing assemblers. + """ + boundaries = workpiece.boundaries + has_vector_source = boundaries is not None and not boundaries.is_empty() + + # 1. Vector source — preferred path. + if has_vector_source and not override_threshold: + if normalize_windings: + assert boundaries is not None + scaled = boundaries.copy() + w, h = workpiece.size + if w > 0 and h > 0: + scaled.transform(Matrix.scale(w, h)) + scaled.normalize_winding_orders() + return Part.from_geometry_multi_face( + geometry=scaled, size_mm=(w, h) + ) + return workpiece.to_part() + + # 2. Raster fallback — trace the surface. + if surface is not None: + geo = _trace_surface_to_mm_geometry( + surface, + workpiece, + threshold=threshold, + auto_threshold=not override_threshold, + ) + if geo is not None and not geo.is_empty(): + if normalize_windings: + geo.normalize_winding_orders() + return Part.from_geometry_multi_face( + geometry=geo, size_mm=workpiece.size + ) + return Part(geometry=geo, size_mm=workpiece.size) + + return None + + +MAX_VECTOR_TRACE_PIXELS = 16 * 1024 * 1024 + + +def build_part_vector_with_raster_fallback( + workpiece: WorkPiece, + pixels_per_mm: tuple[float, float], + *, + override_threshold: bool = False, + threshold: float = 0.5, + normalize_windings: bool = False, +) -> Part: + """Build a vector :class:`Part`, rendering the workpiece source to a + raster surface and tracing it when no vector boundaries are + available. + + This mirrors the old ``_execute_vector`` pipeline path that + fell back to render-and-trace when a workpiece had no boundaries + (e.g. an SVG whose ``pristine_geometry`` is empty). + + Returns a :class:`Part` with at least ``size_mm`` set. The + geometry may be empty (``None``) if neither the vector source + nor the raster trace yields any contours. + """ + boundaries = workpiece.boundaries + has_vector = boundaries is not None and not boundaries.is_empty() + if has_vector and not override_threshold: + part = build_part_vector( + workpiece, + surface=None, + override_threshold=False, + normalize_windings=normalize_windings, + ) + if part is not None and part.has_geometry(): + return part + + size_mm = workpiece.size + if not size_mm or size_mm[0] <= 0 or size_mm[1] <= 0: + return Part(size_mm=size_mm) + + target_w = int(size_mm[0] * pixels_per_mm[0]) + target_h = int(size_mm[1] * pixels_per_mm[1]) + num_pixels = target_w * target_h + if num_pixels > MAX_VECTOR_TRACE_PIXELS: + scale = (MAX_VECTOR_TRACE_PIXELS / num_pixels) ** 0.5 + target_w = int(target_w * scale) + target_h = int(target_h * scale) + + if target_w <= 0 or target_h <= 0: + return Part(size_mm=size_mm) + + surface = workpiece.render_to_pixels(target_w, target_h) + if surface is None: + return Part(size_mm=size_mm) + + part = build_part_vector( + workpiece, + surface=surface, + override_threshold=True, + threshold=threshold, + normalize_windings=normalize_windings, + ) + if part is not None: + return part + return Part(size_mm=size_mm) + + +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, + white_point: int = 255, + threshold: int = 128, + dither_algorithm: DitherAlgorithm | None = None, + laser_spot_x_mm: float = 0.1, + pixels_per_mm_x: float = 1.0, +) -> tuple[np.ndarray | None, np.ndarray | None]: + """Convert a Cairo surface into an image array for a raster assembler. + + Handles depth-mode preprocessing: grayscale with optional levels, + dithering, or binary thresholding. + + Args: + surface: The rendered Cairo ARGB32 surface. + mode: The rasterisation depth mode. + invert: If True, invert grayscale before further processing. + auto_levels: For grayscale modes, if True, auto-compute + black/white points from the image histogram. + computed_auto_levels: Pre-computed ``(black, white)`` points + from a low-resolution preview (e.g. from + ``compute_raster_auto_levels``). When provided, skips + per-chunk auto-level computation. + black_point: Manual black-point (0–255) when + ``auto_levels`` is False. + white_point: Manual white-point (0–255) when + ``auto_levels`` is False. + threshold: Brightness threshold (0–255) for binary + ``CONSTANT_POWER`` mode. + dither_algorithm: Dithering algorithm for ``DITHER`` mode. + laser_spot_x_mm: Laser spot X diameter in mm, used to + 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. + + Returns: + ``(image, alpha)`` where *image* is a 2-D ``uint8`` array + (grayscale or binary depending on mode) and *alpha* is a + ``float32`` alpha array (grayscale modes) or ``None`` + (dither / constant-power modes). + """ + 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, + auto_levels=auto_levels, + computed_auto_levels=computed_auto_levels, + black_point=black_point, + white_point=white_point, + ) + return gray_image, alpha + + if mode == DepthMode.DITHER: + min_feature_px = max( + 1, + round(laser_spot_x_mm * pixels_per_mm_x), + ) + algo = dither_algorithm or DitherAlgorithm.FLOYD_STEINBERG + image = surface_to_dithered_array( + surface, + algo, + invert=invert, + min_feature_px=min_feature_px, + ) + return image, None + + if mode == DepthMode.CONSTANT_POWER: + image = surface_to_binary( + surface, + threshold=threshold, + invert=invert, + ) + return image, None + + return None, None + + +def _apply_raster_levels( + gray_image: np.ndarray, + alpha: np.ndarray, + *, + auto_levels: bool = True, + computed_auto_levels: tuple[int, int] | None = None, + black_point: int = 0, + white_point: int = 255, +) -> np.ndarray: + """Apply auto-levels or manual black/white-point normalization. + + This is the standalone equivalent of ``Rasterizer._apply_levels``. + """ + if auto_levels: + if computed_auto_levels is not None: + bp, wp = computed_auto_levels + else: + bp, wp = compute_auto_levels(gray_image[alpha > 0]) + else: + bp, wp = black_point, white_point + + if bp > 0 or wp < 255: + gray_image = normalize_grayscale(gray_image, bp, wp) + return gray_image + + +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. + + Renders a small preview of the workpiece, converts it to + grayscale, and returns the ``(black_point, white_point)`` tuple + suitable for passing to ``preprocess_raster_image`` as + ``computed_auto_levels``. + + This ensures consistent black/white points across all chunks + when processing large images. + + 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: + ``(black_point, white_point)`` tuple, or ``None`` if the + preview render failed or auto-levels are not applicable. + """ + px_per_mm_x, px_per_mm_y = pixels_per_mm + size = workpiece.size + + scale = min( + 1.0, + max_preview_pixels / (size[0] * px_per_mm_x), + max_preview_pixels / (size[1] * px_per_mm_y), + ) + + preview_width = max(1, int(size[0] * px_per_mm_x * scale)) + preview_height = max(1, int(size[1] * px_per_mm_y * scale)) + + surface = workpiece.render_to_pixels(preview_width, preview_height) + if not surface: + return None + + 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/pipeline/status_messages.py b/rayforge/pipeline/status_messages.py new file mode 100644 index 000000000..99ad38148 --- /dev/null +++ b/rayforge/pipeline/status_messages.py @@ -0,0 +1,111 @@ +"""Translation helpers for pipeline status messages. + +raygeo reports batch progress with a machine-readable payload. An +active node emits ``{key}`` (e.g. ``workpiece:{wp_uid}:{step_uid}``) +and, while an assembler or transformer is running, +``{key}\\t{detail}`` where ``detail`` is an assembler phase such as +``"contour: assemble"`` or a transformer spec name such as +``"overscan"``. A node that just finished emits ``\\t{key}``. This +module turns the payload into a human-readable, translatable status +string for the progress bar. ``gettext`` ``_()`` is applied here, so +the templates are picked up by ``xgettext``. +""" + +from collections.abc import Mapping +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..core.step_registry import step_registry +from .intent_builder import parse_workpiece_key +from .transformer.registry import transformer_registry + +if TYPE_CHECKING: + from ..core.step import Step + from ..core.workpiece import WorkPiece + +#: Labels for stages owned by the pipeline itself (not provided by an +#: addon step or transformer). +_PIPELINE_LABELS = { + "aggregate": lambda: _("Aggregate"), +} + + +def _activity_label(detail: str) -> str | None: + """Translate a ``\\t`` detail suffix to a user-facing activity label. + + :param detail: Text after the ``\\t`` in a batch progress payload, + e.g. ``"contour: assemble"`` or ``"overscan"``. + :returns: A ``_()``-marked activity label, or ``None`` when the + detail is not a recognised assembler or transformer (e.g. + ``"compute: done"`` or an internal raster/optimize message). + """ + pipeline = _PIPELINE_LABELS.get(detail.partition(":")[0]) + if pipeline is not None: + return pipeline() + transformer = transformer_registry.progress_label(detail) + if transformer is not None: + return transformer + name, sep, _rest = detail.partition(":") + if sep: + return step_registry.progress_label(name) + return None + + +def status_message_for_key( + key: str, + workpieces_by_uid: Mapping[str, "WorkPiece"], + steps_by_uid: Mapping[str, "Step"], +) -> str: + """Translate a pipeline batch progress payload into a status string. + + :param key: A raygeo batch progress payload: a node key with an + optional ``\\t``-separated activity detail (or an empty string + when idle). + :param workpieces_by_uid: Map of workpiece uid to :class:`WorkPiece`. + :param steps_by_uid: Map of step uid to :class:`Step`. + :returns: A ``_()``-marked status message, or ``""`` when idle or + when the payload only carries a completion marker. + """ + if not key: + return "" + node_key, sep, detail = key.partition("\t") + if not node_key: + return "" + base = _node_status_message(node_key, workpieces_by_uid, steps_by_uid) + if sep: + activity = _activity_label(detail) + if activity: + return _("{status} — {activity}").format( + status=base, activity=activity + ) + return base + + +def _node_status_message( + node_key: str, + workpieces_by_uid: Mapping[str, "WorkPiece"], + steps_by_uid: Mapping[str, "Step"], +) -> str: + """Translate a bare node key into its base status string.""" + if node_key == "job": + return _("Aggregating job") + if node_key == "job:encode": + return _("Generating machine code") + if node_key == "job:machinexform": + return _("Applying machine transform") + parsed = parse_workpiece_key(node_key) + if parsed is not None: + wp_uid, step_uid = parsed + workpiece = workpieces_by_uid.get(wp_uid) + step = steps_by_uid.get(step_uid) + if workpiece is None or step is None: + return _("Processing") + return _("Processing '{workpiece}' — {step}").format( + workpiece=workpiece.name, step=step.typelabel + ) + if node_key.startswith("step:"): + step = steps_by_uid.get(node_key.split(":", 1)[1]) + if step is None: + return _("Assembling") + return _("Assembling '{step}'").format(step=step.typelabel) + return _("Processing") diff --git a/rayforge/pipeline/transformer/__init__.py b/rayforge/pipeline/transformer/__init__.py new file mode 100644 index 000000000..5c62a44be --- /dev/null +++ b/rayforge/pipeline/transformer/__init__.py @@ -0,0 +1,5 @@ +from .base import OpsTransformer + +__all__ = [ + "OpsTransformer", +] diff --git a/rayforge/pipeline/transformer/base.py b/rayforge/pipeline/transformer/base.py new file mode 100644 index 000000000..f26d5579a --- /dev/null +++ b/rayforge/pipeline/transformer/base.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, ClassVar + +from blinker import Signal + +from ...core.workpiece import WorkPiece + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + +class OpsTransformer(ABC): + """ + Transforms an Ops object in-place. + Examples may include: + + - Applying travel path optimizations + - Applying arc welding + """ + + POSITION_SENSITIVE: bool = False + + #: The raygeo transformer spec ``name()`` this transformer produces + #: (e.g. ``"overscan"``), used to label batch progress details. + SPEC_NAME: ClassVar[str] = "" + + def __init__(self, enabled: bool = True, **kwargs): + self._enabled = enabled + self.changed = Signal() + self.extra: dict[str, Any] = {} + + @property + def enabled(self) -> bool: + return self._enabled + + def set_enabled(self, enabled: bool): + """Sets the enabled state and signals a change.""" + if self._enabled != enabled: + self._enabled = enabled + self.changed.send(self) + + @enabled.setter + def enabled(self, enabled: bool) -> None: + """Convenience setter, delegates to set_enabled.""" + self.set_enabled(enabled) + + @property + @abstractmethod + def label(self) -> str: + """A short label for the transformation, used in UI.""" + + @property + @abstractmethod + def description(self) -> str: + """A brief one-line description of the transformation.""" + + @abstractmethod + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict[str, Any] | None, + ) -> Any: + """Return the typed Rust spec for this transformer. + + The returned object is one of the ``*Spec`` pyclasses defined in + :mod:`raygeo.ops.transform`. Implementations must not return + ``None``: if the transformer cannot run, raise an exception + describing the misconfiguration instead. + """ + + def to_dict(self) -> dict[str, Any]: + """Serializes the transformer's configuration to a dictionary.""" + result = { + "name": self.__class__.__name__, + "enabled": self.enabled, + } + result.update(self.extra) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OpsTransformer: + """ + Acts as a factory to create a transformer instance from a dictionary. + This method should be called on the base class, e.g., + `OpsTransformer.from_dict(...)`. + It determines the correct subclass to instantiate based on the 'name' + field. + """ + # If this is called on a subclass, it must be implemented there. + # This factory logic is only for when called on the base class. + if cls is not OpsTransformer: + raise NotImplementedError( + f"{cls.__name__} must implement its own from_dict classmethod." + ) + + from .placeholder import PlaceholderTransformer + from .registry import transformer_registry + + name = data.get("name") + if not name: + raise ValueError("Transformer data is missing 'name' field.") + + target_cls = transformer_registry.get(name) + if not target_cls: + return PlaceholderTransformer.from_dict(data) + + # Dispatch to the specific class's from_dict method + instance = target_cls.from_dict(data) + + # Extract unknown attributes for forward compatibility + known_keys = {"name", "enabled"} + extra = {k: v for k, v in data.items() if k not in known_keys} + instance.extra = extra + + return instance diff --git a/rayforge/pipeline/transformer/placeholder.py b/rayforge/pipeline/transformer/placeholder.py new file mode 100644 index 000000000..a4d830b40 --- /dev/null +++ b/rayforge/pipeline/transformer/placeholder.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from .base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + from ...core.workpiece import WorkPiece + + +class PlaceholderTransformer(OpsTransformer): + """ + Transformer that preserves configuration for unknown transformer types. + + This is used when a document contains a step whose transformer type is + not available (e.g., because the addon that provides it is not installed). + The placeholder preserves the original configuration so the document can + be saved without data loss. + """ + + def __init__(self, original_name: str, config: dict): + super().__init__() + self._original_name = original_name + self._config = config.copy() + self._label = _("Missing: {}").format(original_name) + + @property + def original_name(self) -> str: + """Returns the original transformer name that was not found.""" + return self._original_name + + @property + def label(self) -> str: + """A short label for the transformation, used in UI.""" + return self._label + + @property + def description(self) -> str: + """A brief one-line description of the transformation.""" + return _("This transformer is not available.") + + def to_dict(self) -> dict: + """Returns the preserved original configuration.""" + return self._config.copy() + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: list[Geometry] | None, + settings: dict[str, Any] | None, + ): + raise RuntimeError( + f"Transformer '{self._original_name}' is not available " + f"and cannot be run" + ) + + @classmethod + def from_dict(cls, data: dict) -> PlaceholderTransformer: + original_name = data.get("name", "Unknown") + return cls(original_name, data) diff --git a/rayforge/pipeline/transformer/registry.py b/rayforge/pipeline/transformer/registry.py new file mode 100644 index 000000000..13f424993 --- /dev/null +++ b/rayforge/pipeline/transformer/registry.py @@ -0,0 +1,97 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import OpsTransformer + + +class TransformerRegistry: + """ + Registry for OpsTransformer classes. + + Allows explicit registration of transformer types for lookup by name. + This replaces the introspection-based approach with a cleaner + explicit registration pattern. + """ + + def __init__(self): + self._transformers: dict[str, type[OpsTransformer]] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, + transformer_class: type["OpsTransformer"], + name: str | None = None, + addon_name: str | None = None, + ) -> None: + """ + Register a transformer class. + + Args: + transformer_class: The OpsTransformer subclass to register. + name: Optional name to use as the registry key. + If not provided, the class name is used. + addon_name: Optional name of the addon registering this + transformer. Used for cleanup when addon is + unloaded. + """ + key = name if name else transformer_class.__name__ + self._transformers[key] = transformer_class + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(key) + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all transformers registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of transformers unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for name in items: + if name in self._transformers: + del self._transformers[name] + count += 1 + return count + + def get(self, name: str) -> type["OpsTransformer"] | None: + """ + Look up a transformer class by name. + + Args: + name: The class name of the transformer. + + Returns: + The transformer class, or None if not found. + """ + return self._transformers.get(name) + + def progress_label(self, spec_name: str) -> str | None: + """ + Look up the UI label for a raygeo transformer spec ``name()``. + + The label comes from the registered transformer whose + ``SPEC_NAME`` matches, via its :attr:`label` property. + + Args: + spec_name: A raygeo transformer spec ``name()`` (e.g. + ``"overscan"``). + + Returns: + The UI label, or None when no registered transformer + declares that spec name. + """ + for transformer_class in self._transformers.values(): + if transformer_class.SPEC_NAME == spec_name: + return transformer_class().label + return None + + +transformer_registry = TransformerRegistry() diff --git a/rayforge/pipeline/view/__init__.py b/rayforge/pipeline/view/__init__.py new file mode 100644 index 000000000..0c5d0fdf5 --- /dev/null +++ b/rayforge/pipeline/view/__init__.py @@ -0,0 +1,11 @@ +from .view_compute import ( + calculate_render_dimensions, + render_workpiece_view_in_process, +) +from .view_manager import ViewManager + +__all__ = [ + "ViewManager", + "calculate_render_dimensions", + "render_workpiece_view_in_process", +] diff --git a/rayforge/pipeline/view/view_compute.py b/rayforge/pipeline/view/view_compute.py new file mode 100644 index 000000000..270db283e --- /dev/null +++ b/rayforge/pipeline/view/view_compute.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import logging +import math + +import numpy as np +from raygeo.geo.types import Rect +from raygeo.ops.convert import ViewSpec +from raygeo.ops.convert.view import render_ops as raygeo_render_ops + +from ...core.color import ColorSet +from ...core.config import OpsColorMode +from ..artifact import WorkPieceArtifact +from ..artifact.workpiece_view import ( + RenderContext, +) + +logger = logging.getLogger(__name__) + +CAIRO_MAX_DIMENSION = 8192 +MAX_TOTAL_PIXELS = CAIRO_MAX_DIMENSION * CAIRO_MAX_DIMENSION + + +def _resolve_color_set( + render_context: RenderContext, + laser_uid: str | None = None, + layer_uid: str | None = None, +) -> ColorSet: + """ + Resolve the appropriate ColorSet based on the ops color mode. + + When ops_color_mode is OpsColorMode.LAYER, uses layer_color_sets + keyed by layer_uid. Otherwise falls back to laser-specific or + default colors. + """ + if render_context.ops_color_mode == OpsColorMode.LAYER and layer_uid: + if layer_uid in render_context.layer_color_sets: + logger.debug( + f"_resolve_color_set: using layer color for " + f"layer_uid={layer_uid}" + ) + return ColorSet.from_dict( + render_context.layer_color_sets[layer_uid] + ) + logger.warning( + f"_resolve_color_set: layer_uid={layer_uid} " + f"not in layer_color_sets, falling back" + ) + if laser_uid and laser_uid in render_context.laser_color_sets: + return ColorSet.from_dict(render_context.laser_color_sets[laser_uid]) + return ColorSet.from_dict(render_context.color_set_dict) + + +# ────────────────────────────────────────────────────────────────── +# Bounding box +# ────────────────────────────────────────────────────────────────── + + +def _get_content_bbox( + artifact: WorkPieceArtifact, + show_travel: bool, +) -> Rect | None: + """Calculate the union bounding box of all visual content.""" + rect = artifact.ops.rect(include_travel=show_travel) + has_content = rect != (0.0, 0.0, 0.0, 0.0) + + if has_content: + v_x1, v_y1, v_x2, v_y2 = rect + else: + v_x1, v_y1 = math.inf, math.inf + v_x2, v_y2 = -math.inf, -math.inf + + if not artifact.is_scalable: + t_x1, t_y1 = 0.0, 0.0 + t_x2 = artifact.generation_size[0] + t_y2 = artifact.generation_size[1] + v_x1 = min(v_x1, t_x1) + v_x2 = max(v_x2, t_x2) + v_y1 = min(v_y1, t_y1) + v_y2 = max(v_y2, t_y2) + has_content = True + + if not has_content: + return None + + return (v_x1, v_y1, v_x2 - v_x1, v_y2 - v_y1) + + +def calculate_render_dimensions( + bbox: Rect, + render_context: RenderContext, +) -> tuple[int, int, float, float] | None: + """ + Calculates pixel dimensions and effective pixels-per-mm for rendering. + + The caller is responsible for including any desired padding in + *bbox* before calling. No implicit margin is added. + + Args: + bbox: The content bounding box (x, y, width, height) in mm. + render_context: The RenderContext containing rendering parameters. + + Returns: + ``(width_px, height_px, effective_ppm_x, effective_ppm_y)`` + or ``None`` if dimensions are invalid. + """ + _, _, w_mm, h_mm = bbox + ppm_x, ppm_y = render_context.pixels_per_mm + + width_px = min(round(w_mm * ppm_x), CAIRO_MAX_DIMENSION) + height_px = min(round(h_mm * ppm_y), CAIRO_MAX_DIMENSION) + + if width_px * height_px > MAX_TOTAL_PIXELS: + scale = (MAX_TOTAL_PIXELS / (width_px * height_px)) ** 0.5 + width_px = max(1, int(width_px * scale)) + height_px = max(1, int(height_px * scale)) + + if width_px <= 0 or height_px <= 0: + return None + + eff_ppm_x = width_px / w_mm if w_mm > 0 else ppm_x + eff_ppm_y = height_px / h_mm if h_mm > 0 else ppm_y + + return width_px, height_px, eff_ppm_x, eff_ppm_y + + +# ────────────────────────────────────────────────────────────────── +# ViewSpec construction +# ────────────────────────────────────────────────────────────────── + + +def _make_view_spec( + render_context: RenderContext, + color_set: ColorSet, + render_bbox_mm: tuple[float, float, float, float], +) -> ViewSpec: + """Build a raygeo ViewSpec from the render context and colour set.""" + return ViewSpec( + pixels_per_mm=render_context.pixels_per_mm, + render_bbox=render_bbox_mm, + show_travel_moves=render_context.show_travel_moves, + cut_color=color_set.get_argb32("cut"), + travel_color=color_set.get_argb32("travel"), + zero_power_color=color_set.get_argb32("zero_power"), + cut_lut=color_set.get_lut_argb32("cut").tolist(), + engrave_lut=color_set.get_lut_argb32("engrave").tolist(), + max_dimension_px=CAIRO_MAX_DIMENSION, + max_total_pixels=MAX_TOTAL_PIXELS, + ) + + +def _expand_bbox_by_px( + bbox: Rect, + ppm: tuple[float, float], + margin_px: int, +) -> tuple[float, float, float, float]: + """Expand a ``(x, y, w, h)`` bbox by ``margin_px`` on each side, + returning ``(min_x, min_y, max_x, max_y)``.""" + x, y, w, h = bbox + ppm_x, ppm_y = ppm + mx = margin_px / ppm_x if ppm_x > 0 else 0 + my = margin_px / ppm_y if ppm_y > 0 else 0 + return (x - mx, y - my, x + w + mx, y + h + my) + + +# ────────────────────────────────────────────────────────────────── +# Public entry points +# ────────────────────────────────────────────────────────────────── + + +def render_workpiece_view_in_process( + artifact: WorkPieceArtifact, + render_context: RenderContext, + laser_uid: str | None = None, + layer_uid: str | None = None, +) -> tuple[np.ndarray, Rect, tuple[float, float]] | None: + """ + Render a WorkPieceArtifact into a view bitmap in-process. + + Calls ``raygeo.render_ops`` directly and returns + ``(bitmap, bbox_mm, workpiece_size_mm)`` — no shared memory, + no artifact store. + + Args: + artifact: The WorkPieceArtifact to render. + render_context: The RenderContext containing rendering parameters. + laser_uid: Optional laser UID for color lookup. + layer_uid: Optional layer UID for color lookup. + + Returns: + ``(bitmap, bbox_mm, workpiece_size_mm)`` or ``None`` when + there is no content to render. + """ + bbox = _get_content_bbox(artifact, render_context.show_travel_moves) + if not bbox or bbox[2] <= 1e-9 or bbox[3] <= 1e-9: + return None + + color_set = _resolve_color_set(render_context, laser_uid, layer_uid) + + # Expand the content bbox by margin_px on each side so strokes + # at the edge are not clipped. The expanded bbox is what raygeo + # renders — no implicit margin inside raygeo. + render_bbox_mm = _expand_bbox_by_px( + bbox, render_context.pixels_per_mm, render_context.margin_px + ) + + spec = _make_view_spec(render_context, color_set, render_bbox_mm) + result = raygeo_render_ops(artifact.ops, spec) + if result is None: + return None + + return ( + np.asarray(result.bitmap, dtype=np.uint8), + bbox, + artifact.generation_size, + ) diff --git a/rayforge/pipeline/view/view_manager.py b/rayforge/pipeline/view/view_manager.py new file mode 100644 index 000000000..9b33a3b1f --- /dev/null +++ b/rayforge/pipeline/view/view_manager.py @@ -0,0 +1,916 @@ +from __future__ import annotations + +import logging +import threading +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import numpy as np +from blinker import Signal + +from ..artifact import ( + BaseArtifactHandle, + WorkPieceArtifactHandle, +) +from ..artifact.workpiece_view import ( + RenderContext, + WorkPieceViewArtifact, + WorkPieceViewArtifactHandle, +) +from .view_compute import ( + calculate_render_dimensions, + render_workpiece_view_in_process, +) + +if TYPE_CHECKING: + from ...core.step import Step + from ...core.workpiece import WorkPiece + from ...machine.models.machine import Machine + from ...shared.tasker.task import Task + from ..artifact.store import ArtifactStore + from ..pipeline import Pipeline + + +logger = logging.getLogger(__name__) + +MAX_CONCURRENT_VIEW_RENDERS = 3 + + +@dataclass +class ViewEntry: + """Holds state for a single view artifact.""" + + bitmap: np.ndarray | None = None + bbox_mm: tuple[float, float, float, float] | None = None + workpiece_size_mm: tuple[float, float] | None = None + handle: WorkPieceViewArtifactHandle | None = None + render_context: RenderContext | None = None + source_handle: WorkPieceArtifactHandle | None = None + laser_uid: str | None = None + layer_uid: str | None = None + + +class ViewManager: + """ + Manages view rendering for workpieces, decoupled from the data pipeline. + + The ViewManager is responsible for: + - Maintaining the current RenderContext (from UI events like zoom/pan) + - Tracking source WorkPieceArtifact handles it is displaying + - Triggering rendering tasks when source data or render context changes + - Managing view artifact lifecycle (retain/release handles) + + It indexes views by (workpiece_uid, step_uid) to support visualizing + intermediate states of a workpiece across multiple steps. + + Shared Memory Lifecycle + ----------------------- + The ViewManager retains handles for two purposes: + + 1. Source artifact tracking: Handles in _source_artifact_handles are + retained when stored and released in shutdown() or reconcile(). + + 2. Async task execution: Handles are retained before launching a task + and released in the task's completion callback (e.g., when_done). + + Every retain() is paired with a release() to prevent memory leaks. + """ + + def __init__( + self, + pipeline: Pipeline, + artifact_store: ArtifactStore, + machine: Machine | None, + ): + self._pipeline = pipeline + self._store = artifact_store + self._task_manager = pipeline.task_manager + self._machine = machine + self._current_view_context: RenderContext | None = None + self._view_generation_id = 0 + self._is_shutdown = False + self._render_semaphore = threading.Semaphore( + MAX_CONCURRENT_VIEW_RENDERS + ) + self._pending_render_queue: list[tuple[str, str]] = [] + self._pending_render_lock = threading.Lock() + + # Keys are (workpiece_uid, step_uid) + self._source_artifact_handles: dict[ + tuple[str, str], WorkPieceArtifactHandle + ] = {} + self._view_entries: dict[tuple[str, str], ViewEntry] = {} + + # Stable task keys for view computation (deduplication/cancellation). + self._view_task_keys: dict[tuple[str, str], str] = {} + + # Throttling state keyed by the composite key (workpiece_uid, step_uid) + self._pending_updates: dict[tuple[str, str], bool] = {} + self._last_update_time: dict[tuple[str, str], float] = {} + self._throttle_timers: dict[tuple[str, str], threading.Timer] = {} + + self.view_artifact_created = Signal() + self.view_artifact_updated = Signal() + self.generation_finished = Signal() + self.source_artifact_ready = Signal() + + self._connect_pipeline_signals() + + def _connect_pipeline_signals(self): + """Connect to pipeline signals.""" + self._pipeline.workpiece_artifact_ready.connect( + self.on_workpiece_artifact_ready + ) + self._pipeline.workpiece_starting.connect(self.on_generation_starting) + self._pipeline.step_assembly_starting.connect( + self.on_workpiece_artifact_ready + ) + if self._pipeline.doc: + self._pipeline.doc.descendant_removed.connect( + self._on_doc_item_removed + ) + + def _disconnect_pipeline_signals(self): + """Disconnect from pipeline signals.""" + self._pipeline.workpiece_artifact_ready.disconnect( + self.on_workpiece_artifact_ready + ) + self._pipeline.workpiece_starting.disconnect( + self.on_generation_starting + ) + self._pipeline.step_assembly_starting.disconnect( + self.on_workpiece_artifact_ready + ) + if self._pipeline.doc: + self._pipeline.doc.descendant_removed.disconnect( + self._on_doc_item_removed + ) + + def _on_doc_item_removed(self, sender, *, origin, parent_of_origin): + """Handle removal of workpieces and steps from the document.""" + from ...core.step import Step + from ...core.workpiece import WorkPiece + + if isinstance(origin, WorkPiece): + self._cleanup_views_for_workpiece(origin.uid) + elif isinstance(origin, Step): + self._cleanup_views_for_step(origin.uid) + + def _cleanup_views_for_workpiece(self, workpiece_uid: str): + """Remove all view entries for a workpiece.""" + # Collect keys from both _view_entries and _source_artifact_handles + all_keys = set(self._view_entries.keys()) | set( + self._source_artifact_handles.keys() + ) + keys_to_remove = [ + composite_id + for composite_id in all_keys + if composite_id[0] == workpiece_uid + ] + for composite_id in keys_to_remove: + self._remove_view_entry(composite_id) + + if keys_to_remove: + logger.debug( + f"Cleaned up {len(keys_to_remove)} view entries for " + f"workpiece {workpiece_uid}" + ) + + def _cleanup_views_for_step(self, step_uid: str): + """Remove all view entries for a step.""" + # Collect keys from both _view_entries and _source_artifact_handles + all_keys = set(self._view_entries.keys()) | set( + self._source_artifact_handles.keys() + ) + keys_to_remove = [ + composite_id + for composite_id in all_keys + if composite_id[1] == step_uid + ] + for composite_id in keys_to_remove: + self._remove_view_entry(composite_id) + + if keys_to_remove: + logger.debug( + f"Cleaned up {len(keys_to_remove)} view entries for " + f"step {step_uid}" + ) + + def _remove_view_entry(self, composite_id: tuple): + """Remove a view entry and release its resources.""" + self._view_entries.pop(composite_id, None) + + source_handle = self._source_artifact_handles.pop(composite_id, None) + if source_handle: + self._store.release(source_handle) + + task_key = self._view_task_keys.pop(composite_id, None) + if task_key: + self._task_manager.cancel_task(task_key) + + timer = self._throttle_timers.pop(composite_id, None) + if timer: + timer.cancel() + self._pending_updates.pop(composite_id, None) + self._last_update_time.pop(composite_id, None) + + @property + def current_view_context(self) -> RenderContext | None: + """Returns the current render context.""" + return self._current_view_context + + @property + def view_generation_id(self) -> int: + """Returns the current view generation ID.""" + return self._view_generation_id + + @property + def store(self) -> ArtifactStore: + """Returns the artifact store.""" + return self._store + + def _get_task_key(self, workpiece_uid: str, step_uid: str) -> str: + """ + Retrieves or creates a stable task key for managing tasks associated + with a specific (workpiece, step) view. + """ + composite_id = (workpiece_uid, step_uid) + if composite_id not in self._view_task_keys: + self._view_task_keys[composite_id] = str(uuid.uuid4()) + return self._view_task_keys[composite_id] + + def _is_view_stale( + self, + workpiece_uid: str, + step_uid: str, + new_context: RenderContext | None, + source_handle: WorkPieceArtifactHandle | None, + laser_uid: str | None = None, + layer_uid: str | None = None, + ) -> bool: + """Check if a view needs re-rendering.""" + composite_id = (workpiece_uid, step_uid) + entry = self._view_entries.get(composite_id) + + if entry is None or entry.bitmap is None: + logger.debug(f"_is_view_stale[{composite_id}]: no entry -> STALE") + return True + + if new_context is not None: + if entry.render_context is None: + logger.debug( + f"_is_view_stale[{composite_id}]: no context -> STALE" + ) + return True + if entry.render_context != new_context: + logger.debug( + f"_is_view_stale[{composite_id}]: context changed -> STALE" + ) + return True + + if source_handle is not None: + if entry.source_handle is None: + logger.debug( + f"_is_view_stale[{composite_id}]: no src handle -> STALE" + ) + return True + if entry.source_handle.key != source_handle.key: + logger.debug( + f"_is_view_stale[{composite_id}]: key changed -> STALE" + ) + return True + entry_gen_size = entry.source_handle.generation_size + new_gen_size = source_handle.generation_size + if entry_gen_size != new_gen_size: + logger.debug( + f"_is_view_stale[{composite_id}]: " + f"gen_size {entry_gen_size} -> {new_gen_size} -> STALE" + ) + return True + entry_src_dims = entry.source_handle.source_dimensions + new_src_dims = source_handle.source_dimensions + if entry_src_dims != new_src_dims: + logger.debug( + f"_is_view_stale[{composite_id}]: " + f"src_dims {entry_src_dims} -> {new_src_dims} -> STALE" + ) + return True + + if entry.laser_uid != laser_uid: + logger.debug( + f"_is_view_stale[{composite_id}]: laser_uid changed -> STALE" + ) + return True + + if entry.layer_uid != layer_uid: + logger.debug( + f"_is_view_stale[{composite_id}]: layer_uid changed -> STALE" + ) + return True + + return False + + def update_render_context( + self, + context: RenderContext, + ) -> None: + """ + Updates the view context and triggers re-rendering for tracked + workpieces if the context has changed significantly. + + Args: + context: The new render context to apply. + """ + if self._current_view_context == context: + logger.debug("update_render_context: Context unchanged, skipping") + return + + new_ppm = context.pixels_per_mm[0] + + logger.debug( + f"update_render_context called with context " + f"ppm={context.pixels_per_mm}, " + f"show_travel_moves={context.show_travel_moves}" + ) + + self._current_view_context = context + self._view_generation_id += 1 + + # Re-render each view only if its rendered ppm differs from the + # requested ppm by more than 25%. This avoids frequent re-renders + # during small zoom adjustments. + for key, entry in self._view_entries.items(): + old_ppm = 0.0 + old_show_travel = False + if entry.render_context is not None: + old_ppm = entry.render_context.pixels_per_mm[0] + old_show_travel = entry.render_context.show_travel_moves + + ppm_changed = ( + old_ppm <= 0 or abs(new_ppm - old_ppm) / old_ppm > 0.25 + ) + travel_changed = old_show_travel != context.show_travel_moves + logger.debug( + f"update_render_context: key={key}, old_ppm={old_ppm:.2f}, " + f"new_ppm={new_ppm:.2f}, ppm_changed={ppm_changed}, " + f"travel_changed={travel_changed}" + ) + if ( + ppm_changed + or travel_changed + or entry.render_context != context + ): + self.request_view_render(key[0], key[1]) + + def _handles_represent_same_artifact( + self, + handle1: WorkPieceArtifactHandle | None, + handle2: WorkPieceArtifactHandle | None, + ) -> bool: + """ + Check if two handles represent the same artifact. + + Two handles represent the same artifact if they point to the same + shared memory (key), have the same generation_size, and the + same source_dimensions. + + Returns True if both handles are None, or if they represent the + same artifact. + """ + if handle1 is None and handle2 is None: + return True + if handle1 is None or handle2 is None: + return False + return ( + handle1.key == handle2.key + and handle1.generation_size == handle2.generation_size + and handle1.source_dimensions == handle2.source_dimensions + ) + + def on_workpiece_artifact_ready( + self, + sender, + *, + step: Step, + workpiece: WorkPiece, + handle: BaseArtifactHandle, + **kwargs, + ) -> None: + """ + Handler for the pipeline.workpiece_artifact_ready signal. + + This method manages the source artifact handles: + - Releases any old handle for this workpiece (if not in use by tasks) + - Retains the new handle + - Triggers a view render + + If the handle represents the same artifact as the existing one + (e.g., when step_assembly_starting is emitted during a + position-only transform change), no signal is emitted to avoid + unnecessary UI redraws. + + Args: + sender: The signal sender. + step: The step for which the artifact is ready. + workpiece: The workpiece whose artifact is ready. + handle: The artifact handle. + **kwargs: Additional keyword arguments. + """ + if self._is_shutdown: + return + + doc = self._pipeline.doc + if not doc: + return + + workpiece_exists = any( + wp.uid == workpiece.uid for wp in doc.all_workpieces + ) + if not workpiece_exists: + return + + step_exists = any( + s.uid == step.uid + for layer in doc.layers + if layer.workflow + for s in layer.workflow.steps + ) + if not step_exists: + return + + if not isinstance(handle, WorkPieceArtifactHandle): + logger.warning( + f"Expected WorkPieceArtifactHandle, got {type(handle)}" + ) + return + + composite_id = (workpiece.uid, step.uid) + + old_handle = self._source_artifact_handles.get(composite_id) + wp_handle = cast(WorkPieceArtifactHandle, handle) + + same_artifact = self._handles_represent_same_artifact( + old_handle, wp_handle + ) + + logger.debug( + f"on_workpiece_artifact_ready: composite_id={composite_id}, " + f"old_handle={old_handle.key if old_handle else None}, " + f"new_handle={wp_handle.key}, " + f"same_artifact={same_artifact}" + ) + + if not same_artifact: + if old_handle is not None: + logger.debug( + f"Releasing old source artifact handle for {composite_id}" + ) + self._store.release(old_handle) + + self._source_artifact_handles[composite_id] = wp_handle + self._store.retain(wp_handle) + logger.debug( + f"Retained new source artifact handle for {composite_id}" + ) + + self.request_view_render(workpiece.uid, step_uid=step.uid) + + self.source_artifact_ready.send( + self, + step=step, + workpiece=workpiece, + handle=wp_handle, + ) + else: + laser_uid = doc.get_laser_uid_for_step(step.uid) + layer_uid = doc.get_layer_uid_for_step(step.uid) + entry = self._view_entries.get(composite_id) + if entry and ( + entry.laser_uid != laser_uid or entry.layer_uid != layer_uid + ): + logger.debug( + f"Same artifact for {composite_id}, but " + f"laser/layer uid changed -> requesting re-render" + ) + self.request_view_render(workpiece.uid, step_uid=step.uid) + else: + logger.debug( + f"Same artifact already tracked for {composite_id}, " + "skipping signal emission" + ) + + def request_view_render( + self, + workpiece_uid: str, + step_uid: str, + ) -> None: + """ + Requests an asynchronous render of a workpiece view for a specific + step. + + Args: + workpiece_uid: The unique identifier of the workpiece. + step_uid: The unique identifier of the step (optional). + """ + if self._current_view_context is None: + logger.debug( + f"Cannot render view for ({workpiece_uid}, {step_uid}): " + "No render context set." + ) + return + + if not self._render_semaphore.acquire(blocking=False): + logger.debug( + f"View render for ({workpiece_uid}, {step_uid}) " + "queued: max concurrent renders reached." + ) + with self._pending_render_lock: + self._pending_render_queue.append((workpiece_uid, step_uid)) + return + + context = self._current_view_context + view_id = self._view_generation_id + + task_key = self._get_task_key(workpiece_uid, step_uid) + + source_handle = self._source_artifact_handles.get( + (workpiece_uid, step_uid) + ) + if source_handle is None: + self._render_semaphore.release() + logger.warning( + f"Cannot render view for ({workpiece_uid}, {step_uid}): " + "No source artifact handle tracked." + ) + return + + self._request_view_render_internal( + task_key, + context, + view_id, + source_handle, + step_uid, + workpiece_uid, + ) + + def _request_view_render_internal( + self, + key: str, + context: RenderContext, + view_id: int, + source_handle: WorkPieceArtifactHandle, + step_uid: str, + workpiece_uid: str, + ): + """ + Internal method to request a view render. + + Args: + key: The task key for the workpiece view. + context: The render context to use. + view_id: The view generation ID for this render. + source_handle: The source WorkPieceArtifact handle. + step_uid: The unique identifier of the step (optional). + workpiece_uid: The unique identifier of the workpiece. + """ + logger.debug( + f"_request_view_render_internal: workpiece_uid={workpiece_uid}, " + f"step_uid={step_uid}, source_handle={source_handle.key}" + ) + + doc = self._pipeline.doc + laser_uid = doc.get_laser_uid_for_step(step_uid) if doc else None + layer_uid = doc.get_layer_uid_for_step(step_uid) if doc else None + + if not self._is_view_stale( + workpiece_uid, + step_uid, + context, + source_handle, + laser_uid, + layer_uid, + ): + logger.debug(f"View for ({workpiece_uid}, {step_uid}) is valid.") + self._render_semaphore.release() + self._drain_pending_render_queue() + return + + self._current_view_context = context + + task = self._task_manager.get_task(key) + if task and not task.is_final(): + logger.debug( + f"[{key}] View render already in progress. Cancelling." + ) + self._task_manager.cancel_task(key) + + composite_id = (workpiece_uid, step_uid) + entry = self._view_entries.get(composite_id) + if entry is None: + entry = ViewEntry() + self._view_entries[composite_id] = entry + entry.render_context = context + entry.source_handle = source_handle + entry.laser_uid = laser_uid + entry.layer_uid = layer_uid + + # Retain source handle for the duration of this task + self._store.retain(source_handle) + task_source_handle = source_handle + + # Load the artifact before the thread starts so the thread is + # pure computation — no store access. + artifact = self._store.get(source_handle) + + def when_done_callback(task: Task): + logger.debug( + f"[{key}] when_done_callback called, " + f"task_status={task.get_status()}" + ) + self._render_semaphore.release() + self._drain_pending_render_queue() + if task.get_status() == "canceled": + logger.debug( + f"[{key}] Task was cancelled, skipping " + "_on_render_complete signal." + ) + elif task.result() is not None: + bitmap, bbox_mm, workpiece_size_mm = task.result() + entry.bitmap = bitmap + entry.bbox_mm = bbox_mm + entry.workpiece_size_mm = workpiece_size_mm + self._on_render_complete( + task, key, view_id, workpiece_uid, step_uid + ) + else: + # Task completed but returned nothing — clear stale view. + entry.bitmap = None + entry.bbox_mm = None + entry.workpiece_size_mm = None + self._store.release(task_source_handle) + + self._task_manager.run_thread( + render_workpiece_view_in_process, + artifact, + context, + laser_uid, + layer_uid, + key=key, + when_done=when_done_callback, + ) + + def _drain_pending_render_queue(self): + """Process queued render requests now that a slot is available.""" + while True: + with self._pending_render_lock: + if not self._pending_render_queue: + return + workpiece_uid, step_uid = self._pending_render_queue.pop(0) + + if not self._render_semaphore.acquire(blocking=False): + with self._pending_render_lock: + self._pending_render_queue.insert( + 0, (workpiece_uid, step_uid) + ) + return + + context = self._current_view_context + if context is None: + self._render_semaphore.release() + return + + view_id = self._view_generation_id + task_key = self._get_task_key(workpiece_uid, step_uid) + source_handle = self._source_artifact_handles.get( + (workpiece_uid, step_uid) + ) + if source_handle is None: + self._render_semaphore.release() + continue + + self._request_view_render_internal( + task_key, + context, + view_id, + source_handle, + step_uid, + workpiece_uid, + ) + + def shutdown(self): + """Cancels any active rendering tasks and releases held handles.""" + logger.debug("ViewManager shutting down.") + self._is_shutdown = True + + self._disconnect_pipeline_signals() + + # Cancel all managed tasks + for key in self._view_task_keys.values(): + self._task_manager.cancel_task(key) + self._view_task_keys.clear() + + for entry in self._view_entries.values(): + if entry.handle is not None: + self._store.release(entry.handle) + self._view_entries.clear() + + for timer in self._throttle_timers.values(): + if timer: + timer.cancel() + self._throttle_timers.clear() + self._pending_updates.clear() + self._pending_render_queue.clear() + self._last_update_time.clear() + + for handle in self._source_artifact_handles.values(): + self._store.release(handle) + self._source_artifact_handles.clear() + + def _send_view_artifact_created_signals( + self, + step_uid: str, + workpiece_uid: str, + handle: WorkPieceViewArtifactHandle, + ): + """Sends signals when a view artifact is created.""" + logger.debug( + f"_send_view_artifact_created_signals: step_uid={step_uid}, " + f"workpiece_uid={workpiece_uid}" + ) + self.view_artifact_created.send( + self, + step_uid=step_uid, + workpiece_uid=workpiece_uid, + handle=handle, + ) + + def _on_render_complete( + self, + task: Task, + key: str, + view_id: int, + workpiece_uid: str, + step_uid: str, + ): + """Callback for when a rendering task finishes.""" + self.generation_finished.send( + self, + key=key, + workpiece_uid=workpiece_uid, + step_uid=step_uid, + ) + + def _cancel_pending_throttled_update(self, composite_id: tuple[str, str]): + """Cancels any pending throttled update for the given composite.""" + self._pending_updates.pop(composite_id, None) + timer = self._throttle_timers.pop(composite_id, None) + if timer: + timer.cancel() + + def allocate_live_buffer( + self, + workpiece: WorkPiece, + step_uid: str, + view_id: int, + generation_id: int, + context: RenderContext, + ) -> None: + """ + Allocates a new blank view artifact based on the workpiece size and + registers it as the live buffer for this generation. + """ + workpiece_uid = workpiece.uid + composite_id = (workpiece_uid, step_uid) + + w_mm, h_mm = workpiece.size + bbox = (0.0, 0.0, w_mm, h_mm) + + dims = calculate_render_dimensions(bbox, context) + if dims is None: + return + + width_px, height_px, _, _ = dims + logger.debug( + f"[{composite_id}] Allocating live buffer: " + f"{width_px}x{height_px} px" + ) + + try: + bitmap = np.zeros(shape=(height_px, width_px, 4), dtype=np.uint8) + view_artifact = WorkPieceViewArtifact( + bitmap_data=bitmap, + bbox_mm=bbox, + workpiece_size_mm=(w_mm, h_mm), + generation_id=generation_id, + ) + + handle = self._store.put(view_artifact, creator_tag="view_live") + view_handle = cast(WorkPieceViewArtifactHandle, handle) + + entry = self._view_entries.get(composite_id) + if entry is None: + entry = ViewEntry() + self._view_entries[composite_id] = entry + if entry.handle is not None: + self._store.release(entry.handle) + self._cancel_pending_throttled_update(composite_id) + entry.bitmap = bitmap + entry.bbox_mm = bbox + entry.workpiece_size_mm = (w_mm, h_mm) + entry.handle = view_handle + entry.render_context = context + entry.source_handle = None + entry.laser_uid = None + entry.layer_uid = None + + self._send_view_artifact_created_signals( + step_uid, workpiece_uid, view_handle + ) + + except Exception: + logger.exception( + f"[{composite_id}] Failed to allocate live buffer" + ) + + def on_generation_starting( + self, + sender, + *, + step: Step, + workpiece: WorkPiece, + generation_id: int, + ): + """ + Called when workpiece generation starts. + Pre-allocates the view buffer to enable progressive rendering. + """ + composite_id = (workpiece.uid, step.uid) + entry = self._view_entries.get(composite_id) + existing_handle = entry.handle if entry else None + + task_key = self._get_task_key(workpiece.uid, step.uid) + task = self._task_manager.get_task(task_key) + if task and not task.is_final(): + self._task_manager.cancel_task(task_key) + + context = self._current_view_context + if not context: + return + + need_new_buffer = False + if ( + existing_handle is None + or entry is not None + and entry.render_context != context + ): + need_new_buffer = True + else: + w_mm, h_mm = workpiece.size + if existing_handle.workpiece_size_mm != (w_mm, h_mm): + need_new_buffer = True + + if need_new_buffer: + # Invalidate the old view bitmap so the canvas doesn't + # display stale content at the wrong size while the new + # generation is being computed. + if entry is not None: + entry.bitmap = None + entry.bbox_mm = None + entry.workpiece_size_mm = None + self.allocate_live_buffer( + workpiece=workpiece, + step_uid=step.uid, + view_id=self._view_generation_id, + generation_id=self._view_generation_id, + context=context, + ) + + def get_view_handle( + self, workpiece_uid: str, step_uid: str + ) -> WorkPieceViewArtifactHandle | None: + """Get the view handle for a specific workpiece and step.""" + composite_id = (workpiece_uid, step_uid) + entry = self._view_entries.get(composite_id) + return entry.handle if entry else None + + def get_view_bitmap( + self, workpiece_uid: str, step_uid: str + ) -> ( + tuple[ + np.ndarray, tuple[float, float, float, float], tuple[float, float] + ] + | None + ): + """Get the view bitmap for a specific workpiece and step. + + Returns ``(bitmap, bbox_mm, workpiece_size_mm)`` or ``None`` + if no view is available. + """ + composite_id = (workpiece_uid, step_uid) + entry = self._view_entries.get(composite_id) + if entry is None or entry.bitmap is None: + return None + if entry.bbox_mm is None or entry.workpiece_size_mm is None: + return None + return entry.bitmap, entry.bbox_mm, entry.workpiece_size_mm diff --git a/rayforge/render/__init__.py b/rayforge/render/__init__.py deleted file mode 100644 index b43d92cb2..000000000 --- a/rayforge/render/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# flake8: noqa:F401 -import inspect -from .renderer import Renderer -from .dxf import DXFRenderer -from .pdf import PDFRenderer -from .png import PNGRenderer -from .svg import SVGRenderer - -def isrenderer(obj): - return (inspect.isclass(obj) - and issubclass(obj, Renderer) - and not obj is Renderer) - -renderers = [obj for name, obj in list(locals().items()) if isrenderer(obj)] - -renderer_by_mime_type = dict() -for renderer in renderers: - for mime_type in renderer.mime_types: - renderer_by_mime_type[mime_type] = renderer diff --git a/rayforge/render/dxf.py b/rayforge/render/dxf.py deleted file mode 100644 index 4dbfb1db0..000000000 --- a/rayforge/render/dxf.py +++ /dev/null @@ -1,267 +0,0 @@ -import io -import math -import cairo -import ezdxf -from ezdxf import bbox -from .renderer import Renderer - - -units_to_mm = { - 0: None, # Unitless - 1: 25.4, # Inches → mm - 2: 304.8, # Feet → mm - 4: 1.0, # Millimeters - 5: 10.0, # Centimeters → mm - 6: 1000.0, # Meters → mm - 8: 0.0254, # Microinches → mm - 9: 0.0254, # Mils → mm - 10: 914.4, # Yards → mm -} - - -def get_scale_to_mm(doc, default=None): - insunits = doc.header.get("$INSUNITS", 0) # Default to 0 (undefined) - if insunits not in units_to_mm: - return default - return units_to_mm.get(insunits, default) or default - - -def get_bounds_px(doc): - """ - Return x, y, w, h - """ - msp = doc.modelspace() - entity_bbox = bbox.extents(msp) - if not entity_bbox.has_data: - return None - - min_x, min_y, _ = entity_bbox.extmin - max_x, max_y, _ = entity_bbox.extmax - return min_x, min_y, (max_x-min_x), (max_y-min_y) - - -def get_bounds_mm(doc): - """ - Return x, y, w, h - """ - bounds = get_bounds_px(doc) - if bounds is None: - return None - min_x, min_y, width, height = bounds - - scale = get_scale_to_mm(doc) - if scale is None: - return None - - return min_x*scale, min_y*scale, width*scale, height*scale - - -def draw_line(ctx, entity): - """Draw a LINE entity.""" - start = entity.dxf.start - end = entity.dxf.end - ctx.move_to(start.x, start.y) - ctx.line_to(end.x, end.y) - ctx.stroke() - - -def draw_circle(ctx, entity): - """Draw a CIRCLE entity.""" - center = entity.dxf.center - radius = entity.dxf.radius - ctx.arc(center.x, center.y, radius, 0, 2*math.pi) - ctx.stroke() - - -def draw_lwpolyline(ctx, entity, factor): - """Draw an LWPOLYLINE entity.""" - points = list(entity.vertices()) # Get vertices as tuples - if len(points) == 0: - return - ctx.move_to(points[0][0] * factor, points[0][1] * factor) - for point in points[1:]: - ctx.line_to(point[0] * factor, point[1] * factor) - if entity.closed: - ctx.close_path() - ctx.stroke() - - -def draw_arc(ctx, entity): - """Draw an ARC entity.""" - center = entity.dxf.center - radius = entity.dxf.radius - start_angle = math.radians(entity.dxf.start_angle) - end_angle = math.radians(entity.dxf.end_angle) - ctx.arc(center.x, center.y, radius, start_angle, end_angle) - ctx.stroke() - - -def draw_text(ctx, entity): - """Draw a TEXT entity.""" - ctx.save() # Save the current state of the context - insert = entity.dxf.insert - text = entity.dxf.text - height = entity.dxf.height - rotation = math.radians(entity.dxf.rotation) - - # Set font size and rotation - ctx.set_font_size(height) - ctx.translate(insert.x, insert.y) - ctx.rotate(rotation) - - # Draw text - ctx.move_to(0, 0) - ctx.show_text(text) - ctx.restore() # Restore the original state - - -def draw_ellipse(ctx, entity): - """Draw an ELLIPSE entity.""" - ctx.save() - center = entity.dxf.center - major_axis = entity.dxf.major_axis - ratio = entity.dxf.ratio - start_angle = math.radians(entity.dxf.start_param) - end_angle = math.radians(entity.dxf.end_param) - - # Calculate minor axis - minor_axis = (major_axis[1], -major_axis[0]) # Rotate 90 degrees - minor_axis = (minor_axis[0] * ratio, minor_axis[1] * ratio) - - # Apply transformation for ellipse - ctx.translate(center.x, center.y) - ctx.rotate(math.atan2(major_axis[1], major_axis[0])) - ctx.scale(math.hypot(*major_axis), math.hypot(*minor_axis)) - - # Draw ellipse - ctx.arc(0, 0, 1, start_angle, end_angle) - ctx.stroke() - ctx.restore() - - -def draw_spline(ctx, entity): - """Draw a SPLINE entity.""" - ctx.save() - control_points = entity.control_points() - if len(control_points) == 0: - return - - # Move to the first control point - ctx.move_to(control_points[0][0], control_points[0][1]) - - # Draw a polyline approximation of the spline - for point in control_points[1:]: - ctx.line_to(point[0], point[1]) - ctx.stroke() - ctx.restore() - - -def draw_insert(ctx, entity, doc): - """Draw an INSERT entity (block reference).""" - block = doc.blocks[entity.dxf.name] - insert_point = entity.dxf.insert - scale_x = entity.dxf.xscale - scale_y = entity.dxf.yscale - rotation = math.radians(entity.dxf.rotation) - - # Apply transformations for the block - ctx.save() - ctx.translate(insert_point.x, insert_point.y) - ctx.rotate(rotation) - ctx.scale(scale_x, scale_y) - - # Recursively render the block's entities - for block_entity in block: - match block_entity.dxftype(): - case 'LINE': - draw_line(ctx, block_entity) - case 'CIRCLE': - draw_circle(ctx, block_entity) - case 'LWPOLYLINE': - # No scaling for nested entities - draw_lwpolyline(ctx, block_entity, 1.0) - case 'ARC': - draw_arc(ctx, block_entity) - case 'TEXT': - draw_text(ctx, block_entity) - case 'ELLIPSE': - draw_ellipse(ctx, block_entity) - case 'SPLINE': - draw_spline(ctx, block_entity) - case 'INSERT': - draw_insert(ctx, block_entity, doc) # Handle nested blocks - case _: - thetype = block_entity.dxftype() - print(f"Unsupported nested entity type: {thetype}") - - ctx.restore() - - -class DXFRenderer(Renderer): - label = 'DFX files (2d)' - mime_types = ('image/vnd.dxf',) - extensions = ('.dxf',) - - @classmethod - def prepare(cls, data): - return ezdxf.read(io.StringIO(data.decode("utf-8"))) - - @classmethod - def get_natural_size(cls, data): - """ - Returns the natural size of the document in mm as a tuple (w, h). - This is BEFORE cropping the margins. - """ - bounds = get_bounds_mm(data) - if bounds is None: - return None, None # No known dimensions - return bounds[2], bounds[3] - - @classmethod - def get_aspect_ratio(cls, data): - x, y, w, h = get_bounds_px(data) - return w/h if h else 1 - - @classmethod - def render_workpiece(cls, data, width=None, height=None): - msp = data.modelspace() - factor = get_scale_to_mm(data, 1.0) # default to 1mm = 1px - - # Set up Cairo transformations - if width is None or height is None: - _, _, width, height = get_bounds_px(data) - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - ctx = cairo.Context(surface) - - # Scale and flip Y-axis due to DXF's coordinate system - x, y, w, h = get_bounds_px(data) - ctx.scale(width/w, -height/h) - ctx.translate(-x, -y-h) - - # Set default drawing style (0.1 mm line width, black) - ctx.set_source_rgb(0, 0, 0) - ctx.set_line_width(1*factor) - - # Draw all entities - for entity in msp: - match entity.dxftype(): - case 'LINE': - draw_line(ctx, entity) - case 'CIRCLE': - draw_circle(ctx, entity) - case 'LWPOLYLINE': - draw_lwpolyline(ctx, entity, factor) - case 'ARC': - draw_arc(ctx, entity) - case 'TEXT': - draw_text(ctx, entity) - case 'ELLIPSE': - draw_ellipse(ctx, entity) - case 'SPLINE': - draw_spline(ctx, entity) - case 'INSERT': - draw_insert(ctx, entity, data) - case _: - print(f"Unsupported entity type: {entity.dxftype()}") - - return surface diff --git a/rayforge/render/pdf.py b/rayforge/render/pdf.py deleted file mode 100644 index 494540f79..000000000 --- a/rayforge/render/pdf.py +++ /dev/null @@ -1,137 +0,0 @@ -import re -import io -import cairo -from PIL import Image -import pymupdf -from pypdf import PdfReader, PdfWriter -from .renderer import Renderer - - -def parse_length(s): - m = re.match(r"([0-9.]+)\s*([a-z%]*)", s) - if m: - return float(m.group(1)), m.group(2) or "pt" - return float(s), "pt" - - -def to_mm(value, unit): - """Convert a value to millimeters based on its unit.""" - if unit == "cm": - return value * 10 - if unit == "mm": - return value - elif unit == "in": - return value * 25.4 - elif unit == "pt": - return value * 25.4 / 72 - raise ValueError(f"Unsupported unit: {unit}") - - -class PDFRenderer(Renderer): - label = 'PDF files' - mime_types = ('application/pdf',) - extensions = ('.pdf',) - - @classmethod - def prepare(cls, data): - return cls._crop_to_content(data) - - @classmethod - def get_natural_size(cls, data): - reader = PdfReader(io.BytesIO(data)) - page = reader.pages[0] - media_box = page.mediabox - width_pt = float(media_box.width) - height_pt = float(media_box.height) - return to_mm(width_pt, "pt"), to_mm(height_pt, "pt") - - @classmethod - def get_aspect_ratio(cls, data): - width_mm, height_mm = cls.get_natural_size(data) - return width_mm / height_mm - - @classmethod - def render_workpiece(cls, data, width=None, height=None): - return cls._render_data(data, width, height) - - @classmethod - def _render_data(cls, data, width=None, height=None): - doc = pymupdf.open(stream=data, filetype="pdf") - page = doc.load_page(0) - zoom_x, zoom_y = 1.0, 1.0 - - if width or height: - rect = page.rect - zoom_x = width / rect.width if width else 1.0 - zoom_y = height / rect.height if height else 1.0 - - matrix = pymupdf.Matrix(zoom_x, zoom_y) - pix = page.get_pixmap(matrix=matrix, alpha=True) - - # Convert the pixmap to a Pillow image - img = Image.frombytes("RGBA", [pix.width, pix.height], pix.samples) - - # Save the Pillow image to an in-memory PNG buffer - buffer = io.BytesIO() - img.save(buffer, format="PNG") - buffer.seek(0) - - return cairo.ImageSurface.create_from_png(buffer) - - @classmethod - def _get_margins(cls, data): - doc = pymupdf.open(stream=data, filetype="pdf") - page = doc.load_page(0) - pix = page.get_pixmap(matrix=pymupdf.Matrix(1, 1), alpha=False) - img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) - gray_img = img.convert("L") - - # Invert to find non-white regions - inverted = Image.eval(gray_img, lambda x: 255 - x) - bbox = inverted.getbbox() - - if not bbox: - return (0.0, 0.0, 0.0, 0.0) - - x_min, y_min, x_max, y_max = bbox - img_w, img_h = gray_img.size - - left_pct = x_min / img_w - top_pct = y_min / img_h - right_pct = (img_w - x_max) / img_w - bottom_pct = (img_h - y_max) / img_h - - return left_pct, top_pct, right_pct, bottom_pct - - @classmethod - def _crop_to_content(cls, data): - left_pct, top_pct, right_pct, bottom_pct = cls._get_margins(data) - - reader = PdfReader(io.BytesIO(data)) - writer = PdfWriter() - - for page in reader.pages: - media_box = page.mediabox - x0 = float(media_box.left) - y0 = float(media_box.bottom) - x1 = float(media_box.right) - y1 = float(media_box.top) - width_pt = x1 - x0 - height_pt = y1 - y0 - - new_x0 = x0 + left_pct * width_pt - new_x1 = x1 - right_pct * width_pt - new_y0 = y0 + bottom_pct * height_pt - new_y1 = y1 - top_pct * height_pt - - # Create a new media box with the cropped dimensions - page.mediabox.left = new_x0 - page.mediabox.bottom = new_y0 - page.mediabox.right = new_x1 - page.mediabox.top = new_y1 - - writer.add_page(page) - - output = io.BytesIO() - writer.write(output) - return output.getvalue() diff --git a/rayforge/render/png.py b/rayforge/render/png.py deleted file mode 100644 index 6b4bbf0a1..000000000 --- a/rayforge/render/png.py +++ /dev/null @@ -1,28 +0,0 @@ -import cairo -import io -from ..util.cairoutil import make_transparent -from .renderer import Renderer - - -class PNGRenderer(Renderer): - label = 'PNG files' - mime_types = ('image/png',) - extensions = ('.png',) - - @classmethod - def prepare(cls, data): - stream = io.BytesIO(data) - surface = cairo.ImageSurface.create_from_png(stream) - make_transparent(surface) - stream.seek(0) - surface.write_to_png(stream) - return stream.getvalue() - - @classmethod - def get_aspect_ratio(cls, data): - surface = cairo.ImageSurface.create_from_png(io.BytesIO(data)) - return surface.get_width()/surface.get_height() - - @classmethod - def render_workpiece(cls, data, width=None, height=None): - return cairo.ImageSurface.create_from_png(io.BytesIO(data)) diff --git a/rayforge/render/renderer.py b/rayforge/render/renderer.py deleted file mode 100644 index c30aba133..000000000 --- a/rayforge/render/renderer.py +++ /dev/null @@ -1,43 +0,0 @@ -from abc import ABC, abstractmethod - - -class Renderer(ABC): - """ - Reads image data and renders to a Cairo surface. - """ - label = None - mime_types = None - extensions = None - - @classmethod - def prepare(cls, data): - """ - Called once for every image on import and can be used to preload - or prepare the image. - """ - return data - - @classmethod - @abstractmethod - def get_natural_size(cls, data): - """ - Returns the natural (untransformed) size of the image in mm, if - known. Return None, None, otherwise. - """ - return None, None - - @classmethod - @abstractmethod - def get_aspect_ratio(cls, data): - """ - Returns the natural (untransformed) aspect ratio of the image. - """ - pass - - @classmethod - @abstractmethod - def render_workpiece(cls, data, width=None, height=None): - """ - Renders to a Cairo surface. - """ - pass diff --git a/rayforge/render/svg.py b/rayforge/render/svg.py deleted file mode 100644 index dc378b7b9..000000000 --- a/rayforge/render/svg.py +++ /dev/null @@ -1,142 +0,0 @@ -import re -import io -import cairo -import cairosvg -from xml.etree import ElementTree as ET -from PIL import Image -from .renderer import Renderer - - -def parse_length(s): - m = re.match(r"([0-9.]+)\s*([a-z%]*)", s) - if m: - return float(m.group(1)), m.group(2) or "px" - return float(s), "px" - - -def to_mm(value, unit): - """Convert a value to millimeters based on its unit.""" - if unit == "cm": - return value*10 - if unit == "mm": - return value - elif unit == "in": - return value * 25.4 # 1 inch = 25.4 mm - raise ValueError("Cannot convert to millimeters without DPI information.") - - -class SVGRenderer(Renderer): - label = 'SVG files' - mime_types = ('image/svg+xml',) - extensions = ('.svg',) - - @classmethod - def prepare(cls, data): - return cls._crop_to_content(data) - - @classmethod - def get_natural_size(cls, data): - """ - Returns the natural size of the document in mm as a tuple (w, h). - This is BEFORE cropping the margins. - """ - # Parse the SVG from the bytestring - root = ET.fromstring(data) - - # Extract width and height attributes - width_attr = root.get("width") - height_attr = root.get("height") - - if not width_attr or not height_attr: - # SVG does not have width or height attributes. - return None, None - - width, width_unit = parse_length(width_attr) - height, height_unit = parse_length(height_attr) - - # Convert to millimeters - try: - width_mm = to_mm(width, width_unit) - height_mm = to_mm(height, height_unit) - except ValueError: - return None, None - - return width_mm, height_mm - - @classmethod - def get_aspect_ratio(cls, data): - surface = cls._render_data(data) - return surface.get_width()/surface.get_height() - - @classmethod - def render_workpiece(cls, data, width=None, height=None): - return cls._render_data(data, width, height) - - @classmethod - def _render_data(cls, data, width=None, height=None): - png_data = cairosvg.svg2png(bytestring=data, - parent_height=height, - output_height=height) - return cairo.ImageSurface.create_from_png(io.BytesIO(png_data)) - - @classmethod - def _get_margins(cls, data): - """ - Reliably finding the content width of an SVG is surprisingly hard. - I tried several modules (svgelements, svg2paths2) and all methods - failed depending on the content of the SVG. - - So instead I render the SVG to PNG, find the width and height - of the content in relation to the PNG size, and apply the factor - agains the viewport size of the SVG to get the actual bounds. - """ - # Open the image with PIL. - png_data = cairosvg.svg2png(bytestring=data) - img = Image.open(io.BytesIO(png_data)) - - # If the image has an alpha channel, use it to determine non- - # transparent pixels. - if img.mode in ('RGBA', 'LA'): - bbox = img.split()[-1].getbbox() # bbox of non-transparent pixels - else: - # Otherwise, convert to grayscale and compute bbox. - bbox = img.convert("L").getbbox() - - # Calculate margin percentages relative to the full image dimensions - x_min, y_min, x_max, y_max = bbox - img_w, img_h = img.size - left_pct = x_min / img_w - top_pct = y_min / img_h - right_pct = (img_w - x_max) / img_w - bottom_pct = (img_h - y_max) / img_h - - return left_pct, top_pct, right_pct, bottom_pct - - @classmethod - def _crop_to_content(cls, data): - left_pct, top_pct, right_pct, bottom_pct = cls._get_margins(data) - - root = ET.fromstring(data) - - # Adjust viewBox by applying the margin percentages - viewbox_str = root.get("viewBox") - if not viewbox_str: - return data # not sure what to do in this case. bail out - - vb_x, vb_y, vb_w, vb_h = map(float, viewbox_str.split()) - new_x = vb_x + left_pct * vb_w - new_y = vb_y + top_pct * vb_h - new_w = vb_w * (1 - left_pct - right_pct) - new_h = vb_h * (1 - top_pct - bottom_pct) - root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}") - - width_str = root.get("width") - if width_str: - width_val, unit = parse_length(width_str) - root.set("width", f"{new_w}{unit}") - height_str = root.get("height") - if height_str: - height_val, unit = parse_length(height_str) - root.set("height", f"{new_h}{unit}") - - return ET.tostring(root, encoding="unicode") diff --git a/rayforge/resources/devices/acmer-p3/device.yaml b/rayforge/resources/devices/acmer-p3/device.yaml new file mode 100644 index 000000000..aa4156679 --- /dev/null +++ b/rayforge/resources/devices/acmer-p3/device.yaml @@ -0,0 +1,43 @@ +api_version: 1 +device: + name: Acmer P3 + description: Dual-head diode laser engraver with 400x390mm work area +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + poll_status_while_running: false + gcode_precision: 3 + supports_arcs: true + axis_extents: + - 400.0 + - 390.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 48000 + home_on_start: false + acceleration: 1000 + single_axis_homing_enabled: true + heads: + - name: Blue Diode Laser + tool_number: 0 + max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.06 + focal_distance: 5.0 + laser_type: diode + pwm_frequency: 500 + - name: Infra Red Diode + tool_number: 1 + max_power: 1000 + frame_power_percent: 0.0 + focus_power_percent: 0.0 + spot_size_mm: + - 0.03 + - 0.03 + focal_distance: 5.0 + laser_type: diode + pwm_frequency: 500 diff --git a/rayforge/resources/devices/acmer-p3/dialect.yaml b/rayforge/resources/devices/acmer-p3/dialect.yaml new file mode 100644 index 000000000..3f68583f7 --- /dev/null +++ b/rayforge/resources/devices/acmer-p3/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/devices/acmer-s1/device.yaml b/rayforge/resources/devices/acmer-s1/device.yaml new file mode 100644 index 000000000..cc1e1d780 --- /dev/null +++ b/rayforge/resources/devices/acmer-s1/device.yaml @@ -0,0 +1,21 @@ +api_version: 1 +device: + name: Acmer S1 + description: Compact diode laser engraver with 130x130mm work area +machine: + driver: GrblSerialDriver + gcode_precision: 3 + axis_extents: + - 130.0 + - 130.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 3000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/acmer-s1/dialect.yaml b/rayforge/resources/devices/acmer-s1/dialect.yaml new file mode 100644 index 000000000..3f68583f7 --- /dev/null +++ b/rayforge/resources/devices/acmer-s1/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/devices/atomstack-a70/device.yaml b/rayforge/resources/devices/atomstack-a70/device.yaml new file mode 100644 index 000000000..9abb30257 --- /dev/null +++ b/rayforge/resources/devices/atomstack-a70/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Atomstack A70 + description: 70W diode laser cutter with 500x500mm work area and spot compression +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 500.0 + - 500.0 + origin: bottom_left + max_travel_speed: 36000 + max_cut_speed: 6000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.15 + - 0.12 diff --git a/rayforge/resources/devices/atomstack-a70/dialect.yaml b/rayforge/resources/devices/atomstack-a70/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/atomstack-a70/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/atomstack-x40-pro/device.yaml b/rayforge/resources/devices/atomstack-x40-pro/device.yaml new file mode 100644 index 000000000..b3a6d1d85 --- /dev/null +++ b/rayforge/resources/devices/atomstack-x40-pro/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Atomstack X40 Pro + description: 40W diode laser engraver with 400x400mm work area and air assist +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 400.0 + - 400.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 3000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.06 diff --git a/rayforge/resources/devices/atomstack-x40-pro/dialect.yaml b/rayforge/resources/devices/atomstack-x40-pro/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/atomstack-x40-pro/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/carvera-air/device.yaml b/rayforge/resources/devices/carvera-air/device.yaml new file mode 100644 index 000000000..64cfbe931 --- /dev/null +++ b/rayforge/resources/devices/carvera-air/device.yaml @@ -0,0 +1,22 @@ +api_version: 1 +device: + name: Carvera Air + description: Desktop laser engraver with Smoothieware controller and 300x200mm work area +machine: + driver: SmoothieDriver + gcode_precision: 4 + axis_extents: + - 300.0 + - 200.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 3000 + home_on_start: true + heads: + - tool_number: 8888 + max_power: 1.0 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/carvera-air/dialect.yaml b/rayforge/resources/devices/carvera-air/dialect.yaml new file mode 100644 index 000000000..8c142bda5 --- /dev/null +++ b/rayforge/resources/devices/carvera-air/dialect.yaml @@ -0,0 +1,39 @@ +laser_on: '' +laser_off: G1 S0 +focus_laser_on: M3 S{power:.0f} +tool_change: T{tool_number} +set_speed: '' +travel_move: G0 X{x} Y{y} Z{z}{extra_cmd} +linear_move: G1 X{x} Y{y} Z{z}{extra_cmd}{s_command}{f_command} +arc_cw: G2 X{x} Y{y} Z{z}{extra_cmd} I{i} J{j}{s_command}{f_command} +arc_ccw: G3 X{x} Y{y} Z{z}{extra_cmd} I{i} J{j}{s_command}{f_command} +bezier_cubic: '' +air_assist_on: M8 +air_assist_off: M9 +home_all: $H +home_axis: G28 {axis_letter}0 +move_to: G90 G0 X{x} Y{y} +jog: G91 G0 F{speed} +clear_alarm: M999 +set_wcs_offset: G10 L20 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: +- M321 +- G0Z0 +- G00 {machine.active_wcs} +- M3 +- G21 ; Set units to mm +- G90 ; Absolute positioning +postscript: +- M5 ; Ensure laser is off +- G0 X0 Y0 ; Return to origin +- ;USER END SCRIPT +- M322 +- ;USER END SCRIPT +- M2 +inject_wcs_after_preamble: false +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: false +modal_feedrate: false diff --git a/rayforge/resources/devices/carvera/device.yaml b/rayforge/resources/devices/carvera/device.yaml new file mode 100644 index 000000000..2e21e30fc --- /dev/null +++ b/rayforge/resources/devices/carvera/device.yaml @@ -0,0 +1,33 @@ +api_version: 1 +device: + name: Carvera + description: Desktop CNC machine with Smoothieware controller and 210x170mm work area +machine: + driver: SmoothieDriver + gcode_precision: 4 + axis_extents: + - 210.0 + - 170.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 3000 + home_on_start: true + heads: + - type: LaserHead + name: Laser Module + tool_number: -1 + max_power: 1.0 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.1 + - 0.1 + - type: SpindleHead + name: Spindle + tool_number: 1 + max_rpm: 20000 + min_rpm: 1000 + cooling_methods: [] + capabilities: + - LASER + - MILL diff --git a/rayforge/resources/devices/carvera/dialect.yaml b/rayforge/resources/devices/carvera/dialect.yaml new file mode 100644 index 000000000..8c142bda5 --- /dev/null +++ b/rayforge/resources/devices/carvera/dialect.yaml @@ -0,0 +1,39 @@ +laser_on: '' +laser_off: G1 S0 +focus_laser_on: M3 S{power:.0f} +tool_change: T{tool_number} +set_speed: '' +travel_move: G0 X{x} Y{y} Z{z}{extra_cmd} +linear_move: G1 X{x} Y{y} Z{z}{extra_cmd}{s_command}{f_command} +arc_cw: G2 X{x} Y{y} Z{z}{extra_cmd} I{i} J{j}{s_command}{f_command} +arc_ccw: G3 X{x} Y{y} Z{z}{extra_cmd} I{i} J{j}{s_command}{f_command} +bezier_cubic: '' +air_assist_on: M8 +air_assist_off: M9 +home_all: $H +home_axis: G28 {axis_letter}0 +move_to: G90 G0 X{x} Y{y} +jog: G91 G0 F{speed} +clear_alarm: M999 +set_wcs_offset: G10 L20 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: +- M321 +- G0Z0 +- G00 {machine.active_wcs} +- M3 +- G21 ; Set units to mm +- G90 ; Absolute positioning +postscript: +- M5 ; Ensure laser is off +- G0 X0 Y0 ; Return to origin +- ;USER END SCRIPT +- M322 +- ;USER END SCRIPT +- M2 +inject_wcs_after_preamble: false +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: false +modal_feedrate: false diff --git a/rayforge/resources/devices/creality-falcon-10w/device.yaml b/rayforge/resources/devices/creality-falcon-10w/device.yaml new file mode 100644 index 000000000..972ca6be3 --- /dev/null +++ b/rayforge/resources/devices/creality-falcon-10w/device.yaml @@ -0,0 +1,28 @@ +api_version: 1 +device: + name: Creality Falcon 10W + description: 10W diode laser engraver with 400x415mm work area +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + poll_status_while_running: false + gcode_precision: 3 + supports_arcs: true + supports_curves: false + axis_extents: + - 400.0 + - 415.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 10000 + home_on_start: true + acceleration: 500 + single_axis_homing_enabled: true + rotary_enabled_default: false + heads: + - max_power: 1000 + frame_power_percent: 0.1 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/creality-falcon-10w/dialect.yaml b/rayforge/resources/devices/creality-falcon-10w/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/creality-falcon-10w/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/creality-falcon-2-pro/device.yaml b/rayforge/resources/devices/creality-falcon-2-pro/device.yaml new file mode 100644 index 000000000..6e70b5601 --- /dev/null +++ b/rayforge/resources/devices/creality-falcon-2-pro/device.yaml @@ -0,0 +1,24 @@ +api_version: 1 +device: + name: Creality Falcon 2 Pro 40W + description: Enclosed 40W diode laser engraver with 300x300mm work area +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + rx_buffer_size_override: 127 + gcode_precision: 3 + supports_arcs: true + axis_extents: + - 300.0 + - 300.0 + origin: bottom_left + max_travel_speed: 25000 + max_cut_speed: 3000 + home_on_start: false + heads: + - max_power: 1000 + frame_power_percent: 0.1 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/creality-falcon-2-pro/dialect.yaml b/rayforge/resources/devices/creality-falcon-2-pro/dialect.yaml new file mode 100644 index 000000000..fb54762f1 --- /dev/null +++ b/rayforge/resources/devices/creality-falcon-2-pro/dialect.yaml @@ -0,0 +1,30 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +- '; Bounds: X{job.extents[0]} Y{job.extents[1]} to X{job.extents[2]} Y{job.extents[3]}' +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/creality-falcon-a1/device.yaml b/rayforge/resources/devices/creality-falcon-a1/device.yaml new file mode 100644 index 000000000..ce9f14a56 --- /dev/null +++ b/rayforge/resources/devices/creality-falcon-a1/device.yaml @@ -0,0 +1,34 @@ +api_version: 1 +device: + name: Creality Falcon A1 + description: Diode laser engraver with 381x305mm work area and rotary support +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + poll_status_while_running: false + rx_buffer_size_override: 127 + gcode_precision: 3 + supports_arcs: true + axis_extents: + - 381.0 + - 305.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 1000 + home_on_start: false + rotary_enabled_default: false + rotary_modules: + - name: Creality Roller Rotary + axis: A + mode: axis_replacement + rotary_type: rollers + roller_diameter: 36.0 + max_workpiece_length: 300.0 + model_path: creality-roller.glb + heads: + - max_power: 1000 + frame_power_percent: 0.1 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/creality-falcon-a1/dialect.yaml b/rayforge/resources/devices/creality-falcon-a1/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/creality-falcon-a1/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/elidor-z6/device.yaml b/rayforge/resources/devices/elidor-z6/device.yaml new file mode 100644 index 000000000..87dc8cec8 --- /dev/null +++ b/rayforge/resources/devices/elidor-z6/device.yaml @@ -0,0 +1,28 @@ +api_version: 1 +device: + name: Elidor Z6 + vendor: Elidor + model: Z6 + description: Diode laser engraver with 300x300mm work area + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + poll_status_while_running: false + driver_config: + rx_buffer_size: 31 + gcode_precision: 3 + axis_extents: + - 300.0 + - 300.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 2000 + home_on_start: true + heads: + - laser_type: diode + max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 0.3 + spot_size_mm: + - 0.13 + - 0.13 diff --git a/rayforge/resources/devices/elidor-z6/dialect.yaml b/rayforge/resources/devices/elidor-z6/dialect.yaml new file mode 100644 index 000000000..3f68583f7 --- /dev/null +++ b/rayforge/resources/devices/elidor-z6/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/devices/grbl-mks-dlc32/device.yaml b/rayforge/resources/devices/grbl-mks-dlc32/device.yaml new file mode 100644 index 000000000..51a32a219 --- /dev/null +++ b/rayforge/resources/devices/grbl-mks-dlc32/device.yaml @@ -0,0 +1,7 @@ +api_version: 1 +device: + name: Grbl MKS DLC32 + description: GRBL-based controller board for custom laser builds +machine: + driver: GrblSerialDriver + origin: bottom_left diff --git a/rayforge/resources/devices/grbl-mks-dlc32/dialect.yaml b/rayforge/resources/devices/grbl-mks-dlc32/dialect.yaml new file mode 100644 index 000000000..f47458008 --- /dev/null +++ b/rayforge/resources/devices/grbl-mks-dlc32/dialect.yaml @@ -0,0 +1,35 @@ +laser_on: M4 S{power:.0f} +laser_off: M5 +focus_laser_on: M4 S{power:.0f} +tool_change: T{tool_number} +set_speed: '' +travel_move: G0 X{x} Y{y} Z{z}{extra_cmd} +linear_move: G1 X{x} Y{y} Z{z}{extra_cmd}{f_command} +arc_cw: G2 X{x} Y{y} Z{z}{extra_cmd} I{i} J{j}{f_command} +arc_ccw: G3 X{x} Y{y} Z{z}{extra_cmd} I{i} J{j}{f_command} +bezier_cubic: '' +air_assist_on: M8 +air_assist_off: M9 +home_all: G0 X0 Y0 +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 +- 'G92 X0 Y0 Z0 ;Optional: Reset current position' +- G10 L2 P1 X0 Y0 Z0 ;Explicitly reset WCS to 0 +postscript: +- M5 ;Turn off laser +- G4 P0.5 ;Wait 0.5 seconds for motion to settle +- G0 X0 Y0 ;Return to origin +- G4 P0.5 ;Wait again to ensure the move completes +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/devices/longer-ray5/device.yaml b/rayforge/resources/devices/longer-ray5/device.yaml new file mode 100644 index 000000000..886a76a11 --- /dev/null +++ b/rayforge/resources/devices/longer-ray5/device.yaml @@ -0,0 +1,26 @@ +api_version: 1 +device: + name: Longer Ray5 20W + description: Diode laser engraver with 375x375mm work area and touchscreen display +machine: + driver: GrblNetworkDriver + driver_args: + host: '192.168.0.1' + port: 8848 + ws_port: 8849 + protocol: 'Longer' + gcode_precision: 3 + axis_extents: + - 375.0 + - 375.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 3000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.08 diff --git a/rayforge/resources/devices/longer-ray5/dialect.yaml b/rayforge/resources/devices/longer-ray5/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/longer-ray5/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/monport-60w-co2/device.yaml b/rayforge/resources/devices/monport-60w-co2/device.yaml new file mode 100644 index 000000000..453e9886c --- /dev/null +++ b/rayforge/resources/devices/monport-60w-co2/device.yaml @@ -0,0 +1,31 @@ +api_version: 1 +device: + name: Monport 60W CO2 + description: 60W CO2 laser cutter with 600x400mm work area and Ruida controller +machine: + driver: RuidaDriver + gcode_precision: 3 + axis_extents: + - 600.0 + - 400.0 + origin: top_left + max_travel_speed: 60000 + max_cut_speed: 50000 + home_on_start: true + driver_args: + host: '' + main_port: 50200 + jog_port: 50210 + heads: + - max_power: 1000 + frame_power_percent: 0.0 + focus_power_percent: 0.0 + spot_size_mm: + - 0.1 + - 0.1 + laser_type: co2 + pwm_frequency: 1000 + max_pwm_frequency: 5000 + pulse_width: 50 + min_pulse_width: 5 + max_pulse_width: 500 diff --git a/rayforge/resources/devices/neje-master-3-max/device.yaml b/rayforge/resources/devices/neje-master-3-max/device.yaml new file mode 100644 index 000000000..4449e7ace --- /dev/null +++ b/rayforge/resources/devices/neje-master-3-max/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: NEJE Master 3 Max + description: 40W diode laser engraver with 460x460mm work area +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 460.0 + - 460.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 3000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.06 diff --git a/rayforge/resources/devices/neje-master-3-max/dialect.yaml b/rayforge/resources/devices/neje-master-3-max/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/neje-master-3-max/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/omtech-k40plus/device.yaml b/rayforge/resources/devices/omtech-k40plus/device.yaml new file mode 100644 index 000000000..fc7835439 --- /dev/null +++ b/rayforge/resources/devices/omtech-k40plus/device.yaml @@ -0,0 +1,32 @@ +api_version: 1 +device: + name: OMTech K40+ + description: CO2 laser cutter with 300x200mm work area +machine: + driver: GrblSerialDriver + gcode_precision: 3 + axis_extents: + - 300.0 + - 200.0 + work_margins: + - 5.0 + - 5.0 + - 5.0 + - 5.0 + origin: top_left + max_travel_speed: 3000 + max_cut_speed: 18000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 0.0 + focus_power_percent: 0.0 + spot_size_mm: + - 0.1 + - 0.1 + laser_type: co2 + pwm_frequency: 1000 + max_pwm_frequency: 5000 + pulse_width: 50 + min_pulse_width: 5 + max_pulse_width: 500 diff --git a/rayforge/resources/devices/omtech-k40plus/dialect.yaml b/rayforge/resources/devices/omtech-k40plus/dialect.yaml new file mode 100644 index 000000000..266569720 --- /dev/null +++ b/rayforge/resources/devices/omtech-k40plus/dialect.yaml @@ -0,0 +1,31 @@ +laser_on: M4 S0 +laser_off: M5 +focus_laser_on: M4 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}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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/devices/omtech-polar/device.yaml b/rayforge/resources/devices/omtech-polar/device.yaml new file mode 100644 index 000000000..f9a21fa53 --- /dev/null +++ b/rayforge/resources/devices/omtech-polar/device.yaml @@ -0,0 +1,31 @@ +api_version: 1 +device: + name: OMTech Polar 50W + description: Desktop 50W CO2 laser cutter with 508x305mm work area and Ruida controller +machine: + driver: RuidaDriver + gcode_precision: 3 + axis_extents: + - 508.0 + - 305.0 + origin: top_left + max_travel_speed: 60000 + max_cut_speed: 30000 + home_on_start: true + driver_args: + host: '' + main_port: 50200 + jog_port: 50210 + heads: + - max_power: 1000 + frame_power_percent: 0.0 + focus_power_percent: 0.0 + spot_size_mm: + - 0.1 + - 0.1 + laser_type: co2 + pwm_frequency: 1000 + max_pwm_frequency: 5000 + pulse_width: 50 + min_pulse_width: 5 + max_pulse_width: 500 diff --git a/rayforge/resources/devices/ortur-laser-master-3/device.yaml b/rayforge/resources/devices/ortur-laser-master-3/device.yaml new file mode 100644 index 000000000..bede94e69 --- /dev/null +++ b/rayforge/resources/devices/ortur-laser-master-3/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Ortur Laser Master 3 + description: Diode laser engraver with 400x400mm work area and 32-bit controller +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 400.0 + - 400.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 3000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.08 diff --git a/rayforge/resources/devices/ortur-laser-master-3/dialect.yaml b/rayforge/resources/devices/ortur-laser-master-3/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/ortur-laser-master-3/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/ortur-laser-master-4/device.yaml b/rayforge/resources/devices/ortur-laser-master-4/device.yaml new file mode 100644 index 000000000..8156a7684 --- /dev/null +++ b/rayforge/resources/devices/ortur-laser-master-4/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Ortur Laser Master 4 + description: Diode laser engraver with 400x400mm work area and high-speed capabilities +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 400.0 + - 400.0 + origin: bottom_left + max_travel_speed: 20000 + max_cut_speed: 6000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.06 + - 0.06 diff --git a/rayforge/resources/devices/ortur-laser-master-4/dialect.yaml b/rayforge/resources/devices/ortur-laser-master-4/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/ortur-laser-master-4/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/sculpfun-c1/device.yaml b/rayforge/resources/devices/sculpfun-c1/device.yaml new file mode 100644 index 000000000..3b06fc039 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-c1/device.yaml @@ -0,0 +1,20 @@ +api_version: 1 +device: + name: Sculpfun C1 + description: Budget diode laser engraver with 150x130mm work area +machine: + driver: GrblSerialDriver + gcode_precision: 3 + axis_extents: + - 150.0 + - 130.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 10000 + heads: + - max_power: 1000 + frame_power_percent: 0.25 + focus_power_percent: 0.25 + spot_size_mm: + - 0.04 + - 0.04 diff --git a/rayforge/resources/devices/sculpfun-c1/dialect.yaml b/rayforge/resources/devices/sculpfun-c1/dialect.yaml new file mode 100644 index 000000000..3f68583f7 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-c1/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/devices/sculpfun-icube-ultra/device.yaml b/rayforge/resources/devices/sculpfun-icube-ultra/device.yaml new file mode 100644 index 000000000..e8e0fffd3 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-icube-ultra/device.yaml @@ -0,0 +1,28 @@ +api_version: 1 +device: + name: Sculpfun iCube Ultra + vendor: Sculpfun + model: iCube Ultra + description: Diode laser engraver with Bluetooth and 150x150mm work area +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + poll_status_while_running: false + gcode_precision: 3 + axis_extents: + - 150.0 + - 150.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 6000 + supports_arcs: true + supports_curves: false + home_on_start: false + heads: + - max_power: 1000 + frame_power_percent: 2.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.08 diff --git a/rayforge/resources/devices/sculpfun-icube-ultra/dialect.yaml b/rayforge/resources/devices/sculpfun-icube-ultra/dialect.yaml new file mode 100644 index 000000000..1e907b60b --- /dev/null +++ b/rayforge/resources/devices/sculpfun-icube-ultra/dialect.yaml @@ -0,0 +1,31 @@ +laser_on: M4 S{power:.0f} +laser_off: M5 +focus_laser_on: M4 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/devices/sculpfun-icube/device.yaml b/rayforge/resources/devices/sculpfun-icube/device.yaml new file mode 100644 index 000000000..41ad1c2ec --- /dev/null +++ b/rayforge/resources/devices/sculpfun-icube/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Sculpfun iCube + description: Compact diode laser engraver with 120x120mm work area +machine: + driver: GrblSerialDriver + driver_config: + rx_buffer_size: 31 + gcode_precision: 3 + axis_extents: + - 120.0 + - 120.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 1000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/sculpfun-icube/dialect.yaml b/rayforge/resources/devices/sculpfun-icube/dialect.yaml new file mode 100644 index 000000000..1e907b60b --- /dev/null +++ b/rayforge/resources/devices/sculpfun-icube/dialect.yaml @@ -0,0 +1,31 @@ +laser_on: M4 S{power:.0f} +laser_off: M5 +focus_laser_on: M4 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/devices/sculpfun-s30-pro-max/device.yaml b/rayforge/resources/devices/sculpfun-s30-pro-max/device.yaml new file mode 100644 index 000000000..079008063 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s30-pro-max/device.yaml @@ -0,0 +1,21 @@ +api_version: 1 +device: + name: Sculpfun S30 Pro Max + description: 20W diode laser engraver with 370x360mm work area and automatic + air-assist +machine: + driver: GrblSerialDriver + gcode_precision: 3 + axis_extents: + - 370.0 + - 360.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 3000 + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.1 diff --git a/rayforge/resources/devices/sculpfun-s30-pro-max/dialect.yaml b/rayforge/resources/devices/sculpfun-s30-pro-max/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s30-pro-max/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/sculpfun-s30/device.yaml b/rayforge/resources/devices/sculpfun-s30/device.yaml new file mode 100644 index 000000000..68dbd5392 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s30/device.yaml @@ -0,0 +1,20 @@ +api_version: 1 +device: + name: Sculpfun S30 + description: Budget diode laser engraver with 400x400mm work area +machine: + driver: GrblSerialDriver + gcode_precision: 3 + axis_extents: + - 400.0 + - 400.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 1000 + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.1 + - 0.1 diff --git a/rayforge/resources/devices/sculpfun-s30/dialect.yaml b/rayforge/resources/devices/sculpfun-s30/dialect.yaml new file mode 100644 index 000000000..1e907b60b --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s30/dialect.yaml @@ -0,0 +1,31 @@ +laser_on: M4 S{power:.0f} +laser_off: M5 +focus_laser_on: M4 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/devices/sculpfun-s40-max/device.yaml b/rayforge/resources/devices/sculpfun-s40-max/device.yaml new file mode 100644 index 000000000..ea18f6201 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s40-max/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Sculpfun S40 MAX + description: 48W diode laser cutter with 830x800mm work area and auto-focus +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 830.0 + - 800.0 + origin: bottom_left + max_travel_speed: 36000 + max_cut_speed: 6000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.154 + - 0.121 diff --git a/rayforge/resources/devices/sculpfun-s40-max/dialect.yaml b/rayforge/resources/devices/sculpfun-s40-max/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s40-max/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/sculpfun-s70-max/device.yaml b/rayforge/resources/devices/sculpfun-s70-max/device.yaml new file mode 100644 index 000000000..41a3133c9 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s70-max/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: Sculpfun S70 MAX + description: 70W diode laser cutter with 830x800mm work area and auto-focus +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 830.0 + - 800.0 + origin: bottom_left + max_travel_speed: 36000 + max_cut_speed: 6000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.15 + - 0.12 diff --git a/rayforge/resources/devices/sculpfun-s70-max/dialect.yaml b/rayforge/resources/devices/sculpfun-s70-max/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s70-max/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/thunder-laser-nova35/device.yaml b/rayforge/resources/devices/thunder-laser-nova35/device.yaml new file mode 100644 index 000000000..c1e170077 --- /dev/null +++ b/rayforge/resources/devices/thunder-laser-nova35/device.yaml @@ -0,0 +1,31 @@ +api_version: 1 +device: + name: Thunder Laser Nova 35 + description: 80W CO2 laser cutter with 900x600mm work area and Ruida controller +machine: + driver: RuidaDriver + gcode_precision: 3 + axis_extents: + - 900.0 + - 600.0 + origin: top_left + max_travel_speed: 60000 + max_cut_speed: 50000 + home_on_start: true + driver_args: + host: '' + main_port: 50200 + jog_port: 50210 + heads: + - max_power: 1000 + frame_power_percent: 0.0 + focus_power_percent: 0.0 + spot_size_mm: + - 0.1 + - 0.1 + laser_type: co2 + pwm_frequency: 1000 + max_pwm_frequency: 5000 + pulse_width: 50 + min_pulse_width: 5 + max_pulse_width: 500 diff --git a/rayforge/resources/devices/twotrees-tts55/device.yaml b/rayforge/resources/devices/twotrees-tts55/device.yaml new file mode 100644 index 000000000..ac9a6a44e --- /dev/null +++ b/rayforge/resources/devices/twotrees-tts55/device.yaml @@ -0,0 +1,23 @@ +api_version: 1 +device: + name: TwoTrees TTS-55 + description: 55W diode laser engraver with 400x400mm work area +machine: + driver: GrblSerialDriver + driver_args: + baudrate: '115200' + gcode_precision: 3 + axis_extents: + - 400.0 + - 400.0 + origin: bottom_left + max_travel_speed: 10000 + max_cut_speed: 3000 + home_on_start: true + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.08 + - 0.08 diff --git a/rayforge/resources/devices/twotrees-tts55/dialect.yaml b/rayforge/resources/devices/twotrees-tts55/dialect.yaml new file mode 100644 index 000000000..18b667393 --- /dev/null +++ b/rayforge/resources/devices/twotrees-tts55/dialect.yaml @@ -0,0 +1,29 @@ +laser_on: M4 S0 +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}{f_command}{s_command} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command}{s_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command}{s_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} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +can_g0_with_speed: true +omit_unchanged_coords: true +continuous_laser_mode: true +modal_feedrate: true diff --git a/rayforge/resources/devices/xtool-d1-pro/device.yaml b/rayforge/resources/devices/xtool-d1-pro/device.yaml new file mode 100644 index 000000000..69178bf3a --- /dev/null +++ b/rayforge/resources/devices/xtool-d1-pro/device.yaml @@ -0,0 +1,25 @@ +api_version: 1 +device: + name: xTool D1 Pro + description: Diode laser engraver with 430x390mm work area, connected via Wi-Fi +machine: + driver: GrblNetworkDriver + gcode_precision: 3 + axis_extents: + - 430.0 + - 390.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 1000 + home_on_start: true + driver_args: + host: '' + port: 8080 + ws_port: 8081 + heads: + - max_power: 1000 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.05 + - 0.05 diff --git a/rayforge/resources/devices/xtool-d1-pro/dialect.yaml b/rayforge/resources/devices/xtool-d1-pro/dialect.yaml new file mode 100644 index 000000000..1881d64fc --- /dev/null +++ b/rayforge/resources/devices/xtool-d1-pro/dialect.yaml @@ -0,0 +1,34 @@ +laser_on: M4 S{power:.0f} +laser_off: M5 +focus_laser_on: M4 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 +- M5 +- M17 +- M106 S0 +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/3d-rotation-symbolic.svg b/rayforge/resources/icons/3d-rotation-symbolic.svg new file mode 100644 index 000000000..a08940f07 --- /dev/null +++ b/rayforge/resources/icons/3d-rotation-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/3d-symbolic.svg b/rayforge/resources/icons/3d-symbolic.svg new file mode 100644 index 000000000..30f3256c8 --- /dev/null +++ b/rayforge/resources/icons/3d-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/add-stock-symbolic.svg b/rayforge/resources/icons/add-stock-symbolic.svg new file mode 100644 index 000000000..4aae7e633 --- /dev/null +++ b/rayforge/resources/icons/add-stock-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/add-symbolic.svg b/rayforge/resources/icons/add-symbolic.svg new file mode 100644 index 000000000..7d680a826 --- /dev/null +++ b/rayforge/resources/icons/add-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/addon-builtin-symbolic.svg b/rayforge/resources/icons/addon-builtin-symbolic.svg new file mode 100644 index 000000000..0e2960401 --- /dev/null +++ b/rayforge/resources/icons/addon-builtin-symbolic.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/rayforge/resources/icons/addon-symbolic.svg b/rayforge/resources/icons/addon-symbolic.svg new file mode 100644 index 000000000..1fbc0ce75 --- /dev/null +++ b/rayforge/resources/icons/addon-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/ai-symbolic.svg b/rayforge/resources/icons/ai-symbolic.svg new file mode 100644 index 000000000..11111ec65 --- /dev/null +++ b/rayforge/resources/icons/ai-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/alarm-symbolic.svg b/rayforge/resources/icons/alarm-symbolic.svg new file mode 100644 index 000000000..4f5201562 --- /dev/null +++ b/rayforge/resources/icons/alarm-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/align-bottom-symbolic.svg b/rayforge/resources/icons/align-bottom-symbolic.svg new file mode 100644 index 000000000..820e981db --- /dev/null +++ b/rayforge/resources/icons/align-bottom-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/align-horizontal-center-symbolic.svg b/rayforge/resources/icons/align-horizontal-center-symbolic.svg new file mode 100644 index 000000000..91123b070 --- /dev/null +++ b/rayforge/resources/icons/align-horizontal-center-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/align-left-symbolic.svg b/rayforge/resources/icons/align-left-symbolic.svg new file mode 100644 index 000000000..064044a32 --- /dev/null +++ b/rayforge/resources/icons/align-left-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/align-right-symbolic.svg b/rayforge/resources/icons/align-right-symbolic.svg new file mode 100644 index 000000000..6598d7463 --- /dev/null +++ b/rayforge/resources/icons/align-right-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/align-top-symbolic.svg b/rayforge/resources/icons/align-top-symbolic.svg new file mode 100644 index 000000000..18d078364 --- /dev/null +++ b/rayforge/resources/icons/align-top-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/align-vertical-center-symbolic.svg b/rayforge/resources/icons/align-vertical-center-symbolic.svg new file mode 100644 index 000000000..456e3d22e --- /dev/null +++ b/rayforge/resources/icons/align-vertical-center-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-east-symbolic.svg b/rayforge/resources/icons/arrow-east-symbolic.svg new file mode 100644 index 000000000..493ebf4c6 --- /dev/null +++ b/rayforge/resources/icons/arrow-east-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-north-east-symbolic.svg b/rayforge/resources/icons/arrow-north-east-symbolic.svg new file mode 100644 index 000000000..2053b3788 --- /dev/null +++ b/rayforge/resources/icons/arrow-north-east-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-north-symbolic.svg b/rayforge/resources/icons/arrow-north-symbolic.svg new file mode 100644 index 000000000..1495e3c6a --- /dev/null +++ b/rayforge/resources/icons/arrow-north-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-north-west-symbolic.svg b/rayforge/resources/icons/arrow-north-west-symbolic.svg new file mode 100644 index 000000000..26d7e9dac --- /dev/null +++ b/rayforge/resources/icons/arrow-north-west-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-south-east-symbolic.svg b/rayforge/resources/icons/arrow-south-east-symbolic.svg new file mode 100644 index 000000000..e0bbe686e --- /dev/null +++ b/rayforge/resources/icons/arrow-south-east-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-south-symbolic.svg b/rayforge/resources/icons/arrow-south-symbolic.svg new file mode 100644 index 000000000..9f93fce02 --- /dev/null +++ b/rayforge/resources/icons/arrow-south-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-south-west-symbolic.svg b/rayforge/resources/icons/arrow-south-west-symbolic.svg new file mode 100644 index 000000000..98bec02c5 --- /dev/null +++ b/rayforge/resources/icons/arrow-south-west-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-west-symbolic.svg b/rayforge/resources/icons/arrow-west-symbolic.svg new file mode 100644 index 000000000..7119f9c7b --- /dev/null +++ b/rayforge/resources/icons/arrow-west-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/arrow-z-down-symbolic.svg b/rayforge/resources/icons/arrow-z-down-symbolic.svg new file mode 100644 index 000000000..f91e4822f --- /dev/null +++ b/rayforge/resources/icons/arrow-z-down-symbolic.svg @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/rayforge/resources/icons/arrow-z-up-symbolic.svg b/rayforge/resources/icons/arrow-z-up-symbolic.svg new file mode 100644 index 000000000..14a7500e7 --- /dev/null +++ b/rayforge/resources/icons/arrow-z-up-symbolic.svg @@ -0,0 +1,43 @@ + + + + + + + diff --git a/rayforge/resources/icons/auto-layout-symbolic.svg b/rayforge/resources/icons/auto-layout-symbolic.svg new file mode 100644 index 000000000..951fb5ba3 --- /dev/null +++ b/rayforge/resources/icons/auto-layout-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/batch-symbolic.svg b/rayforge/resources/icons/batch-symbolic.svg new file mode 100644 index 000000000..4c60a2dd6 --- /dev/null +++ b/rayforge/resources/icons/batch-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/block-symbolic.svg b/rayforge/resources/icons/block-symbolic.svg new file mode 100644 index 000000000..879b00fb8 --- /dev/null +++ b/rayforge/resources/icons/block-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/bottom-left-symbolic.svg b/rayforge/resources/icons/bottom-left-symbolic.svg new file mode 100644 index 000000000..0d1438705 --- /dev/null +++ b/rayforge/resources/icons/bottom-left-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/bottom-right-symbolic.svg b/rayforge/resources/icons/bottom-right-symbolic.svg new file mode 100644 index 000000000..55877ec93 --- /dev/null +++ b/rayforge/resources/icons/bottom-right-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/camera-off-symbolic.svg b/rayforge/resources/icons/camera-off-symbolic.svg new file mode 100644 index 000000000..5a678c449 --- /dev/null +++ b/rayforge/resources/icons/camera-off-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/camera-on-symbolic.svg b/rayforge/resources/icons/camera-on-symbolic.svg new file mode 100644 index 000000000..d6007db12 --- /dev/null +++ b/rayforge/resources/icons/camera-on-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/center-symbolic.svg b/rayforge/resources/icons/center-symbolic.svg new file mode 100644 index 000000000..8070352e0 --- /dev/null +++ b/rayforge/resources/icons/center-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/check-circle-symbolic.svg b/rayforge/resources/icons/check-circle-symbolic.svg new file mode 100644 index 000000000..c8ed70d9d --- /dev/null +++ b/rayforge/resources/icons/check-circle-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/check-circle.svg b/rayforge/resources/icons/check-circle.svg deleted file mode 100644 index 76be07347..000000000 --- a/rayforge/resources/icons/check-circle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/check-symbolic.svg b/rayforge/resources/icons/check-symbolic.svg new file mode 100644 index 000000000..696592d2f --- /dev/null +++ b/rayforge/resources/icons/check-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/chevron-right-symbolic.svg b/rayforge/resources/icons/chevron-right-symbolic.svg new file mode 100644 index 000000000..8f7ccaab3 --- /dev/null +++ b/rayforge/resources/icons/chevron-right-symbolic.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/rayforge/resources/icons/clear-alarm-symbolic.svg b/rayforge/resources/icons/clear-alarm-symbolic.svg new file mode 100644 index 000000000..d3a3baed8 --- /dev/null +++ b/rayforge/resources/icons/clear-alarm-symbolic.svg @@ -0,0 +1,20 @@ + + + + + + diff --git a/rayforge/resources/icons/clear-layers-symbolic.svg b/rayforge/resources/icons/clear-layers-symbolic.svg new file mode 100644 index 000000000..b87e9d39f --- /dev/null +++ b/rayforge/resources/icons/clear-layers-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/clear-layers.svg b/rayforge/resources/icons/clear-layers.svg deleted file mode 100644 index 53d0d7d1b..000000000 --- a/rayforge/resources/icons/clear-layers.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/clear-symbolic.svg b/rayforge/resources/icons/clear-symbolic.svg new file mode 100644 index 000000000..ef9a589ff --- /dev/null +++ b/rayforge/resources/icons/clear-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/close-document-symbolic.svg b/rayforge/resources/icons/close-document-symbolic.svg new file mode 100644 index 000000000..941c97421 --- /dev/null +++ b/rayforge/resources/icons/close-document-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/close-document.svg b/rayforge/resources/icons/close-document.svg deleted file mode 100644 index b126029f7..000000000 --- a/rayforge/resources/icons/close-document.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/close-symbolic.svg b/rayforge/resources/icons/close-symbolic.svg new file mode 100644 index 000000000..8af95eaf1 --- /dev/null +++ b/rayforge/resources/icons/close-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/code-symbolic.svg b/rayforge/resources/icons/code-symbolic.svg new file mode 100644 index 000000000..cbd0d9815 --- /dev/null +++ b/rayforge/resources/icons/code-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/compass-symbolic.svg b/rayforge/resources/icons/compass-symbolic.svg new file mode 100644 index 000000000..0aeeefb07 --- /dev/null +++ b/rayforge/resources/icons/compass-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/computer-symbolic.svg b/rayforge/resources/icons/computer-symbolic.svg new file mode 100644 index 000000000..b24dd4c38 --- /dev/null +++ b/rayforge/resources/icons/computer-symbolic.svg @@ -0,0 +1,6 @@ + + + + diff --git a/rayforge/resources/icons/copy-symbolic.svg b/rayforge/resources/icons/copy-symbolic.svg new file mode 100644 index 000000000..59bc004f1 --- /dev/null +++ b/rayforge/resources/icons/copy-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/crosshairs-symbolic.svg b/rayforge/resources/icons/crosshairs-symbolic.svg new file mode 100644 index 000000000..a54859d9c --- /dev/null +++ b/rayforge/resources/icons/crosshairs-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/crown-symbolic.svg b/rayforge/resources/icons/crown-symbolic.svg new file mode 100644 index 000000000..7f1c85a76 --- /dev/null +++ b/rayforge/resources/icons/crown-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/cut-symbolic.svg b/rayforge/resources/icons/cut-symbolic.svg new file mode 100644 index 000000000..0ddb430dd --- /dev/null +++ b/rayforge/resources/icons/cut-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/cycle-symbolic.svg b/rayforge/resources/icons/cycle-symbolic.svg new file mode 100644 index 000000000..08a79966e --- /dev/null +++ b/rayforge/resources/icons/cycle-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/cycle.svg b/rayforge/resources/icons/cycle.svg deleted file mode 100644 index 023fe3ba3..000000000 --- a/rayforge/resources/icons/cycle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/delete-symbolic.svg b/rayforge/resources/icons/delete-symbolic.svg new file mode 100644 index 000000000..6aa9ad031 --- /dev/null +++ b/rayforge/resources/icons/delete-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/delete.svg b/rayforge/resources/icons/delete.svg deleted file mode 100644 index 9ddea3d40..000000000 --- a/rayforge/resources/icons/delete.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/distribute-horizontal-symbolic.svg b/rayforge/resources/icons/distribute-horizontal-symbolic.svg new file mode 100644 index 000000000..676c85adf --- /dev/null +++ b/rayforge/resources/icons/distribute-horizontal-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/distribute-vertical-symbolic.svg b/rayforge/resources/icons/distribute-vertical-symbolic.svg new file mode 100644 index 000000000..1208e1ba3 --- /dev/null +++ b/rayforge/resources/icons/distribute-vertical-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/document-save-symbolic.svg b/rayforge/resources/icons/document-save-symbolic.svg new file mode 100644 index 000000000..1a1b426bd --- /dev/null +++ b/rayforge/resources/icons/document-save-symbolic.svg @@ -0,0 +1,6 @@ + + + + diff --git a/rayforge/resources/icons/door-symbolic.svg b/rayforge/resources/icons/door-symbolic.svg new file mode 100644 index 000000000..3a87117c2 --- /dev/null +++ b/rayforge/resources/icons/door-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/door.svg b/rayforge/resources/icons/door.svg deleted file mode 100644 index c0b903294..000000000 --- a/rayforge/resources/icons/door.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/download-symbolic.svg b/rayforge/resources/icons/download-symbolic.svg new file mode 100644 index 000000000..b71212252 --- /dev/null +++ b/rayforge/resources/icons/download-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/drag-handle-symbolic.svg b/rayforge/resources/icons/drag-handle-symbolic.svg new file mode 100644 index 000000000..6a2c39746 --- /dev/null +++ b/rayforge/resources/icons/drag-handle-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/drive-harddisk-symbolic.svg b/rayforge/resources/icons/drive-harddisk-symbolic.svg new file mode 100644 index 000000000..b2b881817 --- /dev/null +++ b/rayforge/resources/icons/drive-harddisk-symbolic.svg @@ -0,0 +1,6 @@ + + + + diff --git a/rayforge/resources/icons/drive-removable-media-symbolic.svg b/rayforge/resources/icons/drive-removable-media-symbolic.svg new file mode 100644 index 000000000..56c42cf35 --- /dev/null +++ b/rayforge/resources/icons/drive-removable-media-symbolic.svg @@ -0,0 +1,6 @@ + + + + diff --git a/rayforge/resources/icons/edit-symbolic.svg b/rayforge/resources/icons/edit-symbolic.svg new file mode 100644 index 000000000..2ad09b49b --- /dev/null +++ b/rayforge/resources/icons/edit-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/engrave-symbolic.svg b/rayforge/resources/icons/engrave-symbolic.svg new file mode 100644 index 000000000..689e7e723 --- /dev/null +++ b/rayforge/resources/icons/engrave-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/eraser-symbolic.svg b/rayforge/resources/icons/eraser-symbolic.svg new file mode 100644 index 000000000..320105311 --- /dev/null +++ b/rayforge/resources/icons/eraser-symbolic.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rayforge/resources/icons/error-symbolic.svg b/rayforge/resources/icons/error-symbolic.svg new file mode 100644 index 000000000..d351d4c9b --- /dev/null +++ b/rayforge/resources/icons/error-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/experimental-symbolic.svg b/rayforge/resources/icons/experimental-symbolic.svg new file mode 100644 index 000000000..bea2abece --- /dev/null +++ b/rayforge/resources/icons/experimental-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/export-symbolic.svg b/rayforge/resources/icons/export-symbolic.svg new file mode 100644 index 000000000..c5a04bdc3 --- /dev/null +++ b/rayforge/resources/icons/export-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/fast-forward-symbolic.svg b/rayforge/resources/icons/fast-forward-symbolic.svg new file mode 100644 index 000000000..3a3430d58 --- /dev/null +++ b/rayforge/resources/icons/fast-forward-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/fast-forward.svg b/rayforge/resources/icons/fast-forward.svg deleted file mode 100644 index b13d30b5c..000000000 --- a/rayforge/resources/icons/fast-forward.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/fast-rewind-symbolic.svg b/rayforge/resources/icons/fast-rewind-symbolic.svg new file mode 100644 index 000000000..b6f7653b3 --- /dev/null +++ b/rayforge/resources/icons/fast-rewind-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/file-dxf-symbolic.svg b/rayforge/resources/icons/file-dxf-symbolic.svg new file mode 100644 index 000000000..f9203ef11 --- /dev/null +++ b/rayforge/resources/icons/file-dxf-symbolic.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/rayforge/resources/icons/file-jpg-symbolic.svg b/rayforge/resources/icons/file-jpg-symbolic.svg new file mode 100644 index 000000000..e27f70b11 --- /dev/null +++ b/rayforge/resources/icons/file-jpg-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/file-pdf-symbolic.svg b/rayforge/resources/icons/file-pdf-symbolic.svg new file mode 100644 index 000000000..9ba7d4602 --- /dev/null +++ b/rayforge/resources/icons/file-pdf-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/file-png-symbolic.svg b/rayforge/resources/icons/file-png-symbolic.svg new file mode 100644 index 000000000..7265b633a --- /dev/null +++ b/rayforge/resources/icons/file-png-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/file-rd-symbolic.svg b/rayforge/resources/icons/file-rd-symbolic.svg new file mode 100644 index 000000000..f48b7cc61 --- /dev/null +++ b/rayforge/resources/icons/file-rd-symbolic.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/rayforge/resources/icons/file-svg-symbolic.svg b/rayforge/resources/icons/file-svg-symbolic.svg new file mode 100644 index 000000000..a8fdb1819 --- /dev/null +++ b/rayforge/resources/icons/file-svg-symbolic.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/rayforge/resources/icons/flip-horizontal-symbolic.svg b/rayforge/resources/icons/flip-horizontal-symbolic.svg new file mode 100644 index 000000000..5ede91bea --- /dev/null +++ b/rayforge/resources/icons/flip-horizontal-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/flip-vertical-symbolic.svg b/rayforge/resources/icons/flip-vertical-symbolic.svg new file mode 100644 index 000000000..d7bd1b9e1 --- /dev/null +++ b/rayforge/resources/icons/flip-vertical-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/frame-symbolic.svg b/rayforge/resources/icons/frame-symbolic.svg new file mode 100644 index 000000000..c98313d9a --- /dev/null +++ b/rayforge/resources/icons/frame-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/frame.svg b/rayforge/resources/icons/frame.svg deleted file mode 100644 index 5d5f0a44d..000000000 --- a/rayforge/resources/icons/frame.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/gcode-symbolic.svg b/rayforge/resources/icons/gcode-symbolic.svg new file mode 100644 index 000000000..b761f1bd8 --- /dev/null +++ b/rayforge/resources/icons/gcode-symbolic.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/rayforge/resources/icons/general-symbolic.svg b/rayforge/resources/icons/general-symbolic.svg new file mode 100644 index 000000000..e4b3fa294 --- /dev/null +++ b/rayforge/resources/icons/general-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/go-down-symbolic.svg b/rayforge/resources/icons/go-down-symbolic.svg new file mode 100644 index 000000000..8d0ab07a0 --- /dev/null +++ b/rayforge/resources/icons/go-down-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/go-next-symbolic.svg b/rayforge/resources/icons/go-next-symbolic.svg new file mode 100644 index 000000000..52dad3b97 --- /dev/null +++ b/rayforge/resources/icons/go-next-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/go-previous-symbolic.svg b/rayforge/resources/icons/go-previous-symbolic.svg new file mode 100644 index 000000000..d3e096a20 --- /dev/null +++ b/rayforge/resources/icons/go-previous-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/go-up-symbolic.svg b/rayforge/resources/icons/go-up-symbolic.svg new file mode 100644 index 000000000..393bbed32 --- /dev/null +++ b/rayforge/resources/icons/go-up-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/goto-origin-symbolic.svg b/rayforge/resources/icons/goto-origin-symbolic.svg new file mode 100644 index 000000000..7071a9c24 --- /dev/null +++ b/rayforge/resources/icons/goto-origin-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/hardware-symbolic.svg b/rayforge/resources/icons/hardware-symbolic.svg new file mode 100644 index 000000000..5c77269ab --- /dev/null +++ b/rayforge/resources/icons/hardware-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/home-symbolic.svg b/rayforge/resources/icons/home-symbolic.svg new file mode 100644 index 000000000..4724c93f3 --- /dev/null +++ b/rayforge/resources/icons/home-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/home-x-symbolic.svg b/rayforge/resources/icons/home-x-symbolic.svg new file mode 100644 index 000000000..8e67c2936 --- /dev/null +++ b/rayforge/resources/icons/home-x-symbolic.svg @@ -0,0 +1,51 @@ + + + + + X + + diff --git a/rayforge/resources/icons/home-y-symbolic.svg b/rayforge/resources/icons/home-y-symbolic.svg new file mode 100644 index 000000000..33b4aafba --- /dev/null +++ b/rayforge/resources/icons/home-y-symbolic.svg @@ -0,0 +1,51 @@ + + + + + Y + + diff --git a/rayforge/resources/icons/home-z-symbolic.svg b/rayforge/resources/icons/home-z-symbolic.svg new file mode 100644 index 000000000..ee4b7b915 --- /dev/null +++ b/rayforge/resources/icons/home-z-symbolic.svg @@ -0,0 +1,51 @@ + + + + + Z + + diff --git a/rayforge/resources/icons/home.svg b/rayforge/resources/icons/home.svg deleted file mode 100644 index fbebaf2d2..000000000 --- a/rayforge/resources/icons/home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/homing-symbolic.svg b/rayforge/resources/icons/homing-symbolic.svg new file mode 100644 index 000000000..262e9b44c --- /dev/null +++ b/rayforge/resources/icons/homing-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/homing.svg b/rayforge/resources/icons/homing.svg deleted file mode 100644 index 470f6bbf4..000000000 --- a/rayforge/resources/icons/homing.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/hourglass-symbolic.svg b/rayforge/resources/icons/hourglass-symbolic.svg new file mode 100644 index 000000000..be5c10ae0 --- /dev/null +++ b/rayforge/resources/icons/hourglass-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/hourglass.svg b/rayforge/resources/icons/hourglass.svg deleted file mode 100644 index 07667a06e..000000000 --- a/rayforge/resources/icons/hourglass.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/image-x-generic-symbolic.svg b/rayforge/resources/icons/image-x-generic-symbolic.svg new file mode 100644 index 000000000..05439794e --- /dev/null +++ b/rayforge/resources/icons/image-x-generic-symbolic.svg @@ -0,0 +1,43 @@ + + + + + + + diff --git a/rayforge/resources/icons/info-symbolic.svg b/rayforge/resources/icons/info-symbolic.svg new file mode 100644 index 000000000..4d2fcb3c7 --- /dev/null +++ b/rayforge/resources/icons/info-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/jog-symbolic.svg b/rayforge/resources/icons/jog-symbolic.svg new file mode 100644 index 000000000..7c5bb466b --- /dev/null +++ b/rayforge/resources/icons/jog-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/laps-symbolic.svg b/rayforge/resources/icons/laps-symbolic.svg new file mode 100644 index 000000000..71346856d --- /dev/null +++ b/rayforge/resources/icons/laps-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/laps.svg b/rayforge/resources/icons/laps.svg deleted file mode 100644 index edd4eab58..000000000 --- a/rayforge/resources/icons/laps.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/laser-off-symbolic.svg b/rayforge/resources/icons/laser-off-symbolic.svg new file mode 100644 index 000000000..c13a301e9 --- /dev/null +++ b/rayforge/resources/icons/laser-off-symbolic.svg @@ -0,0 +1,43 @@ + + + + + + + diff --git a/rayforge/resources/icons/laser-on-symbolic.svg b/rayforge/resources/icons/laser-on-symbolic.svg new file mode 100644 index 000000000..ab03702c5 --- /dev/null +++ b/rayforge/resources/icons/laser-on-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/laser-path-symbolic.svg b/rayforge/resources/icons/laser-path-symbolic.svg new file mode 100644 index 000000000..360e1971c --- /dev/null +++ b/rayforge/resources/icons/laser-path-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/laser-path.svg b/rayforge/resources/icons/laser-path.svg deleted file mode 100644 index 228d18e29..000000000 --- a/rayforge/resources/icons/laser-path.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/layer-symbolic.svg b/rayforge/resources/icons/layer-symbolic.svg new file mode 100644 index 000000000..44f3ab7d6 --- /dev/null +++ b/rayforge/resources/icons/layer-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/layers-symbolic.svg b/rayforge/resources/icons/layers-symbolic.svg new file mode 100644 index 000000000..fff99f23c --- /dev/null +++ b/rayforge/resources/icons/layers-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/license-symbolic.svg b/rayforge/resources/icons/license-symbolic.svg new file mode 100644 index 000000000..f94eeb65a --- /dev/null +++ b/rayforge/resources/icons/license-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/lock-open-symbolic.svg b/rayforge/resources/icons/lock-open-symbolic.svg new file mode 100644 index 000000000..5b55a1048 --- /dev/null +++ b/rayforge/resources/icons/lock-open-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/lock-symbolic.svg b/rayforge/resources/icons/lock-symbolic.svg new file mode 100644 index 000000000..12832f889 --- /dev/null +++ b/rayforge/resources/icons/lock-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/locked-symbolic.svg b/rayforge/resources/icons/locked-symbolic.svg new file mode 100644 index 000000000..d49ffa230 --- /dev/null +++ b/rayforge/resources/icons/locked-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/locked.svg b/rayforge/resources/icons/locked.svg deleted file mode 100644 index 64c54c89d..000000000 --- a/rayforge/resources/icons/locked.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/machine-settings-advanced-symbolic.svg b/rayforge/resources/icons/machine-settings-advanced-symbolic.svg new file mode 100644 index 000000000..0fcf7bda4 --- /dev/null +++ b/rayforge/resources/icons/machine-settings-advanced-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/machine-settings-general-symbolic.svg b/rayforge/resources/icons/machine-settings-general-symbolic.svg new file mode 100644 index 000000000..9104b3846 --- /dev/null +++ b/rayforge/resources/icons/machine-settings-general-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/material-symbolic.svg b/rayforge/resources/icons/material-symbolic.svg new file mode 100644 index 000000000..f3297bf33 --- /dev/null +++ b/rayforge/resources/icons/material-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/model-symbolic.svg b/rayforge/resources/icons/model-symbolic.svg new file mode 100644 index 000000000..2f5844517 --- /dev/null +++ b/rayforge/resources/icons/model-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/move-symbolic.svg b/rayforge/resources/icons/move-symbolic.svg new file mode 100644 index 000000000..6569a7c80 --- /dev/null +++ b/rayforge/resources/icons/move-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/network-server-symbolic.svg b/rayforge/resources/icons/network-server-symbolic.svg new file mode 100644 index 000000000..684849c82 --- /dev/null +++ b/rayforge/resources/icons/network-server-symbolic.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/rayforge/resources/icons/network-wired-symbolic.svg b/rayforge/resources/icons/network-wired-symbolic.svg new file mode 100644 index 000000000..e20a6b388 --- /dev/null +++ b/rayforge/resources/icons/network-wired-symbolic.svg @@ -0,0 +1,6 @@ + + + + diff --git a/rayforge/resources/icons/next-symbolic.svg b/rayforge/resources/icons/next-symbolic.svg new file mode 100644 index 000000000..eba95e228 --- /dev/null +++ b/rayforge/resources/icons/next-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/open-in-new-symbolic.svg b/rayforge/resources/icons/open-in-new-symbolic.svg new file mode 100644 index 000000000..aa3b834ad --- /dev/null +++ b/rayforge/resources/icons/open-in-new-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/open-symbolic.svg b/rayforge/resources/icons/open-symbolic.svg new file mode 100644 index 000000000..da0f5b71e --- /dev/null +++ b/rayforge/resources/icons/open-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/open.svg b/rayforge/resources/icons/open.svg deleted file mode 100644 index c0c566a9a..000000000 --- a/rayforge/resources/icons/open.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/org.rayforge.rayforge.svg b/rayforge/resources/icons/org.rayforge.rayforge.svg new file mode 100644 index 000000000..7202bdfb5 --- /dev/null +++ b/rayforge/resources/icons/org.rayforge.rayforge.svg @@ -0,0 +1,730 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rayforge/resources/icons/palette-symbolic.svg b/rayforge/resources/icons/palette-symbolic.svg new file mode 100644 index 000000000..98030c98a --- /dev/null +++ b/rayforge/resources/icons/palette-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/pan-down-symbolic.svg b/rayforge/resources/icons/pan-down-symbolic.svg new file mode 100644 index 000000000..b806b4153 --- /dev/null +++ b/rayforge/resources/icons/pan-down-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/pause-symbolic.svg b/rayforge/resources/icons/pause-symbolic.svg new file mode 100644 index 000000000..ba782c057 --- /dev/null +++ b/rayforge/resources/icons/pause-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/pause.svg b/rayforge/resources/icons/pause.svg deleted file mode 100644 index a568b7c79..000000000 --- a/rayforge/resources/icons/pause.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/play-arrow-symbolic.svg b/rayforge/resources/icons/play-arrow-symbolic.svg new file mode 100644 index 000000000..4fb43a8bf --- /dev/null +++ b/rayforge/resources/icons/play-arrow-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/play-arrow.svg b/rayforge/resources/icons/play-arrow.svg deleted file mode 100644 index 117b492e8..000000000 --- a/rayforge/resources/icons/play-arrow.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/post-processor-symbolic.svg b/rayforge/resources/icons/post-processor-symbolic.svg new file mode 100644 index 000000000..1e084e353 --- /dev/null +++ b/rayforge/resources/icons/post-processor-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/preliminary-check-symbolic.svg b/rayforge/resources/icons/preliminary-check-symbolic.svg new file mode 100644 index 000000000..8b7c79627 --- /dev/null +++ b/rayforge/resources/icons/preliminary-check-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/preliminary-check.svg b/rayforge/resources/icons/preliminary-check.svg deleted file mode 100644 index 0e569a160..000000000 --- a/rayforge/resources/icons/preliminary-check.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/preview-off-symbolic.svg b/rayforge/resources/icons/preview-off-symbolic.svg new file mode 100644 index 000000000..35845d165 --- /dev/null +++ b/rayforge/resources/icons/preview-off-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/preview-off.svg b/rayforge/resources/icons/preview-off.svg deleted file mode 100644 index 498b0ecdd..000000000 --- a/rayforge/resources/icons/preview-off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/preview_off-symbolic.svg b/rayforge/resources/icons/preview_off-symbolic.svg new file mode 100644 index 000000000..35845d165 --- /dev/null +++ b/rayforge/resources/icons/preview_off-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/preview_off.svg b/rayforge/resources/icons/preview_off.svg deleted file mode 100644 index 498b0ecdd..000000000 --- a/rayforge/resources/icons/preview_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/previous-symbolic.svg b/rayforge/resources/icons/previous-symbolic.svg new file mode 100644 index 000000000..c414c8db5 --- /dev/null +++ b/rayforge/resources/icons/previous-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/publish-symbolic.svg b/rayforge/resources/icons/publish-symbolic.svg new file mode 100644 index 000000000..f08de9859 --- /dev/null +++ b/rayforge/resources/icons/publish-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/publish.svg b/rayforge/resources/icons/publish.svg deleted file mode 100644 index e77d9a195..000000000 --- a/rayforge/resources/icons/publish.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/query-symbolic.svg b/rayforge/resources/icons/query-symbolic.svg new file mode 100644 index 000000000..81ffd9523 --- /dev/null +++ b/rayforge/resources/icons/query-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/question-box-symbolic.svg b/rayforge/resources/icons/question-box-symbolic.svg new file mode 100644 index 000000000..6b89cccb5 --- /dev/null +++ b/rayforge/resources/icons/question-box-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/question-box.svg b/rayforge/resources/icons/question-box.svg deleted file mode 100644 index 60ed0810a..000000000 --- a/rayforge/resources/icons/question-box.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/question-mark-symbolic.svg b/rayforge/resources/icons/question-mark-symbolic.svg new file mode 100644 index 000000000..c8cf75e16 --- /dev/null +++ b/rayforge/resources/icons/question-mark-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/question-mark.svg b/rayforge/resources/icons/question-mark.svg deleted file mode 100644 index bf68c3ce3..000000000 --- a/rayforge/resources/icons/question-mark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/queued-symbolic.svg b/rayforge/resources/icons/queued-symbolic.svg new file mode 100644 index 000000000..d596fde7a --- /dev/null +++ b/rayforge/resources/icons/queued-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/queued.svg b/rayforge/resources/icons/queued.svg deleted file mode 100644 index bfcb49beb..000000000 --- a/rayforge/resources/icons/queued.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/rayforge.icns b/rayforge/resources/icons/rayforge.icns new file mode 100644 index 000000000..c65a2588c Binary files /dev/null and b/rayforge/resources/icons/rayforge.icns differ diff --git a/rayforge/resources/icons/rayforge.icon/Assets/icon.png b/rayforge/resources/icons/rayforge.icon/Assets/icon.png new file mode 100644 index 000000000..23790163c Binary files /dev/null and b/rayforge/resources/icons/rayforge.icon/Assets/icon.png differ diff --git a/rayforge/resources/icons/rayforge.icon/icon.json b/rayforge/resources/icons/rayforge.icon/icon.json new file mode 100644 index 000000000..dcf357896 --- /dev/null +++ b/rayforge/resources/icons/rayforge.icon/icon.json @@ -0,0 +1,36 @@ +{ + "fill" : { + "automatic-gradient" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "icon.png", + "name" : "icon", + "position" : { + "scale" : 1.5, + "translation-in-points" : [ + 0, + -5.684341886080802e-14 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/rayforge/resources/icons/recipe-symbolic.svg b/rayforge/resources/icons/recipe-symbolic.svg new file mode 100644 index 000000000..a76f818bc --- /dev/null +++ b/rayforge/resources/icons/recipe-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/redo-symbolic.svg b/rayforge/resources/icons/redo-symbolic.svg new file mode 100644 index 000000000..4981c7a3c --- /dev/null +++ b/rayforge/resources/icons/redo-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/refresh-symbolic.svg b/rayforge/resources/icons/refresh-symbolic.svg new file mode 100644 index 000000000..cc70b936c --- /dev/null +++ b/rayforge/resources/icons/refresh-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/reset-stock-symbolic.svg b/rayforge/resources/icons/reset-stock-symbolic.svg new file mode 100644 index 000000000..e48183a23 --- /dev/null +++ b/rayforge/resources/icons/reset-stock-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/resume-symbolic.svg b/rayforge/resources/icons/resume-symbolic.svg new file mode 100644 index 000000000..0ee92bdef --- /dev/null +++ b/rayforge/resources/icons/resume-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/resume.svg b/rayforge/resources/icons/resume.svg deleted file mode 100644 index c06a534df..000000000 --- a/rayforge/resources/icons/resume.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/rotary-symbolic.svg b/rayforge/resources/icons/rotary-symbolic.svg new file mode 100644 index 000000000..dc2cd626b --- /dev/null +++ b/rayforge/resources/icons/rotary-symbolic.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + diff --git a/rayforge/resources/icons/save-as-symbolic.svg b/rayforge/resources/icons/save-as-symbolic.svg new file mode 100644 index 000000000..33fe711ff --- /dev/null +++ b/rayforge/resources/icons/save-as-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/save-as.svg b/rayforge/resources/icons/save-as.svg deleted file mode 100644 index 9cb4e057c..000000000 --- a/rayforge/resources/icons/save-as.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/save-symbolic.svg b/rayforge/resources/icons/save-symbolic.svg new file mode 100644 index 000000000..17175b7ef --- /dev/null +++ b/rayforge/resources/icons/save-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/save.svg b/rayforge/resources/icons/save.svg deleted file mode 100644 index 9fcc7707f..000000000 --- a/rayforge/resources/icons/save.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/save_as-symbolic.svg b/rayforge/resources/icons/save_as-symbolic.svg new file mode 100644 index 000000000..33fe711ff --- /dev/null +++ b/rayforge/resources/icons/save_as-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/save_as.svg b/rayforge/resources/icons/save_as.svg deleted file mode 100644 index 9cb4e057c..000000000 --- a/rayforge/resources/icons/save_as.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/score-symbolic.svg b/rayforge/resources/icons/score-symbolic.svg new file mode 100644 index 000000000..c32a7eb4b --- /dev/null +++ b/rayforge/resources/icons/score-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/send-symbolic.svg b/rayforge/resources/icons/send-symbolic.svg new file mode 100644 index 000000000..df80e6e77 --- /dev/null +++ b/rayforge/resources/icons/send-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/send.svg b/rayforge/resources/icons/send.svg deleted file mode 100644 index 9f7711b00..000000000 --- a/rayforge/resources/icons/send.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/settings-symbolic.svg b/rayforge/resources/icons/settings-symbolic.svg new file mode 100644 index 000000000..80d61646b --- /dev/null +++ b/rayforge/resources/icons/settings-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/settings.svg b/rayforge/resources/icons/settings.svg deleted file mode 100644 index 8cb503db9..000000000 --- a/rayforge/resources/icons/settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/share-symbolic.svg b/rayforge/resources/icons/share-symbolic.svg new file mode 100644 index 000000000..d775c3ef0 --- /dev/null +++ b/rayforge/resources/icons/share-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/siren-symbolic.svg b/rayforge/resources/icons/siren-symbolic.svg new file mode 100644 index 000000000..33fb50596 --- /dev/null +++ b/rayforge/resources/icons/siren-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/siren.svg b/rayforge/resources/icons/siren.svg deleted file mode 100644 index 9be0b6f4e..000000000 --- a/rayforge/resources/icons/siren.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/sketch-add-symbolic.svg b/rayforge/resources/icons/sketch-add-symbolic.svg new file mode 100644 index 000000000..6764c0ace --- /dev/null +++ b/rayforge/resources/icons/sketch-add-symbolic.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/rayforge/resources/icons/sketch-arc-symbolic.svg b/rayforge/resources/icons/sketch-arc-symbolic.svg new file mode 100644 index 000000000..c937fc825 --- /dev/null +++ b/rayforge/resources/icons/sketch-arc-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-bezier-sharp-symbolic.svg b/rayforge/resources/icons/sketch-bezier-sharp-symbolic.svg new file mode 100644 index 000000000..82ce08d45 --- /dev/null +++ b/rayforge/resources/icons/sketch-bezier-sharp-symbolic.svg @@ -0,0 +1,51 @@ + + + + + + + + + diff --git a/rayforge/resources/icons/sketch-bezier-smooth-symbolic.svg b/rayforge/resources/icons/sketch-bezier-smooth-symbolic.svg new file mode 100644 index 000000000..20c595f6d --- /dev/null +++ b/rayforge/resources/icons/sketch-bezier-smooth-symbolic.svg @@ -0,0 +1,47 @@ + + + + + + + diff --git a/rayforge/resources/icons/sketch-bezier-symbolic.svg b/rayforge/resources/icons/sketch-bezier-symbolic.svg new file mode 100644 index 000000000..4dbbc3748 --- /dev/null +++ b/rayforge/resources/icons/sketch-bezier-symbolic.svg @@ -0,0 +1,60 @@ + + + + + + + + + diff --git a/rayforge/resources/icons/sketch-bezier-symmetric-symbolic.svg b/rayforge/resources/icons/sketch-bezier-symmetric-symbolic.svg new file mode 100644 index 000000000..d99bda61b --- /dev/null +++ b/rayforge/resources/icons/sketch-bezier-symmetric-symbolic.svg @@ -0,0 +1,67 @@ + + + + + + + + + + diff --git a/rayforge/resources/icons/sketch-chamfer-symbolic.svg b/rayforge/resources/icons/sketch-chamfer-symbolic.svg new file mode 100644 index 000000000..81bba01ac --- /dev/null +++ b/rayforge/resources/icons/sketch-chamfer-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-circle-symbolic.svg b/rayforge/resources/icons/sketch-circle-symbolic.svg new file mode 100644 index 000000000..7e631b3f4 --- /dev/null +++ b/rayforge/resources/icons/sketch-circle-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-angle-symbolic.svg b/rayforge/resources/icons/sketch-constrain-angle-symbolic.svg new file mode 100644 index 000000000..ab67c6e6f --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-angle-symbolic.svg @@ -0,0 +1,51 @@ + + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-aspect-symbolic.svg b/rayforge/resources/icons/sketch-constrain-aspect-symbolic.svg new file mode 100644 index 000000000..164647d3c --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-aspect-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-equal-symbolic.svg b/rayforge/resources/icons/sketch-constrain-equal-symbolic.svg new file mode 100644 index 000000000..450d8f6ee --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-equal-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-horizontal-symbolic.svg b/rayforge/resources/icons/sketch-constrain-horizontal-symbolic.svg new file mode 100644 index 000000000..42ddeed1b --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-horizontal-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-perpendicular-symbolic.svg b/rayforge/resources/icons/sketch-constrain-perpendicular-symbolic.svg new file mode 100644 index 000000000..58caef3ba --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-perpendicular-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-point-symbolic.svg b/rayforge/resources/icons/sketch-constrain-point-symbolic.svg new file mode 100644 index 000000000..4fadad124 --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-point-symbolic.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-symmetric-symbolic.svg b/rayforge/resources/icons/sketch-constrain-symmetric-symbolic.svg new file mode 100644 index 000000000..2a4dbfab0 --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-symmetric-symbolic.svg @@ -0,0 +1,43 @@ + + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-tangential-symbolic.svg b/rayforge/resources/icons/sketch-constrain-tangential-symbolic.svg new file mode 100644 index 000000000..d89a616da --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-tangential-symbolic.svg @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-vertical-symbolic.svg b/rayforge/resources/icons/sketch-constrain-vertical-symbolic.svg new file mode 100644 index 000000000..8073474f7 --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-vertical-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-constrain-verticalhorizontal-symbolic.svg b/rayforge/resources/icons/sketch-constrain-verticalhorizontal-symbolic.svg new file mode 100644 index 000000000..3de158b61 --- /dev/null +++ b/rayforge/resources/icons/sketch-constrain-verticalhorizontal-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-construction-symbolic.svg b/rayforge/resources/icons/sketch-construction-symbolic.svg new file mode 100644 index 000000000..fc43eb109 --- /dev/null +++ b/rayforge/resources/icons/sketch-construction-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-diameter-symbolic.svg b/rayforge/resources/icons/sketch-diameter-symbolic.svg new file mode 100644 index 000000000..ff2b69cc1 --- /dev/null +++ b/rayforge/resources/icons/sketch-diameter-symbolic.svg @@ -0,0 +1,42 @@ + + + + + + + diff --git a/rayforge/resources/icons/sketch-distance-symbolic.svg b/rayforge/resources/icons/sketch-distance-symbolic.svg new file mode 100644 index 000000000..a5302fb3d --- /dev/null +++ b/rayforge/resources/icons/sketch-distance-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-edit-symbolic.svg b/rayforge/resources/icons/sketch-edit-symbolic.svg new file mode 100644 index 000000000..7c61f9883 --- /dev/null +++ b/rayforge/resources/icons/sketch-edit-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-fill-symbolic.svg b/rayforge/resources/icons/sketch-fill-symbolic.svg new file mode 100644 index 000000000..fe2f8dbd7 --- /dev/null +++ b/rayforge/resources/icons/sketch-fill-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-fillet-symbolic.svg b/rayforge/resources/icons/sketch-fillet-symbolic.svg new file mode 100644 index 000000000..d175d6855 --- /dev/null +++ b/rayforge/resources/icons/sketch-fillet-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-grid-symbolic.svg b/rayforge/resources/icons/sketch-grid-symbolic.svg new file mode 100644 index 000000000..52a1a78ee --- /dev/null +++ b/rayforge/resources/icons/sketch-grid-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-line-symbolic.svg b/rayforge/resources/icons/sketch-line-symbolic.svg new file mode 100644 index 000000000..3ca556433 --- /dev/null +++ b/rayforge/resources/icons/sketch-line-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-radius-symbolic.svg b/rayforge/resources/icons/sketch-radius-symbolic.svg new file mode 100644 index 000000000..1c68cd8ab --- /dev/null +++ b/rayforge/resources/icons/sketch-radius-symbolic.svg @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/rayforge/resources/icons/sketch-rect-symbolic.svg b/rayforge/resources/icons/sketch-rect-symbolic.svg new file mode 100644 index 000000000..2e531a350 --- /dev/null +++ b/rayforge/resources/icons/sketch-rect-symbolic.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-rounded-rect-symbolic.svg b/rayforge/resources/icons/sketch-rounded-rect-symbolic.svg new file mode 100644 index 000000000..4cf3b47db --- /dev/null +++ b/rayforge/resources/icons/sketch-rounded-rect-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-select-symbolic.svg b/rayforge/resources/icons/sketch-select-symbolic.svg new file mode 100644 index 000000000..4553b7de3 --- /dev/null +++ b/rayforge/resources/icons/sketch-select-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-symbolic.svg b/rayforge/resources/icons/sketch-symbolic.svg new file mode 100644 index 000000000..86d4b4869 --- /dev/null +++ b/rayforge/resources/icons/sketch-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/sketch-text-symbolic.svg b/rayforge/resources/icons/sketch-text-symbolic.svg new file mode 100644 index 000000000..7f1c6872f --- /dev/null +++ b/rayforge/resources/icons/sketch-text-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/skip-forward-symbolic.svg b/rayforge/resources/icons/skip-forward-symbolic.svg new file mode 100644 index 000000000..232bda357 --- /dev/null +++ b/rayforge/resources/icons/skip-forward-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/skip-previous-symbolic.svg b/rayforge/resources/icons/skip-previous-symbolic.svg new file mode 100644 index 000000000..792bacdd9 --- /dev/null +++ b/rayforge/resources/icons/skip-previous-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/sleep-symbolic.svg b/rayforge/resources/icons/sleep-symbolic.svg new file mode 100644 index 000000000..05f3fd924 --- /dev/null +++ b/rayforge/resources/icons/sleep-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/sleep.svg b/rayforge/resources/icons/sleep.svg deleted file mode 100644 index 6bea67d50..000000000 --- a/rayforge/resources/icons/sleep.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/status-check-symbolic.svg b/rayforge/resources/icons/status-check-symbolic.svg new file mode 100644 index 000000000..dc88e2ef5 --- /dev/null +++ b/rayforge/resources/icons/status-check-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/status-connected-symbolic.svg b/rayforge/resources/icons/status-connected-symbolic.svg new file mode 100644 index 000000000..d94463450 --- /dev/null +++ b/rayforge/resources/icons/status-connected-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/status-connecting-symbolic.svg b/rayforge/resources/icons/status-connecting-symbolic.svg new file mode 100644 index 000000000..9a12f9099 --- /dev/null +++ b/rayforge/resources/icons/status-connecting-symbolic.svg @@ -0,0 +1,44 @@ + + + + + + + diff --git a/rayforge/resources/icons/status-idle-symbolic.svg b/rayforge/resources/icons/status-idle-symbolic.svg new file mode 100644 index 000000000..d0baad98f --- /dev/null +++ b/rayforge/resources/icons/status-idle-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/status-offline-symbolic.svg b/rayforge/resources/icons/status-offline-symbolic.svg new file mode 100644 index 000000000..e84cf3b6e --- /dev/null +++ b/rayforge/resources/icons/status-offline-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/step-settings-symbolic.svg b/rayforge/resources/icons/step-settings-symbolic.svg new file mode 100644 index 000000000..4a412a705 --- /dev/null +++ b/rayforge/resources/icons/step-settings-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/stock-symbolic.svg b/rayforge/resources/icons/stock-symbolic.svg new file mode 100644 index 000000000..d81e1dfd4 --- /dev/null +++ b/rayforge/resources/icons/stock-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/stop-symbolic.svg b/rayforge/resources/icons/stop-symbolic.svg new file mode 100644 index 000000000..bffb5be65 --- /dev/null +++ b/rayforge/resources/icons/stop-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/stop.svg b/rayforge/resources/icons/stop.svg deleted file mode 100644 index e9d8cbccb..000000000 --- a/rayforge/resources/icons/stop.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/tabs-equidistant-symbolic.svg b/rayforge/resources/icons/tabs-equidistant-symbolic.svg new file mode 100644 index 000000000..f910aae40 --- /dev/null +++ b/rayforge/resources/icons/tabs-equidistant-symbolic.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/rayforge/resources/icons/tabs-visible-symbolic.svg b/rayforge/resources/icons/tabs-visible-symbolic.svg new file mode 100644 index 000000000..4e90ff221 --- /dev/null +++ b/rayforge/resources/icons/tabs-visible-symbolic.svg @@ -0,0 +1,20 @@ + + + + + + diff --git a/rayforge/resources/icons/terminal-symbolic.svg b/rayforge/resources/icons/terminal-symbolic.svg new file mode 100644 index 000000000..268a87c88 --- /dev/null +++ b/rayforge/resources/icons/terminal-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/rayforge/resources/icons/test-symbolic.svg b/rayforge/resources/icons/test-symbolic.svg new file mode 100644 index 000000000..0d1505778 --- /dev/null +++ b/rayforge/resources/icons/test-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/test.svg b/rayforge/resources/icons/test.svg deleted file mode 100644 index 30e1897e9..000000000 --- a/rayforge/resources/icons/test.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/timeline-symbolic.svg b/rayforge/resources/icons/timeline-symbolic.svg new file mode 100644 index 000000000..94a029770 --- /dev/null +++ b/rayforge/resources/icons/timeline-symbolic.svg @@ -0,0 +1,20 @@ + + + + + + diff --git a/rayforge/resources/icons/timer-symbolic.svg b/rayforge/resources/icons/timer-symbolic.svg new file mode 100644 index 000000000..74a58f429 --- /dev/null +++ b/rayforge/resources/icons/timer-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/tool-change-symbolic.svg b/rayforge/resources/icons/tool-change-symbolic.svg new file mode 100644 index 000000000..8d46ad7cf --- /dev/null +++ b/rayforge/resources/icons/tool-change-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/tool-change.svg b/rayforge/resources/icons/tool-change.svg deleted file mode 100644 index 41a1bfdfd..000000000 --- a/rayforge/resources/icons/tool-change.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/top-left-symbolic.svg b/rayforge/resources/icons/top-left-symbolic.svg new file mode 100644 index 000000000..b1b5bc873 --- /dev/null +++ b/rayforge/resources/icons/top-left-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/top-right-symbolic.svg b/rayforge/resources/icons/top-right-symbolic.svg new file mode 100644 index 000000000..17180b385 --- /dev/null +++ b/rayforge/resources/icons/top-right-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/travel-path-symbolic.svg b/rayforge/resources/icons/travel-path-symbolic.svg new file mode 100644 index 000000000..a3c3fd6ae --- /dev/null +++ b/rayforge/resources/icons/travel-path-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/ui-settings-symbolic.svg b/rayforge/resources/icons/ui-settings-symbolic.svg new file mode 100644 index 000000000..2d8d79155 --- /dev/null +++ b/rayforge/resources/icons/ui-settings-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/undo-symbolic.svg b/rayforge/resources/icons/undo-symbolic.svg new file mode 100644 index 000000000..90179332c --- /dev/null +++ b/rayforge/resources/icons/undo-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/unlocking-symbolic.svg b/rayforge/resources/icons/unlocking-symbolic.svg new file mode 100644 index 000000000..eb19482ff --- /dev/null +++ b/rayforge/resources/icons/unlocking-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/unlocking.svg b/rayforge/resources/icons/unlocking.svg deleted file mode 100644 index bba7f1de9..000000000 --- a/rayforge/resources/icons/unlocking.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/variable-symbolic.svg b/rayforge/resources/icons/variable-symbolic.svg new file mode 100644 index 000000000..6f488f2f4 --- /dev/null +++ b/rayforge/resources/icons/variable-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/visibility-off-symbolic.svg b/rayforge/resources/icons/visibility-off-symbolic.svg new file mode 100644 index 000000000..ebd1df508 --- /dev/null +++ b/rayforge/resources/icons/visibility-off-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/visibility-off.svg b/rayforge/resources/icons/visibility-off.svg deleted file mode 100644 index af27b8f74..000000000 --- a/rayforge/resources/icons/visibility-off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/visibility-on-symbolic.svg b/rayforge/resources/icons/visibility-on-symbolic.svg new file mode 100644 index 000000000..052b6accc --- /dev/null +++ b/rayforge/resources/icons/visibility-on-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/visibility-on.svg b/rayforge/resources/icons/visibility-on.svg deleted file mode 100644 index c77294185..000000000 --- a/rayforge/resources/icons/visibility-on.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/visibility_off.svg b/rayforge/resources/icons/visibility_off.svg deleted file mode 100644 index af27b8f74..000000000 --- a/rayforge/resources/icons/visibility_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/visibility_on.svg b/rayforge/resources/icons/visibility_on.svg deleted file mode 100644 index c77294185..000000000 --- a/rayforge/resources/icons/visibility_on.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/rayforge/resources/icons/warning-symbolic.svg b/rayforge/resources/icons/warning-symbolic.svg new file mode 100644 index 000000000..0d825f512 --- /dev/null +++ b/rayforge/resources/icons/warning-symbolic.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/rayforge/resources/icons/zero-here-symbolic.svg b/rayforge/resources/icons/zero-here-symbolic.svg new file mode 100644 index 000000000..ec069cedd --- /dev/null +++ b/rayforge/resources/icons/zero-here-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/zoom-fit-best-symbolic.svg b/rayforge/resources/icons/zoom-fit-best-symbolic.svg new file mode 100644 index 000000000..371c4d543 --- /dev/null +++ b/rayforge/resources/icons/zoom-fit-best-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/zoom-in-symbolic.svg b/rayforge/resources/icons/zoom-in-symbolic.svg new file mode 100644 index 000000000..806ab009c --- /dev/null +++ b/rayforge/resources/icons/zoom-in-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/icons/zoom-out-symbolic.svg b/rayforge/resources/icons/zoom-out-symbolic.svg new file mode 100644 index 000000000..6763621dd --- /dev/null +++ b/rayforge/resources/icons/zoom-out-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/rayforge/resources/models/2axis-rotary.FCStd b/rayforge/resources/models/2axis-rotary.FCStd new file mode 100644 index 000000000..7bff9ba5d Binary files /dev/null and b/rayforge/resources/models/2axis-rotary.FCStd differ diff --git a/rayforge/resources/models/2axis-rotary.glb b/rayforge/resources/models/2axis-rotary.glb new file mode 100644 index 000000000..540205f3a Binary files /dev/null and b/rayforge/resources/models/2axis-rotary.glb differ diff --git a/rayforge/resources/models/__init__.py b/rayforge/resources/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/resources/models/creality-roller.FCStd b/rayforge/resources/models/creality-roller.FCStd new file mode 100644 index 000000000..5b9e79863 Binary files /dev/null and b/rayforge/resources/models/creality-roller.FCStd differ diff --git a/rayforge/resources/models/creality-roller.glb b/rayforge/resources/models/creality-roller.glb new file mode 100644 index 000000000..c797a5dcf Binary files /dev/null and b/rayforge/resources/models/creality-roller.glb differ diff --git a/rayforge/resources/models/head-co2.FCStd b/rayforge/resources/models/head-co2.FCStd new file mode 100644 index 000000000..770099a5f Binary files /dev/null and b/rayforge/resources/models/head-co2.FCStd differ diff --git a/rayforge/resources/models/head-co2.glb b/rayforge/resources/models/head-co2.glb new file mode 100644 index 000000000..2569e76d8 Binary files /dev/null and b/rayforge/resources/models/head-co2.glb differ diff --git a/rayforge/resources/models/head-diode.FCStd b/rayforge/resources/models/head-diode.FCStd new file mode 100644 index 000000000..d33c43670 Binary files /dev/null and b/rayforge/resources/models/head-diode.FCStd differ diff --git a/rayforge/resources/models/head-diode.glb b/rayforge/resources/models/head-diode.glb new file mode 100644 index 000000000..8b01e42ba Binary files /dev/null and b/rayforge/resources/models/head-diode.glb differ diff --git a/rayforge/shared/README.md b/rayforge/shared/README.md new file mode 100644 index 000000000..6dd771a48 --- /dev/null +++ b/rayforge/shared/README.md @@ -0,0 +1,6 @@ +# Shared module + +This module is for code that is not specific to Rayforge. + +It contains components that could theoretically be copied to another projects +for re-use. diff --git a/rayforge/shared/__init__.py b/rayforge/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/shared/gcodeedit/__init__.py b/rayforge/shared/gcodeedit/__init__.py new file mode 100644 index 000000000..b1174cb93 --- /dev/null +++ b/rayforge/shared/gcodeedit/__init__.py @@ -0,0 +1,5 @@ +from .editor import GcodeEditor +from .highlighter import GcodeHighlighter +from .viewer import GcodeViewer + +__all__ = ["GcodeEditor", "GcodeHighlighter", "GcodeViewer"] diff --git a/rayforge/shared/gcodeedit/editor.py b/rayforge/shared/gcodeedit/editor.py new file mode 100644 index 000000000..d57edb395 --- /dev/null +++ b/rayforge/shared/gcodeedit/editor.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from blinker import Signal +from gi.repository import Gdk, GLib, Gtk + +from .highlighter import GcodeHighlighter + + +class GcodeEditor(Gtk.Box): + """ + A self-contained widget for displaying and editing G-code, featuring + syntax highlighting and a search bar (Ctrl+F). + """ + + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + self.set_can_focus(True) + self.line_activated = Signal() + + self.text_view = Gtk.TextView( + monospace=True, + wrap_mode=Gtk.WrapMode.NONE, + pixels_above_lines=2, + pixels_below_lines=2, + left_margin=6, + right_margin=6, + can_focus=True, + ) + + self.scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vexpand=True, + hexpand=True, + ) + self.scrolled_window.set_child(self.text_view) + + self.search_entry = Gtk.SearchEntry() + self.search_bar = Gtk.SearchBar(child=self.search_entry) + self.search_bar.set_key_capture_widget(self) + self.search_entry.connect("search-changed", self._on_search_changed) + self.search_entry.connect("stop-search", self._on_stop_search) + + ctrl = Gtk.EventControllerKey() + ctrl.connect("key-pressed", self._on_ctrl_key_pressed) + self.add_controller(ctrl) + + buffer = self.text_view.get_buffer() + tag_table = buffer.get_tag_table() + self.search_tag = Gtk.TextTag(name="search") + tag_table.add(self.search_tag) + self.highlight_tag = Gtk.TextTag(name="highlight") + tag_table.add(self.highlight_tag) + self.current_highlight_line = -1 + + style_context = self.get_style_context() + found, color = style_context.lookup_color("theme_selected_bg_color") + if found and color: + self.search_tag.set_property("background-rgba", color) + highlight_color = color.copy() + highlight_color.alpha = 0.3 + self.highlight_tag.set_property("background-rgba", highlight_color) + else: + self.search_tag.set_property("background", "#4A90D9") + fallback_rgba = Gdk.RGBA() + fallback_rgba.parse("#fce94f") + fallback_rgba.alpha = 0.3 + self.highlight_tag.set_property("background-rgba", fallback_rgba) + + self.append(self.search_bar) + self.append(self.scrolled_window) + + self.highlighter = GcodeHighlighter(self.text_view) + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + # Connect to cursor movement to detect line activation + buffer.connect("mark-set", self._on_cursor_move) + + def _on_cursor_move(self, buffer, location, mark): + """Fires the line-activated signal when the cursor moves.""" + if mark.get_name() == "insert": + line_number = location.get_line() + self.line_activated.send(self, line_number=line_number) + + def _on_search_changed(self, search_entry: Gtk.SearchEntry): + buffer = self.text_view.get_buffer() + text = search_entry.get_text() + + buffer.remove_tag( + self.search_tag, buffer.get_start_iter(), buffer.get_end_iter() + ) + + if not text: + return + + first_match = None + current_iter = buffer.get_start_iter() + while True: + try: + result = current_iter.forward_search( + text, Gtk.TextSearchFlags.CASE_INSENSITIVE, None + ) + + if result is None: + break + + start, end = result + if first_match is None: + first_match = start + buffer.apply_tag(self.search_tag, start, end) + current_iter = end + except GLib.Error: + break + + if first_match is not None: + mark = buffer.create_mark(None, first_match, False) + GLib.idle_add(self._scroll_to_mark, mark) + + def _scroll_to_mark(self, mark): + self.text_view.scroll_to_mark(mark, 0.0, True, 0.5, 0.5) + buf = self.text_view.get_buffer() + buf.delete_mark(mark) + + def _on_stop_search(self, search_entry): + self.search_bar.set_search_mode(False) + + def _on_ctrl_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_f and state & Gdk.ModifierType.CONTROL_MASK: + self.search_bar.set_search_mode(True) + self.search_entry.grab_focus() + return True + return False + + def _on_map(self, widget: Gtk.Widget): + """Starts the live highlighter when the widget is shown.""" + self.highlighter.start() + buffer = self.text_view.get_buffer() + self.highlighter.highlight( + buffer.get_start_iter(), buffer.get_end_iter() + ) + + def _on_unmap(self, widget: Gtk.Widget): + """Stops the live highlighter when the widget is hidden.""" + self.highlighter.stop() + + def get_text(self) -> str: + """Returns the full text content of the editor.""" + buffer = self.text_view.get_buffer() + start, end = buffer.get_start_iter(), buffer.get_end_iter() + return buffer.get_text(start, end, include_hidden_chars=True) + + def set_text(self, text: str): + """ + Sets the text content of the editor and triggers a full highlight. + """ + buffer = self.text_view.get_buffer() + buffer.set_text(text, -1) + self.highlighter.highlight( + buffer.get_start_iter(), buffer.get_end_iter() + ) + self.search_bar.set_search_mode(False) + + def highlight_line(self, line_number: int, use_align: bool = True): + buffer = self.text_view.get_buffer() + + # Remove the old highlight if it exists + if self.current_highlight_line != -1: + found, start_iter = buffer.get_iter_at_line( + self.current_highlight_line + ) + if found: + end_iter = start_iter.copy() + if not end_iter.ends_line(): + end_iter.forward_to_line_end() + buffer.remove_tag(self.highlight_tag, start_iter, end_iter) + + # Add the new highlight if valid + if line_number != -1: + found, start_iter = buffer.get_iter_at_line(line_number) + if found: + end_iter = start_iter.copy() + if not end_iter.ends_line(): + end_iter.forward_to_line_end() + buffer.apply_tag(self.highlight_tag, start_iter, end_iter) + # Scroll to the highlighted line with optional alignment + yalign = 0.5 if use_align else 0.0 + self.text_view.scroll_to_iter( + start_iter, 0.0, use_align, 0.5, yalign + ) + + self.current_highlight_line = line_number + + def clear_highlight(self): + self.highlight_line(-1) + + @property + def text(self) -> str: + """The text content of the editor.""" + return self.get_text() + + @text.setter + def text(self, value: str): + self.set_text(value) + + def insert_text_at_cursor(self, text: str): + """Inserts the given text at the current cursor position.""" + buffer = self.text_view.get_buffer() + buffer.insert_at_cursor(text, -1) diff --git a/rayforge/shared/gcodeedit/highlighter.py b/rayforge/shared/gcodeedit/highlighter.py new file mode 100644 index 000000000..16941324a --- /dev/null +++ b/rayforge/shared/gcodeedit/highlighter.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import logging + +from gi.repository import Gtk + +logger = logging.getLogger(__name__) + + +class GcodeHighlighter: + """ + Applies syntax highlighting to a Gtk.TextBuffer containing G-code. + + It uses simple, fast tokenization rather than a full regex parser, + and connects to the buffer's "changed" signal to provide live updates + as the user types. Colors are derived from the current GTK theme. + """ + + def __init__(self, text_view: Gtk.TextView): + self.text_view = text_view + self.buffer = self.text_view.get_buffer() + self._changed_handler_id: int | None = None + + tag_table = self.buffer.get_tag_table() + style_context = self.text_view.get_style_context() + + # Define and add tags for each token type using theme colors + self._create_tag( + tag_table, + "comment", + foreground=self._lookup_theme_color( + style_context, "dim_label_color", "#888A85" + ), + ) + self._create_tag( + tag_table, + "gcode", + foreground=self._lookup_theme_color( + style_context, "accent_color", "#729FCF" + ), + ) + self._create_tag( + tag_table, + "mcode", + foreground=self._lookup_theme_color( + style_context, "warning_color", "#F57900" + ), + ) + self._create_tag( + tag_table, + "coord", + foreground=self._lookup_theme_color( + style_context, "success_color", "#8AE234" + ), + ) + # Use info_color which is typically blue/cyan in Adwaita + self._create_tag( + tag_table, + "param", + foreground=self._lookup_theme_color( + style_context, "info_color", "#72D6D6" + ), + ) + + def _lookup_theme_color( + self, context: Gtk.StyleContext, name: str, fallback: str + ) -> str: + """Looks up a named color from the theme, returning a fallback.""" + found, color = context.lookup_color(name) + if found and color: + return color.to_string() + return fallback + + def _create_tag( + self, tag_table: Gtk.TextTagTable, name: str, **properties + ): + """Helper to create and add a Gtk.TextTag.""" + tag = Gtk.TextTag.new(name) + for prop, value in properties.items(): + tag.set_property(prop, value) + tag_table.add(tag) + + def start(self): + """Connects to buffer signals to enable live highlighting.""" + if self._changed_handler_id is None: + self._changed_handler_id = self.buffer.connect( + "changed", self._on_buffer_changed + ) + + def stop(self): + """Disconnects from buffer signals to disable live highlighting.""" + if self._changed_handler_id is not None: + self.buffer.disconnect(self._changed_handler_id) + self._changed_handler_id = None + + def highlight( + self, start_iter: Gtk.TextIter, end_iter: Gtk.TextIter + ) -> None: + """ + Highlights the specified range in the buffer. + + Args: + start_iter: The starting iterator of the range to highlight. + end_iter: The ending iterator of the range to highlight. + """ + if self._changed_handler_id is None: + return + + # Prevent signals from firing during the update + self.buffer.handler_block(self._changed_handler_id) + + # Remove all existing tags from the range + self.buffer.remove_all_tags(start_iter, end_iter) + + current_iter = start_iter.copy() + while current_iter.compare(end_iter) < 0: + line_end_iter = current_iter.copy() + if not line_end_iter.ends_line(): + line_end_iter.forward_to_line_end() + + line_text = self.buffer.get_text(current_iter, line_end_iter, True) + self._highlight_line(current_iter, line_text) + + if not current_iter.forward_line(): + break + + # Re-enable signals + self.buffer.handler_unblock(self._changed_handler_id) + + def _highlight_line(self, line_start_iter: Gtk.TextIter, line_text: str): + """Applies tags to a single line of text.""" + # 1. Handle comments first, as they take precedence + comment_char = ";" + if "(" in line_text: + comment_char = "(" + + comment_start_idx = line_text.find(comment_char) + + if comment_start_idx != -1: + # Get the part of the line before the comment + code_text = line_text[:comment_start_idx] + + # Tag the comment + comment_start_iter = line_start_iter.copy() + comment_start_iter.forward_chars(comment_start_idx) + comment_end_iter = comment_start_iter.copy() + comment_end_iter.forward_chars(len(line_text) - comment_start_idx) + self.buffer.apply_tag_by_name( + "comment", comment_start_iter, comment_end_iter + ) + else: + code_text = line_text + + # 2. Tokenize and highlight the rest of the line + offset = 0 + for word in code_text.split(): + word_start_idx = code_text.find(word, offset) + if word_start_idx == -1: + continue + + word_len = len(word) + tag_name = None + + if not word: + offset = word_start_idx + 1 + continue + + first_char = word[0].upper() + if first_char == "G": + tag_name = "gcode" + elif first_char == "M": + tag_name = "mcode" + elif first_char in "XYZIJKABCUVW": + tag_name = "coord" + elif first_char in "FSPTH": + tag_name = "param" + + if tag_name: + word_start_iter = line_start_iter.copy() + word_start_iter.forward_chars(word_start_idx) + word_end_iter = word_start_iter.copy() + word_end_iter.forward_chars(word_len) + self.buffer.apply_tag_by_name( + tag_name, word_start_iter, word_end_iter + ) + + offset = word_start_idx + word_len + + def _on_buffer_changed(self, buffer: Gtk.TextBuffer): + """ + Called when the buffer content changes. To keep things simple and + performant enough, we just re-highlight the entire buffer. + """ + start_iter = buffer.get_start_iter() + end_iter = buffer.get_end_iter() + self.highlight(start_iter, end_iter) diff --git a/rayforge/shared/gcodeedit/viewer.py b/rayforge/shared/gcodeedit/viewer.py new file mode 100644 index 000000000..150224e3a --- /dev/null +++ b/rayforge/shared/gcodeedit/viewer.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from blinker import Signal +from gi.repository import Gtk, Pango + +from ...shared.util.size import format_byte_size +from ...ui_gtk.shared.gtk import apply_css +from .editor import GcodeEditor + +if TYPE_CHECKING: + from ...pipeline.encoder import MachineCodeOpMap + +css = """ +.gcode-viewer { + border-radius: 3px; +} +.gcode-status-label { + background-color: alpha(@window_bg_color, 0.8); + border-radius: 3px; + padding: 2px 6px; +} +""" + + +class GcodeViewer(Gtk.Box): + """ + A specialized, read-only widget for displaying G-code, intended for use + as a preview panel. + + It uses a GcodeEditor internally but configures it for a better viewing + experience (non-editable, word wrapping). + """ + + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + apply_css(css) + self.add_css_class("gcode-viewer") + + self.set_margin_top(9) + + self.op_activated = Signal() + self.line_activated = Signal() + self.editor = GcodeEditor() + self.op_map: MachineCodeOpMap | None = None + self._line_count: int = 0 + self._size_bytes: int = 0 + + self.editor.text_view.set_editable(False) + self.editor.text_view.set_cursor_visible(True) + self.editor.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.editor.line_activated.connect(self._on_line_activated) + + self._create_overlay() + + def _create_overlay(self): + self._overlay = Gtk.Overlay() + self._overlay.set_child(self.editor) + + self.status_label = Gtk.Label() + self.status_label.add_css_class("gcode-status-label") + self.status_label.set_halign(Gtk.Align.END) + self.status_label.set_valign(Gtk.Align.END) + self.status_label.set_margin_bottom(3) + self.status_label.set_margin_end(3) + self.status_label.set_ellipsize(Pango.EllipsizeMode.END) + self._overlay.add_overlay(self.status_label) + + self.append(self._overlay) + self._update_status_bar() + + def _update_status_bar(self): + if self._line_count == 0: + self.status_label.set_text("") + return + + size_str = format_byte_size(self._size_bytes) + self.status_label.set_text( + _("{line_count:,} lines · {size}").format( + line_count=self._line_count, size=size_str + ) + ) + + def _on_line_activated(self, sender, *, line_number: int): + self.line_activated.send(self, line_number=line_number) + op_index = ( + self.op_map.op_for_line(line_number) if self.op_map else None + ) + if op_index is not None: + self.op_activated.send(self, op_index=op_index) + + def set_gcode(self, gcode: str): + """ + Sets the G-code content to be displayed in the previewer. + + Args: + gcode: The G-code to display, as a single string. + """ + if gcode: + self._line_count = gcode.count("\n") + 1 + self._size_bytes = len(gcode.encode("utf-8")) + else: + self._line_count = 0 + self._size_bytes = 0 + + if self._line_count > 20000: + lines = gcode.split("\n", 20000) + truncated = "\n".join(lines[:20000]) + truncated += "\n\n" + _( + "— Truncated (showing first 20,000 of {line_count:,} lines) —" + ).format(line_count=self._line_count) + self.editor.set_text(truncated) + else: + self.editor.set_text(gcode) + self._update_status_bar() + + def clear(self): + """Clears the content of the previewer.""" + self._line_count = 0 + self._size_bytes = 0 + self.editor.set_text("") + self.op_map = None + self.clear_highlight() + self._update_status_bar() + + def set_op_map(self, op_map: MachineCodeOpMap): + self.op_map = op_map + + def highlight_line(self, line_number: int, use_align: bool = True): + """Highlights a specific line number in the editor.""" + self.editor.highlight_line(line_number, use_align) + + def highlight_op(self, op_index: int): + if not self.op_map: + self.clear_highlight() + return + + if op_index >= self.op_map.op_count: + self.clear_highlight() + return + + start_line, line_count = self.op_map.span_for_op(op_index) + if line_count: + # Highlight the first line associated with this op + self.editor.highlight_line(start_line) + else: + # Op produced no g-code, so clear any existing highlight + self.clear_highlight() + + def clear_highlight(self): + self.editor.clear_highlight() diff --git a/rayforge/shared/oauth/__init__.py b/rayforge/shared/oauth/__init__.py new file mode 100644 index 000000000..83b207f84 --- /dev/null +++ b/rayforge/shared/oauth/__init__.py @@ -0,0 +1,7 @@ +from .flow import OAuthFlow, OAuthFlowConfig, OAuthResult + +__all__ = [ + "OAuthFlow", + "OAuthFlowConfig", + "OAuthResult", +] diff --git a/rayforge/shared/oauth/flow.py b/rayforge/shared/oauth/flow.py new file mode 100644 index 000000000..d3360d114 --- /dev/null +++ b/rayforge/shared/oauth/flow.py @@ -0,0 +1,243 @@ +import json +import logging +import socket as _socket +import urllib.parse +import urllib.request +import webbrowser +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread +from typing import Any +from urllib.parse import parse_qs, urlparse + +logger = logging.getLogger(__name__) + + +@dataclass +class OAuthFlowConfig: + authorize_url: str + token_url: str + client_id: str + client_secret: str | None = None + scopes: list[str] = field(default_factory=list) + redirect_port: int = 8765 + use_pkce: bool = False + + +@dataclass +class OAuthResult: + access_token: str + refresh_token: str | None = None + expires_at: datetime | None = None + scope: str | None = None + raw_response: dict[str, Any] = field(default_factory=dict) + + +class _OAuthCallbackHandler(BaseHTTPRequestHandler): + def __init__(self, callback, *args, **kwargs): + self._callback = callback + super().__init__(*args, **kwargs) + + def do_GET(self): + if not self.path.startswith("/callback"): + self.send_response(404) + self.end_headers() + return + + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + code = params.get("code", [None])[0] + error = params.get("error", [None])[0] + + if error: + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"" + b"

Authorization Failed

" + b"

You can close this window.

" + b"" + ) + self._callback(None, error) + elif code: + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"" + b"

Authorization Successful

" + b"

You can close this window and return to the app." + b"

" + ) + self._callback(code, None) + else: + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Invalid Request

" + ) + + +class OAuthFlow: + """ + A reusable OAuth 2.0 Authorization Code flow. + + Spawns a localhost HTTP server to capture the redirect callback, + opens the browser for user authorization, and exchanges the code + for tokens. All callbacks are marshalled via GLib.idle_add so + GTK code can safely update the UI. + """ + + def __init__(self, config: OAuthFlowConfig): + self._config = config + + def get_authorize_url(self) -> str: + redirect_uri = ( + f"http://127.0.0.1:{self._config.redirect_port}/callback" + ) + params: dict[str, str] = { + "response_type": "code", + "client_id": self._config.client_id, + "redirect_uri": redirect_uri, + } + if self._config.scopes: + params["scope"] = " ".join(self._config.scopes) + return f"{self._config.authorize_url}?{urllib.parse.urlencode(params)}" + + def start( + self, + on_complete: Callable[[OAuthResult], None], + on_error: Callable[[Exception], None], + ) -> None: + """ + Start the OAuth flow. Opens the browser and listens for the + callback on localhost. Calls on_complete or on_error when done. + """ + callback_received: dict[str, str | None] = { + "code": None, + "error": None, + } + + def callback(code, error): + callback_received["code"] = code + callback_received["error"] = error + + def handler_factory(*args, **kwargs): + return _OAuthCallbackHandler(callback, *args, **kwargs) + + probe = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + try: + probe.bind(("127.0.0.1", self._config.redirect_port)) + except OSError as e: + on_error(e) + return + finally: + probe.close() + + try: + server = HTTPServer( + ("127.0.0.1", self._config.redirect_port), + handler_factory, + ) + except OSError as e: + on_error(e) + return + + def run_server(): + try: + server.handle_request() + except (OSError, TimeoutError, ValueError) as e: + on_error(e) + return + + if callback_received["code"]: + try: + result = self._exchange_code(callback_received["code"]) + on_complete(result) + except (OSError, TimeoutError, ValueError) as e: + on_error(e) + else: + err = callback_received["error"] or "Unknown error" + on_error(RuntimeError(err)) + + thread = Thread(target=run_server, daemon=True) + thread.start() + + url = self.get_authorize_url() + webbrowser.open(url) + + def _exchange_code(self, code: str) -> OAuthResult: + redirect_uri = ( + f"http://127.0.0.1:{self._config.redirect_port}/callback" + ) + data_dict: dict[str, Any] = { + "code": code, + "client_id": self._config.client_id, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + } + if self._config.client_secret: + data_dict["client_secret"] = self._config.client_secret + data = urllib.parse.urlencode(data_dict).encode() + + req = urllib.request.Request( + self._config.token_url, data=data, method="POST" + ) + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + + access_token = result.get("access_token", "") + refresh_token = result.get("refresh_token") + expires_in = result.get("expires_in") + expires_at = None + if expires_in: + expires_at = datetime.fromtimestamp( + datetime.now(tz=timezone.utc).timestamp() + int(expires_in), + tz=timezone.utc, + ) + + return OAuthResult( + access_token=access_token, + refresh_token=refresh_token, + expires_at=expires_at, + scope=result.get("scope"), + raw_response=result, + ) + + def refresh(self, refresh_token: str) -> OAuthResult: + data_dict: dict[str, Any] = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self._config.client_id, + } + if self._config.client_secret: + data_dict["client_secret"] = self._config.client_secret + data = urllib.parse.urlencode(data_dict).encode() + + req = urllib.request.Request( + self._config.token_url, data=data, method="POST" + ) + with urllib.request.urlopen(req, timeout=10) as response: + result = json.loads(response.read().decode()) + + access_token = result.get("access_token", "") + new_refresh = result.get("refresh_token", refresh_token) + expires_in = result.get("expires_in") + expires_at = None + if expires_in: + expires_at = datetime.fromtimestamp( + datetime.now(tz=timezone.utc).timestamp() + int(expires_in), + tz=timezone.utc, + ) + + return OAuthResult( + access_token=access_token, + refresh_token=new_refresh, + expires_at=expires_at, + scope=result.get("scope"), + raw_response=result, + ) diff --git a/rayforge/shared/tasker/__init__.py b/rayforge/shared/tasker/__init__.py new file mode 100644 index 000000000..3ee83718f --- /dev/null +++ b/rayforge/shared/tasker/__init__.py @@ -0,0 +1,22 @@ +""" +Tasker package for managing tasks, contexts, and execution. +""" + +from __future__ import annotations + +from .manager import TaskManager, TaskManagerProxy +from .task import Task + +# This is the global, thread-safe, and process-safe singleton. +# It's a lightweight proxy that will create the real TaskManager on +# first use. +# We hint it as TaskManager so type checkers and IDEs provide +# correct autocompletion. +task_mgr: TaskManager = TaskManagerProxy() # type: ignore + + +__all__ = [ + "Task", + "TaskManager", + "task_mgr", +] diff --git a/rayforge/shared/tasker/context.py b/rayforge/shared/tasker/context.py new file mode 100644 index 000000000..21e826721 --- /dev/null +++ b/rayforge/shared/tasker/context.py @@ -0,0 +1,183 @@ +""" +ExecutionContext for managing task execution with throttled progress. + +This module provides ExecutionContext, which extends ThrottledProgressContext +to add threading, debouncing, and scheduler support for progress reporting +in the stage layer. It manages task execution with cancellation checking +and progress updates that are throttled to prevent UI flooding. +""" + +import logging +import threading +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Optional + +from .progress import ThrottledProgressContext + +if TYPE_CHECKING: + from .task import Task + +logger = logging.getLogger(__name__) + + +class ExecutionContext(ThrottledProgressContext): + """Context for task execution with throttled progress reporting. + + Extends ThrottledProgressContext to add threading, debouncing, + and scheduler support. Progress updates are throttled to prevent + UI flooding. Supports hierarchical sub-contexting for nested + task execution. + """ + + def __init__( + self, + update_callback: Callable[[float | None, str | None], None] + | None = None, + check_cancelled: Callable[[], bool] | None = None, + scheduler: Callable[..., Any] | None = None, + debounce_interval_ms: int = 100, + # Internal args for sub-contexting + _parent_context: Optional["ExecutionContext"] = None, + _base_progress: float = 0.0, + _progress_range: float = 1.0, + _total: float = 1.0, + ): + super().__init__(_base_progress, _progress_range, _total) + self._parent_context = _parent_context + self.task: Task | None = None + + if self._parent_context: + # This is a sub-context. It doesn't own resources. + self._root_context = self._parent_context._get_root() + self._check_cancelled = ( + check_cancelled or self._root_context.is_cancelled + ) + # These are only used by the root context + self._update_callback = None + self._scheduler = None + self._debounce_interval_sec = 0 + self._update_timer = None + self._pending_progress = None + self._pending_message = None + self._lock = None + else: + # This is a root context. Initialize resources. + self._root_context = self + self._update_callback = update_callback + self._scheduler = scheduler + self._check_cancelled = check_cancelled or (lambda: False) + self._debounce_interval_sec = debounce_interval_ms / 1000.0 + self._update_timer: threading.Timer | None = None + self._pending_progress: float | None = None + self._pending_message: str | None = None + self._lock = threading.Lock() + + def _get_root(self) -> "ExecutionContext": + """Returns the root context in the chain.""" + return self._root_context + + def _fire_update(self): + """Called by the timer to schedule a UI update.""" + assert self._lock is not None, ( + "_fire_update() called on a non-root context" + ) + with self._lock: + if self._update_timer is None: + return + progress = self._pending_progress + message = self._pending_message + self._pending_progress = None + self._pending_message = None + self._update_timer = None + + if ( + self._scheduler + and self._update_callback + and not self.is_cancelled() + ): + self._scheduler(self._update_callback, progress, message) + + def _schedule_update(self): + """(Re)schedules the update timer for the root context.""" + assert self._lock is not None, ( + "_schedule_update() called on a non-root context" + ) + if self._update_timer is None: + self._update_timer = threading.Timer( + self._debounce_interval_sec, self._fire_update + ) + self._update_timer.start() + + def _update_root_state( + self, progress: float | None = None, message: str | None = None + ): + """ + Sets pending state on the root and schedules an update. + """ + assert self._lock is not None, ( + "_update_root_state() called on a non-root context" + ) + with self._lock: + if progress is not None: + self._pending_progress = progress + if message is not None: + self._pending_message = message + self._schedule_update() + + def _report_normalized_progress(self, progress: float): + """ + The core logic for handling 0.0-1.0 progress values. + This calculates the final global progress and reports it to the root. + """ + progress = max(0.0, min(1.0, progress)) + global_progress = self._base + (progress * self._range) + self._get_root()._update_root_state(progress=global_progress) + + def is_cancelled(self) -> bool: + """Checks if the operation has been cancelled.""" + return self._check_cancelled() + + def set_message(self, message: str): + """Sets a descriptive message.""" + self._get_root()._update_root_state(message=message) + + def flush(self): + """Immediately sends the last known values to the UI.""" + root = self._get_root() + if self is not root: + root.flush() + return + + assert self._lock is not None, "flush() called on a non-root context" + with self._lock: + if self._update_timer: + self._update_timer.cancel() + self._update_timer = None + progress = self._pending_progress + message = self._pending_message + self._pending_progress = None + self._pending_message = None + + if ( + self._scheduler + and self._update_callback + and (progress is not None or message is not None) + ): + self._scheduler(self._update_callback, progress, message) + + def _create_sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + ) -> "ExecutionContext": + """ + Creates a sub-context that reports progress within a specified + range of this context's progress. + """ + return ExecutionContext( + _parent_context=self, + _base_progress=self._base + (base_progress * self._range), + _progress_range=progress_range * self._range, + _total=total, + ) diff --git a/rayforge/shared/tasker/manager.py b/rayforge/shared/tasker/manager.py new file mode 100644 index 000000000..adccd352b --- /dev/null +++ b/rayforge/shared/tasker/manager.py @@ -0,0 +1,1047 @@ +""" +TaskManager module for managing task execution. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from collections.abc import Callable, Coroutine, Iterator +from multiprocessing import get_context +from multiprocessing.managers import DictProxy +from typing import ( + Any, + Protocol, + runtime_checkable, +) + +from blinker import Signal + +from .context import ExecutionContext +from .pool import WorkerPoolManager +from .task import Task + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class CancelHandle(Protocol): + def cancel(self) -> None: ... + + +class TaskManager: + def __init__( + self, + main_thread_scheduler: Callable, + worker_initializer: Callable[..., None] | None = None, + worker_initargs: tuple = (), + shared_state: DictProxy[str, Any] | None = None, + ) -> None: + logger.debug("Initializing TaskManager") + self._tasks: dict[Any, Task] = {} + # A holding area for recently replaced/cancelled tasks to + # catch in-flight messages. + self._zombie_tasks: dict[int, Task] = {} + # Invisible tasks that don't appear in UI but still need callbacks + self._invisible_tasks: dict[int, Task] = {} + self._progress_map: dict[ + Any, float + ] = {} # Stores progress of all current tasks + + self._lock = threading.RLock() + self.tasks_updated: Signal = Signal() + self.loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() + self._thread: threading.Thread = threading.Thread( + target=self._run_event_loop, args=(self.loop,), daemon=True + ) + self._main_thread_scheduler = main_thread_scheduler + self._thread.start() + + # Multiprocessing pool is created lazily on first run_process call. + # This avoids spawning worker processes at app startup. + self._manager: Any = None + self._shared_state: Any = None + self._pool: Any = None + self._pool_kwargs = { + "initializer": worker_initializer, + "initargs": worker_initargs, + } + + def _ensure_pool(self) -> None: + """ + Lazily create the multiprocessing pool and its supporting + Manager and shared state on first use. + """ + if self._pool is not None: + return + logger.debug("Lazily creating multiprocessing pool.") + self._manager = get_context("spawn").Manager() + self._shared_state = self._manager.dict() + self._pool_kwargs["shared_state"] = self._shared_state + self._pool = WorkerPoolManager(**self._pool_kwargs) + self._connect_pool_signals() + + def restart_worker_pool(self) -> None: + """ + Shuts down the current worker pool and starts a new one. + This is necessary to apply changes like addon installation or updates + to worker processes. The shared_state is preserved. + No-op if the pool has never been started (lazy init). + """ + if self._pool is None: + logger.info("Worker pool not started yet; restart is a no-op.") + return + logger.info("Restarting worker pool to apply configuration changes...") + with self._lock: + self._pool.shutdown() + self._pool = WorkerPoolManager(**self._pool_kwargs) + self._connect_pool_signals() + logger.info("Worker pool restarted.") + + def _connect_pool_signals(self): + """Connects to signals emitted by the WorkerPoolManager.""" + self._pool.task_completed.connect(self._on_pool_task_completed) + self._pool.task_failed.connect(self._on_pool_task_failed) + self._pool.task_progress_updated.connect(self._on_pool_task_progress) + self._pool.task_message_updated.connect(self._on_pool_task_message) + self._pool.task_event_received.connect(self._on_pool_task_event) + self._pool.worker_died.connect(self._on_pool_worker_died) + + def __len__(self) -> int: + """Return the number of active tasks.""" + with self._lock: + return len(self._tasks) + + def __iter__(self) -> Iterator[Task]: + """Return an iterator over the active tasks.""" + with self._lock: + # Return an iterator over a copy of the tasks to prevent + # "RuntimeError: dictionary changed size during iteration" + # if tasks are added/removed while iterating. + return iter(list(self._tasks.values())) + + def has_tasks(self) -> bool: + """Return True if there are any active tasks, False otherwise.""" + with self._lock: + return bool(self._tasks) + + def _run_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Run the asyncio event loop in a background thread.""" + asyncio.set_event_loop(loop) + try: + loop.run_forever() + finally: + # Clean up when the thread is told to stop (during TaskManager + # shutdown) + try: + # 1. Cancel any lingering tasks to prevent "Task was destroyed + # but it is pending" + pending = asyncio.all_tasks(loop) + for t in pending: + t.cancel() + + # 2. Run the loop briefly so cancelled tasks can finish + # their __step and actually complete. Without this, + # cancel() only sets a flag and the task remains in + # "cancelling" state until GC triggers the warning. + if pending: + logger.debug( + f"Waiting for {len(pending)} pending tasks to " + f"complete after cancellation during event loop " + f"shutdown..." + ) + loop.run_until_complete( + asyncio.wait( + pending, + timeout=2.0, + return_when=asyncio.ALL_COMPLETED, + ) + ) + + # 3. Explicitly close the loop to free the self-pipe file + # descriptors. + if not loop.is_closed(): + loop.close() + except Exception as e: + logger.error(f"Error during event loop cleanup: {e}") + raise + + def _add_or_replace_task_unsafe( + self, + task: Task, + ): + """ + Atomically adds a task, replacing any existing task with the same key. + MUST be called with the lock held. + """ + # Check for and handle an existing task with the same key. + old_task = self._tasks.get(task.key) + if old_task: + logger.debug( + f"TaskManager: Replacing existing task key '{task.key}'." + ) + self.cancel_task(old_task.key) + + # Add the new task. + logger.debug(f"TaskManager: Adding new task key '{task.key}'.") + self._tasks[task.key] = task + self._progress_map[task.key] = 0.0 + + task.status_changed.connect(self._on_task_updated) + self._emit_tasks_updated_unsafe() + + def add_task( + self, task: Task, when_done: Callable[[Task], None] | None = None + ) -> None: + """Add an asyncio-based task to the manager.""" + # For asyncio tasks, the when_done is handled by the _run_task wrapper. + # We store it on the task object for consistency. + if when_done: + task.when_done_callback = when_done + with self._lock: + self._add_or_replace_task_unsafe(task) + + # Coroutines use the asyncio event loop + asyncio.run_coroutine_threadsafe( + self._run_task(task, task.when_done_callback), self.loop + ) + + def add_coroutine( + self, + coro: Callable[..., Coroutine[Any, Any, Any]], + *args: Any, + key: Any | None = None, + when_done: Callable[[Task], None] | None = None, + **kwargs: Any, + ) -> None: + """ + Add a raw coroutine to the manager. + The coroutine will be wrapped in a Task object internally. + It is expected that the coroutine accepts an ExecutionContext + as its first argument, followed by any other *args and **kwargs. + """ + task = Task(coro, *args, key=key, when_done=when_done, **kwargs) + self.add_task(task) + + def schedule_on_main_thread( + self, callback: Callable[..., Any], *args: Any, **kwargs: Any + ) -> None: + """ + Schedules a callable to be executed on the main thread's event loop. + + This is the designated way for background threads or task callbacks to + safely interact with the main thread (e.g., for UI updates). + """ + self._main_thread_scheduler(callback, *args, **kwargs) + + async def run_on_main_thread( + self, func: Callable[..., Any], *args: Any, **kwargs: Any + ) -> Any: + """ + Runs a synchronous callable on the main thread and awaits completion. + + This is for background coroutines that must perform main-thread-only + work (e.g., GTK widget updates) and need to wait for it to finish + before continuing. Returns the callable's return value and re-raises + any exception it raises. + + Raises: + RuntimeError: If called outside a running event loop. + Exception: If *func* raised while running on the main thread. + """ + loop = asyncio.get_running_loop() + done = loop.create_future() + + def _invoke_on_main_thread(): + try: + result = func(*args, **kwargs) + loop.call_soon_threadsafe(done.set_result, result) + except Exception as e: # noqa: BLE001 - boundary for main thread + loop.call_soon_threadsafe(done.set_exception, e) + + self._main_thread_scheduler(_invoke_on_main_thread) + return await done + + def schedule_delayed_on_main_thread( + self, + delay_ms: int, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ): + """ + Schedules a callback to run on the main thread after a delay. + + Uses the TaskManager's asyncio event loop for timing, then schedules + execution on the main thread via the injected scheduler. + + Args: + delay_ms: Delay in milliseconds before executing the callback. + callback: The callable to execute. + *args: Positional arguments to pass to the callback. + **kwargs: Keyword arguments to pass to the callback. + + Returns: + A handle with a cancel() method to prevent the callback from + being executed. + """ + loop = self.loop + cancelled = False + timer_handle: list[asyncio.TimerHandle | None] = [None] + + def _execute(): + if not cancelled: + self._main_thread_scheduler(callback, *args, **kwargs) + + def _schedule(): + timer_handle[0] = loop.call_later(delay_ms / 1000.0, _execute) + + loop.call_soon_threadsafe(_schedule) + + class _CancelHandle: + def cancel(self): + nonlocal cancelled + cancelled = True + h = timer_handle[0] + if h is not None: + loop.call_soon_threadsafe(h.cancel) + + return _CancelHandle() + + async def run_in_executor( + self, func: Callable[..., Any], *args: Any + ) -> Any: + """ + Runs a synchronous function in a separate thread using asyncio's + default executor and returns the result. This is useful for offloading + blocking, CPU-bound work from an async coroutine. + """ + # The first argument 'None' tells asyncio to use its default + # ThreadPoolExecutor. + return await self.loop.run_in_executor(None, func, *args) + + def run_thread( + self, + func: Callable[..., Any], + *args: Any, + key: Any | None = None, + when_done: Callable[[Task], None] | None = None, + **kwargs: Any, + ) -> Task: + """ + Creates, configures, and schedules a task to run a synchronous function + in a background thread. + """ + + async def thread_wrapper( + context: ExecutionContext, *args: Any, **kwargs: Any + ) -> Any: + # This is running inside the TaskManager's event loop thread. + # We use run_in_executor to move the blocking call to a *different* + # thread (from the default thread pool executor), ensuring the + # TaskManager's own event loop is not blocked. + result = await self.run_in_executor(func, *args, **kwargs) + return result + + # We create a task with the async wrapper. + # The original sync function's args/kwargs are passed through. + task = Task( + thread_wrapper, *args, key=key, when_done=when_done, **kwargs + ) + self.add_task(task) + return task + + def run_process( + self, + func: Callable[..., Any], + *args: Any, + key: Any | None = None, + when_done: Callable[[Task], None] | None = None, + when_event: Callable[[Task, str, dict], None] | None = None, + visible: bool = True, + **kwargs: Any, + ) -> Task: + """ + Creates, configures, and schedules a task to run in the worker pool. + + Args: + func: The function to execute in the worker process. + *args: Positional arguments for the function. + key: Optional unique key for the task. + when_done: Callback invoked when task completes. + when_event: Callback for custom events from the worker. + visible: If False, the task is not tracked in the UI. Useful + for child tasks of a parent operation that handles + its own progress reporting. + **kwargs: Additional keyword arguments for the function. + + Returns: + The Task object. + """ + logger.debug(f"Creating task for worker pool {key}") + + # Define a no-op async placeholder. The Task object requires a + # coroutine, but we won't be running it via asyncio. + async def _noop_coro(*_args, **_kwargs): + pass + + # We pass the *real* function and args to the Task object just for + # bookkeeping, even though the Task object itself won't execute them. + task = Task( + _noop_coro, + func, + *args, + key=key, + when_done=when_done, + task_type="process", + **kwargs, + ) + task._visible = visible + + if when_event: + task.event_received.connect(when_event, weak=False) + + with self._lock: + if visible: + self._add_or_replace_task_unsafe(task) + else: + self._invisible_tasks[task.id] = task + + # Manually set status to running and notify + task._status = "running" + if visible: + task._emit_status_changed() + + # Submit the actual work to the pool (creates it lazily if needed) + self._ensure_pool() + self._pool.submit(task.key, task.id, func, *args, **kwargs) + + return task + + def cancel_task(self, key: Any) -> None: + """ + Cancels a running task by its key. This is the authoritative method + for initiating a cancellation. + """ + callback_to_invoke = None + task_to_callback = None + + with self._lock: + task = self._tasks.get(key) + if not task or task.is_final(): + return + + logger.debug(f"TaskManager: Cancelling task with key '{key}'.") + + # Set the internal cancelled flag on the Task object. + # For asyncio tasks, this will also cancel the underlying future. + task.cancel() + + # For pooled tasks, we just notify the pool. + if task.task_type == "process": + self._pool.cancel(key, task.id) + if task.get_status() != "canceled": + task._status = "canceled" + if task._visible: + task._emit_status_changed() + + # Move the task to the zombie dictionary to await final + # message. + del self._tasks[key] + self._zombie_tasks[task.id] = task + if task._visible: + self._emit_tasks_updated_unsafe() + + # Immediately invoke the when_done callback for cancelled + # pooled tasks. This ensures contexts are updated without + # waiting for the worker to finish, which is critical on + # Windows where IPC is slower and can cause a backlog. + # We must call the callback OUTSIDE the lock to avoid + # deadlocks if the callback tries to acquire the lock. + callback_to_invoke = task.when_done_callback + if callback_to_invoke: + task.when_done_callback = None + task_to_callback = task + + # Call the callback outside the lock to avoid deadlocks. + if callback_to_invoke and task_to_callback: + logger.debug( + f"Invoking when_done callback for cancelled " + f"pooled task '{key}' (id: {task_to_callback.id})." + ) + try: + callback_to_invoke(task_to_callback) + except Exception as e: # noqa: BLE001 - user callback boundary + logger.debug( + f"when_done callback for cancelled task '{key}' " + f"raised {type(e).__name__}: {e}. This may occur " + f"during shutdown when resources are being torn down." + ) + + def cancel_task_by_id(self, task_id: int) -> None: + """ + Cancels a running task by its ID. Works for both visible and + invisible tasks. + """ + with self._lock: + # Check invisible tasks first + task = self._invisible_tasks.get(task_id) + if task: + logger.debug( + f"TaskManager: Cancelling invisible task " + f"'{task.key}' (id: {task_id})." + ) + task.cancel() + self._pool.cancel(task.key, task.id) + task._status = "canceled" + del self._invisible_tasks[task_id] + self._zombie_tasks[task_id] = task + + callback = task.when_done_callback + if callback: + task.when_done_callback = None + try: + callback(task) + except Exception as e: # noqa: BLE001 - callback boundary + logger.debug( + f"when_done callback for cancelled task " + f"'{task.key}' raised {type(e).__name__}: {e}" + ) + + def get_task(self, key: Any) -> Task | None: + """Retrieves a task by its key.""" + with self._lock: + return self._tasks.get(key) + + async def _run_task( + self, task: Task, when_done: Callable[[Task], None] | None + ) -> None: + """Run an asyncio task and clean up when done.""" + context = ExecutionContext( + update_callback=task.update, + check_cancelled=task.is_cancelled, + scheduler=self.schedule_on_main_thread, + ) + context.task = task + try: + await task.run(context) + except Exception: + # This is the master error handler for all background tasks. + logger.exception( + f"Unhandled exception in managed task '{task.key}'" + ) + finally: + context.flush() + self._cleanup_task(task) + if when_done: + self._main_thread_scheduler(when_done, task) + + # === Worker Pool Signal Handlers (runs on listener thread) === + + def _on_pool_task_completed(self, sender, key, task_id, result): + self._main_thread_scheduler( + self._finalize_pooled_task, + key, + task_id, + "completed", + result=result, + ) + + def _on_pool_task_failed(self, sender, key, task_id, error): + self._main_thread_scheduler( + self._finalize_pooled_task, key, task_id, "failed", error=error + ) + + def _on_pool_task_progress(self, sender, key, task_id, progress): + self._main_thread_scheduler( + self._update_pooled_task, key, task_id, progress=progress + ) + + def _on_pool_task_message(self, sender, key, task_id, message): + self._main_thread_scheduler( + self._update_pooled_task, key, task_id, message=message + ) + + def _on_pool_task_event( + self, + sender, + key, + task_id, + event_name, + data, + adoption_signals: DictProxy[str, bool], + ): + signal_key = f"{task_id}:{event_name}" + logger.debug( + f"[DIAG] _on_pool_task_event: scheduling event {signal_key}" + ) + + with self._lock: + task = self._tasks.get(key) + if not (task and task.id == task_id): + task = self._zombie_tasks.get(task_id) + + if task: + if task.is_cancelled(): + logger.debug( + f"NACKing event '{event_name}' for cancelled " + f"task '{key}' (id: {task_id})." + ) + adoption_signals[signal_key] = False + else: + self._main_thread_scheduler( + self._dispatch_pooled_task_event, + key, + task_id, + event_name, + data, + adoption_signals, + ) + else: + logger.warning( + f"Received event '{event_name}' for unknown task " + f"key '{key}' (id: {task_id}). NACKing." + ) + adoption_signals[signal_key] = False + + def _on_pool_worker_died(self, sender, key, task_id, pid): + """ + Handle a worker death by finalizing the orphaned task as failed. + Runs on the listener thread; schedules finalization on the main + thread. + """ + logger.warning( + f"Worker PID {pid} died while processing task " + f"'{key}' (id: {task_id}). " + f"Marking orphaned task as failed." + ) + self._main_thread_scheduler( + self._finalize_pooled_task, + key, + task_id, + "failed", + error=( + f"Worker process {pid} died unexpectedly " + f"while executing task '{key}'." + ), + ) + + # === Main Thread Update Methods for Pooled Tasks === + + def _update_pooled_task( + self, + key: Any, + task_id: int, + progress: float | None = None, + message: str | None = None, + ): + """Updates a Task object from the main thread.""" + with self._lock: + task = self._tasks.get(key) + if not (task and task.id == task_id): + task = self._zombie_tasks.get(task_id) + if not task: + task = self._invisible_tasks.get(task_id) + + if task: + task.update(progress, message) + else: + logger.debug( + f"Ignoring progress/message for stale/unknown task instance " + f"for key '{key}' (id: {task_id})." + ) + + def _dispatch_pooled_task_event( + self, + key: Any, + task_id: int, + event_name: str, + data: dict, + adoption_signals: DictProxy[str, Any], + ): + """Dispatches a task event from the main thread.""" + with self._lock: + # First, check if the event is for the currently active task. + task = self._tasks.get(key) + if not (task and task.id == task_id): + # If not, check if it's for a recently replaced (zombie) task. + task = self._zombie_tasks.get(task_id) + if not task: + # Check invisible tasks + task = self._invisible_tasks.get(task_id) + + signal_key = f"{task_id}:{event_name}" + + if task: + if task.is_cancelled(): + logger.debug( + f"Dropping event '{event_name}' for cancelled " + f"task '{key}' (id: {task_id})." + ) + adoption_signals[signal_key] = False + return + logger.debug( + f"TaskManager: Dispatching event '{event_name}' for task " + f"'{task.key}' (task_id={task_id})." + ) + task.event_received.send(task, event_name=event_name, data=data) + # Signal to the worker that adoption is complete. + adoption_signals[signal_key] = True + else: + logger.warning( + f"Received event '{event_name}' for unknown or fully cleaned " + f"up task key '{key}' (id: {task_id}). NACKing." + ) + # Signal NACK so the worker knows to release/destroy the resource + adoption_signals[signal_key] = False + + def _finalize_pooled_task( + self, + key: Any, + task_id: int, + status: str, + result: Any = None, + error: str | None = None, + ): + """ + Finalizes a pooled task from the main thread. This is the single + source of truth for completing a pooled task's lifecycle. + """ + logger.debug( + f"Attempting to finalize pooled task '{key}' " + f"(id: {task_id}) with status '{status}'" + ) + with self._lock: + task = None + # Find the task in either the active or zombie dictionaries + active_task = self._tasks.get(key) + + if active_task and active_task.id == task_id: + # This is the currently active task for this key. + # It is now finished. + logger.debug( + f"Finalizing ACTIVE task '{key}' (id: {task_id})." + ) + task = active_task + del self._tasks[key] + else: + # It's not the active task. See if it's a zombie. + zombie_task = self._zombie_tasks.get(task_id) + if zombie_task: + logger.debug( + f"Finalizing ZOMBIE task '{key}' (id: {task_id})." + ) + task = zombie_task + del self._zombie_tasks[task_id] + else: + # Check invisible tasks + invisible_task = self._invisible_tasks.get(task_id) + if invisible_task: + logger.debug( + f"Finalizing INVISIBLE task '{key}' " + f"(id: {task_id})." + ) + task = invisible_task + del self._invisible_tasks[task_id] + + if not task: + logger.debug( + f"Received final message for unknown/cleaned-up task " + f"instance for key '{key}' (id: {task_id}). Ignoring." + ) + return + + # Now that we have the correct task instance and it has been removed + # from tracking, we can process its completion. + + # Set the final status, unless it was already cancelled. + if not task.is_cancelled(): + task._status = status + if status == "completed": + task._progress = 1.0 + task._task_result = result + elif status == "failed": + # We got a string traceback, wrap it in an Exception + logger.error(f"Task {key} failed in worker pool:\n{error}") + task._task_exception = Exception(error) + + # Emit one final, authoritative signal for all outcomes. + if task._visible: + task._emit_status_changed() + + # Call the user's callback if it was stored on the task. + when_done = task.when_done_callback + if when_done: + logger.debug( + f"Invoking when_done callback for task '{key}' " + f"(id: {task.id})." + ) + when_done(task) + else: + logger.debug( + f"No when_done callback to invoke for task '{key}' " + f"(id: {task.id})." + ) + + with self._lock: + if task._visible: + self._emit_tasks_updated_unsafe() + + def _cleanup_task(self, task: Task) -> None: + """ + Clean up a completed asyncio task. This is NOT used for pooled tasks. + """ + with self._lock: + current_task_in_dict = self._tasks.get(task.key) + # Only remove the task from the dictionary if it's the one we + # expect. This prevents a stale task's cleanup from removing a + # newer, active task. + if current_task_in_dict is task: + logger.debug( + f"Cleaning up (asyncio) task '{task.key}' " + f"(status: {task.get_status()})." + ) + del self._tasks[task.key] + + # DO NOT delete from _progress_map. The final progress + # value (usually 1.0) must be kept for accurate + # overall progress calculation until the next batch starts. + # The map is cleared when a new batch begins. + else: + # This task finished, but it's no longer the active one + # for this key in the dictionary (it was replaced). + logger.debug( + f"Skipping cleanup for finished (asyncio) task " + f"'{task.key}' (status: {task.get_status()}) as it was " + f"already replaced in the manager." + ) + self._emit_tasks_updated_unsafe() + + def _on_task_updated(self, task: Task) -> None: + """Handle task status changes. This method is thread-safe.""" + with self._lock: + # Only update progress if the task is still the active one for + # its key + active_task = self._tasks.get(task.key) + if active_task is task: + self._progress_map[task.key] = task.get_progress() + self._emit_tasks_updated_unsafe() + + def _emit_tasks_updated_unsafe(self) -> None: + """ + Emit a signal with current state. Must be called with the lock held. + """ + progress = self.get_overall_progress_unsafe() + tasks = list(self._tasks.values()) + self._main_thread_scheduler( + lambda: self.tasks_updated.send( + self, tasks=tasks, progress=progress + ) + ) + # Clear progress map only when truly idle (no active tasks and + # no zombies finishing up). This ensures the UI sees the final + # 100% progress before we reset for the next batch. + if not self._tasks and not self._zombie_tasks: + self._progress_map.clear() + + def get_overall_progress(self) -> float: + """Calculate overall progress. This method is thread-safe.""" + with self._lock: + return self.get_overall_progress_unsafe() + + def get_overall_progress_unsafe(self) -> float: + """Calculate overall progress. Assumes lock is held.""" + if not self._tasks: + # If there are no active tasks, progress is 100% + return 1.0 + if not self._progress_map: + # This can happen briefly if tasks are added but the map isn't + # populated yet. + return 0.0 + + # Use all keys in progress_map, including completed tasks that + # have been removed from _tasks but still contribute their 1.0 + # progress. The map is cleared only when a new batch starts. + all_keys = self._progress_map.keys() + total_progress = sum(self._progress_map.get(k, 0.0) for k in all_keys) + + return total_progress / len(all_keys) if all_keys else 1.0 + + def shutdown(self) -> None: + """ + Cancel all tasks, shut down the worker pool, and stop the event loop. + This method is thread-safe. + """ + try: + logger.info("Shutdown started") + with self._lock: + tasks_to_cancel = list(self._tasks.values()) + + logger.info(f"Active tasks at shutdown: {len(tasks_to_cancel)}") + logger.info("Cancelling all active tasks...") + for task in tasks_to_cancel: + status = task.get_status() + progress = task.get_progress() + logger.info( + f" Task '{task.key}': status={status}, " + f"progress={progress:.1f}%" + ) + self.cancel_task(task.key) + + # Shut down the worker pool (only if it was ever started). + if self._pool is not None: + self._pool.shutdown() + if self._manager is not None: + self._manager.shutdown() + + logger.info("Stopping asyncio event loop...") + # Stop the asyncio loop + if self.loop.is_running(): + self.loop.call_soon_threadsafe(self.loop.stop) + logger.debug("Joining thread...") + self._thread.join(timeout=1.0) + if self._thread.is_alive(): + logger.warning("thread shutdown timed out, ignoring") + logger.info("TaskManager shutdown complete.") + except KeyboardInterrupt: + logger.debug( + "TaskManager shutdown interrupted by user. " + "Suppressing traceback." + ) + + def wait_until_settled(self, timeout: int) -> bool: + """ + Wait until all tasks have completed or until timeout is reached. + + This is a thread-safe, non-blocking-loop implementation. + + Args: + timeout: Maximum time to wait in milliseconds. + + Returns: + True if all tasks completed before timeout, False if timeout was + reached. + """ + # Define event and handler + settled_event = threading.Event() + timeout_seconds = timeout / 1000.0 + + def on_update(sender, tasks, **kwargs): + """Signal handler that checks if the manager is idle.""" + if not self.has_tasks(): + # The manager is now idle. Set the event. + settled_event.set() + self.tasks_updated.disconnect(on_update) + + # Connect the handler FIRST to avoid race conditions where the + # signal is fired after has_tasks() check but before connect(). + self.tasks_updated.connect(on_update, weak=False) + + # If already settled, return immediately. + # Check this AFTER connecting to ensure we don't miss a signal + # that fires immediately after the check. + if not self.has_tasks(): + self.tasks_updated.disconnect(on_update) + return True + + # Wait for the event to be set by the callback, polling periodically + # to handle cases where the signal dispatch might be blocked. + poll_interval = 0.01 + total_waited = 0.0 + event_was_set = False + + while total_waited < timeout_seconds: + remaining = timeout_seconds - total_waited + wait_time = min(poll_interval, remaining) + + if settled_event.wait(timeout=wait_time): + event_was_set = True + break + + if not self.has_tasks(): + break + + total_waited += wait_time + + # Always try to disconnect in case of a timeout to prevent leaks. + self.tasks_updated.disconnect(on_update) + return event_was_set or not self.has_tasks() + + +class TaskManagerProxy: + """ + A lazy-initializing proxy for the TaskManager singleton. + + This object can be safely created at the module level. The real + TaskManager instance (with its threads and processes) is only created + when one of its methods is accessed for the first time. This avoids + the multiprocessing `RuntimeError` on systems that use 'spawn'. + + To provide worker initialization arguments, call the `initialize` method + once at application startup before using the task manager. + """ + + def __init__(self): + self._instance: TaskManager | None = None + self._lock = threading.Lock() + self._init_kwargs: dict[str, Any] = {} + + def initialize(self, **kwargs: Any) -> None: + """ + Provides configuration for the TaskManager before it is created. + This must be called before any other TaskManager methods are used. + + Example: + task_mgr.initialize( + worker_initializer=some_func, + worker_initargs=(arg1, arg2) + ) + + Raises: + RuntimeError: If called after the TaskManager has been created. + """ + with self._lock: + if self._instance is not None: + raise RuntimeError("TaskManager has already been initialized.") + self._init_kwargs = kwargs + + def restart_worker_pool(self) -> None: + """ + Shuts down the current worker pool and starts a new one. + """ + if self._instance is not None: + self._instance.restart_worker_pool() + + def _get_instance(self) -> TaskManager: + """ + Lazily creates the TaskManager instance in a thread-safe manner. + """ + if self._instance is None: + with self._lock: + # Double-check lock to prevent race conditions + if self._instance is None: + logger.debug( + "First use of TaskManager detected. " + "Initializing the real instance." + ) + if "main_thread_scheduler" not in self._init_kwargs: + self._init_kwargs["main_thread_scheduler"] = ( + lambda f, *a, **kw: f(*a, **kw) + ) + self._instance = TaskManager(**self._init_kwargs) + return self._instance + + def __getattr__(self, name: str) -> Any: + """ + Delegates attribute access to the real TaskManager instance, + creating it on first access. + """ + # Forward the call to the real instance. + return getattr(self._get_instance(), name) + + def __iter__(self): + """ + Delegate iteration to the real TaskManager instance. + """ + return iter(self._get_instance()) diff --git a/rayforge/shared/tasker/pool.py b/rayforge/shared/tasker/pool.py new file mode 100644 index 000000000..0c8d9bbfe --- /dev/null +++ b/rayforge/shared/tasker/pool.py @@ -0,0 +1,695 @@ +""" +Defines the WorkerPoolManager, a class for managing a pool of long-lived +worker processes to execute tasks efficiently. +""" + +from __future__ import annotations + +import builtins +import logging +import os +import threading +import traceback +from collections.abc import Callable +from multiprocessing import get_context +from multiprocessing.managers import DictProxy +from multiprocessing.process import BaseProcess +from multiprocessing.queues import Queue as MpQueue +from queue import Empty +from typing import Any + +from blinker import Signal + +from .proxy import ExecutionContextProxy + +logger = logging.getLogger(__name__) + +# A poison pill message to signal workers to shut down. +_WORKER_POISON_PILL = None +# A sentinel message to signal the result listener thread to shut down. +# Use a string for safe comparison across threads/processes. +_LISTENER_SENTINEL = "__listener_sentinel__" +# Message type for worker shutdown info +_SHUTDOWN_INFO_MSG = "__shutdown_info__" + + +class _TaggedQueue: + """ + A wrapper around a multiprocessing queue that tags every message + with a specific key before putting it on the underlying queue. + + This allows a shared result queue to distinguish which message belongs + to which task. It respects the interface of ExecutionContextProxy, which + expects an object with a `put_nowait` method. + """ + + def __init__(self, queue: MpQueue, key: Any, task_id: int): + self._queue = queue + self._key = key + self._task_id = task_id + + def put_nowait(self, msg: tuple[str, Any]): + """Tags the message with the key and puts it on the real queue.""" + msg_type, value = msg + try: + self._queue.put_nowait((self._key, self._task_id, msg_type, value)) + except Exception: + # This can happen if the queue is closed during shutdown. + # It's safe to ignore. + logger.debug("Result queue closed during shutdown", exc_info=True) + + +def _worker_main_loop( + task_queue: MpQueue, + result_queue: MpQueue, + log_level: int, + initializer: Callable[..., None] | None, + initargs: tuple[Any, ...], + adoption_signals: DictProxy[str, bool], + shared_state: DictProxy[str, Any], +): + """ + The main function for a worker process. + + It continuously fetches tasks from the task_queue, executes them, and + reports results, progress, and events back to the main process via the + result_queue. + """ + worker_logger = logging.getLogger(__name__) + try: + state_keys = list(shared_state.keys()) + except OSError: + state_keys = "" + worker_logger.debug( + f"Worker {os.getpid()} shared_state keys: {state_keys}" + ) + # Set up a null translator for gettext in the subprocess. + if not hasattr(builtins, "_"): + builtins._ = lambda s: s # type: ignore[attr-defined] + + # Force reconfiguration of logging for this new process. + root_logger = logging.getLogger() + if root_logger.handlers: + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + worker_logger = logging.getLogger(__name__) + + if initializer is not None: + try: + initializer(shared_state, *initargs) + except Exception: # noqa: BLE001 - arbitrary worker initializer + # If initialization fails, report it and exit immediately. + error_info = traceback.format_exc() + worker_logger.critical( + f"Worker {os.getpid()} failed during initialization:\n" + f"{error_info}" + ) + # We can't easily report this back via normal channels since + # we don't have a task ID yet, so we log critical and die. + return + + worker_logger.info(f"Worker process {os.getpid()} started and ready.") + last_task_key = None + + while True: + try: + worker_logger.debug( + f"Worker {os.getpid()}: Getting job from task_queue..." + ) + job = task_queue.get() + worker_logger.debug( + f"Worker {os.getpid()}: Got job from task_queue" + ) + except (EOFError, OSError) as e: + worker_logger.error( + f"Worker {os.getpid()}: Task queue connection lost. " + f"Exception type: {type(e).__name__}, Exception: {e}" + ) + worker_logger.error( + f"Worker {os.getpid()}: Last task was: {last_task_key}" + ) + break + except KeyboardInterrupt: + # Gracefully exit if the worker is interrupted while waiting + break + + if job is _WORKER_POISON_PILL: + worker_logger.info(f"Worker {os.getpid()} received poison pill.") + try: + result_queue.put_nowait( + ( + _SHUTDOWN_INFO_MSG, + 0, + _SHUTDOWN_INFO_MSG, + (os.getpid(), last_task_key), + ) + ) + except (OSError, BrokenPipeError): + logger.warning( + f"Worker {os.getpid()}: " + f"Failed to send shutdown info via result queue. " + f"Queue may be closed." + ) + break + + key, task_id, user_func, user_args, user_kwargs = job + last_task_key = key + + cancel_key = f"cancel:{task_id}" + if adoption_signals.get(cancel_key): + worker_logger.debug( + f"Worker {os.getpid()} skipping cancelled task " + f"'{key}' (id: {task_id})." + ) + try: + result_queue.put_nowait((key, task_id, "done", None)) + except (OSError, BrokenPipeError): + pass + continue + + worker_logger.debug(f"Worker {os.getpid()} starting task '{key}'.") + worker_logger.info( + f"[DIAGNOSTIC] Worker {os.getpid()} task_id={task_id}, " + f"key={key}, last_task_key={last_task_key}" + ) + + cancel_key = f"cancel:{task_id}" + if cancel_key in adoption_signals: + worker_logger.debug( + f"Worker {os.getpid()} skipping already-cancelled " + f"task '{key}' (id: {task_id})." + ) + try: + result_queue.put_nowait((key, task_id, "done", None)) + except (OSError, BrokenPipeError): + pass + adoption_signals.pop(cancel_key, None) + shared_state.pop(f"_wpool:{os.getpid()}", None) + continue + + # Track this task via the DictProxy BEFORE running user_func. + # This is the sole mechanism for identifying orphaned tasks when + # a worker crashes — it uses the SyncManager's own connection, + # which is immune to POSIX semaphore corruption from a crashed + # peer's _feed thread. Unlike the result queue, a worker crash + # merely closes the DictProxy socket; the shared dict remains + # intact and the health check can read the orphaned task info. + try: + shared_state[f"_wpool:{os.getpid()}"] = (key, task_id) + except (OSError, BrokenPipeError): + pass + + try: + result_queue.put_nowait((key, task_id, "running", os.getpid())) + except (OSError, BrokenPipeError): + pass + + # Wrap the result queue to automatically tag all messages from the + # proxy with this task's unique key. + tagged_queue = _TaggedQueue(result_queue, key, task_id) + # The _TaggedQueue implements the necessary 'put_nowait' method + # (duck typing), but isn't a Queue subclass. We ignore the type + # checker warning here as the code is functionally correct. + proxy = ExecutionContextProxy( + tagged_queue, # type: ignore + parent_log_level=log_level, + adoption_signals=adoption_signals, + task_id=task_id, + ) + + try: + result = user_func(proxy, *user_args, **user_kwargs) + proxy.flush() # Ensure the final progress is sent before "done" + result_queue.put_nowait((key, task_id, "done", result)) + # Clean up the DictProxy entry ONLY after the result was + # successfully sent. If this line is not reached (worker + # crashes), the entry remains so the health check can detect + # the orphaned task. + shared_state.pop(f"_wpool:{os.getpid()}", None) + except Exception: # noqa: BLE001 - arbitrary user task function + error_info = traceback.format_exc() + worker_logger.error( + f"Worker {os.getpid()} task '{key}' failed:\n{error_info}" + ) + # Also flush on error to send any last-known state + proxy.flush() + result_queue.put_nowait((key, task_id, "error", error_info)) + # Clean up ONLY after error was successfully reported. + # If this raises (worker crashes), entry stays for + # health check detection. + shared_state.pop(f"_wpool:{os.getpid()}", None) + worker_logger.debug(f"Worker {os.getpid()} finished task '{key}'.") + + +class WorkerPoolManager: + """ + Manages a pool of persistent worker processes to avoid the overhead of + spawning a new process for every task. + """ + + def __init__( + self, + num_workers: int | None = None, + initializer: Callable[..., None] | None = None, + initargs: tuple[Any, ...] = (), + shared_state: DictProxy[str, Any] | None = None, + ): + if num_workers is None: + env_max = os.environ.get("RAYFORGE_MAX_WORKERS") + if env_max is not None: + num_workers = min(int(env_max), os.cpu_count() or 1) + else: + num_workers = os.cpu_count() or 1 + logger.info( + f"Initializing WorkerPoolManager with {num_workers} workers." + ) + + self._mp_context = get_context("spawn") + self._manager = self._mp_context.Manager() + self._task_queue: MpQueue = self._mp_context.Queue() + self._result_queue: MpQueue = self._mp_context.Queue() + self._adoption_signals = self._manager.dict() + if shared_state is not None: + self._shared_state = shared_state + else: + self._shared_state = self._manager.dict() + self._workers: list[BaseProcess] = [] + self._cancelled_task_ids: set[int] = set() + self._lock = threading.Lock() + self._worker_shutdown_info: dict[int, tuple[int, Any | None]] = {} + self._worker_task_map: dict[int, tuple[Any, int]] = {} + self._pid_to_worker: dict[int, BaseProcess] = {} + self._health_check_counter = 0 + self._shutting_down = False + + # Signals for the TaskManager to subscribe to + self.task_event_received = Signal() + self.task_completed = Signal() + self.task_failed = Signal() + self.task_progress_updated = Signal() + self.task_message_updated = Signal() + self.worker_died = Signal() + + log_level = logging.getLogger().getEffectiveLevel() + + # Store worker creation params for replacement workers + self._log_level = log_level + self._initializer = initializer + self._initargs = initargs + + for _ in range(num_workers): + process = self._mp_context.Process( + target=_worker_main_loop, + args=( + self._task_queue, + self._result_queue, + log_level, + initializer, + initargs, + self._adoption_signals, + self._shared_state, + ), + daemon=True, + ) + self._workers.append(process) + process.start() + assert process.pid is not None + self._pid_to_worker[process.pid] = process + + self._listener_thread = threading.Thread( + target=self._result_listener_loop, daemon=True + ) + self._listener_thread.start() + + def submit( + self, + key: Any, + task_id: int, + target: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> None: + """ + Submits a task to the worker pool for execution. + + Args: + key: A unique identifier for the task. + task_id: The unique ID of the Task object instance. + target: The function to execute in the worker process. + *args: Positional arguments for the target function. + **kwargs: Keyword arguments for the target function. + """ + logger.debug( + f"Submitting task '{key}' (id: {task_id}) to worker pool." + ) + with self._lock: + # Before submitting, remove the ID from the cancelled set in case + # it's a retry of a previously cancelled task ID. This is unlikely + # with UUIDs but good practice. + self._cancelled_task_ids.discard(task_id) + job = (key, task_id, target, args, kwargs) + logger.debug( + f"Putting job on task_queue: key={key}, task_id={task_id}" + ) + try: + self._task_queue.put(job) + except (OSError, BrokenPipeError) as e: + logger.error( + f"Failed to put job on task_queue: {e}. " + f"Task queue may be closed or corrupted." + ) + raise + + def cancel(self, key: Any, task_id: int): + """ + Registers a task ID as cancelled. The listener thread will ignore + any subsequent messages from this task ID. Also sets a flag in + shared adoption_signals so the worker subprocess can cooperatively + abort via ExecutionContextProxy.is_cancelled(). + """ + logger.debug(f"Registering task '{key}' (id: {task_id}) as cancelled.") + with self._lock: + self._cancelled_task_ids.add(task_id) + self._adoption_signals[f"cancel:{task_id}"] = True + + def _result_listener_loop(self): + """ + + Runs in a dedicated thread in the main process, listening for results + from all workers and dispatching them as signals. + """ + logger.debug("Result listener thread started.") + while True: + self._health_check_counter += 1 + if self._health_check_counter % 10 == 0: + self._check_worker_health() + + try: + message = self._result_queue.get(timeout=0.1) + except (EOFError, OSError): + logger.warning( + "Result queue connection lost. Exiting listener." + ) + break + except KeyboardInterrupt: + # Gracefully exit if the listener is interrupted while waiting + break + except Empty: + continue + + # Use '==' for value comparison, as 'is' fails for objects + # passed through a queue. + if message == _LISTENER_SENTINEL: + logger.debug("Result listener thread received sentinel.") + break + + key, task_id, msg_type, value = message + + if msg_type == _SHUTDOWN_INFO_MSG: + pid, last_task_key = value + with self._lock: + self._worker_shutdown_info[pid] = (pid, last_task_key) + logger.debug( + f"Received shutdown info from worker {pid}: " + f"last_task={last_task_key}" + ) + continue + + # Track which worker is processing which task via the result + # queue. The primary tracking mechanism is the DictProxy + # (_wpool:pid), but _worker_task_map serves as a secondary + # source for workers that successfully sent "running" before + # a potential queue stall or peer crash. + if msg_type == "running": + pid = value + with self._lock: + self._worker_task_map[pid] = (key, task_id) + continue + + # The 'event' message type is special because it may carry + # resource handles (like shared memory). These must ALWAYS be + # forwarded to the TaskManager so the receiving code has a + # chance to adopt the resource, even if the task was cancelled + # or is stale. This prevents resource leaks. + if msg_type == "event": + event_name, data = value + self.task_event_received.send( + self, + key=key, + task_id=task_id, + event_name=event_name, + data=data, + adoption_signals=self._adoption_signals, + ) + continue + + # For all other message types, we can safely ignore them if the + # task has been cancelled. + with self._lock: + if task_id in self._cancelled_task_ids: + # For a cancelled task, only process the final 'done' or + # 'error' message for cleanup. Ignore everything else. + if msg_type in ("done", "error"): + # It's the final message. Let it pass through for + # cleanup and remove the ID from the cancelled set. + self._cancelled_task_ids.remove(task_id) + self._adoption_signals.pop(f"cancel:{task_id}", None) + else: + # It's an intermediate message. Ignore it. + logger.debug( + f"Ignoring message '{msg_type}' from cancelled " + f"task '{key}' (id: {task_id})." + ) + continue + + if msg_type == "done": + self._adoption_signals.pop(f"cancel:{task_id}", None) + self.task_completed.send( + self, key=key, task_id=task_id, result=value + ) + elif msg_type == "error": + self._adoption_signals.pop(f"cancel:{task_id}", None) + self.task_failed.send( + self, key=key, task_id=task_id, error=value + ) + elif msg_type == "progress": + self.task_progress_updated.send( + self, key=key, task_id=task_id, progress=value + ) + elif msg_type == "message": + self.task_message_updated.send( + self, key=key, task_id=task_id, message=value + ) + + # Clean up the worker task mapping when a task finishes. + if msg_type in ("done", "error"): + with self._lock: + for pid, (k, tid) in list(self._worker_task_map.items()): + if k == key and tid == task_id: + del self._worker_task_map[pid] + break + + logger.debug("Result listener thread finished.") + + def _check_worker_health(self): + """ + Check if any workers have died. If so, emit a worker_died + signal for orphaned task cleanup and start a replacement worker. + """ + if self._shutting_down: + return + + dead_info = [] + with self._lock: + for pid, (key, task_id) in list(self._worker_task_map.items()): + worker = self._pid_to_worker.get(pid) + if worker is not None: + try: + alive = worker.is_alive() + except ValueError: + alive = False + else: + alive = False + if not alive: + dead_info.append((pid, key, task_id, worker)) + del self._worker_task_map[pid] + if pid in self._pid_to_worker: + del self._pid_to_worker[pid] + try: + if worker is not None: + self._workers.remove(worker) + except (ValueError, AttributeError): + pass + + for pid, worker in list(self._pid_to_worker.items()): + if pid in self._worker_task_map: + continue + try: + alive = worker.is_alive() + except ValueError: + alive = False + + if not alive: + status = self._shared_state.get(f"_wpool:{pid}") + if status is not None: + key, task_id = status + dead_info.append((pid, key, task_id, worker)) + else: + dead_info.append((pid, None, None, worker)) + if pid in self._pid_to_worker: + del self._pid_to_worker[pid] + try: + self._workers.remove(worker) + except ValueError: + pass + + for pid, key, task_id, worker in dead_info: + if key is not None: + logger.warning( + f"Worker PID {pid} died while processing task " + f"'{key}' (id: {task_id}). " + f"Orphaned task will be marked as failed." + ) + else: + logger.warning(f"Worker PID {pid} died while idle.") + try: + worker.close() + except ValueError: + pass + if key is not None: + self.worker_died.send(self, key=key, task_id=task_id, pid=pid) + + if dead_info: + for _ in dead_info: + self._spawn_replacement_worker() + + def _spawn_replacement_worker(self): + """Start a single replacement worker process.""" + process = self._mp_context.Process( + target=_worker_main_loop, + args=( + self._task_queue, + self._result_queue, + self._log_level, + self._initializer, + self._initargs, + self._adoption_signals, + self._shared_state, + ), + daemon=True, + ) + process.start() + assert process.pid is not None + with self._lock: + self._workers.append(process) + self._pid_to_worker[process.pid] = process + logger.info(f"Replacement worker PID {process.pid} started.") + + def shutdown(self, timeout: float = 2.0): + """ + Shuts down the worker pool, terminating all worker processes. + """ + logger.info("Shutting down worker pool.") + self._shutting_down = True + try: + for worker in self._workers: + pid = worker.pid + status = "alive" if worker.is_alive() else "dead" + logger.info(f"Worker PID {pid}: {status}") + + # 1. Signal workers to exit by sending a poison pill for each one. + for _ in self._workers: + try: + self._task_queue.put(_WORKER_POISON_PILL) + except (OSError, BrokenPipeError) as e: + logger.warning( + f"Failed to send poison pill to worker: {e}. " + "Queue may already be closed if workers crashed." + ) + + # 2. Join worker processes with a timeout. + # Capture PIDs before closing workers for shutdown summary. + worker_pids = [w.pid for w in self._workers] + for worker in self._workers: + try: + worker.join(timeout=timeout) + except OSError: + logger.warning( + f"Worker process {worker.pid}: join failed " + "(process handle invalid, may have crashed)." + ) + try: + still_alive = worker.is_alive() + except (OSError, ValueError): + still_alive = False + if still_alive: + logger.warning( + f"Worker process {worker.pid} did not exit cleanly. " + "Terminating." + ) + worker.terminate() + worker.join(timeout=1.0) + # Always close the process object to properly clean up + # and prevent zombie processes. + try: + worker.close() + except ValueError: + # Process might already be closed or in an invalid state + # This can happen if the process was already terminated + # and cleaned up by the OS + pass + + # 3. Stop the result listener thread. + try: + self._result_queue.put(_LISTENER_SENTINEL) + except (OSError, BrokenPipeError) as e: + logger.warning( + f"Failed to send sentinel to listener: {e}. " + "Result queue may already be closed." + ) + self._listener_thread.join(timeout=1.0) + + # 4. Clean up multiprocessing queues. + # Use cancel_join_thread() to prevent joining the feeder thread, + # which avoids reentrant resource_tracker warnings on Python 3.12. + # The feeder thread is a daemon and will be cleaned up when the + # process exits. + self._task_queue.cancel_join_thread() + self._task_queue.close() + self._result_queue.cancel_join_thread() + self._result_queue.close() + + logger.debug("Worker shutdown summary") + for pid in worker_pids: + if pid in self._worker_shutdown_info: + _, last_task_key = self._worker_shutdown_info[pid] + logger.info( + f"Worker PID {pid}: last_task='{last_task_key}'" + ) + else: + logger.warning( + f"Worker PID {pid}: no shutdown info received " + "(may have crashed or not reported)" + ) + # 5. Shut down the Manager to release its process and + # named pipe / Unix socket resources. + try: + self._manager.shutdown() + except (OSError, EOFError, ValueError) as e: + logger.debug( + f"Manager shutdown failed (may already be gone): {e}" + ) + + logger.info("Worker pool shutdown complete.") + except KeyboardInterrupt: + logger.debug( + "Worker pool shutdown interrupted by user. " + "Suppressing traceback." + ) + # At this point, the main process is exiting anyway. + # The daemon processes will be terminated by the OS. We can just + # pass and allow the exit to proceed cleanly. diff --git a/rayforge/shared/tasker/progress.py b/rayforge/shared/tasker/progress.py new file mode 100644 index 000000000..f77494afa --- /dev/null +++ b/rayforge/shared/tasker/progress.py @@ -0,0 +1,370 @@ +"""Progress context abstraction for task execution. + +This module provides a unified hierarchy for progress reporting and +cancellation checking. The base class ProgressContext defines the +interface, with ThrottledProgressContext extending it for contexts +that require throttling or debouncing of progress updates. +""" + +import time +from abc import ABC, abstractmethod +from collections.abc import Callable + + +class ProgressContext(ABC): + """Abstract base class for progress reporting and cancellation. + + This class provides the foundation for all progress reporting + contexts, including normalization, sub-contexting, and cancellation + checking. Subclasses must implement the abstract methods to provide + specific reporting mechanisms. + """ + + def __init__( + self, + base_progress: float = 0.0, + progress_range: float = 1.0, + total: float = 100.0, + ): + """Initialize the progress context. + + Args: + base_progress: The normalized (0.0-1.0) progress when this + context begins relative to its parent. + progress_range: The fraction (0.0-1.0) of the parent's + progress that this context represents. + total: The total number of steps for progress normalization. + Defaults to 100.0. + """ + self._base = base_progress + self._range = progress_range + self._total = 1.0 + self.set_total(total) + + @abstractmethod + def is_cancelled(self) -> bool: + """Check if the operation has been cancelled. + + Returns: + True if the operation should be cancelled, False otherwise. + """ + + def set_progress(self, progress: float) -> None: + """Set progress as an absolute value. + + The value is normalized based on the context's base, range, + and total, then reported via _report_normalized_progress. + + Args: + progress: The absolute progress value to set. + """ + normalized_progress = progress / self._total + self._report_normalized_progress(normalized_progress) + + @abstractmethod + def set_message(self, message: str) -> None: + """Set a descriptive status message. + + Args: + message: The status message to display. + """ + + def set_total(self, total: float) -> None: + """Set the total value for progress normalization. + + If total <= 0, it's treated as 1.0 (already normalized). + + Args: + total: The total number of steps for progress calculation. + """ + if total <= 0: + self._total = 1.0 + else: + self._total = float(total) + + def sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + ) -> "ProgressContext": + """Create a sub-context for hierarchical progress reporting. + + Args: + base_progress: The normalized (0.0-1.0) progress in the parent + when the sub-task begins. + progress_range: The fraction (0.0-1.0) of the parent's + progress that this sub-task represents. + total: The total number of steps for the new sub-context. + + Returns: + A new ProgressContext configured as a sub-context. + """ + return self._create_sub_context(base_progress, progress_range, total) + + @abstractmethod + def flush(self) -> None: + """Immediately send any pending updates.""" + + @abstractmethod + def _report_normalized_progress(self, progress: float) -> None: + """Report a normalized (0.0-1.0) progress value. + + Subclasses must implement this to provide the specific reporting + mechanism. + + Args: + progress: The normalized progress value (0.0-1.0). + """ + + @abstractmethod + def _create_sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + ) -> "ProgressContext": + """Factory method for creating sub-contexts. + + Subclasses must implement this to return an appropriate sub-context + instance. + + Args: + base_progress: The normalized (0.0-1.0) progress in the parent + when the sub-task begins. + progress_range: The fraction (0.0-1.0) of the parent's + progress that this sub-task represents. + total: The total number of steps for the new sub-context. + + Returns: + A new ProgressContext configured as a sub-context. + """ + + +class ThrottledProgressContext(ProgressContext): + """Abstract marker class for contexts requiring throttling. + + This class extends ProgressContext but serves as a marker for + contexts that need progress updates to be throttled or debounced. + All abstract methods remain abstract, requiring concrete subclasses + to implement them. + """ + + +def set_progress( + context: ProgressContext | None, + progress: float, + message: str = "", +) -> None: + """Report progress and message via context if provided. + + This helper eliminates the need for null checks at call sites. + + Args: + context: Optional ProgressContext for progress reporting. + progress: The progress value to set. + message: Optional status message to display. + """ + if context is not None: + context.set_progress(progress) + if message: + context.set_message(message) + + +class NoOpProgressContext(ProgressContext): + """No-op implementation of ProgressContext. + + This class provides a silent implementation that does nothing for all + operations. Useful for testing or when progress reporting is not + needed. + """ + + def __init__( + self, + base_progress: float = 0.0, + progress_range: float = 1.0, + total: float = 100.0, + ): + """Initialize the no-op progress context.""" + super().__init__(base_progress, progress_range, total) + + def is_cancelled(self) -> bool: + """Check if the operation has been cancelled. + + Returns: + Always False for no-op context. + """ + return False + + def set_progress(self, progress: float) -> None: + """Set progress as an absolute value (no-op). + + Args: + progress: The absolute progress value (ignored). + """ + + def set_message(self, message: str) -> None: + """Set a descriptive status message (no-op). + + Args: + message: The status message (ignored). + """ + + def set_total(self, total: float) -> None: + """Set the total value for progress normalization (no-op). + + Args: + total: The total number of steps (ignored). + """ + + def sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + ) -> "ProgressContext": + """Create a sub-context (returns new NoOpProgressContext). + + Args: + base_progress: The normalized (0.0-1.0) progress in the parent + when the sub-task begins (ignored). + progress_range: The fraction (0.0-1.0) of the parent's + progress that this sub-task represents + (ignored). + total: The total number of steps for the new sub-context + (ignored). + + Returns: + A new NoOpProgressContext instance. + """ + return NoOpProgressContext(base_progress, progress_range, total) + + def flush(self) -> None: + """Immediately send any pending updates (no-op).""" + + def _report_normalized_progress(self, progress: float) -> None: + """Report a normalized progress value (no-op). + + Args: + progress: The normalized progress value (ignored). + """ + + def _create_sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + ) -> "ProgressContext": + """Factory method for creating sub-contexts. + + Args: + base_progress: The normalized (0.0-1.0) progress in the parent + when the sub-task begins. + progress_range: The fraction (0.0-1.0) of the parent's + progress that this sub-task represents. + total: The total number of steps for the new sub-context. + + Returns: + A new NoOpProgressContext instance. + """ + return NoOpProgressContext(base_progress, progress_range, total) + + +class CallbackProgressContext(ProgressContext): + """ProgressContext implementation that uses callbacks for reporting. + + This class provides a concrete implementation that delegates progress + reporting, message setting, and cancellation checking to provided + callback functions. + """ + + CANCELLED_CHECK_INTERVAL_S = 0.05 + + def __init__( + self, + is_cancelled_func: Callable[[], bool], + progress_callback: Callable[[float], None], + message_callback: Callable[[str], None], + base_progress: float = 0.0, + progress_range: float = 1.0, + total: float = 100.0, + ): + """Initialize the callback progress context. + + Args: + is_cancelled_func: Function that returns True if cancelled. + progress_callback: Function called with normalized progress. + message_callback: Function called with status messages. + base_progress: The normalized (0.0-1.0) progress when this + context begins relative to its parent. + progress_range: The fraction (0.0-1.0) of the parent's + progress that this context represents. + total: The total number of steps for progress normalization. + """ + super().__init__(base_progress, progress_range, total) + self._is_cancelled_func = is_cancelled_func + self._progress_callback = progress_callback + self._message_callback = message_callback + self._last_check_time = 0.0 + + def is_cancelled(self) -> bool: + """Check if the operation has been cancelled. + + The check is throttled to avoid excessive IPC overhead when + called from tight loops in subprocess workers. + + Returns: + The result of calling the is_cancelled_func callback. + """ + now = time.monotonic() + if now - self._last_check_time < self.CANCELLED_CHECK_INTERVAL_S: + return False + self._last_check_time = now + return self._is_cancelled_func() + + def set_message(self, message: str) -> None: + """Set a descriptive status message. + + Args: + message: The status message to send via callback. + """ + self._message_callback(message) + + def flush(self) -> None: + """Immediately send any pending updates (no-op for callbacks).""" + + def _report_normalized_progress(self, progress: float) -> None: + """Report a normalized progress value via callback. + + Args: + progress: The normalized progress value (0.0-1.0). + """ + scaled_progress = self._base + (progress * self._range) + self._progress_callback(scaled_progress) + + def _create_sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + ) -> "ProgressContext": + """Factory method for creating sub-contexts. + + Args: + base_progress: The normalized (0.0-1.0) progress in the parent + when the sub-task begins. + progress_range: The fraction (0.0-1.0) of the parent's + progress that this sub-task represents. + total: The total number of steps for the new sub-context. + + Returns: + A new CallbackProgressContext instance with the same callbacks. + """ + return CallbackProgressContext( + self._is_cancelled_func, + self._progress_callback, + self._message_callback, + base_progress, + progress_range, + total, + ) diff --git a/rayforge/shared/tasker/proxy.py b/rayforge/shared/tasker/proxy.py new file mode 100644 index 000000000..1599afda3 --- /dev/null +++ b/rayforge/shared/tasker/proxy.py @@ -0,0 +1,200 @@ +""" +Proxy for reporting progress from subprocesses via a queue. + +This module provides ExecutionContextProxy, which extends +ThrottledProgressContext to enable progress reporting from subprocesses +through a queue. It includes throttling to prevent flooding the IPC +queue and provides event sending capabilities. + +WARNING: This file MUST NOT have any imports that cause any other +parts of the application to be initialized. It is designed to +be used during subprocess bootstrapping, where no other parts +of the application should be imported or initialized. +We can also not import any GTK or Adw classes here, +as this would cause the GTK main loop to be initialized, +which is not safe during bootstrapping. +In other words, we cannot use GLib.idle_add or similar. +""" + +import logging +import time +from multiprocessing.queues import Queue +from queue import Full +from typing import Any + +from rayforge.shared.tasker.progress import ( + ThrottledProgressContext, +) + + +class ExecutionContextProxy(ThrottledProgressContext): + """Pickleable proxy for reporting progress from a subprocess via a queue. + + Extends ThrottledProgressContext to enable progress reporting from + subprocesses through an IPC queue. Progress updates are throttled to + prevent flooding the queue. Supports event sending for subprocess + communication with the parent process. + """ + + # Report progress at most ~10 times per second to prevent flooding the UI. + PROGRESS_REPORT_INTERVAL_S = 0.1 + + def __init__( + self, + progress_queue: Queue, + base_progress: float = 0.0, + progress_range: float = 1.0, + parent_log_level: int = logging.DEBUG, + adoption_signals: Any = None, + task_id: int = 0, + ): + super().__init__(base_progress, progress_range, total=1.0) + self._queue = progress_queue + self._adoption_signals = adoption_signals + self._task_id = task_id + self.parent_log_level = parent_log_level + self._last_progress_report_time = 0.0 + self._last_reported_progress: float | None = None + self.task = None + + def _report_normalized_progress(self, progress: float): + """ + Reports a 0.0-1.0 progress value, scaled to the proxy's + range. This is throttled to prevent flooding the IPC queue. + """ + # Clamp to a valid range before scaling + progress = max(0.0, min(1.0, progress)) + scaled_progress = self._base + (progress * self._range) + self._last_reported_progress = scaled_progress + + current_time = time.monotonic() + if ( + current_time - self._last_progress_report_time + < self.PROGRESS_REPORT_INTERVAL_S + ): + return # Not enough time has passed, skip sending the update. + self._last_progress_report_time = current_time + + try: + self._queue.put_nowait(("progress", scaled_progress)) + except Full: + pass # If the queue is full, we drop the update. + + def set_message(self, message: str): + try: + self._queue.put_nowait(("message", message)) + except Full: + pass + + def send_event(self, name: str, data: dict | None = None): + """Sends a named event with a data payload to the parent.""" + try: + self._queue.put_nowait( + ("event", (name, data if data is not None else {})) + ) + except Full: + pass + + def sub_context( + self, + base_progress: float = 0.0, + progress_range: float = 1.0, + total: float = 1.0, + **kwargs, + ) -> "ExecutionContextProxy": + """ + Creates a sub-context that reports progress within a specified range. + """ + new_base = self._base + (base_progress * self._range) + new_range = self._range * progress_range + return self._create_sub_context(new_base, new_range, total, **kwargs) + + def _create_sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + **kwargs, + ) -> "ExecutionContextProxy": + """ + Creates a sub-context that reports progress within a specified range. + """ + # The new proxy gets its own total for its own progress calculations + new_proxy = ExecutionContextProxy( + self._queue, + base_progress, + progress_range, + adoption_signals=self._adoption_signals, + task_id=self._task_id, + ) + new_proxy.set_total(total) + return new_proxy + + def is_cancelled(self) -> bool: + """ + Checks if the task has been cancelled by looking for a cancellation + flag in the shared adoption_signals dict. The main process sets a + ``"cancel:{task_id}"`` key when cancel() is called. + """ + if self._adoption_signals is None: + return False + return f"cancel:{self._task_id}" in self._adoption_signals + + def flush(self): + """ + Immediately sends any pending updates. This ensures the final + progress value is always sent, bypassing the throttle. + """ + if self._last_reported_progress is None: + return + + try: + self._queue.put_nowait(("progress", self._last_reported_progress)) + # Reset to avoid duplicate flushes if called multiple times. + self._last_reported_progress = None + except Full: + pass + + def send_event_and_wait( + self, + name: str, + data: dict | None = None, + timeout: float = 5.0, + logger: logging.Logger | None = None, + ) -> bool: + """ + Sends a named event and waits for adoption acknowledgment. + + This is used for events that carry shared memory handles. On Windows, + shared memory is destroyed when all handles are closed, so the worker + must wait for the main process to adopt before closing its handle. + + Args: + name: The event name. + data: Optional data payload. + timeout: Maximum time to wait for acknowledgment in seconds. + logger: Optional logger for debug messages. + + Returns: + True if acknowledgment was received and successful, False if NACKed + or timed out. + """ + self.send_event(name, data) + + if self._adoption_signals is None: + return True + + signal_key = f"{self._task_id}:{name}" + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + if signal_key in self._adoption_signals: + result = self._adoption_signals.pop(signal_key) + return bool(result) + time.sleep(0.01) + + if logger: + logger.warning( + f"Timeout waiting for adoption signal for {signal_key}" + ) + return False diff --git a/rayforge/shared/tasker/task.py b/rayforge/shared/tasker/task.py new file mode 100644 index 000000000..f54c63c9f --- /dev/null +++ b/rayforge/shared/tasker/task.py @@ -0,0 +1,209 @@ +""" +Task module for managing individual tasks. +""" + +from __future__ import annotations + +import asyncio +import logging +from asyncio.exceptions import CancelledError +from collections.abc import Callable, Coroutine +from typing import Any + +from blinker import Signal + +from .context import ExecutionContext + +logger = logging.getLogger(__name__) + + +class Task: + def __init__( + self, + coro: Callable[..., Coroutine[Any, Any, Any]], + *args: Any, + key: Any | None = None, + when_done: Callable[[Task], None] | None = None, + task_type: str = "asyncio", + **kwargs: Any, + ): + self.coro = coro + self.args = args + self.kwargs = kwargs + self.key: Any = key if key is not None else id(self) + self.id = id(self) + self.task_type = task_type + self._task: asyncio.Task[Any] | None = None + self._task_result: Any = None + self._task_exception: BaseException | None = None + self._status: str = "pending" + self._progress: float = 0.0 + self._message: str | None = None + self._cancel_requested: bool = False # Flag for early cancellation + self._visible: bool = True # Whether task appears in UI + self.status_changed: Signal = Signal() + self.event_received: Signal = Signal() + self.when_done_callback: Callable[[Task], None] | None = when_done + + def update( + self, progress: float | None = None, message: str | None = None + ) -> None: + """ + Updates task progress and/or message. This method is designed to be + called from the main thread (e.g., via idle_add) and emits a + single signal for any change. + """ + updated = False + if progress is not None and self._progress != progress: + self._progress = progress + updated = True + if message is not None and self._message != message: + self._message = message + updated = True + + if updated: + self._emit_status_changed() + + async def run(self, context: ExecutionContext) -> None: + """ + Run the task and update its status. The wrapped coroutine is + responsible for reporting progress via the provided context. + """ + logger.debug(f"Task {self.key}: Entering run method.") + + # Early cancellation check + if self._cancel_requested: + logger.debug( + f"Task {self.key}: Cancellation requested before coro start." + ) + self._status = "canceled" + self._emit_status_changed() + raise CancelledError("Task cancelled before coro execution") + + # Start execution + self._status = "running" + self._emit_status_changed() # Emit running status + logger.debug( + f"Task {self.key}: Creating internal asyncio.Task for coro." + ) + + # Wrap the coroutine in a Task. + self._task = asyncio.create_task( + self.coro(context, *self.args, **self.kwargs) + ) + + # Await Coroutine Completion + try: + logger.debug(f"Task {self.key}: Awaiting internal asyncio.Task.") + await self._task + # If await completes without CancelledError or other Exception: + logger.debug(f"Task {self.key}: Coro completed successfully.") + self._status = "completed" + self._progress = 1.0 + except asyncio.CancelledError: + # This catches cancellation of self._task (the coro) + logger.warning( + f"Task {self.key}: Internal asyncio.Task was cancelled." + ) + self._status = "canceled" + # Propagate so the outer _run_task knows about the cancellation + raise + except Exception: + logger.exception(f"Task {self.key}: Coro failed with exception.") + self._status = "failed" + # Re-raise so the TaskManager can see and log it. + raise + finally: + logger.debug( + f"Task {self.key}: Run method finished " + f"with status '{self._status}'." + ) + # First, flush any pending context updates. This might call + # self.update() and set an intermediate state (e.g. final message). + context.flush() + + # Now, set the authoritative final state. + if self._status == "completed": + self._progress = 1.0 + + # Emit one final signal with the authoritative state. + self._emit_status_changed() + + def _emit_status_changed(self) -> None: + """Emit status_changed signal from the main thread.""" + self.status_changed.send(self) + + def get_progress(self) -> float: + """Get the current progress of the task.""" + return self._progress + + def get_status(self) -> str: + """Get the current lifecycle status of the task.""" + return self._status + + def get_message(self) -> str | None: + """Get the current user-facing message for the task.""" + return self._message + + def is_final(self) -> bool: + """Returns True if the task is in a terminal state.""" + return self._status in ("completed", "failed", "canceled") + + def is_running(self) -> bool: + """Returns True if the task is currently running.""" + return self._status == "running" + + def result(self) -> Any: + if self._task: # It's an asyncio-managed task + if not self._task.done(): + raise asyncio.InvalidStateError("result is not yet available") + return self._task.result() + + # It's a synchronously-managed (process) task + if self._status == "completed": + return self._task_result + if self._status == "failed": + if self._task_exception: + raise self._task_exception + raise asyncio.InvalidStateError( + "Task failed but no exception was captured." + ) + if self._status == "canceled": + raise CancelledError("Task was cancelled.") + + raise asyncio.InvalidStateError( + f"result is not available for task in state '{self._status}'" + ) + + def cancel(self) -> None: + """ + Request cancellation of the task. + Sets a flag to prevent starting if not already started, + and attempts to cancel the underlying asyncio.Task if it exists. + This method does NOT change the task's status itself. + """ + logger.debug(f"Task {self.key}: Cancel method called.") + self._cancel_requested = True # Set flag regardless of current state + + # For asyncio tasks, also propagate the cancellation to the + # underlying coroutine. + task_to_cancel = self._task + if task_to_cancel and not task_to_cancel.done(): + logger.info( + f"Task {self.key}: Attempting to cancel " + f"running internal asyncio.Task." + ) + task_to_cancel.cancel() + elif task_to_cancel: + logger.debug( + f"Task {self.key}: Internal asyncio.Task already done." + ) + else: + logger.debug( + f"Task {self.key}: Internal asyncio.Task not yet " + f"created, flag set." + ) + + def is_cancelled(self) -> bool: + """Checks if cancellation has been requested for this task.""" + return self._cancel_requested diff --git a/rayforge/shared/units/__init__.py b/rayforge/shared/units/__init__.py new file mode 100644 index 000000000..3f2099d47 --- /dev/null +++ b/rayforge/shared/units/__init__.py @@ -0,0 +1,3 @@ +from .system import MM_PER_INCH, UnitSystem, inches_to_mm + +__all__ = ["MM_PER_INCH", "UnitSystem", "inches_to_mm"] diff --git a/rayforge/shared/units/definitions.py b/rayforge/shared/units/definitions.py new file mode 100644 index 000000000..2a20bf272 --- /dev/null +++ b/rayforge/shared/units/definitions.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass +from gettext import gettext as _ + +from .engine import engine + + +@dataclass(frozen=True) +class Unit: + """ + A declarative definition for a unit of measurement. + Conversion logic is handled by the global ConversionEngine. + """ + + name: str # Programmatic, normalized identifier (e.g., "mm/min") + label: str # User-facing, translatable string (e.g., "mm/min") + quantity: str # Physical quantity measured (e.g., "speed", "length") + description: str | None = None # Translatable tooltip + precision: int = 2 # Suggested decimal places for display + + def to_base(self, value: float) -> float: + """Converts a value from this unit to the application's base unit.""" + base_unit = get_base_unit_for_quantity(self.quantity) + if not base_unit or base_unit.name == self.name: + return value + converted_value, _ = engine.convert(value, self.name, base_unit.name) + return converted_value + + def from_base(self, value: float) -> float: + """Converts a value from the application's base unit to this unit.""" + base_unit = get_base_unit_for_quantity(self.quantity) + if not base_unit or base_unit.name == self.name: + return value + converted_value, _ = engine.convert(value, base_unit.name, self.name) + return converted_value + + +_UNIT_REGISTRY: dict[str, Unit] = {} +_BASE_UNITS: dict[str, str] = {} + + +def register_unit(unit: Unit): + """Adds a unit to the central registry.""" + if unit.name in _UNIT_REGISTRY: + raise ValueError( + f"Unit with name '{unit.name}' is already registered." + ) + _UNIT_REGISTRY[unit.name] = unit + + +def set_base_unit(quantity: str, unit_name: str): + """Sets the application-wide base unit for a given quantity.""" + if quantity in _BASE_UNITS: + raise ValueError( + f"Base unit for quantity '{quantity}' is already set." + ) + if unit_name not in _UNIT_REGISTRY: + raise ValueError( + f"Cannot set unregistered unit '{unit_name}' as base." + ) + _BASE_UNITS[quantity] = unit_name + + +def get_units_for_quantity(quantity: str) -> list[Unit]: + """Returns all registered units for a specific physical quantity.""" + units = [u for u in _UNIT_REGISTRY.values() if u.quantity == quantity] + # Sort by label for consistent UI presentation + return sorted(units, key=lambda u: u.label) + + +def get_unit(name: str) -> Unit | None: + """Retrieves a specific unit by its programmatic name.""" + return _UNIT_REGISTRY.get(name) + + +def get_base_unit_for_quantity(quantity: str) -> Unit | None: + """Retrieves the designated base unit for a quantity.""" + base_unit_name = _BASE_UNITS.get(quantity) + return get_unit(base_unit_name) if base_unit_name else None + + +# --- Define and Register Speed Units --- +# Application base unit for speed is mm/min. + +register_unit( + Unit(name="mm/min", label=_("mm/min"), quantity="speed", precision=0) +) +register_unit( + Unit(name="mm/s", label=_("mm/s"), quantity="speed", precision=1) +) +register_unit( + Unit(name="in/min", label=_("in/min"), quantity="speed", precision=1) +) +register_unit( + Unit(name="in/s", label=_("in/s"), quantity="speed", precision=2) +) + +set_base_unit("speed", "mm/min") + +# --- Define and Register Length Units --- +# Application base unit for length is mm. + +register_unit(Unit(name="mm", label=_("mm"), quantity="length", precision=2)) +register_unit(Unit(name="cm", label=_("cm"), quantity="length", precision=2)) +register_unit(Unit(name="m", label=_("m"), quantity="length", precision=3)) +register_unit(Unit(name="in", label=_("in"), quantity="length", precision=3)) +register_unit(Unit(name="ft", label=_("ft"), quantity="length", precision=3)) + +set_base_unit("length", "mm") + +# --- Define and Register Acceleration Units --- +# Application base unit for acceleration is mm/s². + +register_unit( + Unit(name="mm/s²", label=_("mm/s²"), quantity="acceleration", precision=0) +) +register_unit( + Unit(name="cm/s²", label=_("cm/s²"), quantity="acceleration", precision=1) +) +register_unit( + Unit(name="m/s²", label=_("m/s²"), quantity="acceleration", precision=2) +) +register_unit( + Unit(name="in/s²", label=_("in/s²"), quantity="acceleration", precision=2) +) +register_unit( + Unit(name="ft/s²", label=_("ft/s²"), quantity="acceleration", precision=3) +) + +set_base_unit("acceleration", "mm/s²") diff --git a/rayforge/shared/units/engine.py b/rayforge/shared/units/engine.py new file mode 100644 index 000000000..5e6fcd5d0 --- /dev/null +++ b/rayforge/shared/units/engine.py @@ -0,0 +1,178 @@ +import re +from typing import ClassVar + +METERS_TO_INCH = 39.37007874 +METERS_TO_FEET = 3.280839895 +KW_TO_HP = 1.34102 +MM_PER_INCH = 25.4 + + +class ConversionEngine: + _symbols: ClassVar[dict[str, str]] = { + # SI units. + "nanometer": "nm", + "nanometers": "nm", + "um": "μm", + "micrometer": "μm", + "micrometers": "μm", + "millimeter": "mm", + "millimeters": "mm", + "centimeter": "cm", + "centimeters": "cm", + "meter": "m", + "meters": "m", + "kilometer": "km", + "kilometers": "km", + # Time + "second": "s", + "seconds": "s", + "sec": "s", + "minute": "min", + "minutes": "min", + "hour": "hr", + "hours": "hr", + # Imperial. + '"': "in", + "inch": "in", + "inches": "in", + "'": "ft", + "foot": "ft", + "feet": "ft", + "yard": "yd", + "yards": "yd", + "mile": "mi", + "miles": "mi", + } + + _base_conversions: ClassVar[dict[tuple[str, str], float]] = { + ("m", "in"): METERS_TO_INCH, + ("m", "ft"): METERS_TO_FEET, + ("kW", "HP"): KW_TO_HP, + ("min", "s"): 60, + ("hr", "s"): 3600, + ("hr", "min"): 60, + } + + _si_prefixes: ClassVar[dict[str, float]] = { + "n": 1e-9, + "μ": 1e-6, + "m": 1e-3, + "c": 1e-2, + "d": 1e-1, + "": 1.0, + "k": 1e3, + } + + _length_units: ClassVar[set[str]] = {"m", "in", "ft", "yd", "mi"} + _time_units: ClassVar[set[str]] = {"s", "min", "hr"} + _time_squared_units: ClassVar[set[str]] = {"s²", "min²", "hr²"} + + def __init__(self): + self.unitmap: dict[tuple[str, str], float] = {} + self._value_split_re = re.compile(r"^([\d\.\-eE]+)\s*(\S*)$") + self._build_unit_map() + + def _build_unit_map(self): + # Build SI length conversions + for p1, f1 in self._si_prefixes.items(): + for p2, f2 in self._si_prefixes.items(): + if p1 != p2: + self.unitmap[(f"{p1}m", f"{p2}m")] = f1 / f2 + + # Build cross-system length conversions + for (si_unit, imp_unit), factor in self._base_conversions.items(): + if si_unit == "m": # Length + for p, f in self._si_prefixes.items(): + self.unitmap[(f"{p}m", imp_unit)] = f * factor + self.unitmap[(imp_unit, f"{p}m")] = 1 / (f * factor) + + # Build time conversions + for (t1, t2), factor in self._base_conversions.items(): + if t1 in self._time_units and t2 in self._time_units: + self.unitmap[(t1, t2)] = factor + self.unitmap[(t2, t1)] = 1 / factor + + # Build time squared conversions for acceleration + for (t1, t2), factor in self._base_conversions.items(): + if t1 in self._time_units and t2 in self._time_units: + # Square the factor for time squared units + squared_factor = factor * factor + t1_squared = f"{t1}²" + t2_squared = f"{t2}²" + self.unitmap[(t1_squared, t2_squared)] = squared_factor + self.unitmap[(t2_squared, t1_squared)] = 1 / squared_factor + + # Add identity conversions + all_units = {k[0] for k in self.unitmap} | {k[1] for k in self.unitmap} + for unit in all_units: + self.unitmap[(unit, unit)] = 1.0 + + def _suffix_split(self, unit: str) -> tuple[str, str | None]: + if "/" in unit: + base, suffix = unit.split("/", 1) + return base, suffix + return unit, None + + def normalize_unit_symbol(self, unit: str) -> str: + """ + Normalizes a unit symbol string by looking up synonyms. + e.g., "inch" -> "in", "mm/second" -> "mm/s" + """ + base_unit, suffix = self._suffix_split(unit) + normalized_base = self._symbols.get(base_unit.lower(), base_unit) + + if suffix: + normalized_suffix = self._symbols.get(suffix.lower(), suffix) + return f"{normalized_base}/{normalized_suffix}" + return normalized_base + + def parse_value(self, value_str: str) -> tuple[float, str | None]: + if not isinstance(value_str, str): + return value_str, None + match = self._value_split_re.match(value_str.strip()) + if not match: + raise ValueError(f"Could not parse value: '{value_str}'") + value, unit_str = match.groups() + unit = self.normalize_unit_symbol(unit_str) if unit_str else None + return float(value), unit + + def convert( + self, value: float, from_unit: str, to_unit: str + ) -> tuple[float, str]: + if from_unit == to_unit: + return value, to_unit + + from_norm = self.normalize_unit_symbol(from_unit) + to_norm = self.normalize_unit_symbol(to_unit) + + from_base, from_suffix = self._suffix_split(from_norm) + to_base, to_suffix = self._suffix_split(to_norm) + + # Handle time conversion for compound units like speed and acceleration + time_factor = 1.0 + if from_suffix != to_suffix: + if from_suffix and to_suffix: + time_factor = self.unitmap.get((from_suffix, to_suffix)) + if time_factor is None: + msg = ( + f"Incompatible suffixes: '{from_suffix}' " + "and '{to_suffix}'" + ) + raise ValueError(msg) + else: + msg = ( + f"Incompatible suffixes: '{from_suffix}' and '{to_suffix}'" + ) + raise ValueError(msg) + + length_factor = self.unitmap.get((from_base, to_base)) + if length_factor is None: + raise ValueError( + f"Unsupported conversion from '{from_base}' to '{to_base}'" + ) + + return value * length_factor / time_factor, to_norm + + +# Singleton instance for global use +engine = ConversionEngine() diff --git a/rayforge/shared/units/formatter.py b/rayforge/shared/units/formatter.py new file mode 100644 index 000000000..139381bf4 --- /dev/null +++ b/rayforge/shared/units/formatter.py @@ -0,0 +1,62 @@ +from typing import TYPE_CHECKING, Optional + +from ...context import get_context +from .definitions import ( + get_base_unit_for_quantity, + get_unit, +) + +if TYPE_CHECKING: + from .definitions import Unit + + +def format_value(value_in_base: float, quantity: str) -> str: + """ + Formats a value from its base unit into a user-friendly string + with the user's preferred display unit. + """ + config = get_context().config + base_unit = get_base_unit_for_quantity(quantity) + pref_unit_name = config.unit_preferences.get( + quantity, base_unit.name if base_unit else "" + ) + display_unit = get_unit(pref_unit_name) + + if not display_unit: + # Fallback if something is misconfigured + return f"{value_in_base:.0f}" + + display_value = display_unit.from_base(value_in_base) + return f"{display_value:.{display_unit.precision}f} {display_unit.label}" + + +def get_preferred_unit(quantity: str) -> Optional["Unit"]: + """ + Returns the user's preferred display unit for a quantity, falling back + to the quantity's base unit when no preference is set. + """ + config = get_context().config + base_unit = get_base_unit_for_quantity(quantity) + pref_unit_name = config.unit_preferences.get( + quantity, base_unit.name if base_unit else "" + ) + return get_unit(pref_unit_name) or base_unit + + +def get_preferred_unit_factor(quantity: str) -> float: + """ + Returns the number of base units (mm) in one preferred display unit. + """ + unit = get_preferred_unit(quantity) + if unit is None: + return 1.0 + return unit.to_base(1.0) + + +def get_default_grid_step_mm() -> float: + """ + Returns a sensible fixed grid spacing (in mm) for the user's preferred + length unit: one unit, except for mm which keeps the classic 10mm step. + """ + factor = get_preferred_unit_factor("length") + return 10.0 if factor <= 1.0 else factor diff --git a/rayforge/shared/units/system.py b/rayforge/shared/units/system.py new file mode 100644 index 000000000..42a2d1a6b --- /dev/null +++ b/rayforge/shared/units/system.py @@ -0,0 +1,29 @@ +from enum import Enum + +from .engine import MM_PER_INCH + + +class UnitSystem(Enum): + """ + The unit system a machine operates in. + + All internal values in Rayforge are stored in millimeters + (the application base unit). ``UnitSystem`` describes the unit + system of the *machine* — the system used to communicate with + the device and to emit G-code. Conversion to and from the base + unit happens only at driver/encoder boundaries. + """ + + METRIC = "metric" + IMPERIAL = "imperial" + + @property + def scale_from_mm(self) -> float: + """Multiplier to convert a millimeter value into this unit + system. Metric is ``1.0``; imperial is ``1/25.4``.""" + return 1.0 if self is UnitSystem.METRIC else 1.0 / 25.4 + + +def inches_to_mm(value: float) -> float: + """Convert a value in inches to millimeters.""" + return value * MM_PER_INCH diff --git a/rayforge/shared/util/__init__.py b/rayforge/shared/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/util/cache.py b/rayforge/shared/util/cache.py similarity index 87% rename from rayforge/util/cache.py rename to rayforge/shared/util/cache.py index 416bf9c0b..458bd8337 100644 --- a/rayforge/util/cache.py +++ b/rayforge/shared/util/cache.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from functools import lru_cache, wraps -from typing import Callable, Any +from typing import Any def lru_cache_unless_forced(maxsize: int = 128): @@ -7,13 +8,14 @@ def lru_cache_unless_forced(maxsize: int = 128): Extends functools.lru_cache by a "force" argument that allows to force a cache update. """ + def decorator(func: Callable) -> Callable: cached_func = lru_cache(maxsize=maxsize)(func) @wraps(func) def wrapper(*args, **kwargs) -> Any: # Check if 'force' is in kwargs and is True - force = kwargs.pop('force', False) + force = kwargs.pop("force", False) if force: # If force is True, bypass the cache and call the original # function diff --git a/rayforge/shared/util/debug.py b/rayforge/shared/util/debug.py new file mode 100644 index 000000000..1d187a639 --- /dev/null +++ b/rayforge/shared/util/debug.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import inspect +import logging + + +def get_caller_stack(depth: int = 4) -> str: + """ + Return a compact call stack string showing module:function names. + + Args: + depth: Number of caller frames to include (skips this function + and its immediate caller). + + Returns: + A string like "module:func <- module2:func2 <- ..." + """ + frames = [] + frame = inspect.currentframe() + try: + for _ in range(2): + if frame is None: + break + frame = frame.f_back + for _ in range(depth): + if frame is None: + break + name = frame.f_globals.get("__name__", "?") + func = frame.f_code.co_name + frames.append(f"{name.split('.')[-1]}:{func}") + frame = frame.f_back + finally: + del frame + return " <- ".join(frames) if frames else "?" + + +def safe_caller_stack(depth: int = 4) -> str | None: + """ + Get caller stack only if DEBUG-level logging is enabled. + + This avoids the overhead of stack inspection when not debugging. + Always returns None if the root logger's level is higher than DEBUG. + + Args: + depth: Number of caller frames to include. + + Returns: + Stack string if debugging, None otherwise. + """ + if logging.getLogger().getEffectiveLevel() <= logging.DEBUG: + return get_caller_stack(depth) + return None diff --git a/rayforge/shared/util/glib.py b/rayforge/shared/util/glib.py new file mode 100644 index 000000000..8408fe494 --- /dev/null +++ b/rayforge/shared/util/glib.py @@ -0,0 +1,50 @@ +from collections.abc import Callable +from typing import Any + +from gi.repository import GLib + + +def falsify(func, *args, **kwargs): + """ + Wrapper for GLib.idle_add, as function must return False, otherwise it + is automatically rescheduled into the event loop. + """ + func(*args, **kwargs) + return False + + +def idle_add(func, *args, **kwargs): + """ + Wrapper for GLib.idle_add to support multiple args and kwargs. + """ + GLib.idle_add(lambda: falsify(func, *args, **kwargs)) + + +class DebounceMixin: + """A mixin to add debouncing capabilities to a class.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._debounce_timer = 0 + self._debounced_callback: Callable | None = None + self._debounced_args: tuple = () + + def _debounce(self, callback: Callable, *args: Any): + """ + Schedules a callback to be called after a short delay, cancelling any + previously scheduled callback. + """ + if self._debounce_timer > 0: + GLib.source_remove(self._debounce_timer) + self._debounced_callback = callback + self._debounced_args = args + self._debounce_timer = GLib.timeout_add( + 150, self._commit_debounced_change + ) + + def _commit_debounced_change(self) -> bool: + """Executes the debounced callback and resets the timer.""" + if self._debounced_callback: + self._debounced_callback(*self._debounced_args) + self._debounce_timer = 0 + return GLib.SOURCE_REMOVE diff --git a/rayforge/shared/util/localized.py b/rayforge/shared/util/localized.py new file mode 100644 index 000000000..93c14aae2 --- /dev/null +++ b/rayforge/shared/util/localized.py @@ -0,0 +1,327 @@ +""" +Localized field support for multilingual content. + +This module provides the LocalizedField class which extends str to provide +transparent localization of text content. +""" + +import gettext +import locale +import logging +import os +from pathlib import Path + +logger = logging.getLogger(__name__) + +SUPPORTED_LANGUAGES = ["en", "de", "es", "fr", "pt", "uk", "zh_CN"] + +LocalizedString = str | dict[str, str] + + +def _get_context_language() -> str | None: + """ + Get the current language from the application context. + + This is a private helper that isolates the context dependency + to this module only. + + Returns: + Language code or None if context not initialized + """ + from ...context import get_context + + try: + ctx = get_context() + return ctx.language + except RuntimeError: + return None + + +def normalize_language_code(code: str) -> str | None: + """ + Normalize a language code to our supported format. + + Args: + code: Input language code (e.g., 'de', 'de-DE', 'zh-CN') + + Returns: + Normalized code or None if not supported + """ + if not code: + return None + + normalized = code.replace("-", "_") + + if normalized in SUPPORTED_LANGUAGES: + return normalized + + base = normalized.split("_")[0] + if base in SUPPORTED_LANGUAGES: + return base + + return None + + +def get_system_language() -> str: + """ + Detect the system's preferred language. + + Returns: + Language code (e.g., 'de', 'zh_CN') or 'en' as fallback + """ + # Try LC_ALL, LC_CTYPE, LANG environment variables first + for env_var in ("LC_ALL", "LC_CTYPE", "LANG"): + env_lang = os.environ.get(env_var) + if env_lang: + # Extract language part (e.g., "de_DE.UTF-8" -> "de_DE") + lang = env_lang.split(".")[0] + normalized = normalize_language_code(lang) + if normalized: + return normalized + + # Fallback to locale module + try: + lang = locale.getlocale()[0] + if lang: + normalized = normalize_language_code(lang) + if normalized: + return normalized + except (ValueError, TypeError): + pass + + return "en" + + +class LocalizedField(str): + """ + A string subclass that can have different values for different languages. + + This class extends str, so it behaves exactly like a string in all + contexts (concatenation, formatting, comparison, etc.). The string + value is automatically resolved to the current language from context. + + Example: + >>> name = LocalizedField.from_yaml({"default": "Wood", "de": "Holz"}) + >>> # With context language = "de": + >>> str(name) + 'Holz' + >>> name.upper() + 'HOLZ' + >>> f"Material: {name}" + 'Material: Holz' + + Attributes: + translations: Dictionary mapping language codes to translations + """ + + __slots__ = ("_default", "_translations") + + def __new__(cls, default: str, translations: dict[str, str] | None = None): + """ + Create a new LocalizedField. + + The string value is resolved from context language at creation time. + + Args: + default: The default value used when no translation is available + translations: Dictionary mapping language codes to translations + """ + language = _get_context_language() + translations = translations or {} + value = translations.get(language, default) if language else default + + instance = super().__new__(cls, value) + instance._default = default + instance._translations = translations + return instance + + @property + def default(self) -> str: + """Get the default value.""" + return self._default + + @property + def translations(self) -> dict[str, str]: + """Get all translations.""" + return self._translations.copy() + + @classmethod + def from_yaml(cls, value: LocalizedString) -> "LocalizedField": + """ + Create a LocalizedField from YAML data. + + Handles both simple strings and localized objects. + + Args: + value: Either a simple string or a dict with 'default' and lang + keys + + Returns: + A new LocalizedField instance + """ + if isinstance(value, str): + return cls(default=value) + + if isinstance(value, dict): + default = value.get("default", "") + translations = {k: v for k, v in value.items() if k != "default"} + return cls(default=default, translations=translations) + + return cls(default=str(value)) + + def to_yaml(self) -> LocalizedString: + """ + Convert to YAML-compatible format. + + Returns: + Simple string if no translations, otherwise dict format + """ + if not self._translations: + return self._default + return {"default": self._default, **self._translations} + + def get(self, language: str | None = None) -> str: + """ + Get the value for a specific language. + + Args: + language: Language code, or None to use context language + + Returns: + The localized string or default if not available + """ + if language is None: + language = _get_context_language() + + if language is None: + return self._default + + return self._translations.get(language, self._default) + + def get_all_values(self) -> dict[str, str]: + """ + Get all available translations including default. + + Returns: + Dictionary with 'default' key and all language codes + """ + result = {"default": self._default} + result.update(self._translations) + return result + + def matches(self, query: str) -> bool: + """ + Check if any translation contains the query string. + + Args: + query: Search string (case-insensitive) + + Returns: + True if query is found in default or any translation + """ + query = query.lower() + if query in self._default.lower(): + return True + for translation in self._translations.values(): + if query in translation.lower(): + return True + return False + + def __repr__(self) -> str: + """Represent the field showing its structure.""" + if not self._translations: + return f"LocalizedField({self._default!r})" + return f"LocalizedField({self._default!r}, {self._translations!r})" + + +class _AddonDomainChain: + """Single installed gettext patch that delegates to addon domains. + + Earlier implementations chained one closure per addon domain, each + capturing the previous ``gettext.gettext`` as its fallback. That + design produced RecursionError in some test-suite orderings when + the chain could no longer terminate cleanly. + + This version installs exactly one patch function - bound to a + singleton instance - and looks up every loaded addon translator + from a single list. There is no recursion path. + """ + + def __init__(self) -> None: + self._translators: list[gettext.NullTranslations] = [] + self._original = None + + def register(self, translator: "gettext.NullTranslations") -> None: + """Add a translator to the fallback chain (deduplicated).""" + if translator not in self._translators: + self._translators.append(translator) + + def install(self) -> None: + """Ensure ``gettext.gettext`` is our patch. + + Re-installs if a third party (e.g. a test fixture) has restored + ``gettext.gettext`` to its pre-patch value, so the addon + translators are always consulted. + """ + if gettext.gettext is self._translate: + return + if self._original is None: + self._original = gettext.gettext + gettext.gettext = self._translate + + def _translate(self, msg: str) -> str: + if self._original is None: + return msg + result = self._original(msg) + if result != msg: + return result + for translator in self._translators: + try: + addon_result = translator.gettext(msg) + except Exception: + logger.debug( + "Translator %s failed for %r", + translator, + msg, + exc_info=True, + ) + continue + # An empty translation usually means the .mo file was + # compiled with empty msgstr entries (untranslated). Treat + # those as "no translation" and fall back to the original + # message rather than rendering blank UI text (issue #315). + if addon_result and addon_result != msg: + return addon_result + return result + + +_chain = _AddonDomainChain() + + +def register_addon_domain(domain: str, locale_dir: Path) -> None: + """Merge an addon's gettext domain into the global gettext lookup. + + Patches ``gettext.gettext`` so that all modules using + ``from gettext import gettext as _`` can translate strings from + both the main ``rayforge`` domain and the given addon domain. + + Args: + domain: The addon's gettext domain (e.g. ``"laser_essentials"``). + locale_dir: Path to the addon's ``locale/`` directory. + """ + addon_translator = gettext.translation( + domain, localedir=str(locale_dir), fallback=True + ) + # ``gettext.translation`` returns a bare NullTranslations when no + # .mo file matches the system locale (e.g. ``LANG=C`` or unset). + # That silently drops every addon translation. Fall back to English + # so the addon's strings are still resolved from its ``en`` catalog. + if type(addon_translator) is gettext.NullTranslations: + addon_translator = gettext.translation( + domain, + localedir=str(locale_dir), + languages=["en"], + fallback=True, + ) + _chain.register(addon_translator) + _chain.install() diff --git a/rayforge/shared/util/once.py b/rayforge/shared/util/once.py new file mode 100644 index 000000000..e36df2cfa --- /dev/null +++ b/rayforge/shared/util/once.py @@ -0,0 +1,18 @@ +import functools + + +def once_per_object(func): + seen = set() + + @functools.wraps(func) + def wrapper(obj, *args, **kwargs): + if isinstance(obj, str): + key = obj + else: + key = id(obj) + if key in seen: + return + seen.add(key) + return func(obj, *args, **kwargs) + + return wrapper diff --git a/rayforge/shared/util/po_compiler.py b/rayforge/shared/util/po_compiler.py new file mode 100644 index 000000000..6f4724c8b --- /dev/null +++ b/rayforge/shared/util/po_compiler.py @@ -0,0 +1,226 @@ +""" +Pure Python .po to .mo compiler. + +This module provides a cross-platform way to compile gettext .po files +to .mo files without requiring the external msgfmt utility. +""" + +import struct +from pathlib import Path + + +def parse_po_file(po_path: Path) -> list[tuple[str, str]]: + """ + Parse a .po file and return list of (msgid, msgstr) tuples. + + Args: + po_path: Path to the .po file. + + Returns: + List of (msgid, msgstr) tuples, including the header entry. + """ + entries = [] + msgid_lines = [] + msgstr_lines = [] + in_msgid = False + in_msgstr = False + + with open(po_path, "r", encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + + if line.startswith("msgid "): + # Save previous entry if we have one + if msgid_lines and msgstr_lines: + msgid = _join_po_lines(msgid_lines) + msgstr = _join_po_lines(msgstr_lines) + entries.append((msgid, msgstr)) + + msgid_lines = [line[6:]] # Remove "msgid " + msgstr_lines = [] + in_msgid = True + in_msgstr = False + + elif line.startswith("msgstr "): + in_msgid = False + in_msgstr = True + msgstr_lines = [line[7:]] # Remove "msgstr " + + elif line.startswith('"') and in_msgid: + msgid_lines.append(line) + + elif line.startswith('"') and in_msgstr: + msgstr_lines.append(line) + + elif line.startswith("#") or not line: + # Comment or blank line - finalize current entry + if in_msgstr and msgid_lines and msgstr_lines: + msgid = _join_po_lines(msgid_lines) + msgstr = _join_po_lines(msgstr_lines) + entries.append((msgid, msgstr)) + msgid_lines = [] + msgstr_lines = [] + in_msgid = False + in_msgstr = False + + # Handle last entry if file doesn't end with blank line + if msgid_lines and msgstr_lines: + msgid = _join_po_lines(msgid_lines) + msgstr = _join_po_lines(msgstr_lines) + entries.append((msgid, msgstr)) + + return entries + + +def _join_po_lines(lines: list[str]) -> str: + """ + Join quoted .po file lines into a single string. + + Handles multi-line strings like: + "This is a " + "multi-line string" + """ + return "".join(s.strip('"') for s in lines).replace("\\n", "\n") + + +def write_mo_file(mo_path: Path, entries: list[tuple[str, str]]) -> None: + """ + Write entries to a .mo file. + + Args: + mo_path: Path to write the .mo file. + entries: List of (msgid, msgstr) tuples. + """ + if not entries: + raise ValueError("No entries to write") + + # Ensure parent directories exist + mo_path.parent.mkdir(parents=True, exist_ok=True) + + # Sort entries by msgid for binary search + entries = sorted(entries, key=lambda x: x[0]) + + # Calculate sizes and offsets + num_entries = len(entries) + + # Build string data + orig_strings = [] + trans_strings = [] + orig_data = bytearray() + trans_data = bytearray() + + for msgid, msgstr in entries: + orig_bytes = msgid.encode("utf-8") + trans_bytes = msgstr.encode("utf-8") + + orig_strings.append((len(orig_bytes), len(orig_data))) + trans_strings.append((len(trans_bytes), len(trans_data))) + + orig_data.extend(orig_bytes) + orig_data.append(0) # null terminator + trans_data.extend(trans_bytes) + trans_data.append(0) # null terminator + + # Calculate offsets + # Header: 7 * 4 bytes (magic, revision, num, orig_off, + # trans_off, hash_size, hash_off) + header_size = 7 * 4 + orig_table_size = num_entries * 8 # Each entry: length (4) + offset (4) + trans_table_size = num_entries * 8 + + orig_table_offset = header_size + trans_table_offset = orig_table_offset + orig_table_size + hash_table_offset = trans_table_offset + trans_table_size + orig_strings_offset = hash_table_offset # No hash table + trans_strings_offset = orig_strings_offset + len(orig_data) + + with open(mo_path, "wb") as f: + # Magic number (little endian format) + f.write(struct.pack(" bool: + """ + Check if a .mo file needs to be compiled from its .po file. + + Args: + po_path: Path to the source .po file. + mo_path: Path to the existing .mo file. + + Returns: + True if compilation is needed (mo file missing or outdated). + """ + if not mo_path.exists(): + return True + if not po_path.exists(): + return False + return po_path.stat().st_mtime > mo_path.stat().st_mtime + + +def compile_po_to_mo(po_path: Path, mo_path: Path) -> bool: + """ + Compile a .po file to a .mo file. + + Entries with an empty msgstr (i.e. untranslated strings) are skipped, + matching the behavior of GNU ``msgfmt``. Including them would make + ``gettext`` resolve the message to an empty string instead of falling + back to the msgid, which breaks UI text (see issue #315). + + Args: + po_path: Path to the source .po file. + mo_path: Path to write the destination .mo file. + + Returns: + True if compilation succeeded, False otherwise. + """ + try: + entries = parse_po_file(po_path) + if not entries: + return False + translated = [ + (msgid, msgstr) + for msgid, msgstr in entries + if msgstr or msgid == "" + ] + if not translated: + return False + write_mo_file(mo_path, translated) + return True + except (OSError, ValueError, UnicodeError): + return False diff --git a/rayforge/shared/util/profile.py b/rayforge/shared/util/profile.py new file mode 100644 index 000000000..928630cba --- /dev/null +++ b/rayforge/shared/util/profile.py @@ -0,0 +1,26 @@ +import contextlib +import os +import time +from collections.abc import Iterator + + +@contextlib.contextmanager +def profile_if_enabled(name: str, generation_id: int) -> Iterator[None]: + profile_dir = os.environ.get("RAYFORGE_PROFILE_DIR") + if not profile_dir: + yield + return + + import cProfile + + profiler = cProfile.Profile() + profiler.enable() + try: + yield + finally: + profiler.disable() + timestamp = time.time_ns() + filename = f"{name}_{generation_id}_{timestamp}.prof" + filepath = os.path.join(profile_dir, filename) + os.makedirs(profile_dir, exist_ok=True) + profiler.dump_stats(filepath) diff --git a/rayforge/shared/util/size.py b/rayforge/shared/util/size.py new file mode 100644 index 000000000..cd6cbba82 --- /dev/null +++ b/rayforge/shared/util/size.py @@ -0,0 +1,36 @@ +""" +Size comparison utilities for pipeline artifacts. +""" + +import math +from gettext import gettext as _ + + +def sizes_are_close( + size1: tuple[float, float] | None, + size2: tuple[float, float] | None, +) -> bool: + """Compares two size tuples with a safe tolerance for float errors.""" + if size1 is None or size2 is None: + return False + return math.isclose(size1[0], size2[0], abs_tol=1e-6) and math.isclose( + size1[1], size2[1], abs_tol=1e-6 + ) + + +def format_byte_size(size_bytes: int) -> str: + """ + Format a byte size as a human-readable string. + + Args: + size_bytes: The size in bytes. + + Returns: + A formatted string like "512 B", "1.5 KB", or "2.3 MB". + """ + if size_bytes < 1024: + return _("{size} B").format(size=size_bytes) + elif size_bytes < 1024 * 1024: + return _("{size:.1f} KB").format(size=size_bytes / 1024) + else: + return _("{size:.1f} MB").format(size=size_bytes / (1024 * 1024)) diff --git a/rayforge/shared/util/template.py b/rayforge/shared/util/template.py new file mode 100644 index 000000000..930a15fbf --- /dev/null +++ b/rayforge/shared/util/template.py @@ -0,0 +1,117 @@ +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ...machine.models.machine import Machine + from ...machine.models.macro import Macro + from ...pipeline.encoder.context import GcodeContext + + +class TemplateFormatter: + """ + Expands a macro by processing variable placeholders (e.g., {obj.attr}) + and @include(Macro Name) directives. + """ + + def __init__(self, machine: "Machine", context_obj: "GcodeContext"): + """ + Initializes the formatter. + + Args: + machine: The machine object, needed to look up macros by name. + context_obj: The object against which variable paths will be + resolved. + """ + self._machine = machine + self._context = context_obj + + def _resolve_variable(self, path: str) -> str: + """Resolves a dot-notation path like 'machine.axis_extents[0]'.""" + try: + current = self._context + parts = re.split(r"\.|(\[\d+\])", path) + clean_parts = [p for p in parts if p] + + for part in clean_parts: + if part.startswith("[") and part.endswith("]"): + index = int(part[1:-1]) + current = current[index] # type: ignore + else: + current = getattr(current, part) + + if isinstance(current, float): + return repr(current) + return str(current) + except (AttributeError, TypeError, IndexError): + return f"{{{path}}}" + + def format_string(self, template_string: str) -> str: + """Formats a single line by replacing all variable placeholders.""" + return re.sub( + r"\{(.+?)\}", + lambda m: self._resolve_variable(m.group(1)), + template_string, + ) + + def expand_macro(self, macro: "Macro") -> list[str]: + """ + Public entry point to fully expand a macro. + + Args: + macro: The top-level macro (e.g., from a hook) to expand. + + Returns: + A list of fully expanded G-code lines. + """ + # The call_stack tracks macro names to prevent infinite recursion. + return self._recursive_expand(macro, call_stack=set()) + + def _recursive_expand( + self, macro: "Macro", call_stack: set[str] + ) -> list[str]: + """ + Recursively expands a macro, processing includes and formatting + variables. + """ + output_lines: list[str] = [] + + if macro.name in call_stack: + error_msg = ( + f"; ERROR: Circular dependency detected. Macro " + f"'{macro.name}' was included again." + ) + return [error_msg] + + call_stack.add(macro.name) + + for line in macro.code: + match = re.match(r"^\s*@include\((.*?)\)\s*$", line) + if match: + macro_name = match.group(1).strip() + found_macro = next( + ( + m + for m in self._machine.macros.values() + if m.name == macro_name + ), + None, + ) + + if found_macro and found_macro.enabled: + # Recurse: expand the included macro + expanded_lines = self._recursive_expand( + found_macro, call_stack + ) + output_lines.extend(expanded_lines) + else: + output_lines.append( + f"; WARNING: Macro '{macro_name}' " + f" not found or disabled." + ) + else: + # This is a normal G-code line, format it + output_lines.append(self.format_string(line)) + + # Backtrack: remove the macro from the stack after processing + call_stack.remove(macro.name) + return output_lines diff --git a/rayforge/shared/util/time_format.py b/rayforge/shared/util/time_format.py new file mode 100644 index 000000000..696fc2829 --- /dev/null +++ b/rayforge/shared/util/time_format.py @@ -0,0 +1,53 @@ +"""Utility functions for formatting time values.""" + +from gettext import gettext as _ + + +def format_hours_to_hm(hours: float) -> str: + """ + Format a fractional hours value to hours and minutes string. + + Args: + hours: Fractional hours value (e.g., 10.5 for 10h 30m). + + Returns: + Formatted string like "10h 30m", "10h", or "30m". + Zero values are omitted (e.g., 0.5h -> "30m", 10h -> "10h"). + """ + h = int(hours) + m = int((hours - h) * 60) + if h > 0 and m > 0: + return f"{h}h {m}m" + if h > 0: + return f"{h}h" + return f"{m}m" + + +def format_seconds(seconds: float, compact: bool = False) -> str: + """ + Format a number of seconds into a human-readable time string. + + Args: + seconds: Positive number of seconds. + compact: If True, omit spaces and zero-valued components + (e.g. "3s", "2m5s", "1h30m" vs "3s", "2m 5s", "1h 30m"). + + Returns: + Formatted time string. + """ + sep = "" if compact else " " + + if seconds < 60: + return _("{:.0f}s").format(seconds) + elif seconds < 3600: + minutes = int(seconds // 60) + secs = int(seconds % 60) + if compact and secs == 0: + return _("{}m").format(minutes) + return _("{}m" + sep + "{}s").format(minutes, secs) + else: + hours = int(seconds // 3600) + mins = int((seconds % 3600) // 60) + if compact and mins == 0: + return _("{}h").format(hours) + return _("{}h" + sep + "{}m").format(hours, mins) diff --git a/rayforge/shared/util/versioning.py b/rayforge/shared/util/versioning.py new file mode 100644 index 000000000..0474db36b --- /dev/null +++ b/rayforge/shared/util/versioning.py @@ -0,0 +1,246 @@ +import importlib +import logging +import re +from pathlib import Path + +import semver + +logger = logging.getLogger(__name__) + + +class _UnknownVersion: + """ + Sentinel class representing an unknown or undetermined version. + + Used for Git repositories where version cannot be determined. + When displayed, built-in addons should fall back to + rayforge.__version__; external addons should show no version. + """ + + __slots__ = () + + def __repr__(self) -> str: + return "UnknownVersion" + + +UnknownVersion = _UnknownVersion() + + +def get_git_tag_version(path: Path) -> str: + """ + Gets the version from git tags in the directory. + + Args: + path (Path): The directory containing the Git repository. + + Returns: + str: The version from the latest git tag. + + Raises: + RuntimeError: If no git tags are found or git is unavailable. + """ + try: + importlib.import_module("git") + except ImportError: + raise RuntimeError("GitPython is required to get git tag version") + + from git import Repo + + try: + repo = Repo(path) + tags = repo.tags + if tags: + latest_tag = max(tags, key=lambda t: t.commit.committed_datetime) + return latest_tag.name + raise RuntimeError(f"No git tags found in {path}") + except Exception as e: # noqa: BLE001 - normalize to RuntimeError + raise RuntimeError(f"Failed to get git tag version from {path}: {e}") + + +_GIT_DESCRIBE_RE = re.compile(r"-\d+-g[0-9a-f]+$") +_PEP440_POST_RE = re.compile(r"\.post\d+") +_PEP440_LOCAL_RE = re.compile(r"\+git\.[0-9a-f]+$") +_PEP440_PRE_RE = re.compile(r"b(\d+)") + + +def _strip_git_describe(version_str: str) -> str: + s = _GIT_DESCRIBE_RE.sub("", version_str) + s = _PEP440_LOCAL_RE.sub("", s) + s = _PEP440_POST_RE.sub("", s) + s = _PEP440_PRE_RE.sub(r"-b.\1", s) + return s.lstrip("v") + + +def is_newer_version(remote_str: str, local_str: str) -> bool: + """Compares two version strings using semver.""" + try: + remote_v = semver.VersionInfo.parse(_strip_git_describe(remote_str)) + local_v = semver.VersionInfo.parse(_strip_git_describe(local_str)) + return remote_v > local_v + except ValueError: + logger.warning( + f"Could not parse versions '{remote_str}' or '{local_str}' " + "with semver. Falling back to string comparison." + ) + return remote_str != local_str + + +def parse_requirement(req: str) -> tuple[str, str | None]: + """ + Parse a requirement string into name and version constraint. + + Args: + req: Requirement string like "rayforge>=0.27.0" or "laser-essentials" + + Returns: + Tuple of (name, constraint_or_none). + Constraint includes operator, e.g. ">=1.0.0". + + Examples: + "laser-essentials" -> ("laser-essentials", None) + "laser-essentials>=1.0.0" -> ("laser-essentials", ">=1.0.0") + "rayforge >= 0.27.0" -> ("rayforge", ">=0.27.0") + """ + req = req.strip() + match = re.match( + r"^([a-zA-Z0-9_-]+)\s*((?:>=|<=|==|!=|>|<|=|~|\^).*)?$", req + ) + if match: + name = match.group(1) + constraint = match.group(2).strip() if match.group(2) else None + return name, constraint + return req, None + + +def parse_version_constraint(constraint: str) -> tuple[str, str] | None: + """ + Parse a version constraint string into operator and version. + + Args: + constraint: Constraint string like ">=1.0.0" or "^0.27" + + Returns: + Tuple of (operator, version_string) or None if invalid. + """ + op_match = re.match(r"^((?:>=|<=|==|!=|>|<|=|~|\^))(.+)$", constraint) + if not op_match: + return None + op = op_match.group(1) + version = op_match.group(2).lstrip("v") + if not version: + return (op, "") + return (op, version) + + +def normalize_tilde_version(version_str: str) -> str: + """ + Normalize partial versions for tilde operator. + + Args: + version_str: Version string, possibly partial (e.g., "0.27") + + Returns: + Normalized full semver string (e.g., "0.27.0") + """ + version_parts = version_str.split(".") + if len(version_parts) == 2: + return f"{version_parts[0]}.{version_parts[1]}.0" + elif len(version_parts) == 1: + return f"{version_parts[0]}.0.0" + return version_str + + +def check_constraint(current_v, req_v, op: str) -> bool: + """ + Check if current version satisfies a constraint. + + Args: + current_v: Current semver VersionInfo + req_v: Required semver VersionInfo + op: Operator string (>=, >, <=, <, ==, !=, ^, ~) + + Returns: + True if constraint is satisfied, False otherwise. + """ + if op == ">=": + return current_v >= req_v + elif op == ">": + return current_v > req_v + elif op == "<=": + return current_v <= req_v + elif op == "<": + return current_v < req_v + elif op == "==": + return current_v == req_v + elif op == "!=": + return current_v != req_v + elif op == "^": + return current_v.major == req_v.major and current_v >= req_v + elif op == "~": + return ( + current_v.major == req_v.major + and current_v.minor == req_v.minor + and current_v >= req_v + ) + return False + + +def check_rayforge_compatibility( + depends: list[str], current_version: str +) -> bool: + """ + Check if rayforge version satisfies all rayforge dependencies. + + Args: + depends: List of dependency strings + current_version: Current rayforge version string + + Returns: + True if compatible, False otherwise. + """ + try: + parsed_v = semver.VersionInfo.parse(current_version.lstrip("v")) + current_v = semver.VersionInfo( + major=parsed_v.major, + minor=parsed_v.minor, + patch=parsed_v.patch, + ) + except ValueError: + return True + + for dep in depends: + parts = dep.split(",") + first_part = parts[0].strip() + + pkg_name, constraint = parse_requirement(first_part) + if pkg_name != "rayforge": + continue + + constraints = [constraint] if constraint else [] + for extra in parts[1:]: + extra = extra.strip() + if extra: + constraints.append(extra) + + for constraint_str in constraints: + if not constraint_str: + continue + + parsed = parse_version_constraint(constraint_str) + if not parsed: + return False + + op, req_v_str = parsed + + if op == "~": + req_v_str = normalize_tilde_version(req_v_str) + + try: + req_v = semver.VersionInfo.parse(req_v_str) + except ValueError: + return False + + if not check_constraint(current_v, req_v, op): + return False + + return True diff --git a/rayforge/simulator/__init__.py b/rayforge/simulator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/simulator/machine_state.py b/rayforge/simulator/machine_state.py new file mode 100644 index 000000000..9c29a598f --- /dev/null +++ b/rayforge/simulator/machine_state.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from raygeo.ops import Ops +from raygeo.ops.axis import Axis +from raygeo.ops.state import AirAssistMode +from raygeo.ops.types import CommandCategory, CommandType + +if TYPE_CHECKING: + from ..machine.models.axis import AxisSet + + +class MachineState: + def __init__( + self, + axis_letters: Iterable[Axis] | None = None, + ): + self.power: float = 0.0 + self.air_assist: bool = False + self.cut_speed: int | None = None + self.travel_speed: int | None = None + self.active_laser_uid: str | None = None + self.frequency: int | None = None + self.pulse_width: float | None = None + if axis_letters is not None: + self.axes: dict[Axis, float] = {a: 0.0 for a in axis_letters} + else: + self.axes = { + Axis.X: 0.0, + Axis.Y: 0.0, + Axis.Z: 0.0, + } + self.laser_on = False + self.reached_textures: set = set() + self.current_layer_uid: str | None = None + + @classmethod + def from_axis_set(cls, axis_set: AxisSet) -> MachineState: + axes = (cfg.letter for cfg in axis_set.configs) + return cls(axis_letters=axes) + + def apply_command(self, ops: Ops, idx: int): + ct = ops.command_type(idx) + cat = ops.category(idx) + + if cat == CommandCategory.STATE: + if ct == CommandType.SET_POWER: + self.power = ops.power(idx) + elif ct == CommandType.SET_FEED_RATE: + self.cut_speed = int(ops.rate(idx)) + elif ct == CommandType.SET_RAPID_RATE: + self.travel_speed = int(ops.rate(idx)) + elif ct == CommandType.SET_AIR_ASSIST: + self.air_assist = ops.air_assist(idx) == AirAssistMode.ON + elif ct == CommandType.SET_HEAD: + self.active_laser_uid = ops.head_uid(idx) + elif ct == CommandType.SET_FREQUENCY: + self.frequency = ops.frequency(idx) + elif ct == CommandType.SET_PULSE_WIDTH: + self.pulse_width = ops.pulse_width(idx) + elif cat == CommandCategory.MOVING: + end = ops.endpoint(idx) + self.axes[Axis.X] = end[0] + self.axes[Axis.Y] = end[1] + self.axes[Axis.Z] = end[2] + ea = ops.extra_axes(idx) + if ea: + for axis, value in ea.items(): + self.axes[axis] = value + if ct == CommandType.SCAN_LINE: + self.reached_textures.add(idx) + self.laser_on = ct != CommandType.MOVE_TO + elif ct == CommandType.LAYER_START: + self.current_layer_uid = ops.layer_uid(idx) + + def copy(self) -> MachineState: + new = MachineState.__new__(MachineState) + new.power = self.power + new.air_assist = self.air_assist + new.cut_speed = self.cut_speed + new.travel_speed = self.travel_speed + new.active_laser_uid = self.active_laser_uid + new.frequency = self.frequency + new.pulse_width = self.pulse_width + new.axes = dict(self.axes) + new.laser_on = self.laser_on + new.reached_textures = set(self.reached_textures) + new.current_layer_uid = self.current_layer_uid + return new diff --git a/rayforge/simulator/op_player.py b/rayforge/simulator/op_player.py new file mode 100644 index 000000000..b1d61ff26 --- /dev/null +++ b/rayforge/simulator/op_player.py @@ -0,0 +1,368 @@ +from bisect import bisect_right + +from blinker import Signal +from raygeo.ops import Ops +from raygeo.ops.axis import Axis +from raygeo.ops.types import CommandCategory, CommandType + +from ..core.doc import Doc +from ..core.layer import Layer +from ..machine.kinematic_mapping import resolve_layer_rotary +from ..machine.models.machine import Machine +from .machine_state import MachineState + +_SNAPSHOT_INTERVAL = 1000 + + +def create_home_state(machine: Machine) -> MachineState: + """Create the machine-origin (home) state for a playback session.""" + state = MachineState.from_axis_set(machine.axes) + home_x, home_y = machine.panel.machine_point_to_world(0.0, 0.0) + state.axes[Axis.X] = home_x + state.axes[Axis.Y] = home_y + return state + + +def build_snapshots( + ops: Ops, + machine: Machine, + doc: Doc, +) -> list[tuple[int, MachineState, Axis, Axis | None]]: + """Build the seek-acceleration snapshots for *ops*. + + Returns a fresh list of ``(target, state, source_axis, rotary_axis)`` + tuples spaced every ``_SNAPSHOT_INTERVAL`` commands, or an empty list + for short op lists. The returned list may be built off the main thread + and attached to an :class:`OpPlayer` via ``set_snapshots``. + """ + n = ops.len() + if n <= _SNAPSHOT_INTERVAL: + return [] + builder = SnapshotBuilder(ops, machine, doc, create_home_state(machine)) + snapshots: list[tuple[int, MachineState, Axis, Axis | None]] = [] + for target in range(_SNAPSHOT_INTERVAL, n, _SNAPSHOT_INTERVAL): + builder.advance_to(target - 1) + # reached_textures is only needed for real-time playback, + # not for seeking. Clear before snapshot to avoid copying + # a set that grows to millions of entries. + builder.state.reached_textures.clear() + snapshots.append( + ( + target, + builder.state.copy(), + builder._source_axis, + builder._rotary_axis, + ) + ) + return snapshots + + +class OpPlayer: + def __init__( + self, + ops: Ops, + machine: Machine, + doc: Doc, + build_snapshots: bool = True, + time_ops: Ops | None = None, + ): + if not ops or ops.is_empty(): + raise ValueError("OpPlayer requires a non-empty Ops") + self.ops = ops + # Time model backing: defaults to *ops* itself. Rotary-mapped + # ops keep their endpoints at a constant Y (the real rotation + # lives in extra axes), which distorts line distances and makes + # arcs degenerate, so playback passes the unmapped ops here. + # Command indices and order are identical, so the cumulative + # time index stays in sync with *ops*. + self._time_ops = time_ops if time_ops is not None else ops + self._machine = machine + self._doc = doc + self._current_index: int = -1 + self._source_axis: Axis = Axis.Y + self._rotary_axis: Axis | None = None + self._prev_layer_uid: str | None = None + self.state = self._create_home_state() + self._home_axes: dict[Axis, float] = dict(self.state.axes) + self.layer_changed = Signal() + self._snapshots: list[tuple[int, MachineState, Axis, Axis | None]] = [] + # Playback time model: feed/rapid rates in mm/min, accel in + # mm/s^2. Defaults match the raygeo cumulative-time index. + self._play_params: tuple[float, float, float] = ( + 1000.0, + 3000.0, + 1000.0, + ) + self._sim_time: float = 0.0 + self._playback: tuple[int, float] = (0, 0.0) + if build_snapshots: + self._build_snapshots() + + @property + def snapshots(self): + """The seek-acceleration snapshots (may be replaced asynchronously).""" + return self._snapshots + + def set_snapshots(self, snapshots): + """Replaces the seek-acceleration snapshots from an async build.""" + self._snapshots = snapshots + + def set_playback_params( + self, + feed_mm_min: float, + rapid_mm_min: float, + accel_mm_s2: float, + ): + """Set the machine parameters for the playback time model. + + Feed and rapid rates are in mm/min (the unit stored in the ops), + acceleration in mm/s^2. These mirror the arguments of the raygeo + cumulative-time index. + """ + self._play_params = ( + float(feed_mm_min), + float(rapid_mm_min), + float(accel_mm_s2), + ) + + def find_index_at_sim_time(self, t: float) -> int: + """Command index in effect at simulated time *t* (seconds).""" + return self._time_ops.find_index_at_time(t, *self._play_params) + + def get_cumulative_time(self, idx: int) -> float: + """Cumulative simulated time (seconds) up to command *idx*.""" + return self._time_ops.get_cumulative_time_at(idx, *self._play_params) + + def set_sim_time(self, t: float) -> None: + """Set the simulated playback time and cache the playhead progress. + + The playhead is described as the command currently being + executed plus the fraction of its duration that has elapsed. + """ + self._sim_time = float(t) + self._playback = self._compute_playback_progress(self._sim_time) + + def playback_progress(self) -> tuple[int, float]: + """Return ``(in_progress_command_index, fraction)`` for the + current simulated time. + + ``fraction`` is in ``[0, 1]``; it is 0 while the playhead sits + exactly on a command boundary and 1.0 once everything is done. + """ + return self._playback + + def _compute_playback_progress(self, t: float) -> tuple[int, float]: + n = self.ops.len() + if n == 0: + return (0, 0.0) + idx = self.find_index_at_sim_time(t) + t_end = self.get_cumulative_time(idx) + if t < t_end: + # The clamped index has not completed yet (t < cum[0]). + span = self.get_cumulative_time(0) + if span <= 0.0: + return (0, 0.0) + return (0, max(0.0, min(1.0, t / span))) + if idx + 1 >= n: + return (idx, 1.0) + span = self.get_cumulative_time(idx + 1) - t_end + if span <= 0.0: + return (idx + 1, 0.0) + frac = max(0.0, min(1.0, (t - t_end) / span)) + return (idx + 1, frac) + + def render_state(self) -> MachineState: + """State for rendering, with axes interpolated within the + in-progress command during playback. + + ``laser_on`` follows the in-progress command so the laser beam + fires for the whole duration of a cut (not just after the cut's + command completes). Returns the plain machine state when paused + or when the in-progress command does not move (state changes, + dwells), so stepped frames render exactly like the + non-interpolated path. + """ + p, frac = self._playback + if frac <= 0.0 or p >= self.ops.len(): + return self.state + if self.ops.category(p) != CommandCategory.MOVING: + return self.state + end = self.ops.endpoint(p) + if p == 0: + # Nothing has completed yet: the head starts at home. + start_axes = self._home_axes + else: + start_axes = self.state.axes + axes = dict(start_axes) + for axis, value in ( + (Axis.X, end[0]), + (Axis.Y, end[1]), + (Axis.Z, end[2]), + ): + axes[axis] = start_axes.get(axis, 0.0) + frac * ( + value - start_axes.get(axis, 0.0) + ) + ea = self.ops.extra_axes(p) + if ea: + for axis, value in ea.items(): + axes[axis] = start_axes.get(axis, 0.0) + frac * ( + value - start_axes.get(axis, 0.0) + ) + st = MachineState(axis_letters=axes.keys()) + st.axes = axes + st.laser_on = self.ops.command_type(p) != CommandType.MOVE_TO + return st + + def _build_snapshots(self): + self._snapshots = build_snapshots(self.ops, self._machine, self._doc) + + @property + def current_index(self) -> int: + return self._current_index + + @property + def source_axis(self) -> Axis: + return self._source_axis + + @property + def rotary_axis(self) -> Axis | None: + return self._rotary_axis + + def _create_home_state(self) -> MachineState: + return create_home_state(self._machine) + + def _update_rotary_config(self, layer_uid: str) -> None: + item = self._doc.find_descendant_by_uid(layer_uid) + layer = item if isinstance(item, Layer) else None + cfg = resolve_layer_rotary(layer, self._machine) + self._source_axis = cfg.source_axis + self._rotary_axis = cfg.rotary_axis + + def seek(self, index: int): + if index >= self.ops.len(): + raise IndexError( + f"Index {index} out of range " + f"(ops has {self.ops.len()} commands)" + ) + index = max(index, 0) + + snapshot_idx = self._find_snapshot(index) + if snapshot_idx is not None: + snap_index, snap_state, snap_source, snap_rotary = self._snapshots[ + snapshot_idx + ] + self.state = snap_state.copy() + self._current_index = snap_index - 1 + self._source_axis = snap_source + self._rotary_axis = snap_rotary + else: + self.state = self._create_home_state() + self._current_index = -1 + self._source_axis = Axis.Y + self._rotary_axis = None + + self.advance_to(index) + self._emit_layer_change() + + def _find_snapshot(self, index: int) -> int | None: + if not self._snapshots: + return None + positions = [s[0] for s in self._snapshots] + pos = bisect_right(positions, index) + if pos == 0: + return None + return pos - 1 + + def advance_to(self, index: int): + if index < self._current_index: + raise ValueError( + f"Cannot advance backwards: current=" + f"{self._current_index}, requested={index}. " + f"Use seek() instead." + ) + if index >= self.ops.len(): + raise IndexError( + f"Index {index} out of range " + f"(ops has {self.ops.len()} commands)" + ) + for i in range(self._current_index + 1, index + 1): + ct = self.ops.command_type(i) + if ct == CommandType.LAYER_START: + self._update_rotary_config(self.ops.layer_uid(i)) + self.state.apply_command(self.ops, i) + self._current_index = index + + def seek_last_movement(self) -> int | None: + last = None + for i in range(self.ops.len()): + if self.ops.category(i) == CommandCategory.MOVING: + last = i + if last is not None: + self.seek(last) + return last + + def seek_to_fraction(self, fraction: float): + target = int(self.ops.len() * fraction) + target = max(0, min(target, self.ops.len() - 1)) + self.seek(target) + + def seek_to_first_layer(self): + for i in range(self.ops.len()): + if self.ops.command_type(i) == CommandType.LAYER_START: + self.seek(i) + return i + return 0 + + def get_current_layer(self, doc: Doc) -> Layer | None: + uid = self.state.current_layer_uid + if uid: + item = doc.find_descendant_by_uid(uid) + if isinstance(item, Layer): + return item + return None + + def get_effective_layer(self, doc: Doc) -> Layer | None: + """Return the layer that should drive playback configuration. + + Falls back to the first layer of the document while the player is + in the preamble (before the first LAYER_START command). + """ + layer = self.get_current_layer(doc) + if layer is None and doc.layers: + layer = doc.layers[0] + return layer + + def _emit_layer_change(self): + uid = self.state.current_layer_uid + if uid != self._prev_layer_uid: + self._prev_layer_uid = uid + self.layer_changed.send(self, layer_uid=uid) + + +class SnapshotBuilder: + def __init__( + self, + ops: Ops, + machine: Machine, + doc: Doc, + initial_state: MachineState, + ): + self.ops = ops + self._machine = machine + self._doc = doc + self._current_index: int = -1 + self._source_axis: Axis = Axis.Y + self._rotary_axis: Axis | None = None + self.state = initial_state + + def advance_to(self, index: int): + for i in range(self._current_index + 1, index + 1): + ct = self.ops.command_type(i) + if ct == CommandType.LAYER_START: + item = self._doc.find_descendant_by_uid(self.ops.layer_uid(i)) + layer = item if isinstance(item, Layer) else None + cfg = resolve_layer_rotary(layer, self._machine) + self._source_axis = cfg.source_axis + self._rotary_axis = cfg.rotary_axis + self.state.apply_command(self.ops, i) + self._current_index = index diff --git a/rayforge/simulator/scene3d/__init__.py b/rayforge/simulator/scene3d/__init__.py new file mode 100644 index 000000000..416205364 --- /dev/null +++ b/rayforge/simulator/scene3d/__init__.py @@ -0,0 +1,22 @@ +from .compiled_scene import ( + CompiledSceneArtifact, + CompiledSceneArtifactHandle, + ScanlineOverlayLayer, + TextureLayer, + VertexLayer, +) +from .render_config import LayerRenderConfig, RenderConfig3D +from .scene_compiler import compile_scene +from .scene_compiler_runner import compile_scene_from_job + +__all__ = [ + "CompiledSceneArtifact", + "CompiledSceneArtifactHandle", + "LayerRenderConfig", + "RenderConfig3D", + "ScanlineOverlayLayer", + "TextureLayer", + "VertexLayer", + "compile_scene", + "compile_scene_from_job", +] diff --git a/rayforge/simulator/scene3d/compiled_scene.py b/rayforge/simulator/scene3d/compiled_scene.py new file mode 100644 index 000000000..43716f20e --- /dev/null +++ b/rayforge/simulator/scene3d/compiled_scene.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ...pipeline.artifact.base import BaseArtifact +from ...pipeline.artifact.handle import BaseArtifactHandle + +if TYPE_CHECKING: + from raygeo.compressed_array import CompressedArray + + +@dataclass +class VertexLayer: + powered_verts: CompressedArray + powered_attrib: CompressedArray + travel_verts: CompressedArray + zero_power_verts: CompressedArray + powered_cmd_offsets: np.ndarray = field( + default_factory=lambda: np.array([], dtype=np.int32) + ) + travel_cmd_offsets: np.ndarray = field( + default_factory=lambda: np.array([], dtype=np.int32) + ) + is_rotary: bool = False + + +@dataclass +class TextureLayer: + power_texture: CompressedArray + width_px: int + height_px: int + model_matrix: np.ndarray + cylinder_vertices: np.ndarray | None = None + rotary_diameter: float = 0.0 + rotary_enabled: bool = False + activation_cmd_idx: int = -1 + laser_uid: str = "" + + +@dataclass +class ScanlineOverlayLayer: + positions: CompressedArray + overlay_attrib: CompressedArray + cmd_offsets: np.ndarray + is_rotary: bool = False + + +class CompiledSceneArtifactHandle(BaseArtifactHandle): + def __init__( + self, + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + super().__init__( + key=key, + handle_class_name=handle_class_name, + artifact_type_name=artifact_type_name, + generation_id=generation_id, + array_metadata=array_metadata, + ) + + +class CompiledSceneArtifact(BaseArtifact): + def __init__( + self, + generation_id: int, + vertex_layers: list[VertexLayer], + texture_layers: list[TextureLayer], + overlay_layers: list[ScanlineOverlayLayer], + laser_uid_order: list[str] | None = None, + ): + self.generation_id = generation_id + self.vertex_layers = vertex_layers + self.texture_layers = texture_layers + self.overlay_layers = overlay_layers + self.laser_uid_order = laser_uid_order or [] + + def build_handle(self, key: str) -> CompiledSceneArtifactHandle: + return CompiledSceneArtifactHandle( + key=key, + handle_class_name=CompiledSceneArtifactHandle.__name__, + artifact_type_name=self.__class__.__name__, + generation_id=self.generation_id, + ) diff --git a/rayforge/simulator/scene3d/cylinder_compiler.py b/rayforge/simulator/scene3d/cylinder_compiler.py new file mode 100644 index 000000000..3fba11535 --- /dev/null +++ b/rayforge/simulator/scene3d/cylinder_compiler.py @@ -0,0 +1,109 @@ +""" +Pure-numpy cylinder mesh generation for texture mapping. + +Generates vertex arrays that map a texture onto a cylinder surface +by transforming [0,1] texture coordinates through a grid matrix into +local cylinder space, then wrapping into Z/Y planes. + +The cylinder always runs along X in the output vertex data. +""" + +import numpy as np + +GRID_S = 8 +GRID_T = 64 + + +def generate_cylinder_vertices( + grid_matrix: np.ndarray, + diameter: float, + grid_s: int = GRID_S, + grid_t: int = GRID_T, +) -> np.ndarray: + radius = diameter / 2.0 + + i_vals = np.arange(grid_s, dtype=np.float32) + j_vals = np.arange(grid_t, dtype=np.float32) + lx0 = i_vals / grid_s + lx1 = (i_vals + 1) / grid_s + ly0 = j_vals / grid_t + ly1 = (j_vals + 1) / grid_t + + lx_grid, ly_grid = np.meshgrid(lx0, ly1, indexing="ij") + lx1_grid, _ = np.meshgrid(lx1, ly1, indexing="ij") + _, ly0_grid = np.meshgrid(lx0, ly0, indexing="ij") + + def _transform_points(lx, ly): + shape = lx.shape + ones = np.ones(shape, dtype=np.float32) + pts = np.stack( + [ + lx.ravel(), + ly.ravel(), + np.zeros(lx.size, dtype=np.float32), + ones.ravel(), + ], + axis=-1, + ) + p_cyl = pts @ grid_matrix.T + theta = np.radians(p_cyl[:, 1]) + col_cyl = p_cyl[:, 0].reshape(shape) + col_sin = (radius * np.sin(theta)).reshape(shape) + col_cos = (radius * np.cos(theta)).reshape(shape) + return col_cyl, col_sin, col_cos + + x00, y00, z00 = _transform_points(lx_grid, ly0_grid) + x10, y10, z10 = _transform_points(lx1_grid, ly0_grid) + x01, y01, z01 = _transform_points(lx_grid, ly_grid) + x11, y11, z11 = _transform_points(lx1_grid, ly_grid) + + s0 = lx_grid + s1 = lx1_grid + t0 = 1.0 - ly0_grid + t1 = 1.0 - ly_grid + + s0_f = s0.flatten() + s1_f = s1.flatten() + t0_f = t0.flatten() + t1_f = t1.flatten() + x00_f, y00_f, z00_f = x00.flatten(), y00.flatten(), z00.flatten() + x10_f, y10_f, z10_f = x10.flatten(), y10.flatten(), z10.flatten() + x01_f, y01_f, z01_f = x01.flatten(), y01.flatten(), z01.flatten() + x11_f, y11_f, z11_f = x11.flatten(), y11.flatten(), z11.flatten() + + n = s0_f.size + vertices = np.empty(n * 30, dtype=np.float32) + + vertices[0::30] = x00_f + vertices[1::30] = y00_f + vertices[2::30] = z00_f + vertices[3::30] = s0_f + vertices[4::30] = t0_f + vertices[5::30] = x10_f + vertices[6::30] = y10_f + vertices[7::30] = z10_f + vertices[8::30] = s1_f + vertices[9::30] = t0_f + vertices[10::30] = x01_f + vertices[11::30] = y01_f + vertices[12::30] = z01_f + vertices[13::30] = s0_f + vertices[14::30] = t1_f + + vertices[15::30] = x10_f + vertices[16::30] = y10_f + vertices[17::30] = z10_f + vertices[18::30] = s1_f + vertices[19::30] = t0_f + vertices[20::30] = x11_f + vertices[21::30] = y11_f + vertices[22::30] = z11_f + vertices[23::30] = s1_f + vertices[24::30] = t1_f + vertices[25::30] = x01_f + vertices[26::30] = y01_f + vertices[27::30] = z01_f + vertices[28::30] = s0_f + vertices[29::30] = t1_f + + return vertices diff --git a/rayforge/simulator/scene3d/render_config.py b/rayforge/simulator/scene3d/render_config.py new file mode 100644 index 000000000..b386d5d32 --- /dev/null +++ b/rayforge/simulator/scene3d/render_config.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + + +@dataclass +class LayerRenderConfig: + rotary_enabled: bool + rotary_diameter: float + axis_position: float = 0.0 + reverse: bool = False + axis_position_3d: tuple[float, ...] | None = None + cylinder_dir: tuple[float, ...] | None = None + + def to_dict(self) -> dict: + d = { + "rotary_enabled": self.rotary_enabled, + "rotary_diameter": self.rotary_diameter, + "axis_position": self.axis_position, + "reverse": self.reverse, + } + if self.axis_position_3d is not None: + d["axis_position_3d"] = list(self.axis_position_3d) + if self.cylinder_dir is not None: + d["cylinder_dir"] = list(self.cylinder_dir) + return d + + @classmethod + def from_dict(cls, data: dict) -> LayerRenderConfig: + ap3d = data.get("axis_position_3d") + if ap3d is not None: + ap3d = tuple(ap3d) + cdir = data.get("cylinder_dir") + if cdir is not None: + cdir = tuple(cdir) + return cls( + rotary_enabled=data["rotary_enabled"], + rotary_diameter=data["rotary_diameter"], + axis_position=data.get("axis_position", 0.0), + reverse=data.get("reverse", False), + axis_position_3d=ap3d, + cylinder_dir=cdir, + ) + + +@dataclass +class RenderConfig3D: + world_to_visual: np.ndarray + world_to_cyl_local: np.ndarray + layer_configs: dict[str, LayerRenderConfig] | None = None + laser_dot_widths_mm: dict[str, float] | None = None + + def to_dict(self) -> dict: + d: dict[str, object] = { + "world_to_visual": self.world_to_visual.astype( + np.float32 + ).tobytes(), + "world_to_cyl_local": self.world_to_cyl_local.astype( + np.float32 + ).tobytes(), + } + if self.layer_configs: + d["layer_configs"] = { + k: v.to_dict() for k, v in self.layer_configs.items() + } + if self.laser_dot_widths_mm: + d["laser_dot_widths_mm"] = dict(self.laser_dot_widths_mm) + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RenderConfig3D: + w2v = ( + np.frombuffer(data["world_to_visual"], dtype=np.float32) + .reshape(4, 4) + .copy() + ) + w2c = ( + np.frombuffer(data["world_to_cyl_local"], dtype=np.float32) + .reshape(4, 4) + .copy() + ) + layer_configs = None + if "layer_configs" in data: + layer_configs = { + k: LayerRenderConfig.from_dict(v) + for k, v in data["layer_configs"].items() + } + return cls( + world_to_visual=w2v, + world_to_cyl_local=w2c, + layer_configs=layer_configs, + laser_dot_widths_mm=data.get("laser_dot_widths_mm"), + ) diff --git a/rayforge/simulator/scene3d/scene_compiler.py b/rayforge/simulator/scene3d/scene_compiler.py new file mode 100644 index 000000000..6f0ad54f8 --- /dev/null +++ b/rayforge/simulator/scene3d/scene_compiler.py @@ -0,0 +1,231 @@ +""" +Scene compiler: thin wrapper that delegates vertex compilation to +raygeo's Rust ``compile_scene_3d`` and handles texture generation. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +import numpy as np +from raygeo.compressed_array import CompressedArray +from raygeo.image import rasterize_scanlines +from raygeo.ops import LayerInfo, Ops + +from .compiled_scene import ( + CompiledSceneArtifact, + ScanlineOverlayLayer, + TextureLayer, + VertexLayer, +) +from .cylinder_compiler import generate_cylinder_vertices +from .render_config import RenderConfig3D + +logger = logging.getLogger(__name__) + +MAX_TEXTURE_DIMENSION = 8192 +PX_PER_MM = 50.0 + +# Fallback laser dot width (mm) when no laser head info is available, +# matching the minimum sane spot size used elsewhere in the codebase. +DEFAULT_DOT_WIDTH_MM = 0.1 + + +# ── Texture generation ───────────────────────── + + +def _rasterize_scanlines( + ops: Ops, + bbox: tuple[float, float, float, float], + dot_width_mm: float, +) -> tuple[CompressedArray, int, int, float] | None: + x0, y0, w_mm, h_mm = bbox + if w_mm <= 0 or h_mm <= 0: + return None + + px_per_mm = PX_PER_MM + width_px = round(w_mm * px_per_mm) + height_px = round(h_mm * px_per_mm) + + if width_px > MAX_TEXTURE_DIMENSION or height_px > MAX_TEXTURE_DIMENSION: + scale = min( + MAX_TEXTURE_DIMENSION / width_px, + MAX_TEXTURE_DIMENSION / height_px, + ) + px_per_mm *= scale + width_px = round(w_mm * px_per_mm) + height_px = round(h_mm * px_per_mm) + + if width_px <= 0 or height_px <= 0: + return None + + dot_width_px = dot_width_mm * px_per_mm + radius_px = max(0, int((dot_width_px - 1) / 2)) + + buffer = rasterize_scanlines( + ops, + width_px, + height_px, + (px_per_mm, px_per_mm), + origin_mm=(x0, y0), + radius_px=radius_px, + ) + if not isinstance(buffer, CompressedArray): + return None + + return buffer, width_px, height_px, px_per_mm + + +def _generate_texture_layers( + ops: Ops, + layer_infos: list[LayerInfo], + config: RenderConfig3D, +) -> list[TextureLayer]: + texture_layers: list[TextureLayer] = [] + dot_widths = config.laser_dot_widths_mm or {} + + for li in layer_infos: + if not li.has_scanlines: + continue + + layer_ops = ops.extract_range(li.cmd_start, li.cmd_end) + + is_rot = li.is_rotary + + if is_rot: + layer_ops = layer_ops.bake_visual_positions() + + bbox = layer_ops.scanline_bbox() + if bbox is None: + continue + + dot_width_mm = dot_widths.get(li.scanline_laser) + if dot_width_mm is None: + dot_width_mm = DEFAULT_DOT_WIDTH_MM + raster_result = _rasterize_scanlines(layer_ops, bbox, dot_width_mm) + if raster_result is None: + continue + + tex_buf, w_px, h_px, _actual_ppm = raster_result + x0, y0, bw, bh = bbox + + diameter = li.diameter + + if is_rot and diameter > 0: + tex_transform = np.eye(4, dtype=np.float32) + else: + tex_transform = config.world_to_visual + + model = np.eye(4, dtype=np.float32) + model[0, 0] = bw + model[1, 1] = bh + model[0, 3] = x0 + model[1, 3] = y0 + final_model = (tex_transform @ model).astype(np.float32) + + cyl_verts = None + if is_rot and diameter > 0: + cyl_verts = generate_cylinder_vertices( + grid_matrix=final_model, + diameter=diameter, + ) + + texture_layers.append( + TextureLayer( + power_texture=tex_buf, + width_px=w_px, + height_px=h_px, + model_matrix=final_model, + cylinder_vertices=cyl_verts, + rotary_diameter=diameter, + rotary_enabled=is_rot, + activation_cmd_idx=li.activation_cmd_idx, + laser_uid=li.scanline_laser, + ) + ) + + return texture_layers + + +# ── Spec building ───────────────────────────────────────────────── + + +def _build_scene_spec(config: RenderConfig3D) -> tuple[list, dict]: + w2v = config.world_to_visual.astype(np.float32).tolist() + layer_configs = {} + if config.layer_configs: + for uid, lc in config.layer_configs.items(): + layer_configs[uid] = { + "rotary_enabled": lc.rotary_enabled, + "rotary_diameter": lc.rotary_diameter, + "axis_position": lc.axis_position, + "reverse": lc.reverse, + } + return w2v, layer_configs + + +# ── Output wrapping ────────────────────────────────────────────── + + +def _wrap_compiled_scene( + raw, + ops: Ops, + config: RenderConfig3D, + generation_id: int = 0, +) -> CompiledSceneArtifact: + vertex_layers: list[VertexLayer] = [] + overlay_layers: list[ScanlineOverlayLayer] = [] + + for g in raw.groups: + vertex_layers.append( + VertexLayer( + powered_verts=g.powered_verts, + powered_attrib=g.powered_attrib, + travel_verts=g.travel_verts, + zero_power_verts=g.zero_power_verts, + powered_cmd_offsets=g.powered_cmd_offsets, + travel_cmd_offsets=g.travel_cmd_offsets, + is_rotary=g.is_rotary, + ) + ) + overlay_layers.append( + ScanlineOverlayLayer( + positions=g.overlay_positions, + overlay_attrib=g.overlay_attrib, + cmd_offsets=g.overlay_cmd_offsets, + is_rotary=g.is_rotary, + ) + ) + + layer_infos = raw.layer_infos + texture_layers = _generate_texture_layers(ops, layer_infos, config) + + return CompiledSceneArtifact( + generation_id=generation_id, + vertex_layers=vertex_layers, + texture_layers=texture_layers, + overlay_layers=overlay_layers, + laser_uid_order=raw.laser_uid_order, + ) + + +# ── Public API ─────────────────────────────────────────────────── + + +def compile_scene( + ops: Ops, + config: RenderConfig3D, + cancel_check: Callable[[], bool] | None = None, + generation_id: int = 0, +) -> CompiledSceneArtifact: + if cancel_check is not None and cancel_check(): + raise RuntimeError("Cancelled") + + w2v, layer_configs = _build_scene_spec(config) + raw = ops.compile_scene_3d(w2v, layer_configs) + artifact = _wrap_compiled_scene( + raw, ops, config, generation_id=generation_id + ) + + return artifact diff --git a/rayforge/simulator/scene3d/scene_compiler_runner.py b/rayforge/simulator/scene3d/scene_compiler_runner.py new file mode 100644 index 000000000..456279b84 --- /dev/null +++ b/rayforge/simulator/scene3d/scene_compiler_runner.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +import time +from typing import Any + +from ...pipeline.artifact.handle import create_handle_from_dict +from ...pipeline.artifact.job import JobArtifact +from ...pipeline.artifact.store import ArtifactStore +from .compiled_scene import CompiledSceneArtifact +from .render_config import RenderConfig3D +from .scene_compiler import compile_scene + +logger = logging.getLogger(__name__) + + +def compile_scene_from_job( + artifact_store: ArtifactStore, + job_handle_dict: dict[str, Any], + render_config_dict: dict, +) -> CompiledSceneArtifact | None: + """Compile a 3D scene from a job artifact. + + Runs synchronously on the calling thread; the caller owns threading. + The compiled artifact is returned directly, avoiding pickling of + raygeo ``Ops`` objects through multiprocessing queues. + """ + config = RenderConfig3D.from_dict(render_config_dict) + + try: + handle = create_handle_from_dict(job_handle_dict) + artifact = artifact_store.get(handle) + except (ValueError, TypeError, RuntimeError) as e: + logger.warning(f"Job artifact no longer available. Aborting: {e}") + return None + + if not isinstance(artifact, JobArtifact): + logger.error(f"Expected JobArtifact, got {type(artifact).__name__}.") + return None + + ops = artifact.preview_ops + if ops is None or ops.is_empty(): + logger.debug("Job artifact ops are empty.") + return None + + t_start = time.perf_counter() + compiled = compile_scene(ops, config, generation_id=artifact.generation_id) + elapsed = (time.perf_counter() - t_start) * 1000 + logger.debug(f"Compilation took {elapsed:.1f}ms (commands={len(ops)})") + return compiled diff --git a/rayforge/transport/__init__.py b/rayforge/transport/__init__.py deleted file mode 100644 index b69f755fe..000000000 --- a/rayforge/transport/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# flake8: noqa:F401 -from .transport import TransportStatus -from .http import HttpTransport -from .serial import SerialTransport -from .websocket import WebSocketTransport diff --git a/rayforge/transport/http.py b/rayforge/transport/http.py deleted file mode 100644 index 69bf688c8..000000000 --- a/rayforge/transport/http.py +++ /dev/null @@ -1,103 +0,0 @@ -import asyncio -from typing import Optional -import aiohttp -from .transport import Transport, TransportStatus - - -class HttpTransport(Transport): - """ - HTTP transport using persistent connection with auto-reconnect. - """ - - def __init__(self, base_url: str, receive_interval: int = None): - """ - Initialize HTTP transport. - - Args: - base_url: Server endpoint URL (schema://host:port) - """ - super().__init__() - self.base_url = base_url - self._running = False - self._reconnect_interval = 5 - self._receive_interval = receive_interval - self._connection_task: Optional[asyncio.Task] = None - - async def connect(self) -> None: - """ - Maintain persistent connection with reconnect logic. - """ - self._running = True - self._connection_task = asyncio.create_task(self._connection_loop()) - - async def _connection_loop(self) -> None: - while self._running: - try: - self.status_changed.send( - self, - status=TransportStatus.CONNECTING - ) - async with aiohttp.ClientSession() as session: - await self._receive_loop(session) - except Exception as e: - self.status_changed.send( - self, - status=TransportStatus.ERROR, - message=str(e) - ) - finally: - self.status_changed.send( - self, - status=TransportStatus.DISCONNECTED - ) - - if self._running: - self.status_changed.send( - self, - status=TransportStatus.SLEEPING - ) - await asyncio.sleep(self._reconnect_interval) - self.status_changed.send( - self, - status=TransportStatus.DISCONNECTED - ) - - async def disconnect(self) -> None: - """ - Terminate connection and cancel background tasks. - """ - self._running = False - if self._connection_task: - self._connection_task.cancel() - - async def send(self, data: bytes) -> None: - """ - Send data to HTTP endpoint via POST request. - """ - async with aiohttp.ClientSession() as session: - async with session.post( - f"{self.base_url}", - data=data, - timeout=aiohttp.ClientTimeout(total=5), - ) as response: - if response.status != 200: - err = f"Send failed: {await response.text()}" - self.status_changed.send(self, message=err) - raise IOError(err) - - async def _receive_loop(self, session) -> None: - """ - Listen for server-sent events from streaming endpoint. - """ - while self._running: - async with session.get( - f"{self.base_url}", - timeout=aiohttp.ClientTimeout(total=30), - ) as response: - if response.status == 200: - data = await response.read() - if data: - self.received.send(self, data=data) - - if self._running and self._receive_interval: - await asyncio.sleep(self._receive_interval) diff --git a/rayforge/transport/serial.py b/rayforge/transport/serial.py deleted file mode 100644 index 460810d0d..000000000 --- a/rayforge/transport/serial.py +++ /dev/null @@ -1,74 +0,0 @@ -import asyncio -import serial_asyncio -from typing import Optional -from .transport import Transport, TransportStatus - - -class SerialTransport(Transport): - """ - Asynchronous serial port transport. - """ - - def __init__(self, port: str, baudrate: int): - """ - Initialize serial transport. - - Args: - port: Device path (e.g., '/dev/ttyUSB0') - baudrate: Communication speed in bits per second - """ - super().__init__() - self.port = port - self.baudrate = baudrate - self._reader: Optional[asyncio.StreamReader] = None - self._writer: Optional[asyncio.StreamWriter] = None - self._running = False - - async def connect(self) -> None: - """ - Open serial connection and start reading. - """ - self.status_changed.send(self, status=TransportStatus.CONNECTING) - result = await serial_asyncio.open_serial_connection( - url=self.port, baudrate=self.baudrate - ) - self._reader, self._writer = result - self._running = True - self.status_changed.send(self, status=TransportStatus.CONNECTED) - asyncio.create_task(self._receive_loop()) - self.status_changed.send(self, status=TransportStatus.IDLE) - - async def disconnect(self) -> None: - """ - Close serial connection. - """ - self.status_changed.send(self, status=TransportStatus.CLOSING) - self._running = False - if self._writer: - self._writer.close() - await self._writer.wait_closed() - self.status_changed.send(self, status=TransportStatus.DISCONNECTED) - - async def send(self, data: bytes) -> None: - """ - Write data to serial port. - """ - if not self._writer: - raise ConnectionError("Serial port not open") - self._writer.write(data) - await self._writer.drain() - - async def _receive_loop(self) -> None: - """ - Continuous data reception loop. - """ - while self._running and self._reader: - try: - data = await self._reader.read(100) - if data: - self.received.send(self, data=data) - except Exception as e: - self.status_changed.send(self, - status=TransportStatus.ERROR, - message=str(e)) - break diff --git a/rayforge/transport/transport.py b/rayforge/transport/transport.py deleted file mode 100644 index c80f226d7..000000000 --- a/rayforge/transport/transport.py +++ /dev/null @@ -1,55 +0,0 @@ -from abc import ABC, abstractmethod -from enum import Enum, auto -from blinker import Signal - - -class TransportStatus(Enum): - UNKNOWN = auto() - IDLE = auto() - CONNECTING = auto() - CONNECTED = auto() - ERROR = auto() - CLOSING = auto() - DISCONNECTED = auto() - SLEEPING = auto() - - -class Transport(ABC): - """ - Abstract base class for asynchronous data transports. - """ - - def __init__(self): - """ - Initialize transport with callbacks and notification handler. - - Signals: - received: Function to handle received data - status_changed: Function to handle connection status changes - """ - self.received = Signal() - self.status_changed = Signal() - - @abstractmethod - async def connect(self) -> None: - """ - Establish connection and start data flow. - """ - pass - - @abstractmethod - async def disconnect(self) -> None: - """ - Gracefully terminate connection and cleanup resources. - """ - pass - - @abstractmethod - async def send(self, data: bytes) -> None: - """ - Send binary data through the transport. - - Raises: - ConnectionError: If transport is not connected - """ - pass diff --git a/rayforge/transport/websocket.py b/rayforge/transport/websocket.py deleted file mode 100644 index 9d44f45b6..000000000 --- a/rayforge/transport/websocket.py +++ /dev/null @@ -1,148 +0,0 @@ -import asyncio -import websockets -from typing import Optional -from websockets.exceptions import ConnectionClosed -from .transport import Transport, TransportStatus - - -class WebSocketTransport(Transport): - """ - WebSocket transport with robust state management. - """ - - def __init__(self, uri: str, origin=None): - super().__init__() - self.uri = uri - self._websocket: Optional[websockets.WebSocketClientProtocol] = None - self._origin = origin - self._running = False - self._reconnect_interval = 5 - self._lock = asyncio.Lock() - self._receive_task: Optional[asyncio.Task] = None - - async def connect(self) -> None: - """ - Establish connection with proper state validation. - """ - async with self._lock: - if self._running: - return - self._running = True - - while self._running: - try: - self.status_changed.send( - self, - status=TransportStatus.CONNECTING - ) - self._websocket = await websockets.connect( - self.uri, - origin=self._origin, - additional_headers=( - ('Connection', 'Upgrade'), - ('Upgrade', 'websocket'), - ) - ) - self.status_changed.send( - self, - status=TransportStatus.CONNECTED - ) - self._receive_task = asyncio.create_task(self._receive_loop()) - await self._receive_task - self.status_changed.send(self, status=TransportStatus.IDLE) - except (asyncio.CancelledError, ConnectionClosed): - pass - except Exception as e: - self.status_changed.send( - self, - status=TransportStatus.ERROR, - message=str(e) - ) - finally: - await self._safe_close() - if self._running: - self.status_changed.send( - self, - status=TransportStatus.SLEEPING - ) - await asyncio.sleep(self._reconnect_interval) - - async def disconnect(self) -> None: - """ - Terminate connection immediately. - """ - self.status_changed.send( - self, - status=TransportStatus.CLOSING - ) - async with self._lock: - if not self._running: - return - self._running = False - if self._receive_task: - self._receive_task.cancel() - await self._safe_close() - self.status_changed.send( - self, - status=TransportStatus.DISCONNECTED - ) - - async def send(self, data: bytes) -> None: - """ - Send data through active connection. - """ - if self._websocket is None: - raise ConnectionError("Not connected") - try: - await self._websocket.send(self, data) - except ConnectionClosed: - await self._handle_disconnect() - - async def _receive_loop(self) -> None: - """ - Receive messages with proper state checks. - """ - try: - async for message in self._websocket: - if isinstance(message, bytes): - self.received.send(self, data=message) - except ConnectionClosed: - pass - except Exception as e: - self.status_changed.send( - self, - status=TransportStatus.ERROR, - message=str(e) - ) - - async def _safe_close(self) -> None: - """ - Safely close connection with state cleanup. - """ - if self._websocket is not None: - try: - await self._websocket.close() - except Exception as e: - self.status_changed.send( - self, - status=TransportStatus.ERROR, - message=str(e) - ) - finally: - self._websocket = None - - async def _handle_disconnect(self) -> None: - """ - Handle unexpected disconnection. - """ - self.status_changed.send( - self, - status=TransportStatus.CLOSING - ) - async with self._lock: - if self._running: - await self._safe_close() - self.status_changed.send( - self, - status=TransportStatus.DISCONNECTED - ) diff --git a/rayforge/ui_gtk/__init__.py b/rayforge/ui_gtk/__init__.py new file mode 100644 index 000000000..960657158 --- /dev/null +++ b/rayforge/ui_gtk/__init__.py @@ -0,0 +1,7 @@ +from .canvas2d.elements.dot import DotElement +from .canvas2d.surface import WorkSurface + +__all__ = [ + "DotElement", + "WorkSurface", +] diff --git a/rayforge/ui_gtk/about.py b/rayforge/ui_gtk/about.py new file mode 100644 index 000000000..584a1d0ff --- /dev/null +++ b/rayforge/ui_gtk/about.py @@ -0,0 +1,477 @@ +import hashlib +import logging +import os +import platform +import sys +import webbrowser +from gettext import gettext as _ +from importlib.metadata import PackageNotFoundError, distribution, version + +from gi.repository import Adw, GLib, Gtk + +from .. import __version__, const +from .icons import get_icon +from .shared.patched_dialog_window import PatchedDialogWindow + +logger = logging.getLogger(__name__) +_not_found_str = _("Not found") + + +def _get_version(package_name: str) -> str: + """Safely retrieves the version of a Python package.""" + try: + return version(package_name) + except PackageNotFoundError: + return _not_found_str + + +def _is_dev_build(package_name: str, module) -> bool: + """Check if a compiled module's .so file has been modified + relative to the released package (e.g. via maturin develop).""" + try: + dist = distribution(package_name) + record = dist.read_text("RECORD") + if record is None: + return True + site_dir = str(dist.locate_file("")) + for line in record.splitlines(): + parts = line.split(",") + if not parts[0].endswith(".so"): + continue + so_path = os.path.join(site_dir, parts[0]) + if not os.path.exists(so_path): + continue + if len(parts) >= 2 and "=" in parts[1]: + algo, expected_hash = parts[1].split("=", 1) + h = hashlib.new(algo) + with open(so_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + if h.hexdigest() != expected_hash: + return True + except Exception: + logger.exception(f"Error checking if {package_name} is a dev build") + return False + + +def get_dependency_info() -> dict: + """ + Gathers version information for the application's key dependencies. + This function only includes information that can be dynamically queried + at runtime. + """ + info = {} + + # System + info[_("System")] = [ + ("Python", sys.version.split(" ")[0]), + ( + "Platform", + f"{platform.system()} {platform.release()} ({platform.machine()})", + ), + ] + + # UI Toolkit + try: + import gi + + pygobject_ver = gi.__version__ + except (ImportError, AttributeError): + pygobject_ver = _not_found_str + + ui_deps = [ + ( + "GTK", + ( + f"{Gtk.get_major_version()}." + f"{Gtk.get_minor_version()}." + f"{Gtk.get_micro_version()}" + ), + ), + ( + "LibAdwaita", + ( + f"{Adw.get_major_version()}." + f"{Adw.get_minor_version()}." + f"{Adw.get_micro_version()}" + ), + ), + ] + if pygobject_ver != _not_found_str: + ui_deps.append(("PyGObject", pygobject_ver)) + info[_("UI Toolkit")] = ui_deps + + # Graphics & Imaging + graphics_deps = [] + try: + import cairo + + graphics_deps.append(("PyCairo", cairo.version)) + graphics_deps.append(("libcairo", cairo.cairo_version_string())) + except ImportError: + pass + + try: + import pyvips + + pyvips_ver = _get_version("pyvips") + graphics_deps.append(("pyvips", pyvips_ver)) + except Exception as e: # noqa: BLE001 - best-effort diagnostics + msg = f"failed to find pyvips version: {e}" + logger.warning(msg) + graphics_deps.append(("pyvips", msg)) + pyvips = None + + try: + libvips_ver = pyvips.version(0) if pyvips else None + graphics_deps.append(("libvips", libvips_ver)) + except Exception as e: # noqa: BLE001 - best-effort diagnostics + msg = f"failed to find libvips version: {e}" + logger.warning(msg) + graphics_deps.append(("libvips", msg)) + + for pkg_name, display_name in [ + ("opencv-python", "OpenCV"), + ("numpy", "NumPy"), + ("scipy", "SciPy"), + ("vtracer", "vtracer"), + ]: + ver = _get_version(pkg_name) + graphics_deps.append((display_name, ver)) + + if graphics_deps: + info[_("Graphics & Imaging")] = graphics_deps + + geo_deps = [] + try: + import raygeo + + geo_ver = _get_version("raygeo") + if _is_dev_build("raygeo", raygeo): + geo_ver += " (dev)" + geo_deps.append(("raygeo", geo_ver)) + except (ImportError, PackageNotFoundError): + geo_deps.append(("raygeo", _not_found_str)) + if geo_deps: + info[_("Geometry")] = geo_deps + + comm_deps = [] + for pkg in [ + "ezdxf", + "pypdf", + "PyYAML", + "pyserial", + "aiohttp", + "websockets", + ]: + ver = _get_version(pkg) + comm_deps.append((pkg, ver)) + + if comm_deps: + info[_("File Formats & Communication")] = comm_deps + + return {k: v for k, v in info.items() if v} + + +def get_supporters() -> list[tuple[str, str | None]]: + """ + Returns a list of supporters who donated to the app. + Each entry is a tuple of (name, optional_url). + """ + return [ + ("starlynx.dev", None), + ("Anonymous Supporter", None), + ] + + +class AboutDialog(PatchedDialogWindow): + """ + A custom 'About' dialog that uses a ViewStack to navigate between + the main page and a detailed system information page. + """ + + def __init__(self, **kwargs): + super().__init__(modal=True, **kwargs) + self.set_default_size(500, 700) + self.set_hide_on_close(True) + + self._build_ui() + + def _on_copy_info_clicked(self, button: Gtk.Button): + lines = [f"## {const.APP_NAME} {__version__ or _not_found_str}", ""] + dep_info = get_dependency_info() + for category, deps in dep_info.items(): + lines.append(f"### {category}") + for name, ver in deps: + lines.append(f"{name}: {ver}") + lines.append("") + full_text = "\n".join(lines).strip() + clipboard = self.get_display().get_clipboard() + clipboard.set(full_text) + button.set_child(get_icon("check-symbolic")) + GLib.timeout_add( + 2000, + lambda: ( + button.set_child(get_icon("copy-symbolic")) + and GLib.SOURCE_REMOVE + ), + ) + # Also give feedback on the headerbar copy button if it exists + if hasattr(self, "header_copy_button"): + self.header_copy_button.set_child(get_icon("check-symbolic")) + GLib.timeout_add( + 2000, + lambda: ( + self.header_copy_button.set_child( + get_icon("copy-symbolic") + ) + and GLib.SOURCE_REMOVE + ), + ) + + def _on_copy_version_clicked(self, button: Gtk.Button): + """Copy the version information to clipboard.""" + clipboard = self.get_display().get_clipboard() + clipboard.set(__version__ or _not_found_str) + button.set_child(get_icon("check-symbolic")) + GLib.timeout_add( + 2000, + lambda: ( + button.set_child(get_icon("copy-symbolic")) + and GLib.SOURCE_REMOVE + ), + ) + + def _build_main_page(self): + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + content_box.set_halign(Gtk.Align.FILL) + content_box.set_margin_start(24) + content_box.set_margin_end(24) + content_box.set_margin_top(12) + content_box.set_margin_bottom(24) + + hero_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + hero_box.set_vexpand(True) + hero_box.set_valign(Gtk.Align.CENTER) + hero_box.set_halign(Gtk.Align.CENTER) + content_box.append(hero_box) + + icon = get_icon("org.rayforge.rayforge") + icon.set_pixel_size(128) + hero_box.append(icon) + + title = Gtk.Label() + title.set_markup( + f"{const.APP_NAME}" + ) + title.set_margin_top(6) + hero_box.append(title) + + copyright_label = Gtk.Label(label="© 2025 Samuel Abels") + hero_box.append(copyright_label) + + links_box = Gtk.Box(halign=Gtk.Align.CENTER, margin_top=12) + links_box.add_css_class("linked") + hero_box.append(links_box) + + website_button = Gtk.Button.new_with_label(_("Website")) + website_button.connect( + "clicked", + lambda _: webbrowser.open(const.GITHUB_URL), + ) + links_box.append(website_button) + + issues_button = Gtk.Button.new_with_label(_("Report an Issue")) + issues_button.connect( + "clicked", + lambda _: webbrowser.open(const.ISSUES_URL), + ) + links_box.append(issues_button) + + donate_button = Gtk.Button.new_with_label(_("Donate")) + donate_button.connect( + "clicked", + lambda _: webbrowser.open("https://www.patreon.com/c/knipknap"), + ) + links_box.append(donate_button) + + prefgroup = Adw.PreferencesGroup() + content_box.append(prefgroup) + + # Version row with copy button + version_row = Adw.ActionRow(title=_("Version")) + version_row.set_subtitle(__version__ or _not_found_str) + + copy_button = Gtk.Button(child=get_icon("copy-symbolic")) + copy_button.set_valign(Gtk.Align.CENTER) + copy_button.add_css_class("flat") + copy_button.set_tooltip_text(_("Copy Version")) + copy_button.connect("clicked", self._on_copy_version_clicked) + version_row.add_suffix(copy_button) + + prefgroup.add(version_row) + + dev_row = Adw.ActionRow( + title=_("Lead Developer"), subtitle="Samuel Abels" + ) + prefgroup.add(dev_row) + + license_row = Adw.ActionRow(title=_("License"), subtitle="MIT X11") + license_row.set_activatable(True) + license_row.add_suffix(get_icon("open-in-new-symbolic")) + license_row.connect( + "activated", + lambda _: webbrowser.open("https://opensource.org/license/mit"), + ) + prefgroup.add(license_row) + + sys_info_row = Adw.ActionRow( + title=_("System Information"), + subtitle=_("Versions of libraries and components"), + ) + sys_info_row.set_activatable(True) + + self.inline_copy_button = Gtk.Button(child=get_icon("copy-symbolic")) + self.inline_copy_button.set_valign(Gtk.Align.CENTER) + self.inline_copy_button.add_css_class("flat") + self.inline_copy_button.set_tooltip_text(_("Copy System Information")) + self.inline_copy_button.connect("clicked", self._on_copy_info_clicked) + sys_info_row.add_suffix(self.inline_copy_button) + + sys_info_row.add_suffix(get_icon("go-next-symbolic")) + sys_info_row.connect( + "activated", + lambda w: self.view_stack.set_visible_child_name("sysinfo"), + ) + prefgroup.add(sys_info_row) + + supporters = get_supporters() + if supporters: + supporters_row = Adw.ActionRow( + title=_("Supporters"), + subtitle=_("People who donated to the project"), + ) + supporters_row.set_activatable(True) + supporters_row.add_suffix(get_icon("go-next-symbolic")) + supporters_row.connect( + "activated", + lambda w: self.view_stack.set_visible_child_name("supporters"), + ) + prefgroup.add(supporters_row) + + return content_box + + def _build_sysinfo_page(self): + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_start(24) + content_box.set_margin_end(24) + content_box.set_margin_top(12) + content_box.set_margin_bottom(24) + scrolled_window.set_child(content_box) + + dep_info = get_dependency_info() + for category, deps in dep_info.items(): + escaped_category = GLib.markup_escape_text(category) + group = Adw.PreferencesGroup() + group.set_title(escaped_category) + content_box.append(group) + + for name, ver in deps: + row = Adw.ActionRow(title=name, subtitle=str(ver)) + group.add(row) + + return scrolled_window + + def _build_supporters_page(self): + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_start(24) + content_box.set_margin_end(24) + content_box.set_margin_top(12) + content_box.set_margin_bottom(24) + scrolled_window.set_child(content_box) + + thank_you_label = Gtk.Label() + thank_you_label.set_markup( + "" + + _( + "Special thanks go to everyone who has donated to support " + "Rayforge! You keep the coffee and the AI tokens flowing!" + ) + + "" + ) + thank_you_label.set_margin_top(6) + thank_you_label.set_margin_bottom(6) + thank_you_label.set_wrap(True) + thank_you_label.set_max_width_chars(60) + content_box.append(thank_you_label) + + supporters_group = Adw.PreferencesGroup() + supporters_group.set_title(_("Supporters")) + content_box.append(supporters_group) + + for name, url in get_supporters(): + row = Adw.ActionRow(title=name) + if url: + row.set_activatable(True) + row.add_suffix(get_icon("open-in-new-symbolic")) + row.connect("activated", lambda _, u=url: webbrowser.open(u)) + supporters_group.add(row) + + return scrolled_window + + def _on_view_changed(self, stack, param): + visible_page = stack.get_visible_child_name() + is_main = visible_page == "main" + + self.back_button.set_visible(not is_main) + self.header_copy_button.set_visible(visible_page == "sysinfo") + + if visible_page == "supporters": + self.header_bar.set_title_widget(self.supporters_title) + elif visible_page == "sysinfo": + self.header_bar.set_title_widget(self.sysinfo_title) + else: + self.header_bar.set_title_widget(self.main_title) + + def _build_ui(self): + self.header_bar = Adw.HeaderBar() + self.main_title = Adw.WindowTitle( + title=_("About {app_name}").format(app_name=const.APP_NAME) + ) + self.sysinfo_title = Adw.WindowTitle(title=_("System Information")) + self.supporters_title = Adw.WindowTitle(title=_("Supporters")) + + self.back_button = Gtk.Button(child=get_icon("go-previous-symbolic")) + self.back_button.connect( + "clicked", lambda w: self.view_stack.set_visible_child_name("main") + ) + self.header_bar.pack_start(self.back_button) + + self.header_copy_button = Gtk.Button(child=get_icon("copy-symbolic")) + self.header_copy_button.set_tooltip_text(_("Copy System Information")) + self.header_copy_button.connect("clicked", self._on_copy_info_clicked) + self.header_bar.pack_end(self.header_copy_button) + + self.view_stack = Adw.ViewStack() + self.view_stack.add_named(self._build_main_page(), "main") + self.view_stack.add_named(self._build_sysinfo_page(), "sysinfo") + self.view_stack.add_named(self._build_supporters_page(), "supporters") + self.view_stack.connect( + "notify::visible-child-name", self._on_view_changed + ) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_box.append(self.header_bar) + main_box.append(self.view_stack) + self.set_content(main_box) + self._on_view_changed(self.view_stack, None) # Set initial state diff --git a/rayforge/ui_gtk/action_registry.py b/rayforge/ui_gtk/action_registry.py new file mode 100644 index 000000000..3a508d5c7 --- /dev/null +++ b/rayforge/ui_gtk/action_registry.py @@ -0,0 +1,229 @@ +import logging +from dataclasses import dataclass + +from blinker import Signal +from gi.repository import Gio, Gtk + +logger = logging.getLogger(__name__) + + +@dataclass +class MenuPlacement: + menu_id: str = "tools" + priority: int = 100 + + +@dataclass +class ToolbarPlacement: + group: str = "main" + priority: int = 100 + + +@dataclass +class ActionInfo: + action: Gio.SimpleAction + action_name: str + label: str | None = None + icon_name: str | None = None + shortcut: str | None = None + addon_name: str | None = None + menu: MenuPlacement | None = None + toolbar: ToolbarPlacement | None = None + + +class ActionRegistry: + """ + Registry for window actions with optional menu and toolbar placement. + + Tracks which actions belong to which addon so they can be + properly removed when an addon is disabled. + """ + + def __init__(self): + self._actions: dict[str, ActionInfo] = {} + self._addon_actions: dict[str, set[str]] = {} + self._window: Gtk.ApplicationWindow | None = None + self.changed = Signal() + + def set_window(self, window: Gtk.ApplicationWindow) -> None: + """Set the window to which actions will be added.""" + self._window = window + + @property + def window(self) -> Gtk.ApplicationWindow | None: + """Get the window associated with this registry.""" + return self._window + + def register( + self, + action_name: str, + action: Gio.SimpleAction, + addon_name: str | None = None, + label: str | None = None, + icon_name: str | None = None, + shortcut: str | None = None, + menu: MenuPlacement | None = None, + toolbar: ToolbarPlacement | None = None, + ) -> None: + """ + Register an action with the window and track it by addon. + + Args: + action_name: Name of the action (without 'win.' prefix). + action: The Gio.SimpleAction instance. + addon_name: Name of the addon registering this action. + label: Display label for UI (menu, toolbar). + icon_name: Icon name for toolbar items. + shortcut: Keyboard shortcut (e.g., "p"). + menu: Menu placement info if this action should appear in a menu. + toolbar: Toolbar placement info if this action should appear in + the toolbar. + """ + if self._window is None: + logger.warning("Cannot register action: window not set") + return + + if action_name in self._actions: + logger.warning( + f"Action '{action_name}' already registered, replacing" + ) + old_addon = self._actions[action_name].addon_name + if old_addon and old_addon in self._addon_actions: + self._addon_actions[old_addon].discard(action_name) + + self._window.add_action(action) + + info = ActionInfo( + action=action, + action_name=action_name, + label=label, + icon_name=icon_name, + shortcut=shortcut, + addon_name=addon_name or "", + menu=menu, + toolbar=toolbar, + ) + self._actions[action_name] = info + + if addon_name: + if addon_name not in self._addon_actions: + self._addon_actions[addon_name] = set() + self._addon_actions[addon_name].add(action_name) + + logger.debug( + f"Registered action '{action_name}' for addon '{addon_name}'" + ) + self.changed.send(self) + + def unregister(self, action_name: str) -> bool: + """ + Unregister an action from the window. + + Args: + action_name: The name of the action to unregister. + + Returns: + True if the action was unregistered, False if not found. + """ + if action_name not in self._actions: + return False + + if self._window is not None: + self._window.remove_action(action_name) + + info = self._actions[action_name] + if info.addon_name and info.addon_name in self._addon_actions: + self._addon_actions[info.addon_name].discard(action_name) + + del self._actions[action_name] + logger.debug(f"Unregistered action '{action_name}'") + self.changed.send(self) + return True + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all actions registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of actions unregistered. + """ + if addon_name not in self._addon_actions: + return 0 + + action_names = self._addon_actions.pop(addon_name) + count = 0 + for action_name in action_names: + if action_name in self._actions: + if self._window is not None: + self._window.remove_action(action_name) + del self._actions[action_name] + count += 1 + logger.debug(f"Unregistered {count} actions from addon '{addon_name}'") + if count > 0: + self.changed.send(self) + return count + + def get(self, action_name: str) -> ActionInfo | None: + """ + Get action info by name. + + Args: + action_name: The name of the action. + + Returns: + The ActionInfo object, or None if not found. + """ + return self._actions.get(action_name) + + def get_menu_items(self, menu_id: str) -> list[ActionInfo]: + """ + Get actions with menu placement for a specific menu, sorted by + priority. + + Args: + menu_id: The menu identifier (e.g., 'tools', 'arrange'). + + Returns: + List of ActionInfo objects sorted by priority. + """ + items = [ + info + for info in self._actions.values() + if info.menu and info.menu.menu_id == menu_id and info.label + ] + return sorted(items, key=lambda x: x.menu.priority if x.menu else 100) + + def get_toolbar_items(self, group: str) -> list[ActionInfo]: + """ + Get actions with toolbar placement for a specific group, sorted by + priority. + + Args: + group: The toolbar group identifier (e.g., 'main', 'arrange'). + + Returns: + List of ActionInfo objects sorted by priority. + """ + items = [ + info + for info in self._actions.values() + if info.toolbar and info.toolbar.group == group + ] + return sorted( + items, key=lambda x: x.toolbar.priority if x.toolbar else 100 + ) + + def get_all_with_shortcuts(self) -> list[ActionInfo]: + """ + Get all actions that have keyboard shortcuts defined. + + Returns: + List of ActionInfo objects with shortcuts. + """ + return [info for info in self._actions.values() if info.shortcut] + + +action_registry = ActionRegistry() diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py new file mode 100644 index 000000000..2111cdac9 --- /dev/null +++ b/rayforge/ui_gtk/actions.py @@ -0,0 +1,873 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from gi.repository import 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.stock import StockItem +from ..core.workpiece import WorkPiece +from ..doceditor.array import ArrayMode +from ..doceditor.layout.registry import layout_registry +from .action_registry import MenuPlacement, ToolbarPlacement, action_registry +from .array_dialog import ( + CircularArrayDialog, + GridArrayDialog, + PointRotationArrayDialog, +) +from .doceditor.add_tabs_popover import AddTabsPopover +from .doceditor.stock_properties_dialog import StockPropertiesDialog +from .shared.keyboard import PRIMARY_ACCEL + +if TYPE_CHECKING: + from .mainwindow import MainWindow + +logger = logging.getLogger(__name__) + +SHORTCUTS = { + # File + "win.new": f"{PRIMARY_ACCEL}n", + "win.open": f"{PRIMARY_ACCEL}o", + "win.save": f"{PRIMARY_ACCEL}s", + "win.save-as": f"{PRIMARY_ACCEL}s", + "win.import": f"{PRIMARY_ACCEL}i", + "win.export": f"{PRIMARY_ACCEL}e", + "win.quit": f"{PRIMARY_ACCEL}q", + # Edit + "win.undo": f"{PRIMARY_ACCEL}z", + "win.redo": f"{PRIMARY_ACCEL}y", + "win.redo_alt": f"{PRIMARY_ACCEL}z", + "win.cut": f"{PRIMARY_ACCEL}x", + "win.copy": f"{PRIMARY_ACCEL}c", + "win.paste": f"{PRIMARY_ACCEL}v", + "win.select_all": f"{PRIMARY_ACCEL}a", + "win.duplicate": f"{PRIMARY_ACCEL}d", + "win.rename-item": "F2", + "win.remove": "Delete", + "win.clear": f"{PRIMARY_ACCEL}Delete", + "win.settings": f"{PRIMARY_ACCEL}comma", + # View + "win.show_workpieces": "h", + "win.show_tabs": "t", + "win.toggle_camera_view": "c", + "win.toggle_bottom_panel": f"{PRIMARY_ACCEL}l", + "win.toggle_travel_view": f"{PRIMARY_ACCEL}t", + "win.show_3d_view": "F12", + "win.recalculate": "F5", + "win.force-recalculate": "F5", + "win.view_top": "1", + "win.view_front": "2", + "win.view_right": "3", + "win.view_left": "4", + "win.view_back": "5", + "win.view_iso": "7", + "win.view_toggle_perspective": "p", + # Object + "win.add_stock": "s", + "win.add-tabs-equidistant": "t", + # Arrange + "win.group": f"{PRIMARY_ACCEL}g", + "win.ungroup": f"{PRIMARY_ACCEL}u", + "win.split": "w", + "win.layer-move-up": f"{PRIMARY_ACCEL}Page_Up", + "win.layer-move-down": f"{PRIMARY_ACCEL}Page_Down", + "win.align-left": f"{PRIMARY_ACCEL}Left", + "win.align-right": f"{PRIMARY_ACCEL}Right", + "win.align-top": f"{PRIMARY_ACCEL}Up", + "win.align-bottom": f"{PRIMARY_ACCEL}Down", + "win.align-h-center": f"{PRIMARY_ACCEL}Home", + "win.align-v-center": f"{PRIMARY_ACCEL}End", + "win.spread-h": f"{PRIMARY_ACCEL}h", + "win.spread-v": f"{PRIMARY_ACCEL}v", + "win.flip-horizontal": "h", + "win.flip-vertical": "v", + # Machine & Help + "win.machine-settings": f"{PRIMARY_ACCEL}less", + "win.about": "F1", +} + + +ActionSetupHandler = Callable[["ActionManager"], None] +ActionStateUpdateHandler = Callable[["ActionManager"], None] + + +class ActionExtensionRegistry: + """ + Registry for action extension handlers. + + Allows modules to register their own actions and state update + handlers, enabling decoupling of functionality like the sketcher. + """ + + 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] = {} + + def register_setup(self, handler: ActionSetupHandler, addon_name: str): + """Register a handler to be called during action setup.""" + self._setup_handlers.append(handler) + if addon_name: + self._setup_addon_map[handler.__name__] = 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) + return True + except ValueError: + return False + + def register_state_update( + self, handler: ActionStateUpdateHandler, addon_name: str + ): + """Register a handler to be called during action state updates.""" + self._state_update_handlers.append(handler) + if addon_name: + self._state_update_addon_map[handler.__name__] = addon_name + logger.debug( + f"Registered action state update handler: {handler.__name__}" + ) + + def unregister_state_update( + self, handler: ActionStateUpdateHandler + ) -> bool: + """Unregister an action state update handler.""" + try: + self._state_update_handlers.remove(handler) + self._state_update_addon_map.pop(handler.__name__, None) + return True + except ValueError: + return False + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all handlers registered by a specific addon. + + Args: + addon_name: The name of the addon to clean up + + Returns: + The number of handlers unregistered + """ + count = 0 + + setup_to_remove = [ + h + for h in self._setup_handlers + if self._setup_addon_map.get(h.__name__) == addon_name + ] + for h in setup_to_remove: + self._setup_handlers.remove(h) + self._setup_addon_map.pop(h.__name__, 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 + ] + for h in state_to_remove: + self._state_update_handlers.remove(h) + self._state_update_addon_map.pop(h.__name__, None) + count += len(state_to_remove) + + if count > 0: + logger.debug( + f"Unregistered {count} action extension handlers " + f"from addon '{addon_name}'" + ) + return count + + def invoke_setup_handlers(self, action_manager: "ActionManager"): + """Invoke all registered setup handlers.""" + for handler in self._setup_handlers: + try: + handler(action_manager) + except Exception: + logger.exception( + f"Error in action setup handler {handler.__name__}" + ) + + def invoke_state_update_handlers(self, action_manager: "ActionManager"): + """Invoke all registered state update handlers.""" + for handler in self._state_update_handlers: + try: + handler(action_manager) + except Exception: + logger.exception( + f"Error in action state update handler {handler.__name__}" + ) + + +action_extension_registry = ActionExtensionRegistry() + + +class ActionManager: + """Manages the creation and state of all Gio.SimpleActions for the app.""" + + def __init__(self, win: "MainWindow"): + self.win = win + self.actions: dict[str, Gio.SimpleAction] = {} + self._shortcut_controller: Gtk.ShortcutController | None = None + self._layout_shortcuts: list[Gtk.Shortcut] = [] + # A convenient alias to the central controller + self.editor = self.win.doc_editor + self.doc = self.editor.doc + + # Connect to doc signals to update action states + self.doc.descendant_added.connect(self.update_action_states) + self.doc.descendant_removed.connect(self.update_action_states) + self.win.surface.selection_changed.connect(self.update_action_states) + self.win.surface.context_changed.connect(self.update_action_states) + + def register_actions(self): + """Creates all Gio.SimpleActions and adds them to the window.""" + # Menu & File Actions + self._add_action("quit", self.win.on_quit_action) + self._add_action("new", self.win.project_cmd.on_new_project) + self._add_action("open", self.win.project_cmd.on_open_project) + self._add_action("save", self.win.project_cmd.on_save_project) + self._add_action("save-as", self.win.project_cmd.on_save_project_as) + self._add_action( + "export_document", self.win.on_export_document_clicked + ) + # New actions for recent files + self._add_action( + "open-recent", + self.win.project_cmd.on_open_recent, + GLib.VariantType.new("s"), + ) + self._add_action("import", self.win.on_menu_import) + self._add_action("export", self.win.on_export_clicked) + self._add_action("export-object", self.win.on_export_object_clicked) + self._add_action("about", self.win.show_about_dialog) + self._add_action("donate", self.win.on_donate_clicked) + self._add_action("save_debug_log", self.win.on_save_debug_log) + self._add_action("settings", self.win.show_settings) + self._add_action("machine-settings", self.win.show_machine_settings) + + # View Actions + cv = get_context().config.canvas_view + self._add_stateful_action( + "show_3d_view", + self.win.on_show_3d_view, + GLib.Variant.new_boolean(False), + ) + self._add_stateful_action( + "show_workpieces", + self.win.on_show_workpieces_state_change, + GLib.Variant.new_boolean(cv.show_workpieces), + ) + self._add_stateful_action( + "toggle_camera_view", + self.win.on_toggle_camera_view_state_change, + GLib.Variant.new_boolean(cv.show_camera), + ) + self._add_stateful_action( + "toggle_travel_view", + self.win.on_toggle_travel_view_state_change, + GLib.Variant.new_boolean(cv.show_travel_lines), + ) + self._add_stateful_action( + "show_nogo_zones", + self.win.on_show_nogo_zones_state_change, + GLib.Variant.new_boolean(cv.show_nogo_zones), + ) + self._add_stateful_action( + "show_grid", + self.win.on_show_grid_state_change, + GLib.Variant.new_boolean(cv.show_grid), + ) + self._add_stateful_action( + "show_models", + self.win.on_show_models_state_change, + GLib.Variant.new_boolean(cv.show_models), + ) + config = get_context().config + self._add_stateful_action( + "toggle_bottom_panel", + self.win.on_toggle_bottom_panel_state_change, + GLib.Variant.new_boolean( + config.bottom_panel.get("visible", False) + if config.bottom_panel + else True + ), + ) + self._add_stateful_action( + "toggle_right_panel", + self.win.on_toggle_right_panel_state_change, + GLib.Variant.new_boolean(config.right_panel_visible), + ) + + # 3D View Control Actions + self._add_action("view_top", self.win.on_view_top) + self._add_action("view_front", self.win.on_view_front) + self._add_action("view_right", self.win.on_view_right) + self._add_action("view_left", self.win.on_view_left) + self._add_action("view_back", self.win.on_view_back) + self._add_action("view_iso", self.win.on_view_iso) + self._add_stateful_action( + "view_toggle_perspective", + self.win.on_view_perspective_state_change, + GLib.Variant.new_boolean(cv.perspective_mode), + ) + + # Edit & Clipboard Actions + self._add_action( + "undo", lambda a, p: self.editor.history_manager.undo() + ) + # Primary redo action, linked to menu and toolbar + self._add_action( + "redo", lambda a, p: self.editor.history_manager.redo() + ) + # Secondary, hidden redo action for the alternate shortcut + self._add_action( + "redo_alt", lambda a, p: self.editor.history_manager.redo() + ) + self._add_action("cut", self.win.on_menu_cut) + self._add_action("copy", self.win.on_menu_copy) + self._add_action("paste", self.win.on_paste_requested) + self._add_action("select_all", self.win.on_select_all) + self._add_action("duplicate", self.win.on_menu_duplicate) + self._add_action("rename-item", self.win.on_menu_rename) + self._add_action("remove", self.win.on_menu_remove) + self._add_action("clear", self.win.on_clear_clicked) + + self._add_action("recalculate", self.win.on_recalculate_clicked) + self._add_action( + "force-recalculate", self.win.on_force_recalculate_clicked + ) + + # Asset Actions + self._add_action("add-stock", self.on_add_stock) + action_registry.register( + action_name="add-stock", + action=self.actions["add-stock"], + addon_name="core", + label=_("Add Stock"), + icon_name="stock-symbolic", + menu=MenuPlacement(menu_id="object", priority=40), + ) + self._add_action( + "activate-stock", + self.on_activate_stock, + GLib.VariantType.new("s"), + ) + self._add_action( + "edit-stock-item", + self.on_edit_stock_item, + GLib.VariantType.new("s"), + ) + self._add_action("asset-copy", self.on_asset_copy) + self._add_action("asset-cut", self.on_asset_cut) + self._add_action("asset-paste", self.on_asset_paste) + self._add_action("asset-delete", self.on_asset_delete) + self._add_action("asset-duplicate", self.on_asset_duplicate) + self._add_action( + "asset-create-workpiece", + self.on_asset_create_workpiece, + ) + + # Layer Management Actions + self._add_action("layer-move-up", self.on_layer_move_up) + self._add_action("layer-move-down", self.on_layer_move_down) + + # Grouping Actions + self._add_action("group", self.on_group_action) + self._add_action("ungroup", self.on_ungroup_action) + + # Split Action + self._add_action("split", self.on_split_action) + + # Convert to Stock Action + self._add_action("convert-to-stock", self.on_convert_to_stock) + + # Tabbing Actions + self._add_action("add-tabs-equidistant", self.on_add_tabs_equidistant) + self._add_action("add-tabs-cardinal", self.on_add_tabs_cardinal) + self._add_action("tab-add", self.on_tab_add) + self._add_action("tab-remove", self.on_tab_remove) + self._add_stateful_action( + "show_tabs", + self.on_show_tabs_state_change, + GLib.Variant.new_boolean(cv.show_tabs), + ) + + # Alignment Actions + self._add_action("align-h-center", self.on_align_h_center) + self._add_action("align-v-center", self.on_align_v_center) + self._add_action("align-left", self.on_align_left) + self._add_action("align-right", self.on_align_right) + self._add_action("align-top", self.on_align_top) + self._add_action("align-bottom", self.on_align_bottom) + self._add_action("spread-h", self.on_spread_h) + self._add_action("spread-v", self.on_spread_v) + + self._register_layout_actions() + + # Transform Actions + self._add_action("flip-horizontal", self.on_flip_horizontal) + self._add_action("flip-vertical", self.on_flip_vertical) + + # Array / Pattern tool (menu entries live under Arrange -> Array) + self._add_action("array-grid", self.on_array_grid) + self._add_action("array-point-rotation", self.on_array_point_rotation) + self._add_action("array-circular", self.on_array_circular) + + # Macro Actions + self._add_action( + "execute-macro", + self.win.on_execute_macro, + GLib.VariantType.new("s"), + ) + + # Machine Control Actions + self._add_action("machine-home", self.win.on_home_clicked) + self._add_action("machine-frame", self.win.on_frame_clicked) + self._add_action("machine-send", self.win.on_send_clicked) + self._add_action("machine-cancel", self.win.on_cancel_clicked) + self._add_action( + "machine-clear-alarm", self.win.on_clear_alarm_clicked + ) + + # Stateful action for the hold/pause button + self._add_stateful_action( + "machine-hold", + self.win.on_hold_state_change, + GLib.Variant.new_boolean(False), + ) + + self._add_stateful_action( + "toggle-focus", + self.win.on_toggle_focus_state_change, + GLib.Variant.new_boolean(False), + ) + + self._add_action( + "zero-here", + self.win.on_zero_here_clicked, + GLib.VariantType.new("s"), + ) + + action_extension_registry.invoke_setup_handlers(self) + + self.update_action_states() + + def _register_layout_actions(self): + """Register layout strategy actions with menu and toolbar placement.""" + for strategy_class in layout_registry.list_all(): + name = layout_registry.list_names()[ + list(layout_registry.list_all()).index(strategy_class) + ] + if name == "pixel-perfect": + action = Gio.SimpleAction.new("layout-pixel-perfect", None) + action.connect("activate", self.on_layout_pixel_perfect) + action_registry.register( + action_name="layout-pixel-perfect", + action=action, + addon_name="core", + label=_("Auto Layout (Simple)"), + icon_name="auto-layout-symbolic", + shortcut="a", + menu=MenuPlacement(menu_id="arrange"), + toolbar=ToolbarPlacement(group="arrange"), + ) + + def update_action_states(self, *args, **kwargs): + """Updates the enabled state of actions based on document state.""" + self.actions["add-stock"].set_enabled(True) + + is_unsaved = not self.editor.is_saved + self.actions["save"].set_enabled(is_unsaved) + + target_workpieces = self._get_workpieces_for_tabbing() + can_add_tabs = any(wp.boundaries for wp in target_workpieces) + self.actions["add-tabs-equidistant"].set_enabled(can_add_tabs) + self.actions["add-tabs-cardinal"].set_enabled(can_add_tabs) + + context = self.win.surface.right_click_context + can_add_single_tab = context and context.get("type") == "geometry" + can_remove_single_tab = context and context.get("type") == "tab" + self.actions["tab-add"].set_enabled(bool(can_add_single_tab)) + self.actions["tab-remove"].set_enabled(bool(can_remove_single_tab)) + + selected_wps = self.win.surface.get_selected_workpieces() + if selected_wps: + has_workpieces = True + else: + current_layer = self.doc.active_layer + has_workpieces = ( + current_layer + and len(current_layer.get_descendants(WorkPiece)) > 0 + ) + layout_info = action_registry.get("layout-pixel-perfect") + if layout_info and layout_info.action: + layout_info.action.set_enabled(has_workpieces) + + self.actions["split"].set_enabled(bool(selected_wps)) + self.actions["export-object"].set_enabled(len(selected_wps) == 1) + + action_extension_registry.invoke_state_update_handlers(self) + + def on_add_stock(self, action, param): + """Handler for the 'add-stock' action.""" + self.editor.stock.add_stock() + + def on_activate_stock(self, action, param): + """Handler for the 'activate-stock' action.""" + asset_uid = param.get_string() + for item in self.editor.doc.stock_items: + if item.stock_asset_uid == asset_uid: + dialog = StockPropertiesDialog(self.win, item, self.editor) + dialog.present() + break + + def on_edit_stock_item(self, action, param): + """Handler for the 'edit-stock-item' action.""" + item_uid = param.get_string() + item = self.doc.find_descendant_by_uid(item_uid) + if isinstance(item, StockItem): + dialog = StockPropertiesDialog(self.win, item, self.editor) + dialog.present() + + def on_asset_copy(self, action, param): + browser = self.win.bottom_panel.asset_browser + browser.copy_selected_assets() + self._update_asset_action_states() + + def on_asset_cut(self, action, param): + browser = self.win.bottom_panel.asset_browser + browser.cut_selected_assets() + self._update_asset_action_states() + + def on_asset_paste(self, action, param): + browser = self.win.bottom_panel.asset_browser + browser.paste_assets() + + def on_asset_delete(self, action, param): + browser = self.win.bottom_panel.asset_browser + browser.delete_selected_assets() + self._update_asset_action_states() + + def on_asset_duplicate(self, action, param): + browser = self.win.bottom_panel.asset_browser + browser.duplicate_selected_assets() + + def on_asset_create_workpiece(self, action, param): + browser = self.win.bottom_panel.asset_browser + browser.create_workpiece_from_selected() + + def _update_asset_action_states(self): + browser = self.win.bottom_panel.asset_browser + self.actions["asset-paste"].set_enabled(browser.can_paste_assets()) + + def _get_workpieces_for_tabbing(self) -> list[WorkPiece]: + """ + Helper to get a list of workpieces to apply tabs to, using the + surface's selection API. + """ + # Use the dedicated surface method to get selected workpieces. This + # correctly handles nested items inside groups. + selected_workpieces = self.win.surface.get_selected_workpieces() + + if not selected_workpieces: + # If the selection is empty or contains no workpieces, fall back to + # processing all workpieces in the entire document. + return list(self.doc.get_descendants(WorkPiece)) + else: + # Otherwise, return the unique list derived from the selection. + return selected_workpieces + + def on_add_tabs_equidistant(self, action, param): + """Opens the popover for adding equidistant tabs.""" + workpieces_to_process = self._get_workpieces_for_tabbing() + valid_workpieces = [ + wp + for wp in workpieces_to_process + if wp.boundaries + and wp.layer + and wp.layer.workflow + and wp.layer.workflow.has_steps() + ] + + if not valid_workpieces: + return + + # The popover needs to be parented to the SplitMenuButton's main button + button = self.win.toolbar.tab_menu_button.main_button + popover = AddTabsPopover( + editor=self.editor, workpieces=valid_workpieces + ) + popover.set_parent(button) + popover.popup() + + def on_add_tabs_cardinal(self, action, param): + """Handler for adding cardinal tabs to a workpiece.""" + workpieces_to_process = self._get_workpieces_for_tabbing() + if not workpieces_to_process: + return + + # 1. Execute the command to update the data model. + for workpiece in workpieces_to_process: + if not ( + workpiece.layer + and workpiece.layer.workflow + and workpiece.layer.workflow.has_steps() + ): + continue + + self.editor.tab.add_cardinal_tabs( + workpiece=workpiece, + width=2.0, + ) + + # 2. Ensure the UI state is visible. + show_tabs_action = self.get_action("show_tabs") + state = show_tabs_action.get_state() + if not (state and state.get_boolean()): + show_tabs_action.set_state(GLib.Variant.new_boolean(True)) + + def on_tab_add(self, action, param): + """Handler for adding a single tab via context menu.""" + context = self.win.surface.right_click_context + if context and context.get("type") == "geometry": + self.editor.add_tab_from_context(context) + + def on_tab_remove(self, action, param): + """Handler for removing a single tab via context menu.""" + context = self.win.surface.right_click_context + if context and context.get("type") == "tab": + self.editor.remove_tab_from_context(context) + + def on_show_tabs_state_change(self, action, state): + """ + Handler for the global tab visibility state change. This is the + controller that receives the user's intent to change the state. + """ + is_visible = state.get_boolean() + self.win.surface.set_global_tab_visibility(is_visible) + action.set_state(state) + config = get_context().config + config.canvas_view.show_tabs = is_visible + config.changed.send(config) + + def register_shortcuts(self, controller: Gtk.ShortcutController): + """ + Populates the given ShortcutController with all application shortcuts. + """ + for action_name, shortcut_str in SHORTCUTS.items(): + shortcut = Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string(shortcut_str), + Gtk.NamedAction.new(action_name), + ) + controller.add_shortcut(shortcut) + + self._shortcut_controller = controller + self._update_dynamic_shortcuts() + + action_registry.changed.connect(self._on_action_registry_changed) + + def _on_action_registry_changed(self, sender): + """Handle action registry changes by refreshing shortcuts.""" + self._update_dynamic_shortcuts() + + def _update_dynamic_shortcuts(self): + """Update shortcuts for actions registered via action_registry.""" + if not self._shortcut_controller: + return + + for shortcut in self._layout_shortcuts: + self._shortcut_controller.remove_shortcut(shortcut) + self._layout_shortcuts.clear() + + for info in action_registry.get_all_with_shortcuts(): + if info.shortcut: + shortcut = Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string(info.shortcut), + Gtk.NamedAction.new(f"win.{info.action_name}"), + ) + self._shortcut_controller.add_shortcut(shortcut) + self._layout_shortcuts.append(shortcut) + + def get_action(self, name: str) -> Gio.SimpleAction: + """Retrieves a registered action by its name.""" + return self.actions[name] + + def on_layer_move_up(self, action, param): + """Handler for the 'layer-move-up' action.""" + self.editor.layer.move_selected_to_adjacent_layer( + self.win.surface, direction=-1 + ) + + def on_layer_move_down(self, action, param): + """Handler for the 'layer-move-down' action.""" + self.editor.layer.move_selected_to_adjacent_layer( + self.win.surface, direction=1 + ) + + def on_group_action(self, action, param): + """Handler for the 'group' action.""" + selected_elements = self.win.surface.get_selected_elements() + if len(selected_elements) < 2: + return + + items_to_group = [ + elem.data + for elem in selected_elements + if isinstance(elem.data, DocItem) + ] + # All items must belong to the same layer to be grouped + parent_layer = cast(Layer, items_to_group[0].parent) + if not parent_layer or not all( + item.parent is parent_layer for item in items_to_group + ): + return + + new_group = self.editor.group.group_items(parent_layer, items_to_group) + if new_group: + self.win.surface.select_items([new_group]) + + def on_ungroup_action(self, action, param): + """Handler for the 'ungroup' action.""" + selected_elements = self.win.surface.get_selected_elements() + + groups_to_ungroup = [ + elem.data + for elem in selected_elements + if isinstance(elem.data, Group) + ] + if not groups_to_ungroup: + return + + self.editor.group.ungroup_items(groups_to_ungroup) + # The selection will be automatically updated by the history changed + # signal handler. + + def on_split_action(self, action, param): + """Handler for the 'split' action.""" + selected_workpieces = self.win.surface.get_selected_workpieces() + if not selected_workpieces: + return + + new_items = self.editor.split.split_items(selected_workpieces) + if new_items: + self.win.surface.select_items(new_items) + + def on_convert_to_stock(self, action, param): + """Handler for the 'convert-to-stock' action.""" + selected_wps = self.win.surface.get_selected_workpieces() + if not selected_wps: + return + + for wp in selected_wps: + self.editor.stock.convert_to_stock(wp) + + # --- Alignment Action Handlers --- + + def on_align_h_center(self, action, param): + items = list(self.win.surface.get_selected_items()) + w, _ = self.win.surface.get_size_mm() + self.editor.layout.center_horizontally(items, w) + + def on_align_v_center(self, action, param): + items = list(self.win.surface.get_selected_items()) + _, h = self.win.surface.get_size_mm() + self.editor.layout.center_vertically(items, h) + + def on_align_left(self, action, param): + items = list(self.win.surface.get_selected_items()) + self.editor.layout.align_left(items) + + def on_align_right(self, action, param): + items = list(self.win.surface.get_selected_items()) + w, _ = self.win.surface.get_size_mm() + self.editor.layout.align_right(items, w) + + def on_align_top(self, action, param): + items = list(self.win.surface.get_selected_items()) + _, h = self.win.surface.get_size_mm() + self.editor.layout.align_top(items, h) + + def on_align_bottom(self, action, param): + items = list(self.win.surface.get_selected_items()) + self.editor.layout.align_bottom(items) + + def on_spread_h(self, action, param): + items = list(self.win.surface.get_selected_items()) + self.editor.layout.spread_horizontally(items) + + def on_spread_v(self, action, param): + items = list(self.win.surface.get_selected_items()) + self.editor.layout.spread_vertically(items) + + def on_layout_pixel_perfect(self, action, param): + items = list(self.win.surface.get_selected_items()) + self.editor.layout.layout_pixel_perfect(items) + + def on_flip_horizontal(self, action, param): + """Handler for the 'flip-horizontal' action.""" + items = list(self.win.surface.get_selected_items()) + self.editor.transform.flip_horizontal(items) + + def on_flip_vertical(self, action, param): + """Handler for the 'flip-vertical' action.""" + items = list(self.win.surface.get_selected_items()) + self.editor.transform.flip_vertical(items) + + def on_array_grid(self, action, param): + """Handler for the 'array-grid' action: opens the grid dialog.""" + self._open_array_dialog(ArrayMode.GRID) + + def on_array_point_rotation(self, action, param): + """Handler for 'array-point-rotation': opens the point-rotation + dialog.""" + self._open_array_dialog(ArrayMode.POINT_ROTATION) + + def on_array_circular(self, action, param): + """Handler for the 'array-circular' action: opens the circular + dialog.""" + self._open_array_dialog(ArrayMode.CIRCULAR) + + def _open_array_dialog(self, mode: ArrayMode) -> None: + items = list(self.win.surface.get_selected_items()) + if not items: + return + cls = { + ArrayMode.GRID: GridArrayDialog, + ArrayMode.POINT_ROTATION: PointRotationArrayDialog, + ArrayMode.CIRCULAR: CircularArrayDialog, + }[mode] + dialog = cls(self.win, self.editor, self.win.surface, items) + dialog.present() + + def _add_action( + self, + name: str, + callback: Callable, + param: GLib.VariantType | None = None, + ): + """Helper to create, register, and store a simple Gio.SimpleAction.""" + action = Gio.SimpleAction.new(name, param) + action.connect("activate", callback) + self.win.add_action(action) + self.actions[name] = action + + def _add_stateful_action( + self, name: str, callback: Callable, initial_state: GLib.Variant + ): + """Helper for a stateful action, typically for toggle buttons.""" + action = Gio.SimpleAction.new_stateful(name, None, initial_state) + # For stateful actions, we ONLY connect to 'change-state'. The default + # 'activate' handler for boolean actions will correctly call this for + # us. + action.connect("change-state", callback) + self.win.add_action(action) + self.actions[name] = action diff --git a/rayforge/ui_gtk/addon_manager/__init__.py b/rayforge/ui_gtk/addon_manager/__init__.py new file mode 100644 index 000000000..1a5e5215f --- /dev/null +++ b/rayforge/ui_gtk/addon_manager/__init__.py @@ -0,0 +1 @@ +"""Addon manager UI components.""" diff --git a/rayforge/ui_gtk/addon_manager/addon_dialog.py b/rayforge/ui_gtk/addon_manager/addon_dialog.py new file mode 100644 index 000000000..2081e6420 --- /dev/null +++ b/rayforge/ui_gtk/addon_manager/addon_dialog.py @@ -0,0 +1,274 @@ +import logging +import threading +from gettext import gettext as _ + +from gi.repository import Adw, GLib, Gtk + +from ... import __version__ +from ...addon_mgr.addon import AddonMetadata +from ...addon_mgr.addon_manager import UpdateStatus +from ...context import get_context +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow +from .license_dialog import LicenseRequiredDialog + +logger = logging.getLogger(__name__) + + +class AddonRegistryDialog(PatchedDialogWindow): + """ + A dialog that fetches and lists available addons from the + online registry via the AddonManager. + """ + + def __init__(self, parent_window, on_install_callback): + super().__init__() + self.set_transient_for(parent_window) + self.set_modal(True) + self.set_title(_("Addon Registry")) + self.set_default_size(600, 700) + + self.on_install_callback = on_install_callback + + # Main Layout + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(box) + + # Header + header = Adw.HeaderBar() + box.append(header) + + # Content area (Stack for Loading vs List) + self.stack = Gtk.Stack() + self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + box.append(self.stack) + + # 1. Loading Page + loading_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + valign=Gtk.Align.CENTER, + halign=Gtk.Align.CENTER, + ) + spinner = Gtk.Spinner() + spinner.set_size_request(32, 32) + spinner.start() + loading_box.append(spinner) + loading_box.append(Gtk.Label(label=_("Fetching registry..."))) + self.stack.add_named(loading_box, "loading") + + # 2. List Page + list_page_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + # Scrolled Window for the list + scrolled = Gtk.ScrolledWindow() + scrolled.set_vexpand(True) + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + + clamp = Adw.Clamp(maximum_size=800) + clamp.set_margin_top(24) + clamp.set_margin_bottom(24) + clamp.set_margin_start(12) + clamp.set_margin_end(12) + + self.list_box = Gtk.ListBox() + self.list_box.set_selection_mode(Gtk.SelectionMode.NONE) + self.list_box.get_style_context().add_class("boxed-list") + + clamp.set_child(self.list_box) + scrolled.set_child(clamp) + list_page_box.append(scrolled) + + # Manual Install Button Footer + footer_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + margin_bottom=12, + margin_top=12, + ) + manual_btn = Gtk.Button(label=_("Install from URL...")) + manual_btn.get_style_context().add_class("flat") + manual_btn.set_halign(Gtk.Align.CENTER) + manual_btn.connect("clicked", self._on_manual_install_clicked) + footer_box.append(manual_btn) + list_page_box.append(footer_box) + + self.stack.add_named(list_page_box, "list") + + # 3. Error Page + self.error_box = Adw.StatusPage() + self.error_box.set_icon_name("error-symbolic") + self.error_box.set_title(_("Connection Failed")) + self.error_box.set_description(_("Could not reach the registry.")) + self.stack.add_named(self.error_box, "error") + + # Start fetching + self._fetch_registry() + + def _fetch_registry(self): + """Fetches the registry using the AddonManager in a thread.""" + context = get_context() + + def _worker(): + logger.info("Fetching addon registry in background thread") + result = context.addon_mgr.fetch_registry() + logger.info(f"Registry fetch complete: {len(result)} items") + return result + + def _done(data): + if data is not None: + self._populate_list(data) + self.stack.set_visible_child_name("list") + else: + self.stack.set_visible_child_name("error") + + def _thread_target(): + result = _worker() + GLib.idle_add(_done, result) + + thread = threading.Thread(target=_thread_target, daemon=True) + thread.start() + + def _is_license_valid(self, addon: AddonMetadata) -> bool: + """Check if user already has a valid license for this addon.""" + license_config = addon.license + if not license_config or not license_config.required: + return True + + context = get_context() + validator = context.license_validator + if not validator: + return False + + result = validator.validate(addon.name, license_config.to_dict()) + return result.status.value == "valid" + + def _get_product_ids(self, addon: AddonMetadata) -> list[str]: + """Extract product IDs from addon license config.""" + license_config = addon.license + if not license_config: + return [] + + return license_config.get_all_product_ids() + + def _populate_list(self, data: list[AddonMetadata]): + """Populates the list box with registry items.""" + while child := self.list_box.get_row_at_index(0): + self.list_box.remove(child) + + if not data: + empty_label = Gtk.Label( + label=_("No addons found in registry."), margin_top=24 + ) + self.list_box.append(empty_label) + return + + context = get_context() + + for addon in data: + row = Adw.ActionRow( + title=addon.display_name or addon.name or "?", + subtitle=addon.description, + ) + row.add_prefix(get_icon("addon-symbolic")) + + author_name = addon.author.name + if author_name: + lbl = Gtk.Label(label=f"by {author_name}") + lbl.get_style_context().add_class("dim-label") + row.add_suffix(lbl) + + # --- Action Button Logic --- + btn = Gtk.Button(valign=Gtk.Align.CENTER) + status, local_ver = context.addon_mgr.check_update_status(addon) + + license_config = addon.license + is_premium = license_config and license_config.required + has_license = self._is_license_valid(addon) + + if status == UpdateStatus.NOT_INSTALLED: + if is_premium and not has_license: + btn.set_label(_("Unlock")) + btn.get_style_context().add_class("suggested-action") + btn.connect("clicked", self._on_unlock_clicked, addon) + else: + btn.set_label(_("Install")) + btn.get_style_context().add_class("suggested-action") + btn.connect("clicked", self._on_install_clicked, addon) + elif status == UpdateStatus.UPDATE_AVAILABLE: + btn.set_label(_("Update")) + btn.get_style_context().add_class("suggested-action") + btn.connect("clicked", self._on_install_clicked, addon) + elif status == UpdateStatus.UP_TO_DATE: + btn.set_label(_("Installed")) + btn.set_sensitive(False) + btn.set_tooltip_text( + _("Version {v} already installed").format(v=local_ver) + ) + elif status == UpdateStatus.INCOMPATIBLE: + btn.set_label(_("Incompatible")) + btn.set_sensitive(False) + deps_str = ", ".join(addon.depends) + btn.set_tooltip_text( + _( + "Requires {deps}, but current rayforge " + "version is {current}" + ).format(deps=deps_str, current=__version__) + ) + + if not addon.url: # Handle invalid registry entries + btn.set_label(_("Unavailable")) + btn.set_sensitive(False) + + row.add_suffix(btn) + self.list_box.append(row) + + def _on_install_clicked(self, btn, addon_meta: AddonMetadata): + if addon_meta: + self.close() + self.on_install_callback(addon_meta) + + def _on_unlock_clicked(self, btn, addon_meta: AddonMetadata): + """Handle click on Unlock button for premium addon.""" + product_ids = self._get_product_ids(addon_meta) + purchase_url = None + if addon_meta.license: + purchase_url = addon_meta.license.purchase_url + + display_name = addon_meta.display_name or addon_meta.name + + def on_license_added(): + self.close() + self.on_install_callback(addon_meta) + + dialog = LicenseRequiredDialog( + addon_name=display_name, + product_ids=product_ids, + purchase_url=purchase_url, + on_license_added=on_license_added, + ) + dialog.set_transient_for(self) + dialog.present() + + def _on_manual_install_clicked(self, btn): + """Allows manual URL entry if not in registry.""" + dialog = Adw.MessageDialog( + transient_for=self, + heading=_("Manual Install"), + body=_("Enter the Git URL."), + ) + entry = Adw.EntryRow(title="URL") + dialog.set_extra_child(entry) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("install", _("Install")) + + def _cb(dlg, resp): + if resp == "install": + url = entry.get_text().strip() + if url: + self.close() + self.on_install_callback(url) + dlg.close() + + dialog.connect("response", _cb) + dialog.present() diff --git a/rayforge/ui_gtk/addon_manager/addon_list.py b/rayforge/ui_gtk/addon_manager/addon_list.py new file mode 100644 index 000000000..8f1660f60 --- /dev/null +++ b/rayforge/ui_gtk/addon_manager/addon_list.py @@ -0,0 +1,525 @@ +import logging +import threading +from collections.abc import Callable +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, GLib, Gtk + +from ... import __version__ +from ...addon_mgr.addon import Addon, AddonMaturity, AddonMetadata +from ...addon_mgr.addon_manager import AddonState +from ...context import get_context +from ...shared.util.versioning import UnknownVersion +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from .addon_dialog import AddonRegistryDialog +from .experimental_dialog import ExperimentalAddonDialog +from .license_dialog import LicenseRequiredDialog + +logger = logging.getLogger(__name__) + + +class AddonRow(Gtk.Box): + """A widget representing a single Addon in a ListBox.""" + + def __init__( + self, + addon: Addon, + state: str, + on_delete, + on_toggle: Callable | None = None, + on_unlock: Callable | None = None, + error_message: str | None = None, + is_builtin: bool = False, + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.addon = addon + self.state = state + self.on_toggle = on_toggle + self.on_unlock = on_unlock + self.is_builtin = is_builtin + + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + provides = addon.metadata.provides + is_asset_only = not (provides.worker or provides.frontend) + + if state == AddonState.LICENSE_REQUIRED.value: + icon = get_icon("lock-symbolic") + icon.set_valign(Gtk.Align.CENTER) + icon.set_tooltip_text(_("License required")) + self.append(icon) + elif state == AddonState.LOAD_ERROR.value: + icon = get_icon("error-symbolic") + icon.set_valign(Gtk.Align.CENTER) + if error_message: + icon.set_tooltip_text(error_message) + else: + icon.set_tooltip_text(_("Failed to load this addon")) + self.append(icon) + elif state == AddonState.PENDING_UNLOAD.value: + icon = get_icon("hourglass-symbolic") + icon.set_valign(Gtk.Align.CENTER) + icon.set_tooltip_text( + _("This addon will be unloaded when active jobs finish") + ) + self.append(icon) + elif state == AddonState.INCOMPATIBLE.value: + icon = get_icon("warning-symbolic") + icon.set_valign(Gtk.Align.CENTER) + icon.add_css_class("warning") + icon.set_tooltip_text( + _( + "This addon is incompatible with the current " + "version of Rayforge" + ) + ) + self.append(icon) + elif addon.metadata.maturity == AddonMaturity.EXPERIMENTAL: + icon = get_icon("experimental-symbolic") + icon.set_valign(Gtk.Align.CENTER) + icon.set_tooltip_text( + _( + "This addon is experimental and may have " + "unresolved issues. Use it with caution." + ) + ) + self.append(icon) + elif addon.metadata.license and addon.metadata.license.required: + icon = get_icon("crown-symbolic") + icon.set_valign(Gtk.Align.CENTER) + icon.set_tooltip_text(_("Premium addon")) + self.append(icon) + else: + if self.is_builtin: + icon = get_icon("addon-builtin-symbolic") + icon.set_tooltip_text(_("Built-in addon")) + else: + icon = get_icon("addon-symbolic") + icon.set_valign(Gtk.Align.CENTER) + self.append(icon) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, hexpand=True + ) + self.append(labels_box) + + title = Gtk.Label( + label=addon.metadata.display_name or addon.metadata.name, + halign=Gtk.Align.START, + xalign=0, + ) + if state in ( + AddonState.INCOMPATIBLE.value, + AddonState.LOAD_ERROR.value, + AddonState.LICENSE_REQUIRED.value, + ): + title.add_css_class("dim-label") + labels_box.append(title) + + subtitle_text = self._get_subtitle() + if state == AddonState.LICENSE_REQUIRED.value: + subtitle_text = _("License required") + subtitle = Gtk.Label( + label=subtitle_text, + halign=Gtk.Align.START, + xalign=0, + ) + subtitle.add_css_class("dim-label") + labels_box.append(subtitle) + + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + if state == AddonState.LICENSE_REQUIRED.value: + unlock_btn = Gtk.Button(label=_("Unlock")) + unlock_btn.add_css_class("suggested-action") + unlock_btn.set_valign(Gtk.Align.CENTER) + unlock_btn.connect("clicked", self._on_unlock_clicked) + suffix_box.append(unlock_btn) + + if not self.is_builtin: + delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + delete_button.add_css_class("flat") + delete_button.set_tooltip_text(_("Uninstall Addon")) + delete_button.connect("clicked", lambda w: on_delete(addon)) + suffix_box.append(delete_button) + + if ( + state != AddonState.LICENSE_REQUIRED.value + and not is_asset_only + and on_toggle + ): + self.enable_switch = Gtk.Switch(valign=Gtk.Align.CENTER) + self.enable_switch.set_active(state == AddonState.ENABLED.value) + self.enable_switch.set_sensitive( + state + not in ( + AddonState.INCOMPATIBLE.value, + AddonState.LOAD_ERROR.value, + AddonState.PENDING_UNLOAD.value, + AddonState.LICENSE_REQUIRED.value, + ) + ) + self.enable_switch.set_tooltip_text( + _("Enable or disable this addon") + ) + self.enable_switch.connect("state-set", self._on_toggle_clicked) + suffix_box.append(self.enable_switch) + + def _get_subtitle(self) -> str: + parts = [] + version = self.addon.metadata.version + if version is UnknownVersion: + if self.is_builtin: + parts.append(__version__) + elif version: + parts.append(str(version)) + if self.addon.metadata.author.name: + parts.append(self.addon.metadata.author.name) + parts = [str(p) for p in parts if p is not None] + return " | ".join(parts) + + def _on_toggle_clicked(self, switch: Gtk.Switch, state: bool) -> bool: + if self.on_toggle: + self.on_toggle(self.addon.metadata.name, state) + return False + + def _on_unlock_clicked(self, btn): + if self.on_unlock: + self.on_unlock(self.addon) + + +class AddonListWidget(PreferencesGroupWithButton): + """Displays a list of addons and allows adding/deleting them.""" + + def __init__(self, **kwargs): + super().__init__(button_label=_("Install New Addon..."), **kwargs) + + placeholder = Gtk.Label( + label=_("No addons installed."), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_show_separators(True) + + self.populate_addons() + + self.install_started = Signal() + self.install_finished = Signal() + + def populate_addons(self): + """Refreshes the list of addons.""" + context = get_context() + am = context.addon_mgr + + addons: list[tuple[Addon, str, str | None, bool]] = [] + + for name, addon in am.loaded_addons.items(): + if name in am._pending_unloads: + state = AddonState.PENDING_UNLOAD.value + else: + state = AddonState.ENABLED.value + is_builtin = not addon.root_path.is_relative_to(am.install_dir) + addons.append((addon, state, None, is_builtin)) + + for name, addon in am.disabled_addons.items(): + is_builtin = not addon.root_path.is_relative_to(am.install_dir) + addons.append((addon, AddonState.DISABLED.value, None, is_builtin)) + + for name, addon in am.incompatible_addons.items(): + is_builtin = not addon.root_path.is_relative_to(am.install_dir) + addons.append( + (addon, AddonState.INCOMPATIBLE.value, None, is_builtin) + ) + + for name, addon in am.license_required_addons.items(): + is_builtin = not addon.root_path.is_relative_to(am.install_dir) + addons.append( + (addon, AddonState.LICENSE_REQUIRED.value, None, is_builtin) + ) + + for name, error in am._load_errors.items(): + addon = ( + am.loaded_addons.get(name) + or am.disabled_addons.get(name) + or am.incompatible_addons.get(name) + ) + if addon: + is_builtin = not addon.root_path.is_relative_to(am.install_dir) + addons.append( + (addon, AddonState.LOAD_ERROR.value, error, is_builtin) + ) + + addons.sort( + key=lambda a: ( + a[0].metadata.display_name or a[0].metadata.name + ).lower(), + ) + self.set_items(addons) + + def create_row_widget(self, item: tuple) -> Gtk.Widget: + addon, state, error_message, is_builtin = item + return AddonRow( + addon, + state, + self._on_delete_addon, + self._on_toggle_addon, + self._on_unlock_addon, + error_message, + is_builtin, + ) + + def _on_add_clicked(self, button): + """Opens the registry dialog.""" + root = cast(Gtk.Window, self.get_root()) + dialog = AddonRegistryDialog(root, self._install_addon) + dialog.present() + + def _install_addon(self, install_info): + """ + Installs and hot-loads the addon via backend. + `install_info` can be AddonMetadata or a git_url string. + """ + context = get_context() + addon_id = None + git_url = "" + display_name = "" + + if isinstance(install_info, AddonMetadata): + git_url = install_info.url + addon_id = install_info.name + display_name = install_info.display_name or install_info.name + else: + git_url = str(install_info) + addon_id = None + display_name = context.addon_mgr._extract_repo_name(git_url) + + self.list_box.set_sensitive(False) + self.add_button.set_sensitive(False) + self.install_started.send( + self, message=_("Installing {name}...").format(name=display_name) + ) + + def _worker(): + logger.info( + f"Starting addon installation: git_url={git_url}, " + f"addon_id={addon_id}" + ) + result = context.addon_mgr.install_addon(git_url, addon_id) + logger.info(f"Addon installation finished: result={result}") + return result + + def _done(result_path): + logger.info(f"Install _done callback: result={result_path}") + self.list_box.set_sensitive(True) + self.add_button.set_sensitive(True) + self.install_finished.send(self) + + if result_path: + self.populate_addons() + else: + self._show_error(_("Failed to install addon.")) + + def _thread_target(): + result = _worker() + GLib.idle_add(_done, result) + + thread = threading.Thread(target=_thread_target, daemon=True) + thread.start() + + def _on_toggle_addon(self, addon_name: str, enable: bool): + """Toggle addon enabled/disabled state.""" + context = get_context() + am = context.addon_mgr + + if enable: + addon = am.get_installed_addon(addon_name) + if addon and addon.metadata.maturity == AddonMaturity.EXPERIMENTAL: + self._confirm_enable_experimental(addon_name, addon) + else: + self._enable_addon(addon_name) + else: + can_disable, reason = am.can_disable(addon_name) + if not can_disable: + self._show_warning( + _("Cannot Disable Addon"), + _("This addon cannot be disabled.\n\n{reason}").format( + reason=reason + ), + ) + self.populate_addons() + return + + success = am.disable_addon(addon_name) + if not success: + if am.has_pending_unloads(): + self._show_info( + _("Addon will be disabled when active jobs complete.") + ) + else: + self._show_error( + _( + "Failed to disable addon. Check the logs " + "for details." + ) + ) + + self.populate_addons() + + def _confirm_enable_experimental(self, addon_name: str, addon: Addon): + """Ask for confirmation before enabling an experimental addon.""" + display_name = addon.metadata.display_name or addon.metadata.name + dialog = ExperimentalAddonDialog( + addon_name=display_name, + on_enable=lambda: self._enable_addon(addon_name), + on_cancel=self.populate_addons, + ) + root = cast(Gtk.Window, self.get_root()) + if root: + dialog.set_transient_for(root) + dialog.present() + + def _enable_addon(self, addon_name: str): + """Enable an addon, prompting for missing dependencies.""" + context = get_context() + am = context.addon_mgr + + missing = am.get_missing_dependencies(addon_name) + if missing: + missing_names = [name for name, _ in missing] + missing_str = ", ".join(missing_names) + root = cast(Gtk.Window, self.get_root()) + + def on_response(dialog, response): + if response == "enable": + success, _enabled = am.enable_addon_with_deps(addon_name) + if not success: + self._show_error( + _("Failed to enable addon and its dependencies.") + ) + self.populate_addons() + else: + self.populate_addons() + dialog.close() + + dialog = Adw.MessageDialog( + transient_for=root, + heading=_("Enable Dependencies?"), + body=_( + "This addon requires: {deps}\n\nEnable them as well?" + ).format(deps=missing_str), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("enable", _("Enable All")) + dialog.connect("response", on_response) + dialog.present() + return + + success = am.enable_addon(addon_name) + if not success: + self._show_error( + _("Failed to enable addon. Check the logs for details.") + ) + self.populate_addons() + + def _on_unlock_addon(self, addon: Addon): + """Handle unlock button click for license-required addon.""" + license_config = addon.metadata.license + if not license_config: + return + + product_ids = license_config.get_all_product_ids() + purchase_url = license_config.purchase_url + display_name = addon.metadata.display_name or addon.metadata.name + addon_name = addon.metadata.name + + def on_license_added(): + context = get_context() + context.addon_mgr.recheck_license(addon_name) + self.populate_addons() + + root = cast(Gtk.Window, self.get_root()) + dialog = LicenseRequiredDialog( + addon_name=display_name, + product_ids=product_ids, + purchase_url=purchase_url, + on_license_added=on_license_added, + ) + if root: + dialog.set_transient_for(root) + dialog.present() + + def _on_delete_addon(self, addon: Addon): + """Confirm and delete the addon.""" + display_name = addon.metadata.display_name or addon.metadata.name + root = cast(Gtk.Window, self.get_root()) + dialog = Adw.MessageDialog( + transient_for=root, + heading=_("Uninstall {name}?").format(name=display_name), + body=_( + "The addon files will be removed. " + "Restart recommended to fully clear memory." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Uninstall")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + + def _response_cb(dlg, response): + if response == "delete": + self._delete_addon(addon) + dlg.close() + + dialog.connect("response", _response_cb) + dialog.present() + + def _delete_addon(self, addon: Addon): + """Triggers the backend to uninstall the addon.""" + context = get_context() + success = context.addon_mgr.uninstall_addon(addon.metadata.name) + + if success: + self.populate_addons() + else: + logger.error( + f"UI failed to trigger uninstall for {addon.metadata.name}" + ) + self._show_error(_("Error deleting addon.")) + + def _show_error(self, message): + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, self.get_root()), + heading=_("Error"), + body=message, + ) + dialog.add_response("ok", _("OK")) + dialog.present() + + def _show_info(self, message): + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, self.get_root()), + heading=_("Info"), + body=message, + ) + dialog.add_response("ok", _("OK")) + dialog.present() + + def _show_warning(self, heading: str, message: str): + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, self.get_root()), + heading=heading, + body=message, + ) + dialog.add_response("ok", _("OK")) + dialog.present() diff --git a/rayforge/ui_gtk/addon_manager/experimental_dialog.py b/rayforge/ui_gtk/addon_manager/experimental_dialog.py new file mode 100644 index 000000000..2023eb87b --- /dev/null +++ b/rayforge/ui_gtk/addon_manager/experimental_dialog.py @@ -0,0 +1,52 @@ +"""Dialog for confirming the enabling of an experimental addon.""" + +from collections.abc import Callable +from gettext import gettext as _ + +from gi.repository import Adw + + +class ExperimentalAddonDialog(Adw.MessageDialog): + """ + Confirmation dialog shown before enabling an experimental addon. + + The addon is only enabled when the user explicitly confirms that + they want to use an addon that may have unresolved issues. + """ + + def __init__( + self, + addon_name: str, + on_enable: Callable[[], None] | None = None, + on_cancel: Callable[[], None] | None = None, + ): + super().__init__() + + self._on_enable = on_enable + self._on_cancel = on_cancel + + self.set_heading(_("Enable Experimental Addon?")) + self.set_body( + _( + 'The addon "{name}" is experimental and may have ' + "unresolved issues. Use it with caution." + ).format(name=addon_name) + ) + + self.add_response("cancel", _("Cancel")) + self.add_response("enable", _("Enable Anyway")) + self.set_response_appearance( + "enable", Adw.ResponseAppearance.DESTRUCTIVE + ) + self.set_default_response("cancel") + self.set_close_response("cancel") + + self.connect("response", self._on_response) + + def _on_response(self, dialog, response_id: str): + if response_id == "enable": + if self._on_enable: + self._on_enable() + elif self._on_cancel: + self._on_cancel() + self.close() diff --git a/rayforge/ui_gtk/addon_manager/license_dialog.py b/rayforge/ui_gtk/addon_manager/license_dialog.py new file mode 100644 index 000000000..d3f24bd7b --- /dev/null +++ b/rayforge/ui_gtk/addon_manager/license_dialog.py @@ -0,0 +1,190 @@ +import logging +import threading +import webbrowser +from collections.abc import Callable +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, GLib, Gtk + +from ...context import get_context +from ...license.gumroad_provider import GumroadProvider + +logger = logging.getLogger(__name__) + + +class LicenseEntryDialog(Adw.MessageDialog): + """ + Dialog for entering a license key. + + Product ID is hidden - user only sees the license key field. + """ + + def __init__( + self, + product_ids: list[str], + addon_name: str, + on_success: Callable[[], None] | None = None, + ): + super().__init__() + + self.product_ids = product_ids + self.addon_name = addon_name + self.on_success_callback = on_success + self._is_validating = False + + self.set_heading(_("Enter License Key")) + self.set_body(self._get_body_text()) + self.license_key_entry = Adw.EntryRow(title=_("License Key")) + self.set_extra_child(self.license_key_entry) + + self.add_response("cancel", _("Cancel")) + self.add_response("add", _("Activate")) + self.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) + self.set_default_response("add") + self.set_close_response("cancel") + + self.connect("response", self._on_response) + + def _get_body_text(self) -> str: + return _( + "Enter the license key you received when purchasing {addon_name}." + ).format(addon_name=self.addon_name) + + def _on_response(self, dialog, response_id): + if response_id != "add" or self._is_validating: + return + + license_key = self.license_key_entry.get_text().strip() + if not license_key: + self._show_error(_("Please enter a license key.")) + return + + self._start_validation(license_key) + + def _start_validation(self, license_key: str): + self._is_validating = True + self.set_body(_("Validating license...")) + self.set_sensitive(False) + + thread = threading.Thread( + target=self._validate_license, + args=(license_key,), + daemon=True, + ) + thread.start() + + def _validate_license(self, license_key: str): + validator = get_context().license_validator + gumroad = validator.get_provider("gumroad") + success = False + error_message = None + + if isinstance(gumroad, GumroadProvider): + for product_id in self.product_ids: + result = gumroad.validate_key(product_id, license_key) + + if result.status.value == "valid": + validator.add_gumroad_license(product_id, license_key) + success = True + break + + error_message = result.message + + GLib.idle_add(self._on_validation_complete, success, error_message) + + def _on_validation_complete( + self, success: bool, error_message: str | None + ): + self._is_validating = False + self.set_sensitive(True) + self.set_body(self._get_body_text()) + + if success: + self._handle_success() + else: + self._show_error(error_message or _("License validation failed.")) + + def _handle_success(self): + if self.on_success_callback: + self.on_success_callback() + self.close() + + def _show_error(self, message: str): + error_dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window | None, self.get_transient_for()), + modal=True, + heading=_("License Invalid"), + body=message, + ) + error_dialog.add_response("ok", _("OK")) + error_dialog.present() + + +class LicenseRequiredDialog(Adw.MessageDialog): + """ + Dialog shown when trying to install/use a premium addon without license. + + Provides options to buy or enter a license key. + """ + + def __init__( + self, + addon_name: str, + product_ids: list[str], + purchase_url: str | None, + on_license_added: Callable[[], None] | None = None, + ): + super().__init__() + + self.product_ids = product_ids + self.purchase_url = purchase_url + self.addon_name = addon_name + self.on_license_added = on_license_added + + self.set_heading(_("License Required")) + self.set_body( + _( + "{addon_name} is a premium addon. Purchase a license " + "to unlock it, or enter your license key if you " + "already have one." + ).format(addon_name=addon_name) + ) + + self.add_response("cancel", _("Cancel")) + if purchase_url: + self.add_response("buy", _("Buy License")) + self.set_response_appearance( + "buy", Adw.ResponseAppearance.SUGGESTED + ) + self.add_response("enter", _("Enter License Key")) + + self.connect("response", self._on_response) + + def _on_response(self, dialog, response_id): + handlers = { + "buy": self._handle_buy, + "enter": self._show_license_entry_dialog, + } + + handler = handlers.get(response_id) + if handler: + handler() + + def _handle_buy(self): + if self.purchase_url: + webbrowser.open(self.purchase_url) + self._show_license_entry_dialog() + + def _show_license_entry_dialog(self): + parent = self.get_transient_for() + self.close() + + entry_dialog = LicenseEntryDialog( + product_ids=self.product_ids, + addon_name=self.addon_name, + on_success=self.on_license_added, + ) + if parent: + entry_dialog.set_transient_for(parent) + entry_dialog.present() diff --git a/rayforge/ui_gtk/array_dialog.py b/rayforge/ui_gtk/array_dialog.py new file mode 100644 index 000000000..c5bfa1410 --- /dev/null +++ b/rayforge/ui_gtk/array_dialog.py @@ -0,0 +1,656 @@ +""" +Non-modal, movable dialogs for the Array / Pattern tool. + +There are three separate dialogs: Grid, Point Rotation and Circular. +Each shares a common base that manages the preview overlay, canvas +drag signals, and model events, but the content and parameter model +are fully independent. +""" + +import logging +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ..context import get_context +from ..core.group import Group +from ..core.item import DocItem +from ..core.workpiece import WorkPiece +from ..doceditor.array import ( + ArrayMode, + ArrayParams, + CircularArrayParams, + GridArrayParams, + PointRotationParams, + SpacingMode, + make_array_strategy, +) +from ..doceditor.array_cmd import ArrayCmd +from .canvas2d.elements.crosshair import CrosshairElement +from .canvas2d.elements.outline import OutlineElement +from .shared.patched_dialog_window import PatchedDialogWindow +from .shared.pref_rows.angle_spin_row import AngleSpinRow +from .shared.pref_rows.base import SpinRow +from .shared.pref_rows.length_spin_row import LengthSpinRow + +if TYPE_CHECKING: + from ..doceditor.editor import DocEditor + from .canvas2d.surface import WorkSurface + +logger = logging.getLogger(__name__) + +_DISPLACEMENT = 0 +_GAP = 1 + + +# --------------------------------------------------------------------------- +# Shared base +# --------------------------------------------------------------------------- +class _BaseArrayDialog(PatchedDialogWindow): + """Common infrastructure for all array dialogs. + + Subclasses must implement :meth:`_mode_content` and + :meth:`_current_params`. + """ + + def __init__( + self, + parent: Gtk.Window, + editor: "DocEditor", + surface: "WorkSurface", + items: list[DocItem], + mode: ArrayMode, + title: str, + ): + super().__init__(transient_for=parent) + self._mode = mode + self._editor = editor + self._surface = surface + self._items = list(items) + self._updating = False + + self.set_title(title) + self.set_default_size(570, -1) + self.set_modal(False) + self.set_resizable(True) + + # Preview overlay. + self._preview = OutlineElement() + self._surface.root.add(self._preview) + + # Mode-specific initialisation (centre, crosshair, etc.) + self._init_mode() + + # Canvas drag -> live preview. + self._surface.transform_moved.connect(self._on_canvas_transform) + + # Model update -> finalise preview. + for item in self._items: + item.transform_changed.connect(self._on_item_moved) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + main_box.append(self._build_header(title)) + self._updating = True + try: + main_box.append(self._mode_content()) + finally: + self._updating = False + + self.connect("close-request", self._on_close_request) + + self._initial_sync() + self._update_preview() + + # ------------------------------------------------------------------ + # Subclass hooks + # ------------------------------------------------------------------ + def _mode_content(self) -> Gtk.Widget: + raise NotImplementedError + + def _current_params(self) -> ArrayParams: + raise NotImplementedError + + def _init_mode(self) -> None: + """Called before _mode_content — override to set up mode-specific + state such as the crosshair and centre/radius defaults.""" + + def _initial_sync(self) -> None: + """Called once after init, before the first preview.""" + + def _sync_params_live(self, bbox, params: ArrayParams) -> None: + """Called on each canvas-drag frame to keep derived values + (e.g. the circular radius) in sync.""" + + def _guide_for(self, params: ArrayParams, bbox) -> tuple | None: + """Return a ``(center, radius)`` guide circle or ``None``.""" + + # ------------------------------------------------------------------ + # Shared UI helpers + # ------------------------------------------------------------------ + def _build_header(self, title: str) -> Adw.HeaderBar: + header = Adw.HeaderBar() + header.set_title_widget( + Adw.WindowTitle.new(title, _("Copies keep their original layers.")) + ) + apply_btn = Gtk.Button(label=_("_Apply"), use_underline=True) + apply_btn.add_css_class("suggested-action") + apply_btn.connect("clicked", self._on_apply_clicked) + header.pack_end(apply_btn) + return header + + def _make_spin_row( + self, + title: str, + lower, + upper, + step, + value=None, + digits: int = 3, + subtitle: str = "", + change_callback=None, + ) -> SpinRow: + row = SpinRow( + title, + subtitle or None, + lower=float(lower), + upper=float(upper), + step_increment=step, + digits=digits, + numeric=True, + value=(lower if value is None else value), + ) + cb = change_callback or self._update_preview + row.value_changed.connect(lambda *a: cb()) + return row + + @staticmethod + def _canvas_center(): + """World-space centre of the machine work area (canvas).""" + machine = get_context().machine + if not machine: + return (0.0, 0.0) + return machine.panel.work_area_center() + + # ------------------------------------------------------------------ + # Preview data + # ------------------------------------------------------------------ + def _items_shapes_and_bbox(self, moved_elements=None): + """Returns ``(shapes, bbox)`` — see ``ArrayDialog``.""" + live = {} + if moved_elements: + for ce in moved_elements: + d = getattr(ce, "data", None) + if d is not None and hasattr(d, "uid"): + live[d.uid] = ce + + min_x = min_y = math.inf + max_x = max_y = -math.inf + shapes = [] + items = ArrayCmd._get_top_level_items(self._items) + for item in items: + if not isinstance(item, (WorkPiece, Group)): + continue + live_elem = live.get(item.uid) + transform = ( + live_elem.get_world_transform() + if live_elem is not None + else item.get_world_transform() + ) + corners = [ + transform.transform_point(p) + for p in [(0, 0), (1, 0), (1, 1), (0, 1)] + ] + shapes.append(corners) + for x, y in corners: + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x) + max_y = max(max_y, y) + if math.isinf(min_x): + return shapes, None + return shapes, (min_x, min_y, max_x, max_y) + + # ------------------------------------------------------------------ + # Signal handlers + # ------------------------------------------------------------------ + def _on_item_moved(self, sender=None, **kwargs) -> None: + if self._updating: + return + self._update_preview() + + def _on_canvas_transform(self, sender, **kwargs) -> None: + if self._updating: + return + elements = kwargs.get("elements") + shapes, bbox = self._items_shapes_and_bbox(elements) + if not bbox: + return + params = self._current_params() + self._sync_params_live(bbox, params) + params = self._current_params() # re-read if sync changed them + deltas = make_array_strategy(bbox, params).calculate_placements() + guide = self._guide_for(params, bbox) + self._preview.set_outlines(shapes, deltas, guide_circle=guide) + + def _update_preview(self) -> None: + if self._updating: + return + shapes, bbox = self._items_shapes_and_bbox() + params = self._current_params() + if bbox: + deltas = make_array_strategy(bbox, params).calculate_placements() + else: + deltas = [] + guide = self._guide_for(params, bbox) + self._preview.set_outlines(shapes, deltas, guide_circle=guide) + + # ------------------------------------------------------------------ + # Commit / cleanup + # ------------------------------------------------------------------ + def _on_apply_clicked(self, button) -> None: + params = self._current_params() + created = self._editor.array.create_array(self._items, params) + if created: + self._surface.select_items(list(self._items) + created) + self._detach_preview() + self.close() + + def _on_close_request(self, window) -> bool: + self._detach_preview() + return False + + def _detach_preview(self) -> None: + self._preview.clear() + self._surface.transform_moved.disconnect(self._on_canvas_transform) + for item in self._items: + item.transform_changed.disconnect(self._on_item_moved) + if self._preview.parent is not None: + self._preview.remove() + self._preview.canvas = None + + +# --------------------------------------------------------------------------- +# Grid +# --------------------------------------------------------------------------- +class GridArrayDialog(_BaseArrayDialog): + """Creates a rows x columns grid of the selection.""" + + def __init__(self, parent, editor, surface, items): + super().__init__( + parent, + editor, + surface, + items, + ArrayMode.GRID, + _("Grid Array"), + ) + + def _mode_content(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(18) + box.set_margin_bottom(18) + box.set_margin_start(18) + box.set_margin_end(18) + group = Adw.PreferencesGroup() + group.set_title(_("Grid")) + + self._rows_row = self._make_spin_row(_("Rows"), 1, 100, 1, 2, 0) + self._cols_row = self._make_spin_row(_("Columns"), 1, 100, 1, 2, 0) + self._spacing_mode_row = Adw.ComboRow( + model=Gtk.StringList.new([_("Displacement"), _("Gap")]) + ) + self._spacing_mode_row.set_title(_("Spacing")) + self._spacing_mode_row.set_subtitle( + _("Displacement is center-to-center; gap is edge-to-edge.") + ) + self._spacing_mode_row.set_selected(_GAP) + self._spacing_mode_row.connect( + "notify::selected", self._on_spacing_mode_changed + ) + self._col_spacing_row = LengthSpinRow( + _("Column spacing"), + lower=-10000, + upper=10000, + value_in_base=1.0, + ) + self._col_spacing_row.value_changed.connect( + lambda *a: self._update_preview() + ) + self._row_spacing_row = LengthSpinRow( + _("Row spacing"), + lower=-10000, + upper=10000, + value_in_base=1.0, + ) + self._row_spacing_row.value_changed.connect( + lambda *a: self._update_preview() + ) + + for r in ( + self._rows_row, + self._cols_row, + self._spacing_mode_row, + self._col_spacing_row, + self._row_spacing_row, + ): + group.add(r) + box.append(group) + return box + + def _current_params(self) -> ArrayParams: + spacing = ( + SpacingMode.GAP + if self._spacing_mode_row.get_selected() == _GAP + else SpacingMode.DISPLACEMENT + ) + return ArrayParams( + mode=ArrayMode.GRID, + grid=GridArrayParams( + rows=self._rows_row.get_int_value(), + cols=self._cols_row.get_int_value(), + spacing_mode=spacing, + col_spacing_mm=( + self._col_spacing_row.get_value_in_base_units() + ), + row_spacing_mm=( + self._row_spacing_row.get_value_in_base_units() + ), + ), + ) + + def _initial_sync(self) -> None: + self._last_spacing_mode = self._spacing_mode_row.get_selected() + + def _on_spacing_mode_changed(self, *args) -> None: + if self._updating: + return + new_mode = self._spacing_mode_row.get_selected() + old_mode = self._last_spacing_mode + if new_mode != old_mode: + self._translate_spacing(old_mode, new_mode) + self._last_spacing_mode = new_mode + self._update_preview() + + def _translate_spacing(self, from_mode: int, to_mode: int) -> None: + bbox = self._editor.array.get_selection_bbox(self._items) + if not bbox: + return + unit_w = bbox[2] - bbox[0] + unit_h = bbox[3] - bbox[1] + col = self._col_spacing_row.get_value_in_base_units() + row = self._row_spacing_row.get_value_in_base_units() + sign = -1.0 if to_mode == _GAP else 1.0 + self._updating = True + try: + self._col_spacing_row.set_value_in_base_units(col + sign * unit_w) + self._row_spacing_row.set_value_in_base_units(row + sign * unit_h) + finally: + self._updating = False + + +# --------------------------------------------------------------------------- +# Point Rotation +# --------------------------------------------------------------------------- +class PointRotationArrayDialog(_BaseArrayDialog): + """Rotates copies around the selection's own centre.""" + + def __init__(self, parent, editor, surface, items): + super().__init__( + parent, + editor, + surface, + items, + ArrayMode.POINT_ROTATION, + _("Point Rotation Array"), + ) + + def _mode_content(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(18) + box.set_margin_bottom(18) + box.set_margin_start(18) + box.set_margin_end(18) + group = Adw.PreferencesGroup() + group.set_title(_("Point Rotation")) + group.set_description( + _("Rotates copies in place around the selection's centre.") + ) + self._pr_count_row = self._make_spin_row(_("Count"), 1, 360, 1, 6, 0) + self._pr_angle_row = AngleSpinRow(_("Total angle (deg)"), value=360.0) + self._pr_angle_row.value_changed.connect( + lambda *a: self._update_preview() + ) + for r in (self._pr_count_row, self._pr_angle_row): + group.add(r) + box.append(group) + return box + + def _current_params(self) -> ArrayParams: + return ArrayParams( + mode=ArrayMode.POINT_ROTATION, + point_rotation=PointRotationParams( + count=self._pr_count_row.get_int_value(), + total_angle_deg=self._pr_angle_row.get_value(), + ), + ) + + +# --------------------------------------------------------------------------- +# Circular +# --------------------------------------------------------------------------- +class CircularArrayDialog(_BaseArrayDialog): + """Places copies along a circular arc around a centre.""" + + def __init__(self, parent, editor, surface, items): + super().__init__( + parent, + editor, + surface, + items, + ArrayMode.CIRCULAR, + _("Circular Array"), + ) + + def _init_mode(self) -> None: + """Set up centre default and draggable crosshair.""" + bbox = self._editor.array.get_selection_bbox(self._items) + sel_center = ( + ((bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0) + if bbox + else (0.0, 0.0) + ) + centre = self._canvas_center() + self._crosshair = CrosshairElement(on_drag=self._on_center_dragged) + self._crosshair.move_to(centre[0], centre[1]) + self._surface.root.add(self._crosshair) + self._centre = centre + self._default_radius = ( + math.hypot(centre[0] - sel_center[0], centre[1] - sel_center[1]) + if bbox + else 10.0 + ) + + def _mode_content(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(18) + box.set_margin_bottom(18) + box.set_margin_start(18) + box.set_margin_end(18) + group = Adw.PreferencesGroup() + group.set_title(_("Circular")) + group.set_description( + _("Places copies along a circular arc around a centre.") + ) + self._c_count_row = self._make_spin_row(_("Count"), 1, 360, 1, 6, 0) + self._c_angle_row = AngleSpinRow(_("Total angle (deg)"), value=360.0) + self._c_angle_row.value_changed.connect( + lambda *a: self._update_preview() + ) + self._c_center_x_row = LengthSpinRow( + _("Center X"), + lower=-10000, + upper=10000, + value_in_base=self._centre[0], + ) + self._c_center_x_row.value_changed.connect( + lambda *a: self._on_center_changed() + ) + self._c_center_y_row = LengthSpinRow( + _("Center Y"), + lower=-10000, + upper=10000, + value_in_base=self._centre[1], + ) + self._c_center_y_row.value_changed.connect( + lambda *a: self._on_center_changed() + ) + r = self._default_radius if self._default_radius > 0.0 else 10.0 + self._c_radius_row = LengthSpinRow( + _("Radius"), + upper=10000, + value_in_base=r, + ) + self._c_radius_row.value_changed.connect( + lambda *a: self._on_radius_changed() + ) + self._c_rotate_row = Adw.ActionRow() + self._c_rotate_row.set_title(_("Rotate copies")) + self._c_rotate_switch = Gtk.Switch() + self._c_rotate_switch.set_active(True) + self._c_rotate_switch.set_valign(Gtk.Align.CENTER) + self._c_rotate_switch.connect( + "notify::active", lambda *a: self._update_preview() + ) + self._c_rotate_row.add_suffix(self._c_rotate_switch) + self._c_rotate_row.set_activatable_widget(self._c_rotate_switch) + + for r in ( + self._c_count_row, + self._c_angle_row, + self._c_center_x_row, + self._c_center_y_row, + self._c_radius_row, + self._c_rotate_row, + ): + group.add(r) + box.append(group) + return box + + def _current_params(self) -> ArrayParams: + return ArrayParams( + mode=ArrayMode.CIRCULAR, + circular=CircularArrayParams( + count=self._c_count_row.get_int_value(), + total_angle_deg=self._c_angle_row.get_value(), + center_mm=( + self._c_center_x_row.get_value_in_base_units(), + self._c_center_y_row.get_value_in_base_units(), + ), + radius_mm=self._c_radius_row.get_value_in_base_units(), + rotate_copies=self._c_rotate_switch.get_active(), + ), + ) + + def _sync_params_live(self, bbox, params) -> None: + """Keep the radius matching the current centre & workpiece.""" + ux = (bbox[0] + bbox[2]) / 2.0 + uy = (bbox[1] + bbox[3]) / 2.0 + cx = self._c_center_x_row.get_value_in_base_units() + cy = self._c_center_y_row.get_value_in_base_units() + r = math.hypot(ux - cx, uy - cy) + self._updating = True + try: + self._c_radius_row.set_value_in_base_units(r) + finally: + self._updating = False + + def _guide_for(self, params, bbox): + if not bbox: + return None + return (params.circular.center_mm, params.circular.radius_mm) + + def _initial_sync(self) -> None: + self._sync_radius() + + def _sync_radius(self) -> None: + _, bbox = self._items_shapes_and_bbox() + if not bbox: + return + ux = (bbox[0] + bbox[2]) / 2.0 + uy = (bbox[1] + bbox[3]) / 2.0 + cx = self._c_center_x_row.get_value_in_base_units() + cy = self._c_center_y_row.get_value_in_base_units() + r = math.hypot(ux - cx, uy - cy) + was = self._updating + self._updating = True + try: + self._c_radius_row.set_value_in_base_units(r) + finally: + self._updating = was + + def _on_center_changed(self) -> None: + if self._updating: + return + self._sync_radius() + self._crosshair_sync() + self._update_preview() + + def _on_center_dragged(self, pos) -> None: + self._updating = True + try: + self._c_center_x_row.set_value_in_base_units(pos[0]) + self._c_center_y_row.set_value_in_base_units(pos[1]) + if self._crosshair is not None: + self._crosshair.move_to(pos[0], pos[1]) + self._sync_radius() + finally: + self._updating = False + self._update_preview() + + def _crosshair_sync(self) -> None: + if self._crosshair is not None: + self._crosshair.move_to( + self._c_center_x_row.get_value_in_base_units(), + self._c_center_y_row.get_value_in_base_units(), + ) + + def _on_radius_changed(self) -> None: + if self._updating: + return + _, bbox = self._items_shapes_and_bbox() + if not bbox: + return + ux = (bbox[0] + bbox[2]) / 2.0 + uy = (bbox[1] + bbox[3]) / 2.0 + cx = self._c_center_x_row.get_value_in_base_units() + cy = self._c_center_y_row.get_value_in_base_units() + new_r = self._c_radius_row.get_value_in_base_units() + dx = cx - ux + dy = cy - uy + cur_d = math.hypot(dx, dy) + if cur_d > 1e-9: + was = self._updating + self._updating = True + try: + self._c_center_x_row.set_value_in_base_units( + ux + (dx / cur_d) * new_r + ) + self._c_center_y_row.set_value_in_base_units( + uy + (dy / cur_d) * new_r + ) + finally: + self._updating = was + self._update_preview() + + def _detach_preview(self) -> None: + if self._crosshair is not None and self._crosshair.parent is not None: + self._crosshair.remove() + self._crosshair = None + super()._detach_preview() + + def _update_preview(self) -> None: + self._crosshair_sync() + super()._update_preview() diff --git a/rayforge/ui_gtk/camera/__init__.py b/rayforge/ui_gtk/camera/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/camera/alignment_dialog.py b/rayforge/ui_gtk/camera/alignment_dialog.py new file mode 100644 index 000000000..50644ab3d --- /dev/null +++ b/rayforge/ui_gtk/camera/alignment_dialog.py @@ -0,0 +1,90 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow +from .alignment_widget import CameraAlignment + +logger = logging.getLogger(__name__) + + +class CameraAlignmentDialog(PatchedDialogWindow): + def __init__(self, parent, controller: CameraController, **kwargs): + super().__init__( + transient_for=parent, + modal=True, + default_width=1280, + default_height=960, + **kwargs, + ) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(content) + + header_bar = Adw.HeaderBar() + header_title = _("{camera_name} – Image Alignment").format( + camera_name=controller.config.name + ) + header_bar.set_title_widget( + Adw.WindowTitle(title=header_title, subtitle="") + ) + content.append(header_bar) + + zoom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + zoom_box.add_css_class("linked") + + btn_zoom_out = Gtk.Button( + child=get_icon("zoom-out-symbolic"), + tooltip_text=_("Zoom Out (Scroll Down)"), + ) + btn_zoom_out.connect("clicked", lambda _: self._widget.zoom_out()) + btn_zoom_fit = Gtk.Button( + child=get_icon("zoom-fit-best-symbolic"), + tooltip_text=_("Fit to Window"), + ) + btn_zoom_fit.connect("clicked", lambda _: self._widget.zoom_fit()) + btn_zoom_in = Gtk.Button( + child=get_icon("zoom-in-symbolic"), + tooltip_text=_("Zoom In (Scroll Up)"), + ) + btn_zoom_in.connect("clicked", lambda _: self._widget.zoom_in()) + zoom_box.append(btn_zoom_out) + zoom_box.append(btn_zoom_fit) + zoom_box.append(btn_zoom_in) + header_bar.pack_start(zoom_box) + + self._widget = CameraAlignment(controller) + self._widget.applied.connect(lambda *_: self.close()) + self._widget.set_margin_start(12) + self._widget.set_margin_end(12) + self._widget.set_margin_top(12) + content.append(self._widget) + + # The alignment surface owns its Reset/Clear/Apply buttons but + # does not place them itself; the dialog hosts them in a + # bottom button row. + btn_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + halign=Gtk.Align.END, + margin_top=12, + margin_bottom=12, + margin_start=12, + margin_end=12, + ) + for btn in self._widget.footer_buttons(): + btn_box.append(btn) + content.append(btn_box) + + def do_close_request(self, *args) -> bool: + logger.debug( + f"CameraAlignmentDialog closing for {self._widget.camera.name}" + ) + self._widget.stop() + return False + + +__all__ = ["CameraAlignmentDialog"] diff --git a/rayforge/ui_gtk/camera/alignment_widget.py b/rayforge/ui_gtk/camera/alignment_widget.py new file mode 100644 index 000000000..c3314f627 --- /dev/null +++ b/rayforge/ui_gtk/camera/alignment_widget.py @@ -0,0 +1,540 @@ +"""Reusable image↔world alignment widget. + +Owns the live camera surface, the point-pair list, the bubble editor +and the apply logic. Used by both :class:`CameraAlignmentDialog` +and the camera wizard's alignment page so the two stay in sync. +""" + +import logging +import math +from datetime import datetime, timezone +from gettext import gettext as _ + +import numpy as np +from gi.repository import Gdk, GLib, Graphene, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import Pos +from ..canvas.worldsurface import WorldSurface +from ..icons import get_icon +from ..shared.gtk import apply_css +from .point_bubble_widget import PointBubbleWidget + +logger = logging.getLogger(__name__) + + +class CameraAlignmentSurface(WorldSurface): + def __init__(self, owner, controller: CameraController, **kwargs): + w, h = controller.resolution + super().__init__( + width_mm=w, height_mm=h, show_grid=False, show_axis=False, **kwargs + ) + self.owner = owner + self.controller = controller + + self.controller.subscribe() + self.controller.image_captured.connect(self._on_image_captured) + + self.dragging_point_index = -1 + self.drag_offset_x = 0.0 + self.drag_offset_y = 0.0 + + click = Gtk.GestureClick.new() + click.set_button(Gdk.BUTTON_PRIMARY) + click.connect("pressed", self.on_image_click) + self.add_controller(click) + + drag = Gtk.GestureDrag.new() + drag.set_button(Gdk.BUTTON_PRIMARY) + drag.connect("drag-begin", self.on_drag_begin) + drag.connect("drag-update", self.on_drag_update) + drag.connect("drag-end", self.on_drag_end) + self.add_controller(drag) + + def stop(self): + self.controller.unsubscribe() + + def _on_image_captured(self, _): + w, h = self.controller.resolution + if w != self.width_mm or h != self.height_mm: + self.set_size(w, h) + self.queue_draw() + + def get_image_coords(self, x, y): + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w <= 0 or widget_h <= 0: + return 0.0, 0.0 + content_x, content_y, content_w, content_h = ( + self._axis_renderer.get_content_layout(widget_w, widget_h) + ) + scale_x = content_w / self.width_mm if self.width_mm > 0 else 1 + scale_y = content_h / self.height_mm if self.height_mm > 0 else 1 + vx = x - content_x + vy = y - content_y + vx /= self.zoom_level + vy /= self.zoom_level + vx /= scale_x + vy = (content_h - vy) / scale_y + world_x = vx + self.pan_x_mm + world_y = vy + self.pan_y_mm + return world_x, world_y + + def _find_point_near(self, x, y, threshold=10): + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w <= 0 or widget_h <= 0: + return -1 + _content_x, _content_y, content_w, _content_h = ( + self._axis_renderer.get_content_layout(widget_w, widget_h) + ) + scale_x = content_w / self.width_mm if self.width_mm > 0 else 1 + scaled_threshold = threshold / (self.zoom_level * scale_x) + for i, pt in enumerate(self.owner.image_points or []): + if ( + pt is not None + and math.hypot(pt[0] - x, pt[1] - y) < scaled_threshold + ): + return i + return -1 + + def on_image_click(self, gesture, n, x, y): + image_x, image_y = self.get_image_coords(x, y) + point_index = self._find_point_near(image_x, image_y) + if point_index >= 0: + self.owner.set_active_point(point_index) + else: + self.owner.image_points.append((image_x, image_y)) + self.owner.world_points.append((0.0, 0.0)) + self.owner.set_active_point(len(self.owner.image_points) - 1) + self.queue_draw() + self.owner.update_apply_button_sensitivity() + + def on_drag_begin(self, gesture, start_x, start_y): + self.owner._interaction_in_progress = True + image_x, image_y = self.get_image_coords(start_x, start_y) + point_index = self._find_point_near(image_x, image_y) + if point_index >= 0: + self.dragging_point_index = point_index + pt = self.owner.image_points[point_index] + if pt is not None: + self.drag_offset_x = pt[0] - image_x + self.drag_offset_y = pt[1] - image_y + else: + self.drag_offset_x = 0.0 + self.drag_offset_y = 0.0 + self.owner.set_active_point(point_index) + self.owner._interaction_in_progress = True + self.owner.bubble.set_visible(True) + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + else: + self.dragging_point_index = -1 + gesture.set_state(Gtk.EventSequenceState.DENIED) + + def on_drag_update(self, gesture, offset_x, offset_y): + idx = self.dragging_point_index + if idx < 0: + return + ok, start_x, start_y = gesture.get_start_point() + if not ok: + return + current_x = start_x + offset_x + current_y = start_y + offset_y + image_x, image_y = self.get_image_coords(current_x, current_y) + new_x = image_x + self.drag_offset_x + new_y = image_y + self.drag_offset_y + self.owner.image_points[idx] = (new_x, new_y) + if idx == self.owner.active_point_index: + self.owner.bubble.set_image_coords(new_x, new_y) + self.owner._position_bubble() + self.queue_draw() + + def on_drag_end(self, gesture, offset_x, offset_y): + self.owner._interaction_in_progress = False + if self.dragging_point_index >= 0: + self.dragging_point_index = -1 + self.owner._position_bubble() + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + width, height = self.get_width(), self.get_height() + ctx = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) + content_x, content_y, content_w, content_h = ( + self._axis_renderer.get_content_layout(width, height) + ) + scale_x = content_w / self.width_mm if self.width_mm > 0 else 1 + scale_y = content_h / self.height_mm if self.height_mm > 0 else 1 + ctx.save() + ctx.translate(content_x, content_y) + ctx.scale(self.zoom_level, self.zoom_level) + ctx.translate(0, content_h) + ctx.scale(scale_x, -scale_y) + ctx.translate(-self.pan_x_mm, -self.pan_y_mm) + pixbuf = self.controller.pixbuf + if pixbuf: + ctx.save() + ctx.translate(0, self.height_mm) + ctx.scale(1, -1) + Gdk.cairo_set_source_pixbuf(ctx, pixbuf, 0, 0) + ctx.paint() + ctx.restore() + for i, pt in enumerate(self.owner.image_points): + if pt is not None: + world_x = pt[0] + world_y = pt[1] + radius = 5 / (self.zoom_level * scale_x) + ctx.arc(world_x, world_y, radius, 0, 2 * 3.14159) + if i == self.owner.active_point_index: + ctx.set_source_rgba(1, 0.2, 0.2, 0.8) + else: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.8) + ctx.fill() + ctx.set_source_rgba(1, 1, 1, 1) + ctx.set_line_width(1.5 / (self.zoom_level * scale_x)) + ctx.stroke() + ctx.restore() + self._update_theme_colors() + self._axis_renderer.draw_grid_and_labels( + ctx, self.view_transform, width, height + ) + + +class CameraAlignment(Gtk.Box): + """Live alignment surface + point-pair editor for one camera. + + Emits ``applied`` when the user applies a valid alignment; the + owner (dialog or wizard page) can then close / advance. + """ + + def __init__(self, controller: CameraController, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + from blinker import Signal + + self.controller = controller + self.camera = controller.config + self.image_points: list[Pos | None] = [] + self.world_points: list[Pos] = [] + self.active_point_index = -1 + self._display_ready = False + self._interaction_in_progress = False + self.applied = Signal() + + apply_css( + """ + .info-highlight { + background-color: @accent_bg_color; + color: @accent_fg_color; + border-radius: 6px; + padding: 8px 12px; + } + """ + ) + + self._build_ui() + + def _build_ui(self) -> None: + self.set_spacing(6) + + self.main_overlay = Gtk.Overlay() + self.append(self.main_overlay) + + self.camera_display = CameraAlignmentSurface(self, self.controller) + self.camera_display.set_hexpand(True) + self.camera_display.set_vexpand(True) + self.main_overlay.set_child(self.camera_display) + + self.bubble = PointBubbleWidget(0) + self.bubble.set_hexpand(False) + self.bubble.set_vexpand(False) + self.main_overlay.add_overlay(self.bubble) + self.bubble.set_halign(Gtk.Align.START) + self.bubble.set_valign(Gtk.Align.START) + self.bubble.set_visible(False) + self.bubble.value_changed.connect(self.update_apply_button_sensitivity) + self.bubble.delete_requested.connect(self.on_point_delete_requested) + self.bubble.focus_requested.connect(self.on_bubble_focus_requested) + self.bubble.nudge_requested.connect(self.on_nudge_requested) + + self.info_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + margin_top=24, + margin_start=12, + margin_end=12, + ) + self.info_box.add_css_class("info-highlight") + self.info_box.set_valign(Gtk.Align.START) + self.info_box.set_halign(Gtk.Align.CENTER) + self.main_overlay.add_overlay(self.info_box) + + icon = get_icon("info-symbolic") + icon.set_valign(Gtk.Align.CENTER) + self.info_box.append(icon) + + info_text = _( + "Click the image to add reference points. Drag to move " + "them.\nScroll to Zoom. Middle-click and drag to Pan.\n" + "Use the Arrow Keys to nudge the active point precisely." + ) + info_label = Gtk.Label(label=info_text, xalign=0) + info_label.set_wrap(True) + info_label.set_hexpand(True) + self.info_box.append(info_label) + + dismiss_button = Gtk.Button(child=get_icon("close-symbolic")) + dismiss_button.add_css_class("flat") + dismiss_button.set_valign(Gtk.Align.CENTER) + dismiss_button.connect("clicked", lambda btn: self.info_box.hide()) + self.info_box.append(dismiss_button) + + # Footer-action buttons. Not appended to the widget itself — + # the host (dialog or wizard page) places them in its footer + # bar via :meth:`footer_buttons`. + self.reset_button = Gtk.Button(label=_("Reset Points")) + self.reset_button.add_css_class("flat") + self.reset_button.connect("clicked", self.on_reset_points_clicked) + + self.clear_button = Gtk.Button(label=_("Clear All Points")) + self.clear_button.add_css_class("flat") + self.clear_button.connect("clicked", self.on_clear_all_points_clicked) + + self.apply_button = Gtk.Button(label=_("Apply")) + self.apply_button.add_css_class("suggested-action") + self.apply_button.connect("clicked", self.on_apply_clicked) + + key_controller = Gtk.EventControllerKey.new() + key_controller.connect("key-pressed", self.on_key_pressed) + self.add_controller(key_controller) + + self.camera_display.connect("realize", self._on_display_ready) + + if self.camera.image_to_world: + img_pts, wld_pts = self.camera.image_to_world + self.image_points, self.world_points = list(img_pts), list(wld_pts) + self.set_active_point(0) + self.update_apply_button_sensitivity() + + # ----- zoom -------------------------------------------------------- + + def zoom_in(self) -> None: + self.camera_display.set_zoom( + min(10.0, self.camera_display.zoom_level * 1.25) + ) + + def zoom_out(self) -> None: + self.camera_display.set_zoom( + max(0.1, self.camera_display.zoom_level / 1.25) + ) + + def zoom_fit(self) -> None: + self.camera_display.reset_view() + + # ----- bubble / points -------------------------------------------- + + def _on_display_ready(self, *args) -> None: + if not self._display_ready: + self._display_ready = True + GLib.idle_add(self._position_bubble) + else: + self._position_bubble() + + def _calculate_bubble_margins( + self, img_x: float, img_y: float + ) -> tuple[bool, int, int]: + surface = self.camera_display + widget_w, widget_h = surface.get_width(), surface.get_height() + if widget_w <= 0 or widget_h <= 0: + return False, 0, 0 + content_x, content_y, content_w, content_h = ( + surface._axis_renderer.get_content_layout(widget_w, widget_h) + ) + scale_x = content_w / surface.width_mm if surface.width_mm > 0 else 1 + scale_y = content_h / surface.height_mm if surface.height_mm > 0 else 1 + vx = (img_x - surface.pan_x_mm) * scale_x + vy = (img_y - surface.pan_y_mm) * scale_y + vy = content_h - vy + vx *= surface.zoom_level + vy *= surface.zoom_level + display_x = vx + content_x + display_y = vy + content_y + alloc = self.bubble.get_allocation() + bubble_width, bubble_height = alloc.width, alloc.height + x = display_x - (bubble_width / 2) + x = max(12, min(x, widget_w - bubble_width - 12)) + y = display_y + 16 + if y + bubble_height > widget_h - 12: + y = display_y - bubble_height - 16 + return True, int(x), int(y) + + def _position_bubble(self) -> bool: + if self._interaction_in_progress: + return GLib.SOURCE_REMOVE + if not self._display_ready or self.active_point_index < 0: + return GLib.SOURCE_REMOVE + coords = self.image_points[self.active_point_index] + if coords is None: + return GLib.SOURCE_REMOVE + visible, x, y = self._calculate_bubble_margins(coords[0], coords[1]) + if not visible: + return GLib.SOURCE_REMOVE + if self.bubble.get_margin_start() != x: + self.bubble.set_margin_start(x) + if self.bubble.get_margin_top() != y: + self.bubble.set_margin_top(y) + if ( + not self.bubble.get_visible() + and self.camera_display.dragging_point_index == -1 + ): + self.bubble.set_visible(True) + return GLib.SOURCE_REMOVE + + def set_active_point(self, index: int, widget=None) -> None: + if index < 0 or index >= len(self.image_points): + self.active_point_index = -1 + self.bubble.set_visible(False) + self.camera_display.queue_draw() + return + self.active_point_index = index + self.bubble.set_point_index(index) + coords = self.image_points[index] + if coords is not None: + self.bubble.set_image_coords(*coords) + self.bubble.set_world_coords(*self.world_points[index]) + if self.camera_display.dragging_point_index == -1: + self._interaction_in_progress = False + self._position_bubble() + (widget or self.bubble.world_x_spin).grab_focus() + self.camera_display.queue_draw() + + def on_bubble_focus_requested(self, bubble, widget) -> None: + self.set_active_point(self.active_point_index, widget) + + def on_nudge_requested(self, bubble, dx, dy) -> None: + if self.active_point_index < 0: + return + p = self.image_points[self.active_point_index] + if p is None: + return + nx, ny = p[0] + dx, p[1] + dy + self.image_points[self.active_point_index] = (nx, ny) + self.bubble.set_image_coords(nx, ny) + self._position_bubble() + self.camera_display.queue_draw() + + def on_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_Escape: + return Gdk.EVENT_PROPAGATE + root = self.get_root() + focus_widget = ( + root.get_focus() if isinstance(root, Gtk.Window) else None + ) + is_typing = isinstance(focus_widget, (Gtk.Text, Gtk.SpinButton)) + if not is_typing and self.active_point_index >= 0: + dx, dy = 0.0, 0.0 + step = 5.0 if (state & Gdk.ModifierType.SHIFT_MASK) else 0.5 + if keyval == Gdk.KEY_Up: + dy = step + elif keyval == Gdk.KEY_Down: + dy = -step + elif keyval == Gdk.KEY_Left: + dx = -step + elif keyval == Gdk.KEY_Right: + dx = step + elif keyval in (Gdk.KEY_Delete, Gdk.KEY_BackSpace): + self.on_point_delete_requested(self.bubble) + return Gdk.EVENT_STOP + if dx != 0.0 or dy != 0.0: + self.on_nudge_requested(self.bubble, dx, dy) + return Gdk.EVENT_STOP + return Gdk.EVENT_PROPAGATE + + def on_reset_points_clicked(self, _) -> None: + self.image_points.clear() + self.world_points.clear() + if self.camera.image_to_world: + image_points_data, world_points_data = self.camera.image_to_world + self.image_points, self.world_points = ( + list(image_points_data), + list(world_points_data), + ) + else: + self.image_points = [None] * 4 + self.world_points = [(0.0, 0.0)] * 4 + self.set_active_point(0) + self.camera_display.queue_draw() + self.update_apply_button_sensitivity() + + def on_clear_all_points_clicked(self, _) -> None: + self.image_points.clear() + self.world_points.clear() + self.set_active_point(-1) + self.camera_display.queue_draw() + self.update_apply_button_sensitivity() + + def on_point_delete_requested(self, bubble) -> None: + index = bubble.point_index + if 0 <= index < len(self.image_points): + self.image_points.pop(index) + self.world_points.pop(index) + if self.image_points: + self.set_active_point(min(index, len(self.image_points) - 1)) + else: + self.set_active_point(-1) + self.camera_display.queue_draw() + self.update_apply_button_sensitivity() + + def update_apply_button_sensitivity(self, *_) -> None: + idx = self.active_point_index + if idx >= 0 and idx < len(self.world_points): + self.world_points[idx] = self.bubble.get_world_coords() + valid_points = [ + (img, self.world_points[i]) + for i, img in enumerate(self.image_points or []) + if img + ] + can_apply = len(valid_points) >= 4 + if can_apply: + image_coords = np.array([p[0] for p in valid_points]) + world_coords = np.array([p[1] for p in valid_points]) + image_points_matrix = np.hstack( + [image_coords, np.ones((len(valid_points), 1))] + ) + world_points_matrix = np.hstack( + [world_coords, np.ones((len(valid_points), 1))] + ) + world_points_are_unique = len( + {tuple(p) for p in world_coords} + ) == len(world_coords) + can_apply = ( + np.linalg.matrix_rank(image_points_matrix) >= 3 + and np.linalg.matrix_rank(world_points_matrix) >= 3 + and world_points_are_unique + ) + self.apply_button.set_sensitive(can_apply) + + def on_apply_clicked(self, _) -> None: + image_points = [] + world_points = [] + for i, img_coords in enumerate(self.image_points or []): + if not img_coords: + continue + world_x, world_y = ( + self.bubble.get_world_coords() + if i == self.active_point_index + else self.world_points[i] + ) + image_points.append(img_coords) + world_points.append((world_x, world_y)) + if len(image_points) < 4: + raise ValueError("Less than 4 points for alignment.") + self.camera.image_to_world = (image_points, world_points) + self.camera.alignment_date = datetime.now(tz=timezone.utc) + logger.info("Camera alignment applied.") + self.applied.send(self) + + def footer_buttons(self) -> list: + """Reset / Clear / Apply buttons for the host's footer bar.""" + return [self.reset_button, self.clear_button, self.apply_button] + + def stop(self) -> None: + self.camera_display.stop() + + +__all__ = ["CameraAlignment", "CameraAlignmentSurface"] diff --git a/rayforge/ui_gtk/camera/camera_preferences_page.py b/rayforge/ui_gtk/camera/camera_preferences_page.py new file mode 100644 index 000000000..ab6ae4b1c --- /dev/null +++ b/rayforge/ui_gtk/camera/camera_preferences_page.py @@ -0,0 +1,381 @@ +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import Camera +from ...camera.v4l import display_name +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage +from ..shared.slider import create_slider +from .properties_widget import CameraProperties +from .selection_dialog import CameraSelectionDialog + + +class CameraRow(Gtk.Box): + """A widget representing a single Camera in a ListBox.""" + + def __init__(self, camera: Camera): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.camera = camera + self.delete_button: Gtk.Button + self.title_label: Gtk.Label + self.subtitle_label: Gtk.Label + self._setup_ui() + + # Signals + self.remove_clicked = Signal() + """Signal emitted when the remove button is clicked. + Sends: sender, camera (Camera) + """ + + def _setup_ui(self): + """Builds the user interface for the row.""" + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(labels_box) + + self.title_label = Gtk.Label( + label=self.camera.name, + halign=Gtk.Align.START, + xalign=0, + ) + labels_box.append(self.title_label) + + self.subtitle_label = Gtk.Label( + label=self._get_subtitle_text(), + halign=Gtk.Align.START, + xalign=0, + ) + self.subtitle_label.add_css_class("dim-label") + labels_box.append(self.subtitle_label) + + self.delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + self.delete_button.add_css_class("flat") + self.delete_button.connect("clicked", self._on_remove_clicked) + self.append(self.delete_button) + + def _get_subtitle_text(self) -> str: + """Generates the subtitle text from camera properties.""" + name = display_name(self.camera.device_id) + return _("Device ID: {device_id}").format(device_id=name) + + def _on_remove_clicked(self, button: Gtk.Button): + """Emits a signal requesting the removal of this camera.""" + self.remove_clicked.send(self, camera=self.camera) + + +class CameraListEditor(PreferencesGroupWithButton): + """An Adwaita widget for displaying and managing a list of cameras.""" + + def __init__(self, **kwargs): + super().__init__(button_label=_("Add New Camera"), **kwargs) + self._setup_ui() + + # Signals + self.add_requested = Signal() + """Signal emitted when the 'Add New Camera' button is clicked.""" + self.remove_requested = Signal() + """Signal emitted when a camera's remove button is clicked. + Sends: sender, camera (Camera) + """ + + def _setup_ui(self): + """Configures the widget's list box and placeholder.""" + placeholder = Gtk.Label( + label=_("No cameras configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.list_box.set_show_separators(True) + + def set_cameras(self, cameras: list[Camera]): + """Rebuilds the list to match the provided list of cameras.""" + selected_camera = None + selected_row = self.list_box.get_selected_row() + if selected_row: + camera_row_widget = cast(CameraRow, selected_row.get_child()) + selected_camera = camera_row_widget.camera + + row_count = 0 + while self.list_box.get_row_at_index(row_count): + row_count += 1 + + new_selection_index = -1 + for i, camera in enumerate(cameras): + if camera == selected_camera: + new_selection_index = i + + if i < row_count: + row = self.list_box.get_row_at_index(i) + if not row: + continue + camera_row = cast(CameraRow, row.get_child()) + camera_row.camera = camera + camera_row.title_label.set_label(camera.name) + camera_row.subtitle_label.set_label( + camera_row._get_subtitle_text() + ) + else: + list_box_row = Gtk.ListBoxRow() + list_box_row.set_child(self.create_row_widget(camera)) + self.list_box.append(list_box_row) + + while row_count > len(cameras): + last_row = self.list_box.get_row_at_index(row_count - 1) + if last_row: + self.list_box.remove(last_row) + row_count -= 1 + + if new_selection_index >= 0: + row = self.list_box.get_row_at_index(new_selection_index) + self.list_box.select_row(row) + elif len(cameras) > 0: + row = self.list_box.get_row_at_index(0) + self.list_box.select_row(row) + else: + if self.list_box.get_selected_row(): + self.list_box.unselect_all() + else: + self.list_box.emit("row-selected", None) + + def create_row_widget(self, item: Camera) -> Gtk.Widget: + """Creates a CameraRow for the given camera item.""" + row = CameraRow(item) + row.remove_clicked.connect(self._on_row_remove_clicked) + return row + + def _on_add_clicked(self, button: Gtk.Button): + """Emits the add_requested signal.""" + self.add_requested.send(self) + + def _on_row_remove_clicked(self, row_widget: CameraRow, camera: Camera): + """Bubbles up the remove_requested signal from a CameraRow.""" + self.remove_requested.send(self, camera=camera) + + +class CameraEnhancementGroup(Adw.PreferencesGroup): + """A widget for noise reduction and image enhancement.""" + + def __init__(self, **kwargs): + super().__init__( + title=_("Image Enhancement"), + description=_("Reduce noise and improve image stability."), + **kwargs, + ) + self._controller: CameraController | None = None + self._updating = False + + self.denoise_scale = create_slider( + adjustment=Gtk.Adjustment( + value=0, lower=0, upper=100, step_increment=1 + ), + digits=0, + on_value_changed=self._on_value_changed, + ) + + row = Adw.ActionRow(title=_("Noise Reduction")) + subtitle_text = _( + "Temporal averaging. Higher values remove more noise " + "but cause trailing." + ) + row.set_subtitle(subtitle_text) + row.add_suffix(self.denoise_scale) + self.add(row) + + def set_controller(self, controller: CameraController | None): + self._controller = controller + self.set_sensitive(controller is not None) + + if not controller: + return + + self._updating = True + # Read the current config. Defaults to 0.0 + denoise_val = getattr(controller.config, "denoise", 0.0) + # Convert 0.0-0.95 range to 0-100 slider + self.denoise_scale.set_value(denoise_val * 100.0) + self._updating = False + + def _on_value_changed(self, scale): + if self._updating or not self._controller: + return + + # Convert 0-100 slider to 0.0-0.95 range + val = scale.get_value() / 100.0 + # Hard clamp to 0.95 to avoid accidental infinite freeze + val = min(val, 0.95) + + self._controller.config.denoise = val + + +class CameraDistortionGroup(Adw.PreferencesGroup): + """A widget for correcting fisheye/wide-angle lens distortion.""" + + def __init__(self, **kwargs): + desc_text = _( + "Straighten bowed lines using Radial (k1, k2) and Tangential " + "(p1, p2) parameters. Note: Values are usually very small." + ) + super().__init__( + title=_("Lens Distortion Correction (Fisheye)"), + description=desc_text, + **kwargs, + ) + self._controller: CameraController | None = None + self._updating = False + + self.k1_spin = self._create_spin_row(_("Radial 1 (k1)")) + self.k2_spin = self._create_spin_row(_("Radial 2 (k2)")) + self.p1_spin = self._create_spin_row(_("Tangential 1 (p1)")) + self.p2_spin = self._create_spin_row(_("Tangential 2 (p2)")) + + def _create_spin_row(self, title: str) -> Gtk.SpinButton: + row = Adw.ActionRow(title=title) + spin = Gtk.SpinButton( + adjustment=Gtk.Adjustment( + value=0.0, + lower=-10.0, + upper=10.0, + step_increment=0.001, + page_increment=0.01, + ), + digits=4, + numeric=True, + ) + spin.set_valign(Gtk.Align.CENTER) + spin.connect("value-changed", self._on_value_changed) + row.add_suffix(spin) + self.add(row) + return spin + + def set_controller(self, controller: CameraController | None): + self._controller = controller + self.set_sensitive(controller is not None) + + if not controller: + return + + self._updating = True + self.k1_spin.set_value( + getattr(controller.config, "distortion_k1", 0.0) + ) + self.k2_spin.set_value( + getattr(controller.config, "distortion_k2", 0.0) + ) + self.p1_spin.set_value( + getattr(controller.config, "distortion_p1", 0.0) + ) + self.p2_spin.set_value( + getattr(controller.config, "distortion_p2", 0.0) + ) + self._updating = False + + def _on_value_changed(self, spin: Gtk.SpinButton): + if self._updating or not self._controller: + return + + self._controller.config.distortion_k1 = self.k1_spin.get_value() + self._controller.config.distortion_k2 = self.k2_spin.get_value() + self._controller.config.distortion_p1 = self.p1_spin.get_value() + self._controller.config.distortion_p2 = self.p2_spin.get_value() + + +class CameraPreferencesPage(TrackedPreferencesPage): + key = "camera" + path_prefix = "/machine-settings/" + + def __init__(self, **kwargs): + super().__init__( + title=_("Camera"), icon_name="camera-on-symbolic", **kwargs + ) + self._controllers: list[CameraController] = [] + self._cameras: list[Camera] = [] + self.selected_controller: CameraController | None = None + + # Signals + self.camera_add_requested = Signal() + """Signal emitted when a user requests to add a camera. + Sends: sender, device_id (str) + """ + self.camera_remove_requested = Signal() + """Signal emitted when a user requests to remove a camera. + Sends: sender, camera (Camera) + """ + + # List of Cameras, using the new reusable widget + self.camera_list_editor = CameraListEditor( + title=_("Cameras"), + description=_( + "Stream a camera image directly onto the work surface." + ), + ) + self.add(self.camera_list_editor) + + # Configuration panel for the selected Camera (includes the + # Camera Wizard launcher row). + self.camera_properties_widget = CameraProperties(None) + self.add(self.camera_properties_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) + self.camera_list_editor.list_box.connect( + "row-selected", self.on_camera_selected + ) + + def set_controllers(self, controllers: list[CameraController]): + """Sets the list of camera controllers and refreshes the UI.""" + self._controllers = controllers + self._cameras = [c.config for c in controllers] + self.camera_list_editor.set_cameras(self._cameras) + + def on_add_camera(self, sender): + """Show a dialog to select a new camera device.""" + dialog = CameraSelectionDialog(self.get_ancestor(Gtk.Window)) + dialog.present() + dialog.connect("response", self.on_camera_selection_dialog_response) + + def on_camera_selection_dialog_response(self, dialog, response_id): + if response_id == "select": + device_id = dialog.selected_device_id + if device_id: + # Check for duplicates in the current list + if any(c.device_id == device_id for c in self._cameras): + return + # Emit a signal to request the addition + self.camera_add_requested.send(self, device_id=device_id) + dialog.destroy() + + def on_remove_camera(self, sender, camera: Camera): + """Emit a signal to request removal of the selected Camera.""" + self.camera_remove_requested.send(self, camera=camera) + + def on_camera_selected(self, listbox, row): + """Update the configuration panel when a Camera is selected.""" + if row is not None: + camera_row = cast(CameraRow, row.get_child()) + selected_camera = camera_row.camera + # Find the controller that matches this camera model + selected_controller = next( + (c for c in self._controllers if c.config == selected_camera), + None, + ) + self.selected_controller = selected_controller + self.camera_properties_widget.set_controller(selected_controller) + else: + self.selected_controller = None + self.camera_properties_widget.set_controller(None) diff --git a/rayforge/ui_gtk/camera/capture_surface.py b/rayforge/ui_gtk/camera/capture_surface.py new file mode 100644 index 000000000..357ea7c35 --- /dev/null +++ b/rayforge/ui_gtk/camera/capture_surface.py @@ -0,0 +1,142 @@ +"""Live capture surface that overlays Charuco detections.""" + +import logging +from gettext import gettext as _ + +import cv2 +import numpy as np +from gi.repository import Gdk, GdkPixbuf, GLib, Graphene, Gtk + +from ...camera.calibration.charuco import CharucoBoard +from ...camera.controller import CameraController + +logger = logging.getLogger(__name__) + + +def numpy_to_pixbuf(image: np.ndarray) -> GdkPixbuf.Pixbuf | None: + if image is None: + return None + if len(image.shape) == 2: + rgb = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) + elif image.shape[2] == 4: + rgb = cv2.cvtColor(image, cv2.COLOR_BGRA2RGB) + elif image.shape[2] == 3: + rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + return None + + height, width = rgb.shape[:2] + rgb_bytes = GLib.Bytes.new(rgb.tobytes()) + return GdkPixbuf.Pixbuf.new_from_bytes( + rgb_bytes, + GdkPixbuf.Colorspace.RGB, + False, + 8, + width, + height, + width * 3, + ) + + +class CalibrationCaptureSurface(Gtk.Widget): + def __init__( + self, + controller: CameraController, + board: CharucoBoard | None = None, + **kwargs, + ): + super().__init__(**kwargs) + self.controller = controller + self.board = board + self._last_corners: list[tuple[float, float]] | None = None + self._last_ids: list[int] | None = None + + self.set_hexpand(True) + self.set_vexpand(True) + self.set_size_request(750, 500) + + self.controller.subscribe() + self.controller.image_captured.connect(self._on_image_captured) + + def stop(self) -> None: + self.controller.unsubscribe() + + def _on_image_captured(self, _): + self.queue_draw() + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + width = self.get_width() + height = self.get_height() + if width <= 0 or height <= 0: + return + + ctx = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) + + raw_image = self.controller.raw_image_data + if raw_image is not None: + pixbuf = numpy_to_pixbuf(raw_image) + if pixbuf: + img_w = pixbuf.get_width() + img_h = pixbuf.get_height() + + scale = min(width / img_w, height / img_h) + scaled_w = img_w * scale + scaled_h = img_h * scale + offset_x = (width - scaled_w) / 2 + offset_y = (height - scaled_h) / 2 + + ctx.save() + ctx.translate(offset_x, offset_y) + ctx.scale(scale, scale) + Gdk.cairo_set_source_pixbuf(ctx, pixbuf, 0, 0) + ctx.paint() + ctx.restore() + + if self.board is not None: + detection = self.board.detect(raw_image) + if detection is not None: + corners, ids = detection + self._last_corners = corners + self._last_ids = ids + + ctx.save() + ctx.translate(offset_x, offset_y) + ctx.scale(scale, scale) + + for pt in corners: + ctx.arc(pt[0], pt[1], 4, 0, 2 * 3.14159) + ctx.set_source_rgba(0, 1, 0, 0.8) + ctx.fill() + ctx.set_source_rgba(1, 1, 1, 1) + ctx.set_line_width(1.0) + ctx.stroke() + + ctx.restore() + else: + self._last_corners = None + self._last_ids = None + else: + ctx.set_source_rgb(0.1, 0.1, 0.1) + ctx.rectangle(0, 0, width, height) + ctx.fill() + + ctx.set_source_rgb(0.5, 0.5, 0.5) + ctx.set_font_size(14) + text = _("Waiting for camera...") + extents = ctx.text_extents(text) + ctx.move_to( + (width - extents.width) / 2, + (height + extents.height) / 2, + ) + ctx.show_text(text) + + @property + def last_detection( + self, + ) -> tuple[list[tuple[float, float]], list[int]] | None: + if self._last_corners and self._last_ids: + return self._last_corners, self._last_ids + return None + + +__all__ = ["CalibrationCaptureSurface", "numpy_to_pixbuf"] diff --git a/rayforge/ui_gtk/camera/display_widget.py b/rayforge/ui_gtk/camera/display_widget.py new file mode 100644 index 000000000..a8a01ff6f --- /dev/null +++ b/rayforge/ui_gtk/camera/display_widget.py @@ -0,0 +1,165 @@ +import logging + +from gi.repository import Gdk, GdkPixbuf, Graphene, Gtk, Pango, PangoCairo + +from ...camera.controller import CameraController + +logger = logging.getLogger(__name__) + + +class CameraDisplay(Gtk.DrawingArea): + def __init__(self, controller: CameraController, **kwargs): + super().__init__(**kwargs) + self.controller = controller + self.camera = controller.config + self.set_hexpand(True) + self.set_vexpand(True) + self.set_size_request(640, 480) + self.marked_points = [] + self.active_point_index = -1 + self.start() + self.connect("destroy", self.on_destroy) + + def start(self): + """ + Starts the camera display by connecting to the image_captured signal + and subscribing to the controller. + """ + logger.debug( + "CameraDisplay.start called for camera %s (instance: %s)", + self.camera.name, + id(self), + ) + self.queue_draw() + self.controller.image_captured.connect(self.on_image_captured) + self.camera.settings_changed.connect(self.on_settings_changed) + self.controller.subscribe() + + def stop(self): + """ + Stops the camera display by disconnecting the image_captured signal + and unsubscribing from the controller. + """ + logger.debug( + "CameraDisplay.stop called for camera %s (instance: %s)", + self.camera.name, + id(self), + ) + self.controller.image_captured.disconnect(self.on_image_captured) + self.camera.settings_changed.disconnect(self.on_settings_changed) + self.controller.unsubscribe() + + def set_marked_points(self, points, active_point_index=-1): + self.marked_points = points or [] + self.active_point_index = active_point_index + self.queue_draw() + + def do_snapshot(self, snapshot): + """ + Draw handler for the Gtk.DrawingArea. Scales and draws the camera's + pixbuf while maintaining aspect ratio. + """ + width, height = self.get_width(), self.get_height() + ctx = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) + + if not self.camera.enabled: + self._draw_disabled_message(ctx, width, height) + return + + pixbuf = self.controller.pixbuf + if pixbuf is None: + logger.debug("No pixbuf available for camera %s", self.camera.name) + self._draw_no_image_message(ctx, width, height) + return + + if width <= 0 or height <= 0: + return + + img_width = pixbuf.get_width() + img_height = pixbuf.get_height() + + scale = min(width / img_width, height / img_height) + scaled_w = img_width * scale + scaled_h = img_height * scale + offset_x = (width - scaled_w) / 2 + offset_y = (height - scaled_h) / 2 + + scaled_pixbuf = pixbuf.scale_simple( + int(scaled_w), int(scaled_h), GdkPixbuf.InterpType.BILINEAR + ) + + if scaled_pixbuf: + Gdk.cairo_set_source_pixbuf(ctx, scaled_pixbuf, offset_x, offset_y) + ctx.paint() + + if self.marked_points: + for i, point_data in enumerate(self.marked_points): + if point_data is None: + continue + x, y = point_data + display_x = offset_x + x * scale + display_y = offset_y + scaled_h - (y * scale) + + if i == self.active_point_index: + ctx.set_source_rgb(1.0, 0.5, 0.0) + ctx.arc(display_x, display_y, 6, 0, 2 * 3.1416) + ctx.fill_preserve() + ctx.set_source_rgb(0.8, 0.4, 0.0) + else: + ctx.set_source_rgb(0.53, 0.81, 0.98) + ctx.arc(display_x, display_y, 6, 0, 2 * 3.1416) + ctx.fill_preserve() + ctx.set_source_rgb(0.0, 0.0, 0.5) + ctx.set_line_width(1.5) + ctx.stroke() + + def _draw_message(self, ctx, width, height, message): + """Helper to draw a message in the center of the widget.""" + ctx.set_source_rgb(0.5, 0.5, 0.5) # Grey color for text + + # Use Pango to set font options + font_desc = Pango.FontDescription() + font_desc.set_family("Sans") + font_desc.set_style(Pango.Style.NORMAL) + font_desc.set_weight(Pango.Weight.BOLD) + font_desc.set_size(24 * Pango.SCALE) # Pango units + + layout = PangoCairo.create_layout(ctx) + layout.set_font_description(font_desc) + + # Get text extents + _, _, text_width, text_height, _, _ = ctx.text_extents(message) + + # Calculate position to center the text + x = (width - text_width) / 2 + y = (height + text_height) / 2 + + ctx.move_to(x, y) + ctx.show_text(message) + + def _draw_disabled_message(self, ctx, width, height): + """Draws a 'Camera Disabled' message.""" + self._draw_message(ctx, width, height, "Camera Disabled") + + def _draw_no_image_message(self, ctx, width, height): + """Draws a 'No Image' message.""" + self._draw_message(ctx, width, height, "No Image") + + def on_image_captured(self, controller): + """Callback for the camera's image_captured signal.""" + self.queue_draw() + + def on_settings_changed(self, camera): + """Callback for the camera's settings_changed signal.""" + logger.debug( + f"Settings changed, redrawing for camera {self.camera.name}" + ) + self.queue_draw() + + def on_destroy(self, widget): + """Callback for when the CameraDisplay widget is destroyed.""" + logger.debug( + f"CameraDisplay.on_destroy called for camera " + f"{self.camera.name} (instance: {id(self)})" + ) + self.stop() diff --git a/rayforge/ui_gtk/camera/image_settings_dialog.py b/rayforge/ui_gtk/camera/image_settings_dialog.py new file mode 100644 index 000000000..a99853bfa --- /dev/null +++ b/rayforge/ui_gtk/camera/image_settings_dialog.py @@ -0,0 +1,49 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ..shared.patched_dialog_window import PatchedDialogWindow +from .image_settings_widget import CameraImageSettings + +logger = logging.getLogger(__name__) + + +class CameraImageSettingsDialog(PatchedDialogWindow): + def __init__(self, parent, controller: CameraController, **kwargs): + super().__init__( + transient_for=parent, + modal=True, + default_width=1150, + default_height=750, + title=_("{camera_name} - Camera Image Settings").format( + camera_name=controller.config.name + ), + **kwargs, + ) + self.controller = controller + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(content) + + header = Adw.HeaderBar() + content.append(header) + + self._widget = CameraImageSettings(controller) + self._widget.set_margin_start(32) + self._widget.set_margin_end(32) + self._widget.set_margin_top(12) + self._widget.set_margin_bottom(12) + content.append(self._widget) + + def do_close_request(self, *args) -> bool: + logger.debug( + f"CameraImageSettingsDialog closing for " + f"{self.controller.config.name}" + ) + self._widget.stop() + return False + + +__all__ = ["CameraImageSettingsDialog"] diff --git a/rayforge/ui_gtk/camera/image_settings_widget.py b/rayforge/ui_gtk/camera/image_settings_widget.py new file mode 100644 index 000000000..edb78421d --- /dev/null +++ b/rayforge/ui_gtk/camera/image_settings_widget.py @@ -0,0 +1,357 @@ +"""Reusable image-settings controls for a camera. + +Composed by both :class:`CameraImageSettingsDialog` and the camera +wizard's image-settings page so the two stay in sync. +""" + +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ..shared.pref_rows.base import SpinRow +from ..shared.slider import create_slider_row +from .display_widget import CameraDisplay + +logger = logging.getLogger(__name__) + + +class CameraImageSettings(Gtk.Box): + """Live preview + image-quality controls for one camera.""" + + def __init__(self, controller: CameraController, **kwargs): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, **kwargs) + self.controller = controller + self.camera = controller.config + self._updating_ui = False + self._build_ui() + self.camera.settings_changed.connect(self._on_camera_settings_changed) + self.controller.resolutions_probed.connect(self._on_resolutions_probed) + + def _build_ui(self) -> None: + self.set_spacing(16) + + left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + left_box.set_hexpand(True) + left_box.set_vexpand(True) + + self.camera_display = CameraDisplay(self.controller) + self.camera_display.set_hexpand(True) + self.camera_display.set_vexpand(True) + self.camera_display.set_halign(Gtk.Align.FILL) + + left_box.append(self.camera_display) + self.append(left_box) + + right_scroll = Gtk.ScrolledWindow() + right_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.append(right_scroll) + + settings_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + width_request=500, + hexpand=False, + ) + settings_box.set_margin_start(12) + settings_box.set_margin_end(0) + settings_box.set_margin_top(4) + settings_box.set_margin_bottom(12) + right_scroll.set_child(settings_box) + + image_group = Adw.PreferencesGroup( + title=_("Camera Image Settings"), + description=_("Adjust image quality and appearance parameters."), + ) + settings_box.append(image_group) + + self._resolution_values: list[tuple[int, int] | None] = [None] + resolution_labels = [_("Default")] + for w, h in self.controller.available_resolutions: + self._resolution_values.append((w, h)) + resolution_labels.append(f"{w} × {h}") + self._resolution_values.append((-1, -1)) + resolution_labels.append(_("Custom...")) + + self.resolution_store = Gtk.StringList.new(resolution_labels) + self.resolution_row = Adw.ComboRow( + title=_("Resolution"), + subtitle=_( + "Camera capture resolution. " + "Default uses the camera's native setting." + ), + model=self.resolution_store, + ) + self.resolution_row.connect( + "notify::selected", self._on_resolution_changed + ) + image_group.add(self.resolution_row) + + self.custom_width_row = SpinRow( + _("Custom Width"), + lower=16, + upper=16384, + numeric=True, + value=1920, + ) + self.custom_width_row.value_changed.connect( + self._on_custom_res_changed + ) + self.custom_width_row.set_visible(False) + image_group.add(self.custom_width_row) + + self.custom_height_row = SpinRow( + _("Custom Height"), + lower=16, + upper=16384, + numeric=True, + value=1080, + ) + self.custom_height_row.value_changed.connect( + self._on_custom_res_changed + ) + self.custom_height_row.set_visible(False) + image_group.add(self.custom_height_row) + + self._sync_resolution_selection() + + self.yuyv_row = Adw.ActionRow( + title=_("Prefer YUYV Format"), + subtitle=_( + "Use uncompressed YUYV instead of MJPEG. Fixes green " + "artifacts on some USB cameras but may reduce " + "resolution or frame rate on USB 2.0." + ), + ) + self.yuyv_switch = Gtk.Switch() + self.yuyv_switch.set_valign(Gtk.Align.CENTER) + self.yuyv_switch.set_active(self.camera.prefer_yuyv) + self.yuyv_switch.connect("notify::active", self.on_yuyv_toggled) + self.yuyv_row.add_suffix(self.yuyv_switch) + self.yuyv_row.set_activatable_widget(self.yuyv_switch) + image_group.add(self.yuyv_row) + + self.auto_white_balance_row = Adw.ActionRow( + title=_("Auto White Balance"), + subtitle=_("Automatically adjust white balance"), + ) + self.auto_white_balance_switch = Gtk.Switch() + self.auto_white_balance_switch.set_valign(Gtk.Align.CENTER) + self.auto_white_balance_switch.set_active( + self.camera.white_balance is None + ) + self.auto_white_balance_switch.connect( + "notify::active", self.on_auto_white_balance_toggled + ) + self.auto_white_balance_row.add_suffix(self.auto_white_balance_switch) + self.auto_white_balance_row.set_activatable_widget( + self.auto_white_balance_switch + ) + image_group.add(self.auto_white_balance_row) + + initial_wb = ( + self.camera.white_balance + if self.camera.white_balance is not None + else 4000 + ) + self.wb_adjustment = Gtk.Adjustment( + value=initial_wb, + lower=2500, + upper=10000, + step_increment=10, + page_increment=100, + ) + wb_row, self.white_balance_scale = create_slider_row( + title=_("White Balance (Kelvin)"), + subtitle=_("Color temperature for accurate color representation"), + adjustment=self.wb_adjustment, + digits=0, + on_value_changed=lambda s: self.on_white_balance_changed(s), + ) + image_group.add(wb_row) + self.white_balance_scale.set_sensitive( + self.camera.white_balance is not None + ) + + row, self.contrast_scale = self._create_slider_row( + title=_("Contrast"), + subtitle=_("Difference between light and dark areas"), + initial_val=self.camera.contrast, + callback=self.on_contrast_changed, + lower=0.0, + upper=100.0, + step=0.01, + page=10.0, + digits=2, + ) + image_group.add(row) + + row, self.brightness_scale = self._create_slider_row( + title=_("Brightness"), + subtitle=_("Overall lightness or darkness of the image"), + initial_val=self.camera.brightness, + callback=self.on_brightness_changed, + lower=-100.0, + upper=100.0, + step=0.01, + page=10.0, + digits=2, + ) + image_group.add(row) + + row, self.denoise_scale = self._create_slider_row( + title=_("Noise Reduction"), + subtitle=_("Temporal averaging, higher values cause trailing"), + initial_val=self.camera.denoise * 100.0, + callback=self.on_denoise_changed, + lower=0.0, + upper=100.0, + step=1.0, + page=10.0, + digits=0, + ) + image_group.add(row) + + row, self.transparency_scale = self._create_slider_row( + title=_("Transparency"), + subtitle=_("Transparency on the worksurface"), + initial_val=self.camera.transparency, + callback=self.on_transparency_changed, + lower=0.0, + upper=1.0, + step=0.01, + page=0.1, + digits=2, + ) + image_group.add(row) + + def stop(self) -> None: + self.camera_display.stop() + + # ----- signal handlers --------------------------------------------- + + def _on_camera_settings_changed(self, camera) -> None: + pass + + def _on_resolution_changed(self, combo_row, pspec) -> None: + if self._updating_ui: + return + idx = combo_row.get_selected() + if 0 <= idx < len(self._resolution_values): + val = self._resolution_values[idx] + if val == (-1, -1): + self.custom_width_row.set_visible(True) + self.custom_height_row.set_visible(True) + w = self.custom_width_row.get_int_value() + h = self.custom_height_row.get_int_value() + self.camera.resolution = (w, h) + else: + self.custom_width_row.set_visible(False) + self.custom_height_row.set_visible(False) + self.camera.resolution = val + + def _on_custom_res_changed(self, spin_row) -> None: + if self._updating_ui: + return + idx = self.resolution_row.get_selected() + if 0 <= idx < len(self._resolution_values) and ( + self._resolution_values[idx] == (-1, -1) + ): + w = self.custom_width_row.get_int_value() + h = self.custom_height_row.get_int_value() + self.camera.resolution = (w, h) + + def _on_resolutions_probed(self, controller) -> None: + self._resolution_values = [None] + labels = [_("Default")] + for w, h in controller.available_resolutions: + self._resolution_values.append((w, h)) + labels.append(f"{w} × {h}") + self._resolution_values.append((-1, -1)) + labels.append(_("Custom...")) + self.resolution_store = Gtk.StringList.new(labels) + self.resolution_row.set_model(self.resolution_store) + self._sync_resolution_selection() + + def _sync_resolution_selection(self) -> None: + self._updating_ui = True + try: + res = self.camera.resolution + if res is None: + self.resolution_row.set_selected(0) + self.custom_width_row.set_visible(False) + self.custom_height_row.set_visible(False) + elif res in self._resolution_values: + idx = self._resolution_values.index(res) + self.resolution_row.set_selected(idx) + self.custom_width_row.set_visible(False) + self.custom_height_row.set_visible(False) + else: + idx = self._resolution_values.index((-1, -1)) + self.resolution_row.set_selected(idx) + self.custom_width_row.set_value(res[0]) + self.custom_height_row.set_value(res[1]) + self.custom_width_row.set_visible(True) + self.custom_height_row.set_visible(True) + finally: + self._updating_ui = False + + def _create_slider_row( + self, + title, + subtitle, + initial_val, + callback, + lower, + upper, + step, + page, + digits, + ): + adj = Gtk.Adjustment( + value=initial_val, + lower=lower, + upper=upper, + step_increment=step, + page_increment=page, + ) + return create_slider_row( + title=title, + subtitle=subtitle, + adjustment=adj, + digits=digits, + on_value_changed=callback, + ) + + def on_white_balance_changed(self, scale) -> None: + if not self.auto_white_balance_switch.get_active(): + self.camera.white_balance = scale.get_value() + + def on_auto_white_balance_toggled(self, switch_row, pspec) -> None: + is_auto = switch_row.get_active() + self.white_balance_scale.set_sensitive(not is_auto) + if is_auto: + self.camera.white_balance = None + else: + self.camera.white_balance = self.wb_adjustment.get_value() + + def on_yuyv_toggled(self, switch_row, pspec) -> None: + self.camera.prefer_yuyv = switch_row.get_active() + + def on_contrast_changed(self, scale) -> None: + self.camera.contrast = scale.get_value() + + def on_brightness_changed(self, scale) -> None: + self.camera.brightness = scale.get_value() + + def on_denoise_changed(self, scale) -> None: + val = scale.get_value() / 100.0 + val = min(val, 0.95) + self.camera.denoise = val + + def on_transparency_changed(self, scale) -> None: + self.camera.transparency = scale.get_value() + + +__all__ = ["CameraImageSettings"] diff --git a/rayforge/ui_gtk/camera/lens_calibration_dialog.py b/rayforge/ui_gtk/camera/lens_calibration_dialog.py new file mode 100644 index 000000000..c58ca3b1e --- /dev/null +++ b/rayforge/ui_gtk/camera/lens_calibration_dialog.py @@ -0,0 +1,84 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ..shared.patched_dialog_window import PatchedDialogWindow +from .display_widget import CameraDisplay +from .lens_calibration_widget import LensCalibrationWidget + +logger = logging.getLogger(__name__) + + +class LensCalibrationDialog(PatchedDialogWindow): + def __init__(self, parent, controller: CameraController, **kwargs): + super().__init__( + transient_for=parent, + modal=True, + default_width=1150, + default_height=750, + title=_("{camera_name} - Lens Calibration").format( + camera_name=controller.config.name + ), + **kwargs, + ) + self.controller = controller + self.camera = controller.config + + self._setup_ui() + + def _setup_ui(self): + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.toast_overlay.set_child(content) + + header = Adw.HeaderBar() + content.append(header) + + main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + main_box.set_margin_start(32) + main_box.set_margin_top(12) + main_box.set_margin_bottom(12) + content.append(main_box) + + left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + left_box.set_hexpand(True) + left_box.set_vexpand(True) + + self.camera_display = CameraDisplay(self.controller) + self.camera_display.set_hexpand(True) + self.camera_display.set_vexpand(True) + self.camera_display.set_halign(Gtk.Align.FILL) + + left_box.append(self.camera_display) + main_box.append(left_box) + + right_scroll = Gtk.ScrolledWindow() + right_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + main_box.append(right_scroll) + + settings_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + width_request=500, + hexpand=False, + ) + settings_box.set_margin_start(12) + settings_box.set_margin_end(32) + settings_box.set_margin_top(4) + settings_box.set_margin_bottom(12) + right_scroll.set_child(settings_box) + + self.calibration_widget = LensCalibrationWidget(self.camera) + settings_box.append(self.calibration_widget) + + def do_close_request(self, *args) -> bool: + logger.debug( + f"LensCalibrationDialog closing for camera {self.camera.name}" + ) + self.calibration_widget.stop() + self.camera_display.stop() + return False diff --git a/rayforge/ui_gtk/camera/lens_calibration_widget.py b/rayforge/ui_gtk/camera/lens_calibration_widget.py new file mode 100644 index 000000000..c1a0a2599 --- /dev/null +++ b/rayforge/ui_gtk/camera/lens_calibration_widget.py @@ -0,0 +1,101 @@ +"""Reusable manual lens-distortion coefficient controls. + +Composed by both :class:`LensCalibrationDialog` and the camera +wizard's manual lens-calibration page so the two stay in sync. +""" + +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ..shared.pref_rows.base import SpinRow + +logger = logging.getLogger(__name__) + +_DISTORTION_FIELDS = [ + ("distortion_k1", _("Radial 1 (k1)"), _("First order radial distortion")), + ("distortion_k2", _("Radial 2 (k2)"), _("Second order radial distortion")), + ("distortion_k3", _("Radial 3 (k3)"), _("Third order radial distortion")), + ( + "distortion_p1", + _("Tangential 1 (p1)"), + _("First order tangential distortion"), + ), + ( + "distortion_p2", + _("Tangential 2 (p2)"), + _("Second order tangential distortion"), + ), +] + + +class LensCalibrationWidget(Gtk.Box): + """Preferences group for entering lens-distortion coefficients.""" + + def __init__(self, camera, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + self.camera = camera + self._distortion_rows = {} + self._updating_ui = False + + group = Adw.PreferencesGroup( + title=_("Lens Calibration"), + description=_( + "Correct lens distortion for straighter lines. " + "Adjust the coefficients manually." + ), + ) + self.append(group) + + for key, title, subtitle in _DISTORTION_FIELDS: + row = self._create_spin_row( + title, subtitle, getattr(self.camera, key), key + ) + self._distortion_rows[key] = row + group.add(row) + + self.camera.settings_changed.connect(self._on_camera_settings_changed) + + def _create_spin_row( + self, title: str, subtitle: str, value: float, config_key: str + ) -> SpinRow: + row = SpinRow( + title, + subtitle, + lower=-10.0, + upper=10.0, + step_increment=0.001, + digits=4, + numeric=True, + value=value, + ) + row.value_changed.connect( + lambda r, k=config_key: self._on_distortion_value_changed(r, k) + ) + return row + + def _on_camera_settings_changed(self, camera) -> None: + if self._updating_ui: + return + self._updating_ui = True + try: + for key, row in self._distortion_rows.items(): + row.set_value(getattr(camera, key)) + finally: + self._updating_ui = False + + def _on_distortion_value_changed( + self, spin_row: SpinRow, config_key: str + ) -> None: + if self._updating_ui: + return + setattr(self.camera, config_key, spin_row.get_value()) + + def stop(self) -> None: + self.camera.settings_changed.disconnect( + self._on_camera_settings_changed + ) + + +__all__ = ["LensCalibrationWidget"] diff --git a/rayforge/ui_gtk/camera/point_bubble_widget.py b/rayforge/ui_gtk/camera/point_bubble_widget.py new file mode 100644 index 000000000..757ac7639 --- /dev/null +++ b/rayforge/ui_gtk/camera/point_bubble_widget.py @@ -0,0 +1,214 @@ +import logging +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Gtk + +from ..icons import get_icon +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + +css = """ +.point-bubble { + background-color: @window_bg_color; + border: 1px solid @borders; + border-radius: 8px; + padding: 10px; + box-shadow: 0 4px 18px rgba(0,0,0,0.3); +} +.active-point-bubble { +} +.point-bubble-heading { + font-weight: bold; +} +.point-bubble .dim-label { + opacity: 0.7; + font-size: 0.85em; +} +""" + + +class PointBubbleWidget(Gtk.Box): + def __init__(self, point_index: int, **kwargs): + super().__init__( + orientation=Gtk.Orientation.VERTICAL, spacing=8, **kwargs + ) + self.point_index = point_index + self.image_x: float | None = None + self.image_y: float | None = None + + apply_css(css) + self.add_css_class("point-bubble") + + # Define blinker signals + self.value_changed = Signal() + self.delete_requested = Signal() + self.focus_requested = Signal() + self.nudge_requested = Signal() # Sends: sender, dx, dy + + # --- Header Row (Title & Delete) --- + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.title_label = Gtk.Label( + label=_("Point {n}").format(n=point_index + 1) + ) + self.title_label.add_css_class("point-bubble-heading") + self.title_label.set_hexpand(True) + self.title_label.set_halign(Gtk.Align.START) + header_box.append(self.title_label) + + self.delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + self.delete_button.add_css_class("flat") + self.delete_button.set_valign(Gtk.Align.CENTER) + self.delete_button.set_tooltip_text(_("Delete this point")) + self.delete_button.connect("clicked", self.on_delete_clicked) + header_box.append(self.delete_button) + self.append(header_box) + + # --- Coordinates Row --- + coords_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=12 + ) + + # World X + x_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + x_box.append(Gtk.Label(label="X:")) + adjustment_x = Gtk.Adjustment.new( + 0.0, -10000.0, 10000.0, 0.1, 1.0, 0.0 + ) + self.world_x_spin = Gtk.SpinButton.new(adjustment_x, 0.1, 2) + self.world_x_spin.set_valign(Gtk.Align.CENTER) + self.world_x_spin.set_width_chars(6) + x_box.append(self.world_x_spin) + coords_box.append(x_box) + + # World Y + y_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + y_box.append(Gtk.Label(label="Y:")) + adjustment_y = Gtk.Adjustment.new( + 0.0, -10000.0, 10000.0, 0.1, 1.0, 0.0 + ) + self.world_y_spin = Gtk.SpinButton.new(adjustment_y, 0.1, 2) + self.world_y_spin.set_valign(Gtk.Align.CENTER) + self.world_y_spin.set_width_chars(6) + y_box.append(self.world_y_spin) + coords_box.append(y_box) + + self.append(coords_box) + + # Connect SpinButtons + self.world_x_spin.connect("value-changed", self.on_value_changed) + self.world_y_spin.connect("value-changed", self.on_value_changed) + + # --- Image Nudge Row --- + nudge_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) + nudge_box.set_halign(Gtk.Align.CENTER) + + nudge_label = Gtk.Label(label=_("Nudge Pixel:")) + nudge_label.add_css_class("dim-label") + nudge_label.set_margin_end(8) + nudge_box.append(nudge_label) + + btn_left = Gtk.Button(child=get_icon("go-previous-symbolic")) + btn_up = Gtk.Button(child=get_icon("go-up-symbolic")) + btn_down = Gtk.Button(child=get_icon("go-down-symbolic")) + btn_right = Gtk.Button(child=get_icon("go-next-symbolic")) + + for btn in (btn_left, btn_up, btn_down, btn_right): + btn.add_css_class("flat") + btn.add_css_class("circular") + + # Arrow buttons emit a 0.5 sub-pixel nudge to the image coordinate + btn_left.connect( + "clicked", lambda _: self.nudge_requested.send(self, dx=-0.5, dy=0) + ) + btn_right.connect( + "clicked", lambda _: self.nudge_requested.send(self, dx=0.5, dy=0) + ) + btn_up.connect( + "clicked", lambda _: self.nudge_requested.send(self, dx=0, dy=0.5) + ) + btn_down.connect( + "clicked", lambda _: self.nudge_requested.send(self, dx=0, dy=-0.5) + ) + + nudge_box.append(btn_left) + nudge_box.append(btn_up) + nudge_box.append(btn_down) + nudge_box.append(btn_right) + + self.append(nudge_box) + + # --- Event Controllers --- + key_controller_x = Gtk.EventControllerKey.new() + key_controller_x.connect("key-released", self.on_key_released) + self.world_x_spin.add_controller(key_controller_x) + + key_controller_y = Gtk.EventControllerKey.new() + key_controller_y.connect("key-released", self.on_key_released) + self.world_y_spin.add_controller(key_controller_y) + + focus_controller_x = Gtk.EventControllerFocus() + focus_controller_x.connect( + "enter", self.on_spin_focus, self.world_x_spin + ) + self.world_x_spin.add_controller(focus_controller_x) + + focus_controller_y = Gtk.EventControllerFocus() + focus_controller_y.connect( + "enter", self.on_spin_focus, self.world_y_spin + ) + self.world_y_spin.add_controller(focus_controller_y) + + def set_point_index(self, index: int): + self.point_index = index + self.title_label.set_label(_("Point {n}").format(n=index + 1)) + + def on_key_released(self, controller, keyval, keycode, state): + self.on_value_changed(controller.get_widget()) + + def on_spin_focus(self, controller, widget): + self.focus_requested.send(self, widget=widget) + + def on_value_changed(self, widget): + self.value_changed.send(self) + + def on_delete_clicked(self, button): + self.delete_requested.send(self) + + def set_image_coords(self, x: float, y: float): + self.image_x = x + self.image_y = y + + def get_image_coords(self) -> tuple[float, float] | None: + if self.image_x is not None and self.image_y is not None: + return (self.image_x, self.image_y) + return None + + def get_world_coords(self) -> tuple[float, float]: + try: + x = float(self.world_x_spin.get_text()) + except ValueError: + x = self.world_x_spin.get_value() + try: + y = float(self.world_y_spin.get_text()) + except ValueError: + y = self.world_y_spin.get_value() + return x, y + + def set_world_coords(self, x: float, y: float): + self.world_x_spin.set_value(x) + self.world_y_spin.set_value(y) + + def clear_focus(self): + if self.world_x_spin.has_focus() or self.world_y_spin.has_focus(): + window = self.world_x_spin.get_ancestor(Gtk.Window) + if isinstance(window, Gtk.Window): + window.set_focus(None) + + def set_active(self, active: bool): + self.set_visible(active) + if active: + self.add_css_class("active-point-bubble") + else: + self.remove_css_class("active-point-bubble") diff --git a/rayforge/ui_gtk/camera/properties_widget.py b/rayforge/ui_gtk/camera/properties_widget.py new file mode 100644 index 000000000..772a91603 --- /dev/null +++ b/rayforge/ui_gtk/camera/properties_widget.py @@ -0,0 +1,272 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import Camera +from ..icons import get_icon +from .alignment_dialog import CameraAlignmentDialog +from .image_settings_dialog import CameraImageSettingsDialog +from .lens_calibration_dialog import LensCalibrationDialog + +logger = logging.getLogger(__name__) + + +class CameraProperties(Adw.PreferencesGroup): + def __init__(self, controller: CameraController | None, **kwargs): + super().__init__(**kwargs) + self._controller: CameraController | None = None + self._camera: Camera | None = None + self._updating_ui: bool = False + + self.set_title(_("Camera Properties")) + self.set_description(_("Configure the selected camera.")) + + # Device ID + self.device_id_row = Adw.ActionRow( + title=_("Device ID"), + subtitle=_("System identifier for the camera device"), + ) + self.add(self.device_id_row) + + # Camera Name + self.name_row = Adw.ActionRow( + title=_("Name"), + subtitle=_("Display name for this camera"), + ) + self.name_entry = Gtk.Entry() + self.name_entry.set_valign(Gtk.Align.CENTER) + self.name_entry.connect("changed", self.on_name_changed) + self.name_row.add_suffix(self.name_entry) + self.add(self.name_row) + + # Enabled Switch + self.enabled_row = Adw.ActionRow( + title=_("Enabled"), + subtitle=_("Turn the camera stream on or off"), + ) + 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) + + # Camera Wizard — runs the full guided setup (image settings, + # lens calibration, alignment) in one flow. + self.wizard_button = Gtk.Button( + label=_("Start"), valign=Gtk.Align.CENTER + ) + self.wizard_button.add_css_class("suggested-action") + self.wizard_button.connect("clicked", self.on_wizard_button_clicked) + wizard_row = Adw.ActionRow( + title=_("Camera Wizard"), + subtitle=_( + "Guided setup: image settings, lens calibration, " + "and alignment." + ), + ) + wizard_row.add_suffix(self.wizard_button) + wizard_row.set_activatable_widget(self.wizard_button) + self.add(wizard_row) + + # Image Settings button + self.image_settings_button = Gtk.Button( + label=_("Configure"), valign=Gtk.Align.CENTER + ) + self.image_settings_button.connect( + "clicked", self.on_image_settings_button_clicked + ) + image_settings_row = Adw.ActionRow( + title=_("Image Settings"), + subtitle=_( + "Adjust brightness, contrast, white balance, and noise" + ), + ) + image_settings_row.add_suffix(self.image_settings_button) + self.add(image_settings_row) + + # Lens Calibration + self.lens_calibration_button = Gtk.Button( + label=_("Configure"), + valign=Gtk.Align.CENTER, + margin_start=6, + ) + self.lens_calibration_button.connect( + "clicked", self.on_lens_calibration_button_clicked + ) + self.lens_calibration_row = Adw.ActionRow( + title=_("Lens Calibration"), + subtitle=_("Correct lens distortion for straighter lines"), + ) + self._cal_ok = get_icon("check-circle-symbolic") + self._cal_ok.set_valign(Gtk.Align.CENTER) + self._cal_ok.set_visible(False) + self._cal_warn = get_icon("warning-symbolic") + self._cal_warn.set_valign(Gtk.Align.CENTER) + self._cal_warn.set_visible(False) + self.lens_calibration_row.add_suffix(self._cal_ok) + self.lens_calibration_row.add_suffix(self._cal_warn) + self.lens_calibration_row.add_suffix(self.lens_calibration_button) + self.add(self.lens_calibration_row) + + # Image Alignment + self.image_alignment_button = Gtk.Button( + label=_("Configure"), + valign=Gtk.Align.CENTER, + margin_start=6, + ) + self.image_alignment_button.connect( + "clicked", self.on_image_alignment_button_clicked + ) + self.image_alignment_row = Adw.ActionRow( + title=_("Image Alignment"), + subtitle=_("Calibrate camera position and perspective"), + ) + self._align_ok = get_icon("check-circle-symbolic") + self._align_ok.set_valign(Gtk.Align.CENTER) + self._align_ok.set_visible(False) + self._align_warn = get_icon("warning-symbolic") + self._align_warn.set_valign(Gtk.Align.CENTER) + self._align_warn.set_visible(False) + self.image_alignment_row.add_suffix(self._align_ok) + self.image_alignment_row.add_suffix(self._align_warn) + self.image_alignment_row.add_suffix(self.image_alignment_button) + self.add(self.image_alignment_row) + + self.set_controller(controller) + + def set_controller(self, controller: CameraController | None): + if self._camera: + self._camera.changed.disconnect(self._on_camera_changed) + + self._controller = controller + self._camera = controller.config if controller else None + + if self._camera: + self._camera.changed.connect(self._on_camera_changed) + self.update_ui() + self.set_sensitive(True) + else: + self.clear_ui() + self.set_sensitive(False) + + def update_ui(self): + if not self._camera: + self.clear_ui() + return + if self._updating_ui: + return + + self._updating_ui = True + try: + self.device_id_row.set_subtitle(self._camera.device_id) + self.name_entry.set_text(self._camera.name) + self.enabled_switch.set_active(self._camera.enabled) + self.image_settings_button.set_sensitive(self._camera.enabled) + self.lens_calibration_button.set_sensitive(self._camera.enabled) + self.image_alignment_button.set_sensitive(self._camera.enabled) + self.wizard_button.set_sensitive(self._camera.enabled) + self._update_status_icons() + finally: + self._updating_ui = False + + def _update_status_icons(self): + cam = self._camera + if not cam: + return + + calibrated = cam.calibration_date is not None + self._cal_ok.set_visible(calibrated) + self._cal_warn.set_visible(not calibrated) + if calibrated: + self._cal_ok.set_tooltip_text(_("Lens calibration completed")) + else: + self._cal_warn.set_tooltip_text( + _("Lens calibration not yet performed") + ) + + valid = cam.alignment_valid + stale = cam.has_alignment and not valid + self._align_ok.set_visible(valid) + self._align_warn.set_visible(not valid) + if valid: + self._align_ok.set_tooltip_text(_("Image alignment completed")) + elif stale: + self._align_warn.set_tooltip_text( + _( + "Image alignment must be redone after lens " + "calibration was updated" + ) + ) + else: + self._align_warn.set_tooltip_text( + _("Image alignment not yet performed") + ) + + def clear_ui(self): + self.device_id_row.set_subtitle("") + self.name_entry.set_text("") + self.enabled_switch.set_active(False) + # Clear image settings and disable button + self.image_settings_button.set_sensitive(False) + self.lens_calibration_button.set_sensitive(False) + self.image_alignment_button.set_sensitive(False) + self.wizard_button.set_sensitive(False) + + def _on_camera_changed(self, camera, *args): + logger.debug("Camera model changed, updating UI for %s", camera.name) + self.update_ui() + + def on_name_changed(self, entry_row): + if not self._camera or self._updating_ui: + return + self._updating_ui = True + try: + self._camera.name = entry_row.get_text() + finally: + self._updating_ui = False + + def on_enabled_changed(self, switch_row, _): + if not self._camera: + return + self._camera.enabled = switch_row.get_active() + + def on_image_settings_button_clicked(self, button): + """Open the CameraImageSettingsDialog.""" + if not self._controller: + return + window = self.get_ancestor(Gtk.Window) + if isinstance(window, Gtk.Window): + dialog = CameraImageSettingsDialog(window, self._controller) + dialog.present() + + def on_wizard_button_clicked(self, button): + """Launch the guided camera wizard.""" + if not self._controller: + return + window = self.get_ancestor(Gtk.Window) + if not isinstance(window, Gtk.Window): + return + from .wizard.wizard import CameraWizard + + wizard = CameraWizard(window, self._controller) + wizard.present() + + def on_lens_calibration_button_clicked(self, button): + if not self._controller: + return + window = self.get_ancestor(Gtk.Window) + if isinstance(window, Gtk.Window): + dialog = LensCalibrationDialog(window, self._controller) + dialog.present() + + def on_image_alignment_button_clicked(self, button): + """Open the CameraImageAlignmentDialog.""" + if not self._controller: + return + window = self.get_ancestor(Gtk.Window) + if isinstance(window, Gtk.Window): + dialog = CameraAlignmentDialog(window, self._controller) + dialog.present() diff --git a/rayforge/ui_gtk/camera/selection_dialog.py b/rayforge/ui_gtk/camera/selection_dialog.py new file mode 100644 index 000000000..beb38ecc7 --- /dev/null +++ b/rayforge/ui_gtk/camera/selection_dialog.py @@ -0,0 +1,265 @@ +import logging +from gettext import gettext as _ +from typing import Literal + +from gi.repository import Adw, GdkPixbuf, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import Camera +from ...camera.v4l import display_name +from ...context import get_context +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + + +class CameraSelectionDialog(Adw.MessageDialog): + def __init__( + self, + parent, + mode: Literal["available", "configured"] = "available", + **kwargs, + ): + self._mode = mode + body = ( + _("Please select an available camera device") + if mode == "available" + else _("Please select a configured camera") + ) + super().__init__( + transient_for=parent, + modal=True, + heading=_("Select Camera"), + body=body, + close_response="cancel", + **kwargs, + ) + self.set_size_request(450, 350) + self.selected_device_id: str | None = None + + apply_css(""" + .rounded-image { + border-radius: 8px; + } + .nav-button { + padding: 12px; + } + """) + + self.carousel = Adw.Carousel() + self.carousel.set_vexpand(True) + self.carousel.set_hexpand(True) + self.carousel.set_allow_scroll_wheel(True) + self.carousel.set_allow_long_swipes(True) + self.carousel.set_interactive(True) + + self.prev_button = Gtk.Button(icon_name="go-previous-symbolic") + self.prev_button.add_css_class("nav-button") + self.prev_button.add_css_class("flat") + self.prev_button.set_sensitive(False) + self.prev_button.set_valign(Gtk.Align.CENTER) + self.prev_button.connect("clicked", self.on_prev_clicked) + + self.next_button = Gtk.Button(icon_name="go-next-symbolic") + self.next_button.add_css_class("nav-button") + self.next_button.add_css_class("flat") + self.next_button.set_sensitive(False) + self.next_button.set_valign(Gtk.Align.CENTER) + self.next_button.connect("clicked", self.on_next_clicked) + + carousel_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + carousel_box.append(self.prev_button) + carousel_box.append(self.carousel) + carousel_box.append(self.next_button) + + self.indicator = Adw.CarouselIndicatorDots() + self.indicator.set_carousel(self.carousel) + self.indicator.set_margin_bottom(6) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + content_box.append(carousel_box) + content_box.append(self.indicator) + content_box.set_margin_start(12) + content_box.set_margin_end(12) + content_box.set_margin_top(12) + content_box.set_margin_bottom(6) + + self.set_extra_child(content_box) + + self.add_response("cancel", _("Cancel")) + self.set_response_enabled("cancel", True) + self.set_default_response("cancel") + + self.available_devices: list[str] = [] + self._controllers: list[CameraController] = [] + if mode == "available": + self.list_available_cameras() + else: + self.list_configured_cameras() + + self.carousel.connect("page-changed", self.on_page_changed) + + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self.on_key_pressed) + self.add_controller(key_controller) + + def list_configured_cameras(self): + camera_mgr = get_context().camera_mgr + controllers = camera_mgr.controllers + + if not controllers: + label = Gtk.Label(label=_("No cameras configured.")) + self.carousel.append(label) + return + + for ctrl in controllers: + device_id = ctrl.config.device_id + self.available_devices.append(device_id) + self._controllers.append(ctrl) + self._add_camera_page(ctrl, device_id, ctrl.config.name) + + if self.available_devices: + first_child = self.carousel.get_nth_page(0) + self.carousel.scroll_to(first_child, True) + self.selected_device_id = self.available_devices[0] + self._update_nav_buttons() + + def _add_camera_page( + self, controller: CameraController, device_id: str, name: str + ): + pixbuf = controller.pixbuf + + if not pixbuf: + label = Gtk.Label( + label=_( + "Failed to load image for Device ID: {device_id}" + ).format(device_id=device_id) + ) + self.carousel.append(label) + return + + max_height = 250 + width = pixbuf.get_width() + height = pixbuf.get_height() + if height > max_height: + scale_factor = max_height / height + width = int(width * scale_factor) + height = max_height + pixbuf = pixbuf.scale_simple( + width, height, GdkPixbuf.InterpType.BILINEAR + ) + + image_widget = Gtk.Picture.new_for_pixbuf(pixbuf) + image_widget.set_halign(Gtk.Align.CENTER) + image_widget.set_valign(Gtk.Align.CENTER) + image_widget.set_size_request(200, 200) + image_widget.add_css_class("rounded-image") + image_widget.set_margin_start(10) + image_widget.set_margin_end(10) + image_widget.set_margin_top(10) + image_widget.set_margin_bottom(5) + + label_text = name + dev_name = display_name(device_id) + if dev_name == device_id: + label_text = _("Camera {device_id}").format(device_id=device_id) + + label = Gtk.Label(label=label_text) + label.set_halign(Gtk.Align.CENTER) + label.set_valign(Gtk.Align.CENTER) + label.set_margin_bottom(12) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.append(image_widget) + box.append(label) + box.set_halign(Gtk.Align.CENTER) + box.set_valign(Gtk.Align.CENTER) + + gesture = Gtk.GestureClick.new() + gesture.connect("released", self.on_carousel_item_clicked, device_id) + box.add_controller(gesture) + + motion_controller = Gtk.EventControllerMotion.new() + motion_controller.connect( + "enter", self.on_carousel_item_hover_enter, box + ) + motion_controller.connect( + "leave", self.on_carousel_item_hover_leave, box + ) + box.add_controller(motion_controller) + + self.carousel.append(box) + + @staticmethod + def _get_display_name(device_id: str) -> str: + return display_name(device_id) + + def list_available_cameras(self): + self.available_devices = CameraController.list_available_devices() + if not self.available_devices: + label = Gtk.Label(label=_("No cameras found.")) + self.carousel.append(label) + return + + for device_id in self.available_devices: + name = display_name(device_id) + temp_config = Camera( + name=name, + device_id=device_id, + ) + temp_controller = CameraController(temp_config) + temp_controller.capture_image() + self._add_camera_page(temp_controller, device_id, temp_config.name) + + if self.available_devices: + first_child = self.carousel.get_nth_page(0) + self.carousel.scroll_to(first_child, True) + self.selected_device_id = self.available_devices[0] + self._update_nav_buttons() + + def on_page_changed(self, carousel, page_number): + if 0 <= page_number < len(self.available_devices): + self.selected_device_id = self.available_devices[page_number] + else: + self.selected_device_id = None + self._update_nav_buttons() + + def on_carousel_item_clicked(self, gesture, n_press, x, y, device_id): + self.selected_device_id = device_id + self.response("select") + self.close() + + def on_carousel_item_hover_enter(self, motion_controller, x, y, box): + # Add a "card" style class for a subtle shadow effect + box.add_css_class("card") + + def on_carousel_item_hover_leave(self, motion_controller, box): + box.remove_css_class("card") + + def on_prev_clicked(self, button): + current = self.carousel.get_position() + if current > 0: + page = self.carousel.get_nth_page(int(current) - 1) + self.carousel.scroll_to(page, True) + + def on_next_clicked(self, button): + n_pages = self.carousel.get_n_pages() + current = self.carousel.get_position() + if current < n_pages - 1: + page = self.carousel.get_nth_page(int(current) + 1) + self.carousel.scroll_to(page, True) + + def on_key_pressed(self, controller, keyval, keycode, state): + if keyval == 65361: + self.on_prev_clicked(None) + return True + elif keyval == 65363: + self.on_next_clicked(None) + return True + return False + + def _update_nav_buttons(self): + n_pages = self.carousel.get_n_pages() + current = int(self.carousel.get_position()) + self.prev_button.set_sensitive(current > 0) + self.next_button.set_sensitive(current < n_pages - 1) diff --git a/rayforge/ui_gtk/camera/wizard/__init__.py b/rayforge/ui_gtk/camera/wizard/__init__.py new file mode 100644 index 000000000..833801514 --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/__init__.py @@ -0,0 +1 @@ +"""Camera-wizard package: page modules and the wizard shell.""" diff --git a/rayforge/ui_gtk/camera/wizard/alignment_page.py b/rayforge/ui_gtk/camera/wizard/alignment_page.py new file mode 100644 index 000000000..d6b469e3f --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/alignment_page.py @@ -0,0 +1,46 @@ +"""Camera wizard page: image↔world alignment.""" + +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Gtk + +from ....camera.controller import CameraController +from ..alignment_widget import CameraAlignment +from .base_page import CameraWizardPage + + +class AlignmentPage(CameraWizardPage): + step_name = "alignment" + title = _("Image Alignment") + + def __init__(self, wizard, controller: CameraController): + super().__init__(wizard, controller) + # Fired when the user applies the alignment. + self.alignment_applied = Signal() + self._widget: CameraAlignment | None = None + + def build(self) -> Gtk.Box: + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._widget = CameraAlignment(self.controller) + self._widget.applied.connect(self._on_applied) + self.root.append(self._widget) + return self.root + + def leave(self) -> None: + if self._widget is not None: + self._widget.stop() + + def can_proceed(self) -> bool: + return False + + def footer_buttons(self) -> list[Gtk.Button]: + if self._widget is not None: + return self._widget.footer_buttons() + return [] + + def _on_applied(self, _sender) -> None: + self.alignment_applied.send(self) + + +__all__ = ["AlignmentPage"] diff --git a/rayforge/ui_gtk/camera/wizard/base_page.py b/rayforge/ui_gtk/camera/wizard/base_page.py new file mode 100644 index 000000000..7a2decf6d --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/base_page.py @@ -0,0 +1,65 @@ +"""Base class for camera-wizard pages.""" + +from typing import TYPE_CHECKING + +from gi.repository import Gtk + +from ....camera.controller import CameraController + +if TYPE_CHECKING: + from .wizard import CameraWizard + + +class CameraWizardPage: + """A single step of the camera calibration wizard. + + Pages own a region of the wizard's stack and a set of footer + buttons. The wizard drives them via :meth:`enter` (shown) and + reads :meth:`can_proceed` / :meth:`footer_buttons` to update the + footer. Pages mutate the shared :class:`CameraController` / + ``Camera`` model directly; no separate apply step is needed. + + Flow transitions (e.g. a branch chosen, a step completed) are + signalled via :class:`blinker.Signal`s the wizard connects to, + so pages never call wizard methods directly. Ambient UI + affordances (toasts, error dialogs) are exposed here as methods + the wizard provides, keeping pages decoupled from the dialog + shell. + """ + + step_name: str = "" + title: str = "" + + def __init__(self, wizard: "CameraWizard", controller: CameraController): + self.wizard = wizard + self.controller = controller + self.root: Gtk.Box | None = None + + def build(self) -> Gtk.Box: + raise NotImplementedError + + def enter(self) -> None: + pass + + def leave(self) -> None: + pass + + def can_proceed(self) -> bool: + return True + + def footer_buttons(self) -> list[Gtk.Button]: + return [] + + def back_target(self) -> str | None: + return None + + # ----- ambient UI affordances (provided by the wizard) ------------ + + def show_toast(self, message: str) -> None: + self.wizard.show_toast(message) + + def show_error(self, title: str, message: str) -> None: + self.wizard.show_error(title, message) + + +__all__ = ["CameraWizardPage"] diff --git a/rayforge/ui_gtk/camera/wizard/capture_page.py b/rayforge/ui_gtk/camera/wizard/capture_page.py new file mode 100644 index 000000000..7307b29b9 --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/capture_page.py @@ -0,0 +1,266 @@ +"""Camera wizard page: capture Charuco frames and solve calibration.""" + +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ....camera.calibration.calibrator import CameraCalibrator +from ....camera.calibration.charuco import CharucoBoard +from ....camera.calibration.result import CalibrationResult +from ..capture_surface import CalibrationCaptureSurface +from .base_page import CameraWizardPage + +logger = logging.getLogger(__name__) + + +class CapturePage(CameraWizardPage): + step_name = "capture" + title = _("Capture Frames") + MIN_FRAMES = 5 + RECOMMENDED_FRAMES = 8 + + def __init__(self, wizard, controller): + super().__init__(wizard, controller) + self._board: CharucoBoard | None = None + self.calibrator: CameraCalibrator | None = None + self._calibration_result: CalibrationResult | None = None + self._capture_surface: CalibrationCaptureSurface | None = None + + @property + def capture_button(self) -> Gtk.Button | None: + return self._capture_btn + + @property + def clear_button(self) -> Gtk.Button | None: + return self._clear_btn + + @property + def calibrate_button(self) -> Gtk.Button | None: + return self._calibrate_btn + + def set_board(self, board: CharucoBoard | None) -> None: + self._board = board + if self._capture_surface is not None: + self._capture_surface.board = board + + def build(self) -> Gtk.Box: + self.root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + + left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + left_box.set_hexpand(True) + left_box.set_vexpand(True) + self.root.append(left_box) + + preview_frame = Gtk.Frame( + halign=Gtk.Align.FILL, + valign=Gtk.Align.FILL, + hexpand=True, + vexpand=True, + ) + preview_frame.add_css_class("card") + left_box.append(preview_frame) + + self._capture_surface = CalibrationCaptureSurface( + self.controller, self._board + ) + preview_frame.set_child(self._capture_surface) + + right_scroll = Gtk.ScrolledWindow() + right_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.root.append(right_scroll) + + settings_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + width_request=500, + hexpand=False, + ) + settings_box.set_margin_start(12) + settings_box.set_margin_end(12) + settings_box.set_margin_top(4) + settings_box.set_margin_bottom(12) + right_scroll.set_child(settings_box) + + info_group = Adw.PreferencesGroup( + title=_("Instructions"), + description=_( + "Capture the card at different positions. Important: " + "include the image corners and edges for accurate " + "distortion correction." + ), + ) + settings_box.append(info_group) + + status_group = Adw.PreferencesGroup( + title=_("Status"), + description=_("Progress of the calibration capture process."), + ) + settings_box.append(status_group) + + self.frames_row = Adw.ActionRow(title=_("Captured Frames")) + self.frames_row.set_subtitle("0") + status_group.add(self.frames_row) + + self.corners_row = Adw.ActionRow(title=_("Corners Detected")) + self.corners_row.set_subtitle("0") + status_group.add(self.corners_row) + + self.coverage_row = Adw.ActionRow(title=_("Coverage")) + self.coverage_row.set_subtitle(_("Not started")) + status_group.add(self.coverage_row) + + self.status_row = Adw.ActionRow(title=_("Status")) + self.status_row.set_subtitle(_("Move card to capture more positions")) + status_group.add(self.status_row) + + self.progress_bar = Gtk.ProgressBar( + show_text=True, + text=_("Capture Progress"), + margin_top=6, + ) + status_group.add(self.progress_bar) + + self._capture_btn = Gtk.Button(label=_("Capture Frame")) + self._capture_btn.add_css_class("suggested-action") + self._capture_btn.connect("clicked", self._on_capture_clicked) + + self._clear_btn = Gtk.Button(label=_("Clear")) + self._clear_btn.add_css_class("flat") + self._clear_btn.connect("clicked", self._on_clear_clicked) + + self._calibrate_btn = Gtk.Button(label=_("Calibrate")) + self._calibrate_btn.set_sensitive(False) + self._calibrate_btn.connect("clicked", self._on_calibrate_clicked) + + return self.root + + def enter(self) -> None: + self._init_calibrator() + + def leave(self) -> None: + pass + + def footer_buttons(self) -> list: + return [self._clear_btn, self._capture_btn, self._calibrate_btn] + + def _init_calibrator(self) -> None: + if self._board is None: + return + if self.calibrator is not None: + self.calibrator.clear() + self.calibrator = CameraCalibrator(self._board) + self.calibrator.frame_added.connect(self._on_frame_added) + self.calibrator.frame_rejected.connect(self._on_frame_rejected) + if self._capture_surface: + self._capture_surface.board = self._board + self._update_capture_status() + + def _on_capture_clicked(self, button) -> None: + if self._capture_surface is None or self.calibrator is None: + return + raw_image = self.controller.raw_image_data + if raw_image is None: + logger.warning("No image data available") + return + success, _count, _ = self.calibrator.detect_and_add_frame(raw_image) + if success: + self._update_capture_status() + + def _on_frame_added(self, sender, count: int, total: int) -> None: + logger.debug(f"Frame added with {count} corners (total: {total})") + + def _on_frame_rejected(self, sender, reason: str, **kwargs) -> None: + logger.debug(f"Frame rejected: {reason}") + + def _on_clear_clicked(self, button) -> None: + if self.calibrator: + self.calibrator.clear() + self._update_capture_status() + + def _update_capture_status(self) -> None: + if self.calibrator is None: + return + frame_count = self.calibrator.frame_count + total_corners = self.calibrator.total_corners + + self.frames_row.set_subtitle(f"{frame_count}") + avg = total_corners / frame_count if frame_count > 0 else 0 + self.corners_row.set_subtitle( + f"{total_corners} total ({avg:.0f} per frame avg)" + ) + + if frame_count > 0: + coverage_level, _msg = self.calibrator.get_coverage_quality() + if coverage_level == "good": + self.coverage_row.set_subtitle(_("Good")) + elif coverage_level == "warning": + self.coverage_row.set_subtitle(_("Limited — reach edges")) + else: + self.coverage_row.set_subtitle(_("Poor — reach all corners")) + else: + self.coverage_row.set_subtitle(_("Not started")) + + can_calibrate, status_msg = self.calibrator.calibration_status() + self.status_row.set_subtitle(status_msg) + self._calibrate_btn.set_sensitive(can_calibrate) + + progress = min(1.0, frame_count / self.RECOMMENDED_FRAMES) + self.progress_bar.set_fraction(progress) + + def _on_calibrate_clicked(self, button) -> None: + if self.calibrator is None: + return + resolution = self.controller.resolution + result = self.calibrator.calibrate(resolution) + if result is None: + _ready, reason = self.calibrator.calibration_status() + self.wizard.show_error(_("Calibration Failed"), reason) + return + self._calibration_result = result + self._show_result_dialog() + + def _show_result_dialog(self) -> None: + if self._calibration_result is None: + return + result = self._calibration_result + dialog = Adw.MessageDialog( + transient_for=self.wizard, + modal=True, + heading=_("Calibration Complete"), + body=_( + "RMS Error: {rms:.4f} pixels\n" + "Quality: {quality}\n" + "Frames used: {frames}" + ).format( + rms=result.rms_error, + quality=result.quality_rating.title(), + frames=result.num_frames_used, + ), + ) + dialog.add_response("discard", _("Discard")) + dialog.add_response("save", _("Save Calibration")) + dialog.set_response_appearance( + "save", Adw.ResponseAppearance.SUGGESTED + ) + dialog.connect("response", self._on_result_dialog_response) + dialog.present() + + def _on_result_dialog_response(self, dialog, response_id) -> None: + dialog.destroy() + if response_id == "save": + self._apply_calibration() + self.wizard.close() + + def _apply_calibration(self) -> None: + if self._calibration_result is None: + return + self.controller.config.set_calibration_result(self._calibration_result) + logger.info("Calibration applied to camera configuration") + + def stop(self) -> None: + if self._capture_surface is not None: + self._capture_surface.stop() + + +__all__ = ["CapturePage"] diff --git a/rayforge/ui_gtk/camera/wizard/card_page.py b/rayforge/ui_gtk/camera/wizard/card_page.py new file mode 100644 index 000000000..8a0d5e595 --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/card_page.py @@ -0,0 +1,236 @@ +"""Camera wizard page: generate / print the calibration card.""" + +import logging +import os +from gettext import gettext as _ + +import cv2 + +try: + import pymupdf +except ImportError: + import fitz as pymupdf +from gi.repository import Adw, GdkPixbuf, GLib, Gtk + +from ....camera.calibration.charuco import CharucoBoard +from ....context import get_context +from ....shared.units.formatter import format_value +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from ..capture_surface import numpy_to_pixbuf +from .base_page import CameraWizardPage + +logger = logging.getLogger(__name__) + + +class CardPage(CameraWizardPage): + step_name = "card" + title = _("Calibration Card") + DEFAULT_CARD_RATIO = 0.7 + + def __init__(self, wizard, controller): + super().__init__(wizard, controller) + self._board: CharucoBoard | None = None + self._preview_pixbuf: GdkPixbuf.Pixbuf | None = None + + machine = get_context().machine + if machine: + _unused_x, _unused_y, wa_w, wa_h = machine.work_area + self._card_width = min(100.0, wa_w * self.DEFAULT_CARD_RATIO) + self._card_height = min(140.0, wa_h * self.DEFAULT_CARD_RATIO) + else: + self._card_width = 80.0 + self._card_height = 100.0 + + @property + def board(self) -> CharucoBoard | None: + return self._board + + def build(self) -> Gtk.Box: + self.root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + + left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + left_box.set_hexpand(True) + left_box.set_vexpand(True) + self.root.append(left_box) + + preview_frame = Gtk.Frame( + halign=Gtk.Align.FILL, + valign=Gtk.Align.FILL, + hexpand=True, + vexpand=True, + ) + preview_frame.add_css_class("card") + left_box.append(preview_frame) + + self.preview_image = Gtk.Picture( + halign=Gtk.Align.CENTER, + valign=Gtk.Align.CENTER, + ) + self.preview_image.set_content_fit(Gtk.ContentFit.CONTAIN) + self.preview_image.set_size_request(400, 400) + preview_frame.set_child(self.preview_image) + + right_scroll = Gtk.ScrolledWindow() + right_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.root.append(right_scroll) + + settings_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + width_request=500, + hexpand=False, + ) + settings_box.set_margin_start(12) + settings_box.set_margin_end(12) + settings_box.set_margin_top(4) + settings_box.set_margin_bottom(12) + right_scroll.set_child(settings_box) + + intro_group = Adw.PreferencesGroup( + title=_("Instructions"), + description=_( + "Print a calibration card to correct lens distortion. " + "The card size should fit within your camera view." + ), + ) + settings_box.append(intro_group) + + size_group = Adw.PreferencesGroup( + title=_("Card Size"), + description=_("Adjust to fit your work surface."), + ) + settings_box.append(size_group) + + self._width_row = LengthSpinRow( + _("Width"), + _("Card width"), + lower=20.0, + upper=300.0, + value_in_base=self._card_width, + ) + self._width_row.value_changed.connect(self._on_size_changed) + size_group.add(self._width_row) + + self._height_row = LengthSpinRow( + _("Height"), + _("Card height"), + lower=20.0, + upper=300.0, + value_in_base=self._card_height, + ) + self._height_row.value_changed.connect(self._on_size_changed) + size_group.add(self._height_row) + + info_group = Adw.PreferencesGroup( + title=_("Generated Pattern"), + description=_("Details about the calibration pattern."), + margin_top=12, + ) + settings_box.append(info_group) + + self.squares_row = Adw.ActionRow(title=_("Grid Size")) + info_group.add(self.squares_row) + + self.square_size_row = Adw.ActionRow(title=_("Square Size")) + info_group.add(self.square_size_row) + + self._card_size_row = Adw.ActionRow(title=_("Physical Size")) + info_group.add(self._card_size_row) + + save_pdf_row = Adw.ActionRow( + title=_("Save to PDF"), + subtitle=_("Export the calibration card for printing"), + ) + save_pdf_btn = Gtk.Button(label=_("Save"), valign=Gtk.Align.CENTER) + save_pdf_btn.connect("clicked", self._on_save_pdf) + save_pdf_row.add_suffix(save_pdf_btn) + save_pdf_row.set_activatable_widget(save_pdf_btn) + info_group.add(save_pdf_row) + + self._update_card_preview() + return self.root + + def _on_size_changed(self, row) -> None: + self._card_width = self._width_row.get_value_in_base_units() + self._card_height = self._height_row.get_value_in_base_units() + self._update_card_preview() + + def _update_card_preview(self) -> None: + config = CharucoBoard.recommend_config( + card_width_mm=self._card_width, + card_height_mm=self._card_height, + ) + self._board = CharucoBoard(config) + + self.squares_row.set_subtitle( + f"{config.squares_x} x {config.squares_y} squares" + ) + self.square_size_row.set_subtitle( + format_value(config.square_length_mm, "length") + ) + + card_w, card_h = self._board.card_size_mm + self._card_size_row.set_subtitle( + f"{format_value(card_w, 'length')} x " + f"{format_value(card_h, 'length')}" + ) + + px_per_mm = 8 + img_w = int(card_w * px_per_mm) + img_h = int(card_h * px_per_mm) + image = self._board.generate_image(output_size=(img_w, img_h)) + + if image is not None: + self._preview_pixbuf = numpy_to_pixbuf(image) + self.preview_image.set_pixbuf(self._preview_pixbuf) + + def _on_save_pdf(self, button) -> None: + dialog = Gtk.FileDialog() + dialog.set_title(_("Save Calibration Card")) + dialog.set_initial_name("calibration_card.pdf") + dialog.save(self.wizard, None, self._on_save_dialog_response) + + def _on_save_dialog_response(self, dialog, result) -> None: + try: + file = dialog.save_finish(result) + if file: + self._save_pdf(file.get_path()) + except GLib.Error: + pass + + def _save_pdf(self, filepath: str) -> None: + if self._board is None: + return + + card_w_mm, card_h_mm = self._board.card_size_mm + dpi = 300 + px_per_mm = dpi / 25.4 + img_w = int(card_w_mm * px_per_mm) + img_h = int(card_h_mm * px_per_mm) + + image = self._board.generate_image(output_size=(img_w, img_h)) + if image is None: + return + + page_w = card_w_mm / 25.4 * 72 + page_h = card_h_mm / 25.4 * 72 + + doc = pymupdf.open() + page = doc.new_page(width=page_w, height=page_h) + + temp_path = filepath.replace(".pdf", "_temp.png") + cv2.imwrite(temp_path, image) + + rect = pymupdf.Rect(0, 0, page_w, page_h) + page.insert_image(rect, filename=temp_path) + + doc.save(filepath) + doc.close() + + os.remove(temp_path) + logger.info(f"Calibration card saved to {filepath}") + + self.wizard.show_toast(_("Calibration card saved")) + + +__all__ = ["CardPage"] diff --git a/rayforge/ui_gtk/camera/wizard/image_settings_page.py b/rayforge/ui_gtk/camera/wizard/image_settings_page.py new file mode 100644 index 000000000..e163163c6 --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/image_settings_page.py @@ -0,0 +1,30 @@ +"""Camera wizard page: image settings (resolution, WB, brightness, ...).""" + +from gettext import gettext as _ + +from gi.repository import Gtk + +from ..image_settings_widget import CameraImageSettings +from .base_page import CameraWizardPage + + +class ImageSettingsPage(CameraWizardPage): + step_name = "image" + title = _("Image Settings") + + def __init__(self, wizard, controller): + super().__init__(wizard, controller) + self._widget: CameraImageSettings | None = None + + def build(self) -> Gtk.Box: + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._widget = CameraImageSettings(self.controller) + self.root.append(self._widget) + return self.root + + def leave(self) -> None: + if self._widget is not None: + self._widget.stop() + + +__all__ = ["ImageSettingsPage"] diff --git a/rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py b/rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py new file mode 100644 index 000000000..f4308761a --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/lens_calibration_choice_page.py @@ -0,0 +1,110 @@ +"""Camera wizard page: choose how to calibrate the lens. + +Offers two on-page branches — automatic (Charuco capture) or manual +coefficient entry — plus a "Skip" affordance in the footer bar. The +wizard inserts the relevant follow-on page(s) after this page when a +branch is chosen, so the two methods never appear as sequential +steps. +""" + +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Adw, Gtk + +from .base_page import CameraWizardPage + + +class LensCalibrationChoicePage(CameraWizardPage): + step_name = "lens_choice" + title = _("Lens Calibration") + + BRANCH_SKIPPED = "skipped" + BRANCH_AUTOMATIC = "automatic" + BRANCH_MANUAL = "manual" + + def __init__(self, wizard, controller): + super().__init__(wizard, controller) + # Fired with ``branch=...`` when the user picks a branch. + self.branch_chosen = Signal() + self.chosen_branch: str | None = None + self._automatic_btn: Gtk.Button | None = None + self._manual_btn: Gtk.Button | None = None + self._skip_btn: Gtk.Button | None = None + + def build(self) -> Gtk.Box: + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_vexpand(True) + self.root.append(scrolled) + + group = Adw.PreferencesGroup( + title=_("Lens Calibration"), + description=_( + "Correct lens distortion for straighter lines. " + "Choose how to calibrate, or skip if your lens has " + "negligible distortion." + ), + ) + scrolled.set_child(group) + + self._automatic_btn = Gtk.Button( + label=_("Automatic"), valign=Gtk.Align.CENTER + ) + self._automatic_btn.connect("clicked", self._on_branch_clicked) + automatic_row = Adw.ActionRow( + title=_("Automatic Calibration"), + subtitle=_( + "Print a calibration card and capture it at several " + "positions. The wizard solves the distortion " + "coefficients for you." + ), + ) + automatic_row.add_suffix(self._automatic_btn) + automatic_row.set_activatable_widget(self._automatic_btn) + group.add(automatic_row) + + self._manual_btn = Gtk.Button( + label=_("Manual"), valign=Gtk.Align.CENTER + ) + self._manual_btn.connect("clicked", self._on_branch_clicked) + manual_row = Adw.ActionRow( + title=_("Manual Calibration"), + subtitle=_( + "Enter the radial and tangential distortion " + "coefficients by hand." + ), + ) + manual_row.add_suffix(self._manual_btn) + manual_row.set_activatable_widget(self._manual_btn) + group.add(manual_row) + + # Skip lives in the footer bar, not the page content. + self._skip_btn = Gtk.Button(label=_("Skip")) + self._skip_btn.add_css_class("flat") + self._skip_btn.connect("clicked", self._on_skip_clicked) + + return self.root + + def footer_buttons(self) -> list[Gtk.Button]: + return [self._skip_btn] if self._skip_btn is not None else [] + + def can_proceed(self) -> bool: + return False + + def _on_branch_clicked(self, button: Gtk.Button) -> None: + if button is self._automatic_btn: + self.chosen_branch = self.BRANCH_AUTOMATIC + self.branch_chosen.send(self, branch=self.BRANCH_AUTOMATIC) + elif button is self._manual_btn: + self.chosen_branch = self.BRANCH_MANUAL + self.branch_chosen.send(self, branch=self.BRANCH_MANUAL) + + def _on_skip_clicked(self, _button: Gtk.Button) -> None: + self.chosen_branch = self.BRANCH_SKIPPED + self.branch_chosen.send(self, branch=self.BRANCH_SKIPPED) + + +__all__ = ["LensCalibrationChoicePage"] diff --git a/rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py b/rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py new file mode 100644 index 000000000..736734153 --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/lens_calibration_settings_page.py @@ -0,0 +1,47 @@ +"""Camera wizard page: manual lens-distortion coefficients.""" + +from gettext import gettext as _ + +from gi.repository import Gtk + +from ....camera.controller import CameraController +from ..lens_calibration_widget import LensCalibrationWidget +from .base_page import CameraWizardPage + + +class LensCalibrationSettingsPage(CameraWizardPage): + step_name = "lens_manual" + title = _("Lens Calibration") + + def __init__(self, wizard, controller: CameraController): + super().__init__(wizard, controller) + self.camera = controller.config + self._widget: LensCalibrationWidget | None = None + + def build(self) -> Gtk.Box: + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + right_scroll = Gtk.ScrolledWindow() + right_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.root.append(right_scroll) + right_scroll.set_vexpand(True) + + settings_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=12 + ) + settings_box.set_margin_start(12) + settings_box.set_margin_end(12) + settings_box.set_margin_top(12) + settings_box.set_margin_bottom(12) + right_scroll.set_child(settings_box) + + self._widget = LensCalibrationWidget(self.camera) + settings_box.append(self._widget) + return self.root + + def leave(self) -> None: + if self._widget is not None: + self._widget.stop() + + +__all__ = ["LensCalibrationSettingsPage"] diff --git a/rayforge/ui_gtk/camera/wizard/wizard.py b/rayforge/ui_gtk/camera/wizard/wizard.py new file mode 100644 index 000000000..88ee9b6ce --- /dev/null +++ b/rayforge/ui_gtk/camera/wizard/wizard.py @@ -0,0 +1,294 @@ +"""Unified camera configuration wizard. + +Walks the full camera setup sub-workflow: + +* image settings (resolution, white balance, brightness, ...) +* lens calibration (choice of automatic Charuco capture or manual + coefficient entry, or skip) +* world alignment + +The wizard is launched from the machine wizard's camera page and from +the camera preferences page's "Wizard" button. +""" + +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ....camera.controller import CameraController +from ...shared.patched_dialog_window import PatchedDialogWindow +from .alignment_page import AlignmentPage +from .base_page import CameraWizardPage +from .capture_page import CapturePage +from .card_page import CardPage +from .image_settings_page import ImageSettingsPage +from .lens_calibration_choice_page import LensCalibrationChoicePage +from .lens_calibration_settings_page import LensCalibrationSettingsPage + +logger = logging.getLogger(__name__) + + +class CameraWizard(PatchedDialogWindow): + """Page-based camera calibration wizard.""" + + def __init__( + self, + parent: Gtk.Window, + controller: CameraController, + **kwargs, + ): + super().__init__( + transient_for=parent, + modal=True, + default_width=1150, + default_height=750, + title=_("{camera} - Camera Wizard").format( + camera=controller.config.name + ), + **kwargs, + ) + self.controller = controller + + self._pages: dict[str, CameraWizardPage] = {} + self._page_order: list[str] = [] + self._current: str | None = None + self._history: list[str] = [] + + self._setup_ui() + self._build_pages() + self._navigate_to(self._page_order[0], record_history=False) + + def _setup_ui(self) -> None: + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.toast_overlay.set_child(content) + + header = Adw.HeaderBar() + content.append(header) + + self._main_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + margin_top=12, + margin_bottom=12, + margin_start=32, + margin_end=32, + ) + content.append(self._main_box) + + self._stack = Gtk.Stack() + self._stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + ) + self._main_box.append(self._stack) + + self._button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + halign=Gtk.Align.END, + margin_top=12, + ) + self._main_box.append(self._button_box) + + self._back_btn = Gtk.Button(label=_("Back")) + self._back_btn.add_css_class("flat") + self._back_btn.connect("clicked", self._on_back_clicked) + self._button_box.append(self._back_btn) + + self._cancel_btn = Gtk.Button(label=_("Cancel")) + self._cancel_btn.add_css_class("flat") + self._cancel_btn.connect("clicked", lambda _: self.close()) + self._button_box.append(self._cancel_btn) + + self._action_slot = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=12 + ) + self._button_box.append(self._action_slot) + + self._next_btn = Gtk.Button(label=_("Next")) + self._next_btn.add_css_class("suggested-action") + self._next_btn.connect("clicked", self._on_next_clicked) + self._button_box.append(self._next_btn) + + self._finish_btn = Gtk.Button(label=_("Finish")) + self._finish_btn.add_css_class("suggested-action") + self._finish_btn.connect("clicked", lambda _: self.close()) + self._button_box.append(self._finish_btn) + + self._stack.connect("notify::visible-child", self._on_page_changed) + + def _build_pages(self) -> None: + # Fixed prefix: image settings, then the lens-branch choice. + self._register(ImageSettingsPage) + self._register(LensCalibrationChoicePage) + # Branch pages are built up front so they are ready when the + # user picks a branch, but not added to the order until then. + self._register(CardPage, add_to_order=False) + self._register(CapturePage, add_to_order=False) + self._register(LensCalibrationSettingsPage, add_to_order=False) + self._register(AlignmentPage, add_to_order=False) + + for name in self._page_order: + self._stack.add_named(self._pages[name].build(), name) + + def _register(self, cls, *, add_to_order: bool = True) -> CameraWizardPage: + page = cls(self, self.controller) + self._pages[page.step_name] = page + if add_to_order: + self._page_order.append(page.step_name) + self._wire_page_signals(page) + return page + + def _wire_page_signals(self, page: CameraWizardPage) -> None: + if isinstance(page, LensCalibrationChoicePage): + page.branch_chosen.connect(self._on_lens_branch_chosen) + elif isinstance(page, AlignmentPage): + page.alignment_applied.connect(self._on_alignment_applied) + + def _on_lens_branch_chosen(self, _sender, **kwargs) -> None: + self.on_lens_branch_chosen(kwargs["branch"]) + + def _on_alignment_applied(self, _sender) -> None: + self.on_alignment_applied() + + def _ensure_in_stack(self, name: str) -> None: + page = self._pages[name] + if self._stack.get_child_by_name(name) is None: + self._stack.add_named( + page.root if page.root is not None else page.build(), + name, + ) + + # ----- branch handling --------------------------------------------- + + def on_lens_branch_chosen(self, branch: str) -> None: + """Insert the branch-specific pages after the choice page. + + All branches land on the alignment page (the terminal step); + Skip just omits the lens-calibration pages. + """ + choice_idx = self._page_order.index("lens_choice") + # Truncate the order back to the choice page so a re-pick + # rebuilds the tail cleanly. + self._page_order = self._page_order[: choice_idx + 1] + + if branch == LensCalibrationChoicePage.BRANCH_AUTOMATIC: + self._append_branch_page("card") + self._append_branch_page("capture") + elif branch == LensCalibrationChoicePage.BRANCH_MANUAL: + self._append_branch_page("lens_manual") + # Skipped: no lens-calibration pages. + + # Image↔world alignment is always the terminal page (Finish). + self._append_branch_page("alignment") + + # Navigate to the first page after the choice. + self._navigate_to(self._page_order[choice_idx + 1]) + + def on_alignment_applied(self) -> None: + """The user applied the alignment — finish the wizard.""" + self.close() + + def _append_branch_page(self, name: str) -> None: + self._ensure_in_stack(name) + self._page_order.append(name) + + # ----- navigation --------------------------------------------------- + + def _navigate_to(self, name: str, *, record_history: bool = True) -> None: + if record_history and self._current is not None: + self._history.append(self._current) + if self._current is not None and self._current != name: + self._pages[self._current].leave() + # Slide forward when advancing, backward when returning. + if self._current is not None and name in self._page_order: + cur_idx = ( + self._page_order.index(self._current) + if self._current in self._page_order + else -1 + ) + new_idx = self._page_order.index(name) + if new_idx >= cur_idx: + self._stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_LEFT + ) + else: + self._stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_RIGHT + ) + self._current = name + page = self._pages[name] + page.enter() + self._stack.set_visible_child_name(name) + self._update_footer(name, page) + + def _on_page_changed(self, _stack, _pspec) -> None: + name = self._stack.get_visible_child_name() + if name is None or name == self._current: + return + self._navigate_to(name) + + def _update_footer(self, name: str, page: CameraWizardPage) -> None: + page_title = page.title or _("Camera Wizard") + self.set_title(f"{page_title} — {self.controller.config.name}") + idx = self._page_order.index(name) + self._back_btn.set_visible(idx > 0) + # Finish only on the terminal alignment page; the lens-choice + # page is a decision point, not a terminal. + is_terminal = name == "alignment" + self._finish_btn.set_visible(is_terminal) + self._next_btn.set_visible(not is_terminal) + self._next_btn.set_sensitive(page.can_proceed()) + + child = self._action_slot.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self._action_slot.remove(child) + child = nxt + for btn in page.footer_buttons(): + self._action_slot.append(btn) + + def _on_back_clicked(self, _btn) -> None: + if self._current is None: + return + idx = self._page_order.index(self._current) + if idx <= 0: + return + self._navigate_to(self._page_order[idx - 1]) + + def _on_next_clicked(self, _btn) -> None: + if self._current is None: + return + idx = self._page_order.index(self._current) + if idx + 1 >= len(self._page_order): + return + self._navigate_to(self._page_order[idx + 1]) + + # ----- helpers ------------------------------------------------------ + + def show_error(self, title: str, message: str) -> None: + dialog = Adw.MessageDialog( + transient_for=self, + modal=True, + heading=title, + body=message, + ) + dialog.add_response("ok", _("OK")) + dialog.present() + + def show_toast(self, message: str) -> None: + self.toast_overlay.add_toast(Adw.Toast.new(message)) + + def close(self): + for page in self._pages.values(): + if isinstance(page, CapturePage): + page.stop() + elif isinstance(page, (ImageSettingsPage, AlignmentPage)): + page.leave() + super().close() + + +__all__ = ["CameraWizard"] diff --git a/rayforge/ui_gtk/canvas/__init__.py b/rayforge/ui_gtk/canvas/__init__.py new file mode 100644 index 000000000..4bd48d08d --- /dev/null +++ b/rayforge/ui_gtk/canvas/__init__.py @@ -0,0 +1,11 @@ +from .canvas import Canvas +from .element import CanvasElement +from .shrinkwrap import ShrinkWrapGroup +from .worldsurface import WorldSurface + +__all__ = [ + "Canvas", + "CanvasElement", + "ShrinkWrapGroup", + "WorldSurface", +] diff --git a/rayforge/ui_gtk/canvas/axis.py b/rayforge/ui_gtk/canvas/axis.py new file mode 100644 index 000000000..a1839bd2c --- /dev/null +++ b/rayforge/ui_gtk/canvas/axis.py @@ -0,0 +1,553 @@ +import logging +import math +from typing import TYPE_CHECKING + +import cairo +import numpy as np +from raygeo.geo import Matrix + +if TYPE_CHECKING: + from raygeo.geo.types import Point3D + + +logger = logging.getLogger(__name__) + + +class AxisRenderer: + """ + Helper class to render the grid, axes, and labels on a Cairo context. + This renderer is stateless regarding pan and zoom; it operates in + world coordinates (mm) and relies on a view_transform matrix to map + to widget pixel coordinates. + """ + + def __init__( + self, + grid_size_mm: float = 10.0, + width_mm: float = 100.0, + height_mm: float = 100.0, + margin_left_mm: float = 0.0, + margin_top_mm: float = 0.0, + margin_right_mm: float = 0.0, + margin_bottom_mm: float = 0.0, + y_axis_down: bool = False, + x_axis_right: bool = False, + x_axis_negative: bool = False, + y_axis_negative: bool = False, + fg_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + grid_color: tuple[float, float, float, float] = (0.9, 0.9, 0.9, 1.0), + show_grid: bool = True, + show_axis: bool = True, + label_font_size: float = 12.0, + grid_unit_factor: float = 1.0, + ): + self.grid_size_mm: float = grid_size_mm + self.grid_unit_factor: float = grid_unit_factor + self.width_mm: float = width_mm + self.height_mm: float = height_mm + self.margin_left_mm: float = margin_left_mm + self.margin_top_mm: float = margin_top_mm + self.margin_right_mm: float = margin_right_mm + self.margin_bottom_mm: float = margin_bottom_mm + self.y_axis_down: bool = y_axis_down + self.x_axis_right: bool = x_axis_right + self.x_axis_negative: bool = x_axis_negative + self.y_axis_negative: bool = y_axis_negative + self.fg_color: tuple[float, float, float, float] = fg_color + self.grid_color: tuple[float, float, float, float] = grid_color + self.show_grid: bool = show_grid + self.show_axis: bool = show_axis + self.label_font_size: float = label_font_size + self.min_grid_spacing_px = 50.0 + self.x_axis_y_override: float | None = None + + def get_effective_height(self) -> float: + """Returns the effective height for layout calculations.""" + return self.height_mm + + def get_content_layout( + self, widget_w: int, widget_h: int + ) -> tuple[float, float, float, float]: + """ + Calculates the content area's rectangle in widget pixels, respecting + the mm aspect ratio. This is the single source of truth for layout. + + Returns: + A tuple of (content_x, content_y, content_width, content_height). + """ + # 1. Calculate space needed for axes and labels. + x_axis_space = float(self.get_x_axis_height()) + y_axis_space = float(self.get_y_axis_width()) + + # Define paddings based on original logic. + if self.x_axis_right: + left_padding = math.ceil(y_axis_space / 2) + right_padding = y_axis_space + else: + left_padding = y_axis_space + right_padding = math.ceil(y_axis_space / 2) + total_horiz_padding = left_padding + right_padding + + if self.y_axis_down: + top_padding = x_axis_space + bottom_padding = math.ceil(x_axis_space / 2) + else: + top_padding = math.ceil(x_axis_space / 2) + bottom_padding = x_axis_space + total_vert_padding = top_padding + bottom_padding + + # 2. Determine the available drawing area after subtracting padding. + available_width = float(widget_w) - total_horiz_padding + available_height = float(widget_h) - total_vert_padding + + if available_width <= 0 or available_height <= 0: + logger.warning( + "Available drawing area is non-positive; " + "canvas may be too small." + ) + return left_padding, top_padding, 0.0, 0.0 + + # 3. Calculate the target aspect ratio from mm dimensions. + effective_height = self.get_effective_height() + if self.width_mm <= 0 or effective_height <= 0: + return left_padding, top_padding, available_width, available_height + + world_aspect_ratio = self.width_mm / effective_height + + # 4. Calculate content dimensions that fit and match aspect ratio. + available_aspect_ratio = available_width / available_height + + if available_aspect_ratio > world_aspect_ratio: + # Available area is wider than needed. Height is the constraint. + content_height = available_height + content_width = content_height * world_aspect_ratio + else: + # Available area is taller than needed. Width is the constraint. + content_width = available_width + content_height = content_width / world_aspect_ratio + + # 5. Center the content area within the available space. + x_offset = (available_width - content_width) / 2 + y_offset = (available_height - content_height) / 2 + + content_x = left_padding + x_offset + content_y = top_padding + y_offset + + return content_x, content_y, content_width, content_height + + def get_base_pixels_per_mm(self, widget_w: int, widget_h: int) -> float: + """ + Calculates the base pixels/mm for a zoom level of 1.0. + """ + _, _, content_w, content_h = self.get_content_layout( + widget_w, widget_h + ) + effective_height = self.get_effective_height() + if self.width_mm <= 0 or effective_height <= 0: + return 1.0 + + base_ppm_x = content_w / self.width_mm + base_ppm_y = content_h / effective_height + return min(base_ppm_x, base_ppm_y) + + def _get_adaptive_grid_size(self, pixels_per_mm: float) -> float: + """ + Calculates an appropriate grid spacing in mm based on the current + zoom level (pixels per mm). + + The spacing is chosen as a "nice" number (1, 2, 5, 10, 20, 50, + 100...) in the user's preferred length unit, then converted back to + mm so grid lines land on multiples of that unit. + """ + if pixels_per_mm <= 1e-6: + return self.grid_size_mm + + # Calculate the grid size in mm that would correspond to our desired + # minimum pixel spacing. + target_grid_size_mm = self.min_grid_spacing_px / pixels_per_mm + + # Express the target spacing in the user's preferred length unit. + target_grid_size_unit = target_grid_size_mm / self.grid_unit_factor + + # Find the next "nice" number (1, 2, 5, 10, 20, 50, 100...) in + # preferred-unit space that is greater than or equal to the target. + power_of_10 = 10 ** math.floor(math.log10(target_grid_size_unit)) + + # Use corrected thresholds to round up to the nearest 1, 2, 5, or 10. + relative_size = target_grid_size_unit / power_of_10 + if relative_size <= 1.0: + nice_size_unit = power_of_10 + elif relative_size <= 2.0: + nice_size_unit = 2 * power_of_10 + elif relative_size <= 5.0: + nice_size_unit = 5 * power_of_10 + else: + nice_size_unit = 10 * power_of_10 + + return nice_size_unit * self.grid_unit_factor + + def draw_grid_and_labels( + self, + ctx: cairo.Context, + view_transform: Matrix, + widget_w: int, + widget_h: int, + origin_offset_mm: "Point3D" = (0.0, 0.0, 0.0), + ): + """ + Draws the grid, axes, and labels onto the Cairo context using the + provided world-to-view transform and widget dimensions. + """ + if not self.show_grid and not self.show_axis: + return + + ctx.save() + + try: + inv_view = view_transform.invert() + except np.linalg.LinAlgError: + ctx.restore() + return + + # Shared Calculations + # Calculate adaptive grid spacing + scale_x, scale_y = view_transform.get_scale() + pixels_per_mm = (abs(scale_x) + abs(scale_y)) / 2.0 + adaptive_grid_size_mm = self._get_adaptive_grid_size(pixels_per_mm) + + # Calculate visible bounds in mm for culling/optimizing grid lines + tl_mm = inv_view.transform_point((0, 0)) + br_mm = inv_view.transform_point((widget_w, widget_h)) + visible_min_x, visible_max_x = ( + min(tl_mm[0], br_mm[0]), + max(tl_mm[0], br_mm[0]), + ) + visible_min_y, visible_max_y = ( + min(tl_mm[1], br_mm[1]), + max(tl_mm[1], br_mm[1]), + ) + + # Draw Grid + if self.show_grid: + self._draw_grid( + ctx, + view_transform, + adaptive_grid_size_mm, + visible_min_x, + visible_max_x, + visible_min_y, + visible_max_y, + origin_offset_mm, + ) + + # Draw Axes and Labels + if self.show_axis: + self._draw_axis_and_labels( + ctx, view_transform, adaptive_grid_size_mm, origin_offset_mm + ) + + ctx.restore() + + def _draw_grid( + self, + ctx: cairo.Context, + view_transform: Matrix, + grid_size_mm: float, + min_x: float, + max_x: float, + min_y: float, + max_y: float, + origin_offset_mm: "Point3D", + ): + """Internal helper to draw the infinite grid lines.""" + ctx.set_source_rgba(*self.grid_color) + ctx.set_hairline(True) + + # Determine grid origin based on the WCS offset + origin_x, origin_y, _ = origin_offset_mm + + if self.x_axis_negative: + origin_x = -origin_x + if self.y_axis_negative: + origin_y = -origin_y + + # Determine the World Coordinate of the WCS Origin to align grid lines. + # This handles cases where Machine Zero is at Right/Top properly. + if self.x_axis_right: + # If x_axis_right=True, origin_x is distance from Right Edge. + # So World X = Width - origin_x + wcs_world_x = self.width_mm - origin_x + else: + wcs_world_x = origin_x + + if self.y_axis_down: + wcs_world_y = self.height_mm - origin_y + else: + wcs_world_y = origin_y + + # Vertical lines (along X) aligned to WCS X + k_start_x = math.ceil((min_x - wcs_world_x) / grid_size_mm) + k_end_x = math.floor((max_x - wcs_world_x) / grid_size_mm) + for k in range(k_start_x, k_end_x + 1): + x_mm = wcs_world_x + k * grid_size_mm + p1_px = view_transform.transform_point((x_mm, min_y)) + p2_px = view_transform.transform_point((x_mm, max_y)) + ctx.move_to(p1_px[0], p1_px[1]) + ctx.line_to(p2_px[0], p2_px[1]) + ctx.stroke() + + # Horizontal lines (along Y) aligned to WCS Y + k_start_y = math.ceil((min_y - wcs_world_y) / grid_size_mm) + k_end_y = math.floor((max_y - wcs_world_y) / grid_size_mm) + for k in range(k_start_y, k_end_y + 1): + y_mm = wcs_world_y + k * grid_size_mm + p1_px = view_transform.transform_point((min_x, y_mm)) + p2_px = view_transform.transform_point((max_x, y_mm)) + ctx.move_to(p1_px[0], p1_px[1]) + ctx.line_to(p2_px[0], p2_px[1]) + ctx.stroke() + + def _draw_axis_and_labels( + self, + ctx: cairo.Context, + view_transform: Matrix, + grid_size_mm: float, + origin_offset_mm: "Point3D", + ): + """Internal helper to draw the main XY axes and text labels.""" + # Calculate precision needed to display fractional grid sizes, + # expressed in the user's preferred length unit. + grid_step_units = grid_size_mm / self.grid_unit_factor + if grid_step_units < 1.0 - 1e-9: + precision = math.ceil(-math.log10(grid_step_units)) + else: + precision = 0 + + ctx.set_source_rgba(*self.fg_color) + ctx.set_line_width(1) + ctx.set_font_size(self.label_font_size) + + work_origin_x, work_origin_y, _ = origin_offset_mm + + if self.x_axis_negative: + work_origin_x = -work_origin_x + if self.y_axis_negative: + work_origin_y = -work_origin_y + + # Calculate World Coordinates of WCS Origin + if self.x_axis_right: + wcs_world_x = self.width_mm - work_origin_x + else: + wcs_world_x = work_origin_x + + if self.y_axis_down: + wcs_world_y = self.height_mm - work_origin_y + else: + wcs_world_y = work_origin_y + + # Workarea bounds (where axis lines are drawn) + workarea_left = self.margin_left_mm + workarea_right = self.width_mm - self.margin_right_mm + workarea_top = self.margin_bottom_mm + workarea_bottom = self.height_mm - self.margin_top_mm + + # Determine axis positions based on Y and X orientation + # Axes are drawn at workarea edges, unless overridden + if self.x_axis_y_override is not None: + x_axis_y = self.x_axis_y_override + y_axis_start_mm = (workarea_left, workarea_bottom) + y_axis_end_mm = (workarea_left, workarea_top) + elif self.y_axis_down: + x_axis_y = workarea_bottom + y_axis_start_mm = (workarea_right, workarea_bottom) + y_axis_end_mm = (workarea_right, workarea_top) + else: + x_axis_y = workarea_top + y_axis_start_mm = (workarea_left, workarea_top) + y_axis_end_mm = (workarea_left, workarea_bottom) + + if self.x_axis_right: + y_axis_start_mm = (workarea_right, y_axis_start_mm[1]) + y_axis_end_mm = (workarea_right, y_axis_end_mm[1]) + x_axis_start_mm = (workarea_right, x_axis_y) + x_axis_end_mm = (workarea_left, x_axis_y) + else: + y_axis_start_mm = (workarea_left, y_axis_start_mm[1]) + y_axis_end_mm = (workarea_left, y_axis_end_mm[1]) + x_axis_start_mm = (workarea_left, x_axis_y) + x_axis_end_mm = (workarea_right, x_axis_y) + + # Draw physical bed borders + x_start_px = view_transform.transform_point(x_axis_start_mm) + x_end_px = view_transform.transform_point(x_axis_end_mm) + y_start_px = view_transform.transform_point(y_axis_start_mm) + y_end_px = view_transform.transform_point(y_axis_end_mm) + + ctx.move_to(x_start_px[0], x_start_px[1]) + ctx.line_to(x_end_px[0], x_end_px[1]) + ctx.stroke() + ctx.move_to(y_start_px[0], y_start_px[1]) + ctx.line_to(y_end_px[0], y_end_px[1]) + ctx.stroke() + + # --- Draw Labels --- + corner_x_label_value = None + world_x_for_y_labels = ( + workarea_right if self.x_axis_right else workarea_left + ) + + # Draw X Labels + # Constrained to workarea width + min_delta_x = workarea_left - wcs_world_x + max_delta_x = workarea_right - wcs_world_x + k_start_x = math.ceil(min_delta_x / grid_size_mm) + k_end_x = math.floor(max_delta_x / grid_size_mm) + + for k in range(k_start_x, k_end_x + 1): + delta = k * grid_size_mm + world_x = wcs_world_x + delta + + if self.x_axis_right: + # Canvas X increases Right. Machine X increases Left. + # So moving +CanvasX is -MachineX. + label_val = -delta + else: + label_val = delta + + # Apply final sign flip if axis is negative + if self.x_axis_negative: + label_val = -label_val + + # Display the label in the user's preferred length unit + label_val = label_val / self.grid_unit_factor + + # Check if this label is at the corner where the Y-axis is drawn + if abs(world_x - world_x_for_y_labels) < 1e-3: + corner_x_label_value = label_val + + label = f"{round(label_val, precision):g}" + label_pos_px = view_transform.transform_point((world_x, x_axis_y)) + extents = ctx.text_extents(label) + y_offset = -4 if self.y_axis_down else extents.height + 4 + + ctx.move_to( + label_pos_px[0] - extents.width / 2, label_pos_px[1] + y_offset + ) + ctx.show_text(label) + + # Draw Y Labels + min_delta_y = workarea_top - wcs_world_y + max_delta_y = workarea_bottom - wcs_world_y + + k_start_y = math.ceil(min_delta_y / grid_size_mm) + k_end_y = math.floor(max_delta_y / grid_size_mm) + + for k in range(k_start_y, k_end_y + 1): + delta = k * grid_size_mm + world_y = wcs_world_y + delta + + if self.y_axis_down: + # Canvas Y increases Up. Machine Y increases Down. + label_val = -delta + else: + label_val = delta + + if self.y_axis_negative: + label_val = -label_val + + # Display the label in the user's preferred length unit + label_val = label_val / self.grid_unit_factor + + # Check if this label is at the corner where X-axis is drawn + is_at_corner = abs(world_y - x_axis_y) < 1e-3 + + # Skip drawing the Y label if it's at the corner AND its value + # is the same as the X label's value at that corner. + if ( + is_at_corner + and corner_x_label_value is not None + and abs(label_val - corner_x_label_value) < 1e-9 + ): + continue + + label = f"{round(label_val, precision):g}" + extents = ctx.text_extents(label) + + label_pos_px = view_transform.transform_point( + (world_x_for_y_labels, world_y) + ) + + x_offset = 4 if self.x_axis_right else -extents.width - 4 + ctx.move_to( + label_pos_px[0] + x_offset, + label_pos_px[1] + extents.height / 2, + ) + ctx.show_text(label) + + def get_x_axis_height(self) -> int: + """Calculates the maximum height of the X-axis labels.""" + # The height of numeric labels is generally constant for a given font. + # We can measure a representative character like "8", which usually has + # the maximum height among digits. + temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) + ctx = cairo.Context(temp_surface) + ctx.set_font_size(self.label_font_size) + + extents = ctx.text_extents("8") + return math.ceil(extents.height) + 4 + + def get_y_axis_width(self) -> int: + """Calculates the maximum width of the Y-axis labels.""" + # The maximum width is determined by the label with the most digits, + # which corresponds to the largest coordinate value. + temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) + ctx = cairo.Context(temp_surface) + ctx.set_font_size(self.label_font_size) + # Account for negative sign potentially making label wider + if self.y_axis_negative: + max_y_label = f"{-self.height_mm:.0f}" + else: + max_y_label = f"{self.height_mm:.0f}" + extents = ctx.text_extents(max_y_label) + return math.ceil(extents.width) + 4 + + def set_width_mm(self, width_mm: float): + self.width_mm = width_mm + + def set_height_mm(self, height_mm: float): + self.height_mm = height_mm + + def set_margins_mm( + self, left: float, top: float, right: float, bottom: float + ): + self.margin_left_mm = left + self.margin_top_mm = top + self.margin_right_mm = right + self.margin_bottom_mm = bottom + + def set_x_axis_right(self, x_axis_right: bool): + self.x_axis_right = x_axis_right + + def set_y_axis_down(self, y_axis_down: bool): + self.y_axis_down = y_axis_down + + def set_x_axis_negative(self, x_axis_negative: bool): + self.x_axis_negative = x_axis_negative + + def set_y_axis_negative(self, y_axis_negative: bool): + self.y_axis_negative = y_axis_negative + + def set_fg_color(self, fg_color: tuple[float, float, float, float]): + self.fg_color = fg_color + + def set_grid_color(self, grid_color: tuple[float, float, float, float]): + self.grid_color = grid_color + + def set_grid_unit_factor(self, grid_unit_factor: float): + self.grid_unit_factor = grid_unit_factor + + def set_label_font_size(self, label_font_size: float): + self.label_font_size = label_font_size + + def set_x_axis_y_override(self, y: float | None): + self.x_axis_y_override = y diff --git a/rayforge/ui_gtk/canvas/canvas.py b/rayforge/ui_gtk/canvas/canvas.py new file mode 100644 index 000000000..790f7d2ca --- /dev/null +++ b/rayforge/ui_gtk/canvas/canvas.py @@ -0,0 +1,1370 @@ +from __future__ import annotations + +import logging +import math +from collections.abc import Generator +from enum import Enum, auto +from typing import ( + TYPE_CHECKING, + Any, +) + +import cairo +import numpy as np +from blinker import Signal +from gi.repository import Gdk, Graphene, Gtk +from raygeo.geo import Matrix + +from ...core.color import ColorRGBA +from ..shared.keyboard import is_primary_keyval +from . import transform +from .cursor import get_cursor_for_region +from .element import CanvasElement +from .intersect import obb_intersects_aabb +from .multiselect import MultiSelectionGroup +from .overlays import render_selection_frame, render_selection_handles +from .region import ( + BBOX_REGIONS, + MOVE_HANDLES, + RESIZE_HANDLES, + ROTATE_HANDLES, + ROTATE_SHEAR_HANDLES, + SHEAR_HANDLES, + ElementRegion, +) + +if TYPE_CHECKING: + from raygeo.geo.types import Point, Rect + + +logger = logging.getLogger(__name__) +DRAG_THRESHOLD = 5.0 + + +class SelectionMode(Enum): + """Defines the interaction mode for the current selection.""" + + NONE = auto() # nothing selected + RESIZE = auto() + ROTATE_SHEAR = auto() + + +class Canvas(Gtk.DrawingArea): + """ + An interactive drawing area that manages and renders `CanvasElement` + objects. + + It handles user interactions like clicking, dragging, resizing, and + rotating elements, as well as selection management (single, multi, + and framing). + """ + + BASE_HANDLE_SIZE = 20.0 + SNAP_ANGLE_DEGREES = 5.0 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.root = CanvasElement( + 0.0, + 0.0, + 0.0, # Initial size is 0, set in do_size_allocate + 0.0, # Initial size is 0, set in do_size_allocate + canvas=self, + parent=self, + ) + self.grid_size = 5 + self.view_transform: Matrix = Matrix.identity() + # The primary element within the current selection, which receives + # keyboard focus. This is a persistent state. + self._active_elem: CanvasElement | None = None + + # Stores the state of an element or group at the start of a transform + self._active_origin: Rect | None = None # Group bbox (x,y,w,h) + # Stores the initial transform of a single element being transformed + self._initial_transform: Matrix | None = None + self._initial_world_transform: Matrix | None = None + + self._setup_interactions() + + # --- Interaction State --- + self._hovered_elem: CanvasElement | None = None + self._hovered_region: ElementRegion = ElementRegion.NONE + self._active_region: ElementRegion = ElementRegion.NONE + # The element being actively manipulated. This is a transient state, + # lasting only for the duration of an interaction. + self._drag_target: CanvasElement | None = None + self._selection_mode: SelectionMode = SelectionMode.NONE + self._selection_just_changed: bool = False + self._selection_group: MultiSelectionGroup | None = None + self._framing_selection: bool = False + self._selection_frame_rect: Rect | None = None + self._selection_before_framing: set[CanvasElement] = set() + self._group_hovered: bool = False + self._last_mouse_x: float = 0.0 + self._last_mouse_y: float = 0.0 + self._resizing: bool = False + self._moving: bool = False + self._rotating: bool = False + self._shearing: bool = False + self._was_dragging: bool = False + self._edit_dragging: bool = False + self._transforming_elements: list[CanvasElement] = [] + self.edit_context: CanvasElement | None = None + + # --- Rotation State --- + self._drag_start_angle: float = 0.0 + self._rotation_pivot: Point | None = None + + # --- Signals --- + self.move_begin = Signal() + self.move_end = Signal() + self.resize_begin = Signal() + self.resize_end = Signal() + self.rotate_begin = Signal() + self.rotate_end = Signal() + self.shear_begin = Signal() + self.shear_end = Signal() + + # Fired after any transform gesture ends. + self.transform_end = Signal() + + # Fired during an active transform gesture on each mouse-move + # (after the elements have been repositioned). Receives one + # argument ``elements`` — the list of canvas elements that + # were just moved / resized / rotated / sheared. + self.transform_moved = Signal() + + self.elements_deleted = Signal() + self.selection_changed = Signal() + self.active_element_changed = Signal() + self.elem_removed = Signal() + + self.edit_drag_begin = Signal() + self.edit_drag_end = Signal() + + def add(self, elem: CanvasElement): + """Adds a top-level element to the canvas.""" + self.root.add(elem) + + def remove(self, elem: CanvasElement): + """Removes a top-level element from the canvas.""" + self.root.remove_child(elem) + + def find_by_data(self, data: Any) -> CanvasElement | None: + """ + Finds the first element with matching data in the canvas. + """ + return self.root.find_by_data(data) + + def find_by_type( + self, thetype: Any + ) -> Generator[CanvasElement, None, None]: + """ + Finds all elements of a given type in the canvas. + """ + return self.root.find_by_type(thetype) + + def size(self) -> tuple[float, float]: + """Gets the (width, height) of the canvas.""" + return self.root.size() + + def _get_world_coords(self, widget_x: float, widget_y: float) -> Point: + """ + Converts widget pixel coordinates to canvas world coordinates using + the active view_transform. + """ + try: + return self.view_transform.invert().transform_point( + (widget_x, widget_y) + ) + except np.linalg.LinAlgError: + # Fallback to 1:1 if matrix is non-invertible + return widget_x, widget_y + + def _setup_interactions(self): + """Initializes and attaches all GTK event controllers.""" + self._click_gesture = Gtk.GestureClick() + self._click_gesture.connect("pressed", self.on_button_press) + self._click_gesture.connect("released", self.on_click_released) + self.add_controller(self._click_gesture) + + self._motion_controller = Gtk.EventControllerMotion() + self._motion_controller.connect("motion", self.on_motion) + self._motion_controller.connect("leave", self.on_motion_leave) + self.add_controller(self._motion_controller) + + self._drag_gesture = Gtk.GestureDrag() + self._drag_gesture.set_button(Gdk.BUTTON_PRIMARY) + self._drag_gesture.connect("drag-update", self.on_mouse_drag) + self._drag_gesture.connect("drag-end", self.on_drag_end) + self.add_controller(self._drag_gesture) + + self._key_controller = Gtk.EventControllerKey.new() + self._key_controller.connect("key-pressed", self.on_key_pressed) + self._key_controller.connect("key-released", self.on_key_released) + self.add_controller(self._key_controller) + self._shift_pressed: bool = False + self._ctrl_pressed: bool = False + self.set_focusable(True) + self.grab_focus() + + def do_size_allocate(self, width: int, height: int, baseline: int): + """GTK handler for when the widget's size changes.""" + self.root.set_size(float(width), float(height)) + self.root.allocate() + + def do_snapshot(self, snapshot): + """GTK4 snapshot-based drawing handler.""" + width, height = self.get_width(), self.get_height() + bounds = Graphene.Rect().init(0, 0, width, height) + ctx = snapshot.append_cairo(bounds) + + # Apply the view transform to render all elements in world space. + ctx.save() + cairo_matrix = cairo.Matrix(*self.view_transform.for_cairo()) + ctx.transform(cairo_matrix) + if self.edit_context: + # 1. Render everything with low alpha + ctx.push_group() + self.root.render(ctx) + ctx.pop_group_to_source() + ctx.paint_with_alpha(0.3) + # 2. Re-render the edit context and its children on top at full + # alpha + self.edit_context.render(ctx) + else: + self.root.render(ctx) + ctx.restore() + + # After restoring the context, we are now in pure pixel space. + # All overlays are drawn here so they are not affected by + # view zoom/pan. + self._render_overlays(ctx) + + def _render_element_overlays( + self, ctx: cairo.Context, elem: CanvasElement + ): + """Recursively calls the draw_overlay method for all elements.""" + elem.draw_overlay(ctx) + for child in elem.children: + self._render_element_overlays(ctx, child) + + def _render_overlays(self, ctx: cairo.Context): + """Renders all non-content overlays in pixel space.""" + # Draw selection frames and handles on top of everything. + self._render_selection_overlay(ctx, self.root) + + # If in edit mode, the context element draws its own special overlay. + if self.edit_context: + self.edit_context.draw_edit_overlay(ctx) + + # Allow elements to draw their own custom overlays (e.g., previews) + self._render_element_overlays(ctx, self.root) + + # Draw the framing rectangle if we are in framing mode. + if self._framing_selection and self._selection_frame_rect: + ctx.save() + x, y, w, h = self._selection_frame_rect + # A semi-transparent blue fill + ctx.set_source_rgba(0.2, 0.5, 0.8, 0.3) + ctx.rectangle(x, y, w, h) + ctx.fill_preserve() + # A solid blue, dashed border + ctx.set_source_rgb(0.2, 0.5, 0.8) + ctx.set_line_width(1) + ctx.set_dash((4, 4)) + ctx.stroke() + ctx.restore() + + def _render_selection_overlay( + self, ctx: cairo.Context, elem: CanvasElement + ): + """ + Recursively orchestrates the drawing of selection overlays in pixel + space. + """ + is_multi_select = self._selection_group is not None + + # Draw frame for any selected element, respecting show_selection_frame, + # but hide it if the element is the one currently being edited. + if ( + elem.selected + and elem.show_selection_frame + and elem is not self.edit_context + ): + self._draw_selection_frame(ctx, elem) + if not is_multi_select: + self._render_single_selection_overlay(ctx, elem) + + for child in elem.children: + self._render_selection_overlay(ctx, child) + + # The group overlay is handled once at the root level. + if elem is self.root and self._selection_group: + self._render_multi_selection_overlay(ctx, self._selection_group) + + def _draw_selection_frame(self, ctx: cairo.Context, elem: CanvasElement): + """Draws the dashed selection frame for any given element.""" + screen_transform = self.view_transform @ elem.get_world_transform() + render_selection_frame(ctx, elem, screen_transform) + + def _get_handle_color(self, elem: CanvasElement) -> ColorRGBA | None: + """Returns an optional color for selection handles. + + Override in subclasses to provide element-specific handle colors. + Returns None to use the default blue. + """ + return None + + def _render_single_selection_overlay( + self, ctx: cairo.Context, elem: CanvasElement + ): + """Draws the interactive handles for a single selected element.""" + # Hide standard handles if transforming or if in edit mode. + if ( + self._moving + or self._resizing + or self._rotating + or self._shearing + or elem is self.edit_context + ): + return + + screen_transform = self.view_transform @ elem.get_world_transform() + render_selection_handles( + ctx, + target=elem, + transform_to_screen=screen_transform, + mode=self._selection_mode, + hovered_region=self._hovered_region, + base_handle_size=self.BASE_HANDLE_SIZE, + with_labels=False, # Set to True to debug + color=self._get_handle_color(elem), + ) + + def _render_multi_selection_overlay( + self, ctx: cairo.Context, group: MultiSelectionGroup + ): + """Draws the selection frame and handles for a group.""" + group._calculate_bounding_box() + + # The transform from the group's local AABB space to screen space. + group_offset_transform = Matrix.translation(group.x, group.y) + transform_to_screen = self.view_transform @ group_offset_transform + + # Draw the frame for the group. + render_selection_frame(ctx, group, transform_to_screen) + + # Draw handles if not currently transforming. + if not ( + self._moving or self._resizing or self._rotating or self._shearing + ): + colors = {self._get_handle_color(e) for e in group.elements} + color = colors.pop() if len(colors) == 1 else None + render_selection_handles( + ctx, + target=group, + transform_to_screen=transform_to_screen, + mode=self._selection_mode, + hovered_region=self._hovered_region, + base_handle_size=self.BASE_HANDLE_SIZE, + with_labels=False, # Set to True to debug + color=color, + ) + + def _update_hover_state(self, x: float, y: float) -> bool: + """ + Updates the hover state based on cursor position. + + This is the single source of truth for the interactive region. It + checks for hits in a specific order: + 1. Resize/rotation handles on the current selection, respecting the + current selection mode. + 2. Body of any selectable element under the cursor. + + Args: + x: The x-coordinate in WORLD space. + y: The y-coordinate in WORLD space. + + Returns: + True if the hover state changed and a redraw is needed. + """ + selected_elems = self.get_selected_elements() + is_multi_select = len(selected_elems) > 1 + new_hovered_region = ElementRegion.NONE + new_hovered_elem = None + + # Priority 1: Check for a valid handle hit on the current selection. + # We build a set of candidate regions based on the current mode. + handle_candidates: set[ElementRegion] | None = None + if self._selection_mode == SelectionMode.RESIZE: + handle_candidates = RESIZE_HANDLES | MOVE_HANDLES + elif self._selection_mode == SelectionMode.ROTATE_SHEAR: + handle_candidates = ROTATE_SHEAR_HANDLES | MOVE_HANDLES + + if handle_candidates: + target: CanvasElement | MultiSelectionGroup | None = None + if is_multi_select: + target = self._selection_group + elif selected_elems: + target = selected_elems[0] + + if target: + # Pass the candidates to the hit-test function. It will only + # return a valid region from this set, or NONE. + region = target.check_region_hit( + x, y, candidates=handle_candidates + ) + + if region != ElementRegion.NONE: + new_hovered_region = region + if isinstance(target, CanvasElement): + new_hovered_elem = target + + # Priority 2: If no valid handles were hit, find the element body. + if new_hovered_region == ElementRegion.NONE: + hit_elem = self.root.get_elem_hit(x, y, selectable=True) + if hit_elem and hit_elem is not self.root: + new_hovered_region = ElementRegion.BODY + new_hovered_elem = hit_elem + + # Compare new state with old to see if a redraw is needed. + needs_redraw = ( + self._hovered_region != new_hovered_region + or self._hovered_elem is not new_hovered_elem + ) + self._hovered_region = new_hovered_region + self._hovered_elem = new_hovered_elem + + # Update the group hover flag. + new_group_hovered = False + if self._selection_group: + # Check for body or any handle to set the general group hover flag + all_group_regions = ( + RESIZE_HANDLES + | ROTATE_SHEAR_HANDLES + | MOVE_HANDLES + | {ElementRegion.BODY} + ) + if ( + self._selection_group.check_region_hit( + x, y, candidates=all_group_regions + ) + != ElementRegion.NONE + ): + new_group_hovered = True + + if self._group_hovered != new_group_hovered: + self._group_hovered = new_group_hovered + needs_redraw = True + + return needs_redraw + + def on_button_press(self, gesture, n_press: int, x: float, y: float): + """ + Handles the start of a click or drag operation. + + This method determines the user's intent based on what was + clicked (element, handle, or background) and modifier keys. It + manages selection changes and initiates move, resize, rotate, or + framing operations. + """ + logger.debug(f"Canvas.on_button_press fired for {type(self).__name__}") + self.grab_focus() + self._was_dragging = False + event = gesture.get_current_event() + if event: + mods = event.get_modifier_state() + self._shift_pressed = bool(mods & Gdk.ModifierType.SHIFT_MASK) + self._ctrl_pressed = bool(mods & Gdk.ModifierType.CONTROL_MASK) + world_x, world_y = self._get_world_coords(x, y) + self._update_hover_state(world_x, world_y) + + # Edit mode logic + if self.edit_context: + handled = self.edit_context.handle_edit_press( + world_x, world_y, n_press + ) + # If press was not handled by element, leave edit mode + if not handled: + self.leave_edit_mode() + self.queue_draw() + return # Stop further processing in edit mode + + # Double-click to enter edit mode + if ( + n_press == 2 + and self._hovered_elem + and self._hovered_elem.is_editable + ): + self.enter_edit_mode(self._hovered_elem) + return + + self._active_region = self._hovered_region + hit = self._hovered_elem + self._framing_selection = False + selection_changed = False + + # Step 1: Always identify the transient target for a drag operation. + self._drag_target = hit + + # Step 2: Decide if the persistent selection state should change. + if self._active_region in [ElementRegion.NONE, ElementRegion.BODY]: + if hit is None: # Clicked on background: start framing. + self._framing_selection = True + if self._shift_pressed: + self._selection_before_framing = set( + self.get_selected_elements() + ) + else: + if self.get_selected_elements(): + selection_changed = True + self.root.unselect_all() + self._selection_before_framing = set() + elif not hit.preserves_selection_on_click: + # This is a standard element; perform normal selection logic. + if not self._shift_pressed: + if not hit.selected: + self.root.unselect_all() + selection_changed = True + hit.selected = True + else: # Shift-click toggles selection. + hit.selected = not hit.selected + selection_changed = True + # A standard click always updates the active element. + self._active_elem = hit + + # This flag may be used in on_click_released to toggling the + # selection mode. + self._selection_just_changed = selection_changed + + if self._framing_selection: + if selection_changed: + self._finalize_selection_state() + self.queue_draw() + return + + # If the selection changed, finalize it so the transform logic + # below has the correct state (e.g., active_elem) to work with. + if selection_changed: + self._finalize_selection_state() + + # --- Prepare for Transform --- + target = self._selection_group or self._drag_target + if not target: + self.queue_draw() + return + + # Special case: rotation needs to be prepared on press. + if self._active_region in ROTATE_HANDLES: + self._start_rotation(target, world_x, world_y) + + # Store initial state for the transform. The action itself + # (_moving=True, etc.) will be initiated in on_mouse_drag. + if isinstance(target, MultiSelectionGroup): + self._active_origin = target._bounding_box + target.store_initial_states() + elif isinstance(target, CanvasElement): + self._initial_transform = target.transform.copy() + self._initial_world_transform = target.get_world_transform().copy() + tx, ty = target.transform.get_translation() + self._active_origin = (tx, ty, target.width, target.height) + + self.queue_draw() + + def on_motion(self, gesture, x: float, y: float): + """ + Handles mouse movement, updating hover state and cursor icon. + This is the single source of truth for cursor updates. + """ + # Store raw pixel coordinates for selection frame rendering + self._last_mouse_x = x + self._last_mouse_y = y + + is_dragging = ( + self._moving or self._resizing or self._rotating or self._shearing + ) + + # If in edit mode, forward motion to the element for hover tracking. + if self.edit_context: + world_x, world_y = self._get_world_coords(x, y) + self.edit_context.handle_edit_motion(world_x, world_y) + return + + # Only update hover state when not dragging. + if not is_dragging: + world_x, world_y = self._get_world_coords(x, y) + if self._update_hover_state(world_x, world_y): + self.queue_draw() + + # Determine the relevant region: the one being dragged or the one + # hovered. + current_region = ( + self._active_region if is_dragging else self._hovered_region + ) + + # Determine the final visual rotation angle for the cursor. + selected_elems = self.get_selected_elements() + cursor_angle = 0.0 + use_absolute_angle = False + + # For all handles (resize, rotate, shear), the cursor angle depends on + # the total visual rotation of the selection. This is the correct + # logic. + if self._selection_group: + # Group is axis-aligned in world, so only view transform matters, + # unless we are actively rotating it. + if self._rotating and self._rotation_pivot: + # We are actively rotating a group. Calculate dynamic angle. + world_x, world_y = self._get_world_coords(x, y) + pivot_x, pivot_y = self._rotation_pivot + current_mouse_angle = math.degrees( + math.atan2(world_y - pivot_y, world_x - pivot_x) + ) + # Total rotation of the group is the difference from start. + angle_delta = self._drag_start_angle - current_mouse_angle + # Final cursor angle is delta + view rotation. + cursor_angle = angle_delta + self.view_transform.get_rotation() + else: + # For resize/shear/move, group is visually axis-aligned. + cursor_angle = self.view_transform.get_rotation() + elif selected_elems: + # For a single element, combine its world transform with the view. + elem = selected_elems[0] + transform_to_screen = ( + self.view_transform @ elem.get_world_transform() + ) + + if current_region in SHEAR_HANDLES: + # For shear, the cursor must align with the visual edge. + # We calculate the edge's absolute angle and tell the cursor + # logic not to add its own base angle. + use_absolute_angle = True + if current_region in ( + ElementRegion.SHEAR_TOP, + ElementRegion.SHEAR_BOTTOM, + ): + cursor_angle = transform_to_screen.get_x_axis_angle() + else: # SHEAR_LEFT, SHEAR_RIGHT + cursor_angle = transform_to_screen.get_y_axis_angle() + else: + # For resize/rotate, the cursor angle is the element's + # overall rotation. + cursor_angle = transform_to_screen.get_rotation() + + cursor = get_cursor_for_region( + current_region, cursor_angle, absolute=use_absolute_angle + ) + self.set_cursor(cursor) + + def on_motion_leave(self, controller): + """Resets hover state when the mouse leaves the canvas.""" + self._last_mouse_x, self._last_mouse_y = -1.0, -1.0 # Out of bounds + if ( + self._hovered_elem is None + and self._hovered_region == ElementRegion.NONE + ): + return + + self._hovered_elem = None + self._group_hovered = False + self._hovered_region = ElementRegion.NONE + self.queue_draw() + self.set_cursor(Gdk.Cursor.new_from_name("default")) + + def _update_framing_selection_from_drag( + self, offset_x: float, offset_y: float + ): + """Helper to update the rubber-band selection frame during a drag.""" + ok, start_x, start_y = self._drag_gesture.get_start_point() + if not ok: + return + x1, y1 = start_x, start_y + x2, y2 = start_x + offset_x, start_y + offset_y + self._selection_frame_rect = ( + min(x1, x2), + min(y1, y2), + abs(x1 - x2), + abs(y1 - y2), + ) + self._update_framing_selection() # Update selection live + self.queue_draw() + + def _calculate_snap_offset( + self, target_pos: float, size: float, grid_size: float + ) -> float: + """ + Calculates the snap adjustment for one axis, considering both edges. + It returns the smallest offset needed to align either the start or + the end of the object with the grid. + """ + if grid_size <= 0: + return 0.0 + + # Target positions of the two edges + target_start = target_pos + target_end = target_pos + size + + # The closest grid line for each edge + snap_start = round(target_start / grid_size) * grid_size + snap_end = round(target_end / grid_size) * grid_size + + # The adjustment needed to snap each edge + delta_start = snap_start - target_start + delta_end = snap_end - target_end + + # Return the adjustment with the smallest absolute magnitude + if abs(delta_start) < abs(delta_end): + return delta_start + else: + return delta_end + + def on_mouse_drag(self, gesture, offset_x: float, offset_y: float): + """ + Handles an active drag, dispatching to transform-specific methods. + It now includes a threshold to distinguish between a click and a + true drag. + """ + # Edit mode logic + if self.edit_context: + logger.debug( + f"on_mouse_drag: edit_context exists, " + f"offset_x={offset_x}, offset_y={offset_y}" + ) + ok, start_x, start_y = self._drag_gesture.get_start_point() + if not ok: + return + current_x, current_y = start_x + offset_x, start_y + offset_y + start_world_x, start_world_y = self._get_world_coords( + start_x, start_y + ) + current_world_x, current_world_y = self._get_world_coords( + current_x, current_y + ) + world_dx = current_world_x - start_world_x + world_dy = current_world_y - start_world_y + if not self._edit_dragging: + self._edit_dragging = True + self.edit_drag_begin.send(self) + logger.debug( + f"on_mouse_drag: calling handle_edit_drag with " + f"dx={world_dx}, dy={world_dy}" + ) + self.edit_context.handle_edit_drag(world_dx, world_dy) + self.queue_draw() + return # Stop further processing + + if self._framing_selection: + self._update_framing_selection_from_drag(offset_x, offset_y) + return + + is_transforming = ( + self._moving or self._resizing or self._rotating or self._shearing + ) + + # If a transform hasn't started yet, check if we've passed the drag + # threshold to prevent tiny movements from hiding handles on a click. + if not is_transforming: + dist_sq = offset_x**2 + offset_y**2 + if dist_sq < (DRAG_THRESHOLD**2): + return # Not a real drag yet, ignore. + + # Now that the drag is confirmed, set the state and fire signals. + self._was_dragging = True + + # The elements to transform are either the current selection, or + # the single drag target if there's no selection. + elements_to_transform = self.get_selected_elements() + if not elements_to_transform and self._drag_target: + elements_to_transform = [self._drag_target] + + if not elements_to_transform: + return + + if self._active_region in ( + ElementRegion.BODY, + ElementRegion.MOVE, + ): + self._moving = True + self.move_begin.send( + self, + elements=elements_to_transform, + drag_target=self._drag_target, + ) + elif self._active_region in ROTATE_HANDLES: + self._rotating = True + self.rotate_begin.send(self, elements=elements_to_transform) + elif self._active_region in SHEAR_HANDLES: + self._shearing = True + self.shear_begin.send(self, elements=elements_to_transform) + elif self._active_region != ElementRegion.NONE: + self._resizing = True + self.resize_begin.send(self, elements=elements_to_transform) + + # Set a generic "interactive" flag on the elements being + # transformed. This allows complex parents (like ShrinkWrapGroup) + # to react appropriately without the Canvas needing to know + # about them. + self._transforming_elements = elements_to_transform + for elem in self._transforming_elements: + elem.begin_interactive_transform() + + # If we reach here, the drag is confirmed and active. + # Calculate drag delta in WORLD coordinates + ok, start_x, start_y = self._drag_gesture.get_start_point() + if not ok: + return + current_x, current_y = start_x + offset_x, start_y + offset_y + start_world_x, start_world_y = self._get_world_coords(start_x, start_y) + current_world_x, current_world_y = self._get_world_coords( + current_x, current_y + ) + world_dx = current_world_x - start_world_x + world_dy = current_world_y - start_world_y + + if self._ctrl_pressed: + if self._moving: + if self._selection_group and self._active_origin: + # Snap group move to grid using its AABB + initial_x, initial_y, w, h = self._active_origin + target_x = initial_x + world_dx + target_y = initial_y + world_dy + + snap_offset_x = self._calculate_snap_offset( + target_x, w, self.grid_size + ) + snap_offset_y = self._calculate_snap_offset( + target_y, h, self.grid_size + ) + + world_dx += snap_offset_x + world_dy += snap_offset_y + + elif self._drag_target and self._initial_world_transform: + # Snap single element move using its world AABB + elem = self._drag_target + target_transform = ( + Matrix.translation(world_dx, world_dy) + @ self._initial_world_transform + ) + w, h = elem.width, elem.height + local_corners = [(0, 0), (w, 0), (w, h), (0, h)] + world_corners = [ + target_transform.transform_point(p) + for p in local_corners + ] + + x_coords = [c[0] for c in world_corners] + y_coords = [c[1] for c in world_corners] + min_x, max_x = min(x_coords), max(x_coords) + min_y, max_y = min(y_coords), max(y_coords) + + snap_offset_x = self._calculate_snap_offset( + min_x, max_x - min_x, self.grid_size + ) + snap_offset_y = self._calculate_snap_offset( + min_y, max_y - min_y, self.grid_size + ) + + world_dx += snap_offset_x + world_dy += snap_offset_y + + elif self._rotating and self._rotation_pivot: + # Snap rotation to configured degree increments + initial_angle_deg = 0.0 + if self._initial_world_transform: + initial_angle_deg = ( + self._initial_world_transform.get_rotation() + ) + + pivot_x, pivot_y = self._rotation_pivot + current_mouse_angle_deg = math.degrees( + math.atan2( + current_world_y - pivot_y, current_world_x - pivot_x + ) + ) + + angle_delta_deg = ( + current_mouse_angle_deg - self._drag_start_angle + ) + angle_delta_deg = (angle_delta_deg + 180) % 360 - 180 + target_angle_deg = initial_angle_deg + angle_delta_deg + + snapped_angle_deg = ( + round(target_angle_deg / self.SNAP_ANGLE_DEGREES) + * self.SNAP_ANGLE_DEGREES + ) + snapped_delta_deg = snapped_angle_deg - initial_angle_deg + snapped_mouse_angle_deg = ( + self._drag_start_angle + snapped_delta_deg + ) + + dist = math.hypot( + current_world_x - pivot_x, current_world_y - pivot_y + ) + snapped_mouse_angle_rad = math.radians(snapped_mouse_angle_deg) + current_world_x = pivot_x + dist * math.cos( + snapped_mouse_angle_rad + ) + current_world_y = pivot_y + dist * math.sin( + snapped_mouse_angle_rad + ) + + # Dispatch to transform handlers (copied from base class) + if self._selection_group: + if self._moving: + self._selection_group.apply_move(world_dx, world_dy) + self.transform_moved.send( + self, elements=self._selection_group.elements + ) + elif self._resizing: + if self._active_origin: + self._selection_group.resize_from_drag( + self._active_region, + world_dx, + world_dy, + self._active_origin, + self._ctrl_pressed, + self._shift_pressed, + ) + for elem in self._selection_group.elements: + elem.trigger_update() + elif self._rotating: + if self._rotation_pivot: + self._selection_group.rotate_from_drag( + current_world_x, + current_world_y, + self._rotation_pivot, + self._drag_start_angle, + ) + elif self._shearing: + if self._active_origin: + self._selection_group.shear_from_drag( + self._active_region, + world_dx, + world_dy, + self._active_origin, + ) + self.queue_draw() + elif self._drag_target: + if self._moving: + if self._drag_target and self._initial_world_transform: + if ( + self._drag_target.draggable + and self._drag_target.drag_handler_controls_transform + ): + # This element handles its own transform update. + self._drag_target.handle_drag_move(world_dx, world_dy) + # No need for queue_draw here, handler should do it. + elif self._drag_target.draggable: + # This element returns a constrained delta. + ( + constrained_dx, + constrained_dy, + ) = self._drag_target.handle_drag_move( + world_dx, world_dy + ) + transform.move_element( + self._drag_target, + constrained_dx, + constrained_dy, + self._initial_world_transform, + ) + self.transform_moved.send( + self, elements=[self._drag_target] + ) + self.queue_draw() + else: + # Standard, unconstrained move. + transform.move_element( + self._drag_target, + world_dx, + world_dy, + self._initial_world_transform, + ) + self.transform_moved.send( + self, elements=[self._drag_target] + ) + self.queue_draw() + elif self._resizing: + if ( + self._drag_target + and self._initial_transform + and self._initial_world_transform + ): + transform.resize_element( + element=self._drag_target, + world_dx=world_dx, + world_dy=world_dy, + initial_local_transform=self._initial_transform, + initial_world_transform=self._initial_world_transform, + active_region=self._active_region, + view_transform=self.view_transform, + shift_pressed=self._shift_pressed, + ctrl_pressed=self._ctrl_pressed, + ) + self._drag_target.trigger_update() + self.queue_draw() + elif self._rotating: + if ( + self._drag_target + and self._initial_world_transform + and self._rotation_pivot + ): + transform.rotate_element( + element=self._drag_target, + world_x=current_world_x, + world_y=current_world_y, + initial_world_transform=self._initial_world_transform, + rotation_pivot=self._rotation_pivot, + drag_start_angle=self._drag_start_angle, + ) + self.queue_draw() + elif self._shearing and ( + self._drag_target + and self._initial_transform + and self._initial_world_transform + ): + transform.shear_element( + element=self._drag_target, + world_dx=world_dx, + world_dy=world_dy, + initial_local_transform=self._initial_transform, + initial_world_transform=self._initial_world_transform, + active_region=self._active_region, + view_transform=self.view_transform, + ) + self.queue_draw() + + def _start_rotation( + self, + target: CanvasElement | MultiSelectionGroup, + x: float, + y: float, + ): + """ + Stores the initial state for a rotation operation. + The x and y coordinates are in WORLD space. + """ + is_group = isinstance(target, MultiSelectionGroup) + center_x, center_y = ( + target.center if is_group else target.get_world_center() + ) + + # The pivot is always the center of the selection. + self._rotation_pivot = (center_x, center_y) + + self._drag_start_angle = math.degrees( + math.atan2( + y - self._rotation_pivot[1], x - self._rotation_pivot[0] + ) + ) + + def on_drag_end(self, gesture, offset_x: float, offset_y: float): + """ + Handles the end of a drag operation, finalizing transforms. + """ + if self.edit_context: + was_dragging = self._edit_dragging + ok, start_x, start_y = self._drag_gesture.get_start_point() + if ok: + world_x, world_y = self._get_world_coords( + start_x + offset_x, start_y + offset_y + ) + self.edit_context.handle_edit_release(world_x, world_y) + if was_dragging: + self._edit_dragging = False + self.edit_drag_end.send(self) + self.queue_draw() + return + + if self._framing_selection: + self._selection_frame_rect = None + self._selection_before_framing.clear() + self._finalize_selection_state() + return + + is_transforming = ( + self._moving or self._resizing or self._rotating or self._shearing + ) + if not is_transforming: + # Clear the drag target if a drag didn't start. + self._drag_target = None + return + + elements = self._transforming_elements + if not elements: + return + + # Fire specific signals for detailed event handling + if self._moving: + self.move_end.send( + self, elements=elements, drag_target=self._drag_target + ) + elif self._resizing: + self.resize_end.send(self, elements=elements) + elif self._rotating: + self.rotate_end.send(self, elements=elements) + elif self._shearing: + self.shear_end.send(self, elements=elements) + + # Fire the single, generic signal for model synchronization. + self.transform_end.send(self, elements=elements) + + # Recalculate group bounding box if it was being transformed + if self._selection_group: + self._selection_group._calculate_bounding_box() + self._active_origin = self._selection_group._bounding_box + + # Notify elements that the interaction is over. This allows parent + # groups to perform a final state consolidation. + for elem in self._transforming_elements: + elem.end_interactive_transform() + self._transforming_elements.clear() + + # Reset all interaction state variables + self._resizing, self._moving, self._rotating, self._shearing = ( + False, + False, + False, + False, + ) + self._active_region = ElementRegion.NONE + self._initial_transform = None + self._initial_world_transform = None + self._rotation_pivot = None + self._drag_target = None + + self.queue_draw() + + def on_click_released(self, gesture, n_press: int, x: float, y: float): + """ + Handles the completion of a click that did not become a drag. + This is where the selection mode is toggled. + """ + if self.edit_context: + return # Clicks are fully handled by on_button_press in edit mode + + if self._framing_selection: + self._framing_selection = False + self._selection_frame_rect = None + self._selection_before_framing.clear() + # A framing operation (even a zero-pixel one) should finalize + # the selection but never toggle the mode. + self._finalize_selection_state() + self._selection_just_changed = False + return + + if self._was_dragging: + self._was_dragging = False + self._selection_just_changed = False + self._drag_target = None + return + + # Check for mode switch on simple click + world_x, world_y = self._get_world_coords(x, y) + hit = self.root.get_elem_hit(world_x, world_y, selectable=True) + hover_region = ElementRegion.NONE + if hit and hit.selected: + # Re-check region hit to be sure, testing against ALL handles + # to allow mode switching from resize handles to rotate handles, + # etc. + target = self._selection_group if self._selection_group else hit + hover_region = target.check_region_hit(world_x, world_y) + + # ONLY toggle mode if selection did NOT just change in this click + if hit and hit.selected and not self._selection_just_changed: + new_mode = self._selection_mode + if ( + self._selection_mode != SelectionMode.RESIZE + and hover_region in BBOX_REGIONS + ): + new_mode = SelectionMode.RESIZE + elif ( + self._selection_mode != SelectionMode.ROTATE_SHEAR + and hover_region == ElementRegion.BODY + ): + new_mode = SelectionMode.ROTATE_SHEAR + + if new_mode != self._selection_mode: + self._selection_mode = new_mode + self.queue_draw() + + # Reset transient states after the full click action is complete + self._selection_just_changed = False + self._drag_target = None + + def enter_edit_mode(self, element: CanvasElement): + """Enters edit mode, focusing on a specific element.""" + if not element.is_editable or self.edit_context is element: + return + if self.edit_context: + self.leave_edit_mode() + + logger.debug(f"Entering edit mode for {element}") + self.unselect_all() # Clear any existing selections + self.edit_context = element + element.selected = True # Select the context element for visual cues + self._sync_selection_state() + element.on_edit_mode_enter() + self.set_cursor(Gdk.Cursor.new_from_name("default")) + self.queue_draw() + + def leave_edit_mode(self): + """Exits the current edit mode.""" + if not self.edit_context: + return + logger.debug(f"Leaving edit mode for {self.edit_context}") + self.edit_context.on_edit_mode_leave() + self.edit_context = None + self.unselect_all() + self.set_cursor(Gdk.Cursor.new_from_name("default")) + self.queue_draw() + + def _sync_selection_state(self): + """ + Synchronizes the internal selection state with the current `selected` + flags on elements. + """ + selected = self.get_selected_elements() + + # Update the active element, which is the last one selected. + if self._active_elem and self._active_elem not in selected: + self._active_elem = None + if not self._active_elem and selected: + self._active_elem = selected[-1] + + if len(selected) > 1: + if not self._selection_group or set( + self._selection_group.elements + ) != set(selected): + self._selection_group = MultiSelectionGroup(selected, self) + else: + self._selection_group = None + + # The active_element is the single, primary item in the selection. + self.active_element_changed.send(self, element=self._active_elem) + # The selection_changed signal reports the entire group. + self.selection_changed.send( + self, elements=selected, active_element=self._active_elem + ) + self.queue_draw() + + def _finalize_selection_state(self): + """ + Fully updates the selection state after a user interaction, including + resetting the interaction mode to its default. + """ + self._sync_selection_state() + + selected = self.get_selected_elements() + self._selection_mode = SelectionMode.NONE + + if len(selected) > 0: + self._selection_mode = SelectionMode.RESIZE + + def _update_framing_selection(self): + """ + Updates element selection based on the rubber-band frame. + """ + if not self._selection_frame_rect: + return + + frame_x, frame_y, frame_w, frame_h = self._selection_frame_rect + world_tl = self._get_world_coords(frame_x, frame_y) + world_br = self._get_world_coords(frame_x + frame_w, frame_y + frame_h) + world_frame_x = min(world_tl[0], world_br[0]) + world_frame_y = min(world_tl[1], world_br[1]) + world_frame_w = abs(world_br[0] - world_tl[0]) + world_frame_h = abs(world_br[1] - world_tl[1]) + + # Avoid selection changes from a simple click (zero-area frame). + if world_frame_w < 2 and world_frame_h < 2: + return + + selection_rect = Graphene.Rect().init( + world_frame_x, world_frame_y, world_frame_w, world_frame_h + ) + selection_changed = False + + for elem in self.root.get_all_children_recursive(): + if elem.selectable: + x, y, w, h = elem.get_world_bounding_box() + elem_corners = [ + (x, y), + (x + w, y), + (x + w, y + h), + (x, y + h), + ] + intersects = obb_intersects_aabb(elem_corners, selection_rect) + + # Select if it intersects or was part of the initial set + # in shift-mode. + newly_selected = ( + elem in self._selection_before_framing + ) or intersects + if elem.selected != newly_selected: + elem.selected = newly_selected + selection_changed = True + + if selection_changed: + self._finalize_selection_state() + + def on_key_pressed( + self, controller, keyval: int, keycode: int, state: Gdk.ModifierType + ) -> bool: + """Handles key press events for modifiers and actions.""" + if keyval == Gdk.KEY_Escape and self.edit_context: + self.leave_edit_mode() + return True + if keyval in (Gdk.KEY_Shift_L, Gdk.KEY_Shift_R): + self._shift_pressed = True + # Allow propagation for accelerators + elif is_primary_keyval(keyval): + self._ctrl_pressed = True + # Allow propagation for accelerators + elif keyval in (Gdk.KEY_Delete, Gdk.KEY_BackSpace): + if self.edit_context and self.edit_context.handle_edit_key(keyval): + return True + selected_elements = list(self.root.get_selected()) + if selected_elements: + self.elements_deleted.send(self, elements=selected_elements) + self.root.remove_selected() + self._finalize_selection_state() + return True + return False + + def on_key_released( + self, controller, keyval: int, keycode: int, state: Gdk.ModifierType + ): + """Handles key release events for modifiers.""" + if keyval in (Gdk.KEY_Shift_L, Gdk.KEY_Shift_R): + self._shift_pressed = False + elif is_primary_keyval(keyval): + self._ctrl_pressed = False + + def get_active_element(self) -> CanvasElement | None: + """ + Returns the element that is the primary focus of the current + selection. This element receives keyboard events and determines + the context for property panels. + """ + return self._active_elem + + def get_selected_elements(self) -> list[CanvasElement]: + """Returns a list of all currently selected elements.""" + return list(self.root.get_selected()) + + def unselect_all(self): + """Deselects all elements on the canvas.""" + # Do nothing if there's no selection to clear, to avoid + # unnecessary state changes and signal emissions. + if not self.get_selected_elements(): + return + + self.root.unselect_all() + self._finalize_selection_state() + + def dump(self): + """Prints a representation of the entire element hierarchy.""" + self.root.dump() diff --git a/rayforge/ui_gtk/canvas/cursor.py b/rayforge/ui_gtk/canvas/cursor.py new file mode 100644 index 000000000..02790f87b --- /dev/null +++ b/rayforge/ui_gtk/canvas/cursor.py @@ -0,0 +1,302 @@ +import logging +import math + +import cairo +from gi.repository import Gdk, GLib + +from ...core.color import ColorRGBA +from ..icons import get_icon_pixbuf +from .region import ROTATE_HANDLES, ElementRegion + +logger = logging.getLogger(__name__) + +_cursor_cache: dict[int, Gdk.Cursor] = {} +_arc_cursor_cache: dict[int, Gdk.Cursor] = {} +_tool_cursor_cache: dict[tuple[str, ColorRGBA], Gdk.Cursor] = {} + +# This map defines the base angle for each resize handle, using a standard +# counter-clockwise (CCW) convention where 0 degrees is to the right. +_region_angles = { + ElementRegion.MIDDLE_RIGHT: 0, + ElementRegion.TOP_RIGHT: 45, + ElementRegion.TOP_MIDDLE: 90, + ElementRegion.TOP_LEFT: 135, + ElementRegion.MIDDLE_LEFT: 180, + ElementRegion.BOTTOM_LEFT: 225, + ElementRegion.BOTTOM_MIDDLE: 270, + ElementRegion.BOTTOM_RIGHT: 315, + # Rotation handles are arcs with arrows. + ElementRegion.ROTATE_TOP_RIGHT: 315, + ElementRegion.ROTATE_TOP_LEFT: 45, + ElementRegion.ROTATE_BOTTOM_LEFT: 135, + ElementRegion.ROTATE_BOTTOM_RIGHT: 225, + # Shear handles are bidirectional arrows. + ElementRegion.SHEAR_TOP: 0, + ElementRegion.SHEAR_BOTTOM: 0, + ElementRegion.SHEAR_LEFT: 90, + ElementRegion.SHEAR_RIGHT: 90, +} + + +def get_tool_cursor( + icon_name: str, + color: ColorRGBA | None = None, + fallback_cursor_name: str = "crosshair", +) -> Gdk.Cursor | None: + """ + Creates or retrieves from cache a custom cursor with a tool icon. + The cursor consists of a crosshair with the specified icon at the + bottom-right. + + Args: + icon_name: The symbolic name of the icon to use. + color: Optional RGBA color tuple for the crosshair. If None, + defaults to white. + fallback_cursor_name: The name of the GDK cursor to use if the + icon cannot be loaded. + + Returns: + A Gdk.Cursor object, or None if the fallback cursor fails. + """ + default_color: ColorRGBA = (1.0, 1.0, 1.0, 1.0) + rgba = color if color else default_color + cache_key = (icon_name, rgba) + if cache_key in _tool_cursor_cache: + return _tool_cursor_cache[cache_key] + + size = 32 + hotspot = 16 # Center of the crosshair + + try: + # Load the icon from the current theme using the modern GTK4 API + pixbuf = get_icon_pixbuf(icon_name, size / 2) + except GLib.Error: + # If icon not found, return a standard GDK cursor + logger.error(f"failed loading icon for cursor {icon_name}") + pixbuf = None + + if not pixbuf: + return Gdk.Cursor.new_from_name(fallback_cursor_name) + + # 1. Draw the cursor shape using Cairo + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + ctx = cairo.Context(surface) + + # Draw crosshair with outline for visibility + ctx.set_source_rgb(0, 0, 0) # Black outline + ctx.set_line_width(3) + ctx.move_to(hotspot, 4) + ctx.line_to(hotspot, size - 4) + ctx.move_to(4, hotspot) + ctx.line_to(size - 4, hotspot) + ctx.stroke() + + ctx.set_source_rgba(*rgba) # Foreground color inner + ctx.set_line_width(1) + ctx.move_to(hotspot, 4) + ctx.line_to(hotspot, size - 4) + ctx.move_to(4, hotspot) + ctx.line_to(size - 4, hotspot) + ctx.stroke() + + # Draw the icon onto the surface, tinted with the foreground color + icon_x = hotspot + 2 + icon_y = hotspot + 2 + Gdk.cairo_set_source_pixbuf(ctx, pixbuf, icon_x, icon_y) + ctx.paint() + + # Tint the icon area by multiplying with the foreground color + ctx.set_operator(cairo.Operator.ATOP) + ctx.set_source_rgba(*rgba) + ctx.rectangle(icon_x, icon_y, pixbuf.get_width(), pixbuf.get_height()) + ctx.fill() + ctx.set_operator(cairo.Operator.OVER) + + # 2. Convert Cairo surface to Gdk.Texture + data = surface.get_data() + bytes_data = GLib.Bytes.new(data) + texture = Gdk.MemoryTexture.new( + size, + size, + Gdk.MemoryFormat.B8G8R8A8_PREMULTIPLIED, + bytes_data, + surface.get_stride(), + ) + + # 3. Create Gdk.Cursor from the texture and cache it + cursor = Gdk.Cursor.new_from_texture(texture, hotspot, hotspot) + _tool_cursor_cache[cache_key] = cursor + return cursor + + +def get_rotated_cursor(angle_deg: float) -> Gdk.Cursor: + """ + Creates or retrieves from cache a custom two-headed arrow cursor + rotated to the given angle. + + Args: + angle_deg: The desired mathematical rotation (CCW) of the cursor. + + Returns: + A Gdk.Cursor object. + """ + # Round angle to nearest degree for effective caching + angle_key = round(angle_deg) + if angle_key in _cursor_cache: + return _cursor_cache[angle_key] + + size = 32 + hotspot = size // 2 + + # 1. Draw the cursor shape using Cairo + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + ctx = cairo.Context(surface) + ctx.translate(hotspot, hotspot) + ctx.rotate(math.radians(angle_deg)) + + # Draw a white arrow with a black outline for visibility + ctx.set_line_width(2) + ctx.set_source_rgb(0, 0, 0) # Black outline + + # Main line + ctx.move_to(-10, 0) + ctx.line_to(10, 0) + + # Arrowhead 1 + ctx.move_to(10, 0) + ctx.line_to(6, -4) + ctx.move_to(10, 0) + ctx.line_to(6, 4) + + # Arrowhead 2 + ctx.move_to(-10, 0) + ctx.line_to(-6, -4) + ctx.move_to(-10, 0) + ctx.line_to(-6, 4) + ctx.stroke_preserve() # Keep path for white fill + + # White inner fill + ctx.set_source_rgb(1, 1, 1) + ctx.set_line_width(1) + ctx.stroke() + + # 2. Convert Cairo surface to Gdk.Texture (GTK4 method) + data = surface.get_data() + bytes_data = GLib.Bytes.new(data) + texture = Gdk.MemoryTexture.new( + size, + size, + Gdk.MemoryFormat.B8G8R8A8_PREMULTIPLIED, + bytes_data, + surface.get_stride(), + ) + + # 3. Create Gdk.Cursor from the texture and cache it + cursor = Gdk.Cursor.new_from_texture(texture, hotspot, hotspot) + _cursor_cache[angle_key] = cursor + return cursor + + +def get_rotated_arc_cursor(angle_deg: float) -> Gdk.Cursor: + """ + Creates or retrieves from cache a custom rotation cursor (arc with arrows) + rotated to the given angle. + + Args: + angle_deg: The desired mathematical rotation (CCW) of the cursor. + + Returns: + A Gdk.Cursor object. + """ + angle_key = round(angle_deg) + if angle_key in _arc_cursor_cache: + return _arc_cursor_cache[angle_key] + + size = 33 + hotspot = size // 2 + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + ctx = cairo.Context(surface) + ctx.translate(hotspot, hotspot) + # Negate the mathematical angle to get the correct visual rotation (CW) + ctx.rotate(math.radians(angle_deg)) + + ctx.set_line_width(2) + ctx.set_source_rgb(0, 0, 0) + + radius = 14 + start_angle = math.radians(225) + end_angle = math.radians(315) + + # Draw the main arc path + ctx.arc(0, 0, radius, start_angle, end_angle) + + def draw_arrowhead(point_angle: float, is_start_arrow: bool = False): + """Draws a symmetric arrowhead at a given angle on the circle.""" + arrow_length = 5 + arrow_width = 5 + + ctx.save() + + px = radius * math.cos(point_angle) + py = radius * math.sin(point_angle) + ctx.translate(px, py) + + # Rotate to match the arc's tangent (clockwise direction) + tangent_angle = point_angle + math.pi / 2.0 + ctx.rotate(tangent_angle) + + if is_start_arrow: + ctx.rotate(math.pi) + + # Draw a standard V-shape arrowhead pointing away from the tip. + ctx.move_to(0, 0) + ctx.line_to(-arrow_length, -arrow_width) + ctx.move_to(0, 0) + ctx.line_to(-arrow_length, arrow_width) + + ctx.restore() + + # Draw arrowheads at the start and end of the arc + draw_arrowhead(start_angle, is_start_arrow=True) + draw_arrowhead(end_angle, is_start_arrow=False) + + ctx.stroke_preserve() + + # White inner fill + ctx.set_source_rgb(1, 1, 1) + ctx.set_line_width(1) + ctx.stroke() + + # Convert Cairo surface to Gdk.Texture + data = surface.get_data() + bytes_data = GLib.Bytes.new(data) + texture = Gdk.MemoryTexture.new( + size, + size, + Gdk.MemoryFormat.B8G8R8A8_PREMULTIPLIED, + bytes_data, + surface.get_stride(), + ) + + cursor = Gdk.Cursor.new_from_texture(texture, hotspot, hotspot) + _arc_cursor_cache[angle_key] = cursor + return cursor + + +def get_cursor_for_region( + region: ElementRegion, angle: float, absolute: bool = False +) -> Gdk.Cursor | None: + base_angle = _region_angles.get(region, 0) if not absolute else 0 + if region is None or region == ElementRegion.NONE: + return Gdk.Cursor.new_from_name("default") + elif region in (ElementRegion.BODY, ElementRegion.MOVE): + return Gdk.Cursor.new_from_name("move") + elif region in ROTATE_HANDLES: + cursor_angle = -base_angle + angle + return get_rotated_arc_cursor(cursor_angle) + else: # must be a resize or shear region + # The final visual angle of the cursor is the handle's base angle + # plus the element's total world rotation angle. + cursor_angle = -base_angle + angle + return get_rotated_cursor(cursor_angle) diff --git a/rayforge/ui_gtk/canvas/element.py b/rayforge/ui_gtk/canvas/element.py new file mode 100644 index 000000000..4d13ad387 --- /dev/null +++ b/rayforge/ui_gtk/canvas/element.py @@ -0,0 +1,1203 @@ +from __future__ import annotations + +import logging +import os +from collections.abc import Generator +from concurrent.futures import Future, ThreadPoolExecutor +from copy import deepcopy +from typing import ( + TYPE_CHECKING, + Any, +) + +import cairo +import numpy as np +from gi.repository import GLib +from raygeo.geo import Matrix + +from .hittest import check_pixel_hit +from .region import ElementRegion, check_region_hit, get_region_rect + +if TYPE_CHECKING: + from raygeo.geo.types import Point, Rect + + from .canvas import Canvas + + +logger = logging.getLogger(__name__) +# Reserve 2 threads for UI responsiveness +max_workers = max(1, (os.cpu_count() or 1) - 2) +# Define a maximum dimension for our rendering buffers to prevent cairo errors. +MAX_BUFFER_DIM = 8192 + + +class CanvasElement: + """ + The base class for all objects rendered on a Canvas. + + This class provides a hierarchical structure (parent-child), + matrix-based transformations (translation, rotation, scale), + asynchronous off-thread rendering for performance ("buffering"), + and basic UI interaction logic like hit-testing. + """ + + # A shared thread pool for all element background updates. + _executor = ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix="CanvasElementWorker" + ) + + def __init__( + self, + x: float, + y: float, + width: float, + height: float, + selected: bool = False, + selectable: bool = True, + visible: bool = True, + background: tuple[float, float, float, float] = (0, 0, 0, 0), + canvas: Canvas | None = None, + parent: Canvas | CanvasElement | None = None, + data: Any = None, + clip: bool = True, + buffered: bool = False, + debounce_ms: int = 50, + angle: float = 0.0, + pixel_perfect_hit: bool = False, + matrix: Matrix | None = None, + hit_distance: float = 0.0, + is_editable: bool = False, + draggable: bool = False, + show_selection_frame: bool = True, + drag_handler_controls_transform: bool = False, + preserves_selection_on_click: bool = False, + ): + """ + Initializes a new CanvasElement. + + Args: + x: The x-coordinate relative to the parent. + y: The y-coordinate relative to the parent. + width: The width of the element. + height: The height of the element. + selected: The initial selection state. + selectable: If the element can be selected by the user. + visible: If the element is drawn. + background: The background color (r, g, b, a). + canvas: The root Canvas this element belongs to. + parent: The parent element in the hierarchy. + data: Arbitrary user data associated with the element. + clip: If True, drawing is clipped to the element's + bounding box. + buffered: If True, the element is rendered to an + off-screen surface in a background thread. This is + ideal for complex, static elements. If False, the + element is drawn directly on every frame. + debounce_ms: The delay in milliseconds before a + background render is triggered after a change. + angle: The local rotation angle in degrees. + pixel_perfect_hit: If True (and buffered=True), + hit-testing will check the transparency of the + pixel on the element's rendered surface. + matrix: An optional transformation matrix. If provided, + it overrides x, y, angle, and scale properties on + initialization. + hit_distance: For pixel-perfect hit checks, this adds a + "fuzzy" radius around the mouse pointer. The distance is + specified in **screen pixels** and is applied to the + element's rendered surface. A non-zero value will check a + circular area for any opaque pixel. + is_editable: If True, the element can be double-clicked + to enter a special "edit mode". + draggable: If True, the element can be moved by dragging its + body, and its drag behavior can be customized by + overriding `handle_drag_move`. + show_selection_frame: If False, the selection frame and + handles will not be drawn for this element even when it is + selected. Useful for custom interactive handles. + drag_handler_controls_transform: If True, the `handle_drag_move` + method is responsible for updating the element's transform + itself. If False (default), it should return a constrained + delta for the canvas to apply. + preserves_selection_on_click: If True, clicking this element + will not change the existing selection on the canvas. It will + only make this element the target for a potential drag. + """ + logger.debug( + f"CanvasElement.__init__: x={x}, y={y}, width={width}, " + f"height={height}" + ) + + # Primitive properties are used for initialization and by methods + # like set_size that need to rebuild the transform. They are NOT + # kept in sync with the matrix. + self.x: float = float(x) + self.y: float = float(y) + self.width: float = float(width) + self.height: float = float(height) + self.scale_x: float = 1.0 + self.scale_y: float = 1.0 + self.angle: float = angle + + self.selected: bool = selected + self.selectable: bool = selectable + self.visible: bool = visible + self.surface: cairo.ImageSurface | None = None + self.canvas: Canvas | None = canvas + self.parent: Canvas | CanvasElement | None = parent + self.children: list[CanvasElement] = [] + self.background: tuple[float, float, float, float] = background + self.data: Any = data + self.dirty: bool = True + self.clip: bool = clip + self.buffered: bool = buffered + self.debounce_ms: int = debounce_ms + self._debounce_timer_id: int | None = None + self._update_future: Future | None = None + self._update_generation: int = 0 + self.pixel_perfect_hit = pixel_perfect_hit + self.hit_distance: float = hit_distance + self.is_editable: bool = is_editable + self.draggable: bool = draggable + self.show_selection_frame: bool = show_selection_frame + self.drag_handler_controls_transform = drag_handler_controls_transform + self.preserves_selection_on_click = preserves_selection_on_click + + # This is the single source of truth for the local GEOMETRIC transform. + self.transform: Matrix = Matrix.identity() + # This new matrix handles content orientation relative to the geometry. + self.content_transform: Matrix = Matrix.identity() + # Cached matrix for the full transform to world space. + self._world_transform: Matrix = Matrix.identity() + self._transform_dirty: bool = True + + # UI interaction state + self.hovered: bool = False + self._is_under_interactive_transform: bool = False + + if matrix is not None: + self.set_transform(matrix) + else: + # Initial synchronization from primitive properties on creation + self._rebuild_transform() + + @property + def is_hovered(self) -> bool: + """Returns True if the mouse is currently hovering over the element.""" + return self.hovered + + def _rebuild_transform(self): + """ + Builds the unified local transform from primitive properties. + + This method should only be used for initialization or by setters + that are intended to reset an element's shear (like `set_angle` + or `set_scale`). All interactive transformations should modify + the matrix directly via `set_transform`. + """ + center_x, center_y = self.width / 2, self.height / 2 + t_to_origin = Matrix.translation(-center_x, -center_y) + m_scale = Matrix.scale(self.scale_x, self.scale_y) + m_rotate = Matrix.rotation(self.angle) + t_back_from_origin = Matrix.translation(center_x, center_y) + m_trans = Matrix.translation(self.x, self.y) + + # Build the rotation/scale part + m_trs = t_back_from_origin @ m_rotate @ m_scale @ t_to_origin + # Combine with translation to form the final local transform + self.transform = m_trans @ m_trs + + self.mark_dirty(ancestors=False, recursive=True) + + def _set_transform_silent(self, matrix: Matrix): + """ + Internal method to set the transform without notifying the parent. + This is used to break recursion in parent-child update cycles. + """ + self.transform = matrix + self.mark_dirty(ancestors=True, recursive=True) + + if self.canvas: + self.canvas.queue_draw() + + def begin_interactive_transform(self): + """ + Notifies the element that it is being directly manipulated by the user. + This is a hint for complex parent elements like ShrinkWrapGroup. + """ + self._is_under_interactive_transform = True + + def end_interactive_transform(self): + """ + Notifies the element that direct user manipulation has ended. + This triggers a final update notification to the parent to allow it + to consolidate the element's final state. + """ + self._is_under_interactive_transform = False + if isinstance(self.parent, CanvasElement): + self.parent.on_child_transform_changed(self) + + def set_transform(self, matrix: Matrix): + """ + Sets the element's complete local transform matrix directly and + notifies the parent of the change. + """ + self._set_transform_silent(matrix) + # Notify parent of the change, allowing them to react. + if isinstance(self.parent, CanvasElement): + self.parent.on_child_transform_changed(self) + + def on_child_transform_changed(self, child: CanvasElement): + """ + Callback triggered by a child when its transform has changed. + Subclasses can override this to react, e.g., by updating bounds. + The base implementation does nothing. + """ + + def on_child_list_changed(self): + """ + Hook called when the list of children is modified (add/remove). + Subclasses can override this to react. + """ + + def on_attached(self): + """ + Lifecycle hook called when the element is added to a canvas. + `self.canvas` is guaranteed to be available. Subclasses can + override this to connect signals or initialize resources. + """ + + def on_detached(self): + """ + Lifecycle hook called before the element is removed from a canvas. + Subclasses can override this to disconnect signals or clean up. + """ + + def draw_overlay(self, ctx: cairo.Context): + """ + Draws a custom overlay in world coordinates (pixel space). + The cairo context's coordinate system is not transformed. + Subclasses can override this to draw previews or guides. + + Args: + ctx: The cairo context in world/pixel space. + """ + + def draw_edit_overlay(self, ctx: cairo.Context): + """ + Draws a custom overlay for editing, only called when the element is + the canvas's `edit_context`. The context is in screen space. + + Args: + ctx: The cairo context in screen/pixel space. + """ + + def get_world_transform(self) -> Matrix: + """ + Calculates the full world transformation matrix. + + This matrix maps a point from this element's local coordinate + space to the final canvas (world) coordinate space. It caches + the result and only recalculates when the transform is "dirty". + """ + if not self._transform_dirty: + return self._world_transform + + if isinstance(self.parent, CanvasElement): + parent_world = self.parent.get_world_transform() + self._world_transform = parent_world @ self.transform + else: + # For the root element, the world transform is its own transform. + self._world_transform = self.transform + + self._transform_dirty = False + return self._world_transform + + def get_world_bounding_box(self) -> Rect: + """ + Calculates the element's axis-aligned bounding box in world + coordinates. + """ + # The rectangle in an element's local coordinates is defined by its + # width and height, with its origin at (0, 0). + local_rect = (0, 0, self.width, self.height) + + # Get the matrix that transforms from local space to world space + world_transform = self.get_world_transform() + + # Transform the local rectangle to get its world-space bounding box + return world_transform.transform_rectangle(local_rect) + + def trigger_update(self): + """ + Schedules a background render of the element's surface and recursively + triggers updates for all children. + + If called multiple times in quick succession, the calls are + debounced to prevent excessive updates. For unbuffered elements, this + method simply passes the update call to its children. + """ + # If this element is buffered, schedule its own surface update. + if self.buffered: + if self._debounce_timer_id is not None: + GLib.source_remove(self._debounce_timer_id) + + if self.debounce_ms <= 0: + self._start_update() + else: + self._debounce_timer_id = GLib.timeout_add( + self.debounce_ms, self._start_update + ) + + # Always recursively trigger updates for all children, as they might + # be buffered even if this parent element is not. + for child in self.children: + child.trigger_update() + + def _start_update(self) -> bool: + """ + Submits the rendering task to the background thread pool. + This now calculates the correct pixel dimensions for the buffer. + """ + self._debounce_timer_id = None + + if self._update_future and not self._update_future.done(): + self._update_future.cancel() + + if not self.canvas: + return False + + # Calculate the total transformation from this element's local space + # to the final screen (pixel) space. + transform_to_screen = ( + self.canvas.view_transform @ self.get_world_transform() + ) + + # The absolute scale of this matrix tells us how many pixels one + # local unit of the element occupies on screen. + scale_x, scale_y = transform_to_screen.get_abs_scale() + + # Calculate the required buffer dimensions in pixels. + render_width = round(self.width * scale_x) + render_height = round(self.height * scale_y) + + # Clamp the render dimensions to the maximum allowed size + render_width = min(render_width, MAX_BUFFER_DIM) + render_height = min(render_height, MAX_BUFFER_DIM) + + if render_width <= 0 or render_height <= 0: + # Don't try to render to a zero or negative size surface. + self.surface = None # Ensure any old surface is cleared + if self.canvas: + self.canvas.queue_draw() + return False + + # Bump the generation so that any stale render results from + # earlier updates (still in-flight on other thread-pool workers) + # are silently discarded in _on_update_complete. + self._update_generation += 1 + gen = self._update_generation + + # Submit the thread-safe part to the executor with correct pixel dims. + self._update_future = self._executor.submit( + self.render_to_surface, render_width, render_height + ) + # Add a callback to handle the result on the main thread + self._update_future.add_done_callback( + lambda f, g=gen: self._on_update_complete(f, g) + ) + + return False # For GLib.timeout_add, run only once + + def _on_update_complete(self, future: Future, generation: int): + """ + Callback executed when the background render is finished. + + It schedules the final UI update to happen on the main GTK + thread to ensure thread safety. + + Args: + future: The Future object from the completed task. + generation: The generation counter value that was current + when this render was submitted. + """ + if future.cancelled(): + logger.debug(f"Update for {self.__class__.__name__} cancelled.") + return + + if exc := future.exception(): + logger.error( + f"Error in background update for " + f"{self.__class__.__name__}: {exc}", + exc_info=exc, + ) + return + + # Discard stale render results. When rapid changes occur + # (e.g. switching presets), multiple render futures can be + # in-flight concurrently. Only the result whose generation + # matches the current one is applied. + if generation != self._update_generation: + return + + # The result is the new cairo surface + new_surface = future.result() + + # Schedule the UI-modifying part to run on the main thread + GLib.idle_add(self._apply_surface, new_surface) + + def _apply_surface(self, new_surface: cairo.ImageSurface | None) -> bool: + """ + Applies the newly rendered surface from the background task. + + This method runs on the main GTK thread via `GLib.idle_add`. + + Args: + new_surface: The new surface to apply, or None. + """ + self.surface = new_surface + self.mark_dirty(ancestors=True) + if self.canvas: + self.canvas.queue_draw() + # The future is now complete, clear it. + self._update_future = None + return False # Do not call again + + def render_to_surface( + self, width: int, height: int + ) -> cairo.ImageSurface | None: + """ + Performs rendering to a new surface in a background thread. + + Subclasses should override this method for custom, long-running + drawing logic. It MUST be thread-safe. The base implementation + simply creates a surface and fills it with the background + color. + + Args: + width: The integer width of the surface to create. + height: The integer height of the surface to create. + + Returns: + A new `cairo.ImageSurface` or `None` if size is invalid. + """ + if width <= 0 or height <= 0: + return None + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + ctx.set_source_rgba(*self.background) + ctx.set_operator(cairo.OPERATOR_SOURCE) + ctx.paint() + return surface + + def get_region_rect( + self, + region: ElementRegion, + base_handle_size: float, + scale_compensation: float | tuple[float, float] = 1.0, + ) -> Rect: + """ + Gets the rect (x, y, w, h) for a region in local coordinates. + + Args: + region: The `ElementRegion` to query (e.g., a handle). + base_handle_size: The base pixel size for the handle. + scale_compensation: The element's visual scale factor. + + Returns: + A tuple (x, y, width, height) in local coordinates. + """ + return get_region_rect( + region, + self.width, + self.height, + base_handle_size, + scale_compensation, + ) + + def check_region_hit( + self, + x_abs: float, + y_abs: float, + candidates: set[ElementRegion] | None = None, + ) -> ElementRegion: + """ + Checks which region is hit at an absolute canvas position. + + It transforms the absolute point into the element's local + coordinate space to perform the hit check. + + Args: + x_abs: The absolute x-coordinate on the canvas. + y_abs: The y-coordinate on the canvas. + candidates: An optional set of regions to limit the check to. + + Returns: + The `ElementRegion` that was hit (e.g., BODY, HANDLE_SE). + """ + world_transform = self.get_world_transform() + try: + inv_world = world_transform.invert() + except np.linalg.LinAlgError: + return ElementRegion.NONE + + local_x, local_y = inv_world.transform_point((x_abs, y_abs)) + + # Use the single source of truth from the canvas for handle size. + # Fallback to a default if the element is not on a canvas. + base_hit_size = self.canvas.BASE_HANDLE_SIZE if self.canvas else 15.0 + + # This MUST match the calculation in Canvas._render_handles_overlay + # to ensure the hit-test geometry aligns with the rendered geometry. + if self.canvas: + transform_to_screen = self.canvas.view_transform @ world_transform + else: + transform_to_screen = world_transform + + scale_compensation = transform_to_screen.get_scale() + return check_region_hit( + local_x, + local_y, + self.width, + self.height, + base_hit_size, + scale_compensation, + candidates=candidates, + ) + + def mark_dirty(self, ancestors: bool = True, recursive: bool = False): + """ + Flags the element and its transforms as needing an update. + + Args: + ancestors: If True, marks all parent elements as dirty. + recursive: If True, marks all child elements as dirty. + """ + self.dirty = True + self._transform_dirty = True + if ancestors and isinstance(self.parent, CanvasElement): + self.parent.mark_dirty(ancestors=ancestors) + if recursive: + for child in self.children: + child.mark_dirty(ancestors=False, recursive=True) + + def copy(self) -> CanvasElement: + """Creates a deep copy of the element.""" + return deepcopy(self) + + def _attach_to_canvas_recursive(self, canvas: Canvas | None): + """ + Recursively sets the canvas for self and all children, and calls + the on_attached lifecycle hook. + """ + self.canvas = canvas + self.on_attached() + for child in self.children: + child._attach_to_canvas_recursive(canvas) + + def _detach_from_canvas_recursive(self): + """ + Recursively calls the on_detached hook and nullifies the canvas + reference for self and all children. + """ + self.on_detached() + for child in self.children: + child._detach_from_canvas_recursive() + self.canvas = None + + def _reparent(self, elem: CanvasElement): + """Removes an element from its current parent before adding it here.""" + if elem.parent: + # Check parent type to call the correct removal method. + if isinstance(elem.parent, CanvasElement): + elem.parent.remove_child(elem) + elif elem.canvas and isinstance( + elem.parent, elem.canvas.__class__ + ): + elem.parent.remove(elem) + + def add(self, elem: CanvasElement): + """ + Adds a child element. + + The element is added to the end of the children list. If the + element already has a parent, it is removed from it first. + + Args: + elem: The `CanvasElement` to add. + """ + self._reparent(elem) + + self.children.append(elem) + elem.parent = self + # Recursively propagate the canvas reference and trigger the + # on_attached hook for the new child and its descendants. + elem._attach_to_canvas_recursive(self.canvas) + elem.allocate() + self.mark_dirty() + self.on_child_list_changed() + if self.canvas: + self.canvas.queue_draw() + + def insert(self, index: int, elem: CanvasElement): + """ + Inserts a child element at a specific index. + + Args: + index: The index at which to insert the element. + elem: The `CanvasElement` to insert. + """ + self._reparent(elem) + + self.children.insert(index, elem) + elem.parent = self + # Recursively propagate the canvas reference and trigger the + # on_attached hook for the new child and its descendants. + elem._attach_to_canvas_recursive(self.canvas) + elem.allocate() + self.mark_dirty() + self.on_child_list_changed() + if self.canvas: + self.canvas.queue_draw() + + def set_visible(self, visible: bool = True): + """Sets the visibility of the element.""" + self.visible = visible + self.mark_dirty() + if self.canvas: + self.canvas.queue_draw() + + def find_by_data(self, data: Any) -> CanvasElement | None: + """ + Finds the first element (self or descendant) with matching data. + + Args: + data: The data to search for. + + Returns: + The matching `CanvasElement` or `None`. + """ + if data == self.data: + return self + for child in self.children: + result = child.find_by_data(data) + if result: + return result + return None + + def find_by_type( + self, thetype: Any + ) -> Generator[CanvasElement, None, None]: + """ + Finds all elements (self or descendant) of a given type. + + Args: + thetype: The class/type to search for. + + Yields: + Matching `CanvasElement` instances. + """ + if isinstance(self, thetype): + yield self + for child in self.children[:]: + yield from child.find_by_type(thetype) + + def data_by_type(self, thetype: Any) -> Generator[Any, None, None]: + """ + Finds all data from elements of a given type. + + Args: + thetype: The class/type to search for. + + Yields: + The `data` attribute of matching elements. + """ + for elem in self.find_by_type(thetype): + yield elem.data + + def get_all_children_recursive( + self, + ) -> Generator[CanvasElement, None, None]: + """ + Recursively yields all descendant elements. + """ + for child in self.children: + yield child + yield from child.get_all_children_recursive() + + def remove_all(self): + """Removes all children from this element.""" + children_to_remove = self.children[:] + self.children.clear() + + for child in children_to_remove: + child._detach_from_canvas_recursive() + if self.canvas: + self.canvas.elem_removed.send(self, child=child) + + self.mark_dirty() + self.on_child_list_changed() + + def remove(self): + """Removes this element from its parent.""" + assert self.parent is not None + # Check parent type to call the correct removal method. + if isinstance(self.parent, CanvasElement): + self.parent.remove_child(self) + elif self.canvas and isinstance(self.parent, self.canvas.__class__): + self.parent.remove(self) + + def remove_child(self, elem: CanvasElement): + """ + Removes a direct child element. This is not recursive. + + Args: + elem: The child element to remove. + """ + if elem in self.children: + # Trigger the detach hook before actual removal. + elem._detach_from_canvas_recursive() + self.children.remove(elem) + if self.canvas: + self.canvas.elem_removed.send(self, child=elem) + self.mark_dirty() + self.on_child_list_changed() + + def get_selected(self) -> Generator[CanvasElement, None, None]: + """Recursively finds and yields all selected elements.""" + if self.selected: + yield self + for child in self.children[:]: + yield from child.get_selected() + + def get_selected_data(self) -> Generator[Any, None, None]: + """Recursively finds and yields data of selected elements.""" + for elem in self.get_selected(): + yield elem.data + + def remove_selected(self): + """Recursively finds and removes all selected elements.""" + for child in self.children[:]: + if child.selected: + self.remove_child(child) + else: + child.remove_selected() + self.mark_dirty() + + def unselect_all(self): + """Recursively unselects this element and all descendants.""" + for child in self.children: + child.unselect_all() + if self.selected: + self.selected = False + self.mark_dirty() + + def set_pos(self, x: float, y: float): + """ + Sets the element's position relative to its parent. This method + is now matrix-native and preserves shear, rotation, and scale. + """ + new_transform = self.transform.set_translation(x, y) + self.set_transform(new_transform) + + def pos_abs(self) -> Point: + """ + Gets the absolute position on the canvas. + + This is calculated by extracting the translation component from + the element's world transformation matrix. + """ + world_transform = self.get_world_transform() + return world_transform.get_translation() + + def size(self) -> tuple[float, float]: + """Gets the element's size (width, height).""" + return self.width, self.height + + def set_size(self, width: float, height: float): + """ + Sets the element's size. + + This rebuilds the local transform (as the center point changes), + re-allocates the backing surface, and triggers a redraw. + """ + width = float(width) + height = float(height) + if width != self.width or height != self.height: + self.width, self.height = width, height + # Size change affects the center point, so a full rebuild is + # necessary + self._rebuild_transform() + # Use set_transform to apply the change and notify parent + self.set_transform(self.transform) + + def rect(self) -> Rect: + """ + Gets the local rect (x, y, width, height). + """ + x, y = self.transform.get_translation() + return x, y, self.width, self.height + + def rect_abs(self) -> Rect: + """ + Gets the absolute rect (x, y, width, height). + + The x and y are the absolute position of the top-left corner. + The width and height are the element's local size, not the + size of the transformed bounding box. + """ + x, y = self.pos_abs() + return x, y, self.width, self.height + + def get_aspect_ratio(self) -> float: + """Calculates the width-to-height aspect ratio.""" + if self.height == 0: + return 0.0 + return self.width / self.height + + def get_world_angle(self) -> float: + """ + Gets the total rotation angle in world coordinates. + + This is calculated by decomposing the world transformation + matrix. + """ + world_transform = self.get_world_transform() + return world_transform.get_rotation() + + def get_world_center(self) -> Point: + """ + Calculates the element's center point in world coordinates. + """ + local_center = (self.width / 2, self.height / 2) + return self.get_world_transform().transform_point(local_center) + + def allocate(self, force: bool = False): + """ + Allocates or re-allocates resources, like the backing surface. + + For buffered elements, this triggers a surface update if the + element's size has changed or if `force` is True. + + Args: + force: If True, forces reallocation even if size is same. + """ + for child in self.children: + child.allocate(force) + + if not self.buffered: + self.surface = None + return + + size_changed = ( + self.surface is None + or self.surface.get_width() != round(self.width) + or self.surface.get_height() != round(self.height) + ) + + if not size_changed and not force: + return + + if self.width > 0 and self.height > 0: + # Trigger an update to generate the new surface. + self.trigger_update() + else: + self.surface = None + + def render(self, ctx: cairo.Context): + """ + Renders the element and its children to the cairo context. + + This method applies the element's unified local transformation + matrix to the context before drawing its content and children. + + Args: + ctx: The cairo context to draw on. + """ + if not self.visible: + return + + ctx.save() + + # Apply the entire local transform relative to the parent in one go. + cairo_matrix = cairo.Matrix(*self.transform.for_cairo()) + ctx.transform(cairo_matrix) + + # The context is now fully transformed. All subsequent drawing happens + # in the element's untransformed local space (0,0 at top-left). + if self.clip: + ctx.rectangle(0, 0, self.width, self.height) + ctx.clip() + + self.draw(ctx) + + for child in self.children: + child.render(ctx) + + ctx.restore() + + def draw(self, ctx: cairo.Context): + """ + Draws the element's own content. + + The cairo context is assumed to be in the element's geometric + coordinate space. This method applies the `content_transform` before + drawing the final content. + + Args: + ctx: The cairo context, already transformed. + """ + ctx.save() + + # Apply the content_transform relative to the local geometry. + cairo_content_matrix = cairo.Matrix( + *self.content_transform.for_cairo() + ) + ctx.transform(cairo_content_matrix) + + # --- The rest of the drawing logic is now inside this transform --- + if not self.buffered or not self.surface: + # Unbuffered: just draw the background. + ctx.set_source_rgba(*self.background) + ctx.rectangle(0, 0, self.width, self.height) + ctx.fill() + else: + source_w = self.surface.get_width() + source_h = self.surface.get_height() + + if source_w > 0 and source_h > 0: + # Draw the buffered surface. We need to scale it to fit the + # element's width and height. + ctx.save() + scale_x = self.width / source_w + scale_y = self.height / source_h + ctx.scale(scale_x, scale_y) + ctx.set_source_surface(self.surface, 0, 0) + if scale_x < 1.0 or scale_y < 1.0: + ctx.get_source().set_filter(cairo.FILTER_BILINEAR) + else: + ctx.get_source().set_filter(cairo.FILTER_GOOD) + ctx.paint() + ctx.restore() + + ctx.restore() + + def clear_surface(self): + """ + Clears the internal surface of a buffered element. + """ + if self.surface: + ctx = cairo.Context(self.surface) + ctx.set_source_rgba(*self.background) + ctx.set_operator(cairo.OPERATOR_SOURCE) + ctx.paint() + self.mark_dirty() + + def has_dirty_children(self) -> bool: + """Checks if this element or any descendant is dirty.""" + if self.dirty: + return True + return any(c.has_dirty_children() for c in self.children) + + def get_elem_hit( + self, world_x: float, world_y: float, selectable: bool = False + ) -> CanvasElement | None: + """ + Checks for a hit on this element or its children given world + coordinates. + + The check is performed recursively, starting with the top-most + child (which is rendered last). The incoming coordinates are always + in world space. + + Args: + world_x: The x-coordinate in the canvas's world space. + world_y: The y-coordinate in the canvas's world space. + selectable: If True, only selectable elements are checked. + + Returns: + The `CanvasElement` that was hit, or `None`. + """ + # 1. Check children first (top-most are last in list, so drawn on top). + for child in reversed(self.children): + # Pass the original world coordinates down recursively. + hit = child.get_elem_hit(world_x, world_y, selectable) + if hit: + # A child was hit, so it's on top of us. Return it immediately. + return hit + + # 2. If no children were hit, check this element itself. + if selectable and not self.selectable: + return None + + # To check ourself, transform the world point into our local + # geometry space. + try: + inv_world = self.get_world_transform().invert() + local_geom_x, local_geom_y = inv_world.transform_point( + (world_x, world_y) + ) + except np.linalg.LinAlgError: + return ( + None # Cannot hit an element with a non-invertible transform + ) + + # 3. Now, perform a simple bounding box check in our own geometry + # space. + if not ( + 0 <= local_geom_x < self.width and 0 <= local_geom_y < self.height + ): + return None + + # 4. Optional: If inside the bounding box, perform pixel-perfect check. + # Draggable elements should be hittable anywhere within their bbox. + if ( + self.pixel_perfect_hit + and not self.draggable + and not self.is_pixel_opaque(local_geom_x, local_geom_y) + ): + return None + + # 5. If all checks pass, we have a hit on this element. + return self + + def is_pixel_opaque(self, local_x: float, local_y: float) -> bool: + """ + Checks if the pixel at local geometry coordinates is opaque. + + Args: + local_x: The x-coordinate in the element's local GEOMETRY space. + local_y: The y-coordinate in the element's local GEOMETRY space. + + Returns: + True if the pixel is considered a hit (alpha > 0). + """ + if not self.buffered: + # For unbuffered elements, pixel_perfect_hit means the element's + # body is effectively transparent. The hit is determined solely + # by a bounding box check in the calling method. + return False + + if not self.surface: + # Cannot perform pixel check if the surface doesn't exist. + return False + + return check_pixel_hit( + surface=self.surface, + content_transform=self.content_transform, + element_width=self.width, + element_height=self.height, + hit_distance=self.hit_distance, + local_x=local_x, + local_y=local_y, + ) + + def on_edit_mode_enter(self): + """Called when this element becomes the Canvas's edit_context.""" + + def on_edit_mode_leave(self): + """Called when this element is no longer the Canvas's edit_context.""" + + def handle_edit_press( + self, world_x: float, world_y: float, n_press: int = 1 + ) -> bool: + """ + Handles a mouse press event while in edit mode. + + Args: + world_x: The x-coordinate of press in world space. + world_y: The y-coordinate of press in world space. + n_press: The number of clicks (1=click, 2=double, 3=triple). + + Returns: + True if the event was handled, False otherwise. + """ + return False + + def handle_edit_drag(self, world_dx: float, world_dy: float): + """ + Handles a mouse drag event while in edit mode. + + Args: + world_dx: The horizontal drag distance in world coordinates. + world_dy: The vertical drag distance in world coordinates. + """ + + def handle_edit_release(self, world_x: float, world_y: float): + """ + Handles a mouse release event while in edit mode. + + Args: + world_x: The x-coordinate of the release in world space. + world_y: The y-coordinate of the release in world space. + """ + + def handle_edit_motion(self, world_x: float, world_y: float) -> bool: + """ + Handles a mouse motion event while in edit mode. + + Args: + world_x: The x-coordinate in world space. + world_y: The y-coordinate in world space. + + Returns: + True if the event was handled. + """ + return False + + def handle_edit_key(self, keyval: int) -> bool: + """ + Handles a key press event while in edit mode. + + Args: + keyval: The GDK key value. + + Returns: + True if the event was handled. + """ + return False + + def handle_edit_select_all(self) -> bool: + """ + Handles a select-all request while in edit mode. + + Returns: + True if the event was handled. + """ + return False + + def handle_drag_move(self, world_dx: float, world_dy: float) -> Point: + """ + Intercepts a drag move to apply constraints. Subclasses can + override this to customize drag behavior. + + This method is only called if the `draggable` property is True. + + If `drag_handler_controls_transform` is True, this method is + responsible for setting the element's transform directly. + + If it's False, this method should return a constrained delta tuple + `(constrained_dx, constrained_dy)` in world space. + """ + return world_dx, world_dy + + def dump(self, indent: int = 0): + """Prints a debug representation of the element and children.""" + pad = " " * indent + print(f"{pad}{self.__class__.__name__}: (Data: {self.data})") + print(f"{pad} Visible: {self.visible}, Selected: {self.selected}") + print(f"{pad} Rect: {self.rect()}") + print(f"{pad} Clip: {self.clip}") + if self.buffered: + surface_info = "None" + if self.surface: + surface_info = ( + f"Cairo Surface ({self.surface.get_width()}x" + f"{self.surface.get_height()})" + ) + print(f"{pad} Buffered: True, Surface: {surface_info}") + if self.children: + print(f"{pad} Children ({len(self.children)}):") + for child in self.children: + child.dump(indent + 1) diff --git a/rayforge/ui_gtk/canvas/hittest.py b/rayforge/ui_gtk/canvas/hittest.py new file mode 100644 index 000000000..eb7657470 --- /dev/null +++ b/rayforge/ui_gtk/canvas/hittest.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import cairo +import numpy as np + +if TYPE_CHECKING: + from raygeo.geo import Matrix + + +def check_pixel_hit( + surface: cairo.ImageSurface, + content_transform: Matrix, + element_width: float, + element_height: float, + hit_distance: float, + local_x: float, + local_y: float, +) -> bool: + """ + Checks if a pixel at specific local coordinates is opaque on a cairo + surface. + + This function is a utility for performing pixel-perfect hit-testing on a + rendered element buffer. It accounts for an element's content transform + and a "fuzzy" hit distance specified in screen pixels. + + Args: + surface: The cairo ImageSurface to check against. + content_transform: The transformation from the element's geometry + space to its content space. + element_width: The geometric width of the element. + element_height: The geometric height of the element. + hit_distance: A "fuzzy" radius in screen pixels. If > 0, checks a + circular area for any opaque pixel. + local_x: The x-coordinate in the element's local GEOMETRY space. + local_y: The y-coordinate in the element's local GEOMETRY space. + + Returns: + True if the pixel (or surrounding area) is considered a hit. + """ + surface_w = surface.get_width() + surface_h = surface.get_height() + if surface_w <= 0 or surface_h <= 0: + return False + + # The received coordinates are in the element's GEOMETRIC space. + # We must transform them into the CONTENT's space before sampling + # the surface. + content_x, content_y = local_x, local_y + if not content_transform.is_identity(): + try: + # We need to map the geometric point to the content's + # coordinate system. This requires the inverse of the + # content_transform. + inv_content = content_transform.invert() + content_x, content_y = inv_content.transform_point( + (local_x, local_y) + ) + except np.linalg.LinAlgError: + # If matrix is non-invertible, we can't do the hit-test. + # Default to a hit, as the user is inside the bounding box. + return True + + # Scale CONTENT coordinates to surface pixel coordinates. + center_surface_x = int(content_x * (surface_w / element_width)) + center_surface_y = int(content_y * (surface_h / element_height)) + + # Clamp the calculated pixel coordinates to be safely within the + # surface bounds [0, dim-1]. This guards against floating-point + # inaccuracies where a coordinate might be calculated to be exactly + # the surface dimension (e.g., surface_w), which is an invalid index. + if center_surface_x < 0: + center_surface_x = 0 + elif center_surface_x >= surface_w: + center_surface_x = surface_w - 1 + + if center_surface_y < 0: + center_surface_y = 0 + elif center_surface_y >= surface_h: + center_surface_y = surface_h - 1 + + # --- Standard (non-fuzzy) hit check --- + if hit_distance <= 0: + # Check if the calculated pixel is within the surface's bounds. + if not ( + 0 <= center_surface_x < surface_w + and 0 <= center_surface_y < surface_h + ): + return False + + # Read the alpha value from the cairo surface data buffer. + data = surface.get_data() + stride = surface.get_stride() + pixel_offset = center_surface_y * stride + center_surface_x * 4 + alpha = data[pixel_offset + 3] # BGRA format, alpha is 4th byte + return alpha > 0 + + # --- Fuzzy hit check --- + else: + # The hit_distance is in screen pixels, making it intuitive. + radius_px = round(hit_distance) + # Add a safety clamp to prevent a deadlock if a huge value is given + radius_px = min(radius_px, 50) + radius_sq = radius_px * radius_px + + data = surface.get_data() + stride = surface.get_stride() + + # Iterate over a square bounding box around the center point. + for dy in range(-radius_px, radius_px + 1): + for dx in range(-radius_px, radius_px + 1): + # Check if the point is inside the circular radius + if dx * dx + dy * dy > radius_sq: + continue + + px = center_surface_x + dx + py = center_surface_y + dy + + # Check if the sample point is within surface bounds + if not (0 <= px < surface_w and 0 <= py < surface_h): + continue + + # Check pixel alpha + offset = py * stride + px * 4 + alpha = data[offset + 3] + if alpha > 0: + return True # Found an opaque pixel, it's a hit. + + # If we checked the whole area and found nothing, it's a miss. + return False diff --git a/rayforge/ui_gtk/canvas/intersect.py b/rayforge/ui_gtk/canvas/intersect.py new file mode 100644 index 000000000..397748922 --- /dev/null +++ b/rayforge/ui_gtk/canvas/intersect.py @@ -0,0 +1,69 @@ +from gi.repository import Graphene + + +def obb_intersects_aabb( + obb_corners: list[tuple[float, float]], aabb: Graphene.Rect +) -> bool: + """ + Checks if an Oriented Bounding Box (OBB) intersects with an Axis-Aligned + Bounding Box (AABB) using the Separating Axis Theorem (SAT). + + An intersection occurs if the projections of the two shapes overlap on all + potential separating axes. The axes to test are the normals of the edges + of both shapes. + """ + + def project(polygon_corners, axis): + """ + Projects a polygon onto an axis and returns the min/max projection. + """ + min_p = float("inf") + max_p = float("-inf") + for p in polygon_corners: + # Vector dot product + projection = p[0] * axis[0] + p[1] * axis[1] + min_p = min(min_p, projection) + max_p = max(max_p, projection) + return min_p, max_p + + aabb_corners = [ + (aabb.get_x(), aabb.get_y()), + (aabb.get_x() + aabb.get_width(), aabb.get_y()), + (aabb.get_x() + aabb.get_width(), aabb.get_y() + aabb.get_height()), + (aabb.get_x(), aabb.get_y() + aabb.get_height()), + ] + + # The axes to test are the unique normals of the edges. + # For an AABB, the normals are the world axes. + # For an OBB (rectangle), there are two unique normals. + edge1 = ( + obb_corners[1][0] - obb_corners[0][0], + obb_corners[1][1] - obb_corners[0][1], + ) + normal1 = (-edge1[1], edge1[0]) + + edge2 = ( + obb_corners[3][0] - obb_corners[0][0], + obb_corners[3][1] - obb_corners[0][1], + ) + normal2 = (-edge2[1], edge2[0]) + + axes_to_test = [(1, 0), (0, 1), normal1, normal2] + + for axis in axes_to_test: + # Ensure axis is not a zero vector (can happen with zero-sized + # elements) + if axis[0] == 0 and axis[1] == 0: + continue + + min_p1, max_p1 = project(obb_corners, axis) + min_p2, max_p2 = project(aabb_corners, axis) + + # Check for separation: if one projection doesn't overlap, + # there's a separating axis. + if max_p1 < min_p2 or max_p2 < min_p1: + return False # A separating axis was found + + # If no separating axis was found after checking all axes, the + # polygons must be intersecting. + return True diff --git a/rayforge/ui_gtk/canvas/multiselect.py b/rayforge/ui_gtk/canvas/multiselect.py new file mode 100644 index 000000000..a75a6205a --- /dev/null +++ b/rayforge/ui_gtk/canvas/multiselect.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import logging +import math +from typing import ( + TYPE_CHECKING, + Any, +) + +from raygeo.geo import Matrix + +from . import element, transform +from .region import ElementRegion, check_region_hit, get_region_rect + +# Forward declaration for type hinting to avoid circular imports +if TYPE_CHECKING: + from raygeo.geo.types import Point, Rect + + from .canvas import Canvas + from .element import CanvasElement + +logger = logging.getLogger(__name__) + + +class MultiSelectionGroup: + def __init__(self, elements: list[CanvasElement], canvas: Canvas): + if not elements: + raise ValueError( + "MultiSelectionGroup cannot be initialized with an " + "empty list of elements." + ) + + self.elements: list[CanvasElement] = elements + self.canvas: Canvas = canvas + self._bounding_box: Rect = (0, 0, 0, 0) + self._center: Point = (0, 0) + self.initial_states: list[dict[str, Any]] = [] + self.initial_center: Point = (0, 0) + + # The transformation matrix for the entire group, applied during a + # drag operation. + self.transform: Matrix = Matrix.identity() + + self._calculate_bounding_box() + + @property + def x(self) -> float: + return self._bounding_box[0] + + @property + def y(self) -> float: + return self._bounding_box[1] + + @property + def width(self) -> float: + return self._bounding_box[2] + + @property + def height(self) -> float: + return self._bounding_box[3] + + @property + def center(self) -> Point: + return self._center + + def _calculate_bounding_box(self): + min_x, min_y = float("inf"), float("inf") + max_x, max_y = float("-inf"), float("-inf") + + for elem in self.elements: + x, y, w, h = elem.get_world_bounding_box() + min_x, min_y = min(min_x, x), min(min_y, y) + max_x, max_y = max(max_x, x + w), max(max_y, y + h) + + self._bounding_box = (min_x, min_y, max_x - min_x, max_y - min_y) + self._center = (min_x + self.width / 2, min_y + self.height / 2) + + def store_initial_states(self): + """ + Stores the initial state of each top-level selected element. + This includes its world transform and its parent's inverse world + transform, which is crucial for recalculating its new local + properties after a group transform. + """ + self.initial_states.clear() + self._calculate_bounding_box() + self.initial_center = self.center + self.transform = Matrix.identity() + + selected_set = set(self.elements) + top_level_elements = [] + + for elem in self.elements: + is_top_level = True + parent = elem.parent + while isinstance(parent, element.CanvasElement): + if parent in selected_set: + is_top_level = False + break + parent = parent.parent + if is_top_level: + top_level_elements.append(elem) + + for elem in top_level_elements: + parent_inv_world = Matrix.identity() + if isinstance(elem.parent, element.CanvasElement): + parent_inv_world = elem.parent.get_world_transform().invert() + + self.initial_states.append( + { + "elem": elem, + "initial_world": elem.get_world_transform(), + "parent_inv_world": parent_inv_world, + } + ) + + def _update_element_transforms(self): + """ + Applies the group's `self.transform` to each element's initial + state to calculate its new local transform matrix, which is then + set directly on the element. This preserves shear. + """ + for state in self.initial_states: + elem: CanvasElement = state["elem"] + + # Calculate the element's new world transform by applying the + # group's delta transform to its initial state. + new_world_transform = self.transform @ state["initial_world"] + + # To get the new local transform, we must convert this new + # world transform back into the element's parent-relative + # coordinate space. + new_transform_in_parent_space = ( + state["parent_inv_world"] @ new_world_transform + ) + + # Set the new matrix directly on the element. This avoids + # destructive decomposition and preserves shear. + elem.set_transform(new_transform_in_parent_space) + + def get_region_rect( + self, + region: ElementRegion, + base_handle_size: float, + scale_compensation: float | tuple[float, float] = 1.0, + ) -> Rect: + return get_region_rect( + region, + self.width, + self.height, + base_handle_size, + scale_compensation, + ) + + def check_region_hit( + self, + x: float, + y: float, + candidates: set[ElementRegion] | None = None, + ) -> ElementRegion: + # The group's bounding box is (min_x, min_y, width, height) in world + # coords. We convert the world mouse coordinate (x,y) into the group's + # local AABB coordinate space. + min_x, min_y, _, _ = self._bounding_box + local_x = x - min_x + local_y = y - min_y + + # Use the get_scale() method which correctly returns signed scale + # factors, implicitly handling the flip status for the geometry check. + scale_compensation = self.canvas.view_transform.get_scale() + + return check_region_hit( + local_x, + local_y, + self.width, + self.height, + self.canvas.BASE_HANDLE_SIZE, + scale_compensation=scale_compensation, + candidates=candidates, + ) + + def apply_move(self, dx: float, dy: float): + """ + Sets the group transform to a simple translation and updates + elements. + """ + self.transform = Matrix.translation(dx, dy) + self._update_element_transforms() + + def apply_resize( + self, + new_box: Rect, + original_box: Rect, + ): + """ + Calculates a scale/translate transform that maps the original + bounding box to the new one, and applies it to the group. + """ + orig_x, orig_y, orig_w, orig_h = original_box + new_x, new_y, new_w, new_h = new_box + + if orig_w <= 1e-6 or orig_h <= 1e-6: + return + + scale_x = new_w / orig_w + scale_y = new_h / orig_h + + # To map a point from the old box to the new one, the correct matrix + # is T_new * S * T_inv. Assuming post-multiplication (M' = M * Op), + # the operations must be chained in the order they should appear in + # the final matrix product. + self.transform = ( + Matrix.identity() + .post_translate(new_x, new_y) + .post_scale(scale_x, scale_y) + .post_translate(-orig_x, -orig_y) + ) + self._update_element_transforms() + + def apply_rotate(self, angle_delta: float, center: Point | None = None): + """ + Sets the group transform to a rotation around the group's initial + center and updates elements. + """ + if center is None: + center = self.initial_center + self.transform = Matrix.rotation(angle_delta, center) + self._update_element_transforms() + + def resize_from_drag( + self, + active_region: ElementRegion, + offset_x: float, + offset_y: float, + active_origin: Rect, + ctrl_pressed: bool, + shift_pressed: bool, + ): + """ + Calculates and applies the new group bounding box by calling the + centralized logic in `transform.py`. + """ + # 1. Define minimum size in world units. + min_size_px = 20.0 + scale_x, scale_y = self.canvas.view_transform.get_abs_scale() + min_size_world = ( + min_size_px / scale_x if scale_x > 1e-6 else 0.0, + min_size_px / scale_y if scale_y > 1e-6 else 0.0, + ) + + # 2. Delegate the calculation, passing raw offsets and flip status. + # The drag_delta for the world-aligned box is the world mouse offset. + new_box = transform.calculate_resized_box( + original_box=active_origin, + active_region=active_region, + drag_delta=(offset_x, offset_y), + is_flipped=self.canvas.view_transform.is_flipped(), + constrain_aspect=shift_pressed, + from_center=ctrl_pressed, + min_size=min_size_world, + ) + + # 3. Apply the result. + self.apply_resize(new_box, active_origin) + + def rotate_from_drag( + self, + current_x: float, + current_y: float, + rotation_pivot: Point, + drag_start_angle: float, + ): + """ + Rotates the entire selection group based on cursor drag. + The coordinates are in WORLD space. + """ + center_x, center_y = rotation_pivot + current_angle = math.degrees( + math.atan2(current_y - center_y, current_x - center_x) + ) + angle_diff = current_angle - drag_start_angle + # Temporarily override initial_center for the rotate call + original_center = self.initial_center + self.initial_center = rotation_pivot + self.apply_rotate(angle_diff) + self.initial_center = original_center + + def shear_from_drag( + self, + active_region: ElementRegion, + world_dx: float, + world_dy: float, + active_origin: Rect, + ): + """Shears the entire selection group.""" + shx, shy = 0.0, 0.0 + x, y, w, h = active_origin + anchor_x, anchor_y = 0.0, 0.0 + + is_view_flipped = self.canvas.view_transform.is_flipped() + semantic_is_top = active_region == ElementRegion.SHEAR_TOP + semantic_is_bottom = active_region == ElementRegion.SHEAR_BOTTOM + semantic_is_left = active_region == ElementRegion.SHEAR_LEFT + semantic_is_right = active_region == ElementRegion.SHEAR_RIGHT + + if semantic_is_top or semantic_is_bottom: + # This logic is confirmed to work correctly in both Y-up and + # Y-down. + visual_top_y = y + h if is_view_flipped else y + visual_bottom_y = y if is_view_flipped else y + h + anchor_y = visual_bottom_y if semantic_is_top else visual_top_y + anchor_x = x + w / 2 + + y_diff = visual_bottom_y - visual_top_y + if semantic_is_top: + shx = -world_dx / y_diff if abs(y_diff) > 1e-6 else 0.0 + else: # semantic_is_bottom + shx = world_dx / y_diff if abs(y_diff) > 1e-6 else 0.0 + + elif semantic_is_left or semantic_is_right: + # Anchor is the edge opposite to the one being dragged. + anchor_x = x if semantic_is_right else (x + w) + anchor_y = y + h / 2 + + # The vertical shear factor `shy` is derived from the transform: + # `delta_y = shy * (x_dragged - x_anchor)`. + # The `world_dy` already has the correct sign for the drag + # regardless of whether the view is Y-up or Y-down. This single + # set of formulas works for both cases without modification. + if semantic_is_left: + # Drag left edge, anchor is on right: x_dragged-x_anchor = -w + # shy = delta_y / -w + shy = -world_dy / w if w > 1e-6 else 0.0 + else: # semantic_is_right + # Drag right edge, anchor is on left: x_dragged-x_anchor = +w + # shy = delta_y / w + shy = world_dy / w if w > 1e-6 else 0.0 + + # Construct delta shear matrix around world anchor + self.transform = ( + Matrix.identity() + .post_translate(anchor_x, anchor_y) + .post_shear(shx, shy) + .post_translate(-anchor_x, -anchor_y) + ) + self._update_element_transforms() diff --git a/rayforge/ui_gtk/canvas/overlays.py b/rayforge/ui_gtk/canvas/overlays.py new file mode 100644 index 000000000..e79a5fe9e --- /dev/null +++ b/rayforge/ui_gtk/canvas/overlays.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +import math +from typing import ( + TYPE_CHECKING, + Any, +) + +import cairo +from gi.repository import Gdk +from raygeo.geo import Matrix + +from ...core.color import ColorRGBA +from ..icons import get_icon_pixbuf +from .region import ( + CORNER_RESIZE_HANDLES, + MIDDLE_RESIZE_HANDLES, + MOVE_HANDLES, + RESIZE_HANDLES, + ROTATE_HANDLES, + ROTATE_SHEAR_HANDLES, + ElementRegion, +) + +if TYPE_CHECKING: + from .canvas import CanvasElement, MultiSelectionGroup, SelectionMode + +_DEFAULT_HANDLE_COLOR: ColorRGBA = (0.2, 0.5, 0.8, 1.0) +_DEFAULT_HANDLE_COLOR_HOVER: ColorRGBA = (0.3, 0.6, 0.9, 1.0) + +_move_gizmo_pixbuf = None + + +def _get_move_gizmo_pixbuf(size: int = 24): + """Returns a cached pixbuf for the move-symbolic icon.""" + global _move_gizmo_pixbuf + if _move_gizmo_pixbuf is None: + _move_gizmo_pixbuf = get_icon_pixbuf("move-symbolic", size) + return _move_gizmo_pixbuf + + +def _handle_colors( + base_color: ColorRGBA | None, + is_hovered: bool, +) -> ColorRGBA: + if base_color: + r, g, b, _ = base_color + if is_hovered: + return ( + min(r + 0.15, 1.0), + min(g + 0.15, 1.0), + min(b + 0.15, 1.0), + 0.9, + ) + return (r, g, b, 0.7) + if is_hovered: + return (*_DEFAULT_HANDLE_COLOR_HOVER[:3], 0.9) + return (*_DEFAULT_HANDLE_COLOR[:3], 0.7) + + +def _draw_quad_handle( + ctx: cairo.Context, + p1: tuple[float, float], + p2: tuple[float, float], + p3: tuple[float, float], + p4: tuple[float, float], + is_hovered: bool, + color: ColorRGBA | None = None, +): + """Draws a quadrilateral handle given four screen-space points.""" + ctx.set_source_rgba(*_handle_colors(color, is_hovered)) + + ctx.move_to(*p1) + ctx.line_to(*p2) + ctx.line_to(*p3) + ctx.line_to(*p4) + ctx.close_path() + ctx.fill() + + +def _draw_square_handle( + ctx: cairo.Context, + width: float, + height: float, + is_hovered: bool, + color: ColorRGBA | None = None, +): + """Draws a square handle. Uses the smaller of width/height for size.""" + size = min(width, height) + ctx.set_source_rgba(*_handle_colors(color, is_hovered)) + + half_size = size / 2 + ctx.rectangle(-half_size, -half_size, size, size) + ctx.fill() + + +def _draw_rectangle_handle( + ctx: cairo.Context, + width: float, + height: float, + is_hovered: bool, + color: ColorRGBA | None = None, +): + """Draws a rectangular handle, perfect for stretched edge handles.""" + ctx.set_source_rgba(*_handle_colors(color, is_hovered)) + + ctx.rectangle(-width / 2, -height / 2, width, height) + ctx.fill() + + +def _draw_arc_handle( + ctx: cairo.Context, + width: float, + height: float, + is_hovered: bool, + color: ColorRGBA | None = None, +): + """Draws a rotation arc handle. Uses average of width/height for size.""" + size = (width + height) / 2.0 + c = _handle_colors(color, is_hovered) + if not color: + c = (*c[:3], 0.95 if is_hovered else 0.8) + + ctx.set_source_rgba(*c) + ctx.set_line_width(2.0) + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + radius = size * 0.5 + start_angle, end_angle = math.radians(45), math.radians(-45) + ctx.arc_negative(0, 0, radius, start_angle, end_angle) + + def draw_arrowhead(point_angle: float, is_start_arrow: bool): + arrow_len, arrow_width = size * 0.18, size * 0.2 + ctx.save() + px = radius * math.cos(point_angle) + py = radius * math.sin(point_angle) + ctx.translate(px, py) + tangent = point_angle - math.pi / 2.0 + ctx.rotate(tangent + (math.pi if is_start_arrow else 0)) + ctx.move_to(0, 0) + + if is_start_arrow: + ctx.line_to(-arrow_len, -arrow_width * 0.9) + ctx.move_to(0, 0) + ctx.line_to(-arrow_len * 0.9, arrow_width * 1.2) + else: + ctx.line_to(-arrow_len * 0.9, -arrow_width * 1.2) + ctx.move_to(0, 0) + ctx.line_to(-arrow_width, arrow_len * 0.9) + ctx.restore() + + draw_arrowhead(start_angle, True) + draw_arrowhead(end_angle, False) + ctx.stroke() + + +def _draw_arrow_handle( + ctx: cairo.Context, + width: float, + height: float, + is_hovered: bool, + color: ColorRGBA | None = None, +): + """Draws a bidirectional arrow. Uses average of width/height for size.""" + size = (width + height) / 2.0 + ctx.set_source_rgba(*_handle_colors(color, is_hovered)) + + ctx.set_line_width(2.0) + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + ctx.set_line_join(cairo.LINE_JOIN_ROUND) + length, arrow_size = size * 0.4, size * 0.25 + + ctx.move_to(-length, 0) + ctx.line_to(length, 0) + ctx.move_to(length, 0) + ctx.line_to(length - arrow_size, -arrow_size * 0.7) + ctx.move_to(length, 0) + ctx.line_to(length - arrow_size, arrow_size * 0.7) + ctx.move_to(-length, 0) + ctx.line_to(-length + arrow_size, -arrow_size * 0.7) + ctx.move_to(-length, 0) + ctx.line_to(-length + arrow_size, arrow_size * 0.7) + ctx.stroke() + + +def _draw_move_gizmo( + ctx: cairo.Context, + width: float, + height: float, + is_hovered: bool, + color: ColorRGBA | None = None, +): + """Draws a rounded square rotated 45 degrees (diamond) with the + move-symbolic icon centered on it, axis-aligned.""" + size = min(width, height) + diamond_side = size / math.sqrt(2) + radius = diamond_side * 0.2 + + ctx.save() + ctx.rotate(math.radians(45)) + ctx.set_source_rgba(*_handle_colors(color, is_hovered)) + half = diamond_side / 2 + path_rounded_square(ctx, -half, -half, diamond_side, diamond_side, radius) + ctx.fill() + ctx.restore() + + pixbuf = _get_move_gizmo_pixbuf() + if pixbuf is not None: + icon_size = size * 0.65 + pb_w = pixbuf.get_width() + pb_h = pixbuf.get_height() + ctx.save() + ctx.scale(icon_size / pb_w, icon_size / pb_h) + Gdk.cairo_set_source_pixbuf(ctx, pixbuf, -pb_w / 2, -pb_h / 2) + ctx.get_source().set_filter(cairo.FILTER_BILINEAR) + ctx.set_operator(cairo.OPERATOR_DEST_OUT) + ctx.paint() + ctx.restore() + + +def path_rounded_square( + ctx: cairo.Context, + x: float, + y: float, + w: float, + h: float, + r: float, +): + """Draws a rounded-rectangle path centered at origin coordinates.""" + ctx.new_sub_path() + ctx.arc(x + w - r, y + r, r, -math.pi / 2, 0) + ctx.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + ctx.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + ctx.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + ctx.close_path() + + +_ARC_HANDLE_BASE_ANGLES_DEG = { + ElementRegion.ROTATE_TOP_RIGHT: 315, + ElementRegion.ROTATE_TOP_LEFT: 225, + ElementRegion.ROTATE_BOTTOM_LEFT: 135, + ElementRegion.ROTATE_BOTTOM_RIGHT: 45, +} + +HANDLE_DRAW_INFO: dict[ElementRegion, dict[str, Any]] = { + region: { + "draw": _draw_square_handle, + "get_angle": lambda t, r: t.get_x_axis_angle(), + } + for region in CORNER_RESIZE_HANDLES +} +HANDLE_DRAW_INFO.update( + { + ElementRegion.TOP_MIDDLE: { + "draw": _draw_rectangle_handle, + "get_angle": lambda t, r: t.get_x_axis_angle(), + }, + ElementRegion.BOTTOM_MIDDLE: { + "draw": _draw_rectangle_handle, + "get_angle": lambda t, r: t.get_x_axis_angle(), + }, + ElementRegion.MIDDLE_LEFT: { + "draw": _draw_rectangle_handle, + "get_angle": lambda t, r: t.get_y_axis_angle(), + "swap_dims": True, + }, + ElementRegion.MIDDLE_RIGHT: { + "draw": _draw_rectangle_handle, + "get_angle": lambda t, r: t.get_y_axis_angle(), + "swap_dims": True, + }, + } +) +HANDLE_DRAW_INFO.update( + { + region: { + "draw": _draw_arc_handle, + "get_angle": lambda t, r: ( + t.get_rotation() + _ARC_HANDLE_BASE_ANGLES_DEG[r] + ), + } + for region in ROTATE_HANDLES + } +) +HANDLE_DRAW_INFO.update( + { + ElementRegion.SHEAR_TOP: { + "draw": _draw_arrow_handle, + "get_angle": lambda t, r: t.get_x_axis_angle(), + }, + ElementRegion.SHEAR_BOTTOM: { + "draw": _draw_arrow_handle, + "get_angle": lambda t, r: t.get_x_axis_angle(), + }, + ElementRegion.SHEAR_LEFT: { + "draw": _draw_arrow_handle, + "get_angle": lambda t, r: t.get_y_axis_angle(), + "swap_dims": True, + }, + ElementRegion.SHEAR_RIGHT: { + "draw": _draw_arrow_handle, + "get_angle": lambda t, r: t.get_y_axis_angle(), + "swap_dims": True, + }, + ElementRegion.MOVE: { + "draw": _draw_move_gizmo, + "get_angle": lambda t, r: 0.0, + }, + } +) + + +def render_selection_frame( + ctx: cairo.Context, + target: CanvasElement | MultiSelectionGroup, + transform_to_screen: Matrix, +): + """ + Draws the dashed selection frame for a target. + + Args: + ctx: The cairo context (in screen space). + target: The CanvasElement or MultiSelectionGroup to draw frame for. + transform_to_screen: The matrix to transform from local to screen. + """ + ctx.save() + w, h = target.width, target.height + corners_local = [(0, 0), (w, 0), (w, h), (0, h)] + corners_screen = [ + transform_to_screen.transform_point(p) for p in corners_local + ] + + # Draw the dashed outline connecting the screen-space corners. + # Line width and dash pattern are now in fixed pixels. + ctx.set_source_rgb(0.4, 0.4, 0.4) + ctx.set_line_width(1.0) + ctx.set_dash((5, 5)) + + ctx.move_to(*corners_screen[0]) + ctx.line_to(*corners_screen[1]) + ctx.line_to(*corners_screen[2]) + ctx.line_to(*corners_screen[3]) + ctx.close_path() + ctx.stroke() + ctx.restore() + + +def _render_handles( + ctx: cairo.Context, + target: CanvasElement | MultiSelectionGroup, + transform_to_screen: Matrix, + regions: list[ElementRegion], + hovered_region: ElementRegion, + base_handle_size: float, + scale_compensation: tuple[float, float], + color: ColorRGBA | None = None, +): + sx_abs, sy_abs = transform_to_screen.get_abs_scale() + + for region in regions: + # Resize handles must be drawn as transformed quads to + # account for shear. Rotate/Shear handles are glyphs that are only + # rotated to align with the frame. + if region in RESIZE_HANDLES: + lx, ly, lw, lh = target.get_region_rect( + region, base_handle_size, scale_compensation + ) + if lw <= 0 or lh <= 0: + continue + + # Get the 4 corners of the handle's rectangle in local space + corners_local = [ + (lx, ly), + (lx + lw, ly), + (lx + lw, ly + lh), + (lx, ly + lh), + ] + # Transform them to screen space to get the final skewed quad + corners_screen = [ + transform_to_screen.transform_point(p) for p in corners_local + ] + _draw_quad_handle( + ctx, + *corners_screen, + is_hovered=(region == hovered_region), + color=color, + ) + else: # Rotate or Shear handles + draw_info = HANDLE_DRAW_INFO.get(region) + if not draw_info: + continue + + lx, ly, lw, lh = target.get_region_rect( + region, base_handle_size, scale_compensation + ) + if lw <= 0 or lh <= 0: + continue + + center_local = (lx + lw / 2, ly + lh / 2) + screen_x, screen_y = transform_to_screen.transform_point( + center_local + ) + angle_rad = math.radians( + draw_info["get_angle"](transform_to_screen, region) + ) + + screen_width = lw * sx_abs + screen_height = lh * sy_abs + + draw_w, draw_h = screen_width, screen_height + if draw_info.get("swap_dims", False): + draw_w, draw_h = screen_height, screen_width + + ctx.save() + ctx.translate(screen_x, screen_y) + ctx.rotate(angle_rad) + draw_info["draw"]( + ctx, + draw_w, + draw_h, + is_hovered=(region == hovered_region), + color=color, + ) + ctx.restore() + + +def render_selection_handles( + ctx: cairo.Context, + target: CanvasElement | MultiSelectionGroup, + transform_to_screen: Matrix, + mode: SelectionMode, + hovered_region: ElementRegion, + base_handle_size: float, + with_labels: bool = False, + color: ColorRGBA | None = None, +): + """ + Renders selection handles for a target based on the current interaction + mode. + + This function understands the application logic (modes, regions) but is + "dumb" regarding transformations; it requires a pre-computed matrix to + map the target's local coordinates to the screen. + + Args: + ctx: The cairo context (in screen space). + target: The CanvasElement or MultiSelectionGroup to draw handles for. + transform_to_screen: The matrix to transform from local to screen. + mode: The current SelectionMode. + hovered_region: The currently hovered region, for hover effects. + base_handle_size: The base pixel size for the handles. + with_labels: If True, draws debug text labels on the handles. + color: Optional ColorRGBA to color handles. Defaults to blue. + """ + from .canvas import SelectionMode # Avoid circular import at module level + + if transform_to_screen.has_zero_scale(): + return + + sx_abs, sy_abs = transform_to_screen.get_abs_scale() + is_view_flipped = transform_to_screen.is_flipped() + scale_compensation = (sx_abs, -sy_abs if is_view_flipped else sy_abs) + + # Determine regions to draw + regions_to_draw = [] + if mode == SelectionMode.RESIZE: + regions_to_draw.extend(CORNER_RESIZE_HANDLES) + if hovered_region in MIDDLE_RESIZE_HANDLES: + regions_to_draw.append(hovered_region) + + elif mode == SelectionMode.ROTATE_SHEAR: + regions_to_draw.extend(ROTATE_SHEAR_HANDLES) + + # The move gizmo is always drawn regardless of the selection mode. + if mode != SelectionMode.NONE: + regions_to_draw.extend(MOVE_HANDLES) + + if regions_to_draw: + _render_handles( + ctx, + target, + transform_to_screen, + regions_to_draw, + hovered_region, + base_handle_size, + scale_compensation, + color=color, + ) + + if with_labels: + _render_debug_labels( + ctx, + target, + transform_to_screen, + regions_to_draw, + base_handle_size, + scale_compensation, + ) + + +def _render_debug_labels( + ctx, target, transform, regions, base_size, scale_comp +): + """Helper to draw debug text labels on handles.""" + _region_letters = { + r: chr(ord("A") + i) + for i, r in enumerate(RESIZE_HANDLES | ROTATE_SHEAR_HANDLES) + } + ctx.save() + ctx.set_source_rgb(1, 0, 0) + ctx.select_font_face( + "Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD + ) + ctx.set_font_size(10) + for region in regions: + letter = _region_letters.get(region) + if not letter: + continue + lx, ly, lw, lh = target.get_region_rect(region, base_size, scale_comp) + sx, sy = transform.transform_point((lx + lw / 2, ly + lh / 2)) + ext = ctx.text_extents(letter) + ctx.move_to( + sx - (ext.width / 2 + ext.x_bearing), + sy - (ext.height / 2 + ext.y_bearing), + ) + ctx.show_text(letter) + ctx.restore() diff --git a/rayforge/ui_gtk/canvas/region.py b/rayforge/ui_gtk/canvas/region.py new file mode 100644 index 000000000..a418fd70a --- /dev/null +++ b/rayforge/ui_gtk/canvas/region.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +from enum import Enum, auto +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from raygeo.geo.types import Rect + + +class ElementRegion(Enum): + """Defines interactive regions for selection frames.""" + + NONE = auto() + BODY = auto() + # Resize handles (inside) + TOP_LEFT = auto() + TOP_MIDDLE = auto() + TOP_RIGHT = auto() + MIDDLE_LEFT = auto() + MIDDLE_RIGHT = auto() + BOTTOM_LEFT = auto() + BOTTOM_MIDDLE = auto() + BOTTOM_RIGHT = auto() + # Rotate & Shear handles (outside) + ROTATE_TOP_LEFT = auto() + ROTATE_TOP_RIGHT = auto() + ROTATE_BOTTOM_LEFT = auto() + ROTATE_BOTTOM_RIGHT = auto() + SHEAR_TOP = auto() + SHEAR_RIGHT = auto() + SHEAR_BOTTOM = auto() + SHEAR_LEFT = auto() + # Move gizmo (outside, below the selection frame) + MOVE = auto() + + +RESIZE_HANDLES: set[ElementRegion] = { + ElementRegion.TOP_LEFT, + ElementRegion.TOP_MIDDLE, + ElementRegion.TOP_RIGHT, + ElementRegion.MIDDLE_LEFT, + ElementRegion.MIDDLE_RIGHT, + ElementRegion.BOTTOM_LEFT, + ElementRegion.BOTTOM_MIDDLE, + ElementRegion.BOTTOM_RIGHT, +} + +BBOX_REGIONS: set[ElementRegion] = {ElementRegion.BODY} | RESIZE_HANDLES + +ROTATE_HANDLES: set[ElementRegion] = { + ElementRegion.ROTATE_TOP_LEFT, + ElementRegion.ROTATE_TOP_RIGHT, + ElementRegion.ROTATE_BOTTOM_LEFT, + ElementRegion.ROTATE_BOTTOM_RIGHT, +} + +SHEAR_HANDLES: set[ElementRegion] = { + ElementRegion.SHEAR_TOP, + ElementRegion.SHEAR_RIGHT, + ElementRegion.SHEAR_BOTTOM, + ElementRegion.SHEAR_LEFT, +} + +ROTATE_SHEAR_HANDLES: set[ElementRegion] = ROTATE_HANDLES | SHEAR_HANDLES + +MOVE_HANDLES: set[ElementRegion] = {ElementRegion.MOVE} + +LEFT_HANDLES: set[ElementRegion] = { + ElementRegion.TOP_LEFT, + ElementRegion.MIDDLE_LEFT, + ElementRegion.BOTTOM_LEFT, +} + +RIGHT_HANDLES: set[ElementRegion] = { + ElementRegion.TOP_RIGHT, + ElementRegion.MIDDLE_RIGHT, + ElementRegion.BOTTOM_RIGHT, +} + +TOP_HANDLES: set[ElementRegion] = { + ElementRegion.TOP_LEFT, + ElementRegion.TOP_MIDDLE, + ElementRegion.TOP_RIGHT, +} + +BOTTOM_HANDLES: set[ElementRegion] = { + ElementRegion.BOTTOM_LEFT, + ElementRegion.BOTTOM_MIDDLE, + ElementRegion.BOTTOM_RIGHT, +} + +CORNER_RESIZE_HANDLES: set[ElementRegion] = (TOP_HANDLES | BOTTOM_HANDLES) & ( + LEFT_HANDLES | RIGHT_HANDLES +) + +MIDDLE_RESIZE_HANDLES: set[ElementRegion] = ( + RESIZE_HANDLES - CORNER_RESIZE_HANDLES +) + + +def get_region_rect( + region: ElementRegion, + width: float, + height: float, + base_handle_size: float, + scale_compensation: float | tuple[float, float] = 1.0, +) -> Rect: + """ + A generic function to calculate the rectangle (x, y, w, h) for a given + region, relative to a bounding box of a given width and height. + + It compensates for scale to keep handle sizes visually consistent and + adapts to flipped coordinate systems by checking the sign of the + scale_compensation. + + Args: + region: The ElementRegion to calculate. + width: The width of the bounding box. + height: The height of the bounding box. + base_handle_size: The desired base size of the handles in pixels. + scale_compensation: The signed scale factor(s) of the context. + A negative y-scale indicates a flipped axis. + """ + w, h = width, height + + if isinstance(scale_compensation, tuple): + scale_x, scale_y = scale_compensation + else: + scale_x = scale_y = scale_compensation + + # Check for a flipped Y-axis BEFORE taking the absolute value. + is_flipped_y = scale_y < 0 + + # Use absolute scale for calculating handle *dimensions*. + abs_scale_x = abs(scale_x) + abs_scale_y = abs(scale_y) + + if abs_scale_x < 1e-6 or abs_scale_y < 1e-6: + return (0.0, 0.0, 0.0, 0.0) + + # Calculate local handle dimensions by dividing the desired + # visual size by the scale factors. + local_handle_w = base_handle_size / abs_scale_x + local_handle_h = base_handle_size / abs_scale_y + + # Dynamically calculate handle size to prevent overlap on small elements. + effective_hw = min(local_handle_w, w / 3.0) + effective_hh = min(local_handle_h, h / 3.0) + + # Use average scale for distance calculation of rotation handle + avg_abs_scale = (abs_scale_x + abs_scale_y) / 2.0 + handle_dist = 5.0 / avg_abs_scale # Visual distance for external handles + + # Conditionally calculate Y positions based on the axis orientation. + if is_flipped_y: + # In a flipped system (like WorkSurface), the visual "top" starts + # at y=h. + y_start_top = h - effective_hh + # And the visual "bottom" starts at y=0. + y_start_bottom = 0.0 + else: + # In a standard Y-down system, the visual "top" is at y=0. + y_start_top = 0.0 + # And the visual "bottom" is at y=h. + y_start_bottom = h - effective_hh + + # Side handles always start below the top corner handle's space. + y_start_middle = effective_hh + middle_height = h - 2.0 * effective_hh + middle_height = max(middle_height, 0) + + # Resize handles + if region == ElementRegion.TOP_LEFT: + return 0.0, y_start_top, effective_hw, effective_hh + if region == ElementRegion.TOP_RIGHT: + return w - effective_hw, y_start_top, effective_hw, effective_hh + if region == ElementRegion.BOTTOM_LEFT: + return 0.0, y_start_bottom, effective_hw, effective_hh + if region == ElementRegion.BOTTOM_RIGHT: + return w - effective_hw, y_start_bottom, effective_hw, effective_hh + + if region == ElementRegion.TOP_MIDDLE: + return effective_hw, y_start_top, w - 2.0 * effective_hw, effective_hh + if region == ElementRegion.BOTTOM_MIDDLE: + return ( + effective_hw, + y_start_bottom, + w - 2.0 * effective_hw, + effective_hh, + ) + if region == ElementRegion.MIDDLE_LEFT: + return 0.0, y_start_middle, effective_hw, middle_height + if region == ElementRegion.MIDDLE_RIGHT: + return w - effective_hw, y_start_middle, effective_hw, middle_height + + # Rotate/Shear handles (external) + if region == ElementRegion.ROTATE_TOP_LEFT: + rot_w = min((base_handle_size * 1.4) / abs_scale_x, w / 2.0) + rot_h = min((base_handle_size * 1.4) / abs_scale_y, h / 2.0) + center_y = h if is_flipped_y else 0.0 + return -rot_w / 2, center_y - rot_h / 2, rot_w, rot_h + if region == ElementRegion.ROTATE_TOP_RIGHT: + rot_w = min((base_handle_size * 1.4) / abs_scale_x, w / 2.0) + rot_h = min((base_handle_size * 1.4) / abs_scale_y, h / 2.0) + center_y = h if is_flipped_y else 0.0 + return w - rot_w / 2, center_y - rot_h / 2, rot_w, rot_h + if region == ElementRegion.ROTATE_BOTTOM_LEFT: + rot_w = min((base_handle_size * 1.4) / abs_scale_x, w / 2.0) + rot_h = min((base_handle_size * 1.4) / abs_scale_y, h / 2.0) + center_y = 0.0 if is_flipped_y else h + return -rot_w / 2, center_y - rot_h / 2, rot_w, rot_h + if region == ElementRegion.ROTATE_BOTTOM_RIGHT: + rot_w = min((base_handle_size * 1.4) / abs_scale_x, w / 2.0) + rot_h = min((base_handle_size * 1.4) / abs_scale_y, h / 2.0) + center_y = 0.0 if is_flipped_y else h + return w - rot_w / 2, center_y - rot_h / 2, rot_w, rot_h + + if region == ElementRegion.SHEAR_TOP: + y_pos = ( + h + handle_dist if is_flipped_y else -handle_dist - effective_hh + ) + return w / 2 - effective_hw / 2, y_pos, effective_hw, effective_hh + if region == ElementRegion.SHEAR_BOTTOM: + y_pos = ( + -effective_hh - handle_dist if is_flipped_y else h + handle_dist + ) + return w / 2 - effective_hw / 2, y_pos, effective_hw, effective_hh + if region == ElementRegion.SHEAR_LEFT: + y_pos = h / 2 - effective_hh / 2 + return -effective_hw - handle_dist, y_pos, effective_hw, effective_hh + if region == ElementRegion.SHEAR_RIGHT: + y_pos = h / 2 - effective_hh / 2 + return w + handle_dist, y_pos, effective_hw, effective_hh + # Move gizmo: centered horizontally below the bottom edge. + # It is ~95% larger than the standard resize handles for easier + # grabbing on workpieces with little geometry. + if region == ElementRegion.MOVE: + move_hw = effective_hw * 1.95 + move_hh = effective_hh * 1.95 + move_margin = 5.0 / avg_abs_scale + y_pos = ( + -move_hh - handle_dist - move_margin + if is_flipped_y + else h + handle_dist + move_margin + ) + x_pos = w / 2 - move_hw / 2 + return x_pos, y_pos, move_hw, move_hh + if region == ElementRegion.BODY: + return 0.0, 0.0, w, h + + return 0.0, 0.0, 0.0, 0.0 # For NONE or other cases + + +def check_region_hit( + local_x: float, + local_y: float, + width: float, + height: float, + base_handle_size: float, + scale_compensation: float | tuple[float, float] = 1.0, + candidates: set[ElementRegion] | None = None, +) -> ElementRegion: + """ + Checks which interactive region is hit by a point in LOCAL coordinates. + If `candidates` is provided, it will only check against regions in that + set. + + Args: + local_x: The x-coordinate in LOCAL coordinates. + local_y: The y-coordinate in LOCAL coordinates. + width: The width of the bounding box. + height: The height of the bounding box. + base_handle_size: The desired base size of the handles in pixels. + scale_compensation: The signed scale factor(s) of the context. + candidates: Optional set of regions to check against. + """ + # Determine which handle regions to check based on the candidates. + # The order of _HIT_TEST_ORDER is crucial to resolve overlap ambiguity. + regions_to_check = candidates if candidates is not None else BBOX_REGIONS + + for region in regions_to_check: + # Calculate the hit rectangle for the current region. This ensures + # the hit-test area matches the rendered handle size. + rx, ry, rw, rh = get_region_rect( + region, width, height, base_handle_size, scale_compensation + ) + if ( + rw > 0 + and rh > 0 + and rx <= local_x < rx + rw + and ry <= local_y < ry + rh + ): + return region + + # If no handle is hit, check the body if it's a candidate. + if ElementRegion.BODY in regions_to_check and ( + 0 <= local_x < width and 0 <= local_y < height + ): + return ElementRegion.BODY + + return ElementRegion.NONE diff --git a/rayforge/ui_gtk/canvas/shrinkwrap.py b/rayforge/ui_gtk/canvas/shrinkwrap.py new file mode 100644 index 000000000..bee6a4d79 --- /dev/null +++ b/rayforge/ui_gtk/canvas/shrinkwrap.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import math +from typing import cast + +import cairo +import numpy as np +from gi.repository import GLib +from raygeo.geo import Matrix + +from .element import CanvasElement + + +class ShrinkWrapGroup(CanvasElement): + """ + A generic group element that automatically calculates its bounding + box to tightly enclose all of its children ("shrink-wrap"). + + When its bounds are updated, it adjusts its own transformation matrix and + simultaneously calculates and applies compensating transforms to all its + children, so their world position, scale, and rotation remain unchanged. + """ + + def __init__(self, x: float = 0, y: float = 0, **kwargs): + super().__init__(x, y, 1, 1, clip=False, **kwargs) + self._bounds_update_scheduled: bool = False + + def _schedule_update_bounds(self): + """Schedules a deferred bounds update, debouncing multiple requests.""" + if self._bounds_update_scheduled: + return + self._bounds_update_scheduled = True + GLib.idle_add(self._do_update_bounds) + + def _do_update_bounds(self) -> bool: + """The idle callback that performs the actual update.""" + self._bounds_update_scheduled = False + self.update_bounds() + if self.canvas: + self.canvas.queue_draw() + return False # Do not call again + + def on_child_transform_changed(self, child: CanvasElement): + """Override to handle updates synchronously or via preview.""" + if child._is_under_interactive_transform: + # While dragging, do NOT update transforms. Just redraw so the + # overlay preview is shown. + if self.canvas: + self.canvas.queue_draw() + else: + # The interaction has just ended. Update the bounds *immediately* + # and synchronously. This ensures that when the WorkSurface's + # _on_transform_end handler runs moments later, the group's + # transform is already correct. + self.update_bounds() + if self.canvas: + self.canvas.queue_draw() + + def on_child_list_changed(self): + """ + When children are added/removed, schedule a deferred update for safety. + """ + self._schedule_update_bounds() + + def update_bounds(self): + """ + Calculates and applies the group's new transform and compensating + child transforms. This is only called when the scene is stable. + """ + if not self.children: + self.set_transform(Matrix.identity()) + return + + child_desired_world_transforms = { + child: child.get_world_transform() for child in self.children + } + + min_x, min_y = float("inf"), float("inf") + max_x, max_y = float("-inf"), float("-inf") + + for child, world_transform in child_desired_world_transforms.items(): + x, y, w, h = world_transform.transform_rectangle( + (0, 0, child.width, child.height) + ) + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x + w) + max_y = max(max_y, y + h) + + if not all(map(math.isfinite, [min_x, min_y, max_x, max_y])): + return + + new_world_w = max(max_x - min_x, 1e-9) + new_world_h = max(max_y - min_y, 1e-9) + + new_group_world_transform = Matrix.translation( + min_x, min_y + ) @ Matrix.scale(new_world_w, new_world_h) + + parent_world_transform = ( + cast(CanvasElement, self.parent).get_world_transform() + if isinstance(self.parent, CanvasElement) + else Matrix.identity() + ) + try: + new_group_local_transform = ( + parent_world_transform.invert() @ new_group_world_transform + ) + inv_new_group_world = new_group_world_transform.invert() + except np.linalg.LinAlgError: + return + + self._set_transform_silent(new_group_local_transform) + for child, desired_world in child_desired_world_transforms.items(): + new_child_local = inv_new_group_world @ desired_world + child._set_transform_silent(new_child_local) + + if isinstance(self.parent, CanvasElement): + self.parent.on_child_transform_changed(self) + + def draw_overlay(self, ctx: cairo.Context): + """ + Draws a live preview of the bounding box during a child transform. + """ + if not self.canvas or not any( + c._is_under_interactive_transform for c in self.children + ): + return + + # Calculate the current bounding box in world coordinates + min_x, min_y = float("inf"), float("inf") + max_x, max_y = float("-inf"), float("-inf") + for child in self.children: + x, y, w, h = child.get_world_bounding_box() + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x + w) + max_y = max(max_y, y + h) + + if not all(map(math.isfinite, [min_x, min_y, max_x, max_y])): + return + + # Transform the world-space AABB to pixel-space for drawing + points = [ + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y), + ] + pixel_points = [ + self.canvas.view_transform.transform_point(p) for p in points + ] + + # Draw the dashed preview frame + ctx.save() + ctx.set_source_rgba(0.5, 0.7, 1.0, 0.9) + ctx.set_line_width(1.0) + ctx.set_dash([4.0, 2.0]) + + p1_x, p1_y = pixel_points[0] + ctx.move_to(round(p1_x) + 0.5, round(p1_y) + 0.5) + for x, y in pixel_points[1:]: + ctx.line_to(round(x) + 0.5, round(y) + 0.5) + ctx.close_path() + ctx.stroke() + ctx.restore() + + def draw(self, ctx: cairo.Context): + """ + Draws a crisp, dashed bounding box for the group, but only when + it is NOT selected and NOT being interactively updated via a child. + """ + is_interacting = any( + c._is_under_interactive_transform for c in self.children + ) + + if self.selected or not self.canvas or is_interacting: + return + + # 1. Calculate the final screen coordinates of the group's corners. + transform_to_screen = ( + self.canvas.view_transform @ self.get_world_transform() + ) + if transform_to_screen.has_zero_scale(): + return + + # The group's local coordinate system is a unit square. + local_corners = [(0, 0), (1, 0), (1, 1), (0, 1)] + screen_corners = [ + transform_to_screen.transform_point(p) for p in local_corners + ] + + # 2. Reset the transformation matrix to draw directly in screen space. + ctx.save() + ctx.identity_matrix() + + # 3. Draw the path with pixel-perfect dimensions. + ctx.set_source_rgba(0.5, 0.7, 1.0, 0.9) + ctx.set_line_width(1.0) # 1 pixel + ctx.set_dash([4.0, 2.0]) # 4 pixels on, 2 pixels off + + # For crisp lines, it's best to draw on the half-pixel grid. + start_x, start_y = screen_corners[0] + ctx.move_to(round(start_x) + 0.5, round(start_y) + 0.5) + for x, y in screen_corners[1:]: + ctx.line_to(round(x) + 0.5, round(y) + 0.5) + + ctx.close_path() + ctx.stroke() + + # 4. Restore the original transformation matrix. + ctx.restore() diff --git a/rayforge/ui_gtk/canvas/transform.py b/rayforge/ui_gtk/canvas/transform.py new file mode 100644 index 000000000..c47e8d44f --- /dev/null +++ b/rayforge/ui_gtk/canvas/transform.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from raygeo.geo import Matrix + +from .element import CanvasElement +from .region import ElementRegion + +if TYPE_CHECKING: + from raygeo.geo.types import Point, Rect + +# This data structure defines the behavior for a standard Y-DOWN view. +RESIZE_BEHAVIORS = { + ElementRegion.TOP_LEFT: {"scale": (-1, -1), "anchor": (1.0, 1.0)}, + ElementRegion.TOP_MIDDLE: {"scale": (0, -1), "anchor": (0.5, 1.0)}, + ElementRegion.TOP_RIGHT: {"scale": (1, -1), "anchor": (0.0, 1.0)}, + ElementRegion.MIDDLE_LEFT: {"scale": (-1, 0), "anchor": (1.0, 0.5)}, + ElementRegion.MIDDLE_RIGHT: {"scale": (1, 0), "anchor": (0.0, 0.5)}, + ElementRegion.BOTTOM_LEFT: {"scale": (-1, 1), "anchor": (1.0, 0.0)}, + ElementRegion.BOTTOM_MIDDLE: {"scale": (0, 1), "anchor": (0.5, 0.0)}, + ElementRegion.BOTTOM_RIGHT: {"scale": (1, 1), "anchor": (0.0, 0.0)}, +} + + +def calculate_resized_box( + original_box: Rect, + active_region: ElementRegion, + drag_delta: Point, + is_flipped: bool, + constrain_aspect: bool = False, + from_center: bool = False, + min_size: tuple[float, float] = (0.0, 0.0), +) -> Rect: + """ + Calculates a new bounding box based on a resize operation. + This is the central, data-driven logic for all resizing, and it + now correctly handles flipped coordinate systems. + """ + if active_region not in RESIZE_BEHAVIORS: + return original_box + + orig_x, orig_y, orig_w, orig_h = original_box + delta_x, delta_y = drag_delta + min_w, min_h = min_size + + # 1. Get base behavior for a Y-down view + base_behavior = RESIZE_BEHAVIORS[active_region] + + # 2. Determine the effective geometric behavior based on view orientation + effective_scale = list(base_behavior["scale"]) + effective_anchor = list(base_behavior["anchor"]) + + if is_flipped: + # Invert the vertical scale's effect + effective_scale[1] *= -1 + # Invert the vertical anchor's position (top becomes bottom) + effective_anchor[1] = 1.0 - effective_anchor[1] + + if from_center: + effective_anchor = [0.5, 0.5] + + # 3. Calculate raw change in width and height (dw, dh) + dw = delta_x * effective_scale[0] + dh = delta_y * effective_scale[1] + + if from_center: + dw *= 2 + dh *= 2 + + # 4. Apply aspect ratio constraint + if constrain_aspect and orig_w > 0 and orig_h > 0: + aspect = orig_w / orig_h + is_corner = effective_scale[0] != 0 and effective_scale[1] != 0 + + if (is_corner and abs(dw) * aspect > abs(dh)) or ( + not is_corner and effective_scale[0] != 0 + ): + dh = dw / aspect + else: + dw = dh * aspect + + # 5. Calculate final size, enforcing minimums + new_w = max(orig_w + dw, min_w) + new_h = max(orig_h + dh, min_h) + + # 6. Calculate new origin based on the fixed anchor point + anchor_world_x = orig_x + effective_anchor[0] * orig_w + anchor_world_y = orig_y + effective_anchor[1] * orig_h + + new_x = anchor_world_x - effective_anchor[0] * new_w + new_y = anchor_world_y - effective_anchor[1] * new_h + + return new_x, new_y, new_w, new_h + + +def move_element( + element: CanvasElement, + world_dx: float, + world_dy: float, + initial_world_transform: Matrix, +): + """ + Calculates the new local transform for an element being moved. + + Args: + element: The element to move. + world_dx: The horizontal drag distance in world coordinates. + world_dy: The vertical drag distance in world coordinates. + initial_world_transform: The element's world transform at the + start of the drag. + """ + # Apply the drag translation to the initial world transform + new_world_transform = initial_world_transform.pre_translate( + world_dx, world_dy + ) + + # Convert back to the element's local space + parent_inv_world = Matrix.identity() + if isinstance(element.parent, CanvasElement): + parent_inv_world = element.parent.get_world_transform().invert() + + new_local_transform = parent_inv_world @ new_world_transform + + # Set the final transform, preserving all components + element.set_transform(new_local_transform) + + +def resize_element( + element: CanvasElement, + world_dx: float, + world_dy: float, + initial_local_transform: Matrix, + initial_world_transform: Matrix, + active_region: ElementRegion, + view_transform: Matrix, + shift_pressed: bool, + ctrl_pressed: bool, +): + """ + Calculates and applies the new local transform for a resizing element + by using the centralized `calculate_resized_box` logic. + """ + # 1. Convert world drag delta to the element's local, unrotated space. + initial_world_no_trans = initial_world_transform.without_translation() + inv_rot_scale = initial_world_no_trans.invert() + local_delta = inv_rot_scale.transform_vector((world_dx, world_dy)) + + # 2. Define minimum size in local units + min_size_world = 2.0 + world_scale_x, world_scale_y = initial_world_transform.get_abs_scale() + min_size_local = ( + min_size_world / world_scale_x if world_scale_x > 1e-6 else 0, + min_size_world / world_scale_y if world_scale_y > 1e-6 else 0, + ) + + # 3. Delegate calculation to the central function + original_box_local = (0, 0, element.width, element.height) + _, _, new_w, new_h = calculate_resized_box( + original_box=original_box_local, + active_region=active_region, + drag_delta=local_delta, + is_flipped=view_transform.is_flipped(), # Pass the flag + constrain_aspect=shift_pressed, + from_center=ctrl_pressed, + min_size=min_size_local, + ) + + # 4. Build the correct delta transform: a scale around the fixed + # anchor point. + orig_w, orig_h = element.width, element.height + base_behavior = RESIZE_BEHAVIORS[active_region] + + anchor_norm = list(base_behavior["anchor"]) + if view_transform.is_flipped(): + anchor_norm[1] = 1.0 - anchor_norm[1] + if ctrl_pressed: + anchor_norm = [0.5, 0.5] + + anchor_x = anchor_norm[0] * orig_w + anchor_y = anchor_norm[1] * orig_h + + scale_x = new_w / orig_w if orig_w > 0 else 1.0 + scale_y = new_h / orig_h if orig_h > 0 else 1.0 + + t_to_anchor = Matrix.translation(-anchor_x, -anchor_y) + m_scale = Matrix.scale(scale_x, scale_y) + t_from_anchor = Matrix.translation(anchor_x, anchor_y) + delta_transform_local = t_from_anchor @ m_scale @ t_to_anchor + + # 5. Apply the delta to the initial transform and set it + new_local_transform = initial_local_transform @ delta_transform_local + element.set_transform(new_local_transform) + + +def rotate_element( + element: CanvasElement, + world_x: float, + world_y: float, + initial_world_transform: Matrix, + rotation_pivot: Point, + drag_start_angle: float, +): + """ + Calculates the new local transform for a rotating element. + """ + # 1. Calculate the angle of the current mouse position around the pivot + current_angle = math.degrees( + math.atan2( + world_y - rotation_pivot[1], + world_x - rotation_pivot[0], + ) + ) + + # 2. Find the change in angle since the drag started. + angle_diff = current_angle - drag_start_angle + + # 3. Apply this delta to the element's initial world state. + new_world_transform = initial_world_transform.pre_rotate( + angle_diff, center=rotation_pivot + ) + + # 4. Convert the new world transform back to a local transform. + parent_inv_world = Matrix.identity() + if isinstance(element.parent, CanvasElement): + parent_inv_world = element.parent.get_world_transform().invert() + new_local_transform = parent_inv_world @ new_world_transform + + # 5. Set the new matrix directly. + element.set_transform(new_local_transform) + + +def shear_element( + element: CanvasElement, + world_dx: float, + world_dy: float, + initial_local_transform: Matrix, + initial_world_transform: Matrix, + active_region: ElementRegion, + view_transform: Matrix, +): + """Calculates the new local transform for a shearing element.""" + # Transform world delta into element's unrotated local space. + init_world_no_trans = initial_world_transform.without_translation() + inv_rot_scale = init_world_no_trans.invert() + local_dx, local_dy = inv_rot_scale.transform_vector((world_dx, world_dy)) + + is_view_flipped = view_transform.is_flipped() + + # If the view is flipped, the coordinate system of the local + # drag vector is inverted relative to the user's visual perception. + # We must flip the x-component back to match the visual drag direction. + if is_view_flipped: + local_dx = -local_dx + + w, h = element.width, element.height + shx, shy = 0.0, 0.0 + anchor_x, anchor_y = 0.0, 0.0 + + semantic_is_top = active_region == ElementRegion.SHEAR_TOP + semantic_is_bottom = active_region == ElementRegion.SHEAR_BOTTOM + semantic_is_left = active_region == ElementRegion.SHEAR_LEFT + semantic_is_right = active_region == ElementRegion.SHEAR_RIGHT + + if semantic_is_top or semantic_is_bottom: + geom_is_top_edge = ( + semantic_is_bottom if is_view_flipped else semantic_is_top + ) + anchor_y = h if geom_is_top_edge else 0 + anchor_x = w / 2 + + if semantic_is_top: + shx = -local_dx / h if h > 1e-6 else 0.0 + else: + shx = local_dx / h if h > 1e-6 else 0.0 + + elif semantic_is_left or semantic_is_right: + anchor_x = w if semantic_is_left else 0 + anchor_y = h / 2 + + if semantic_is_left: + shy = -local_dy / w if w > 1e-6 else 0.0 + else: + shy = local_dy / w if w > 1e-6 else 0.0 + + # Construct delta shear matrix around local anchor + t_to = Matrix.translation(-anchor_x, -anchor_y) + m_shear = Matrix.shear(shx, shy) + t_from = Matrix.translation(anchor_x, anchor_y) + delta_local = t_from @ m_shear @ t_to + + new_local_transform = initial_local_transform @ delta_local + element.set_transform(new_local_transform) diff --git a/rayforge/ui_gtk/canvas/worldsurface.py b/rayforge/ui_gtk/canvas/worldsurface.py new file mode 100644 index 000000000..b4383baa4 --- /dev/null +++ b/rayforge/ui_gtk/canvas/worldsurface.py @@ -0,0 +1,494 @@ +import logging + +from gi.repository import Gdk, Graphene, Gtk +from raygeo.geo import Matrix + +from .axis import AxisRenderer +from .canvas import Canvas + +logger = logging.getLogger(__name__) + + +class WorldSurface(Canvas): + """ + The WorldSurface provides a generic canvas with a real-world coordinate + system (in millimeters), a grid, axes, and interactive pan/zoom controls. + It is the base class for more specific surfaces like the WorkSurface. + """ + + # The minimum allowed zoom level, relative to the "fit-to-view" size + # (zoom=1.0). 0.1 means you can zoom out until the view is 10% of its + # "fit" size. + MIN_ZOOM_FACTOR = 0.1 + + # The maximum allowed pixel density when zooming in. + MAX_PIXELS_PER_MM = 100.0 + + def __init__( + self, + width_mm: float = 100.0, + height_mm: float = 100.0, + x_axis_right: bool = False, + y_axis_down: bool = False, + reverse_x_axis: bool = False, + reverse_y_axis: bool = False, + show_grid: bool = True, + show_axis: bool = True, + **kwargs, + ): + logger.debug("WorldSurface.__init__ called") + super().__init__(**kwargs) + self.grid_size = 1.0 # Set snap grid to 1mm in world coordinates + self.zoom_level = 1.0 + self.pan_x_mm = 0.0 + self.pan_y_mm = 0.0 + self._last_view_scale_x: float = 0.0 + self._last_view_scale_y: float = 0.0 + self.width_mm = width_mm + self.height_mm = height_mm + + # The root element is now static and sized in world units (mm). + self.root.set_size(self.width_mm, self.height_mm) + self.root.clip = False + + self._axis_renderer = AxisRenderer( + width_mm=self.width_mm, + height_mm=self.height_mm, + x_axis_right=x_axis_right, + y_axis_down=y_axis_down, + x_axis_negative=reverse_x_axis, + y_axis_negative=reverse_y_axis, + show_grid=show_grid, + show_axis=show_axis, + ) + self.root.background = 0.8, 0.8, 0.8, 0.1 + + # Set theme colors for axis and grid. + self._update_theme_colors() + + # Add scroll event controller for zoom + self._scroll_controller = Gtk.EventControllerScroll.new( + Gtk.EventControllerScrollFlags.VERTICAL + ) + self._scroll_controller.connect("scroll", self.on_scroll) + self.add_controller(self._scroll_controller) + + # Add middle click gesture for panning + self._pan_gesture = Gtk.GestureDrag.new() + self._pan_gesture.set_button(Gdk.BUTTON_MIDDLE) + self._pan_gesture.connect("drag-begin", self.on_pan_begin) + self._pan_gesture.connect("drag-update", self.on_pan_update) + self._pan_gesture.connect("drag-end", self.on_pan_end) + self.add_controller(self._pan_gesture) + self._pan_start = (0.0, 0.0) + + # Track Space key for Space+drag panning + self._space_pressed = False + + # Add right-click gesture for context menu + self._context_menu_gesture = Gtk.GestureClick.new() + self._context_menu_gesture.set_button(Gdk.BUTTON_SECONDARY) + self._context_menu_gesture.connect( + "pressed", self.on_right_click_pressed + ) + self.add_controller(self._context_menu_gesture) + + # This is hacky, but what to do: The EventControllerScroll provides + # no access to any mouse position, and there is no easy way to + # get the mouse position in Gtk4. So I have to store it here and + # track the motion event... + self._mouse_pos = (0.0, 0.0) + + def set_show_grid(self, show: bool): + """Sets the visibility of the inner grid lines.""" + self._axis_renderer.show_grid = show + self.queue_draw() + + def set_show_axis(self, show: bool): + """Sets the visibility of the outer axis lines and labels.""" + self._axis_renderer.show_axis = show + self.queue_draw() + + def on_right_click_pressed( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ) -> None: + """ + Placeholder for handling right-clicks. Subclasses should override this + to implement context menu logic. + """ + + def _update_theme_colors(self) -> None: + """ + Reads the current theme colors from the widget's style context + and applies them to the AxisRenderer. + """ + # Get the foreground color for axes and labels + fg_rgba = self.get_color() + self._axis_renderer.set_fg_color( + (fg_rgba.red, fg_rgba.green, fg_rgba.blue, fg_rgba.alpha) + ) + + # Set the separator color for the grid lines + self._axis_renderer.set_grid_color( + ( + fg_rgba.red, + fg_rgba.green, + fg_rgba.blue, + fg_rgba.alpha * 0.3, + ) + ) + + def set_pan(self, pan_x_mm: float, pan_y_mm: float) -> None: + """Sets the pan position in mm and updates the axis importer.""" + self.pan_x_mm = pan_x_mm + self.pan_y_mm = pan_y_mm + self._rebuild_view_transform() + self.queue_draw() + + def set_zoom(self, zoom_level: float) -> None: + """ + Sets the zoom level and updates the axis importer. + The caller is responsible for ensuring the zoom_level is clamped. + """ + self.zoom_level = zoom_level + self._rebuild_view_transform() + self.queue_draw() + + def set_size(self, width_mm: float, height_mm: float) -> None: + """ + Sets the real-world size of the work surface in mm + and updates related properties. + """ + self.width_mm = width_mm + self.height_mm = height_mm + self.root.set_size(width_mm, height_mm) + self._axis_renderer.set_width_mm(self.width_mm) + self._axis_renderer.set_height_mm(self.height_mm) + self._rebuild_view_transform() + self.queue_draw() + + def get_size_mm(self) -> tuple[float, float]: + """Returns the size of the work surface in mm.""" + return self.width_mm, self.height_mm + + def get_view_scale(self) -> tuple[float, float]: + """ + Returns the current effective pixels-per-millimeter scale of the view, + taking into account the base scale, zoom, and widget size. + """ + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w <= 0 or widget_h <= 0: + return 1.0, 1.0 + + _, _, content_w, content_h = self._axis_renderer.get_content_layout( + widget_w, widget_h + ) + + effective_height = self._axis_renderer.get_effective_height() + base_scale_x = content_w / self.width_mm if self.width_mm > 0 else 1 + base_scale_y = ( + content_h / effective_height if effective_height > 0 else 1 + ) + + return base_scale_x * self.zoom_level, base_scale_y * self.zoom_level + + def on_motion(self, gesture: Gtk.Gesture, x: float, y: float) -> None: + self._mouse_pos = x, y + + # Let the base canvas handle hover updates and cursor changes. + super().on_motion(gesture, x, y) + + def on_scroll( + self, controller: Gtk.EventControllerScroll, dx: float, dy: float + ) -> None: + """Handles the scroll event for zoom.""" + logger.debug(f"Scroll event: dx={dx:.2f}, dy={dy:.2f}") + zoom_speed = 0.1 + # 1. Calculate a desired new zoom level based on scroll direction + desired_zoom = self.zoom_level * ( + (1 - zoom_speed) if dy > 0 else (1 + zoom_speed) + ) + # 2. Get the base "fit-to-view" pixel density (for zoom = 1.0) + base_ppm = self._axis_renderer.get_base_pixels_per_mm( + self.get_width(), self.get_height() + ) + if base_ppm <= 0: + return + # 3. Calculate the pixel density limits + min_ppm = base_ppm * self.MIN_ZOOM_FACTOR + max_ppm = self.MAX_PIXELS_PER_MM + + # 4. Calculate the target density and clamp it within our limits + clamped_ppm = max(min_ppm, min(base_ppm * desired_zoom, max_ppm)) + # 5. Convert the valid, clamped density back into a final zoom level + final_zoom = clamped_ppm / base_ppm + if abs(final_zoom - self.zoom_level) < 1e-9: + return + + # 6. Calculate pan adjustment to zoom around the mouse cursor + mouse_x_px, mouse_y_px = self._mouse_pos + focus_x_mm, focus_y_mm = self._get_world_coords(mouse_x_px, mouse_y_px) + self.set_zoom(final_zoom) + new_mouse_x_mm, new_mouse_y_mm = self._get_world_coords( + mouse_x_px, mouse_y_px + ) + new_pan_x_mm = self.pan_x_mm + (focus_x_mm - new_mouse_x_mm) + new_pan_y_mm = self.pan_y_mm + (focus_y_mm - new_mouse_y_mm) + self.set_pan(new_pan_x_mm, new_pan_y_mm) + + def do_size_allocate(self, width: int, height: int, baseline: int) -> None: + # Let the parent Canvas/Gtk.DrawingArea do its work first. This will + # call self.root.set_size() with pixel dimensions, which we will + # immediately correct. + super().do_size_allocate(width, height, baseline) + + # Enforce the correct world (mm) dimensions on the root + # element, overriding the pixel-based sizing from the parent class. + if ( + self.root.width != self.width_mm + or self.root.height != self.height_mm + ): + self.root.set_size(self.width_mm, self.height_mm) + + # Rebuild the view transform, which depends on the widget's new pixel + # dimensions to calculate the correct pan/zoom/scale matrix. + self._rebuild_view_transform() + + def _rebuild_view_transform(self) -> bool: + """ + Constructs the world-to-view transformation matrix. + Returns True if the view scale has changed. + """ + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w <= 0 or widget_h <= 0: + return False + + content_x, content_y, content_w, content_h = ( + self._axis_renderer.get_content_layout(widget_w, widget_h) + ) + + # Base scale to map mm to the unzoomed content area pixels + # Use effective height to handle rotary mode correctly + effective_height = self._axis_renderer.get_effective_height() + scale_x = content_w / self.width_mm if self.width_mm > 0 else 1 + scale_y = content_h / effective_height if effective_height > 0 else 1 + + # The sequence of transformations is critical and is applied + # from right-to-left (bottom to top in this code). + + # 5. Final Offset: Translate the transformed content to its + # final position within the widget. + m_offset = Matrix.translation(content_x, content_y) + + # 4. Zoom: Scale the content around its top-left corner (0,0). + m_zoom = Matrix.scale(self.zoom_level, self.zoom_level) + + # 3. Y-Axis and Pan transformation + # We combine pan and the y-flip into one matrix. This ensures panning + # feels correct regardless of the axis orientation. + pan_transform = Matrix.translation(-self.pan_x_mm, -self.pan_y_mm) + + # The world is ALWAYS Y-up. The view is ALWAYS Y-down. + # Therefore, we ALWAYS need to flip the Y-axis. This matrix scales + # the world to pixels and flips it into the view's coordinate system. + m_scale = Matrix.translation(0, content_h) @ Matrix.scale( + scale_x, -scale_y + ) + + # Compose final matrix (read operations from bottom to top): + # Transformation order: + # Pan the world + # -> Scale&Flip it + # -> Zoom it + # -> Offset to final position. + final_transform = m_offset @ m_zoom @ m_scale @ pan_transform + + # Update the base Canvas's view_transform + self.view_transform = final_transform + + # Check if the effective scale (pixels-per-mm) has changed. Panning + # does not change the scale, but zooming and resizing the window do. + # This prevents expensive re-rendering of buffered elements during + # panning. + new_scale_x, new_scale_y = self.get_view_scale() + scale_changed = ( + abs(new_scale_x - self._last_view_scale_x) > 1e-9 + or abs(new_scale_y - self._last_view_scale_y) > 1e-9 + ) + + if scale_changed: + self._last_view_scale_x = new_scale_x + self._last_view_scale_y = new_scale_y + + return scale_changed + + def reset_view(self) -> None: + """ + Resets the view to fit the surface, including a + full reset of pan and zoom. + """ + logger.debug("Resetting WorldSurface view.") + self.set_pan(0.0, 0.0) + self.set_zoom(1.0) + self._rebuild_view_transform() + self.queue_draw() + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + # Update theme colors right before drawing to catch any live changes. + self._update_theme_colors() + + # Create a Cairo context for the snapshot + width, height = self.get_width(), self.get_height() + ctx = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) + + # Draw grid and axes first, in pixel space, before any transformations. + self._axis_renderer.draw_grid_and_labels( + ctx, self.view_transform, width, height + ) + + # Now, delegate to the base Canvas's snapshot implementation, which + # will correctly apply the view_transform and render all elements + # and selection handles. + super().do_snapshot(snapshot) + + def on_key_pressed( + self, + controller: Gtk.EventControllerKey, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> bool: + """Handles key press events for the work surface.""" + key_name = Gdk.keyval_name(keyval) + logger.debug(f"Key pressed: key='{key_name}', state={state}") + if keyval in (Gdk.KEY_space, Gdk.KEY_KP_Space): + self._space_pressed = True + return True + if keyval == Gdk.KEY_1: + # Reset pan and zoom with '1' + self.reset_view() + return True # Event handled + + # Propagate to parent Canvas for its default behavior. The base Canvas + # handles leaving edit mode on Escape. + return super().on_key_pressed(controller, keyval, keycode, state) + + def on_key_released( + self, + controller: Gtk.EventControllerKey, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> None: + """Handles key release events for the work surface.""" + if keyval in (Gdk.KEY_space, Gdk.KEY_KP_Space): + self._space_pressed = False + return + super().on_key_released(controller, keyval, keycode, state) + + def on_button_press( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ) -> None: + """Override to suppress element selection when Space is held.""" + if self._space_pressed: + self.grab_focus() + self._pan_start = (self.pan_x_mm, self.pan_y_mm) + return + super().on_button_press(gesture, n_press, x, y) + + def on_click_released( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ) -> None: + """Override to suppress click actions when Space is held.""" + if self._space_pressed: + return + super().on_click_released(gesture, n_press, x, y) + + def on_mouse_drag( + self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float + ) -> None: + """Override to pan instead of selecting when Space is held.""" + if self._space_pressed: + ok, drag_offset_x, drag_offset_y = gesture.get_offset() + if not ok: + return + + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w <= 0 or widget_h <= 0: + return + + _, _, content_w, content_h = ( + self._axis_renderer.get_content_layout(widget_w, widget_h) + ) + + base_scale_x = ( + content_w / self.width_mm if self.width_mm > 0 else 1 + ) + base_scale_y = ( + content_h / self.height_mm if self.height_mm > 0 else 1 + ) + + delta_x_mm = drag_offset_x / (base_scale_x * self.zoom_level) + delta_y_mm = drag_offset_y / (base_scale_y * self.zoom_level) + + new_pan_x = self._pan_start[0] - delta_x_mm + new_pan_y = self._pan_start[1] + delta_y_mm + + self.set_pan(new_pan_x, new_pan_y) + return + super().on_mouse_drag(gesture, offset_x, offset_y) + + def on_drag_end( + self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float + ) -> None: + """Override to suppress drag end when Space was held.""" + if self._space_pressed: + return + super().on_drag_end(gesture, offset_x, offset_y) + + def on_pan_begin( + self, gesture: Gtk.GestureDrag, x: float, y: float + ) -> None: + logger.debug(f"Pan begin at ({x:.2f}, {y:.2f})") + self._pan_start = (self.pan_x_mm, self.pan_y_mm) + + def on_pan_update( + self, gesture: Gtk.GestureDrag, x: float, y: float + ) -> None: + # Gtk.GestureDrag.get_offset returns a boolean and populates the + # provided variables. + ok, offset_x, offset_y = gesture.get_offset() + if not ok: + return + + logger.debug(f"Pan update: offset=({offset_x:.2f}, {offset_y:.2f})") + + # We need to convert the pixel offset into a mm delta. This delta + # is independent of the pan, so we can calculate it from the scale. + widget_w, widget_h = self.get_width(), self.get_height() + if widget_w <= 0 or widget_h <= 0: + return + + _, _, content_w, content_h = self._axis_renderer.get_content_layout( + widget_w, widget_h + ) + + base_scale_x = content_w / self.width_mm if self.width_mm > 0 else 1 + base_scale_y = content_h / self.height_mm if self.height_mm > 0 else 1 + + delta_x_mm = offset_x / (base_scale_x * self.zoom_level) + delta_y_mm = offset_y / (base_scale_y * self.zoom_level) + + # The world-to-view transform is always Y-inverting. To make the + # content follow the mouse ("natural" panning), the logic must be + # consistent. A rightward drag (positive offset_x) requires a + # negative adjustment to pan_x. A downward drag (positive offset_y) + # requires a positive adjustment to pan_y because of the Y-inversion + # in the transform matrix. + new_pan_x = self._pan_start[0] - delta_x_mm + new_pan_y = self._pan_start[1] + delta_y_mm + + self.set_pan(new_pan_x, new_pan_y) + + def on_pan_end(self, gesture: Gtk.GestureDrag, x: float, y: float) -> None: + logger.debug(f"Pan end at ({x:.2f}, {y:.2f})") diff --git a/rayforge/ui_gtk/canvas2d/__init__.py b/rayforge/ui_gtk/canvas2d/__init__.py new file mode 100644 index 000000000..a1847c4f1 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/__init__.py @@ -0,0 +1,7 @@ +from .elements.dot import DotElement +from .surface import WorkSurface + +__all__ = [ + "DotElement", + "WorkSurface", +] diff --git a/rayforge/ui_gtk/canvas2d/context_menu.py b/rayforge/ui_gtk/canvas2d/context_menu.py new file mode 100644 index 000000000..d8793c324 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/context_menu.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Optional + +from gi.repository import Gio, Gtk + +if TYPE_CHECKING: + from ...core.item import DocItem + from .surface import WorkSurface + +logger = logging.getLogger(__name__) + +ContextMenuHandler = Callable[ + ["WorkSurface", Optional["DocItem"], Gtk.Gesture, Gio.Menu], None +] + + +class ContextMenuExtensionRegistry: + """ + Registry for context menu extension handlers. + + Handlers are called when a context menu is about to be shown, + allowing addons to add custom menu items. + """ + + def __init__(self): + self._handlers: list[ContextMenuHandler] = [] + self._addon_map: dict[str, str] = {} + + def register(self, handler: ContextMenuHandler, addon_name: str): + """Register a context menu extension handler.""" + self._handlers.append(handler) + if addon_name: + self._addon_map[handler.__name__] = addon_name + logger.debug(f"Registered context menu handler: {handler.__name__}") + + def unregister(self, handler: ContextMenuHandler) -> bool: + """Unregister a context menu extension handler.""" + try: + self._handlers.remove(handler) + self._addon_map.pop(handler.__name__, None) + return True + except ValueError: + return False + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all handlers registered by a specific addon. + + Args: + addon_name: The name of the addon to clean up + + Returns: + The number of handlers unregistered + """ + to_remove = [ + h + for h in self._handlers + if self._addon_map.get(h.__name__) == addon_name + ] + for h in to_remove: + self._handlers.remove(h) + self._addon_map.pop(h.__name__, None) + if to_remove: + logger.debug( + f"Unregistered {len(to_remove)} context menu handlers " + f"from addon '{addon_name}'" + ) + return len(to_remove) + + def invoke_all( + self, + surface: WorkSurface, + item: DocItem | None, + gesture: Gtk.Gesture, + menu: Gio.Menu, + ): + """Invoke all registered handlers.""" + for handler in self._handlers: + try: + handler(surface, item, gesture, menu) + except Exception: + logger.exception( + f"Error in context menu handler {handler.__name__}" + ) + + +context_menu_extension_registry = ContextMenuExtensionRegistry() + + +def _populate_standard_items(menu: Gio.Menu): + """ + Helper to append standard items to a menu using flat structure with + separators. + """ + menu.append_item( + Gio.MenuItem.new(_("Move Up a Layer"), "win.layer-move-up") + ) + menu.append_item( + Gio.MenuItem.new(_("Move Down a Layer"), "win.layer-move-down") + ) + + # Separator + menu.append_section(None, Gio.Menu.new()) + + menu.append_item(Gio.MenuItem.new(_("Group"), "win.group")) + menu.append_item(Gio.MenuItem.new(_("Ungroup"), "win.ungroup")) + + # Separator + menu.append_section(None, Gio.Menu.new()) + + menu.append_item( + Gio.MenuItem.new(_("Convert to Stock"), "win.convert-to-stock") + ) + + # Separator + menu.append_section(None, Gio.Menu.new()) + + menu.append_item(Gio.MenuItem.new(_("Remove"), "win.remove")) + + +def _create_item_context_menu() -> Gio.Menu: + """Builds the standard context menu for DocItems.""" + menu = Gio.Menu.new() + _populate_standard_items(menu) + return menu + + +def _create_geometry_context_menu() -> Gio.Menu: + """Builds the context menu for interacting with a workpiece's path.""" + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("Add Tab Here"), "win.tab-add")) + return menu + + +def _create_tab_context_menu() -> Gio.Menu: + """Builds the context menu for an existing tab handle.""" + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("Remove Tab"), "win.tab-remove")) + return menu + + +# Pre-build and cache the menu models once when the module is loaded. +_MENU_MODELS = { + "item": _create_item_context_menu(), + "geometry": _create_geometry_context_menu(), + "tab": _create_tab_context_menu(), +} + + +def _show_popover( + surface: WorkSurface, gesture: Gtk.Gesture, menu_model: Gio.Menu +): + """Helper to create and show a popover menu from a model.""" + popover = Gtk.PopoverMenu.new_from_model(menu_model) + popover.set_parent(surface) + popover.set_has_arrow(False) + + # Position usually defaults to bottom/right, rely on set_pointing_to for + # exact placement. + popover.set_position(Gtk.PositionType.RIGHT) + + ok, rect = gesture.get_bounding_box() + if ok: + popover.set_pointing_to(rect) + + popover.popup() + + +def show_item_context_menu( + surface: WorkSurface, + gesture: Gtk.Gesture, + item: DocItem | None = None, +): + """ + Displays the context menu for general items like WorkPieces or Groups. + + Emits the context_menu_requested signal and invokes registered extension + handlers to allow addons to add custom menu items. The item parameter is + passed to handlers so they can determine if the menu should be extended. + """ + menu = Gio.Menu.new() + _populate_standard_items(menu) + + # Invoke registered extension handlers + context_menu_extension_registry.invoke_all(surface, item, gesture, menu) + + # Also emit signal for direct connections + surface.context_menu_requested.send( + surface, item=item, gesture=gesture, menu=menu + ) + + _show_popover(surface, gesture, menu) + + +def show_geometry_context_menu(surface: WorkSurface, gesture: Gtk.Gesture): + """Displays the context menu for adding a tab to a geometry path.""" + _show_popover(surface, gesture, _MENU_MODELS["geometry"]) + + +def show_tab_context_menu(surface: WorkSurface, gesture: Gtk.Gesture): + """Displays the context menu for an existing tab.""" + _show_popover(surface, gesture, _MENU_MODELS["tab"]) + + +def show_background_context_menu(surface: WorkSurface, gesture: Gtk.Gesture): + """Displays the context menu for empty canvas space.""" + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("New Sketch"), "win.new_sketch")) + menu.append_item(Gio.MenuItem.new(_("New Stock"), "win.add-stock")) + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Import File\u2026"), "win.import")) + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Paste"), "win.paste")) + _show_popover(surface, gesture, menu) diff --git a/rayforge/ui_gtk/canvas2d/drag_drop_cmd.py b/rayforge/ui_gtk/canvas2d/drag_drop_cmd.py new file mode 100644 index 000000000..c1833a010 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/drag_drop_cmd.py @@ -0,0 +1,633 @@ +""" +Command module for handling drag-and-drop and clipboard paste operations. + +This module encapsulates all drag-and-drop import functionality and clipboard +paste operations, keeping them separate from the core UI components. +""" + +import json +import logging +import tempfile +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gdk, Gio, GLib, Gtk +from raygeo.geo import Matrix + +from ...context import get_context +from ...core.layer import Layer +from ...core.source_asset import SourceAsset +from ...core.stock import StockItem +from ...core.stock_asset import StockAsset +from ...core.undo import ListItemCommand +from ...core.vectorization_spec import PassthroughSpec +from ...core.workpiece import WorkPiece +from ...doceditor.file_cmd import ImportAction +from ...image import ImporterFeature +from ...image.registry import importer_registry +from ..doceditor import import_handler + +if TYPE_CHECKING: + from ...ui_gtk.mainwindow import MainWindow + from .surface import WorkSurface + +logger = logging.getLogger(__name__) + + +class DragDropCmd: + """Handles drag-and-drop file imports and clipboard paste operations.""" + + def __init__(self, main_window: "MainWindow", surface: "WorkSurface"): + """ + Initialize the drag-drop command handler. + + Args: + main_window: The main application window + surface: The WorkSurface canvas widget + """ + self.main_window = main_window + self.surface = surface + self._drop_overlay_label: Gtk.Label | None = None + + self._drop_target: Gtk.DropTarget | None = None + + self._apply_drop_overlay_css() + + def _apply_drop_overlay_css(self): + """Apply CSS styling for the drop overlay.""" + display = Gdk.Display.get_default() + + # CSS for drop overlay + drop_overlay_css = """ + .drop-overlay { + font-size: 24px; + font-weight: bold; + color: white; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 12px; + padding: 24px 48px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + """ + + if display: + provider = Gtk.CssProvider() + provider.load_from_string(drop_overlay_css) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def setup_drop_targets(self): + """ + Configure the canvas to accept file drops for importing. + Supports local files and file lists, as well as internal asset + UIDs (Strings) for generic asset drops. + + Uses a single DropTarget with multiple GTypes. Gio.File is + listed first so that external file drags (which may offer both + text/uri-list and Gio.File) are always received as file objects + rather than strings. + """ + self._drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY) + self._drop_target.set_gtypes([Gio.File, Gdk.FileList, str]) + self._drop_target.connect("drop", self._on_drop) + self._drop_target.connect("enter", self._on_drag_enter) + self._drop_target.connect("leave", self._on_drag_leave) + self.surface.add_controller(self._drop_target) + + logger.debug("Unified drop target configured for WorkSurface") + + # --- Drop Handlers --- + + def _on_drag_enter(self, drop_target, x, y): + drop = drop_target.get_current_drop() + if drop: + formats = drop.get_formats() + if formats.contain_gtype(Gio.File) or formats.contain_gtype( + Gdk.FileList + ): + self._show_drop_overlay() + else: + logger.debug("Asset drag entered surface") + return Gdk.DragAction.COPY + + def _on_drop(self, drop_target, value, x, y): + self._hide_drop_overlay() + + world_x_mm, world_y_mm = self.surface._get_world_coords(x, y) + + if isinstance(value, str): + logger.debug(f"Asset drop event: value={value}") + return self._handle_asset_drop(value, (world_x_mm, world_y_mm)) + + logger.debug(f"File drop event: type={type(value)}") + files = self._extract_files_from_drop_value(value) + if files: + logger.info( + f"Processing file drop at world coords " + f"({world_x_mm:.2f}, {world_y_mm:.2f}) mm" + ) + file_infos = self._get_file_infos(files) + self._import_dropped_files(file_infos, (world_x_mm, world_y_mm)) + return bool(file_infos) + + return False + + def _handle_asset_drop( + self, data: str, position_mm: tuple[float, float] + ) -> bool: + try: + uids = json.loads(data) + if not isinstance(uids, list): + uids = [uids] + except json.JSONDecodeError: + uids = [data] + + success = False + offset_x = 0.0 + offset_y = 0.0 + spacing = 10.0 + + for i, asset_uid in enumerate(uids): + doc = self.main_window.doc_editor.doc + asset = doc.get_asset_by_uid(asset_uid) + if asset is None: + logger.warning(f"Dropped asset UID {asset_uid} not found") + continue + if not asset.is_draggable_to_canvas: + continue + + if i > 0: + if isinstance(asset, StockAsset): + w, _h = asset.get_natural_size() + offset_x += w + spacing + else: + offset_x += 50.0 + spacing + + pos = (position_mm[0] + offset_x, position_mm[1] + offset_y) + + if isinstance(asset, StockAsset): + new_item = self._create_stock_item_instance(asset_uid, pos) + elif isinstance(asset, SourceAsset): + new_item = self._create_source_workpiece_instance( + asset_uid, pos + ) + else: + edit = self.main_window.doc_editor.edit + new_item = edit.add_geometry_provider_instance(asset_uid, pos) + if new_item: + logger.info( + f"Created instance {new_item.uid[:8]} " + f"from asset {asset_uid[:8]} at {pos}" + ) + success = True + + return success + + def _create_stock_item_instance( + self, asset_uid: str, position_mm: tuple[float, float] + ): + """ + Creates a new StockItem instance from a StockAsset. + + Args: + asset_uid: The UID of the StockAsset to instantiate + position_mm: The (x, y) position in mm where to place the instance + + Returns: + The newly created StockItem instance + """ + doc = self.main_window.doc_editor.doc + history = doc.history_manager + + asset = doc.get_asset_by_uid(asset_uid) + if not asset or not isinstance(asset, StockAsset): + raise ValueError(f"StockAsset with UID {asset_uid} not found.") + + stock_item = StockItem(stock_asset_uid=asset_uid, name=asset.name) + w, h = asset.get_natural_size() + stock_item.matrix = Matrix.scale(w, h) + stock_item.pos = ( + position_mm[0] - w / 2, + position_mm[1] - h / 2, + ) + + with history.transaction(_("Add {} Instance").format(asset.name)) as t: + command = ListItemCommand( + owner_obj=doc, + item=stock_item, + undo_command="remove_child", + redo_command="add_child", + name=_("Add {} Instance").format(asset.name), + ) + t.execute(command) + + return stock_item + + def _create_source_workpiece_instance( + self, asset_uid: str, position_mm: tuple[float, float] + ): + """ + Creates a new WorkPiece instance from a SourceAsset by re-running + the import pipeline with the existing asset. + + If the asset supports interactive configuration, opens the import + dialog so the user can adjust settings. Otherwise reimports + directly with the last-used spec (or a sensible default). + """ + doc = self.main_window.doc_editor.doc + asset = doc.get_asset_by_uid(asset_uid) + if not asset or not isinstance(asset, SourceAsset): + return None + + meta = asset.metadata + importer_cls_name = meta.get("_importer_class") + if not importer_cls_name: + logger.warning( + "Cannot reimport: SourceAsset has no _importer_class metadata" + ) + return None + importer_cls = importer_registry.get_by_name(importer_cls_name) + if not importer_cls: + return None + + features = importer_cls.features + needs_dialog = ( + ImporterFeature.BITMAP_TRACING in features + or ImporterFeature.LAYER_SELECTION in features + ) + + if needs_dialog: + import_handler.start_reimport( + self.main_window, + self.main_window.doc_editor, + asset, + position_mm, + ) + return None + else: + editor = self.main_window.doc_editor + result = editor.file.reimport_from_source_asset( + asset, PassthroughSpec(), position_mm + ) + if result and result.payload and result.payload.items: + return self._extract_first_workpiece(result.payload.items) + return None + + @staticmethod + def _extract_first_workpiece(items): + for item in items: + if isinstance(item, WorkPiece): + return item + if isinstance(item, Layer): + result = DragDropCmd._extract_first_workpiece(item.children) + if result: + return result + return None + + # --- Overlay & Helper Methods --- + + def _on_drag_leave(self, drop_target): + if self._drop_overlay_label: + logger.debug("Drag leave signal received, scheduling delayed hide") + GLib.timeout_add(100, self._delayed_hide_overlay) + + def _show_drop_overlay(self): + """Display 'Drop files to import' overlay on canvas.""" + if self._drop_overlay_label: + return # Already showing + + # Create overlay label with styling + self._drop_overlay_label = Gtk.Label(label=_("Drop files to import")) + self._drop_overlay_label.add_css_class("drop-overlay") + self._drop_overlay_label.set_halign(Gtk.Align.CENTER) + self._drop_overlay_label.set_valign(Gtk.Align.CENTER) + + # Make it semi-transparent + self._drop_overlay_label.set_opacity(0.9) + self._drop_overlay_label.set_can_target(False) + + # Find the parent overlay (surface_overlay from MainWindow) + overlay_parent = self._find_parent_overlay() + if overlay_parent: + overlay_parent.add_overlay(self._drop_overlay_label) + logger.debug("Drop overlay added to parent Gtk.Overlay") + else: + logger.warning("Could not find parent overlay for drop message") + + def _delayed_hide_overlay(self) -> bool: + """ + Hide overlay after a delay. Returns False to not repeat the timeout. + """ + self._hide_drop_overlay() + logger.debug("Delayed hide executed, overlay removed") + return False # Don't repeat + + def _hide_drop_overlay(self): + """Remove the drop overlay from canvas. Safe to call multiple times.""" + if not self._drop_overlay_label: + return # Already removed or never created + + try: + overlay_parent = self._find_parent_overlay() + if overlay_parent: + overlay_parent.remove_overlay(self._drop_overlay_label) + self._drop_overlay_label = None + logger.debug("Drop overlay removed") + except GLib.Error as e: + logger.warning(f"Error removing drop overlay: {e}") + self._drop_overlay_label = None # Clear reference anyway + + def _find_parent_overlay(self): + """Find the Gtk.Overlay parent that contains this canvas.""" + widget = self.surface.get_parent() + while widget: + if isinstance(widget, Gtk.Overlay): + return widget + widget = widget.get_parent() + return None + + def _extract_files_from_drop_value(self, value) -> list[Gio.File]: + """Extract file list from drop value.""" + files = [] + if isinstance(value, Gdk.FileList): + files = value.get_files() + elif isinstance(value, Gio.File): + files = [value] + else: + logger.warning(f"Unexpected drop value type: {type(value)}") + return [] + + if not files: + logger.warning("No files in drop") + return [] + + return files + + def _get_file_infos(self, files: list[Gio.File]) -> list[tuple[Path, str]]: + """Get file path and MIME type information for dropped files.""" + editor = self.main_window.doc_editor + file_infos = [] + for gfile in files: + path_str = gfile.get_path() + if not path_str: + logger.warning("File has no path, skipping") + continue + + file_path = Path(path_str) + try: + file_info = gfile.query_info( + Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, + Gio.FileQueryInfoFlags.NONE, + None, + ) + mime_type = file_info.get_content_type() + except GLib.Error as e: + logger.warning( + f"Could not query file info for {file_path}: {e}" + ) + continue + + # Check if we support this file by asking the backend. + importer_cls, _ = editor.file.get_importer_info( + file_path, mime_type + ) + if not importer_cls: + logger.warning( + f"Unsupported file type: {mime_type} for {file_path}" + ) + continue + + file_infos.append((file_path, mime_type)) + + return file_infos + + def _import_dropped_files( + self, + file_infos: list[tuple[Path, str]], + position_mm: tuple[float, float], + ): + """ + Import dropped files, routing them to individual or batch import + handlers based on their capabilities. + + Args: + file_infos: List of (file_path, mime_type) tuples + position_mm: (x, y) tuple in world coordinates + """ + editor = self.main_window.doc_editor + files_for_batch_import: list[tuple[Path, str]] = [] + + for file_path, mime_type in file_infos: + action = editor.file.analyze_import_target(file_path, mime_type) + + if action == ImportAction.INTERACTIVE_CONFIG: + # These files need their own dialog, so handle them one by one. + logger.info( + f"Routing for individual import: {file_path.name} at " + f"{position_mm}" + ) + import_handler.import_file_at_position( + self.main_window, editor, file_path, mime_type, position_mm + ) + elif action == ImportAction.DIRECT_LOAD: + # These files can be batched together for a single + # import command. + files_for_batch_import.append((file_path, mime_type)) + else: + # Unsupported files are already filtered out, but handle + # just in case. + logger.warning(f"Skipping unsupported file: {file_path.name}") + + # Handle any files that were collected for batch import. + if files_for_batch_import: + if len(files_for_batch_import) == 1: + # If only one direct-load file, just import it. + file_path, mime_type = files_for_batch_import[0] + logger.info(f"Importing direct-load file: {file_path.name}") + editor.file.load_file_from_path( + file_path, mime_type, None, position_mm + ) + else: + # If multiple direct-load files, use the batch handler. + logger.info( + f"Batch importing {len(files_for_batch_import)} " + "direct-load files." + ) + # Note: The batch handler will show a confirmation dialog. + import_handler.import_multiple_files_at_position( + self.main_window, + editor, + files_for_batch_import, + position_mm, + ) + + def handle_clipboard_paste(self): + """ + Handle paste operation, checking clipboard for image data first, + then falling back to workpiece paste. + """ + clipboard = self.main_window.get_clipboard() + formats = clipboard.get_formats() + + # Get all bitmap mime types from the backend + supported_bitmap_mimes = importer_registry.mime_types_by_feature( + ImporterFeature.BITMAP_TRACING + ) + + # Check for any supported bitmap image formats + has_image = any( + formats.contain_mime_type(mime_type) + for mime_type in supported_bitmap_mimes + ) + + if has_image: + # Import image from clipboard asynchronously + self._import_image_from_clipboard() + return True + + return False # Let caller handle workpiece paste + + def _import_image_from_clipboard(self): + """ + Asynchronously read an image from the clipboard and import it. + This entire process is thread-safe. + """ + clipboard = self.main_window.get_clipboard() + + # This callback is guaranteed to run on the main GTK thread. + def on_texture_ready(source_obj, result): + try: + texture = source_obj.read_texture_finish(result) + if not texture: + logger.warning("Failed to read texture from clipboard") + self._show_clipboard_error() + return + + # Safely save the texture to a file from the main thread. + temp_path = self._save_texture_to_temp_file(texture) + if not temp_path: + self._show_clipboard_error() + return + + logger.info(f"Saved clipboard image to {temp_path}") + + # Import the file and schedule it for future cleanup. + self._import_temp_file_and_cleanup(temp_path) + + # Now that the data has been successfully read and saved, + # we can safely clear the clipboard content. + source_obj.set_content(None) + + except GLib.Error as e: + # This can happen if clipboard content changes during read. + logger.warning(f"GLib error reading clipboard texture: {e}") + self._show_clipboard_error() + except Exception: + logger.exception("Failed to process clipboard texture") + self._show_clipboard_error() + + # Start the asynchronous clipboard read. + clipboard.read_texture_async(None, on_texture_ready) + + def _save_texture_to_temp_file(self, texture) -> Path | None: + """ + Save GdkTexture to a temporary PNG file. + MUST be called from the main GTK thread. + + Args: + texture: GdkTexture to save + + Returns: + Path to temporary file, or None on failure + """ + try: + with tempfile.NamedTemporaryFile( + delete=False, suffix=".png" + ) as tmp_file: + temp_path = Path(tmp_file.name) + + # Get pixbuf from texture and save as PNG + pixbuf = Gdk.pixbuf_get_from_texture(texture) + if not pixbuf: + logger.warning("Failed to convert texture to pixbuf") + return None + + pixbuf.savev(str(temp_path), "png", [], []) + return temp_path + + except (GLib.Error, OSError) as e: + logger.error(f"Failed to save texture: {e}") + return None + + def _import_temp_file_and_cleanup(self, temp_path: Path) -> bool: + """ + Import temporary file and schedule cleanup. + Runs on main thread. + + Args: + temp_path: Path to temporary file + + Returns: + False (to not repeat GLib.idle_add) + """ + try: + machine = get_context().machine + if machine: + center_x, center_y = machine.panel.work_area_center() + else: + center_x, center_y = 50.0, 50.0 # Fallback + + # Import the temporary file + import_handler.import_file_at_position( + self.main_window, + self.main_window.doc_editor, + temp_path, + "image/png", + (center_x, center_y), + ) + + # Schedule cleanup after delay + GLib.timeout_add_seconds(5, self._cleanup_temp_file, temp_path) + + # Show success notification + self.main_window.toast_overlay.add_toast( + Adw.Toast.new(_("Image imported from clipboard")) + ) + + except Exception: + logger.exception("Failed to import from clipboard") + self._show_clipboard_error() + + return False # Don't repeat + + def _cleanup_temp_file(self, temp_path: Path) -> bool: + """ + Clean up temporary file. + + Args: + temp_path: Path to file to delete + + Returns: + False (to not repeat GLib.timeout_add_seconds) + """ + try: + temp_path.unlink() + logger.debug(f"Cleaned up clipboard temp file: {temp_path}") + except OSError as e: + logger.warning(f"Failed to clean up temp file: {e}") + + return False # Don't repeat + + def _show_clipboard_error(self): + """ + Show error notification for clipboard import failure. + + Returns: + False (to not repeat GLib.idle_add) + """ + self.main_window.toast_overlay.add_toast( + Adw.Toast.new(_("Failed to import image from clipboard")) + ) + return False diff --git a/rayforge/ui_gtk/canvas2d/elements/__init__.py b/rayforge/ui_gtk/canvas2d/elements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/canvas2d/elements/axis_extent_frame.py b/rayforge/ui_gtk/canvas2d/elements/axis_extent_frame.py new file mode 100644 index 000000000..7212af21a --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/axis_extent_frame.py @@ -0,0 +1,88 @@ +import cairo + +from ...canvas import CanvasElement + + +class WorkareaBackgroundElement(CanvasElement): + """ + A non-interactive CanvasElement that draws a gray background + for the workarea within the machine bed. + """ + + def __init__(self, **kwargs): + super().__init__( + x=0, + y=0, + width=200.0, + height=200.0, + selectable=False, + draggable=False, + clip=False, + **kwargs, + ) + self._color = (0.8, 0.8, 0.8, 0.1) + + def set_color(self, r: float, g: float, b: float, a: float = 1.0): + """Sets the background color.""" + self._color = (r, g, b, a) + if self.canvas: + self.canvas.queue_draw() + + def draw(self, ctx: cairo.Context): + """Renders the workarea background as a filled rectangle.""" + ctx.save() + ctx.set_source_rgba(*self._color) + ctx.rectangle(0, 0, self.width, self.height) + ctx.fill() + ctx.restore() + + +class AxisExtentFrameElement(CanvasElement): + """ + A non-interactive CanvasElement that draws a red frame outline + representing the full axis extents of the machine. This frame + surrounds the work surface when the work surface is smaller than + the axis extents. + """ + + def __init__(self, **kwargs): + super().__init__( + x=0, + y=0, + width=200.0, + height=200.0, + selectable=False, + draggable=False, + clip=False, + **kwargs, + ) + self._color = (1.0, 0.0, 0.0, 0.5) + + def set_size(self, width: float, height: float): + """Updates the size of the extent frame.""" + if self.width == width and self.height == height: + return + super().set_size(width, height) + if self.canvas: + self.canvas.queue_draw() + + def set_color(self, r: float, g: float, b: float, a: float = 1.0): + """Sets the frame color.""" + self._color = (r, g, b, a) + if self.canvas: + self.canvas.queue_draw() + + def draw(self, ctx: cairo.Context): + """ + Renders the extent frame as a simple rectangle outline. + Uses a 1-pixel stroke width regardless of zoom level. + """ + ctx.save() + + ctx.set_source_rgba(*self._color) + ctx.set_hairline(True) + ctx.new_path() + ctx.rectangle(0, 0, self.width, self.height) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/ui_gtk/canvas2d/elements/camera_image.py b/rayforge/ui_gtk/canvas2d/elements/camera_image.py new file mode 100644 index 000000000..bc31df060 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/camera_image.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +import cairo +import cv2 +import numpy as np +from gi.repository import GLib + +from ....camera.controller import CameraController +from ...canvas import CanvasElement + +if TYPE_CHECKING: + from ..surface import WorkSurface + + +logger = logging.getLogger(__name__) + +# Cap the maximum dimension for the expensive warp operation. +# This gives a good balance between quality on high-zoom and performance. +MAX_PROCESSING_DIMENSION = 2048 + + +class CameraImageElement(CanvasElement): + def __init__(self, controller: CameraController, **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. + super().__init__( + x=0, y=0, width=1.0, height=1.0, buffered=False, **kwargs + ) + self.selectable = False + 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) + self.camera.settings_changed.connect(self._on_state_changed) + self.set_visible(self.camera.enabled) + + # Cache for the processed cairo surface and its underlying data buffer. + self._cached_surface: cairo.ImageSurface | None = None + self._cached_surface_data: np.ndarray | None = None + # A key representing the state that generated the cached surface. + self._cached_key: tuple | None = None + + def _on_camera_model_changed(self, sender): + """ + Handles changes in the camera model, such as being enabled or disabled. + + The element's visibility depends on both its model's `enabled` state + and the global visibility toggle on the `WorkSurface`. This handler + ensures the element's visibility is correctly re-evaluated when the + model changes at runtime. + """ + if not self.canvas: + return # Cannot update visibility without canvas context + worksurface = cast("WorkSurface", self.canvas) + is_globally_visible = worksurface._cam_visible + should_be_visible = is_globally_visible and self.camera.enabled + if self.visible != should_be_visible: + self.set_visible(should_be_visible) + + def remove(self): + """ + Extends the base remove to disconnect signals before being removed + from the canvas. Subscription is managed by the WorkSurface. + """ + self.controller.image_captured.disconnect(self._on_state_changed) + self.camera.changed.disconnect(self._on_camera_model_changed) + self.camera.settings_changed.disconnect(self._on_state_changed) + super().remove() + + def _on_state_changed(self, sender): + """ + Handles any change that makes the current cache stale. + Invalidates the key to trigger a recompute on the next draw, but + keeps the old surface and data to prevent flickering. + """ + self._cached_key = None + self.mark_dirty() + if self.canvas: + self.canvas.queue_draw() + + def allocate(self, force: bool = False): + """ + Ensures our element's dimensions always match the canvas'. + """ + worksurface = cast("WorkSurface", self.canvas) + self.set_size(worksurface.width_mm, worksurface.height_mm) + return super().allocate(force) + + def draw(self, ctx: cairo.Context): + """ + Draws the cached camera surface, scaled correctly to fit the element's + 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) + + # 1. Draw the last valid computed surface to prevent flicker. + if self._cached_surface: + ctx.save() + source_w = self._cached_surface.get_width() + source_h = self._cached_surface.get_height() + + if ( + source_w > 0 + and source_h > 0 + and self.width > 0 + and self.height > 0 + ): + # This logic is equivalent to the standard way of drawing a + # surface onto a rectangle in the base CanvasElement, but is + # reimplemented here as this element manages its own cache. + + # Scale the context so that drawing a (source_w x source_h) + # area will fill the element's (width x height) rectangle. + scale_x = self.width / source_w + scale_y = self.height / source_h + ctx.scale(scale_x, scale_y) + + # The world is Y-up, but the cairo surface is Y-down. + # Flip the Y axis to match. + ctx.translate(0, source_h) + ctx.scale(1, -1) + + # Set the cached surface as the source and paint. + ctx.set_source_surface(self._cached_surface, 0, 0) + ctx.get_source().set_filter(cairo.FILTER_GOOD) + ctx.paint() + + ctx.restore() + + # 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() + + if ( + self.controller.image_data is None + or output_width <= 0 + or output_height <= 0 + ): + return + + physical_area = None + if self.camera.image_to_world: + physical_area = ( + (0, 0), + (worksurface.width_mm, worksurface.height_mm), + ) + + current_key = ( + id(self.controller.image_data), + output_width, + output_height, + physical_area, + self.camera.transparency, + ) + + # 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 _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 image_data is None or id(image_data) != img_data_id: + # A newer frame has already arrived; 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 + ) + + if result: + new_surface, new_surface_data = result + # Store both to keep the data buffer alive. + self._cached_surface = new_surface + self._cached_surface_data = new_surface_data + self._cached_key = key_for_this_job + if self.canvas: + self.canvas.queue_draw() + + # This function should only run once per schedule. + return False + + def _generate_surface( + self, + image_data: np.ndarray, + output_size: tuple[int, int], + physical_area: tuple | None, + transparency: float, + ) -> tuple[cairo.ImageSurface, np.ndarray] | None: + """ + Contains the core image processing logic, creating a Cairo surface + and returning it along with its data buffer. + """ + 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), + physical_area=physical_area, + ) + + if transformed_image is None: + logger.warning("Image transformation failed, skipping frame.") + return None + processed_image = transformed_image + + if processed_image.shape[2] == 3: + bgra_image = cv2.cvtColor(processed_image, cv2.COLOR_BGR2BGRA) + else: + bgra_image = processed_image.copy() + + if transparency < 1.0: + if not bgra_image.flags["WRITEABLE"]: + bgra_image = bgra_image.copy() + bgra_image[:, :, 3] = bgra_image[:, :, 3] * transparency + + height, width, _ = bgra_image.shape + + # Create a new data buffer that Cairo will use. + surface_data = np.copy(bgra_image) + new_surface = cairo.ImageSurface.create_for_data( + surface_data, # type: ignore + cairo.FORMAT_ARGB32, + width, + height, # type: ignore + ) + + # Return both the surface and its data to ensure the buffer is not + # garbage collected while the C-level surface is in use. + return new_surface, surface_data diff --git a/rayforge/ui_gtk/canvas2d/elements/crosshair.py b/rayforge/ui_gtk/canvas2d/elements/crosshair.py new file mode 100644 index 000000000..d7a85f448 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/crosshair.py @@ -0,0 +1,112 @@ +"""A draggable crosshair marker. + +The element draws a small crosshair + ring and allows the user to +drag it on the canvas. The visual centre sits at the centre of +the hit rectangle, so a click anywhere near the drawn crosshair +registers as a hit. +The element's position (set via ``move_to``) refers to the centre, +not the bottom‑left corner. +""" + +import logging +import math +from collections.abc import Callable + +import cairo + +from ...canvas import CanvasElement +from ...canvas.region import ElementRegion + +logger = logging.getLogger(__name__) + +# Hit-test region width & height in world mm — matches the drawn +# crosshair span (arm‑to‑arm) so the grab zone feels natural. +_HIT_SIZE = 5 + + +class CrosshairElement(CanvasElement): + """A small, draggable crosshair at a given world position. + + Call ``move_to(x, y)`` to position the visual centre at ``(x, y)``. + The hit region (bounding box) is centred on the same point, so + clicks near the drawn crosshair always register. + """ + + def __init__( + self, + on_drag: Callable[[tuple[float, float]], None] | None = None, + **kwargs, + ): + super().__init__( + 0, + 0, + _HIT_SIZE, + _HIT_SIZE, + selectable=True, + draggable=True, + drag_handler_controls_transform=True, + visible=True, + clip=False, + background=(0, 0, 0, 0), + show_selection_frame=False, + pixel_perfect_hit=False, + **kwargs, + ) + self._on_drag = on_drag + self._drag_origin: tuple[float, float] | None = None + self._centre_offset = _HIT_SIZE / 2.0 + + def move_to(self, x: float, y: float) -> None: + """Places the visual centre at ``(x, y)`` world.""" + self.set_pos(x - self._centre_offset, y - self._centre_offset) + + def draw(self, ctx: cairo.Context): + c = self._centre_offset # 2.5 mm — centre of the hit rect + s = c # visual arm reaches the same distance as the hit boundary + + ctx.save() + ctx.set_source_rgba(0.45, 0.70, 1.0, 0.6) + ctx.arc(c, c, 1.0, 0.0, 2.0 * math.pi) + ctx.fill() + + # Crosshair arms. + ctx.set_line_width(1.0) + ctx.move_to(c - s, c) + ctx.line_to(c + s, c) + ctx.move_to(c, c - s) + ctx.line_to(c, c + s) + ctx.stroke() + + # Outer ring (slightly smaller than the arms). + ctx.arc(c, c, s - 0.3, 0.0, 2.0 * math.pi) + ctx.stroke() + ctx.restore() + + def check_region_hit(self, x_abs, y_abs, candidates=None) -> ElementRegion: + world = self.get_world_transform() + inv = world.invert() + lx, ly = inv.transform_point((x_abs, y_abs)) + if 0 <= lx < self.width and 0 <= ly < self.height: + return ElementRegion.BODY + return ElementRegion.NONE + + def end_interactive_transform(self): + self._drag_origin = None + super().end_interactive_transform() + + def handle_drag_move( + self, world_dx: float, world_dy: float + ) -> tuple[float, float]: + if self._drag_origin is None: + self._drag_origin = self.get_world_transform().get_translation() + ox, oy = self._drag_origin + new_pos = (ox + world_dx, oy + world_dy) + self.set_pos(*new_pos) + if self._on_drag: + self._on_drag( + ( + new_pos[0] + self._centre_offset, + new_pos[1] + self._centre_offset, + ) + ) + return 0.0, 0.0 diff --git a/rayforge/ui_gtk/canvas2d/elements/dot.py b/rayforge/ui_gtk/canvas2d/elements/dot.py new file mode 100644 index 000000000..01a37284e --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/dot.py @@ -0,0 +1,54 @@ +import logging +import math + +import cairo + +from ...canvas import CanvasElement + +logger = logging.getLogger(__name__) + + +class DotElement(CanvasElement): + """ + Draws a simple red dot. The dot has a constant size in its local + coordinate space. + """ + + def __init__(self, x, y, diameter: float = 5.0, **kwargs): + """ + Initializes a DotElement. + + The dimensions (x, y, diameter) are in the parent's coordinate + system. For WorkSurface, this is typically millimeters. + + Args: + x: The x-coordinate relative to the parent. + y: The y-coordinate relative to the parent. + diameter: The diameter of the dot. + **kwargs: Additional keyword arguments for CanvasElement. + """ + # Laser dot is always a circle, so width and height should be equal. + super().__init__( + x, + y, + diameter, + diameter, + visible=True, + selectable=False, + **kwargs, + ) + + def draw(self, ctx: cairo.Context): + """Renders the dot onto the provided cairo context.""" + # Let the parent draw its background if any. + super().draw(ctx) + + # Prepare the context for our drawing. + ctx.set_source_rgb(0.9, 0, 0) + + # Draw the circle centered within the element's local bounds. + center_x = self.width / 2 + center_y = self.height / 2 + radius = self.width / 2 + ctx.arc(center_x, center_y, radius, 0.0, 2 * math.pi) + ctx.fill() diff --git a/rayforge/ui_gtk/canvas2d/elements/group.py b/rayforge/ui_gtk/canvas2d/elements/group.py new file mode 100644 index 000000000..cf1ab4dc6 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/group.py @@ -0,0 +1,158 @@ +import logging +from typing import TYPE_CHECKING, Optional, cast + +from gi.repository import GLib +from raygeo.geo import Matrix + +from ....core.group import Group +from ....core.workpiece import WorkPiece +from ...canvas import ShrinkWrapGroup +from .workpiece import WorkPieceElement + +if TYPE_CHECKING: + from ...canvas import CanvasElement + from ..surface import WorkSurface + + +logger = logging.getLogger(__name__) + + +class GroupElement(ShrinkWrapGroup): + """ + A CanvasElement that represents a Group data model. + """ + + def __init__(self, group: "Group", **kwargs): + # The element is "passive" during its entire construction and the + # synchronous execution of the command that creates it. + self._is_passive = True + super().__init__(data=group, pixel_perfect_hit=True, **kwargs) + self.data.updated.connect(self.sync_with_model) + self.data.transform_changed.connect(self._on_transform_changed) + self.data.descendant_added.connect(self.sync_with_model) + self.data.descendant_removed.connect(self.sync_with_model) + + # Set the initial transform from the model. This is critical. + self._on_transform_changed(self.data) + + # Build the child view hierarchy. + self.sync_with_model() + + # Schedule the group to become "active" and + # perform its first shrink-wrap calculation in the next idle cycle. + # This guarantees that the CreateGroupCommand has completely finished + # setting up the model state before the view tries to react to it. + GLib.idle_add(self._activate_and_update) + + def _activate_and_update(self) -> bool: + """Callback to activate the element and run its first update.""" + self._is_passive = False + self.update_bounds() + if self.canvas: + self.canvas.queue_draw() + return GLib.SOURCE_REMOVE # Run only once + + def on_child_transform_changed(self, child: "CanvasElement"): + """Override to prevent updates while the group is passive.""" + if self._is_passive: + return # Ignore all child updates during initialization + + # After activation, use the standard ShrinkWrapGroup behavior + super().on_child_transform_changed(child) + + def remove(self): + """Disconnects signals before removing the element.""" + self.data.updated.disconnect(self.sync_with_model) + self.data.transform_changed.disconnect(self._on_transform_changed) + self.data.descendant_added.disconnect(self.sync_with_model) + self.data.descendant_removed.disconnect(self.sync_with_model) + super().remove() + + def set_ops_visibility(self, step_uid: str, visible: bool): + """ + Propagates the ops visibility setting to all child elements. + """ + for child in self.children: + assert isinstance(child, (WorkPieceElement, GroupElement)) + child.set_ops_visibility(step_uid, visible) + + def get_elem_hit( + self, world_x: float, world_y: float, selectable: bool = False + ) -> Optional["CanvasElement"]: + """ + Overrides the default hit-test to enforce group selection behavior. + + If any element within this group's hierarchy (a child, a grandchild, + or the group's own body) is visually under the cursor, this method + intercepts the result and returns the group itself, provided the group + is selectable. This makes the entire group act as a single unit for + both click and frame selection. + """ + # If the caller requires a selectable element and this group isn't, + # quit. + if selectable and not self.selectable: + return None + + # Check for a visual hit within the group's hierarchy, ignoring the + # individual `selectable` flags of children. This is the key to making + # the group an atomic unit for click-selection. + hit_candidate = super().get_elem_hit( + world_x, world_y, selectable=False + ) + + # If a visual component was hit, the hit is on the group itself. + return self if hit_candidate else None + + def _on_transform_changed( + self, group: Group, *, old_matrix: Matrix | None = None + ): + """ + Handles transform changes from the model by applying the model's + local matrix to this canvas element's transform. (MODEL -> VIEW) + """ + if self.transform != group.matrix: + self.set_transform(group.matrix) + + def sync_with_model(self, *args, **kwargs): + """ + Reconciles child elements (WorkPieceElement, GroupElement) with the + state of the Group model. + """ + if not self.data or not self.canvas: + return + + work_surface = cast("WorkSurface", self.canvas) + model_children = set(self.data.children) + current_elements = self.children[:] + current_element_data = {elem.data for elem in current_elements} + + # Remove elements for items no longer in the group + for elem in current_elements: + if elem.data not in model_children: + elem.remove() + + # Add elements for new items in the group + items_to_add = model_children - current_element_data + for item_data in items_to_add: + child_elem = None + if isinstance(item_data, WorkPiece): + child_elem = WorkPieceElement( + workpiece=item_data, + view_manager=work_surface.editor.view_manager, + canvas=self.canvas, + selectable=False, # Children are not selectable + ) + elif isinstance(item_data, Group): + child_elem = GroupElement( + group=item_data, + canvas=self.canvas, + selectable=False, # Children are not selectable + ) + + if child_elem: + self.add(child_elem) + + # Do NOT call update_bounds here. It's handled by the activation + # callback or the on_child_transform_changed logic. + if self.canvas: + self.canvas.queue_draw() diff --git a/rayforge/ui_gtk/canvas2d/elements/layer.py b/rayforge/ui_gtk/canvas2d/elements/layer.py new file mode 100644 index 000000000..68811c235 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/layer.py @@ -0,0 +1,211 @@ +import logging +from typing import TYPE_CHECKING, cast + +from ....core.group import Group +from ....core.item import DocItem +from ....core.stock import StockItem +from ....core.workpiece import WorkPiece +from ...canvas.element import CanvasElement +from .group import GroupElement +from .step import StepElement +from .stock import StockElement +from .workpiece import WorkPieceElement + +if TYPE_CHECKING: + from ....core.layer import Layer + + +logger = logging.getLogger(__name__) + + +class LayerElement(CanvasElement): + """ + A non-selectable container that corresponds to a Layer model. + It creates and manages child elements for WorkPieces, Groups, and Steps. + """ + + def __init__(self, layer: "Layer", **kwargs): + super().__init__( + x=0, + y=0, + width=0, + height=0, + selectable=False, + background=(0, 0, 0, 0), + clip=False, + data=layer, + **kwargs, + ) + self.data: Layer = layer + self.data.updated.connect(self.sync_with_model) + self.data.descendant_added.connect(self.sync_with_model) + self.data.descendant_removed.connect(self.sync_with_model) + self.sync_with_model(self.data) + + def remove(self): + """Disconnects signals before removing the element.""" + self.data.updated.disconnect(self.sync_with_model) + self.data.descendant_added.disconnect(self.sync_with_model) + self.data.descendant_removed.disconnect(self.sync_with_model) + super().remove() + + def set_size(self, width: float, height: float): + """Sets the size and propagates it to child StepElements.""" + if self.width == width and self.height == height: + return + super().set_size(width, height) + + for elem in self.children: + if isinstance(elem, StepElement): + elem.set_size(width, height) + + def sort_children_by_z_order(self): + """Sorts child elements to maintain correct drawing order.""" + model_visual_items = [ + c + for c in self.data.children + if isinstance(c, (WorkPiece, Group, StockItem)) + ] + model_order = { + id(item): i for i, item in enumerate(model_visual_items) + } + + def sort_key(element: CanvasElement): + if isinstance( + element, (WorkPieceElement, GroupElement, StockElement) + ): + return ( + 0, + model_order.get(id(element.data), len(model_visual_items)), + ) + if isinstance(element, StepElement): + return (1, 0) + return (2, 0) + + self.children.sort(key=sort_key) + + def sync_with_model( + self, + sender, + origin: DocItem | None = None, + parent_of_origin: DocItem | None = None, + ): + """ + Reconciles all child elements with the state of the Layer model. + """ + if not self.data or not self.canvas: + return + + logger.debug( + f"LayerElement for '{self.data.name}': sync_with_model is" + f" executing, called by {origin or sender}." + ) + self.set_visible(self.data.visible) + from ..surface import WorkSurface + + work_surface = cast(WorkSurface, self.canvas) + + # Reconcile Visual Elements (WorkPieces, Groups, StockItems) + model_items = { + c + for c in self.data.children + if isinstance(c, (WorkPiece, Group, StockItem)) + } + current_visual_elements = [ + elem + for elem in self.children + if isinstance(elem, (WorkPieceElement, GroupElement, StockElement)) + ] + + # Remove elements for items no longer in the layer + for elem in current_visual_elements[:]: + if elem.data not in model_items: + logger.debug(f"Removing visual element: {elem}") + elem.remove() + + # Add elements for new items in the layer. + current_item_data = {elem.data for elem in self.children} + items_to_add = model_items - current_item_data + for item_data in items_to_add: + new_elem = None + if isinstance(item_data, WorkPiece): + new_elem = WorkPieceElement( + workpiece=item_data, + view_manager=work_surface.editor.view_manager, + canvas=self.canvas, + selectable=self.data.visible, + ) + new_elem.set_base_image_visible( + work_surface.are_workpieces_visible() + ) + elif isinstance(item_data, Group): + new_elem = GroupElement( + group=item_data, + canvas=self.canvas, + selectable=self.data.visible, + ) + elif isinstance(item_data, StockItem): + new_elem = StockElement( + stock_item=item_data, + canvas=self.canvas, + # Stock is potentially selectable, but its get_elem_hit + # method will make the final decision based on layer state. + selectable=True, + ) + + if new_elem: + self.add(new_elem) + + if self.data.workflow is None: + return # layers without workflow + + # Reconcile StepElements (Lifecycle Managers) + current_step_elements = [ + elem for elem in self.children if isinstance(elem, StepElement) + ] + workpiece_views = [ + elem + for elem in self.children + if isinstance(elem, WorkPieceElement) + ] + model_steps = set(self.data.workflow.steps) + + # Remove StepElements for steps that are no longer in the model + for elem in current_step_elements: + if elem.data not in model_steps: + removed_step_uid = elem.data.uid + logger.debug( + "LayerElement detected removal of step " + f"'{elem.data.name}'. " + f"Cleaning up visuals for UID {removed_step_uid}." + ) + # Instruct all workpiece views in this layer to clear the + # surface + for wp_view in workpiece_views: + wp_view.clear_ops_surface(removed_step_uid) + elem.remove() + + # Add StepElements for new steps. + current_step_data = { + elem.data + for elem in self.children + if isinstance(elem, StepElement) + } + steps_to_add = model_steps - current_step_data + for step_data in steps_to_add: + step_elem = StepElement( + step=step_data, + pipeline=work_surface.editor.pipeline, + canvas=self.canvas, + ) + self.add(step_elem) + + # After all children are added/removed, we ensure every StepElement + # broadcasts its current visibility to every WorkPieceElement. This + # guarantees that newly created elements get the correct initial state. + for elem in self.children: + if isinstance(elem, StepElement): + elem._update_sibling_ops_visibility() + + self.sort_children_by_z_order() + self.canvas.queue_draw() diff --git a/rayforge/ui_gtk/canvas2d/elements/nogo_zone.py b/rayforge/ui_gtk/canvas2d/elements/nogo_zone.py new file mode 100644 index 000000000..4b1c9a174 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/nogo_zone.py @@ -0,0 +1,104 @@ +import math + +import cairo + +from ....machine.models.zone import Zone, ZoneShape +from ...canvas import CanvasElement + +_FILL_COLOR = (1.0, 0.2, 0.2, 0.15) +_STROKE_COLOR = (1.0, 0.0, 0.0, 0.6) +_HATCH_COLOR = (1.0, 0.0, 0.0, 0.25) +_HATCH_SPACING = 4.0 + + +class NogoZoneElement(CanvasElement): + """ + A non-interactive CanvasElement that draws a no-go zone as a + semi-transparent red filled rectangle with a border and diagonal + hatch lines. Zones with BOX or CYLINDER shape are projected to + their X/Y footprint in the 2D view. + """ + + def __init__(self, zone: Zone, **kwargs): + super().__init__( + x=0, + y=0, + width=10.0, + height=10.0, + selectable=False, + draggable=False, + clip=False, + data=zone, + **kwargs, + ) + self._fill_color = _FILL_COLOR + self._stroke_color = _STROKE_COLOR + self._hatch_color = _HATCH_COLOR + self._update_from_zone() + + def _update_from_zone(self): + zone: Zone = self.data + p = zone.params + x = p.get("x", 0.0) + y = p.get("y", 0.0) + + if zone.shape == ZoneShape.CYLINDER: + r = p.get("radius", 5.0) + w = r * 2 + h = r * 2 + x -= r + y -= r + else: + w = p.get("w", 10.0) + h = p.get("h", 10.0) + + self.set_pos(x, y) + self.set_size(w, h) + self.set_visible(zone.enabled) + + def draw(self, ctx: cairo.Context): + zone: Zone = self.data + + ctx.save() + ctx.set_source_rgba(*self._fill_color) + if zone.shape == ZoneShape.CYLINDER: + r = self.width / 2.0 + ctx.arc(r, r, r, 0, 2 * math.pi) + else: + ctx.rectangle(0, 0, self.width, self.height) + ctx.fill_preserve() + + ctx.set_source_rgba(*self._stroke_color) + ctx.set_hairline(True) + ctx.stroke() + + if zone.shape == ZoneShape.CYLINDER: + r = self.width / 2.0 + ctx.arc(r, r, r, 0, 2 * math.pi) + else: + ctx.rectangle(0, 0, self.width, self.height) + ctx.clip() + self._draw_hatch(ctx) + ctx.restore() + + def _draw_hatch(self, ctx: cairo.Context): + ctx.set_source_rgba(*self._hatch_color) + ctx.set_hairline(True) + + w, h = self.width, self.height + step = _HATCH_SPACING + + for offset in _frange(-w - h, w + h, step): + ctx.move_to(offset, 0) + ctx.line_to(offset + h, h) + + ctx.stroke() + + +def _frange(start: float, stop: float, step: float): + vals = [] + v = start + while v < stop: + vals.append(v) + v += step + return vals diff --git a/rayforge/ui_gtk/canvas2d/elements/outline.py b/rayforge/ui_gtk/canvas2d/elements/outline.py new file mode 100644 index 000000000..930a1de9d --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/outline.py @@ -0,0 +1,163 @@ +"""A non-interactive overlay element that draws shape outlines. + +Given a list of world-space polygons (one per source item) and a list +of world-space transform deltas, the element strokes a translucent +outline of every polygon at every transformed position, plus a small +marker at the first corner so copies which only differ by rotation +(e.g. 0 and 180 degrees) remain visually distinct. + +It can also draw a guide circle, used by the circular array to show +the virtual circle the copies sit on. + +The element is a plain canvas element (added to and removed from the +canvas root by its owner) and carries no behaviour of its own beyond +rendering. +""" + +import logging +import math + +import cairo + +from ...canvas import CanvasElement + +logger = logging.getLogger(__name__) + + +class OutlineElement(CanvasElement): + """Draws translucent outlines of shapes at a set of transforms.""" + + def __init__(self): + super().__init__( + 0, + 0, + 1, + 1, + selectable=False, + visible=True, + clip=False, + ) + # Each entry in _shapes is a list of world-space (x, y) corners + # forming the footprint of one source item. + self._shapes: list[list[tuple[float, float]]] = [] + # List of world-space delta Matrices; each shape is drawn once + # per delta. An identity delta is skipped. + self._deltas = [] + # Optional guide circle (world center, world radius). + self._guide_circle: tuple[tuple[float, float], float] | None = None + + def set_outlines( + self, + shapes, + deltas, + guide_circle: tuple[tuple[float, float], float] | None = None, + ) -> None: + """Stores the per-item shape corners, transform deltas and an + optional guide circle, then redraws. + + ``shapes`` is a list of polygon definitions; each polygon is + itself a list of ``(x, y)`` corner tuples in world-space. + For backward compatibility a single ``(min_x, min_y, max_x, + max_y)`` bounding-box tuple is also accepted and converted to a + single-axis-aligned rectangle. + """ + self._shapes = self._normalise_shapes(shapes) + self._deltas = list(deltas) + self._guide_circle = guide_circle + if self.canvas: + self.canvas.queue_draw() + + @staticmethod + def _normalise_shapes(shapes): + """Accepts both the legacy bbox format (4‑tuple) and the new + list-of-polygons format.""" + if not shapes: + return [] + # bbox format: (min_x, min_y, max_x, max_y) + if isinstance(shapes, tuple) and len(shapes) == 4: + min_x, min_y, max_x, max_y = shapes + return [ + [ + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y), + ] + ] + return list(shapes) + + def clear(self) -> None: + """Clears all outlines.""" + self._deltas = [] + self._shapes = [] + self._guide_circle = None + if self.canvas: + self.canvas.queue_draw() + + def draw_overlay(self, ctx: cairo.Context) -> None: + if not self.canvas: + return + view = self.canvas.view_transform + + if self._guide_circle is not None: + self._draw_guide_circle(ctx, view) + + if not self._shapes or not self._deltas: + return + + ctx.save() + for delta in self._deltas: + if delta.is_identity(): + continue + for corners in self._shapes: + screen = [ + view.transform_point(delta.transform_point(p)) + for p in corners + ] + + # Translucent fill. + ctx.set_source_rgba(0.45, 0.70, 1.0, 0.12) + x0, y0 = screen[0] + ctx.move_to(round(x0) + 0.5, round(y0) + 0.5) + for sx, sy in screen[1:]: + ctx.line_to(round(sx) + 0.5, round(sy) + 0.5) + ctx.close_path() + ctx.fill_preserve() + + # Dashed outline. + ctx.set_source_rgba(0.45, 0.70, 1.0, 0.9) + ctx.set_line_width(1.0) + ctx.set_dash([5.0, 3.0]) + ctx.stroke() + + # Orientation marker at the first corner. + ctx.set_source_rgba(0.45, 0.70, 1.0, 0.95) + ctx.set_dash([]) + ctx.arc(screen[0][0], screen[0][1], 2.5, 0.0, 2.0 * math.pi) + ctx.fill() + ctx.restore() + + def _draw_guide_circle(self, ctx: cairo.Context, view) -> None: + guide = self._guide_circle + if guide is None: + return + center, radius = guide + if radius <= 0: + return + cx, cy = view.transform_point(center) + px = view.transform_point((center[0] + 1.0, center[1])) + py = view.transform_point((center[0], center[1] + 1.0)) + scale = (abs(px[0] - cx) + abs(py[1] - cy)) / 2.0 + r_px = radius * scale + + ctx.save() + ctx.set_source_rgba(0.45, 0.70, 1.0, 0.55) + ctx.set_line_width(1.0) + ctx.set_dash([3.0, 3.0]) + ctx.arc(cx, cy, r_px, 0.0, 2.0 * math.pi) + ctx.stroke() + # Mark the centre. + ctx.set_dash([]) + ctx.arc(cx, cy, 2.0, 0.0, 2.0 * math.pi) + ctx.fill() + ctx.restore() diff --git a/rayforge/ui_gtk/canvas2d/elements/step.py b/rayforge/ui_gtk/canvas2d/elements/step.py new file mode 100644 index 000000000..006e1cc61 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/step.py @@ -0,0 +1,91 @@ +import logging +from typing import TYPE_CHECKING, cast + +from ....core.workflow import Step +from ...canvas import CanvasElement +from .group import GroupElement +from .workpiece import WorkPieceElement + +if TYPE_CHECKING: + from ....pipeline.pipeline import Pipeline + + +logger = logging.getLogger(__name__) + + +class StepElement(CanvasElement): + """ + A non-rendering CanvasElement that manages the view-state for a Step. + + This element is the "controller" for a Step in the view. Its primary job + is to listen to its model for visibility changes and then broadcast that + state to all sibling WorkPieceElements within the same layer. + Its lifecycle is automatically managed by its parent LayerElement. + """ + + def __init__( + self, + step: Step, + pipeline: "Pipeline", + **kwargs, + ): + """ + Initializes a StepElement. + + Args: + step: The Step data object. + pipeline: The central generator for pipeline operations. + **kwargs: Additional keyword arguments for CanvasElement. + """ + super().__init__( + x=0, + y=0, + width=0, + height=0, # No dimensions needed + data=step, + selectable=False, + visible=step.visible, # Sync initial visibility + **kwargs, + ) + self.pipeline = pipeline + + # Connect to the model signal that drives its behavior + step.visibility_changed.connect(self._on_visibility_changed) + + def remove(self): + """Disconnects signals before removing the element.""" + step = cast(Step, self.data) + step.visibility_changed.disconnect(self._on_visibility_changed) + super().remove() + + def _on_visibility_changed(self, step: Step): + """ + Handles visibility changes from the model. It updates its own state + and then broadcasts the change to its siblings. + """ + if self.visible != step.visible: + self.set_visible(step.visible) + self._update_sibling_ops_visibility() + + def _update_sibling_ops_visibility(self): + """ + THE CORE LOGIC: Finds all WorkPieceElement siblings in the same parent + (LayerElement) and tells them to update the visibility of the ops + layer corresponding to this step. + """ + if not self.parent: + return + parent = cast("CanvasElement", self.parent) + + step_uid = self.data.uid + is_visible = self.visible + + logger.debug( + f"StepElement '{self.data.name}' broadcasting visibility " + f"({is_visible}) to siblings." + ) + + # Iterate through all children of the parent (the LayerElement) + for child in parent.children: + if isinstance(child, (WorkPieceElement, GroupElement)): + child.set_ops_visibility(step_uid, is_visible) diff --git a/rayforge/ui_gtk/canvas2d/elements/stock.py b/rayforge/ui_gtk/canvas2d/elements/stock.py new file mode 100644 index 000000000..47bea3635 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/stock.py @@ -0,0 +1,105 @@ +import logging + +import cairo +from raygeo.geo import Matrix + +from ....core.stock import StockItem +from ....image.geo_renderer import geometry_to_cairo +from ...canvas import CanvasElement + +logger = logging.getLogger(__name__) + + +class StockElement(CanvasElement): + """ + A CanvasElement that visualizes a single StockItem model. + """ + + def __init__(self, stock_item: StockItem, **kwargs): + self.data: StockItem = stock_item + super().__init__( + 0, + 0, + 1.0, + 1.0, # Geometry is 1x1, transform handles size + data=stock_item, + buffered=False, + pixel_perfect_hit=False, # Bbox is fine for stock + **kwargs, + ) + self.data.updated.connect(self._on_model_content_changed) + self.data.transform_changed.connect(self._on_transform_changed) + self._on_transform_changed(self.data) + self._on_visibility_changed() + + def remove(self): + """Disconnects signals before removal.""" + self.data.updated.disconnect(self._on_model_content_changed) + self.data.transform_changed.disconnect(self._on_transform_changed) + super().remove() + + def set_visible(self, visible: bool = True): + self.selectable = visible + if not visible and self.selected: + self.selected = False + return super().set_visible(visible) + + def _on_model_content_changed(self, stock_item: StockItem): + """Handler for when the stock item's geometry changes.""" + logger.debug( + f"Model content changed for '{stock_item.name}', " + "triggering update." + ) + self._on_visibility_changed() + if self.canvas: + self.canvas.queue_draw() + + def _on_visibility_changed(self): + """Handler for when the stock item's visibility changes.""" + self.set_visible(self.data.visible) + + def _on_transform_changed( + self, stock_item: StockItem, *, old_matrix: Matrix | None = None + ): + """Handler for when the stock item's transform changes.""" + if not self.canvas or self.transform == stock_item.matrix: + return + self.set_transform(stock_item.matrix) + + def draw(self, ctx: cairo.Context): + """Draws the stock geometry directly to the main canvas context.""" + if self.data.geometry.is_empty() or not self.visible: + return + + ctx.save() + + min_x, min_y, max_x, max_y = self.data.geometry.rect() + geo_width = max_x - min_x + geo_height = max_y - min_y + + # Scale and translate context to fit geometry inside the 1x1 element + if geo_width > 1e-9 and geo_height > 1e-9: + ctx.scale(1.0 / geo_width, 1.0 / geo_height) + ctx.translate(-min_x, -min_y) + + # Draw the geometry path using the standard method + geometry_to_cairo(self.data.geometry, ctx) + + # Get the material color if available + material = self.data.material + if material: + # Use material color with 0.5 alpha + r, g, b, a = material.get_display_rgba(0.5) + ctx.set_source_rgba(r, g, b, a) + else: + # Use default color when no material is assigned + ctx.set_source_rgba(0.5, 0.5, 0.5, 0.3) + + ctx.fill_preserve() + + # Stroke the path with a crisp, 1-device-pixel hairline + ctx.set_source_rgba(0.2, 0.2, 0.2, 0.8) + ctx.set_hairline(True) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/ui_gtk/canvas2d/elements/tab_handle.py b/rayforge/ui_gtk/canvas2d/elements/tab_handle.py new file mode 100644 index 000000000..4e29293f5 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/tab_handle.py @@ -0,0 +1,398 @@ +import logging +import math +from copy import deepcopy +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +import cairo +import numpy as np +from gi.repository import Gdk +from raygeo.geo import Matrix + +from ....core.tab import Tab +from ....core.undo import ChangePropertyCommand +from ...canvas.element import CanvasElement + +if TYPE_CHECKING: + from ..surface import WorkSurface + from .workpiece import WorkPieceElement + +logger = logging.getLogger(__name__) + + +class TabHandleElement(CanvasElement): + """ + A canvas element representing a single Tab, which is always visible + and can be dragged along its parent's vector path. + """ + + def __init__(self, tab_data: Tab, parent: "WorkPieceElement"): + super().__init__( + x=0, + y=0, + width=1.0, # A unit square, scaled by the transform + height=1.0, + data=tab_data, + parent=parent, + selectable=True, + draggable=True, + show_selection_frame=False, + drag_handler_controls_transform=True, + preserves_selection_on_click=True, # This is the key flag + clip=False, + ) + self._initial_tabs_state: list[Tab] | None = None + # Cache for geometric calculations, in parent's normalized (0-1) space. + self._local_pos_norm: tuple[float, float] = (0.0, 0.0) + self._local_tangent_norm: tuple[float, float] = (1.0, 0.0) + + # Holds the transient state during a drag + self._dragged_tab_state: Tab | None = None + + def on_attached(self): + """Lifecycle hook called when added to the canvas.""" + assert self.canvas + self.canvas.move_begin.connect(self._on_drag_begin) + self.canvas.move_end.connect(self._on_drag_end) + + def on_detached(self): + """Lifecycle hook called before being removed from the canvas.""" + assert self.canvas + self.canvas.move_begin.disconnect(self._on_drag_begin) + self.canvas.move_end.disconnect(self._on_drag_end) + + def _on_drag_begin( + self, + sender, + elements: list[CanvasElement], + drag_target: CanvasElement | None = None, + ): + """Called by the canvas when a move operation starts.""" + if drag_target is self: + parent_view = cast("WorkPieceElement", self.parent) + # 1. Store the "before" state for the undo command. + self._initial_tabs_state = deepcopy(parent_view.data.tabs) + # 2. Create a transient copy of the tab data to modify during drag. + self._dragged_tab_state = deepcopy(cast(Tab, self.data)) + logger.debug(f"Drag begin for tab {self.data.uid}") + + def _on_drag_end( + self, + sender, + elements: list[CanvasElement], + drag_target: CanvasElement | None = None, + ): + """Called by the canvas when a move operation ends.""" + if ( + drag_target is self + and self._initial_tabs_state is not None + and self._dragged_tab_state is not None + ): + parent_view = cast("WorkPieceElement", self.parent) + work_surface = cast("WorkSurface", self.canvas) + doc = work_surface.editor.doc + + # 1. Create the "after" state from the "before" state. + new_tabs_state = deepcopy(self._initial_tabs_state) + + # 2. Find the dragged tab in the new list and update it with the + # final state from our transient copy. + found = False + for i, tab in enumerate(new_tabs_state): + if tab.uid == self.data.uid: + new_tabs_state[i] = self._dragged_tab_state + found = True + break + + if not found: + logger.error("Could not find dragged tab to finalize move.") + self._initial_tabs_state = None + self._dragged_tab_state = None + return + + # 3. Create a command to perform the atomic update. The model + # is still in the "old" state, so we can execute directly. + cmd = ChangePropertyCommand( + target=parent_view.data, + property_name="tabs", + new_value=new_tabs_state, + old_value=self._initial_tabs_state, + name=_("Move Tab"), + ) + doc.history_manager.execute(cmd) + + # 4. Clean up transient state. + self._initial_tabs_state = None + self._dragged_tab_state = None + logger.debug(f"Drag end for tab {self.data.uid}") + + def handle_drag_move( + self, world_dx: float, world_dy: float + ) -> tuple[float, float]: + """ + Performs calculations to move the handle along the path, updating only + the handle's local state for a fast preview. The document model is NOT + modified during this operation. + """ + parent_view = cast("WorkPieceElement", self.parent) + vectors = parent_view.data.boundaries + if not self.canvas or not vectors: + return world_dx, world_dy + + # Get mouse position in world coordinates + world_mouse_x, world_mouse_y = self.canvas._get_world_coords( + self.canvas._last_mouse_x, self.canvas._last_mouse_y + ) + + # Transform mouse coordinates to the parent's local, + # normalized space (0-1). + try: + inv_parent_world = parent_view.get_world_transform().invert() + local_x_norm, local_y_norm = inv_parent_world.transform_point( + (world_mouse_x, world_mouse_y) + ) + except np.linalg.LinAlgError: + return world_dx, world_dy + + # 1. Find the closest point on the normalized geometry path. + # The `vectors` object operates in a normalized 0-1 space as per + # the WorkPiece model's design. + closest = vectors.find_closest_point(local_x_norm, local_y_norm) + if not closest: + return world_dx, world_dy + segment_index, pos, local_pos_norm = closest + + # 2. Get the tangent for orientation from the normalized geometry. + tangent_result = vectors.get_tangent_at(segment_index, pos) + if not tangent_result: + return world_dx, world_dy + + local_tangent_norm = tangent_result + + # 3. Update the temporary copy, NOT the document model's data. + if self._dragged_tab_state: + self._dragged_tab_state.segment_index = segment_index + self._dragged_tab_state.pos = pos + + # 4. Update the handle's internal geometry caches for fast visual + # preview. + self._local_pos_norm = local_pos_norm + self._local_tangent_norm = local_tangent_norm + + # 5. Updating the transform. This will trigger a redraw. + # See the drag_handler_controls_transform constructor argument + # above. + self.update_transform() + + return world_dx, world_dy + + def render(self, ctx: cairo.Context): + """ + Overrides render to ensure transform is always up-to-date before + drawing. + """ + self.update_transform() + super().render(ctx) + + def update_base_geometry(self): + """ + Calculates the handle's position and tangent vector based on its + data model. This is used for initialization and non-performance + -critical updates. + """ + parent_view = cast("WorkPieceElement", self.parent) + tab = cast(Tab, self.data) + if not parent_view.data.boundaries or tab.segment_index >= len( + parent_view.data.boundaries + ): + return + + point_result = parent_view.data.boundaries.get_point_at( + tab.segment_index, tab.pos + ) + tangent_result = parent_view.data.boundaries.get_tangent_at( + tab.segment_index, tab.pos + ) + if not point_result or not tangent_result: + return + + local_pos_norm = point_result[:2] + local_tangent_norm = tangent_result + + self._local_pos_norm = local_pos_norm + self._local_tangent_norm = local_tangent_norm + + def update_transform(self): + """ + Calculates and sets this handle's transform to be a fixed pixel size + with the correct orientation, regardless of parent transformations. + """ + if not self.canvas or not self.parent: + return + + parent_element = cast(CanvasElement, self.parent) + work_surface = cast("WorkSurface", self.canvas) + + # 1. Get transforms and scales + parent_world_transform = parent_element.get_world_transform() + zoom_x, zoom_y = work_surface.get_view_scale() + + # 2. Transform local normalized pos/tangent into world space + world_pos = parent_world_transform.transform_point( + self._local_pos_norm + ) + world_tangent = parent_world_transform.transform_vector( + self._local_tangent_norm + ) + + # 3. Calculate visually correct angle from the world-space tangent + world_angle_rad = math.atan2(world_tangent[1], world_tangent[0]) + + # 4. Define handle size in pixels and convert to world units + TARGET_WIDTH_PX = 10.0 + TARGET_LENGTH_PX = 22.0 + handle_width_world = TARGET_WIDTH_PX / zoom_x + handle_length_world = TARGET_LENGTH_PX / zoom_y + + # 5. Construct the desired handle transform in WORLD space + desired_world_transform = ( + Matrix.translation(world_pos[0], world_pos[1]) + @ Matrix.rotation(math.degrees(world_angle_rad)) + @ Matrix.scale(handle_width_world, handle_length_world) + @ Matrix.translation(-0.5, -0.5) # Center handle on its origin + ) + + # 6. Convert this world transform back into the handle's LOCAL + # transform + try: + inv_parent_world = parent_world_transform.invert() + local_transform = inv_parent_world @ desired_world_transform + self.set_transform(local_transform) + except np.linalg.LinAlgError: + # Invert can fail if parent is scaled to zero + pass + + def draw(self, ctx: cairo.Context): + """Draws the tab handle as a themed slot shape with a grip.""" + if not self.canvas: + return + + style_context = self.canvas.get_style_context() + + # Define fallback RGBA colors + fallback_bg = Gdk.RGBA(red=0.3, green=0.5, blue=0.9, alpha=0.8) + fallback_fg = Gdk.RGBA(red=0.5, green=0.5, blue=0.9, alpha=0.9) + fallback_grip = Gdk.RGBA(red=0.9, green=0.9, blue=0.9, alpha=0.5) + + # Use accent colors as required + found, bg_color = style_context.lookup_color("accent_bg_color") + bg_color = bg_color if found else fallback_bg + found, fg_color = style_context.lookup_color("accent_color") + fg_color = fg_color if found else fallback_fg + found, grip_color = style_context.lookup_color("accent_fg_color") + grip_color = grip_color if found else fallback_grip + + if self.is_hovered: + bg_color.alpha = min(1.0, bg_color.alpha + 0.15) + fg_color.alpha = 1.0 + + # Deconstruct the element's transform to find its screen geometry + original_ctm = ctx.get_matrix() + p00 = original_ctm.transform_point(0, 0) + p10 = original_ctm.transform_point(1, 0) + p01 = original_ctm.transform_point(0, 1) + + vx_w, vy_w = p10[0] - p00[0], p10[1] - p00[1] + screen_width = math.hypot(vx_w, vy_w) + + vx_l, vy_l = p01[0] - p00[0], p01[1] - p00[1] + screen_length = math.hypot(vx_l, vy_l) + + if screen_width < 1 or screen_length < 1: + return + + orientation_angle_rad = math.atan2(vy_l, vx_l) + + # Draw the shape in a clean, screen-aligned coordinate system + ctx.save() + try: + ctx.identity_matrix() + center_x = p00[0] + (vx_w + vx_l) / 2.0 + center_y = p00[1] + (vy_w + vy_l) / 2.0 + ctx.translate(center_x, center_y) + ctx.rotate(orientation_angle_rad - math.pi / 2.0) + + w, h = screen_width, screen_length + + def _create_slot_path(): + """Helper to build the slot path geometry.""" + ctx.new_path() + if h >= w: # Taller than wide + radius = w / 2.0 + y1 = -(h / 2.0 - radius) + y2 = h / 2.0 - radius + ctx.arc(0, y1, radius, math.pi, 0) + ctx.line_to(radius, y2) + ctx.arc(0, y2, radius, 0, math.pi) + ctx.close_path() + else: # Wider than tall + radius = h / 2.0 + x1 = -(w / 2.0 - radius) + x2 = w / 2.0 - radius + ctx.arc(x1, 0, radius, math.pi / 2.0, 3.0 * math.pi / 2.0) + ctx.line_to(x2, -radius) + ctx.arc(x2, 0, radius, 3.0 * math.pi / 2.0, math.pi / 2.0) + ctx.close_path() + + # 1. Fill the slot background + _create_slot_path() + ctx.set_source_rgba( + bg_color.red, bg_color.green, bg_color.blue, bg_color.alpha + ) + ctx.fill() + + # 2. Draw the grip lines on top of the fill + ctx.new_path() + if h >= w: # Vertical slot: lines are horizontal + grip_len = w * 0.7 + x_start, x_end = -grip_len / 2.0, grip_len / 2.0 + y_spacing = h * 0.15 + ctx.move_to(x_start, -y_spacing) + ctx.line_to(x_end, -y_spacing) + ctx.move_to(x_start, 0) + ctx.line_to(x_end, 0) + ctx.move_to(x_start, y_spacing) + ctx.line_to(x_end, y_spacing) + else: # Horizontal slot: lines are vertical + grip_len = h * 0.8 + y_start, y_end = -grip_len / 2.0, grip_len / 2.0 + x_spacing = w * 0.15 + ctx.move_to(-x_spacing, y_start) + ctx.line_to(-x_spacing, y_end) + ctx.move_to(0, y_start) + ctx.line_to(0, y_end) + ctx.move_to(x_spacing, y_start) + ctx.line_to(x_spacing, y_end) + + ctx.set_line_width(1.5) + ctx.set_source_rgba( + grip_color.red, + grip_color.green, + grip_color.blue, + grip_color.alpha * 0.6, + ) + ctx.stroke() + + # 3. Stroke the slot outline on top of everything + _create_slot_path() + ctx.set_line_width(1.0) + ctx.set_source_rgba( + fg_color.red, + fg_color.green, + fg_color.blue, + fg_color.alpha * 0.6, + ) + ctx.stroke() + + finally: + ctx.restore() diff --git a/rayforge/ui_gtk/canvas2d/elements/work_origin.py b/rayforge/ui_gtk/canvas2d/elements/work_origin.py new file mode 100644 index 000000000..6179be10a --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/work_origin.py @@ -0,0 +1,81 @@ +import cairo + +from ...canvas import CanvasElement + + +class WorkOriginElement(CanvasElement): + """ + A non-interactive CanvasElement that draws a CNC-style work origin + symbol (a quadrant with two axes arrows). Its position on the canvas + represents the physical location of the active Work Coordinate System's + zero point. + """ + + def __init__(self, **kwargs): + # The element's size is in world units (mm), so it scales with zoom. + super().__init__( + x=0, + y=0, + width=15.0, + height=15.0, + selectable=False, + draggable=False, + clip=False, # Allow drawing outside bounds when scaled/flipped + **kwargs, + ) + self.x_axis_right = False + self.y_axis_down = False + + def set_axis_direction(self, x_axis_right: bool, y_axis_down: bool): + """ + Configure the arrow directions from the displayed axis orientation. + """ + if ( + self.x_axis_right == x_axis_right + and self.y_axis_down == y_axis_down + ): + return + + self.x_axis_right = x_axis_right + self.y_axis_down = y_axis_down + + # Trigger a redraw when orientation changes + if self.canvas: + self.canvas.queue_draw() + + def draw(self, ctx: cairo.Context): + """ + Renders the origin symbol. + """ + ctx.save() + + # Set drawing properties + ctx.set_source_rgba(0.2, 0.8, 0.2, 0.9) # A distinct green color + ctx.set_line_width(0.2) # Use a thin line width in world units (mm) + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + ctx.set_line_join(cairo.LINE_JOIN_ROUND) + + # Determine visual direction of Positive X arrow. + # If Origin is Right (Machine 0 at Right): Values increase Left. + # (Scale -1) + scale_x = -1.0 if self.x_axis_right else 1.0 + + # Determine visual direction of Positive Y arrow. + scale_y = -1.0 if self.y_axis_down else 1.0 + + ctx.scale(scale_x, scale_y) + + # --- Draw X-Axis with Arrow --- + axis_len = self.width + ctx.new_path() + ctx.move_to(0, 0) + ctx.line_to(axis_len, 0) + ctx.stroke() + + # --- Draw Y-Axis with Arrow --- + ctx.new_path() + ctx.move_to(0, 0) + ctx.line_to(0, axis_len) + ctx.stroke() + + ctx.restore() diff --git a/rayforge/ui_gtk/canvas2d/elements/workpiece.py b/rayforge/ui_gtk/canvas2d/elements/workpiece.py new file mode 100644 index 000000000..e6405ef3f --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/workpiece.py @@ -0,0 +1,1298 @@ +import logging +import math +from typing import TYPE_CHECKING, Optional, cast + +import cairo +import numpy as np +from gi.repository import Gdk, GLib +from raygeo.geo import Arc, Bezier, Geometry, Line, Matrix, Move +from raygeo.image.composite import composite_views_into + +from ....core.step import Step +from ....core.workpiece import WorkPiece +from ....pipeline.artifact import ( + BaseArtifactHandle, + WorkPieceArtifact, +) +from ...canvas import CanvasElement +from ..ops_cache_registry import registry +from .tab_handle import TabHandleElement + +if TYPE_CHECKING: + from ....pipeline.view import ViewManager + from ..surface import WorkSurface + +logger = logging.getLogger(__name__) + +# Cairo has a hard limit on surface dimensions. +CAIRO_MAX_DIMENSION = 8192 +OPS_MARGIN_PX = 5 +REC_MARGIN_MM = 0.1 # A small "safe area" margin in mm for recordings +CONTOUR_HIT_THRESHOLD_PX = 8.0 + + +def _draw_segment(ctx: cairo.Context, data: list, idx: int): + """Draws a single segment (LINE/ARC/BEZIER) to a cairo context.""" + cmd = data[idx] + if isinstance(cmd, Move): + return + ex, ey = cmd.end[0], cmd.end[1] + if idx > 0: + prev = data[idx - 1] + sx = prev.end[0] if hasattr(prev, "end") else 0.0 + sy = prev.end[1] if hasattr(prev, "end") else 0.0 + else: + sx, sy = 0.0, 0.0 + if isinstance(cmd, Line): + ctx.move_to(sx, sy) + ctx.line_to(ex, ey) + elif isinstance(cmd, Bezier): + ctx.move_to(sx, sy) + ctx.curve_to( + cmd.control1[0], + cmd.control1[1], + cmd.control2[0], + cmd.control2[1], + ex, + ey, + ) + elif isinstance(cmd, Arc): + ci, cj = cmd.center_offset[0], cmd.center_offset[1] + r = math.hypot(ci, cj) + cx, cy = sx + ci, sy + cj + start_angle = math.atan2(-cj, -ci) + end_angle = math.atan2(ey - cy, ex - cx) + cw = cmd.clockwise + ctx.move_to(sx, sy) + if cw: + ctx.arc_negative(cx, cy, r, start_angle, end_angle) + else: + ctx.arc(cx, cy, r, start_angle, end_angle) + + +class VectorEditState: + """Tracks the state of an in-progress vector segment edit session.""" + + def __init__(self, geometry: Geometry): + self.geometry = geometry + self.selected_segments: set[int] = set() + self.hovered_segment: int | None = None + self.frame_start: tuple[float, float] | None = None + self.frame_end: tuple[float, float] | None = None + self.frame_drag_start_world: tuple[float, float] | None = None + + +class WorkPieceElement(CanvasElement): + """A unified CanvasElement that visualizes a single WorkPiece model. + + This class customizes its rendering by overriding the `draw` + method to correctly handle the coordinate system transform (from the + canvas's Y-Up world to Cairo's Y-Down surfaces) for both the base + image and all ops overlays. + + By setting `clip=False`, this element signals to the base `render` + method that its drawing should not be clipped to its geometric bounds. + This allows the ops margin to be drawn correctly. + """ + + def __init__( + self, + workpiece: WorkPiece, + view_manager: "ViewManager", + **kwargs, + ): + """Initializes the WorkPieceElement. + + Args: + workpiece: The WorkPiece data model to visualize. + view_manager: The ViewManager for view rendering. + **kwargs: Additional arguments for the CanvasElement. + """ + logger.debug(f"Initializing WorkPieceElement for '{workpiece.name}'") + self.data: WorkPiece = workpiece + self.view_manager = view_manager + self._base_image_visible = True + self._surface: cairo.ImageSurface | None = None + + self._ops_visibility: dict[str, bool] = {} + self._artifact_cache: dict[str, WorkPieceArtifact | None] = {} + self._ops_surface_cache: dict[str, cairo.ImageSurface] = {} + self._ops_surface_data_cache: dict[str, np.ndarray] = {} + self._ops_metadata_cache: dict[str, tuple] = {} + + # Composited ops surface: a single surface that blends all + # visible step surfaces, rebuilt incrementally. + self._composited_surface: cairo.ImageSurface | None = None + self._composited_data: np.ndarray | None = None + self._composited_dirty: bool = True + self._composited_bbox_mm: tuple | None = None + self._composited_wp_size_mm: tuple | None = None + self._composited_bytes: int = 0 + + self._tab_handles: list[TabHandleElement] = [] + # Default to False; the correct state will be pulled from the surface. + self._tabs_visible_override: bool = False + + self._rendered_ppm: float = 0.0 + + # The element's geometry is a 1x1 unit square. + # The transform matrix handles all scaling and positioning. + super().__init__( + 0.0, + 0.0, + 1.0, + 1.0, + data=workpiece, + # clip must be False so the parent `render` method + # does not clip the drawing, allowing margins to show. + clip=False, + buffered=True, + pixel_perfect_hit=True, + hit_distance=5, + is_editable=True, + **kwargs, + ) + + # After super().__init__, self.canvas is set. Pull the initial + # tab visibility state from the WorkSurface, which is the state owner. + if self.canvas: + work_surface = cast("WorkSurface", self.canvas) + self._tabs_visible_override = ( + work_surface.get_global_tab_visibility() + ) + + self.content_transform = Matrix.translation(0, 1) @ Matrix.scale(1, -1) + + self._edit_state: VectorEditState | None = None + + self.data.updated.connect(self._on_model_content_changed) + self.data.transform_changed.connect(self._on_transform_changed) + registry.register(self) + + self.view_manager.source_artifact_ready.connect( + self._on_source_artifact_ready + ) + self.view_manager.view_artifact_updated.connect( + self._on_view_artifact_updated + ) + self.view_manager.view_artifact_created.connect( + self._on_view_artifact_created + ) + self.view_manager.generation_finished.connect( + self._on_view_generation_finished + ) + + # Track the last known model size to detect size changes even when + # the transform matrix is pre-synced (e.g. during interactive drags). + self._last_synced_size = self.data.size + + # Attempt to hydrate visual state from the model's transient cache + hydrated = self._hydrate_from_cache() + + self._on_transform_changed(self.data) + self._create_or_update_tab_handles() + + # Only invalidate if we didn't recover state from the cache. + if not hydrated: + self.invalidate_and_rerender() + else: + # We recovered state, but verify if a repaint is needed + super().trigger_update() + + self._update_editable_state() + + def _hydrate_from_cache(self) -> bool: + """ + Restores visual state from the persistent model cache if available. + Returns True if significant state was restored. + """ + cache = self.data._view_cache + if not cache: + return False + + self._surface = cache.get("surface") + if self._surface is not None: + self.surface = self._surface + self._artifact_cache = cache.get("artifact_cache", {}).copy() + + return self._surface is not None or len(self._artifact_cache) > 0 + + def _update_model_view_cache(self): + """ + Updates the persistent cache on the model with current view state. + """ + cache = self.data._view_cache + cache["surface"] = self._surface + cache["artifact_cache"] = self._artifact_cache + + def invalidate_and_rerender(self): + """ + Invalidates all cached rendering artifacts (base image and all ops) + and schedules a full re-render. This should be called whenever the + element's content or size changes. + """ + logger.debug(f"Full invalidation for workpiece '{self.data.name}'") + self._rendered_ppm = 0.0 + self._artifact_cache.clear() + self.clear_all_ops_caches() + + # Clear the model cache as well, since the data is invalid + self.data._view_cache.clear() + + if self.data.layer and self.data.layer.workflow: + for step in self.data.layer.workflow.steps: + self.clear_ops_surface(step.uid) + super().trigger_update() + + def trigger_view_update(self, ppm: float = 0.0) -> bool: + """ + Invalidates resolution-dependent caches (raster surfaces) and + triggers a re-render. This is called on view changes like zooming. + It preserves expensive-to-generate data like vector recordings. + + Only re-renders if the new resolution (ppm) is higher than what + was previously rendered, since scaling down an existing image + doesn't require re-rendering. + + Returns True if a re-render was triggered, False if skipped. + """ + if ppm <= self._rendered_ppm: + return False + + logger.debug(f"View update for workpiece '{self.data.name}'") + self._rendered_ppm = ppm + + # Note: We do NOT clear self._surface here to prevent flicker. + # The old surface will be replaced once the new one is ready + # in _apply_surface(). + + # Note: We do NOT clear the model cache here, as view updates + # (like zooming) shouldn't erase the persistent data needed by + # other views or future rebuilds. + + super().trigger_update() # Re-renders the base image. + return True + + def _apply_surface(self, new_surface: cairo.ImageSurface | None) -> bool: + """ + Applies the newly rendered surface from the background task. + + This override prevents flicker by only replacing the surface + if the new one is valid. If the new surface is None, the old + surface is kept as a fallback. + + Args: + new_surface: The new surface to apply, or None. + """ + if new_surface is not None: + self.surface = new_surface + self._surface = new_surface + self._update_model_view_cache() + self.mark_dirty(ancestors=True) + if self.canvas: + self.canvas.queue_draw() + self._update_future = None + return False + + def _update_ops_cache(self, step_uid: str): + """ + Loads the view bitmap from the ViewManager and caches it. + + Reads the current bitmap from the ViewManager's in-memory cache, + creates a Cairo surface wrapper around it, and stores the result + in the per-step ops cache. + + Skips if no view is available or the buffer is entirely blank. + """ + result = self.view_manager.get_view_bitmap(self.data.uid, step_uid) + if result is None: + return + + bitmap, bbox_mm, workpiece_size_mm = result + if bitmap is None: + return + + try: + if not np.any(bitmap): + self._remove_ops_surface(step_uid) + self._invalidate_composited() + return + height, width, _ = bitmap.shape + stride = cairo.ImageSurface.format_stride_for_width( + cairo.FORMAT_ARGB32, width + ) + new_surface = cairo.ImageSurface.create_for_data( + bitmap, + cairo.FORMAT_ARGB32, + width, + height, + stride, + ) + self._store_ops_surface( + step_uid, + new_surface, + bitmap, + (bbox_mm, workpiece_size_mm), + ) + except (cairo.Error, ValueError) as e: + logger.warning( + f"Failed to update ops cache for step {step_uid}: {e}" + ) + + def _store_ops_surface( + self, + step_uid: str, + surface: cairo.ImageSurface, + data: np.ndarray, + metadata: tuple, + ): + """ + Stores a step's Cairo surface, backing data, and metadata. + + *data* is a view into shared memory managed by the artifact + store, so its byte size is not tracked in the OpsCacheRegistry. + """ + self._ops_surface_cache[step_uid] = surface + self._ops_surface_data_cache[step_uid] = data + self._ops_metadata_cache[step_uid] = metadata + + def _remove_ops_surface(self, step_uid: str): + """Removes a step's cached surface, data, and metadata.""" + self._ops_surface_cache.pop(step_uid, None) + self._ops_surface_data_cache.pop(step_uid, None) + self._ops_metadata_cache.pop(step_uid, None) + + def clear_all_ops_caches(self): + """ + Removes every step cache entry and disposes the composite. + + Called by the registry's LRU eviction and during full invalidation. + """ + for step_uid in list(self._ops_surface_cache.keys()): + self._remove_ops_surface(step_uid) + self._invalidate_composited() + + def _invalidate_composited(self): + """Marks the composited surface as needing a full rebuild.""" + self._composited_dirty = True + self._dispose_composited() + + def _dispose_composited(self): + if self._composited_bytes: + registry.remove( + self.data.uid, "__composite__", self._composited_bytes + ) + self._composited_surface = None + self._composited_data = None + self._composited_bbox_mm = None + self._composited_wp_size_mm = None + self._composited_bytes = 0 + + def _rebuild_composited_surface(self): + """ + Builds a single composited surface from all visible step caches. + + For each workflow step that is visible and has data, loads it + from the view handle on demand (if not already cached), computes + the union bounding box at the highest step PPM, allocates a + single ARGB32 buffer, and blits each step into position. + + The composite buffer is reused across rebuilds when dimensions + match, avoiding repeated large allocations. Only the composite + itself (a heap allocation) is tracked in the OpsCacheRegistry. + + Called from ``draw()`` when ``_composited_dirty`` is True. + """ + if not self.data.layer or not self.data.layer.workflow: + self._dispose_composited() + self._composited_dirty = False + return + + edited = self.data._edited_boundaries + if edited is not None and edited.is_empty(): + self._dispose_composited() + self._composited_dirty = False + return + + world_w, world_h = self.data.size + if world_w < 1e-9 or world_h < 1e-9: + self._dispose_composited() + self._composited_dirty = False + return + + visible_steps = [] + for step in self.data.layer.workflow.steps: + if not self._ops_visibility.get(step.uid, True): + continue + if step.uid not in self._ops_surface_cache: + self._update_ops_cache(step.uid) + meta = self._ops_metadata_cache.get(step.uid) + if meta is None: + continue + surf = self._ops_surface_cache.get(step.uid) + if surf is None: + continue + visible_steps.append((step.uid, surf, meta)) + + if not visible_steps: + self._dispose_composited() + self._composited_dirty = False + return + + # Compute union of all scaled bboxes in workpiece-local mm space. + # Track per-axis PPM so that the CAIRO_MAX_DIMENSION cap, which may + # affect one axis more than the other for non-square workpieces, does + # not corrupt the opposite axis' positioning. + union_x = float("inf") + union_y = float("inf") + union_r = float("-inf") + union_t = float("-inf") + ppm_x = 0.0 + ppm_y = 0.0 + + for step_uid, surf, meta in visible_steps: + bbox_mm, wp_size_mm = meta + vx, vy, vw, vh = bbox_mm + ref_w, ref_h = wp_size_mm + sx = world_w / ref_w if ref_w > 1e-9 else 1.0 + sy = world_h / ref_h if ref_h > 1e-9 else 1.0 + vx *= sx + vy *= sy + vw *= sx + vh *= sy + if vw < 1e-9 or vh < 1e-9: + continue + w_px = surf.get_width() + h_px = surf.get_height() + step_ppm_x = (w_px - 2 * OPS_MARGIN_PX) / vw if vw > 1e-9 else 0 + step_ppm_y = (h_px - 2 * OPS_MARGIN_PX) / vh if vh > 1e-9 else 0 + ppm_x = max(ppm_x, step_ppm_x) + ppm_y = max(ppm_y, step_ppm_y) + margin_w = OPS_MARGIN_PX / step_ppm_x if step_ppm_x > 0 else 0 + margin_h = OPS_MARGIN_PX / step_ppm_y if step_ppm_y > 0 else 0 + union_x = min(union_x, vx - margin_w) + union_y = min(union_y, vy - margin_h) + union_r = max(union_r, vx + vw + margin_w) + union_t = max(union_t, vy + vh + margin_h) + + if ppm_x <= 0 or ppm_y <= 0: + self._dispose_composited() + self._composited_dirty = False + return + + composite_w_mm = union_r - union_x + composite_h_mm = union_t - union_y + comp_w_px = min(round(composite_w_mm * ppm_x), CAIRO_MAX_DIMENSION) + comp_h_px = min(round(composite_h_mm * ppm_y), CAIRO_MAX_DIMENSION) + + if comp_w_px <= 0 or comp_h_px <= 0: + self._dispose_composited() + self._composited_dirty = False + return + + # Effective PPM per axis after capping surface dimensions. When the + # 8192px cap binds on one axis but not the other, these differ and + # must be applied independently to keep each axis aligned. + eff_ppm_x = ( + comp_w_px / composite_w_mm if composite_w_mm > 1e-9 else ppm_x + ) + eff_ppm_y = ( + comp_h_px / composite_h_mm if composite_h_mm > 1e-9 else ppm_y + ) + + if ( + self._composited_data is not None + and self._composited_data.shape == (comp_h_px, comp_w_px, 4) + ): + comp_data = self._composited_data + comp_data[:] = 0 + else: + comp_data = np.zeros((comp_h_px, comp_w_px, 4), dtype=np.uint8) + stride = cairo.ImageSurface.format_stride_for_width( + cairo.FORMAT_ARGB32, comp_w_px + ) + comp_surf = cairo.ImageSurface.create_for_data( + comp_data, cairo.FORMAT_ARGB32, comp_w_px, comp_h_px, stride + ) + + # Composite each step bitmap into the target buffer. + views: list = [] + for step_uid, surf, meta in visible_steps: + bbox_mm, wp_size_mm = meta + vx, vy, vw, vh = bbox_mm + ref_w, ref_h = wp_size_mm + sx = world_w / ref_w if ref_w > 1e-9 else 1.0 + sy = world_h / ref_h if ref_h > 1e-9 else 1.0 + vx *= sx + vy *= sy + vw *= sx + vh *= sy + if vw < 1e-9 or vh < 1e-9: + continue + step_ppm_x = ( + (surf.get_width() - 2 * OPS_MARGIN_PX) / vw if vw > 1e-9 else 0 + ) + step_ppm_y = ( + (surf.get_height() - 2 * OPS_MARGIN_PX) / vh + if vh > 1e-9 + else 0 + ) + if step_ppm_x <= 0 or step_ppm_y <= 0: + continue + dest_x = (vx - OPS_MARGIN_PX / step_ppm_x - union_x) * eff_ppm_x + scale_x = eff_ppm_x / step_ppm_x + scale_y = eff_ppm_y / step_ppm_y + surf_h = surf.get_height() + dest_y = ( + comp_h_px + - (vy - union_y) * eff_ppm_y + - (surf_h - OPS_MARGIN_PX) * scale_y + ) + src_data = self._ops_surface_data_cache[step_uid] + views.append((src_data, dest_x, dest_y, scale_x, scale_y)) + + composite_views_into(comp_data, views) + + self._dispose_composited() + self._composited_surface = comp_surf + self._composited_data = comp_data + self._composited_bbox_mm = ( + union_x, + union_y, + composite_w_mm, + composite_h_mm, + ) + self._composited_wp_size_mm = (world_w, world_h) + self._composited_bytes = comp_data.nbytes + registry.add(self.data.uid, "__composite__", self._composited_bytes) + self._composited_dirty = False + + def _on_view_artifact_created( + self, + sender, + *, + step_uid: str, + workpiece_uid: str, + handle: BaseArtifactHandle, + **kwargs, + ): + """ + Handles the creation of a new view artifact. + + Invalidates the ops surface cache for this step so that + ``_rebuild_composited_surface`` will reload from the new + handle on the next draw. + """ + if workpiece_uid != self.data.uid or not self.canvas: + return + self._remove_ops_surface(step_uid) + self._composited_dirty = True + self.canvas.queue_draw() + + def _on_view_artifact_updated( + self, + sender, + *, + step_uid: str, + workpiece_uid: str, + handle: BaseArtifactHandle, + **kwargs, + ): + """ + Handles progressive chunk updates from the background worker. + + Marks the composite dirty and schedules a redraw. The actual + data loading happens lazily in ``_rebuild_composited_surface`` + during the next ``draw()`` call, so this method is O(1). + + For invisible steps, the cache is evicted immediately so the + composite is rebuilt without them. + """ + if workpiece_uid != self.data.uid or not self.canvas: + return + if not self._ops_visibility.get(step_uid, True): + self._remove_ops_surface(step_uid) + self._composited_dirty = True + self.canvas.queue_draw() + return + self._composited_dirty = True + self.canvas.queue_draw() + + def _on_view_generation_finished( + self, + sender, + *, + key, + workpiece_uid: str, + step_uid: str, + **kwargs, + ): + """ + Handler for when view generation is complete. + This is the safe time to update the ops surface cache. + """ + if workpiece_uid != self.data.uid: + return + edited = self.data._edited_boundaries + if edited is not None and edited.is_empty(): + self._remove_ops_surface(step_uid) + self._invalidate_composited() + return + self._update_ops_cache(step_uid) + self._composited_dirty = True + if self.canvas: + self.canvas.queue_draw() + + def get_closest_point_on_path( + self, world_x: float, world_y: float, threshold_px: float = 5.0 + ) -> dict | None: + """ + Checks if a point in world coordinates is close to the workpiece's + vector path. + + Args: + world_x: The x-coordinate in world space (mm). + world_y: The y-coordinate in world space (mm). + threshold_px: The maximum distance in screen pixels to be + considered "close". + + Returns: + A dictionary with location info + `{'segment_index': int, 't': float}` + if the point is within the threshold, otherwise None. + """ + if not self.data.boundaries or not self.canvas: + return None + + work_surface = cast("WorkSurface", self.canvas) + + # 1. Convert pixel threshold to a world-space (mm) threshold + ppm_x, _ = work_surface.get_view_scale() + if ppm_x < 1e-9: + return None + threshold_mm = threshold_px / ppm_x + + # 2. Transform click coordinates to local, natural millimeter space + try: + inv_world_transform = self.get_world_transform().invert() + local_x_norm, local_y_norm = inv_world_transform.transform_point( + (world_x, world_y) + ) + except np.linalg.LinAlgError: + return None # Transform not invertible + + natural_size = self.data.natural_size + if natural_size and None not in natural_size: + natural_w, natural_h = cast(tuple[float, float], natural_size) + else: + natural_w, natural_h = self.data.get_local_size() + + if natural_w <= 1e-9 or natural_h <= 1e-9: + return None + + local_x_mm = local_x_norm * natural_w + local_y_mm = local_y_norm * natural_h + + # 3. Find closest point on path in local mm space + closest = self.data.boundaries.find_closest_point( + local_x_mm, local_y_mm + ) + if not closest: + return None + + segment_index, t, closest_point_local_mm = closest + + # 4. Transform local closest point back to world space + closest_point_norm_x = closest_point_local_mm[0] / natural_w + closest_point_norm_y = closest_point_local_mm[1] / natural_h + ( + closest_point_world_x, + closest_point_world_y, + ) = self.get_world_transform().transform_point( + (closest_point_norm_x, closest_point_norm_y) + ) + + # 5. Perform distance check in world space + dist_sq_world = (world_x - closest_point_world_x) ** 2 + ( + world_y - closest_point_world_y + ) ** 2 + + if dist_sq_world > threshold_mm**2: + return None + + # 6. Return location info if within threshold + return {"segment_index": segment_index, "t": t} + + def _update_editable_state(self): + if self.data.geometry_provider_uid: + self.is_editable = False + return + boundaries = self.data.boundaries + self.is_editable = boundaries is not None and not boundaries.is_empty() + + def on_edit_mode_enter(self): + boundaries = self.data.boundaries + if boundaries is None or boundaries.is_empty(): + return + self._edit_state = VectorEditState(boundaries) + self.invalidate_and_rerender() + + def on_edit_mode_leave(self): + self._edit_state = None + if self.canvas: + self.canvas.queue_draw() + + def draw_edit_overlay(self, ctx: cairo.Context): + if not self._edit_state or not self.canvas: + return + data = self._edit_state.geometry.data + if data is None: + return + + screen_transform = ( + self.canvas.view_transform @ self.get_world_transform() + ) + sx, sy = screen_transform.get_scale() + screen_scale = max(abs(sx), abs(sy)) + if screen_scale < 1e-9: + return + + ctx.save() + cairo_matrix = cairo.Matrix(*screen_transform.for_cairo()) + ctx.transform(cairo_matrix) + + default_color = (0.5, 0.5, 0.5, 0.6) + default_width = 1.5 / screen_scale + selected_color = (1.0, 0.3, 0.3, 0.9) + selected_width = 2.5 / screen_scale + hovered_color = (0.4, 0.6, 1.0, 0.8) + hovered_width = 2.0 / screen_scale + + prev_color = None + prev_width = None + + for idx in range(len(data)): + cmd = data[idx] + if isinstance(cmd, Move): + continue + + is_sel = idx in self._edit_state.selected_segments + is_hov = idx == self._edit_state.hovered_segment + + if is_sel: + color, width = selected_color, selected_width + elif is_hov: + color, width = hovered_color, hovered_width + else: + color, width = default_color, default_width + + if color != prev_color or width != prev_width: + ctx.set_source_rgba(*color) + ctx.set_line_width(width) + prev_color = color + prev_width = width + + _draw_segment(ctx, data, idx) + ctx.stroke() + + ctx.restore() + + if self._edit_state.frame_start and self._edit_state.frame_end: + self._draw_selection_frame(ctx, screen_transform) + + def _draw_selection_frame(self, ctx, screen_transform): + if not self._edit_state: + return + fs = self._edit_state.frame_start + fe = self._edit_state.frame_end + if fs is None or fe is None: + return + s1 = screen_transform.transform_point(fs) + s2 = screen_transform.transform_point(fe) + ctx.save() + ctx.set_source_rgba(0.2, 0.5, 0.8, 0.3) + fx, fy = min(s1[0], s2[0]), min(s1[1], s2[1]) + fw, fh = abs(s2[0] - s1[0]), abs(s2[1] - s1[1]) + ctx.rectangle(fx, fy, fw, fh) + ctx.fill_preserve() + ctx.set_source_rgb(0.2, 0.5, 0.8) + ctx.set_line_width(1.0) + ctx.set_dash((4, 4)) + ctx.stroke() + ctx.restore() + + def _hit_test_segment(self, world_x: float, world_y: float) -> int | None: + if not self._edit_state or not self.canvas: + return None + + work_surface = cast("WorkSurface", self.canvas) + ppm_x, _ = work_surface.get_view_scale() + if ppm_x < 1e-9: + return None + + try: + inv_world = self.get_world_transform().invert() + local_x, local_y = inv_world.transform_point((world_x, world_y)) + except np.linalg.LinAlgError: + return None + + world_w, world_h = self.data.size + if world_w < 1e-9 or world_h < 1e-9: + return None + + threshold_01 = CONTOUR_HIT_THRESHOLD_PX / (ppm_x * world_w) + threshold_sq = threshold_01 * threshold_01 + + result = self._edit_state.geometry.find_closest_point(local_x, local_y) + if result is None: + return None + + seg_idx, _, closest_pt = result + dx = local_x - closest_pt[0] + dy = local_y - closest_pt[1] + if dx * dx + dy * dy <= threshold_sq: + return seg_idx + return None + + def _segments_in_frame( + self, x1: float, y1: float, x2: float, y2: float + ) -> set[int]: + if not self._edit_state: + return set() + return set(self._edit_state.geometry.segments_in_frame(x1, y1, x2, y2)) + + def _world_to_local( + self, wx: float, wy: float + ) -> tuple[float, float] | None: + try: + inv = self.get_world_transform().invert() + return inv.transform_point((wx, wy)) + except np.linalg.LinAlgError: + return None + + def handle_edit_press( + self, world_x: float, world_y: float, n_press: int = 1 + ) -> bool: + if not self._edit_state: + return False + + if n_press >= 2: + return False + + hit = self._hit_test_segment(world_x, world_y) + + if hit is None: + local = self._world_to_local(world_x, world_y) + if local is not None: + self._edit_state.frame_start = local + self._edit_state.frame_end = local + self._edit_state.frame_drag_start_world = ( + world_x, + world_y, + ) + return True + + shift_pressed = self.canvas._shift_pressed if self.canvas else False + + if shift_pressed: + if hit in self._edit_state.selected_segments: + self._edit_state.selected_segments.discard(hit) + else: + self._edit_state.selected_segments.add(hit) + else: + self._edit_state.selected_segments = {hit} + + if self.canvas: + self.canvas.queue_draw() + return True + + def handle_edit_drag(self, world_dx: float, world_dy: float): + if not self._edit_state or not self.canvas: + return + if self._edit_state.frame_drag_start_world is None: + return + + swx, swy = self._edit_state.frame_drag_start_world + cur_wx = swx + world_dx + cur_wy = swy + world_dy + + cur_local = self._world_to_local(cur_wx, cur_wy) + if cur_local is None: + return + + start_local = self._world_to_local(swx, swy) + if start_local is None: + return + + self._edit_state.frame_start = start_local + self._edit_state.frame_end = cur_local + self.canvas.queue_draw() + + def handle_edit_release(self, world_x: float, world_y: float): + if not self._edit_state: + return + if self._edit_state.frame_start and self._edit_state.frame_end: + x1, y1 = self._edit_state.frame_start + x2, y2 = self._edit_state.frame_end + if abs(x2 - x1) > 1e-9 or abs(y2 - y1) > 1e-9: + in_frame = self._segments_in_frame(x1, y1, x2, y2) + shift = self.canvas._shift_pressed if self.canvas else False + if shift: + self._edit_state.selected_segments ^= in_frame + else: + self._edit_state.selected_segments = in_frame + else: + if not (self.canvas._shift_pressed if self.canvas else False): + self._edit_state.selected_segments.clear() + self._edit_state.frame_start = None + self._edit_state.frame_end = None + self._edit_state.frame_drag_start_world = None + if self.canvas: + self.canvas.queue_draw() + + def handle_edit_motion(self, world_x: float, world_y: float) -> bool: + if not self._edit_state: + return False + + hit = self._hit_test_segment(world_x, world_y) + + if hit != self._edit_state.hovered_segment: + self._edit_state.hovered_segment = hit + if self.canvas: + self.canvas.queue_draw() + return True + + def handle_edit_key(self, keyval: int) -> bool: + if not self._edit_state: + return False + + if keyval in (Gdk.KEY_Delete, Gdk.KEY_BackSpace): + if not self._edit_state.selected_segments: + return True + work_surface = cast("WorkSurface", self.canvas) + work_surface.editor.edit.delete_segments( + self.data, self._edit_state.selected_segments + ) + boundaries = self.data.boundaries + if boundaries is None or boundaries.is_empty(): + self.clear_all_ops_caches() + if self.canvas: + self.canvas.leave_edit_mode() + return True + self._edit_state = VectorEditState(boundaries) + if self.canvas: + self.canvas.queue_draw() + return True + + return False + + def handle_edit_select_all(self) -> bool: + if not self._edit_state: + return False + data = self._edit_state.geometry.data + if data is not None: + self._edit_state.selected_segments = { + i for i in range(len(data)) if not isinstance(data[i], Move) + } + else: + self._edit_state.selected_segments = set() + if self.canvas: + self.canvas.queue_draw() + return True + + def remove(self): + """Disconnects signals and removes the element from the canvas.""" + logger.debug(f"Removing WorkPieceElement for '{self.data.name}'") + self.data.updated.disconnect(self._on_model_content_changed) + self.data.transform_changed.disconnect(self._on_transform_changed) + self.view_manager.source_artifact_ready.disconnect( + self._on_source_artifact_ready + ) + self.view_manager.view_artifact_updated.disconnect( + self._on_view_artifact_updated + ) + self.view_manager.view_artifact_created.disconnect( + self._on_view_artifact_created + ) + self.view_manager.generation_finished.disconnect( + self._on_view_generation_finished + ) + self.clear_all_ops_caches() + registry.unregister(self.data.uid) + super().remove() + + def set_base_image_visible(self, visible: bool): + """ + Controls the visibility of the base rendered image, while leaving + ops overlays unaffected. + """ + if self._base_image_visible != visible: + self._base_image_visible = visible + if self.canvas: + self.canvas.queue_draw() + + def set_ops_visibility(self, step_uid: str, visible: bool): + """Sets the visibility for a specific step's ops overlay. + + Args: + step_uid: The unique identifier of the step. + visible: True to make the ops visible, False to hide them. + """ + if self._ops_visibility.get(step_uid, True) != visible: + logger.debug( + f"Setting ops visibility for step '{step_uid}' to {visible}" + ) + self._ops_visibility[step_uid] = visible + self._composited_dirty = True + if self.canvas: + self.canvas.queue_draw() + + def clear_ops_surface(self, step_uid: str): + """ + Removes the cached ops surface for a step and schedules a + composite rebuild. + + Called by ``LayerElement.sync_with_model`` when a step is + deleted from the workflow. Must mark the composite dirty so + the next draw() rebuilds it without the removed step. + """ + logger.debug(f"Clearing ops surface for step '{step_uid}'") + self._remove_ops_surface(step_uid) + self._composited_dirty = True + if self.canvas: + self.canvas.queue_draw() + + def _on_model_content_changed(self, workpiece: WorkPiece): + """Handler for when the workpiece model's content changes.""" + logger.debug( + f"Model content changed for '{workpiece.name}', triggering update." + ) + self._create_or_update_tab_handles() + self._update_editable_state() + self.invalidate_and_rerender() + + def _on_transform_changed( + self, workpiece: WorkPiece, *, old_matrix: Optional["Matrix"] = None + ): + """ + Handler for when the workpiece model's transform changes. + + This is the key fix for the blurriness issue. When the transform + changes, we check if the object's *size* has also changed. If so, + the buffered raster image is now invalid (it would be stretched and + blurry), so we must trigger a full update to re-render it cleanly at + the new resolution. + """ + if not self.canvas: + return + + # Check if the size has changed significantly since the last sync. + # We cannot rely on comparing self.transform vs workpiece.matrix here, + # because interactive tools update self.transform before the model + # commits, leading to them being equal when this signal finally fires. + # However, the cached artifacts/buffers correspond to the *old* size. + new_w, new_h = workpiece.size + old_w, old_h = self._last_synced_size + + if abs(new_w - old_w) > 1e-6 or abs(new_h - old_h) > 1e-6: + self._last_synced_size = (new_w, new_h) + # Sync the transform immediately + self.set_transform(workpiece.matrix) + # Note: We do NOT request view renders here. The pipeline will + # automatically trigger view rendering when the workpiece + # artifact is regenerated after the size change. + super().trigger_update() + else: + # Size hasn't changed, just sync the transform + self.set_transform(workpiece.matrix) + + def _on_source_artifact_ready( + self, + sender, + *, + step: Step, + workpiece: WorkPiece, + handle, + **kwargs, + ): + """ + Signal handler for when source artifact is ready from ViewManager. + This runs on a background thread, so it schedules the actual work + on the main thread to prevent UI deadlocks. + """ + if workpiece is not self.data: + return + + artifact = self.view_manager.store.get(handle) + GLib.idle_add( + self._on_source_artifact_ready_main_thread, + step, + workpiece, + artifact, + ) + + def _on_source_artifact_ready_main_thread( + self, + step: Step, + workpiece: WorkPiece, + artifact: WorkPieceArtifact, + ): + """The thread-safe part of the source artifact ready handler.""" + logger.debug( + f"_on_source_artifact_ready_main_thread called for step " + f"'{step.uid}'" + ) + if workpiece is not self.data: + return + + self._artifact_cache[step.uid] = artifact + self._update_model_view_cache() + + if self.canvas: + self.canvas.queue_draw() + + def render_to_surface( + self, width: int, height: int + ) -> cairo.ImageSurface | None: + """Renders the base workpiece content to a new surface.""" + return self.data.render_to_pixels(width=width, height=height) + + def draw(self, ctx: cairo.Context): + """Draws the element's content and ops overlays. + + The context is already transformed into the element's local 1x1 + Y-UP space. + + Args: + ctx: The cairo context to draw on. + """ + # Check if the workpiece depends on a hidden geometry provider + provider_hidden = False + if self.data.geometry_provider_uid and self.data.doc: + provider = self.data.doc.get_asset_by_uid( + self.data.geometry_provider_uid + ) + if provider and provider.hidden: + provider_hidden = True + + if self._base_image_visible and not provider_hidden: + # This handles the Y-flip for the base image and restores the + # context, leaving it Y-UP for the next drawing operation. + super().draw(ctx) + + # Draw Ops (hide during interaction) + worksurface = cast("WorkSurface", self.canvas) if self.canvas else None + if not worksurface: + return + + if worksurface.ops_suppressed: + return + + # Draw view artifacts (complete, pre-rendered bitmaps) + world_w, world_h = self.data.size + + if world_w < 1e-9 or world_h < 1e-9: + return + + if self.data.layer and self.data.layer.workflow: + registry.touch(self.data.uid) + + if self._composited_dirty: + self._rebuild_composited_surface() + + comp_surf = self._composited_surface + comp_bbox = self._composited_bbox_mm + if comp_surf is None or comp_bbox is None: + return + + try: + view_x, view_y, view_w, view_h = comp_bbox + + if view_w < 1e-9 or view_h < 1e-9: + return + + surface_w_px = comp_surf.get_width() + surface_h_px = comp_surf.get_height() + + ppm_x = surface_w_px / view_w if view_w > 1e-9 else 0 + ppm_y = surface_h_px / view_h if view_h > 1e-9 else 0 + + if ppm_x <= 0 or ppm_y <= 0: + return + + ctx.save() + + ctx.translate(view_x / world_w, view_y / world_h) + ctx.scale( + surface_w_px / (world_w * ppm_x), + surface_h_px / (world_h * ppm_y), + ) + ctx.translate(0, 1) + ctx.scale(1, -1) + ctx.scale(1.0 / surface_w_px, 1.0 / surface_h_px) + + ctx.set_source_surface(comp_surf, 0, 0) + ctx.get_source().set_filter(cairo.FILTER_NEAREST) + ctx.paint() + + ctx.restore() + except cairo.Error as e: + logger.warning( + f"Failed to draw composited ops for " + f"'{self.data.name}': {e}" + ) + + def on_travel_visibility_changed(self): + """Handles changes in travel move visibility.""" + logger.debug("Travel visibility changed. Invalidating composite.") + self._composited_dirty = True + self._update_model_view_cache() + + if self.canvas: + self.canvas.queue_draw() + + def set_tabs_visible_override(self, visible: bool): + """Sets the global visibility override for tab handles.""" + if self._tabs_visible_override != visible: + self._tabs_visible_override = visible + self._update_tab_handle_visibility() + + def _update_tab_handle_visibility(self): + """Applies the current visibility logic to all tab handles.""" + # A handle is visible if the global toggle is on AND tabs are enabled + # on the workpiece model. + is_visible = self._tabs_visible_override and self.data.tabs_enabled + for handle in self._tab_handles: + handle.set_visible(is_visible) + + def _create_or_update_tab_handles(self): + """Creates or replaces TabHandleElements based on the model.""" + # Remove old handles + for handle in self._tab_handles: + if handle in self.children: + self.remove_child(handle) + self._tab_handles.clear() + + # Determine visibility based on the global override and the model flag + is_visible = self._tabs_visible_override and self.data.tabs_enabled + + if not self.data.tabs: + return + + for tab in self.data.tabs: + handle = TabHandleElement(tab_data=tab, parent=self) + # The handle is now responsible for its own geometry. + handle.update_base_geometry() + handle.update_transform() + handle.set_visible(is_visible) + self._tab_handles.append(handle) + self.add(handle) + + def update_handle_transforms(self): + """ + Recalculates transforms for all tab handles. This is called on zoom. + """ + # This method is now only called by the WorkSurface on zoom. + # The live resize update is handled implicitly by the render pass. + for handle in self._tab_handles: + handle.update_transform() diff --git a/rayforge/ui_gtk/canvas2d/ops_cache_registry.py b/rayforge/ui_gtk/canvas2d/ops_cache_registry.py new file mode 100644 index 000000000..2b960b2a8 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/ops_cache_registry.py @@ -0,0 +1,110 @@ +import logging +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .elements.workpiece import WorkPieceElement + +logger = logging.getLogger(__name__) + +MAX_CACHE_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB + + +class OpsCacheRegistry: + """Global registry that caps total ops surface cache memory + across all WorkPieceElements using LRU eviction.""" + + def __init__(self, max_bytes: int = MAX_CACHE_BYTES): + self._max_bytes = max_bytes + self._total_bytes: int = 0 + self._wp_bytes: dict[str, int] = {} + self._wp_last_draw: dict[str, float] = {} + self._wp_elements: dict[str, WorkPieceElement] = {} + self._evicting: bool = False + + @property + def total_bytes(self) -> int: + return self._total_bytes + + @property + def max_bytes(self) -> int: + return self._max_bytes + + def register(self, element: "WorkPieceElement"): + uid = element.data.uid + self._wp_elements[uid] = element + self._wp_bytes.setdefault(uid, 0) + self._wp_last_draw.setdefault(uid, 0.0) + + def unregister(self, wp_uid: str): + freed = self._wp_bytes.pop(wp_uid, 0) + self._total_bytes = max(0, self._total_bytes - freed) + self._wp_elements.pop(wp_uid, None) + self._wp_last_draw.pop(wp_uid, None) + + def touch(self, wp_uid: str): + if wp_uid in self._wp_last_draw: + self._wp_last_draw[wp_uid] = time.monotonic() + + def add(self, wp_uid: str, step_uid: str, byte_size: int): + self._total_bytes += byte_size + self._wp_bytes[wp_uid] = self._wp_bytes.get(wp_uid, 0) + byte_size + logger.debug( + f"OpsCache: +{byte_size >> 20}MB " + f"({wp_uid[:8]}/{step_uid[:8]}) " + f"total={self._total_bytes >> 20}MB" + ) + if not self._evicting: + self._evict_if_needed(wp_uid) + + def remove(self, wp_uid: str, step_uid: str, byte_size: int): + if byte_size <= 0: + return + self._total_bytes = max(0, self._total_bytes - byte_size) + self._wp_bytes[wp_uid] = max( + 0, self._wp_bytes.get(wp_uid, 0) - byte_size + ) + + def _evict_if_needed(self, protect_uid: str): + self._evicting = True + try: + while self._total_bytes > self._max_bytes: + lru_uid = self._find_lru(protect_uid) + if lru_uid is None: + logger.warning( + f"OpsCache: budget exceeded " + f"({self._total_bytes >> 20}MB/" + f"{self._max_bytes >> 20}MB), " + f"cannot evict" + ) + break + elem = self._wp_elements.get(lru_uid) + if elem: + logger.info( + f"OpsCache: evicting {lru_uid[:8]}, " + f"freeing " + f"~{self._wp_bytes.get(lru_uid, 0) >> 20}MB" + ) + elem.clear_all_ops_caches() + else: + freed = self._wp_bytes.pop(lru_uid, 0) + self._total_bytes = max(0, self._total_bytes - freed) + self._wp_last_draw.pop(lru_uid, None) + finally: + self._evicting = False + + def _find_lru(self, protect_uid: str) -> str | None: + lru_uid = None + lru_time = float("inf") + for uid, t in self._wp_last_draw.items(): + if ( + uid != protect_uid + and t < lru_time + and self._wp_bytes.get(uid, 0) > 0 + ): + lru_time = t + lru_uid = uid + return lru_uid + + +registry = OpsCacheRegistry() diff --git a/rayforge/ui_gtk/canvas2d/projection.py b/rayforge/ui_gtk/canvas2d/projection.py new file mode 100644 index 000000000..1acf77e6d --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/projection.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from raygeo.ops.axis import Axis + + +@dataclass(frozen=True) +class CanvasProjection: + horizontal_axis: Axis = Axis.X + vertical_axis: Axis = Axis.Y diff --git a/rayforge/ui_gtk/canvas2d/surface.py b/rayforge/ui_gtk/canvas2d/surface.py new file mode 100644 index 000000000..1e1ac6206 --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/surface.py @@ -0,0 +1,1645 @@ +import logging +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING, cast + +from blinker import Signal +from gi.repository import Gdk, GLib, Graphene, Gtk + +from ...camera.controller import CameraController +from ...context import get_context +from ...core.color import ColorRGBA, hex_to_rgba +from ...core.group import Group +from ...core.item import DocItem +from ...core.layer import Layer +from ...core.stock import StockItem +from ...core.stock_asset import StockAsset +from ...core.workpiece import WorkPiece +from ...machine.models.machine import Machine +from ...machine.models.machine_panel import MachinePanel +from ...pipeline.artifact import RenderContext +from ...shared.units.formatter import get_preferred_unit_factor +from ..canvas import Canvas, CanvasElement, WorldSurface +from ..shared.keyboard import is_primary_modifier +from . import context_menu +from .elements.axis_extent_frame import ( + AxisExtentFrameElement, + WorkareaBackgroundElement, +) +from .elements.camera_image import CameraImageElement +from .elements.dot import DotElement +from .elements.group import GroupElement +from .elements.layer import LayerElement +from .elements.nogo_zone import NogoZoneElement +from .elements.stock import StockElement +from .elements.tab_handle import TabHandleElement +from .elements.work_origin import WorkOriginElement +from .elements.workpiece import WorkPieceElement +from .projection import CanvasProjection + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + from .drag_drop_cmd import DragDropCmd + +logger = logging.getLogger(__name__) + + +class WorkSurface(WorldSurface): + """ + The WorkSurface displays a grid area with WorkPieces and generated Ops + according to real world dimensions. It is the application-specific + subclass of the generic WorldSurface. + """ + + def __init__( + self, + editor: "DocEditor", + parent_window: Gtk.Window, + machine: Machine | None, + cam_visible: bool = False, + **kwargs, + ): + logger.debug("WorkSurface.__init__ called") + self.editor = editor + self.machine = None # will be assigned by set_machine() below + self._show_travel_moves = False + self._workpieces_visible = True + self._tracked_axis_extents: tuple[float, float] = (0.0, 0.0) + x_axis_right = False + y_axis_down = False + reverse_x_axis = False + reverse_y_axis = False + if machine: + self._tracked_axis_extents = machine.axis_extents + # Canvas shows full machine bed, not just workarea + width_mm, height_mm = ( + float(machine.axis_extents[0]), + float(machine.axis_extents[1]), + ) + view = MachinePanel(machine) + x_axis_right = view.x_axis_right + y_axis_down = view.y_axis_down + reverse_x_axis = view.x_axis_negative + reverse_y_axis = view.y_axis_negative + else: + width_mm, height_mm = 100.0, 100.0 + + self._cam_visible = cam_visible + self._transform_start_states: dict[CanvasElement, dict] = {} + self.right_click_context: dict | None = None + + # Click-to-zero mode state + self._click_to_zero_mode = False + + # Ops rendering suppression for lazy ops rendering (Idea 5). + # During pan/zoom/drag, ops drawing and pipeline context updates + # are suppressed. They are restored after ~200ms of idle time. + self._ops_suppressed: bool = False + self._ops_restore_timer_id: int | None = None + + self._nogo_zone_elements: dict[str, NogoZoneElement] = {} + self._nogo_zones_visible = True + self._projection = CanvasProjection() + + # Initialize the base WorldSurface with machine dimensions + super().__init__( + width_mm=width_mm, + height_mm=height_mm, + x_axis_right=x_axis_right, + y_axis_down=y_axis_down, + reverse_x_axis=reverse_x_axis, + reverse_y_axis=reverse_y_axis, + **kwargs, + ) + + # Keep the grid unit labels in sync with the user's unit preference. + self._axis_renderer.set_grid_unit_factor( + get_preferred_unit_factor("length") + ) + get_context().config.changed.connect(self._on_config_changed) + + # Prevent GTK from implicitly grabbing focus on click, which can + # interfere with popover/menu closing logic. + # We'll manage focus manually. + self.set_focus_on_click(False) + + # DotElement size is in world units (mm) and is dynamically + # updated to maintain a constant pixel size on screen. + self._laser_dot_pos_mm = 0.0, 0.0 + self._laser_dot = DotElement(0, 0, 1.0) + self.root.add(self._laser_dot) + + # Add the Work Origin visual element + self._work_origin_element = WorkOriginElement() + self.root.add(self._work_origin_element) + + # Add the Workarea Background element (gray background for workarea) + self._workarea_bg_element = WorkareaBackgroundElement() + self._workarea_bg_element.set_visible(False) + self.root.add(self._workarea_bg_element) + # Move to back so it's behind everything + self.root.children.insert(0, self.root.children.pop()) + + # Clear root background since we draw workarea background separately + self.root.background = (0, 0, 0, 0) + + # Add the Axis Extent Frame element (red frame around machine bed) + self._extent_frame_element = AxisExtentFrameElement() + self._extent_frame_element.set_visible(False) + self.root.add(self._extent_frame_element) + + # Signals for clipboard and duplication operations + self.cut_requested = Signal() + self.copy_requested = Signal() + self.paste_requested = Signal() + self.duplicate_requested = Signal() + self.aspect_ratio_changed = Signal() + self.context_changed = Signal() + self.transform_initiated = Signal() + + # Signal to request editing an item (handled by MainWindow) + # Sends: (item, action_name) + self.edit_item_requested = Signal() + + # Signal to set work zero at clicked position + self.work_zero_requested = Signal() + + # Signal to cancel click-to-zero mode + self.click_to_zero_cancelled = Signal() + + # Signal for context menu extension - addons can connect to add items + # Sends: (item, gesture, menu) + self.context_menu_requested = Signal() + + # Signal emitted when an asset is dropped onto the canvas + # Sends: (uid, position_mm) where position_mm is (x, y) in world coords + self.item_dropped = Signal() + + # Connect to generic signals from the base Canvas class + self.move_begin.connect(self._on_any_transform_begin) + self.resize_begin.connect(self._on_resize_begin) + self.rotate_begin.connect(self._on_any_transform_begin) + self.shear_begin.connect(self._on_any_transform_begin) + + # The primary connection for model updates + self.transform_end.connect(self._on_transform_end) + + self.set_machine(machine) + + self.editor.pipeline.data_stale.connect(self._on_pipeline_data_stale) + + self._active_layer_wcs_conn = None + self._connected_layer = None + self._connected_doc = None + self._connect_doc_signals() + + # Connect to view change signals to update pipeline view context + self.aspect_ratio_changed.connect(self._on_aspect_ratio_changed) + + # Reconnect signals when a new document is loaded. + self.editor.document_changed.connect(self._on_document_changed) + + # --- View State Management --- + # This property holds the canonical global state for tab visibility. + self._tabs_globally_visible: bool = True + + # Drag-drop command will be initialized by MainWindow after + # construction + self.drag_drop_cmd: DragDropCmd | None = None + + # Initialize pipeline view context to ensure workpiece artifacts + # can be rendered immediately when adopted + self._update_pipeline_view_context() + + @property + def doc(self): + """Returns the current document from the editor.""" + return self.editor.doc + + @property + def projection(self) -> CanvasProjection: + return self._projection + + @projection.setter + def projection(self, value: CanvasProjection): + if self._projection != value: + self._projection = value + self._update_extent_frame() + self.queue_draw() + + @property + def show_travel_moves(self) -> bool: + """Returns True if travel moves should be rendered.""" + return self._show_travel_moves + + @property + def ops_suppressed(self) -> bool: + """Returns True if ops rendering is suppressed during interaction.""" + return self._ops_suppressed + + def _suppress_ops(self): + """Suppresses ops rendering during pan/zoom/drag interactions.""" + if not self._ops_suppressed: + logger.debug("Suppressing ops rendering during interaction") + self._ops_suppressed = True + if self._ops_restore_timer_id is not None: + GLib.source_remove(self._ops_restore_timer_id) + self._ops_restore_timer_id = None + + def _defer_restore_ops(self, delay_ms: int = 200): + """Schedules deferred ops restoration after an idle period.""" + if self._ops_restore_timer_id is not None: + GLib.source_remove(self._ops_restore_timer_id) + self._ops_restore_timer_id = GLib.timeout_add( + delay_ms, self._restore_ops + ) + + def _restore_ops(self) -> bool: + """Restores ops rendering after interaction ends.""" + self._ops_restore_timer_id = None + logger.debug("Restoring ops rendering after idle") + self._ops_suppressed = False + + self._update_pipeline_view_context() + + ppm_x, _ = self.get_view_scale() + for elem in self.find_by_type(WorkPieceElement): + wp_view = cast(WorkPieceElement, elem) + wp_view.trigger_view_update(ppm_x) + + self.queue_draw() + return False + + def set_laser_dot_visible(self, visible: bool = True) -> None: + self._laser_dot.set_visible(visible) + self.queue_draw() + + def set_laser_dot_position(self, x_mm: float, y_mm: float) -> None: + """Sets the laser dot position in real-world mm.""" + self._laser_dot_pos_mm = x_mm, y_mm + + # Transform machine coordinates to canvas coordinates (similar to + # Work Origin logic) + canvas_x, canvas_y = self._machine_coords_to_canvas(x_mm, y_mm) + + # The dot is a child of self.root, so its coordinates are in the + # world (mm) space. We want to center it on the given mm coords. + dot_w_mm = self._laser_dot.width + dot_h_mm = self._laser_dot.height + self._laser_dot.set_pos( + canvas_x - dot_w_mm / 2, canvas_y - dot_h_mm / 2 + ) + + self.queue_draw() + + def _machine_coords_to_canvas( + self, m_x: float, m_y: float + ) -> tuple[float, float]: + """ + Convert machine-reported coordinates to canvas world coordinates. + + Machine-reported coordinates come from the controller and may be + negated based on reverse_x/reverse_y settings. The panel's + machine->world transform undoes this sign flip. + """ + if self.machine: + return self.machine.panel.machine_point_to_world(m_x, m_y) + return m_x, m_y + + def get_global_tab_visibility(self) -> bool: + """ + Returns the current global visibility state for tab handles. This is + used by new WorkPieceElements to pull the correct initial state. + """ + return self._tabs_globally_visible + + def set_global_tab_visibility(self, visible: bool): + """ + Sets the global visibility for tab handles and propagates the change + to all existing WorkPieceElements. + """ + if self._tabs_globally_visible == visible: + return # No change + self._tabs_globally_visible = visible + # Propagate the new state to all existing views + for wp_elem in self.find_by_type(WorkPieceElement): + wp_view = cast(WorkPieceElement, wp_elem) + wp_view.set_tabs_visible_override(visible) + + def on_right_click_pressed( + self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float + ): + """ + Handles right-clicks. Shows the standard WorkSurface context menu. + """ + if self._click_to_zero_mode and n_press == 1: + self.click_to_zero_cancelled.send(self) + return + + self.right_click_context = None # Reset context on each click + world_x, world_y = self._get_world_coords(x, y) + hit_elem = self.root.get_elem_hit(world_x, world_y, selectable=True) + + if not hit_elem or hit_elem is self.root: + context_menu.show_background_context_menu(self, gesture) + self.context_changed.send(self) + return + + # Determine the context type based on the hit element + # Case 1: Clicked on a TabHandle + context_type = None + if isinstance(hit_elem, TabHandleElement): + parent_wp_view = cast(WorkPieceElement, hit_elem.parent) + self.right_click_context = { + "type": "tab", + "tab_data": hit_elem.data, + "workpiece": parent_wp_view.data, + } + # Case 2: Clicked on a WorkPieceElement, check for path proximity + elif isinstance(hit_elem, WorkPieceElement): + wp_view = cast(WorkPieceElement, hit_elem) + + # Check path proximity + location = wp_view.get_closest_point_on_path( + world_x, world_y, threshold_px=5.0 + ) + if location: + self.right_click_context = { + "type": "geometry", + "workpiece": wp_view.data, + "location": location, + } + else: + self.right_click_context = {"type": "item"} + # Case 3: Clicked on another selectable item (e.g., a Group) + elif hit_elem.selectable: + self.right_click_context = {"type": "item"} + + # Notify listeners to update action states *before* showing the menu + self.context_changed.send(self) + + # Now, call the specific function to show the menu. + if self.right_click_context: + context_type = self.right_click_context["type"] + if context_type == "item": + if not hit_elem.selected: + self.unselect_all() + hit_elem.selected = True + self._finalize_selection_state() + context_menu.show_item_context_menu( + self, gesture, item=hit_elem.data + ) + elif context_type == "geometry": + context_menu.show_geometry_context_menu(self, gesture) + elif context_type == "tab": + context_menu.show_tab_context_menu(self, gesture) + + def _on_history_changed(self, sender, **kwargs): + """ + Called when the undo/redo history changes. This handler acts as a + synchronizer to fix state timing issues. It re-commits the current + selection state to ensure all listeners are in sync. + """ + logger.debug( + f"History changed, synchronizing selection state. Sender: {sender}" + ) + self._sync_selection_state() + self.queue_draw() + + def _on_pipeline_data_stale(self, sender, **kwargs): + """Clears ops overlays when the pipeline is in manual mode.""" + if self.editor.pipeline.auto_pipeline: + return + for elem in self.find_by_type(WorkPieceElement): + cast(WorkPieceElement, elem).clear_all_ops_caches() + self.queue_draw() + + def _on_config_changed(self, sender, **kwargs): + """Re-renders ops when config settings change.""" + self._axis_renderer.set_grid_unit_factor( + get_preferred_unit_factor("length") + ) + self.queue_draw() + self._update_pipeline_view_context() + + def _on_doc_structure_changed(self, sender, **kwargs): + """Refreshes render context when layers are added/removed.""" + logger.debug(f"_on_doc_structure_changed fired: sender={sender}") + self._update_pipeline_view_context() + + def _on_document_changed(self, sender, **kwargs): + """Reconnect all doc signals when a new doc is loaded.""" + self._disconnect_doc_signals() + self._connect_doc_signals() + self._update_pipeline_view_context() + self.reset_view() + + def _connect_doc_signals(self): + doc = self.doc + if not doc: + return + doc.history_manager.changed.connect(self._on_history_changed) + doc.active_layer_changed.connect(self._on_active_layer_changed) + doc.descendant_added.connect(self._on_doc_structure_changed) + doc.descendant_removed.connect(self._on_doc_structure_changed) + self._connect_active_layer_wcs() + self._connected_doc = doc + + def _disconnect_doc_signals(self): + self._disconnect_active_layer_wcs() + doc = self._connected_doc + if doc: + doc.history_manager.changed.disconnect(self._on_history_changed) + doc.active_layer_changed.disconnect(self._on_active_layer_changed) + doc.descendant_added.disconnect(self._on_doc_structure_changed) + doc.descendant_removed.disconnect(self._on_doc_structure_changed) + self._connected_doc = None + + def _on_any_transform_begin( + self, + sender, + elements: list[CanvasElement], + drag_target: CanvasElement | None = None, + **kwargs, + ): + """ + Saves the initial matrix of all transformed elements (including their + ancestor groups) and the world size of all affected workpieces. + The 'drag_target' argument is now explicitly accepted from signals + that provide it (like move_begin). + """ + logger.debug( + f"Transform begin for {len(elements)} element(s). " + f"Drag target: {drag_target}" + ) + self._suppress_ops() + self.transform_initiated.send(self) + self._transform_start_states.clear() + + # 1. Collect all unique elements and their group ancestors + items_to_capture = set() + for element in elements: + items_to_capture.add(element) + parent = element.parent + while isinstance(parent, GroupElement): + items_to_capture.add(parent) + parent = parent.parent + + # 2. Store the initial matrix for each captured item + for element in items_to_capture: + if isinstance(element.data, DocItem): + self._transform_start_states[element] = { + "matrix": element.data.matrix.copy() + } + + # 2. Find ALL unique workpieces that will be affected (including + # those inside selected groups) and store their initial world size. + affected_workpieces = set() + for element in elements: + if isinstance(element.data, WorkPiece): + affected_workpieces.add(element.data) + elif isinstance(element.data, Group): + affected_workpieces.update( + element.data.get_descendants(WorkPiece) + ) + + for wp in affected_workpieces: + wp_element = self.find_by_data(wp) + if not wp_element: + logger.warning( + f"Got a transformation for workpiece {wp.name} " + "but did not find its element. Skipping." + ) + continue + # Store the world size against the element for easy lookup later + self._transform_start_states.setdefault(wp_element, {})[ + "world_size" + ] = wp.get_world_transform().get_abs_scale() + + def _on_resize_begin(self, sender, elements: list[CanvasElement]): + """Handles start of a resize, which may invalidate Ops.""" + logger.debug( + f"Resize begin for {len(elements)} element(s). Pausing pipeline." + ) + # Call the generic transform begin handler. + # Note: resize_begin signal in canvas.py currently doesn't send + # drag_target, so this call will pass None for drag_target in + # _on_any_transform_begin, which is correct. + self._on_any_transform_begin(sender, elements) + self.editor.pipeline.pause() + + def _on_transform_end(self, sender, elements: list[CanvasElement]): + """ + Finalizes an interactive transform by collecting all matrix changes + from view elements and creating a single, undoable transaction. + """ + # Step 1: Collect all elements that may have changed. + affected_elements = set() + for element in elements: + affected_elements.add(element) + parent = element.parent + while isinstance(parent, GroupElement): + affected_elements.add(parent) + parent = parent.parent + + # Step 2: Create a list of all model changes found. + changes_to_commit = [] + for element in affected_elements: + if ( + not isinstance(element.data, DocItem) + or element not in self._transform_start_states + or "matrix" not in self._transform_start_states[element] + ): + continue + + docitem: DocItem = element.data + start_matrix = self._transform_start_states[element]["matrix"] + new_matrix = element.transform + + if start_matrix != new_matrix: + changes_to_commit.append( + (docitem, start_matrix, new_matrix.copy()) + ) + + # Step 3: Delegate to the command handler to create the transaction. + if changes_to_commit: + self.editor.transform.create_transform_transaction( + changes_to_commit + ) + + self._transform_start_states.clear() + + # If it was a resize, the ops are now stale. Resume the pipeline. + if self._resizing: + self.editor.pipeline.resume() + + self._defer_restore_ops() + + def on_button_press(self, gesture, n_press: int, x: float, y: float): + """ + Overrides base to add application-specific layer selection logic + and handle double-click editing. + """ + logger.debug("WorkSurface.on_button_press fired") + + # Handle click-to-zero mode + if ( + self._click_to_zero_mode + and gesture.get_button() == Gdk.BUTTON_PRIMARY + and n_press == 1 + ): + world_x, world_y = self._get_world_coords(x, y) + if self.machine: + machine_x, machine_y = ( + self.machine.panel.world_point_to_machine(world_x, world_y) + ) + else: + machine_x, machine_y = world_x, world_y + self.work_zero_requested.send(self, x=machine_x, y=machine_y) + return + + # A left-click should clear any lingering right-click context. + if ( + gesture.get_button() == Gdk.BUTTON_PRIMARY + and self.right_click_context + ): + self.right_click_context = None + self.context_changed.send(self) + + logger.debug( + f"Button press: n_press={n_press}, pos=({x:.2f}, {y:.2f})" + ) + + # The base class method handles hit testing and updates + # self.edit_context + super().on_button_press(gesture, n_press, x, y) + new_context = self.edit_context + + # Check for double-click to edit items. + if n_press == 2: + world_x, world_y = self._get_world_coords(x, y) + hit_elem = self.root.get_elem_hit( + world_x, world_y, selectable=True + ) + + if isinstance(hit_elem, WorkPieceElement): + wp = hit_elem.data + if wp.geometry_provider_uid: + asset = self.doc.get_asset_by_uid(wp.geometry_provider_uid) + if asset: + asset_cls = type(asset) + action_name = asset_cls.edit_item_action + if action_name: + self.edit_item_requested.send( + self, item=wp, action_name=action_name + ) + return + elif isinstance(hit_elem, StockElement): + stock_item = cast(StockItem, hit_elem.data) + action_name = StockAsset.edit_item_action + if action_name: + self.edit_item_requested.send( + self, item=stock_item, action_name=action_name + ) + return + + # After the click, check if the active element dictates a layer change. + if new_context and isinstance(new_context.data, WorkPiece): + active_layer = new_context.data.layer + # If the workpiece's layer is not the document's active layer, + # create an undoable command to change it. + if active_layer and active_layer != self.doc.active_layer: + self.editor.layer.set_active_layer(active_layer) + + def on_motion(self, gesture: Gtk.Gesture, x: float, y: float) -> None: + if self._click_to_zero_mode: + self.set_cursor(Gdk.Cursor.new_from_name("crosshair")) + return + super().on_motion(gesture, x, y) + + def on_motion_leave(self, controller: Gtk.EventControllerMotion) -> None: + if self._click_to_zero_mode: + self.set_cursor(None) + super().on_motion_leave(controller) + + def set_machine(self, machine: Machine | None): + """ + Updates the WorkSurface to use a new machine instance. This handles + disconnecting from the old machine's signals, connecting to the new + one's, and performing a full reset of the view. + """ + if self.machine is machine: + return + + # Disconnect from the old machine's signals + if self.machine: + self.machine.changed.disconnect(self._on_machine_changed) + self.machine.wcs_updated.disconnect(self._on_wcs_updated) + self.machine.state_changed.disconnect( + self._on_machine_state_changed + ) + + # Update the machine reference + self.machine = machine + + # Connect to the new machine's signals + if self.machine: + self.machine.changed.connect(self._on_machine_changed) + self.machine.wcs_updated.connect(self._on_wcs_updated) + self.machine.state_changed.connect(self._on_machine_state_changed) + self.reset_view() + self._on_wcs_updated(self.machine) + + # Synchronize camera elements to match the new machine. This is called + # after the machine is set (or cleared) to ensure the view is correct. + self._sync_camera_elements() + + def _on_wcs_updated(self, machine: Machine): + """Handles updates to the machine's WCS state.""" + if self._is_rotary_active(): + self._work_origin_element.set_visible(False) + self._update_extent_frame() + self.queue_draw() + return + + panel = machine.panel + if machine.wcs_origin_is_workarea_origin: + canvas_x, canvas_y = panel.get_workarea_origin_in_machine() + else: + wcs_x, wcs_y, _ = self._get_active_layer_wcs_offset() + canvas_x, canvas_y = self._machine_coords_to_canvas(wcs_x, wcs_y) + + self._work_origin_element.set_pos(canvas_x, canvas_y) + self._work_origin_element.set_visible(True) + self._update_extent_frame() + self.queue_draw() + + def _is_rotary_active(self): + """Returns True if the active layer has rotary mode enabled.""" + return ( + self.doc is not None + and self.doc.active_layer is not None + and self.doc.active_layer.rotary_enabled + ) + + def _get_active_layer_wcs_offset(self): + """ + Returns the WCS offset for the active layer. + + If the active layer has a specific WCS, uses that. Otherwise + falls back to the machine's active WCS. + """ + if self.machine and self.doc: + layer = self.doc.active_layer + if layer and layer.wcs: + return self.machine.get_wcs_offset(layer.wcs) + if self.machine: + return self.machine.get_active_wcs_offset() + return (0.0, 0.0, 0.0) + + def _connect_active_layer_wcs(self): + """Connect to the active layer's updated signal for WCS changes.""" + self._disconnect_active_layer_wcs() + layer = self.doc.active_layer if self.doc else None + if layer: + self._active_layer_wcs_conn = layer.updated.connect( + self._on_active_layer_updated + ) + self._connected_layer = layer + + def _disconnect_active_layer_wcs(self): + if self._connected_layer and self._active_layer_wcs_conn: + self._connected_layer.updated.disconnect( + self._active_layer_wcs_conn + ) + self._active_layer_wcs_conn = None + self._connected_layer = None + + def _on_active_layer_changed(self, sender): + """Reconnect WCS tracking to the new active layer.""" + self._connect_active_layer_wcs() + if self.machine: + self._on_wcs_updated(self.machine) + + def _on_active_layer_updated(self, layer): + """Handle property changes on the active layer, including WCS.""" + if self.machine: + self._on_wcs_updated(self.machine) + + def _on_machine_state_changed(self, machine: Machine, state): + """Handles machine state changes including position updates.""" + m_pos = state.machine_pos + if m_pos and all(p is not None for p in m_pos): + m_x, m_y = m_pos[0], m_pos[1] + self.set_laser_dot_position(m_x, m_y) + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + self._update_theme_colors() + + width, height = self.get_width(), self.get_height() + ctx = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) + + # Get offset for axis labels (where 0,0 should appear) + if self.machine: + wcs_offset = self._get_active_layer_wcs_offset() + wcs_is_workarea = self.machine.wcs_origin_is_workarea_origin + origin_offset_mm = self.machine.panel.get_axis_label_origin( + wcs_offset=wcs_offset, + wcs_is_workarea_origin=wcs_is_workarea, + ) + else: + origin_offset_mm = (0.0, 0.0, 0.0) + + self._axis_renderer.draw_grid_and_labels( + ctx, + self.view_transform, + width, + height, + origin_offset_mm=origin_offset_mm, + ) + + Canvas.do_snapshot(self, snapshot) + + def _rebuild_view_transform(self) -> bool: + """ + Constructs the world-to-view transformation matrix. + This override propagates view scale changes to WorkPieceElements and + updates the laser dot to maintain a constant pixel size. + """ + # Let the base class do the actual transform calculation and tell us + # if the scale changed. + scale_changed = super()._rebuild_view_transform() + + logger.debug( + f"_rebuild_view_transform: scale_changed={scale_changed}, " + f"view_scale={self.get_view_scale()}" + ) + + if scale_changed: + # Update laser dot size to maintain a constant size in pixels. + desired_diameter_px = 6.0 + new_scale_x, _ = self.get_view_scale() + if new_scale_x > 1e-9: + diameter_mm = desired_diameter_px / new_scale_x + self._laser_dot.set_size(diameter_mm, diameter_mm) + + # Skip pipeline context updates and ops re-rendering during + # interaction. They will be restored after idle via + # _restore_ops(). + if not self._ops_suppressed: + logger.debug( + "_rebuild_view_transform: Calling " + "_update_pipeline_view_context" + ) + self._update_pipeline_view_context() + + logger.debug( + "_rebuild_view_transform: Updating handle transforms " + f"for {len(list(self.find_by_type(WorkPieceElement)))} " + "WorkPieceElements" + ) + ppm_x, _ = self.get_view_scale() + for elem in self.find_by_type(WorkPieceElement): + wp_view = cast(WorkPieceElement, elem) + wp_view.trigger_view_update(ppm_x) + wp_view.update_handle_transforms() + else: + logger.debug( + "_rebuild_view_transform: ops suppressed, " + "skipping pipeline context update" + ) + for elem in self.find_by_type(WorkPieceElement): + cast(WorkPieceElement, elem).update_handle_transforms() + + # Reposition the laser dot after any view change + self.set_laser_dot_position( + self._laser_dot_pos_mm[0], self._laser_dot_pos_mm[1] + ) + + return scale_changed + + def on_pan_begin( + self, gesture: Gtk.GestureDrag, x: float, y: float + ) -> None: + self._suppress_ops() + super().on_pan_begin(gesture, x, y) + + def on_pan_end(self, gesture: Gtk.GestureDrag, x: float, y: float) -> None: + super().on_pan_end(gesture, x, y) + self._defer_restore_ops() + + def on_scroll( + self, + controller: Gtk.EventControllerScroll, + dx: float, + dy: float, + ) -> None: + self._suppress_ops() + super().on_scroll(controller, dx, dy) + self._defer_restore_ops() + + def set_show_travel_moves(self, show: bool): + """Sets whether to display travel moves and triggers re-rendering.""" + if self._show_travel_moves != show: + self._show_travel_moves = show + # Re-render all ops surfaces on all workpiece views + for elem in self.find_by_type(WorkPieceElement): + wp_view = cast(WorkPieceElement, elem) + wp_view.on_travel_visibility_changed() + # Update pipeline view context + self._update_pipeline_view_context() + + def set_click_to_zero_mode(self, active: bool): + """Sets whether click-to-zero mode is active.""" + self._click_to_zero_mode = active + if not active: + self.set_cursor(None) + + def _update_pipeline_view_context(self) -> None: + """ + Updates the view manager context with the current view settings. + + This method collects all the current view context information + (pixels per mm, show travel moves, theme colors) and calls + the view_manager's update_render_context method to trigger + re-rendering of all cached workpiece views. + """ + if not self.editor or not self.editor.view_manager: + logger.debug( + "_update_pipeline_view_context: No editor or view_manager" + ) + return + + ppm_x, ppm_y = self.get_view_scale() + + logger.debug( + f"_update_pipeline_view_context: ppm=({ppm_x:.2f}, " + f"{ppm_y:.2f}), show_travel_moves={self._show_travel_moves}" + ) + + if ppm_x <= 1e-9 or ppm_y <= 1e-9: + logger.debug( + "_update_pipeline_view_context: Scale too small, skipping" + ) + return + + theme = get_context().theme + theme.set_machine(self.machine) + theme.set_doc(self.doc) + color_set = theme.color_set + if color_set is None: + return + + context = RenderContext( + pixels_per_mm=(ppm_x, ppm_y), + show_travel_moves=self._show_travel_moves, + margin_px=5, + color_set_dict=color_set.to_dict(), + laser_color_sets={ + uid: cs.to_dict() for uid, cs in theme.laser_color_sets.items() + }, + layer_color_sets={ + uid: cs.to_dict() for uid, cs in theme.layer_color_sets.items() + }, + ops_color_mode=get_context().config.ops_color_mode, + ) + self.editor.view_manager.update_render_context(context) + + def _get_handle_color(self, elem: CanvasElement) -> ColorRGBA | None: + """Returns the layer color for the element's selection handles.""" + data = getattr(elem, "data", None) + if data is None: + return None + layer = getattr(data, "layer", None) + if layer is None: + return None + return hex_to_rgba(layer.color) + + def _create_and_add_layer_element(self, layer: "Layer"): + """Creates a new LayerElement and adds it to the canvas root.""" + logger.debug(f"Adding new LayerElement for '{layer.name}'") + layer_elem = LayerElement(layer=layer, canvas=self) + self.root.add(layer_elem) + + def _create_and_add_stock_element(self, stock_item: StockItem): + """Creates a new StockElement and adds it to the canvas root.""" + logger.debug(f"Adding new StockElement for '{stock_item.name}'") + stock_elem = StockElement(stock_item=stock_item, canvas=self) + stock_elem.selectable = stock_elem.visible + self.root.add(stock_elem) + child_count = len(self.root.children) + logger.debug(f"StockElement added, total children: {child_count}") + # Trigger a redraw to show the new stock element + self.queue_draw() + + def update_from_doc(self): + """ + Synchronizes the canvas elements with the document model. + + This method ensures that the layers and their contents (workpieces, + steps) displayed on the canvas perfectly match the state of the + document's data model. It also reorders the LayerElements to match + the Z-order of the layers in the document. + """ + doc = self.doc + + # --- Step 1: Add and Remove LayerElements --- + doc_layers_set = set(doc.layers) + current_elements_on_canvas = { + elem.data: elem for elem in self.find_by_type(LayerElement) + } + + # Remove elements for layers that are no longer in the doc + for layer, elem in current_elements_on_canvas.items(): + if layer not in doc_layers_set: + elem.remove() + + # Add elements for new layers that are not yet on the canvas + for layer in doc.layers: + if layer not in current_elements_on_canvas: + self._create_and_add_layer_element(layer) + + # --- Step 1.5: Add and Remove StockElements --- + doc_stock_items_set = set(doc.stock_items) + current_stock_elements_on_canvas = { + elem.data: elem for elem in self.find_by_type(StockElement) + } + + # Remove elements for stock items that are no longer in the doc + for stock_item, elem in current_stock_elements_on_canvas.items(): + if stock_item not in doc_stock_items_set: + elem.remove() + + # Add elements for new stock items that are not yet on the canvas + for stock_item in doc.stock_items: + if stock_item not in current_stock_elements_on_canvas: + self._create_and_add_stock_element(stock_item) + + # --- Step 2: Reorder LayerElements for Z-stacking --- + # The first layer in the list is at the bottom (drawn first). + # The last layer is at the top (drawn last). + layer_order_map = {layer: i for i, layer in enumerate(doc.layers)} + + def sort_key(element: CanvasElement): + """ + Sort key for root's children. Camera at bottom, then stock, + then layers and laser dot on top. + """ + if isinstance(element, DotElement): + return float("inf") - 1 + if isinstance(element, LayerElement): + # LayerElements are ordered according to the doc.layers list. + # Add a large offset to ensure all layers are above stock + layer_order = layer_order_map.get( + element.data, len(layer_order_map) + ) + return layer_order + 1000 + if isinstance(element, StockElement): + # Stock elements are below all layers but above camera images + return 10 + if isinstance(element, (CameraImageElement, WorkOriginElement)): + # Camera images and WCS origin are at the very bottom. + return -2 + if isinstance(element, AxisExtentFrameElement): + # Extent frame is above camera but below everything else + return -1.5 + if isinstance(element, NogoZoneElement): + return -1.4 + # Other elements are above the camera but below stock and layers. + return -1 + + self.root.children.sort(key=sort_key) + + self._update_extent_frame() + self.queue_draw() + + def remove_all(self): + # Clear all children except the fixed ones + children_to_remove = [ + c + for c in self.root.children + if not isinstance( + c, + ( + CameraImageElement, + DotElement, + NogoZoneElement, + WorkOriginElement, + AxisExtentFrameElement, + ), + ) + ] + for child in children_to_remove: + child.remove() + self.queue_draw() + + def find_by_type(self, thetype): + """ + Search recursively through the root's children + """ + return self.root.find_by_type(thetype) + + def are_workpieces_visible(self) -> bool: + """Returns True if the workpiece base images should be visible.""" + return self._workpieces_visible + + def set_workpieces_visible(self, visible=True): + """ + Sets the visibility of the base image for all workpieces. Ops overlays + remain visible. + """ + self._workpieces_visible = visible + # Find the WorkPieceElements and toggle their base image + for wp_elem in self.find_by_type(WorkPieceElement): + cast(WorkPieceElement, wp_elem).set_base_image_visible(visible) + self.queue_draw() + + def set_show_nogo_zones(self, visible: bool): + self._nogo_zones_visible = visible + for elem in self._nogo_zone_elements.values(): + elem.set_visible(visible and elem.data.enabled) + self.queue_draw() + + def set_camera_controllers(self, controllers: list[CameraController]): + """ + Manages camera elements and their subscriptions based on the + provided list of live controllers. + """ + current_elements = { + cast(CameraImageElement, e).controller: e + for e in self.find_by_type(CameraImageElement) + } + current_controllers = set(current_elements.keys()) + new_controllers = set(controllers) + + # Remove elements for controllers that are no longer active + for controller in current_controllers - new_controllers: + element = current_elements[controller] + element.remove() # This will disconnect signals + controller.unsubscribe() + logger.debug( + f"Unsubscribed and removed element for camera " + f"{controller.config.name}" + ) + + # Add elements for new controllers + for controller in new_controllers - current_controllers: + element = CameraImageElement(controller) + element.set_visible( + self._cam_visible and controller.config.enabled + ) + self.root.insert(0, element) # Insert at the bottom of the z-stack + controller.subscribe() + logger.debug( + f"Subscribed and added element for camera " + f"{controller.config.name}" + ) + + self.queue_draw() + + def set_camera_image_visibility(self, visible: bool): + self._cam_visible = visible + for elem in self.find_by_type(CameraImageElement): + camera_elem = cast(CameraImageElement, elem) + camera_elem.set_visible(visible and camera_elem.camera.enabled) + self.queue_draw() + + @property + def _machine_view(self) -> MachinePanel: + """Display-facing projection of the current machine's coordinate + space. Callers must ensure ``self.machine`` is set.""" + assert self.machine + return MachinePanel(self.machine) + + def _on_machine_changed(self, machine: Machine | None): + """ + Handles incremental updates from the currently-assigned machine model. + """ + logger.debug( + "Machine changed signal received: " + f"machine={machine.name if machine else 'None'}" + ) + if not machine: + self._sync_camera_elements() + return + + extent_w, extent_h = machine.axis_extents + extent_changed = (extent_w, extent_h) != self._tracked_axis_extents + view = self._machine_view + y_axis_changed = view.y_axis_down != self._axis_renderer.y_axis_down + x_axis_changed = view.x_axis_right != self._axis_renderer.x_axis_right + x_reverse_changed = ( + view.x_axis_negative != self._axis_renderer.x_axis_negative + ) + y_reverse_changed = ( + view.y_axis_negative != self._axis_renderer.y_axis_negative + ) + + logger.debug( + f"_on_machine_changed: extent_changed={extent_changed}, " + f"extents=({extent_w}, {extent_h}), " + f"tracked={self._tracked_axis_extents}, " + f"margins={machine.work_margins}" + ) + + if ( + extent_changed + or x_axis_changed + or y_axis_changed + or x_reverse_changed + or y_reverse_changed + ): + self.reset_view() + else: + self._update_extent_frame() + self._sync_camera_elements() + self._sync_nogo_zone_elements() + self._on_wcs_updated(machine) + self._update_pipeline_view_context() + + def reset_view(self): + """ + Resets the view to fit the given machine's properties. + """ + if not self.machine: + self._tracked_axis_extents = (100, 100) + self.set_size(100.0, 100.0) + self._axis_renderer.set_width_mm(100.0) + self._axis_renderer.set_height_mm(100.0) + self._axis_renderer.set_margins_mm(0.0, 0.0, 0.0, 0.0) + self._axis_renderer.set_x_axis_right(False) + self._axis_renderer.set_y_axis_down(False) + self._axis_renderer.set_x_axis_negative(False) + self._axis_renderer.set_y_axis_negative(False) + super().reset_view() + self.aspect_ratio_changed.send(self, ratio=1.0) + self._sync_camera_elements() + return + + # Canvas shows full machine bed + width_mm, height_mm = self.machine.axis_extents + + view = self._machine_view + logger.debug( + f"Resetting view for machine '{self.machine.name}' " + f"with axis_extents=({width_mm}, {height_mm}), " + f"x_right={view.x_axis_right}, " + f"y_down={view.y_axis_down}, " + f"x_negative={view.x_axis_negative}, " + f"y_negative={view.y_axis_negative}" + ) + self._tracked_axis_extents = self.machine.axis_extents + self.set_size(float(width_mm), float(height_mm)) + ml, mt, mr, mb = self.machine.work_margins + self._axis_renderer.set_width_mm(float(width_mm)) + self._axis_renderer.set_height_mm(float(height_mm)) + self._axis_renderer.set_margins_mm( + float(ml), float(mt), float(mr), float(mb) + ) + self._axis_renderer.set_x_axis_right(view.x_axis_right) + self._axis_renderer.set_y_axis_down(view.y_axis_down) + self._axis_renderer.set_x_axis_negative(view.x_axis_negative) + self._axis_renderer.set_y_axis_negative(view.y_axis_negative) + + self._work_origin_element.set_axis_direction( + view.x_axis_right, view.y_axis_down + ) + + self._update_extent_frame() + + super().reset_view() + + if self.doc.active_layer.rotary_enabled and self.machine: + self._center_on_rotary_axis() + + new_ratio = width_mm / height_mm if height_mm > 0 else 1.0 + self.aspect_ratio_changed.send(self, ratio=new_ratio) + self._sync_camera_elements() + self._sync_nogo_zone_elements() + self._on_wcs_updated(self.machine) + self._update_pipeline_view_context() + + def _update_extent_frame(self): + """ + Updates the machine extent frame and workarea background. + + In flat mode, the workarea background shows the usable area + within the machine bed (margins excluded). In rotary mode, + the workarea background represents the unrolled cylinder + surface, centered vertically on the WCS origin. + """ + if not self.machine: + self._extent_frame_element.set_visible(False) + self._workarea_bg_element.set_visible(False) + return + + active_layer = self.doc.active_layer + + if active_layer.rotary_enabled: + self._update_extent_frame_rotary() + else: + self._update_extent_frame_flat() + + self.queue_draw() + + def _update_extent_frame_flat(self): + """Updates extent frame and workarea for flat (non-rotary) mode.""" + assert self.machine + panel = self.machine.panel + extent_w, extent_h = panel.extents + ml, mt, mr, mb = panel.margins + workarea_w, workarea_h = panel.workarea_size + + logger.debug( + f"_update_extent_frame_flat: extents=({extent_w}, " + f"{extent_h}), margins=({ml}, {mt}, {mr}, {mb})" + ) + + self._axis_renderer.set_x_axis_y_override(None) + + if (extent_w, extent_h) != (self.width_mm, self.height_mm): + self.set_size(float(extent_w), float(extent_h)) + self._axis_renderer.set_margins_mm( + float(ml), float(mt), float(mr), float(mb) + ) + + self._extent_frame_element.set_size(float(extent_w), float(extent_h)) + self._extent_frame_element.set_pos(0.0, 0.0) + self._extent_frame_element.set_visible(True) + + self._workarea_bg_element.set_size( + float(workarea_w), float(workarea_h) + ) + self._workarea_bg_element.set_pos(float(ml), float(mb)) + self._workarea_bg_element.set_visible(True) + + def _update_extent_frame_rotary(self): + """Updates extent frame and workarea for rotary mode.""" + assert self.machine + active_layer = self.doc.active_layer + diameter = active_layer.rotary_diameter + circumference = math.pi * diameter + half_circ = circumference / 2.0 + + origin_x, origin_y = self._machine_coords_to_canvas(0.0, 0.0) + + bed_width = self.machine.axis_extents[0] + max_length = bed_width + default_rm = self.machine.get_default_rotary_module() + if default_rm: + max_length = min(max_length, default_rm.max_workpiece_length) + + if self._machine_view.x_axis_right: + width = min(origin_x, max_length) + x = origin_x - width + else: + x = origin_x + width = min(bed_width - origin_x, max_length) + + y = origin_y - half_circ + height = circumference + + if width < 0: + width = 0.0 + if height < 0: + height = 0.0 + + logger.debug( + f"_update_extent_frame_rotary: diameter={diameter}, " + f"circ={circumference:.2f}, pos=({x:.2f}, {y:.2f}), " + f"size=({width:.2f}, {height:.2f})" + ) + + self._axis_renderer.set_x_axis_y_override(origin_y) + + margin_right = self.width_mm - x - width + margin_top = self.height_mm - y - height + self._axis_renderer.set_margins_mm(x, margin_top, margin_right, y) + + self._extent_frame_element.set_size(width, height) + self._extent_frame_element.set_pos(x, y) + self._extent_frame_element.set_visible(width > 0) + + self._workarea_bg_element.set_size(width, height) + self._workarea_bg_element.set_pos(x, y) + self._workarea_bg_element.set_visible(width > 0) + + def _center_on_rotary_axis(self): + """Adjusts pan to vertically center the view on the cylinder X axis.""" + assert self.machine + _, origin_y = self._machine_coords_to_canvas(0.0, 0.0) + self.set_pan(self.pan_x_mm, origin_y - self.height_mm / 2.0) + + def _sync_nogo_zone_elements(self): + if not self.machine: + for elem in self._nogo_zone_elements.values(): + elem.remove() + self._nogo_zone_elements.clear() + return + + current_uids = set(self.machine.nogo_zones.keys()) + existing_uids = set(self._nogo_zone_elements.keys()) + + for uid in existing_uids - current_uids: + self._nogo_zone_elements.pop(uid).remove() + + for uid, zone in self.machine.nogo_zones.items(): + if uid in self._nogo_zone_elements: + elem = self._nogo_zone_elements[uid] + elem._update_from_zone() + elem.set_visible(self._nogo_zones_visible and zone.enabled) + else: + elem = NogoZoneElement(zone) + self._nogo_zone_elements[uid] = elem + self.root.add(elem) + + self.queue_draw() + + def _on_aspect_ratio_changed(self, sender, **kwargs) -> None: + """ + Handler for aspect ratio changes (zoom level changes). + + When the zoom level changes, the view context (pixels per mm) + changes, so we need to update the pipeline view context to trigger + re-rendering of all cached workpiece views. + """ + self._update_pipeline_view_context() + + def _sync_camera_elements(self): + """ + Synchronizes the camera elements on the canvas with the cameras + defined in the current machine model. + """ + camera_mgr = get_context().camera_mgr + if not self.machine: + self.set_camera_controllers([]) + return + + # Get the controller for each camera model in the current machine + machine_camera_controllers = [] + for camera_model in self.machine.cameras: + controller = camera_mgr.get_controller(camera_model.device_id) + if controller: + machine_camera_controllers.append(controller) + else: + logger.warning( + "Could not find a live controller for camera " + f"with device ID '{camera_model.device_id}'." + ) + + self.set_camera_controllers(machine_camera_controllers) + + def on_key_pressed( + self, + controller: Gtk.EventControllerKey, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> bool: + """Handles key press events for the work surface.""" + # Let the base WorldSurface class handle generic keys (e.g., '1') + if super().on_key_pressed(controller, keyval, keycode, state): + return True + + is_primary = is_primary_modifier(state) + is_shift = bool(state & Gdk.ModifierType.SHIFT_MASK) + + # Handle moving workpiece to another layer + if is_primary and ( + keyval == Gdk.KEY_Page_Up or keyval == Gdk.KEY_Page_Down + ): + direction = -1 if keyval == Gdk.KEY_Page_Up else 1 + self.editor.layer.move_selected_to_adjacent_layer(self, direction) + return True + + # Handle clipboard and duplication + if is_primary: + selected_items = [e.data for e in self.get_selected_elements()] + if keyval == Gdk.KEY_x: + if selected_items: + self.cut_requested.send(self, items=selected_items) + return True + elif keyval == Gdk.KEY_c: + if selected_items: + self.copy_requested.send(self, items=selected_items) + return True + elif keyval == Gdk.KEY_v: + self.paste_requested.send(self) + return True + elif keyval == Gdk.KEY_d: + if selected_items: + self.duplicate_requested.send(self, items=selected_items) + return True + elif keyval == Gdk.KEY_a: + self.select_all() + return True + + move_amount_mm = 1.0 + if is_shift: + move_amount_mm *= 10 + elif is_primary: + move_amount_mm *= 0.1 + + move_x, move_y = 0.0, 0.0 + if keyval == Gdk.KEY_Up: + move_y = move_amount_mm + elif keyval == Gdk.KEY_Down: + move_y = -move_amount_mm + elif keyval == Gdk.KEY_Left: + move_x = -move_amount_mm + elif keyval == Gdk.KEY_Right: + move_x = move_amount_mm + + if move_x != 0 or move_y != 0: + selected_items = [ + e.data + for e in self.get_selected_elements() + if isinstance(e.data, DocItem) + ] + if not selected_items: + return True # Consume event but do nothing + + self.transform_initiated.send(self) + self.editor.transform.nudge_items(selected_items, move_x, move_y) + return True + + return False + + def get_active_workpiece(self) -> WorkPiece | None: + active_elem = self.get_active_element() + if active_elem and isinstance(active_elem.data, WorkPiece): + return active_elem.data + return None + + def get_selected_workpieces(self) -> list[WorkPiece]: + all_wps = [] + for elem in self.get_selected_elements(): + # Check for the element's direct data + if isinstance(elem.data, WorkPiece): + all_wps.append(elem.data) + # If it's a group, get all descendant workpieces from the model + elif isinstance(elem.data, Group): + all_wps.extend(elem.data.get_descendants(WorkPiece)) + # Return a unique list + return list(dict.fromkeys(all_wps)) + + def get_selection_bounds( + self, + ) -> tuple[float, float, float, float] | None: + """ + Get the bounding box of selected items or workarea bounds. + + Returns: + A tuple (min_x, min_y, max_x, max_y) in world coordinates, + or None if there is no machine configured. + """ + selected_elements = self.get_selected_elements() + + if selected_elements: + workpieces = [] + for elem in selected_elements: + if isinstance(elem.data, WorkPiece): + workpieces.append(elem.data) + elif isinstance(elem.data, Group): + workpieces.extend(elem.data.get_descendants(WorkPiece)) + + bboxes = [] + for wp in workpieces: + bbox = wp.get_geometry_world_bbox() + if bbox is not None: + bboxes.append(bbox) + + if bboxes: + min_x = min(b[0] for b in bboxes) + min_y = min(b[1] for b in bboxes) + max_x = max(b[2] for b in bboxes) + max_y = max(b[3] for b in bboxes) + return (min_x, min_y, max_x, max_y) + + machine = get_context().machine + if not machine: + return None + + wx, wy, w, h = machine.panel.get_workarea_world_rect() + return (wx, wy, wx + w, wy + h) + + def get_selected_items(self) -> Sequence[DocItem]: + return [ + elem.data + for elem in self.get_selected_elements() + if isinstance(elem.data, DocItem) + ] + + def get_selected_top_level_items(self) -> list[DocItem]: + """ + Returns a list of the highest-level selected DocItems. + + This follows a simple, robust algorithm: + 1. For each selected item, find its highest selected ancestor. + 2. Collect these ancestors. + 3. Return the unique list of ancestors. + + This correctly handles all cases, including selecting items inside a + group. If two workpieces inside a group are selected (and not the + group itself), this method will correctly return just those two + workpieces. The business logic for what to do with them belongs + in the calling code. + """ + selected_elements = self.get_selected_elements() + if not selected_elements: + return [] + + # Create a set of the data models for efficient lookup. + selected_item_data = { + elem.data + for elem in selected_elements + if isinstance(elem.data, DocItem) + } + if not selected_item_data: + return [] + + top_level_ancestors = [] + for item in selected_item_data: + # For each item, walk up its hierarchy to find the highest + # ancestor that is ALSO in the selection set. + current = item + highest_selected_ancestor = item + while current.parent: + if current.parent in selected_item_data: + highest_selected_ancestor = current.parent + current = current.parent + top_level_ancestors.append(highest_selected_ancestor) + + # Return a unique list, preserving order. + return list(dict.fromkeys(top_level_ancestors)) + + def select_all(self): + """ + Selects all workpieces on all layers. + In edit mode, selects all segments instead. + """ + if self.edit_context and self.edit_context.handle_edit_select_all(): + return + for elem in self.root.get_all_children_recursive(): + if isinstance(elem.data, DocItem) and elem.selectable: + elem.selected = True + + self._finalize_selection_state() + + def select_items(self, items_to_select: Sequence[DocItem]): + """ + Clears the current selection and selects the canvas elements + corresponding to the given list of DocItem objects. + """ + self.unselect_all() + uids_to_select = {item.uid for item in items_to_select} + + for elem in self.root.get_all_children_recursive(): + if ( + isinstance(elem.data, DocItem) + and elem.data.uid in uids_to_select + and elem.selectable + ): + elem.selected = True + + self._finalize_selection_state() diff --git a/rayforge/ui_gtk/debug_log_dialog.py b/rayforge/ui_gtk/debug_log_dialog.py new file mode 100644 index 000000000..e2e6973f9 --- /dev/null +++ b/rayforge/ui_gtk/debug_log_dialog.py @@ -0,0 +1,124 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from gi.repository import Adw, Gio, GLib, Gtk + +from ..context import get_context +from ..debug import DebugDumpManager + +if TYPE_CHECKING: + from ..doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class DebugLogDialog(Adw.MessageDialog): + """ + Dialog shown before creating a debug dump archive. + Lets the user choose whether to include the current + project in the archive. + """ + + def __init__( + self, + parent: Gtk.Window, + editor: Optional["DocEditor"] = None, + on_saved: Callable[[str], None] | None = None, + on_error: Callable[[str], None] | None = None, + ): + super().__init__(transient_for=parent) + self._editor = editor + self._parent = parent + self._on_saved = on_saved + self._on_error = on_error + + self.set_heading(_("Save Debug Log")) + self.set_body( + _( + "Create a ZIP archive with log files and system " + "information for troubleshooting." + ), + ) + + self._include_switch = Adw.SwitchRow( + title=_("Include current project"), + subtitle=_("Add the current project file to the debug archive"), + ) + self._include_switch.set_active(True) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_top(12) + content_box.append(self._include_switch) + self.set_extra_child(content_box) + + self.add_response("cancel", _("Cancel")) + self.add_response("save", _("_Save")) + self.set_default_response("save") + self.set_close_response("cancel") + self.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) + + self.connect("response", self._on_response) + + @property + def include_project(self) -> bool: + return self._include_switch.get_active() + + def _create_archive(self) -> Path | None: + editor = self._editor if self.include_project else None + return get_context().debug_dump_manager.create_dump_archive( + editor=editor, + ) + + def _on_response(self, dialog, response_id): + self.destroy() + if response_id != "save": + return + + archive_path = self._create_archive() + + if not archive_path: + if self._on_error: + self._on_error(_("Failed to create debug archive.")) + return + + file_dialog = Gtk.FileDialog.new() + file_dialog.set_title(_("Save Debug Log")) + file_dialog.set_initial_name(archive_path.name) + + def save_callback(file_dialog, result): + try: + destination_file = file_dialog.save_finish(result) + if destination_file: + destination_path = Path(destination_file.get_path()) + DebugDumpManager.save_archive_to( + archive_path, destination_path + ) + if self._on_saved: + self._on_saved(destination_path.name) + return + except GLib.Error as e: + if ( + not e.matches( + Gio.io_error_quark(), + Gio.IOErrorEnum.CANCELLED, + ) + and self._on_error + ): + self._on_error( + _("Error saving file: {msg}").format(msg=e.message) + ) + except OSError as e: + if self._on_error: + self._on_error( + _("An unexpected error occurred: {error}").format( + error=e + ) + ) + finally: + if archive_path.exists(): + archive_path.unlink() + + file_dialog.save(self._parent, None, save_callback) diff --git a/rayforge/ui_gtk/doceditor/__init__.py b/rayforge/ui_gtk/doceditor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/doceditor/add_material_dialog.py b/rayforge/ui_gtk/doceditor/add_material_dialog.py new file mode 100644 index 000000000..78b2bc835 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/add_material_dialog.py @@ -0,0 +1,117 @@ +"""A dialog for adding a new material.""" + +import logging +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, Gdk, Gtk + +from ...core.material import Material + +logger = logging.getLogger(__name__) + + +class AddMaterialDialog(Adw.MessageDialog): + """A dialog for creating a new material.""" + + def __init__(self, material: Material | None = None, **kwargs): + super().__init__(**kwargs) + + self.material = material + self.is_edit_mode = material is not None + + if self.is_edit_mode: + self.set_heading(_("Edit Material")) + self.set_body(_("Update the material details:")) + self.add_response("cancel", _("Cancel")) + self.add_response("save", _("Save")) + self.set_response_appearance( + "save", Adw.ResponseAppearance.SUGGESTED + ) + self.set_default_response("save") + else: + self.set_heading(_("Add New Material")) + self.set_body(_("Enter the details for the new material:")) + self.add_response("cancel", _("Cancel")) + self.add_response("add", _("Add")) + self.set_response_appearance( + "add", Adw.ResponseAppearance.SUGGESTED + ) + self.set_default_response("add") + + self.name_entry = Adw.EntryRow(title=_("Name")) + self.category_entry = Adw.EntryRow(title=_("Category")) + + self.color_button = Gtk.ColorButton(margin_bottom=0) + self.color_button.set_size_request(32, 32) + self.color_row = Adw.ActionRow( + title=_("Color"), activatable_widget=self.color_button + ) + self.color_row.add_suffix(self.color_button) + + # Use a preferences group for a clean layout + group = Adw.PreferencesGroup() + group.add(self.name_entry) + group.add(self.category_entry) + group.add(self.color_row) + + self.set_extra_child(group) + + # If editing, populate the fields with existing data + if self.is_edit_mode: + self._populate_fields() + + # Set initial focus on the name entry + self.name_entry.grab_focus() + + # Connect Enter key handler to entries + # Adw.EntryRow has an internal entry widget we need to access + self.name_entry.connect("entry-activated", self._on_enter_key) + self.category_entry.connect("entry-activated", self._on_enter_key) + + def _on_enter_key(self, widget): + """Handle Enter key pressed in entry fields.""" + # Get the default response and emit the response signal + default_response = self.get_default_response() + if default_response: + self.response(default_response) + + def get_name(self) -> str: + """Get the text from the name entry.""" + return self.name_entry.get_text() + + def get_category(self) -> str: + """Get the text from the category entry.""" + return self.category_entry.get_text() + + def get_color_hex(self) -> str: + """Get the color as a hex string.""" + rgba = self.color_button.get_rgba() + r = int(rgba.red * 255) + g = int(rgba.green * 255) + b = int(rgba.blue * 255) + return f"#{r:02x}{g:02x}{b:02x}" + + def _populate_fields(self): + """Populate the dialog fields with existing material data.""" + if not self.material: + return + + self.name_entry.set_text(self.material.name) + self.category_entry.set_text(self.material.category) + + # Set the color button to the material's color + color_hex = self.material.appearance.color + if color_hex.startswith("#"): + # Try using GTK's built-in color parsing + rgba = Gdk.RGBA() + if rgba.parse(color_hex): + self.color_button.set_rgba(rgba) + + def get_material_data(self) -> dict[str, Any]: + """Returns a dictionary with the entered material data.""" + return { + "name": self.get_name().strip(), + "category": self.get_category().strip() or _("Custom"), + "color": self.get_color_hex(), + } diff --git a/rayforge/ui_gtk/doceditor/add_tabs_popover.py b/rayforge/ui_gtk/doceditor/add_tabs_popover.py new file mode 100644 index 000000000..d4d029d72 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/add_tabs_popover.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Gtk + +from ..shared.pref_rows.base import SpinRow +from ..shared.pref_rows.length_spin_row import LengthSpinRow + +if TYPE_CHECKING: + from ...core.workpiece import WorkPiece + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class AddTabsPopover(Gtk.Popover): + def __init__( + self, + editor: DocEditor, + workpieces: list[WorkPiece], + ): + super().__init__() + self.editor = editor + self.workpieces = workpieces + self._in_update = False + + 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.tab_count_row = SpinRow( + _("Number of Tabs"), + lower=1, + upper=1000, + digits=0, + value=4, + ) + rows_container.append(self.tab_count_row) + + self.tab_width_row = LengthSpinRow( + _("Tab Width"), + lower=0.1, + upper=100, + value_in_base=2.0, + ) + rows_container.append(self.tab_width_row) + + # Use the first workpiece to set initial values + first_workpiece = self.workpieces[0] + + self._in_update = True + initial_count = len(first_workpiece.tabs) + if initial_count > 0: + self.tab_count_row.set_value(initial_count) + self.tab_width_row.set_value_in_base_units( + first_workpiece.tabs[0].width + ) + else: + self.tab_count_row.set_value(4) + self.tab_width_row.set_value_in_base_units(2.0) + self._in_update = False + + # Connect signals for live updates + self.tab_count_row.value_changed.connect(self._on_value_changed) + self.tab_width_row.value_changed.connect(self._on_value_changed) + + # Trigger the initial command to set the default tabs + self._on_value_changed() + + def _on_value_changed(self, *args): + if self._in_update: + return + + count = self.tab_count_row.get_int_value() + width = self.tab_width_row.get_value_in_base_units() + + # Group all changes into a single undoable transaction. + # This is the correct way to batch changes that should be undone + # together. However, for live updates where each tweak should be + # undoable, we execute commands directly. + with self.editor.history_manager.transaction( + _("Adjust Equidistant Tabs") + ): + for workpiece in self.workpieces: + if ( + not workpiece.layer + or not workpiece.layer.workflow + or not workpiece.layer.workflow.steps + ): + continue + + self.editor.tab.add_tabs( + workpiece=workpiece, + count=count, + width=width, + strategy="equidistant", + ) diff --git a/rayforge/ui_gtk/doceditor/asset_browser.py b/rayforge/ui_gtk/doceditor/asset_browser.py new file mode 100644 index 000000000..462d4a9b0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/asset_browser.py @@ -0,0 +1,687 @@ +import copy +import json +import logging +import uuid +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from blinker import Signal +from gi.repository import Gdk, Gio, GLib, Graphene, Gtk, Pango + +from ...core.asset import IAsset +from ...core.asset_registry import asset_type_registry +from ...core.doc import Doc +from ...core.geometry_provider import IGeometryProvider +from ...core.stock import StockItem +from ...core.undo import ListItemCommand +from ..icons import get_icon +from ..shared.gtk import apply_css +from ..shared.popover_menu import PopoverMenu + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +THUMBNAIL_SIZE = 64 +CARD_SIZE = THUMBNAIL_SIZE + 36 + +css = """ +.asset-browser { + padding: 9px; +} +.asset-flowbox > flowboxchild { + padding: 0; + margin: 0; + background: none; + border: none; + outline: none; + box-shadow: none; + min-width: 0; + min-height: 0; +} +.asset-flowbox > flowboxchild.selected .asset-card { + background: alpha(@theme_selected_bg_color, 0.25); + border: 2px solid @theme_selected_bg_color; +} +.asset-card { + background: @card_bg_color; + border-radius: 6px; + padding: 4px; + border: 2px solid transparent; +} +.asset-card:hover { + background: alpha(@theme_selected_bg_color, 0.08); +} +.asset-card-label { + font-size: 13px; + margin-top: 2px; +} +.asset-type-icon { + opacity: 0.9; + background-color: alpha(@card_bg_color, 0.95); + border-radius: 4px; +} +.asset-browser-empty { + padding: 24px; +} +.asset-browser-empty-icon { + opacity: 0.15; +} +.asset-browser-empty-buttons button { + padding: 12px 24px; + font-size: 1.1em; +} +""" + + +class AssetCard(Gtk.Box): + """A thumbnail card for a single asset.""" + + def __init__(self, asset: IAsset): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.asset = asset + self.add_css_class("asset-card") + self._texture: Gdk.Texture | None = None + self.set_size_request(CARD_SIZE, CARD_SIZE) + self.set_halign(Gtk.Align.CENTER) + self.set_valign(Gtk.Align.START) + self.set_hexpand(False) + self.set_vexpand(False) + + self._draw_area = Gtk.DrawingArea() + self._draw_area.set_content_width(THUMBNAIL_SIZE) + self._draw_area.set_content_height(THUMBNAIL_SIZE) + self._draw_area.set_draw_func(self._draw_thumbnail) + + self._icon_fallback = Gtk.Image() + self._icon_fallback.set_pixel_size(THUMBNAIL_SIZE // 2) + self._icon_fallback.set_visible(False) + + image_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + image_box.append(self._draw_area) + image_box.append(self._icon_fallback) + + type_icon = get_icon(asset.display_icon_name) + type_icon.set_pixel_size(12) + type_icon.set_margin_end(4) + type_icon.set_tooltip_text(asset.type_display_name) + + self._label = Gtk.Label() + self._label.add_css_class("asset-card-label") + self._label.set_ellipsize(Pango.EllipsizeMode.MIDDLE) + self._label.set_max_width_chars(10) + + label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + label_box.set_halign(Gtk.Align.CENTER) + label_box.set_margin_top(4) + label_box.append(type_icon) + label_box.append(self._label) + + self.append(image_box) + self.append(label_box) + self.refresh() + + def invalidate(self): + self._texture = None + + def refresh(self): + if self._texture is None: + png = self.asset.get_thumbnail(THUMBNAIL_SIZE) + if png: + bytes_data = GLib.Bytes.new(png) + self._texture = Gdk.Texture.new_from_bytes(bytes_data) + + if self._texture: + self._draw_area.set_visible(True) + self._icon_fallback.set_visible(False) + else: + self._draw_area.set_visible(False) + self._icon_fallback.set_from_icon_name( + self.asset.display_icon_name + ) + self._icon_fallback.set_visible(True) + + self._label.set_label(self.asset.name) + self.set_tooltip_text(self.asset.name) + + def _draw_thumbnail(self, area, cr, width, height): + if self._texture is None: + return + tex_w = self._texture.get_intrinsic_width() + tex_h = self._texture.get_intrinsic_height() + if tex_w <= 0 or tex_h <= 0: + return + scale = min(width / tex_w, height / tex_h) + draw_w = tex_w * scale + draw_h = tex_h * scale + x = (width - draw_w) / 2 + y = (height - draw_h) / 2 + snapshot = Gtk.Snapshot() + rect = Graphene.Rect() + rect.init(x, y, draw_w, draw_h) + snapshot.append_texture(self._texture, rect) + node = snapshot.to_node() + if node is not None: + node.draw(cr) + + +class AssetBrowser(Gtk.Box): + """ + A bottom-panel widget that displays document assets as a grid of + thumbnails. + """ + + def __init__(self, editor: "DocEditor", **kwargs): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, **kwargs) + self.add_asset_requested = Signal() + self.asset_activated = Signal() + apply_css(css) + self.add_css_class("asset-browser") + self.editor = editor + self.doc = editor.doc + + self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._main_box.set_hexpand(True) + self._main_box.set_vexpand(True) + self.append(self._main_box) + + self._flowbox = Gtk.FlowBox() + self._flowbox.add_css_class("asset-flowbox") + self._flowbox.set_column_spacing(6) + self._flowbox.set_row_spacing(6) + self._flowbox.set_min_children_per_line(3) + self._flowbox.set_max_children_per_line(200) + self._flowbox.set_selection_mode(Gtk.SelectionMode.NONE) + self._flowbox.set_homogeneous(False) + self._flowbox.set_valign(Gtk.Align.START) + self._flowbox.set_activate_on_single_click(False) + self._flowbox.connect("child-activated", self._on_child_activated) + + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self._flowbox.add_controller(key_controller) + + click_gesture = Gtk.GestureClick() + click_gesture.set_button(1) + click_gesture.connect("pressed", self._on_flowbox_pressed) + click_gesture.connect("released", self._on_flowbox_released) + self._flowbox.add_controller(click_gesture) + + self._scrolled = Gtk.ScrolledWindow() + self._scrolled.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + self._scrolled.set_child(self._flowbox) + self._scrolled.set_hexpand(True) + self._scrolled.set_vexpand(True) + self._main_box.append(self._scrolled) + + right_click = Gtk.GestureClick() + right_click.set_button(Gdk.BUTTON_SECONDARY) + right_click.connect("pressed", self._on_right_click_pressed) + self._scrolled.add_controller(right_click) + + self._empty_state = self._create_empty_state() + self._main_box.append(self._empty_state) + + toolbar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + toolbar.set_spacing(4) + toolbar.set_margin_start(9) + toolbar.set_margin_top(9) + + self._add_btn = Gtk.Button(child=get_icon("add-symbolic")) + self._add_btn.add_css_class("flat") + self._add_btn.set_tooltip_text(_("Add Asset")) + self._add_btn.connect("clicked", self._on_add_clicked) + toolbar.append(self._add_btn) + self.append(toolbar) + + self._cards: dict[str, list] = {} + self._selected_uids: set[str] = set() + self._asset_clipboard: list[dict] = [] + self._context_popover: Gtk.PopoverMenu | None = None + self._connect_signals() + self._sync_cards(self.doc) + + def _create_empty_state(self) -> Gtk.Box: + empty_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + empty_box.add_css_class("asset-browser-empty") + empty_box.set_halign(Gtk.Align.CENTER) + empty_box.set_valign(Gtk.Align.CENTER) + empty_box.set_margin_top(24) + empty_box.set_margin_bottom(24) + empty_box.set_hexpand(True) + empty_box.set_vexpand(True) + + icon = get_icon("sketch-edit-symbolic") + icon.set_pixel_size(128) + icon.add_css_class("asset-browser-empty-icon") + empty_box.append(icon) + + buttons_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + halign=Gtk.Align.CENTER, + ) + buttons_box.add_css_class("asset-browser-empty-buttons") + + add_stock_btn = Gtk.Button(label=_("Add Stock")) + add_stock_btn.connect("clicked", self._on_empty_add_clicked, "stock") + buttons_box.append(add_stock_btn) + + add_sketch_btn = Gtk.Button(label=_("Add Sketch")) + add_sketch_btn.connect("clicked", self._on_empty_add_clicked, "sketch") + buttons_box.append(add_sketch_btn) + + empty_box.append(buttons_box) + + return empty_box + + def _on_empty_add_clicked(self, button: Gtk.Button, type_name: str): + self.add_asset_requested.send(self, type_name=type_name) + + def set_doc(self, doc: Doc): + """Updates the widget to track a new document instance.""" + if self.doc == doc: + return + self._disconnect_signals() + self._disconnect_all_asset_signals() + self.doc = doc + self._connect_signals() + self._sync_cards(doc) + + def _connect_signals(self): + self.doc.updated.connect(self._on_doc_changed) + self.doc.descendant_added.connect(self._on_doc_changed) + self.doc.descendant_removed.connect(self._on_doc_changed) + + def _disconnect_signals(self): + self.doc.updated.disconnect(self._on_doc_changed) + self.doc.descendant_added.disconnect(self._on_doc_changed) + self.doc.descendant_removed.disconnect(self._on_doc_changed) + + def _connect_asset_signal(self, card: AssetCard): + asset = card.asset + handler = asset.updated.connect( + lambda sender, uid=asset.uid: self._on_asset_updated(uid) + ) + self._cards[asset.uid] = [card, asset.updated, handler] + + def _disconnect_all_asset_signals(self): + for card, signal, handler in self._cards.values(): + signal.disconnect(handler) + self._cards.clear() + + def _on_asset_updated(self, uid: str): + entry = self._cards.get(uid) + if entry is None: + return + card = entry[0] + logger.debug("Refreshing thumbnail for asset %s", uid[:8]) + card.invalidate() + card.refresh() + + def _on_doc_changed(self, sender, **kwargs): + child = kwargs.get("child") + if child and not isinstance(child, StockItem): + return + self._sync_cards(self.doc) + + def _sync_cards(self, doc: Doc): + visible = [a for a in doc.get_all_assets() if not a.hidden] + has_assets = len(visible) > 0 + + self._scrolled.set_visible(has_assets) + self._empty_state.set_visible(not has_assets) + + old_cards: dict[str, AssetCard] = {} + for uid, (card, signal, handler) in self._cards.items(): + signal.disconnect(handler) + old_cards[uid] = card + self._cards.clear() + + child = self._flowbox.get_first_child() + while child: + next_child = child.get_next_sibling() + if isinstance(child, Gtk.FlowBoxChild): + card = child.get_child() + if card: + child.set_child(None) + self._flowbox.remove(child) + child = next_child + + for asset in visible: + card = old_cards.pop(asset.uid, None) + if card is not None: + card.asset = asset + card.invalidate() + card.refresh() + else: + card = AssetCard(asset) + drag_source = Gtk.DragSource() + drag_source.set_actions(Gdk.DragAction.COPY) + drag_source.connect("prepare", self._on_drag_prepare) + card.add_controller(drag_source) + + fb_child = Gtk.FlowBoxChild() + fb_child.set_halign(Gtk.Align.CENTER) + fb_child.set_valign(Gtk.Align.START) + fb_child.set_hexpand(False) + fb_child.set_vexpand(False) + fb_child.set_child(card) + self._flowbox.append(fb_child) + self._connect_asset_signal(card) + + def _on_drag_prepare(self, source, x, y): + card = source.get_widget() + asset = card.asset + if not asset.is_draggable_to_canvas: + return None + + uids: list[str] = [] + + if self._selected_uids and asset.uid in self._selected_uids: + for uid in self._selected_uids: + entry = self._cards.get(uid) + if entry and entry[0].asset.is_draggable_to_canvas: + uids.append(str(uid)) + else: + uids.append(str(asset.uid)) + + data = json.dumps(uids) + provider = Gdk.ContentProvider.new_for_value(data) + paintable = Gtk.WidgetPaintable.new(card) + source.set_icon(paintable, int(x), int(y)) + return provider + + def _update_selection_visual(self): + child = self._flowbox.get_first_child() + while child: + card = ( + child.get_child() + if isinstance(child, Gtk.FlowBoxChild) + else None + ) + uid = card.asset.uid if isinstance(card, AssetCard) else None + if uid and uid in self._selected_uids: + child.add_css_class("selected") + else: + child.remove_css_class("selected") + child = child.get_next_sibling() + + def _on_flowbox_pressed(self, gesture, n_press, x, y): + modifier = ( + gesture.get_current_event_state() + if gesture.get_current_event() + else 0 + ) + ctrl = bool(modifier & Gdk.ModifierType.CONTROL_MASK) + shift = bool(modifier & Gdk.ModifierType.SHIFT_MASK) + + widget = self._flowbox.pick(x, y, Gtk.PickFlags.DEFAULT) + clicked_child = None + while widget and widget != self._flowbox: + if isinstance(widget, Gtk.FlowBoxChild): + clicked_child = widget + break + widget = widget.get_parent() + + if clicked_child is None: + if not ctrl and not shift: + self._selected_uids.clear() + self._update_selection_visual() + return + + card = cast(AssetCard, clicked_child.get_child()) + if card is None: + return + + uid = card.asset.uid + if n_press == 1: + if ctrl: + if uid in self._selected_uids: + self._selected_uids.discard(uid) + else: + self._selected_uids.add(uid) + elif shift and self._selected_uids: + self._select_range(uid) + elif uid not in self._selected_uids: + self._selected_uids = {uid} + self._update_selection_visual() + + def _select_range(self, uid): + all_uids = [] + child = self._flowbox.get_first_child() + while child: + card = ( + child.get_child() + if isinstance(child, Gtk.FlowBoxChild) + else None + ) + if isinstance(card, AssetCard): + all_uids.append(card.asset.uid) + child = child.get_next_sibling() + + if not all_uids or uid not in all_uids: + return + + anchor_uid = next(iter(self._selected_uids)) + if anchor_uid not in all_uids: + self._selected_uids = {uid} + return + + anchor_idx = all_uids.index(anchor_uid) + click_idx = all_uids.index(uid) + lo = min(anchor_idx, click_idx) + hi = max(anchor_idx, click_idx) + self._selected_uids = set(all_uids[lo : hi + 1]) + + def _on_flowbox_released(self, gesture, n_press, x, y): + pass + + def _on_child_activated(self, flowbox, child): + card = cast(AssetCard, child.get_child()) + if card: + self.asset_activated.send(self, asset=card.asset) + + def _on_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_Delete: + self.delete_selected_assets() + return True + return False + + def _on_right_click_pressed(self, gesture, n_press, x, y): + widget = self._scrolled.pick(x, y, Gtk.PickFlags.DEFAULT) + clicked_child = None + while widget and widget != self._scrolled: + if isinstance(widget, Gtk.FlowBoxChild): + clicked_child = widget + break + widget = widget.get_parent() + + if clicked_child is None: + self._show_empty_context_menu(gesture) + else: + card = cast(AssetCard, clicked_child.get_child()) + if card: + if card.asset.uid not in self._selected_uids: + self._selected_uids = {card.asset.uid} + self._update_selection_visual() + self._show_asset_context_menu(gesture, card.asset) + + def _popup_context_menu(self, menu: Gio.Menu, gesture: Gtk.Gesture): + if self._context_popover: + self._context_popover.unparent() + popover = Gtk.PopoverMenu.new_from_model(menu) + popover.set_parent(self._scrolled) + popover.set_has_arrow(False) + popover.set_position(Gtk.PositionType.RIGHT) + ok, rect = gesture.get_bounding_box() + if ok: + popover.set_pointing_to(rect) + self._context_popover = popover + popover.popup() + + def _show_empty_context_menu(self, gesture): + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("New Sketch"), "win.new_sketch")) + menu.append_item(Gio.MenuItem.new(_("New Stock"), "win.add-stock")) + menu.append_section(None, Gio.Menu.new()) + menu.append_item( + Gio.MenuItem.new(_("Import File\u2026"), "win.import") + ) + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Paste"), "win.asset-paste")) + self._popup_context_menu(menu, gesture) + + def _show_asset_context_menu(self, gesture, asset: IAsset): + menu = Gio.Menu.new() + + if isinstance(asset, IGeometryProvider): + menu.append_item( + Gio.MenuItem.new( + _("Create New Workpiece"), + "win.asset-create-workpiece", + ) + ) + menu.append_section(None, Gio.Menu.new()) + + menu.append_item( + Gio.MenuItem.new(_("Duplicate"), "win.asset-duplicate") + ) + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Copy"), "win.asset-copy")) + menu.append_item(Gio.MenuItem.new(_("Cut"), "win.asset-cut")) + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Delete"), "win.asset-delete")) + self._popup_context_menu(menu, gesture) + + def get_selected_assets(self) -> list[IAsset]: + assets = [] + for uid in self._selected_uids: + entry = self._cards.get(uid) + if entry: + assets.append(entry[0].asset) + return assets + + def copy_selected_assets(self): + assets = self.get_selected_assets() + if not assets: + return + self._asset_clipboard = [a.to_dict() for a in assets] + logger.debug( + "Copied %d asset(s) to clipboard", len(self._asset_clipboard) + ) + + def cut_selected_assets(self): + assets = self.get_selected_assets() + if not assets: + return + self._asset_clipboard = [a.to_dict() for a in assets] + history = self.editor.history_manager + with history.transaction(_("Cut asset(s)")) as t: + for asset in assets: + t.execute( + ListItemCommand( + owner_obj=self.editor.doc, + item=asset, + undo_command="add_asset", + redo_command="remove_asset", + name=_("Cut asset"), + ) + ) + self._selected_uids.clear() + + def paste_assets(self): + if not self._asset_clipboard: + logger.debug("Paste: clipboard is empty") + return + logger.debug( + "Pasting %d asset(s) from clipboard", + len(self._asset_clipboard), + ) + history = self.editor.history_manager + with history.transaction(_("Paste asset(s)")) as t: + for asset_dict in self._asset_clipboard: + data = copy.deepcopy(asset_dict) + new_uid = str(uuid.uuid4()) + data["uid"] = new_uid + type_name = data.get("type", "unknown") + asset_class = asset_type_registry.get(type_name) + if asset_class: + new_asset = asset_class.from_dict(data) + t.execute( + ListItemCommand( + owner_obj=self.editor.doc, + item=new_asset, + undo_command="remove_asset", + redo_command="add_asset", + name=_("Paste asset"), + ) + ) + else: + logger.warning("Paste: unknown asset type '%s'", type_name) + + def duplicate_selected_assets(self): + assets = self.get_selected_assets() + if not assets: + return + history = self.editor.history_manager + with history.transaction(_("Duplicate asset(s)")) as t: + for asset in assets: + data = copy.deepcopy(asset.to_dict()) + new_uid = str(uuid.uuid4()) + data["uid"] = new_uid + data["name"] = asset.name + " copy" + type_name = data.get("type", "unknown") + asset_class = asset_type_registry.get(type_name) + if asset_class: + new_asset = asset_class.from_dict(data) + t.execute( + ListItemCommand( + owner_obj=self.editor.doc, + item=new_asset, + undo_command="remove_asset", + redo_command="add_asset", + name=_("Duplicate asset"), + ) + ) + + def delete_selected_assets(self): + if not self._selected_uids: + return + for uid in list(self._selected_uids): + entry = self._cards.get(uid) + if entry: + self.editor.asset.delete_asset(entry[0].asset) + self._selected_uids.clear() + + def create_workpiece_from_selected(self): + assets = self.get_selected_assets() + if not assets: + return + for asset in assets: + if isinstance(asset, IGeometryProvider): + self.editor.edit.add_geometry_provider_instance( + asset.uid, (0.0, 0.0) + ) + + def can_paste_assets(self) -> bool: + return len(self._asset_clipboard) > 0 + + def _on_add_clicked(self, button): + asset_types = [] + for type_name, asset_class in asset_type_registry.all_types().items(): + if asset_class.is_addable: + display_name = f"Add {type_name.title()}" + asset_types.append((_(display_name), type_name)) + + popup = PopoverMenu(items=asset_types) + popup.set_parent(button) + popup.popup() + popup.connect("closed", self._on_add_popup_closed) + + def _on_add_popup_closed(self, popup): + if popup.selected_item: + self.add_asset_requested.send(self, type_name=popup.selected_item) diff --git a/rayforge/ui_gtk/doceditor/bottom_panel.py b/rayforge/ui_gtk/doceditor/bottom_panel.py new file mode 100644 index 000000000..1280a1df3 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/bottom_panel.py @@ -0,0 +1,743 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from blinker import Signal +from gi.repository import Adw, Gtk +from raygeo.ops.axis import Axis + +from ...logging_setup import ui_log_event_received +from ...machine.cmd import MachineCmd +from ...machine.driver.dummy import NoDeviceDriver +from ...machine.models.machine import Machine +from ...shared.gcodeedit.viewer import GcodeViewer +from ...shared.tasker import task_mgr +from ..doceditor.layers_tab import LayersTab +from ..icons import get_icon +from ..machine.console import Console +from ..machine.jog_widget import JogWidget +from ..machine.laser_control_widget import LaserControlWidget +from ..machine.wcs_dialog import WcsDialog +from ..shared.dock_item import DockItem +from ..shared.dock_layout import DockLayout +from ..shared.gtk import apply_css +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.pref_rows.speed_spin_row import SpeedSpinRow +from ..shared.responsive_box import ResponsiveBox +from .asset_browser import AssetBrowser + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +controls_css = """ +preferencesgroup.compact list { + margin-left: 0; + margin-right: 0; +} +""" + +apply_css(controls_css) + + +class BottomPanel(Gtk.Box): + def __init__( + self, + machine: Machine | None, + doc_editor: "DocEditor", + machine_cmd: MachineCmd | None = None, + **kwargs, + ): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + + self.notification_requested = Signal() + self.click_to_zero_mode_changed = Signal() + self.tab_changed = Signal() + self.layout_changed = Signal() + self.edit_item_requested = Signal() + self.select_items_requested = Signal() + self.machine = machine + self.machine_cmd = machine_cmd + self.doc = None + self._edit_dialog = None + self._click_to_zero_mode = False + self._updating_wcs_ui = False + self._active_layer = None + self._get_bounds_callback: ( + Callable[[], tuple[float, float, float, float] | None] | None + ) = None + + self.console = Console() + self.console.set_hexpand(True) + self.console.set_vexpand(True) + if machine: + self.console.set_machine(machine) + self.console.command_submitted.connect(self._on_command_submitted) + + ui_log_event_received.connect(self.console.on_log_received) + + self.layers_tab = LayersTab(doc_editor) + self.layers_tab.edit_item_requested.connect( + self._on_layers_tab_edit_item + ) + self.layers_tab.select_items_requested.connect( + self._on_layers_tab_select_items + ) + + self.asset_browser = AssetBrowser(doc_editor) + + self.gcode_viewer = GcodeViewer() + self.gcode_viewer.set_margin_start(0) + self.gcode_viewer.set_margin_end(0) + self.gcode_viewer.set_margin_top(9) + self.gcode_viewer.set_margin_bottom(9) + + self.jog_widget = JogWidget() + if machine and machine_cmd: + self.jog_widget.set_machine(machine, machine_cmd) + + self.laser_control = LaserControlWidget() + if machine and machine_cmd: + self.laser_control.set_machine(machine, machine_cmd) + + self._laser_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._laser_box.set_margin_start(9) + self._laser_box.set_margin_end(9) + self._laser_box.set_margin_top(9) + self._laser_box.set_margin_bottom(9) + self._laser_box.set_vexpand(True) + self._laser_box.set_hexpand(False) + self._laser_box.set_halign(Gtk.Align.START) + self._laser_box.append(self.laser_control) + self._jog_laser_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=12 + ) + self._jog_laser_box.append(self.jog_widget) + self._jog_laser_box.set_vexpand(True) + + self._controls_widget = ResponsiveBox() + self._controls_widget.set_halign(Gtk.Align.FILL) + self._controls_widget.set_vexpand(True) + self._controls_widget.set_valign(Gtk.Align.FILL) + self._controls_widget.set_margin_start(9) + self._controls_widget.set_margin_end(9) + self._controls_widget.set_margin_top(9) + self._controls_widget.set_margin_bottom(9) + + if machine: + self._setup_wcs_controls() + self._connect_machine_signals() + self._controls_widget.set_children( + self.wcs_group, self._jog_laser_box + ) + else: + self._controls_widget.set_children(self._jog_laser_box) + + self.dock_layout = DockLayout(orientation=Gtk.Orientation.HORIZONTAL) + self.dock_layout.layout_changed.connect(self._on_dock_layout_changed) + self.dock_layout.tab_changed.connect(self._on_dock_tab_changed) + + self._register_items() + self._build_default_layout() + self.append(self.dock_layout) + + def _register_items(self): + self.dock_layout.register_item( + DockItem( + name="layers", + icon_name="layers-symbolic", + widget=self.layers_tab, + label=_("Layers"), + ) + ) + self.dock_layout.register_item( + DockItem( + name="assets", + icon_name="image-x-generic-symbolic", + widget=self.asset_browser, + label=_("Assets"), + ) + ) + self.dock_layout.register_item( + DockItem( + name="gcode", + icon_name="gcode-symbolic", + widget=self.gcode_viewer, + label=_("G-code Viewer"), + ) + ) + self.dock_layout.register_item( + DockItem( + name="console", + icon_name="terminal-symbolic", + widget=self.console, + label=_("Console"), + ) + ) + self.dock_layout.register_item( + DockItem( + name="controls", + icon_name="jog-symbolic", + widget=self._controls_widget, + label=_("Controls"), + expands=False, + ) + ) + self.dock_layout.register_item( + DockItem( + name="laser", + icon_name="laser-on-symbolic", + widget=self._laser_box, + label=_("Laser"), + expands=False, + ) + ) + + def _build_default_layout(self): + tabs_area = self.dock_layout.add_area() + tabs_area.add_item(self.dock_layout.get_item("layers")) + tabs_area.add_item(self.dock_layout.get_item("assets")) + tabs_area.add_item(self.dock_layout.get_item("gcode")) + tabs_area.add_item(self.dock_layout.get_item("console")) + + controls_area = self.dock_layout.add_area() + controls_area.add_item(self.dock_layout.get_item("controls")) + controls_area.add_item(self.dock_layout.get_item("laser")) + + self.dock_layout.set_default_item_buddy("laser", "controls") + + def to_dict(self): + return { + "visible": self.get_visible(), + "areas": self.dock_layout.get_layout()["areas"], + } + + def from_dict(self, data): + if not data: + return + visible = data.get("visible", False) + self.set_visible(visible) + areas = data.get("areas") + if areas: + self.dock_layout.apply_layout({"areas": areas}) + + def is_item_visible(self, name): + area = self.dock_layout.find_item_area(name) + if area is None: + return False + active = area.get_active_item() + return active == name + + def _on_dock_layout_changed(self, sender): + self.layout_changed.send(self) + + def _on_dock_tab_changed(self, sender, *, name): + self.tab_changed.send(self, name=name) + + def set_doc(self, doc): + self._disconnect_layer_signals() + self.doc = doc + self.asset_browser.set_doc(doc) + self.layers_tab.set_doc(doc) + if doc: + doc.active_layer_changed.connect(self._on_active_layer_changed) + self._connect_layer_signals() + if self.machine: + self._update_wcs_ui() + + def _on_layers_tab_edit_item(self, sender, **kwargs): + self.edit_item_requested.send(sender, **kwargs) + + def _on_layers_tab_select_items(self, sender, **kwargs): + self.select_items_requested.send(sender, **kwargs) + + def update_layer_selection(self, selected_uids: set): + self.layers_tab.update_row_selection(selected_uids) + + def _on_active_layer_changed(self, sender): + self._disconnect_layer_signals() + self._connect_layer_signals() + if self.machine: + self._update_wcs_ui() + + def _connect_layer_signals(self): + if self.doc and self.doc.active_layer: + self._active_layer = self.doc.active_layer + self._active_layer.updated.connect(self._on_layer_updated) + + def _disconnect_layer_signals(self): + if self._active_layer: + self._active_layer.updated.disconnect(self._on_layer_updated) + self._active_layer = None + + def _on_layer_updated(self, sender): + if self.machine: + self._update_wcs_ui() + + def _on_command_submitted(self, sender, command: str, machine: Machine): + async def send_command(ctx): + try: + await machine.run_raw(command) + except Exception as e: # noqa: BLE001 - fire-and-forget task + logger.error(str(e), extra={"log_category": "ERROR"}) + + task_mgr.add_coroutine(send_command) + + def _setup_wcs_controls(self): + self.wcs_group = Adw.PreferencesGroup() + self.wcs_group.add_css_class("compact") + + if self.machine: + self.wcs_list = self.machine.supported_wcs + else: + self.wcs_list = [] + self._wcs_model = Gtk.StringList.new(self.wcs_list) + + factory = Gtk.SignalListItemFactory() + factory.connect("setup", self._on_wcs_factory_setup) + factory.connect("bind", self._on_wcs_factory_bind) + + self.wcs_row = Adw.ComboRow( + model=self._wcs_model, + factory=factory, + use_subtitle=True, + ) + self.wcs_row.connect( + "notify::selected", self._on_wcs_selection_changed + ) + self.wcs_group.add(self.wcs_row) + + self.offsets_row = Adw.ActionRow(title=_("Current Offsets")) + + self.edit_offsets_btn = Gtk.Button(child=get_icon("edit-symbolic")) + self.edit_offsets_btn.set_tooltip_text(_("Edit Offsets Manually")) + self.edit_offsets_btn.add_css_class("flat") + self.edit_offsets_btn.set_valign(Gtk.Align.CENTER) + self.edit_offsets_btn.connect("clicked", self._on_edit_offsets_clicked) + self.wcs_row.add_suffix(self.edit_offsets_btn) + + self.position_row = Adw.ActionRow(title=_("Current Position")) + self.wcs_group.add(self.position_row) + + position_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + position_button_box.set_spacing(6) + self.position_row.add_suffix(position_button_box) + + self.move_ll_btn = Gtk.Button(child=get_icon("bottom-left-symbolic")) + self.move_ll_btn.add_css_class("flat") + self.move_ll_btn.set_size_request(40, -1) + self.move_ll_btn.connect("clicked", self._on_move_to_position, "ll") + self.move_ll_btn.set_tooltip_text( + _("Move to Lower-Left of Selection or Workarea") + ) + position_button_box.append(self.move_ll_btn) + + self.move_center_btn = Gtk.Button(child=get_icon("center-symbolic")) + self.move_center_btn.add_css_class("flat") + self.move_center_btn.set_size_request(40, -1) + self.move_center_btn.connect( + "clicked", self._on_move_to_position, "center" + ) + self.move_center_btn.set_tooltip_text( + _("Move to Center of Selection or Workarea") + ) + position_button_box.append(self.move_center_btn) + + self.move_ur_btn = Gtk.Button(child=get_icon("top-right-symbolic")) + self.move_ur_btn.add_css_class("flat") + self.move_ur_btn.set_size_request(40, -1) + self.move_ur_btn.connect("clicked", self._on_move_to_position, "ur") + self.move_ur_btn.set_tooltip_text( + _("Move to Upper-Right of Selection or Workarea") + ) + position_button_box.append(self.move_ur_btn) + + self.move_origin_btn = Gtk.Button( + child=get_icon("goto-origin-symbolic") + ) + self.move_origin_btn.add_css_class("flat") + self.move_origin_btn.set_size_request(40, -1) + self.move_origin_btn.connect("clicked", self._on_move_to_wcs_zero) + self.move_origin_btn.set_tooltip_text( + _("Move to Origin of Active WCS") + ) + position_button_box.append(self.move_origin_btn) + + self.zero_row = Adw.ActionRow(title=_("Zero Axes")) + self.wcs_group.add(self.zero_row) + + zero_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + zero_button_box.set_spacing(6) + self.zero_row.add_suffix(zero_button_box) + + self.zero_x_btn = Gtk.Button(label=_("X")) + self.zero_x_btn.add_css_class("flat") + self.zero_x_btn.set_size_request(40, -1) + self.zero_x_btn.connect("clicked", self._on_zero_axis_clicked, Axis.X) + self.zero_x_btn.set_tooltip_text( + _("Set current X position as 0 for active WCS") + ) + zero_button_box.append(self.zero_x_btn) + + self.zero_y_btn = Gtk.Button(label=_("Y")) + self.zero_y_btn.add_css_class("flat") + self.zero_y_btn.set_size_request(40, -1) + self.zero_y_btn.connect("clicked", self._on_zero_axis_clicked, Axis.Y) + self.zero_y_btn.set_tooltip_text( + _("Set current Y position as 0 for active WCS") + ) + zero_button_box.append(self.zero_y_btn) + + self.zero_z_btn = Gtk.Button(label=_("Z")) + self.zero_z_btn.add_css_class("flat") + self.zero_z_btn.set_size_request(40, -1) + self.zero_z_btn.connect("clicked", self._on_zero_axis_clicked, Axis.Z) + self.zero_z_btn.set_tooltip_text( + _("Set current Z position as 0 for active WCS") + ) + zero_button_box.append(self.zero_z_btn) + + self.zero_here_btn = Gtk.Button(child=get_icon("zero-here-symbolic")) + self.zero_here_btn.set_tooltip_text( + _("Set Work Zero at Current Position") + ) + self.zero_here_btn.add_css_class("flat") + self.zero_here_btn.set_size_request(40, -1) + self.zero_here_btn.connect( + "clicked", self._on_zero_axis_clicked, Axis.X | Axis.Y | Axis.Z + ) + zero_button_box.append(self.zero_here_btn) + + self._click_to_zero_icon = get_icon("crosshairs-symbolic") + self.click_to_zero_btn = Gtk.Button(child=self._click_to_zero_icon) + self.click_to_zero_btn.set_tooltip_text( + _("Click Canvas to Set Work Zero") + ) + self.click_to_zero_btn.add_css_class("flat") + self.click_to_zero_btn.set_size_request(40, -1) + self.click_to_zero_btn.connect( + "clicked", self._on_click_to_zero_toggled + ) + zero_button_box.append(self.click_to_zero_btn) + self.click_to_zero_btn.set_tooltip_text( + _("Click on canvas to set work zero") + ) + + self.speed_row = SpeedSpinRow( + _("Jog Speed"), + _("Speed"), + lower=1, + upper=60000, + value_in_base=1000, + ) + self.speed_row.value_changed.connect(self._on_speed_changed) + self.wcs_group.add(self.speed_row) + + self.distance_row = LengthSpinRow( + _("Jog Distance"), + _("Distance in machine units"), + lower=0.1, + upper=1000, + value_in_base=10.0, + ) + self.distance_row.value_changed.connect(self._on_distance_changed) + self.wcs_group.add(self.distance_row) + + self._update_wcs_ui() + + def _on_speed_changed(self, row): + speed_mm_min = int(self.speed_row.get_value_in_base_units()) + self.jog_widget.jog_speed = speed_mm_min + + def _on_distance_changed(self, row): + self.jog_widget.jog_distance = ( + self.distance_row.get_value_in_base_units() + ) + + def _connect_machine_signals(self): + if self.machine: + self.machine.wcs_updated.connect(self._on_wcs_updated) + self.machine.state_changed.connect(self._on_machine_state_changed) + self.machine.changed.connect(self._on_wcs_updated) + + def _disconnect_machine_signals(self): + if self.machine: + self.machine.wcs_updated.disconnect(self._on_wcs_updated) + self.machine.state_changed.disconnect( + self._on_machine_state_changed + ) + self.machine.changed.disconnect(self._on_wcs_updated) + + def set_machine( + self, + machine: Machine | None, + machine_cmd: MachineCmd | None = None, + ): + self._disconnect_machine_signals() + + self.machine = machine + self.machine_cmd = machine_cmd + + self.console.set_machine(machine) + + if self.machine: + self._connect_machine_signals() + self._update_wcs_ui() + + if self.machine and self.machine_cmd: + self.jog_widget.set_machine(self.machine, self.machine_cmd) + self.laser_control.set_machine(self.machine, self.machine_cmd) + + def _on_wcs_selection_changed(self, combo_row, _pspec): + if self._updating_wcs_ui: + return + if not self.machine: + return + machine = self.machine + idx = combo_row.get_selected() + if 0 <= idx < len(self.wcs_list): + wcs = self.wcs_list[idx] + if machine.active_wcs != wcs: + task_mgr.add_coroutine( + lambda ctx, w=wcs: machine.switch_active_wcs(w), + key=(machine.id, "select-wcs"), + ) + + def _on_zero_axis_clicked(self, button, axis): + if not self.machine: + return + machine = self.machine + task_mgr.add_coroutine(lambda ctx: machine.set_work_origin_here(axis)) + + def set_click_to_zero_mode(self, active: bool): + if self._click_to_zero_mode != active: + self._click_to_zero_mode = active + self._update_wcs_ui() + self.click_to_zero_mode_changed.send(self, active=active) + + def set_get_bounds_callback( + self, + callback: Callable[[], tuple[float, float, float, float] | None] + | None, + ): + self._get_bounds_callback = callback + + def update_position_menu_sensitivity(self): + if not self.machine: + return + is_dummy = isinstance(self.machine.driver, NoDeviceDriver) + is_connected = self.machine.is_connected() + is_active = is_connected or is_dummy + + has_bounds = ( + self._get_bounds_callback is not None + and self._get_bounds_callback() is not None + ) + self.move_ll_btn.set_sensitive(has_bounds and is_active) + self.move_center_btn.set_sensitive(has_bounds and is_active) + self.move_ur_btn.set_sensitive(has_bounds and is_active) + self.move_origin_btn.set_sensitive(is_active) + + def _on_move_to_position(self, button, position: str): + if not self.machine or not self.machine_cmd: + return + if not self._get_bounds_callback: + return + + bounds = self._get_bounds_callback() + if not bounds: + return + + min_x, min_y, max_x, max_y = bounds + + if position == "ll": + world_x, world_y = min_x, min_y + elif position == "center": + world_x, world_y = (min_x + max_x) / 2, (min_y + max_y) / 2 + elif position == "ur": + world_x, world_y = max_x, max_y + else: + return + + panel = self.machine.panel + machine_x, machine_y = panel.world_point_to_machine(world_x, world_y) + wcs_offset = self.machine.get_active_wcs_offset() + x_off, y_off, _ = panel.get_command_offset( + wcs_offset=wcs_offset, + wcs_is_workarea_origin=self.machine.wcs_origin_is_workarea_origin, + ) + self.machine_cmd.move_to( + self.machine, machine_x - x_off, machine_y - y_off + ) + + def _on_move_to_wcs_zero(self, button): + if not self.machine or not self.machine_cmd: + return + self.machine_cmd.move_to(self.machine, 0.0, 0.0) + + def _on_click_to_zero_toggled(self, button): + self.set_click_to_zero_mode(not self._click_to_zero_mode) + + def _on_edit_offsets_clicked(self, button): + if not self.machine: + return + + root = self.get_root() + self._edit_dialog = WcsDialog( + machine=self.machine, + transient_for=root if isinstance(root, Gtk.Window) else None, + ) + self._edit_dialog.connect( + "destroy", lambda *_: setattr(self, "_edit_dialog", None) + ) + self._edit_dialog.present() + + def _on_wcs_updated(self, machine): + self._update_wcs_ui() + + def _on_machine_state_changed(self, machine, state): + self._update_wcs_ui() + self.console.on_machine_state_changed(machine, state) + + def _on_wcs_factory_setup(self, factory, list_item): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + name_label = Gtk.Label(xalign=0) + subtitle_label = Gtk.Label(xalign=0) + subtitle_label.add_css_class("dim-label") + box.append(name_label) + box.append(subtitle_label) + list_item.set_child(box) + + def _on_wcs_factory_bind(self, factory, list_item): + idx = list_item.get_position() + if idx < 0 or idx >= len(self.wcs_list): + return + wcs_name = self.wcs_list[idx] + box = list_item.get_child() + name_label = box.get_first_child() + subtitle_label = name_label.get_next_sibling() + if self.machine: + label = self.machine.get_wcs_label(wcs_name) + if label: + name_label.set_label(f"{wcs_name} ({label})") + else: + name_label.set_label(wcs_name) + off = self.machine.get_wcs_offset(wcs_name) + subtitle_label.set_label( + f"X: {off[0]:.2f} Y: {off[1]:.2f} Z: {off[2]:.2f}" + ) + subtitle_label.set_visible(True) + else: + name_label.set_label(wcs_name) + subtitle_label.set_visible(False) + + def _update_wcs_ui(self): + if not self.machine: + return + + hide_wcs_controls = self.machine.wcs_origin_is_workarea_origin + self.wcs_row.set_visible(not hide_wcs_controls) + self.zero_row.set_visible(not hide_wcs_controls) + + layer_has_wcs = ( + self.doc and self.doc.active_layer and self.doc.active_layer.wcs + ) + self.wcs_row.set_sensitive(not layer_has_wcs) + if layer_has_wcs: + self.wcs_row.set_tooltip_text( + _( + "Overridden by the current layer. " + "Change it in the layer settings." + ) + ) + else: + self.wcs_row.set_tooltip_text("") + + current_wcs = self.machine.active_wcs + if current_wcs in self.wcs_list: + idx = self.wcs_list.index(current_wcs) + if self.wcs_row.get_selected() != idx: + self._updating_wcs_ui = True + self.wcs_row.set_selected(idx) + self._updating_wcs_ui = False + + wcs_label = self.machine.get_wcs_label(current_wcs) + if wcs_label: + title = f"{current_wcs} ({wcs_label})" + else: + title = current_wcs + self.wcs_row.set_title(title) + + off_x, off_y, off_z = self.machine.get_active_wcs_offset() + self.wcs_row.set_subtitle( + f"X: {off_x:.2f} Y: {off_y:.2f} Z: {off_z:.2f}" + ) + + n = self._wcs_model.get_n_items() + for i in range(n): + self._wcs_model.items_changed(i, 1, 1) + + is_dummy = isinstance(self.machine.driver, NoDeviceDriver) + is_connected = self.machine.is_connected() + is_active = is_connected or is_dummy + + m_pos = self.machine.device_state.machine_pos + m_x, m_y, m_z = ( + (m_pos[0], m_pos[1], m_pos[2]) + if m_pos and all(p is not None for p in m_pos) + else (None, None, None) + ) + + selected_idx = self.wcs_row.get_selected() + if 0 <= selected_idx < len(self.wcs_list): + selected_wcs_ui = self.wcs_list[selected_idx] + else: + selected_wcs_ui = self.machine.active_wcs + + pos_x, pos_y, pos_z = (None, None, None) + if m_x is not None and m_y is not None and m_z is not None: + if selected_wcs_ui == self.machine.machine_space_wcs: + pos_x, pos_y, pos_z = m_x, m_y, m_z + else: + offset = self.machine.get_wcs_offset(selected_wcs_ui) + pos_x = m_x - offset[0] + pos_y = m_y - offset[1] + pos_z = m_z - offset[2] + + pos_str = "" + if pos_x is not None: + pos_str += f"X: {pos_x:.2f} " + if pos_y is not None: + pos_str += f"Y: {pos_y:.2f} " + if pos_z is not None: + pos_str += f"Z: {pos_z:.2f}" + + if not is_active: + self.position_row.set_subtitle(_("Offline - Position Unknown")) + else: + self.position_row.set_subtitle(pos_str if pos_str else "---") + + is_mcs = current_wcs == self.machine.machine_space_wcs + can_zero = is_active and not is_mcs + can_manual = not is_mcs + + self.zero_x_btn.set_sensitive(can_zero) + self.zero_y_btn.set_sensitive(can_zero) + self.zero_z_btn.set_sensitive(can_zero) + self.zero_here_btn.set_sensitive(can_zero) + self.edit_offsets_btn.set_sensitive(can_manual) + + self.update_position_menu_sensitivity() + + if is_mcs: + msg = _( + "Offsets cannot be set in Machine Coordinate Mode ({wcs})" + ).format(wcs=self.machine.machine_space_wcs_display_name) + elif not is_active: + msg = _("Machine must be connected to set Zero Here") + else: + msg = _("Set current position as 0") + + self.zero_here_btn.set_tooltip_text(msg) diff --git a/rayforge/ui_gtk/doceditor/file_dialogs.py b/rayforge/ui_gtk/doceditor/file_dialogs.py new file mode 100644 index 000000000..2e2cdcb52 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/file_dialogs.py @@ -0,0 +1,260 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, Optional, cast + +from gi.repository import Gio, GLib, Gtk + +from ... import const +from ...image.registry import exporter_registry, importer_registry +from ..shared.gtk import file_filter_to_gtk + +if TYPE_CHECKING: + from ...core.workpiece import WorkPiece + from ...doceditor.editor import DocEditor + from ..mainwindow import MainWindow + +logger = logging.getLogger(__name__) + + +def show_import_dialog( + win: "MainWindow", + editor: "DocEditor", + callback: Callable, + user_data: Any = None, +): + """ + Shows the file chooser dialog for importing files. + + Args: + win: The parent Gtk.Window. + editor: The DocEditor instance (for future extensibility). + callback: The function to call with (dialog, result, user_data) upon + response. + user_data: Custom data to pass to the callback. + """ + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Open File")) + + filter_list = Gio.ListStore.new(Gtk.FileFilter) + all_supported = Gtk.FileFilter() + all_supported.set_name(_("All supported")) + + supported_types = importer_registry.get_all_filters() + + for file_type in supported_types: + if file_type.extensions: + for ext in file_type.extensions: + pattern = f"*{ext}" + all_supported.add_pattern(pattern) + + if file_type.mime_types: + for mime_type in file_type.mime_types: + all_supported.add_mime_type(mime_type) + + filter_list.append(all_supported) + + for file_type in supported_types: + file_filter = file_filter_to_gtk(file_type) + filter_list.append(file_filter) + + 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", + callback: Callable, + initial_name: str | None = None, +): + """ + Shows the save file dialog for exporting G-code. + + Args: + win: The parent Gtk.Window. + callback: The function to call with (dialog, result, user_data) upon + response. The window instance is passed as user_data. + initial_name: Optional initial file name for the dialog. + """ + initial_name = initial_name or "output.gcode" + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Save G-code File")) + + dialog.set_initial_name(initial_name) + + # Create a Gio.ListModel for the filters + filter_list = Gio.ListStore.new(Gtk.FileFilter) + gcode_filter = Gtk.FileFilter() + gcode_filter.set_name(_("G-code files")) + gcode_filter.add_mime_type("text/x.gcode") + filter_list.append(gcode_filter) + + # Set the filters for the dialog + dialog.set_filters(filter_list) + dialog.set_default_filter(gcode_filter) + + # Show the dialog and handle the response + dialog.save(win, None, callback, win) + + +def show_export_object_dialog( + win: "MainWindow", + callback: Callable, + workpiece: Optional["WorkPiece"] = None, +): + """ + Shows the save file dialog for exporting a workpiece. + + Available formats are dynamically populated from the exporter registry. + + Args: + win: The parent Gtk.Window. + callback: The function to call with (dialog, result, user_data) upon + response. + workpiece: Optional workpiece to use for default export location. + If provided, the dialog will default to the source file + location and name of the workpiece. + """ + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Export Object")) + + export_filters = exporter_registry.get_all_filters() + + if workpiece and workpiece.source_file: + dialog.set_initial_name(workpiece.source_file.name) + try: + folder = Gio.File.new_for_path(str(workpiece.source_file.parent)) + dialog.set_initial_folder(folder) + except GLib.Error: + logger.debug( + "Could not set initial folder for object export dialog" + ) + elif export_filters: + default_ext = export_filters[0].extensions[0] + dialog.set_initial_name(f"object{default_ext}") + + filter_list = Gio.ListStore.new(Gtk.FileFilter) + + for export_filter in export_filters: + file_filter = file_filter_to_gtk(export_filter) + filter_list.append(file_filter) + + if filter_list.get_n_items() > 0: + dialog.set_filters(filter_list) + default_filter = filter_list.get_item(0) + if default_filter: + dialog.set_default_filter(cast(Gtk.FileFilter, default_filter)) + + dialog.save(win, None, callback, win) + + +def show_export_document_dialog( + win: "MainWindow", + callback: Callable, + initial_name: str | None = None, +): + """ + Shows the save file dialog for exporting a complete document. + + Supports formats: + - SVG (.svg) - scalable vector graphics + - DXF (.dxf) - CAD exchange format + + Args: + win: The parent Gtk.Window. + callback: The function to call with (dialog, result, user_data) upon + response. + initial_name: Optional initial file name for the dialog. + """ + initial_name = initial_name or "document.svg" + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Export Document")) + + dialog.set_initial_name(initial_name) + + filter_list = Gio.ListStore.new(Gtk.FileFilter) + + svg_filter = Gtk.FileFilter() + svg_filter.set_name(_("SVG (Scalable Vector Graphics)")) + svg_filter.add_pattern("*.svg") + svg_filter.add_mime_type("image/svg+xml") + filter_list.append(svg_filter) + + dxf_filter = Gtk.FileFilter() + dxf_filter.set_name(_("DXF (CAD Exchange Format)")) + dxf_filter.add_pattern("*.dxf") + dxf_filter.add_mime_type("image/vnd.dxf") + filter_list.append(dxf_filter) + + dialog.set_filters(filter_list) + dialog.set_default_filter(svg_filter) + + dialog.save(win, None, callback, win) + + +def show_open_project_dialog(win: "MainWindow", callback: Callable): + """ + Shows file chooser dialog for opening Rayforge project files. + + Args: + win: The parent Gtk.Window. + callback: The function to call with (dialog, result, user_data) upon + response. + """ + dialog = Gtk.FileDialog.new() + dialog.set_title( + _("Open {app_name} Project").format(app_name=const.APP_NAME) + ) + + filter_list = Gio.ListStore.new(Gtk.FileFilter) + project_filter = Gtk.FileFilter() + project_filter.set_name( + _("{app_name} Project").format(app_name=const.APP_NAME) + ) + project_filter.add_pattern("*.ryp") + filter_list.append(project_filter) + + dialog.set_filters(filter_list) + dialog.set_default_filter(project_filter) + + dialog.open(win, None, callback, win) + + +def show_save_project_dialog( + win: "MainWindow", + callback: Callable, + initial_name: str | None = None, +): + """ + Shows save file dialog for saving Rayforge project files. + + Args: + win: The parent Gtk.Window. + callback: The function to call with (dialog, result, user_data) upon + response. + initial_name: Optional initial file name for the dialog. + """ + dialog = Gtk.FileDialog.new() + dialog.set_title( + _("Save {app_name} Project").format(app_name=const.APP_NAME) + ) + + if initial_name: + dialog.set_initial_name(initial_name) + else: + dialog.set_initial_name("untitled.ryp") + + filter_list = Gio.ListStore.new(Gtk.FileFilter) + project_filter = Gtk.FileFilter() + project_filter.set_name( + _("{app_name} Project").format(app_name=const.APP_NAME) + ) + project_filter.add_pattern("*.ryp") + filter_list.append(project_filter) + + dialog.set_filters(filter_list) + dialog.set_default_filter(project_filter) + + dialog.save(win, None, callback, win) diff --git a/rayforge/ui_gtk/doceditor/group_row.py b/rayforge/ui_gtk/doceditor/group_row.py new file mode 100644 index 000000000..45a1fbea1 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/group_row.py @@ -0,0 +1,169 @@ +import logging + +from gi.repository import Gdk, Gtk, Pango + +from ...core.group import Group +from ..icons import get_icon +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + +_RENAME_CSS = """ +.layer-workpiece-list .layer-rename-entry { + min-height: 0; + padding: 1px 6px; +} +""" + + +class GroupRow(Gtk.Box): + def __init__(self, group: Group, on_rename=None): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.group = group + self._on_rename = on_rename + self._rename_entry = None + self._rename_click_controller = None + self.set_margin_start(6) + self.set_margin_end(6) + self.set_margin_top(4) + self.set_margin_bottom(4) + + apply_css(_RENAME_CSS) + + self.icon = get_icon("layer-symbolic") + self.icon.set_valign(Gtk.Align.CENTER) + self.append(self.icon) + + self.name_label = Gtk.Label() + self.name_label.set_hexpand(True) + self.name_label.set_halign(Gtk.Align.START) + self.name_label.set_valign(Gtk.Align.CENTER) + self.name_label.set_ellipsize(Pango.EllipsizeMode.END) + self.append(self.name_label) + + click = Gtk.GestureClick() + click.set_button(Gdk.BUTTON_PRIMARY) + click.connect("pressed", self._on_double_clicked) + self.add_controller(click) + + self._update_ui() + + group.updated.connect(self._on_group_updated) + + def do_destroy(self): + self.group.updated.disconnect(self._on_group_updated) + self._remove_rename_click_controller() + + def get_drag_content(self) -> Gdk.ContentProvider: + return Gdk.ContentProvider.new_for_value(self.group.uid) + + def _update_ui(self): + self.name_label.set_text(self.group.name) + + def _on_group_updated(self, sender, **kwargs): + self._update_ui() + + def _on_drag_prepare(self, drag_source, x, y): + snapshot = Gtk.Snapshot() + GroupRow.do_snapshot(self, snapshot) + paintable = snapshot.to_paintable() + if paintable: + drag_source.set_icon(paintable, x, y) + return self.get_drag_content() + + def _on_double_clicked(self, gesture, n_press, x, y): + if n_press != 2: + return + self.start_rename() + + def start_rename(self): + """Starts in-place editing of the item name.""" + if self._rename_entry is not None: + return + entry = Gtk.Entry() + entry.set_text(self.group.name) + entry.select_region(0, -1) + entry.set_hexpand(True) + entry.set_halign(Gtk.Align.START) + entry.set_valign(Gtk.Align.CENTER) + entry.add_css_class("layer-rename-entry") + entry.connect("activate", self._on_rename_committed) + focus_controller = Gtk.EventControllerFocus.new() + focus_controller.connect("leave", self._on_rename_focus_out) + entry.add_controller(focus_controller) + key_controller = Gtk.EventControllerKey.new() + key_controller.connect("key-pressed", self._on_rename_key_pressed) + entry.add_controller(key_controller) + self._rename_entry = entry + self.remove(self.name_label) + self.append(entry) + entry.grab_focus() + self._install_rename_click_capture() + + def _install_rename_click_capture(self): + """Closes the editor when clicking anywhere outside the entry.""" + root = self.get_ancestor(Gtk.Window) + if root is None: + return + controller = Gtk.GestureClick() + controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + controller.connect("pressed", self._on_rename_root_click) + root.add_controller(controller) + self._rename_click_controller = controller + + def _remove_rename_click_controller(self): + if self._rename_click_controller is None: + return + widget = self._rename_click_controller.get_widget() + if widget: + widget.remove_controller(self._rename_click_controller) + self._rename_click_controller = None + + def _on_rename_root_click(self, gesture, n_press, x, y): + if self._rename_entry is None: + return + entry = self._rename_entry + root = self.get_ancestor(Gtk.Window) + picked = root.pick(x, y, Gtk.PickFlags.DEFAULT) if root else None + while picked is not None and picked is not root: + if picked is entry: + return + picked = picked.get_parent() + self._finish_rename(entry) + + def _on_rename_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_Escape: + self._cancel_rename() + return True + return False + + def _on_rename_committed(self, entry): + self._finish_rename(entry) + + def _on_rename_focus_out(self, *args): + self._finish_rename(self._rename_entry) + + def _cancel_rename(self): + if self._rename_entry is None: + return + self._replace_name_widget() + self._update_ui() + + def _finish_rename(self, entry): + if self._rename_entry is None: + return + new_name = entry.get_text().strip() + self._replace_name_widget() + if new_name and new_name != self.group.name: + if self._on_rename: + self._on_rename(self.group, new_name) + else: + self._update_ui() + + def _replace_name_widget(self): + if self._rename_entry is None: + return + self._remove_rename_click_controller() + self.remove(self._rename_entry) + self._rename_entry = None + self.append(self.name_label) diff --git a/rayforge/ui_gtk/doceditor/image_metadata_dialog.py b/rayforge/ui_gtk/doceditor/image_metadata_dialog.py new file mode 100644 index 000000000..5f5898f8e --- /dev/null +++ b/rayforge/ui_gtk/doceditor/image_metadata_dialog.py @@ -0,0 +1,263 @@ +import logging +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, Gdk, Gtk, Pango + +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow + +logger = logging.getLogger(__name__) + + +class ImageMetadataDialog(PatchedDialogWindow): + """ + A dialog that displays image metadata in a clean, organized format. + """ + + def __init__(self, parent: Gtk.Window | None = None): + super().__init__() + self.set_title(_("Image Metadata")) + self.set_transient_for(parent) + self.set_modal(False) + + # Set a reasonable default size + self.set_default_size(600, 500) + + # Create a vertical box to hold the header bar and the content + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + + # Add a header bar for title and window controls + self.header_bar = Adw.HeaderBar() + main_box.append(self.header_bar) + + # Add copy button to header bar + self.copy_button = Gtk.Button(child=get_icon("copy-symbolic")) + self.copy_button.set_tooltip_text(_("Copy Metadata")) + self.copy_button.connect("clicked", self._on_copy_clicked) + self.header_bar.pack_end(self.copy_button) + + # The main content area should be scrollable + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + scrolled_window.set_vexpand(True) + main_box.append(scrolled_window) + + # Create a preferences page and add it to the scrollable area + self.scrolled_window = scrolled_window + self.page = Adw.PreferencesPage() + scrolled_window.set_child(self.page) + + # Status label for when no metadata is available + self.status_label = Gtk.Label(label=_("No metadata available")) + self.status_label.add_css_class("dim-label") + self.status_label.set_halign(Gtk.Align.CENTER) + self.status_label.set_valign(Gtk.Align.CENTER) + self.status_label.set_visible(False) + + # Add a key controller to close the dialog on Escape press + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self.add_controller(key_controller) + + def set_metadata(self, import_source): + """ + Sets the metadata to display in the dialog. + + Args: + import_source: ImportSource object with explicitly modeled + attributes + """ + # Store import_source for clipboard operations + self.import_source = import_source + metadata = import_source.metadata + filename = import_source.source_file.name + + # Update window title + self.set_title(f"{filename} - Image Metadata") + + # Clear existing content by creating a new page + self.page = Adw.PreferencesPage() + self.scrolled_window.set_child(self.page) + + self.status_label.set_visible(False) + + # Group metadata by category + metadata_info = [] + + logger.debug(f"Processing metadata with {len(metadata)} items") + for key, value in metadata.items(): + # All metadata attributes (except basic) go to metadata_info + metadata_info.append((key, value)) + + # Create sections for each category + self._create_basic_section(import_source) + + if metadata_info: + self._create_metadata_section(metadata_info) + + def _create_basic_section(self, import_source): + """ + Creates the Basic Information section directly from import_source. + """ + # Create preferences group for basic information + group = Adw.PreferencesGroup() + group.set_title(_("Basic Information")) + group.set_description( + _("Basic image properties like dimensions and format.") + ) + + # Add Source File row + row = Adw.ActionRow() + row.set_title("Source File") + value_label = Gtk.Label(label=str(import_source.source_file)) + row.add_suffix(value_label) + group.add(row) + + # Add UID row + row = Adw.ActionRow() + row.set_title("UID") + value_label = Gtk.Label(label=import_source.uid) + row.add_suffix(value_label) + group.add(row) + + # Add Renderer row + row = Adw.ActionRow() + row.set_title("Renderer") + value_label = Gtk.Label( + label=import_source.renderer.__class__.__name__ + ) + row.add_suffix(value_label) + group.add(row) + + self.page.add(group) + + def _create_metadata_section(self, items: list[tuple[str, Any]]): + """ + Creates the Metadata section containing all metadata attributes. + + Args: + items: List of (key, value) tuples for metadata + """ + # Create preferences group for metadata + group = Adw.PreferencesGroup() + group.set_title(_("Metadata")) + group.set_description(_("All metadata extracted from the image.")) + + # Add key-value pairs as action rows + for key, value in items: + row = Adw.ActionRow() + row.set_title(key) + + # Format value for display + value_str = self._format_value(value) + + # Create value label + value_label = Gtk.Label(label=value_str) + value_label.set_ellipsize(Pango.EllipsizeMode.END) + value_label.set_xalign(0.0) + value_label.add_css_class("dim-label") + + # Add value label to row + row.add_suffix(value_label) + group.add(row) + + self.page.add(group) + + def _format_value(self, value: Any) -> str: + """Formats a metadata value for display.""" + if value is None: + return "N/A" + elif isinstance(value, bool): + return "Yes" if value else "No" + elif isinstance(value, bytes): + return f"Binary data ({len(value)} bytes)" + elif isinstance(value, str) and value.startswith(" 200: + return f"Text data ({len(value)} characters)" + elif isinstance(value, (list, tuple)): + if len(value) > 10: + return f"[{len(value)} items]" + return ", ".join(str(v) for v in value) + elif isinstance(value, dict): + return f"[Dictionary with {len(value)} keys]" + else: + return str(value) + + def _on_copy_clicked(self, button): + """Copy all metadata to clipboard.""" + display = Gdk.Display.get_default() + if display: + clipboard = display.get_clipboard() + else: + return + + # Get metadata and filename from import_source + metadata = self.import_source.metadata + filename = self.import_source.source_file.name + + # Format metadata as text + text_parts = [] + if filename: + text_parts.append(f"File: {filename}") + text_parts.append("") + + # Group metadata by category + metadata_info = [] + + for key, value in metadata.items(): + # All metadata attributes (except basic) go to metadata_info + metadata_info.append((key, value)) + + # Add sections to text + text_parts.append("Basic Information") + text_parts.append("=" * 20) + text_parts.append(f"Source File: {self.import_source.source_file!s}") + text_parts.append(f"UID: {self.import_source.uid}") + text_parts.append( + f"Renderer: {self.import_source.renderer.__class__.__name__}" + ) + text_parts.append("") + + if metadata_info: + text_parts.append("Metadata") + text_parts.append("=" * 20) + for key, value in metadata_info: + text_parts.append(f"{key}: {self._format_value(value)}") + + # Copy to clipboard + text = "\n".join(text_parts) + clipboard.set(text) + + # Show a brief notification + self._show_copy_notification() + + def _show_copy_notification(self): + """Show a brief notification that metadata was copied.""" + # Create a simple notification by changing the window title briefly + original_title = self.get_title() + self.set_title(_("Metadata copied to clipboard")) + + # Restore original title after 2 seconds + def restore_title(): + self.set_title(original_title) + + # Use GLib.timeout_add to restore the title + import gi + + gi.require_version("GLib", "2.0") + from gi.repository import GLib + + GLib.timeout_add(2000, restore_title) + + def _on_key_pressed(self, controller, keyval, keycode, state): + """Handle key press events, closing the dialog on Escape.""" + if keyval == Gdk.KEY_Escape: + self.close() + return True + return False diff --git a/rayforge/ui_gtk/doceditor/import_dialog.py b/rayforge/ui_gtk/doceditor/import_dialog.py new file mode 100644 index 000000000..5183336c9 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/import_dialog.py @@ -0,0 +1,821 @@ +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +import cairo +from blinker import Signal +from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk +from raygeo.geo import Matrix + +from ...context import get_context +from ...core.item import DocItem +from ...core.layer import Layer +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import ( + LayerImportMode, + LayerSource, + PassthroughSpec, + TraceSpec, + VectorizationSpec, +) +from ...core.workpiece import WorkPiece +from ...doceditor.file_cmd import PreviewResult +from ...image.base_importer import ImporterFeature +from ...image.geo_renderer import geometry_to_cairo +from ...image.structures import ImportManifest +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..shared.pref_rows.base import SpinRow +from ..shared.slider import create_slider + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +# A fixed, reasonable resolution for generating preview bitmaps. +PREVIEW_RENDER_SIZE_PX = 1024 + + +class ImportDialog(PatchedDialogWindow): + """ + A dialog for importing images with live preview of vectorization. + """ + + def __init__( + self, + parent: Gtk.Window, + editor: "DocEditor", + file_path: Path, + mime_type: str, + features: set[ImporterFeature], + source_asset: SourceAsset | None = None, + initial_spec: VectorizationSpec | None = None, + ): + super().__init__(transient_for=parent, modal=True) + self.editor = editor + self.file_path = file_path + self.mime_type = mime_type + self.features = features + self.source_asset = source_asset + self.response = Signal() + + # Internal state + self._file_bytes: bytes | None = None + self._manifest: ImportManifest | None = None + self._preview_result: PreviewResult | None = None + self._background_pixbuf: GdkPixbuf.Pixbuf | None = None + self._in_update = False # Prevent signal recursion + self._layer_widgets: dict[Gtk.Switch, str] = {} + self._layers_expander: Adw.ExpanderRow | None = None + + self._layer_import_model = Gtk.StringList.new( + [ + _("Map to Existing"), + _("New Layers"), + _("Flatten"), + ] + ) + self.layer_import_mode_row = Adw.ComboRow( + title=_("Layer Import Mode"), + subtitle=_("How imported layers are mapped to document layers"), + model=self._layer_import_model, + selected=0, + ) + self.layer_import_mode_row.connect( + "notify::selected", self._schedule_preview_update + ) + + self._layer_source_model = Gtk.StringList.new( + [ + _("SVG Layers"), + _("Colors"), + ] + ) + self.layer_source_row = Adw.ComboRow( + title=_("Layer Source"), + subtitle=_("Group imported geometry by SVG layer or by color"), + model=self._layer_source_model, + selected=0, + ) + self.layer_source_row.connect( + "notify::selected", self._on_layer_source_changed + ) + + self.set_title(_("Import Image")) + self.set_default_size(1100, 800) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + + header_bar = Adw.HeaderBar() + main_box.append(header_bar) + + # Banner for errors (e.g. SVG parse errors) + self.error_banner = Adw.Banner(title="") + self.error_banner.set_revealed(False) + main_box.append(self.error_banner) + + # Banner for warnings (e.g. SVG empty content) + self.warning_banner = Adw.Banner( + title=_( + "The file produced no output in direct vector mode. " + "Files containing text or other non-path elements " + "should be converted to paths before importing " + "(e.g., in Inkscape: Path > Object to Path)." + ), + button_label=_("Switch to Trace Mode"), + ) + self.warning_banner.connect( + "button-clicked", self._on_switch_to_trace_clicked + ) + self.warning_banner.set_revealed(False) + main_box.append(self.warning_banner) + + content_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, vexpand=True, hexpand=True + ) + main_box.append(content_box) + + # Header Bar Buttons + if source_asset: + self.import_button = Gtk.Button( + label=_("Re-Import"), css_classes=["suggested-action"] + ) + else: + self.import_button = Gtk.Button( + label=_("Import"), css_classes=["suggested-action"] + ) + self.import_button.connect("clicked", self._on_import_clicked) + header_bar.pack_end(self.import_button) + + cancel_button = Gtk.Button(label=_("Cancel")) + cancel_button.connect("clicked", lambda btn: self.close()) + header_bar.pack_start(cancel_button) + + self.status_spinner = Gtk.Spinner(spinning=True) + header_bar.pack_start(self.status_spinner) + + # Sidebar for Controls + sidebar = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + width_request=500, + hexpand=False, + margin_top=12, + margin_bottom=12, + margin_start=12, + margin_end=6, + ) + content_box.append(sidebar) + + preferences_page = Adw.PreferencesPage() + sidebar.append(preferences_page) + + # Import Mode Group (for files with multiple import options) + mode_group = Adw.PreferencesGroup(title=_("Import Mode")) + preferences_page.add(mode_group) + + self.use_vectors_switch = Adw.SwitchRow( + title=_("Use Original Vectors"), + subtitle=_("Import vector data directly"), + active=True, + ) + self.use_vectors_switch.connect( + "notify::active", self._on_import_mode_toggled + ) + mode_group.add(self.use_vectors_switch) + + config = get_context().config + self.dpi_row = SpinRow( + _("DPI"), + _( + "Pixels per inch for unitless SVG dimensions. " + "Inkscape ≥0.92 uses 96, older Inkscape uses 90, " + "Illustrator uses 72" + ), + lower=1.0, + upper=10000.0, + numeric=True, + value=config.import_dpi, + ) + self.dpi_row.value_changed.connect(self._on_dpi_changed) + self.dpi_row.set_visible(False) + mode_group.add(self.dpi_row) + + # Show this group if the importer supports both vector and trace, + # or if DPI is relevant (detected later from manifest) + can_trace = ImporterFeature.BITMAP_TRACING in self.features + can_vector = ImporterFeature.DIRECT_VECTOR in self.features + self._mode_group = mode_group + self._mode_visible = can_trace and can_vector + mode_group.set_visible(self._mode_visible) + + # Layers Group (Dynamic) + self.layers_group = Adw.PreferencesGroup(title=_("Layers")) + self.layers_group.set_visible(False) + preferences_page.add(self.layers_group) + self.layers_group.add(self.layer_source_row) + + # Trace Settings Group + self.trace_group = Adw.PreferencesGroup(title=_("Trace Settings")) + preferences_page.add(self.trace_group) + self.trace_group.set_visible(can_trace) + + # Import Whole Image + self.import_whole_image_switch = Adw.SwitchRow( + title=_("Import Whole Image"), + subtitle=_("Import the entire image without tracing"), + active=True, + ) + self.import_whole_image_switch.connect( + "notify::active", self._on_import_whole_image_toggled + ) + self.trace_group.add(self.import_whole_image_switch) + + # Auto Threshold + self.auto_threshold_switch = Adw.SwitchRow( + title=_("Auto Threshold"), + subtitle=_("Automatically determine the trace threshold"), + active=True, + ) + self.auto_threshold_switch.connect( + "notify::active", self._on_auto_threshold_toggled + ) + self.trace_group.add(self.auto_threshold_switch) + + # Manual Threshold Slider + self.threshold_adjustment = Gtk.Adjustment.new( + 0.5, 0.0, 1.0, 0.01, 0.1, 0 + ) + self.threshold_scale = create_slider( + adjustment=self.threshold_adjustment, + digits=2, + on_value_changed=lambda s: self._schedule_preview_update(), + ) + self.threshold_row = Adw.ActionRow( + title=_("Threshold"), + subtitle=_("Trace objects darker than this value"), + ) + self.threshold_row.add_suffix(self.threshold_scale) + self.threshold_row.set_sensitive(False) + self.trace_group.add(self.threshold_row) + + # Invert + self.invert_switch = Adw.SwitchRow( + title=_("Invert"), + subtitle=_("Trace light objects on a dark background"), + ) + self.invert_switch.connect( + "notify::active", self._schedule_preview_update + ) + self.trace_group.add(self.invert_switch) + + # Preview Area + preview_frame = Gtk.Frame( + vexpand=True, + hexpand=True, + margin_top=12, + margin_bottom=12, + margin_start=6, + margin_end=12, + ) + preview_frame.add_css_class("card") + content_box.append(preview_frame) + + self.preview_area = Gtk.DrawingArea( + vexpand=True, + hexpand=True, + css_classes=["view"], + ) + self.preview_area.set_draw_func(self._on_draw_preview) + preview_frame.set_child(self.preview_area) + + # Initial Load & State + self._load_initial_data() + if initial_spec: + self._apply_spec_to_widgets(initial_spec) + else: + self._on_import_mode_toggled(self.use_vectors_switch) + self._on_import_whole_image_toggled( + self.import_whole_image_switch, None + ) + + def _on_import_mode_toggled(self, switch, *args): + is_direct_import = ( + ImporterFeature.DIRECT_VECTOR in self.features + and switch.get_active() + ) + self.trace_group.set_sensitive(not is_direct_import) + self.layers_group.set_sensitive(is_direct_import) + self.warning_banner.set_revealed(False) + self._schedule_preview_update() + + def _on_switch_to_trace_clicked(self, banner): + self.use_vectors_switch.set_active(False) + + def _on_auto_threshold_toggled(self, switch, _pspec): + is_auto = switch.get_active() + self.threshold_row.set_sensitive(not is_auto) + self._schedule_preview_update() + + def _on_import_whole_image_toggled(self, switch, _pspec): + is_whole_image = switch.get_active() + self.auto_threshold_switch.set_sensitive(not is_whole_image) + self.threshold_row.set_sensitive( + not is_whole_image and not self.auto_threshold_switch.get_active() + ) + self.invert_switch.set_sensitive(not is_whole_image) + self._schedule_preview_update() + + def _on_dpi_changed(self, spin_row): + if self._in_update: + return + get_context().config.set_import_dpi(spin_row.get_value()) + self._schedule_preview_update() + + def _load_initial_data(self): + try: + if self.source_asset: + self._file_bytes = self.source_asset.original_data + else: + self._file_bytes = self.file_path.read_bytes() + # Use the new generic scan method + self._manifest = self.editor.file.scan_import_file( + self._file_bytes, self.file_path, self.mime_type + ) + # Log any warnings from the scan + if self._manifest and self._manifest.warnings: + for warning in self._manifest.warnings: + logger.warning( + f"Scan warning for {self.file_path.name}: {warning}" + ) + # Display any errors from the scan + if self._manifest and self._manifest.errors: + error_text = "\n".join(self._manifest.errors) + self.error_banner.set_title(error_text) + self.error_banner.set_revealed(True) + for error in self._manifest.errors: + logger.error( + f"Scan error for {self.file_path.name}: {error}" + ) + self._populate_layers_ui() + # Default to color grouping when the file has distinct colors + # but no useful layer structure (no layers, or a single generic + # layer wrapping everything). Also default to it when the user + # has defined color rules, since those only take effect with a + # color source. + if ( + self._manifest + and self._manifest.color_layers + and self.layer_source_row.get_selected() == 0 + and ( + len(self._manifest.layers) <= 1 + or self._color_rules_exist() + ) + ): + self._in_update = True + try: + self.layer_source_row.set_selected(1) + finally: + self._in_update = False + self._populate_layers_ui() + if self._manifest and self._manifest.is_unitless: + self.dpi_row.set_visible(True) + if not self._mode_visible: + self._mode_visible = True + self._mode_group.set_visible(True) + except (AttributeError, KeyError, TypeError): + logger.exception(f"Failed to read import file {self.file_path}") + self.close() + + def _on_layer_source_changed(self, combo, *args): + self._populate_layers_ui() + self._schedule_preview_update() + + @staticmethod + def _color_rules_exist() -> bool: + """Returns True if the user has defined any color rules.""" + try: + return bool(get_context().color_preset_mgr.all_presets()) + except OSError: + return False + + def _get_layer_source(self) -> LayerSource: + idx = self.layer_source_row.get_selected() + return LayerSource.COLORS if idx == 1 else LayerSource.SVG_LAYERS + + @staticmethod + def _draw_color_swatch( + area: Gtk.DrawingArea, + ctx: cairo.Context, + width: int, + height: int, + rgb: tuple[float, float, float] | None, + ): + if rgb is None: + return + ctx.set_source_rgb(*rgb) + ctx.rectangle(0, 0, width, height) + ctx.fill() + + def _make_color_swatch( + self, rgb: tuple[float, float, float] | None + ) -> Gtk.DrawingArea: + area = Gtk.DrawingArea() + area.set_size_request(24, 24) + area.set_valign(Gtk.Align.CENTER) + area.set_draw_func(self._draw_color_swatch, rgb) + return area + + def _populate_layers_ui(self): + if not self._manifest: + return + + if self._layers_expander is not None: + self.layers_group.remove(self._layers_expander) + self._layers_expander = None + + self._layer_widgets.clear() + if not (self._manifest.layers or self._manifest.color_layers): + self.layers_group.set_visible(False) + return + + self.layers_group.set_visible(True) + # Color layer grouping is an importer capability, not a file type. + show_color_controls = ImporterFeature.COLOR_LAYERS in self.features + self.layer_source_row.set_visible(show_color_controls) + is_color_source = self._get_layer_source() == LayerSource.COLORS + layer_infos = ( + self._manifest.color_layers + if is_color_source + else self._manifest.layers + ) + if not layer_infos: + return + + expander = Adw.ExpanderRow(title=_("Select Layers"), expanded=True) + self.layers_group.add(expander) + self._layers_expander = expander + + for layer_info in layer_infos: + row = Adw.ActionRow(title=layer_info.name) + + count = layer_info.feature_count + is_empty = count is not None and count == 0 + + # Configure row subtitle based on content + if is_empty: + row.set_subtitle(_("Layer is empty")) + row.set_sensitive(False) + elif count is not None: + row.set_subtitle(_("Layer with {n} vectors").format(n=count)) + + if is_color_source: + row.add_prefix(self._make_color_swatch(layer_info.color)) + + switch = Gtk.Switch( + active=not is_empty, + valign=Gtk.Align.CENTER, + ) + switch.set_sensitive(not is_empty) + switch.connect("notify::active", self._schedule_preview_update) + + row.add_suffix(switch) + row.set_activatable_widget(switch) + expander.add_row(row) + + self._layer_widgets[switch] = layer_info.id + + expander.add_row(self.layer_import_mode_row) + + def _get_active_layer_ids(self) -> list[str] | None: + if not self._layer_widgets: + return None + return [ + lid for w, lid in self._layer_widgets.items() if w.get_active() + ] + + def _get_layer_import_mode(self) -> LayerImportMode: + idx = self.layer_import_mode_row.get_selected() + modes = [ + LayerImportMode.MAP_TO_EXISTING, + LayerImportMode.NEW_LAYERS, + LayerImportMode.FLATTEN, + ] + return ( + modes[idx] if idx < len(modes) else LayerImportMode.MAP_TO_EXISTING + ) + + def _get_current_spec(self) -> VectorizationSpec: + """ + Constructs a VectorizationSpec from the current UI control values. + """ + ppi = self.dpi_row.get_value() + if ( + ImporterFeature.DIRECT_VECTOR in self.features + and self.use_vectors_switch.get_active() + ): + return PassthroughSpec( + active_layer_ids=self._get_active_layer_ids(), + layer_import_mode=self._get_layer_import_mode(), + layer_source=self._get_layer_source(), + ppi=ppi, + ) + else: + if self.import_whole_image_switch.get_active(): + return TraceSpec( + threshold=1.0, + auto_threshold=False, + invert=False, + ppi=ppi, + ) + return TraceSpec( + threshold=self.threshold_adjustment.get_value(), + auto_threshold=self.auto_threshold_switch.get_active(), + invert=self.invert_switch.get_active(), + ppi=ppi, + ) + + def _apply_spec_to_widgets(self, spec: VectorizationSpec): + self._in_update = True + try: + can_vector = ImporterFeature.DIRECT_VECTOR in self.features + + if isinstance(spec, PassthroughSpec): + if can_vector: + self.use_vectors_switch.set_active(True) + if spec.layer_source == LayerSource.COLORS: + self.layer_source_row.set_selected(1) + else: + self.layer_source_row.set_selected(0) + if spec.active_layer_ids: + for w, lid in self._layer_widgets.items(): + w.set_active(lid in spec.active_layer_ids) + mode_map = { + LayerImportMode.MAP_TO_EXISTING: 0, + LayerImportMode.NEW_LAYERS: 1, + LayerImportMode.FLATTEN: 2, + } + idx = mode_map.get(spec.layer_import_mode, 0) + self.layer_import_mode_row.set_selected(idx) + elif isinstance(spec, TraceSpec): + if can_vector: + self.use_vectors_switch.set_active(False) + if spec.threshold >= 1.0 and not spec.auto_threshold: + self.import_whole_image_switch.set_active(True) + else: + self.import_whole_image_switch.set_active(False) + self.auto_threshold_switch.set_active(spec.auto_threshold) + self.threshold_adjustment.set_value(spec.threshold) + self.invert_switch.set_active(spec.invert) + + self.dpi_row.set_value(spec.ppi) + finally: + self._in_update = False + + self._on_import_mode_toggled(self.use_vectors_switch) + self._on_import_whole_image_toggled( + self.import_whole_image_switch, None + ) + self._schedule_preview_update() + + def _schedule_preview_update(self, *args): + if self._in_update: + return + logger.debug("Scheduling preview update") + self.status_spinner.start() + self.import_button.set_sensitive(False) + + # Dispatch task to TaskManager using FileCmd + self.editor.task_manager.add_coroutine( + self._update_preview_task, key="import-preview" + ) + + async def _update_preview_task(self, ctx): + """ + Async task that calls the backend to generate the preview. + """ + if not self._file_bytes: + return + + spec = self._get_current_spec() + ctx.set_message(_("Generating preview...")) + + result = await self.editor.file.generate_preview( + self._file_bytes, + self.file_path.name, + self.mime_type, + spec, + PREVIEW_RENDER_SIZE_PX, + ) + + self.editor.task_manager.schedule_on_main_thread( + self._update_ui_with_preview, result + ) + + def _update_ui_with_preview(self, result: PreviewResult | None): + """Updates the UI with the result of the preview task.""" + self._preview_result = result + self._background_pixbuf = None + + if result and result.image_bytes: + try: + loader = GdkPixbuf.PixbufLoader.new() + loader.write(result.image_bytes) + loader.close() + self._background_pixbuf = loader.get_pixbuf() + except GLib.Error: + logger.error("Failed to create pixbuf from preview bytes.") + + self.preview_area.queue_draw() + self.status_spinner.stop() + self.import_button.set_sensitive(self._preview_result is not None) + + # Handle warnings/errors + is_direct_vector = ( + ImporterFeature.DIRECT_VECTOR in self.features + and self.use_vectors_switch.get_active() + ) + failed_generation = ( + self._preview_result is None + or self._preview_result.payload is None + or not self._preview_result.payload.items + ) + can_trace = ImporterFeature.BITMAP_TRACING in self.features + # Only show warning if switching to trace mode is possible + self.warning_banner.set_revealed( + is_direct_vector and failed_generation and can_trace + ) + + def _draw_checkerboard_background( + self, ctx: cairo.Context, width: int, height: int + ): + """Fills the given context with a light gray checkerboard pattern.""" + CHECKER_SIZE = 10 + # Create a small surface to hold one tile of the pattern (2x2 checkers) + tile_surface = cairo.ImageSurface( + cairo.FORMAT_RGB24, CHECKER_SIZE * 2, CHECKER_SIZE * 2 + ) + tile_ctx = cairo.Context(tile_surface) + + # Color 1 (e.g., light gray) + tile_ctx.set_source_rgb(0.85, 0.85, 0.85) + tile_ctx.rectangle(0, 0, CHECKER_SIZE, CHECKER_SIZE) + tile_ctx.fill() + tile_ctx.rectangle( + CHECKER_SIZE, CHECKER_SIZE, CHECKER_SIZE, CHECKER_SIZE + ) + tile_ctx.fill() + + # Color 2 (e.g., slightly darker gray) + tile_ctx.set_source_rgb(0.78, 0.78, 0.78) + tile_ctx.rectangle(CHECKER_SIZE, 0, CHECKER_SIZE, CHECKER_SIZE) + tile_ctx.fill() + tile_ctx.rectangle(0, CHECKER_SIZE, CHECKER_SIZE, CHECKER_SIZE) + tile_ctx.fill() + + pattern = cairo.SurfacePattern(tile_surface) + pattern.set_extend(cairo.EXTEND_REPEAT) + ctx.set_source(pattern) + ctx.paint() + + def _on_draw_preview( + self, area: Gtk.DrawingArea, ctx: cairo.Context, w: int, h: int + ): + """ + Draws the preview using the authoritative frame of reference and + pre-calculated transforms provided by the backend. This method + contains no format-specific logic. + """ + self._draw_checkerboard_background(ctx, w, h) + + if ( + not self._preview_result + or not self._background_pixbuf + or not self._preview_result.parse_result + or not self._preview_result.payload + ): + return + + parse_result = self._preview_result.parse_result + payload = self._preview_result.payload + + # The backend MUST provide the frame and background transform. + assert parse_result.world_frame_of_reference is not None + assert parse_result.background_world_transform is not None + + # --- 1. Establish the World-to-Canvas Transform --- + frame_x, frame_y, frame_w, frame_h = ( + parse_result.world_frame_of_reference + ) + if frame_w <= 1e-9 or frame_h <= 1e-9: + return + + margin = 20 + view_w, view_h = w - 2 * margin, h - 2 * margin + if view_w <= 0 or view_h <= 0: + return + + scale = min(view_w / frame_w, view_h / frame_h) + + # This matrix maps the Y-Up world space to the Y-Down canvas space, + # centering the world frame in the drawing area. + world_to_canvas = ( + Matrix.translation(w / 2, h / 2) + @ Matrix.scale(scale, -scale) + @ Matrix.translation( + -(frame_x + frame_w / 2), -(frame_y + frame_h / 2) + ) + ) + + # --- 2. Draw the Background Image --- + ctx.save() + + # The background's transform maps a 1x1 unit square to its place + # in the Y-Up world. We compose it with the master transform to get + # its final position on the Y-Down canvas. + final_bg_transform = ( + world_to_canvas @ parse_result.background_world_transform + ) + + # The transform positions a 1x1 unit square. We need to find the + # top-left corner and the size on the canvas. + top_left = final_bg_transform.transform_point((0, 1)) + top_right = final_bg_transform.transform_point((1, 1)) + bottom_left = final_bg_transform.transform_point((0, 0)) + + canvas_w = top_right[0] - top_left[0] + canvas_h = bottom_left[1] - top_left[1] + + # Draw the pixbuf directly into its calculated canvas rectangle. + ctx.translate(top_left[0], top_left[1]) + ctx.scale( + canvas_w / self._background_pixbuf.get_width(), + canvas_h / self._background_pixbuf.get_height(), + ) + Gdk.cairo_set_source_pixbuf(ctx, self._background_pixbuf, 0, 0) + ctx.paint() + ctx.restore() + + # --- 3. Draw Vector Overlays --- + ctx.save() + # Set the master transform for all vector drawing. + ctx.transform(cairo.Matrix(*world_to_canvas.for_cairo())) + + # Compute constant pixel line width using the uniform + # world_to_canvas transform only, before any per-item + # transforms that may be non-uniform. + px, _ = ctx.device_to_user_distance(1.5, 0) + constant_line_width = abs(px) + + def draw_item( + item: DocItem, color: tuple[float, float, float] | None = None + ): + if isinstance(item, WorkPiece) and item.boundaries: + ctx.save() + # Transform boundaries into world space manually so + # we avoid putting the item's potentially non-uniform + # transform on the context, which would distort the + # stroke width. + world_geo = item.boundaries.copy() + world_geo.transform(item.get_world_transform()) + ctx.set_line_width(constant_line_width) + if color is not None: + ctx.set_source_rgb(*color) + else: + # Blue for layers without a color (or merged items) + ctx.set_source_rgb(0.1, 0.5, 1.0) + ctx.new_path() + geometry_to_cairo(world_geo, ctx) + ctx.stroke() + ctx.restore() + elif isinstance(item, Layer): + layer_color = self._hex_to_rgb(item.color) + for child in item.children: + draw_item(child, layer_color or color) + + for item in payload.items: + draw_item(item) + ctx.restore() + + @staticmethod + def _hex_to_rgb( + color: str | None, + ) -> tuple[float, float, float] | None: + """Converts a '#rrggbb' hex string to an RGB 0-1 tuple.""" + if not color: + return None + try: + value = color.lstrip("#") + r = int(value[0:2], 16) / 255.0 + g = int(value[2:4], 16) / 255.0 + b = int(value[4:6], 16) / 255.0 + return r, g, b + except (ValueError, IndexError): + return None + + def _on_import_clicked(self, button): + final_spec = self._get_current_spec() + logger.debug(f"_on_import_clicked: {final_spec}") + self.response.send(self, response_id="import", spec=final_spec) + self.close() diff --git a/rayforge/ui_gtk/doceditor/import_handler.py b/rayforge/ui_gtk/doceditor/import_handler.py new file mode 100644 index 000000000..ee7309893 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/import_handler.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gio, GLib + +from ...core.layer import Layer +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import TraceSpec, VectorizationSpec +from ...doceditor.file_cmd import ImportAction +from ...image.registry import importer_registry +from . import file_dialogs +from .import_dialog import ImportDialog + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + from ..mainwindow import MainWindow + +logger = logging.getLogger(__name__) + + +def _start_interactive_import( + win: MainWindow, + editor: DocEditor, + file_path: Path, + mime_type: str, + position_mm: tuple[float, float] | None = None, +): + """Creates and presents the main interactive import dialog.""" + logger.info("Starting interactive import...") + + _, features = editor.file.get_importer_info(file_path, mime_type) + import_dialog = ImportDialog( + parent=win, + editor=editor, + file_path=file_path, + mime_type=mime_type, + features=features, + ) + + # Define the handler locally to capture context from its closure. + def on_dialog_response( + sender, *, response_id: str, spec: VectorizationSpec + ): + _on_import_dialog_response( + sender, + response_id, + spec, + win, + editor, + file_path, + mime_type, + position_mm, + ) + + # Use weak=False to prevent the handler from being garbage collected. + import_dialog.response.connect(on_dialog_response, weak=False) + import_dialog.present() + + +def _on_import_dialog_response( + dialog, + response_id: str, + spec: VectorizationSpec, + win: MainWindow, + editor: DocEditor, + file_path: Path, + mime_type: str, + position_mm: tuple[float, float] | None = None, +): + """Callback for when the interactive import dialog is closed.""" + logger.info(f"Received response '{response_id}' from ImportDialog.") + if response_id == "import": + logger.info( + f"Executing final import for {file_path} with spec: {spec}" + ) + editor.file.load_file_from_path( + file_path, mime_type, spec, position_mm + ) + win.item_revealer.set_reveal_child(False) + + +def _on_file_selected(dialog, result, user_data): + """Callback for when the user selects a file from the dialog.""" + win, editor = user_data + try: + file = dialog.open_finish(result) + if not file: + return + except GLib.Error: + return + + try: + file_path = Path(file.get_path()) + + # Get MIME type from Gio for accuracy + file_info = file.query_info( + Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, + Gio.FileQueryInfoFlags.NONE, + None, + ) + mime_type = ( + Gio.content_type_get_mime_type(file_info.get_content_type()) + or file_info.get_content_type() + ) + + # Ask the backend what to do with this file + action = editor.file.analyze_import_target(file_path, mime_type) + + if action == ImportAction.INTERACTIVE_CONFIG: + _start_interactive_import(win, editor, file_path, mime_type) + elif action == ImportAction.DIRECT_LOAD: + editor.file.load_file_from_path(file_path, mime_type, None) + win.item_revealer.set_reveal_child(False) + else: # UNSUPPORTED + logger.warning( + f"Unsupported file type: {mime_type} for {file_path}" + ) + + except (OSError, ValueError, KeyError): + logger.exception("Error opening file") + + +def start_interactive_import(win: MainWindow, editor: DocEditor): + """ + Initiates the full interactive file import process, starting with a + file chooser dialog. + """ + # Now passing editor to get supported file types + file_dialogs.show_import_dialog( + win, editor, _on_file_selected, (win, editor) + ) + + +def import_file_at_position( + win: MainWindow, + editor: DocEditor, + file_path: Path, + mime_type: str, + position_mm: tuple[float, float] | None = None, +): + """ + Import a file and optionally position it at specified coordinates. + + Args: + win: MainWindow instance + editor: DocEditor instance + file_path: Path to file to import + mime_type: MIME type of the file + position_mm: Optional (x, y) tuple in world coordinates (mm) + to center the imported item + """ + # Ask backend for routing decision + action = editor.file.analyze_import_target(file_path, mime_type) + + if action == ImportAction.INTERACTIVE_CONFIG: + _start_interactive_import( + win, editor, file_path, mime_type, position_mm + ) + elif action == ImportAction.DIRECT_LOAD: + editor.file.load_file_from_path( + file_path, mime_type, None, position_mm + ) + win.item_revealer.set_reveal_child(False) + else: + logger.warning(f"Unsupported file type: {mime_type} for {file_path}") + + +def _on_batch_trace_response( + dialog, + response_id: str, + editor: DocEditor, + file_list: list[tuple[Path, str]], + position_mm: tuple[float, float], + win: MainWindow, +): + """ + Handles the user's choice from the batch tracing configuration dialog. + """ + if response_id == "import": + # User confirmed - execute batch import via backend + # We extract just the paths for the backend method + paths = [f[0] for f in file_list] + vectorization_spec = TraceSpec() + + editor.file.execute_batch_import( + paths, vectorization_spec, position_mm + ) + logger.info(f"Batch import started for {len(file_list)} files") + # else: user cancelled, do nothing + + +def import_multiple_files_at_position( + win: MainWindow, + editor: DocEditor, + file_list: list[tuple[Path, str]], + position_mm: tuple[float, float], +): + """ + Import multiple files with a single batch configuration dialog. + + Args: + win: MainWindow instance + editor: DocEditor instance + file_list: List of (file_path, mime_type) tuples + position_mm: (x, y) tuple in world coordinates (mm) + to center the imported items + """ + if not file_list: + return + + # Check if any file in the list actually requires interactive config + needs_config = False + for path, mime in file_list: + if ( + editor.file.analyze_import_target(path, mime) + == ImportAction.INTERACTIVE_CONFIG + ): + needs_config = True + break + + if not needs_config: + # If no files need config, just load them all directly + paths = [f[0] for f in file_list] + vectorization_spec = TraceSpec() + editor.file.execute_batch_import( + paths, vectorization_spec, position_mm + ) + return + + # If configuration is needed, show the batch dialog + file_count = len(file_list) + file_names = ", ".join(f.name for f, _ in file_list[:3]) + if file_count > 3: + file_names += f" and {file_count - 3} more" + + # Show batch tracing configuration dialog + dialog = Adw.MessageDialog( + transient_for=win, + modal=True, + heading=_("Batch Import {file_count} Images").format( + file_count=file_count + ), + body=_( + "Import {file_count} images:\n{file_names}\n\n" + "All images will be traced using the default tracing settings " + "and positioned at the drop location." + ).format(file_count=file_count, file_names=file_names), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("import", _("Import All")) + dialog.set_default_response("import") + dialog.set_close_response("cancel") + + dialog.connect( + "response", + _on_batch_trace_response, + editor, + file_list, + position_mm, + win, + ) + dialog.present() + + # Hide properties widget + win.item_revealer.set_reveal_child(False) + + +def start_reimport( + win: MainWindow, + editor: DocEditor, + source_asset: SourceAsset, + position_mm: tuple[float, float] | None = None, + target_layer: Layer | None = None, +): + """ + Re-open the import dialog for an existing SourceAsset so the user + can edit settings and produce a fresh set of workpieces. + """ + meta = source_asset.metadata + importer_cls_name = meta.get("_importer_class") + if not importer_cls_name: + logger.warning("Cannot reimport: missing _importer_class metadata") + return + importer_cls = importer_registry.get_by_name(importer_cls_name) + if not importer_cls: + logger.warning( + f"Cannot reimport: importer '{importer_cls_name}' not registered" + ) + return + + mime_type = meta.get("_importer_mime", "") + file_path = source_asset.source_file or Path( + source_asset.name or "Untitled" + ) + features = importer_cls.features + + initial_spec = None + for wp in editor.doc.all_workpieces: + if ( + wp.source_segment + and wp.source_segment.source_asset_uid == source_asset.uid + ): + initial_spec = wp.source_segment.vectorization_spec + break + + dialog = ImportDialog( + parent=win, + editor=editor, + file_path=file_path, + mime_type=mime_type, + features=features, + source_asset=source_asset, + initial_spec=initial_spec, + ) + + def on_response(sender, *, response_id: str, spec: VectorizationSpec): + if response_id == "import": + editor.file.reimport_from_source_asset( + source_asset, spec, position_mm, target_layer + ) + + dialog.response.connect(on_response, weak=False) + dialog.present() diff --git a/rayforge/ui_gtk/doceditor/item_properties.py b/rayforge/ui_gtk/doceditor/item_properties.py new file mode 100644 index 000000000..0fb661548 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/item_properties.py @@ -0,0 +1,215 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Optional + +from gi.repository import Gtk +from raygeo.geo import Matrix + +from ...context import get_context +from ...core.group import Group +from ...core.item import DocItem +from ...core.stock import StockItem +from ...core.workpiece import WorkPiece +from ..shared.expander import Expander +from .property_providers import ( + PropertyProvider, + property_provider_registry, +) + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + + +logger = logging.getLogger(__name__) + + +class DocItemPropertiesWidget(Gtk.Box): + """ + An orchestrator widget that displays properties for selected document + items. + + It composes its UI from a set of registered "Property Provider" components, + each responsible for a specific aspect of an item (e.g., transformation, + source file, tabs). It manages persistent widgets to avoid interrupting + user edits. + + Providers with ``separate_group = True`` each get their own Expander + card, visually separated from the main item properties card. + """ + + def __init__( + self, + editor: "DocEditor", + items: list[DocItem] | None = None, + *args, + **kwargs, + ): + super().__init__(*args, orientation=Gtk.Orientation.VERTICAL, **kwargs) + + self.editor = editor + self.items: list[DocItem] = [] + + self._main_expander = Expander() + self._main_expander.set_expanded(True) + self._main_expander.set_title(_("Item Properties")) + self.append(self._main_expander) + + self._rows_container = Gtk.ListBox() + self._rows_container.set_selection_mode(Gtk.SelectionMode.NONE) + self._main_expander.set_child(self._rows_container) + + self.providers: list[PropertyProvider] = ( + property_provider_registry.create_instances() + ) + self._provider_widget_map: list[ + tuple[PropertyProvider, list[Gtk.Widget]] + ] = [] + self._separate_groups: list[ + tuple[PropertyProvider, list[Gtk.Widget], Expander] + ] = [] + self._initialize_providers_ui() + + self._machine = None + self._connect_signals() + + self.set_items(items) + + def _connect_signals(self): + """Connect to config and machine signals.""" + config = get_context().config + config.changed.connect(self._on_config_changed) + self._connect_machine_signals() + + def _connect_machine_signals(self): + """Connect to machine signals to update UI when WCS changes.""" + machine = get_context().machine + if machine and machine != self._machine: + if self._machine: + self._machine.changed.disconnect(self._on_machine_changed) + self._machine = machine + machine.changed.connect(self._on_machine_changed) + + def _on_config_changed(self, config): + """Handle config changes (including machine switching).""" + self._connect_machine_signals() + if self.items: + self._update_ui() + + def _on_machine_changed(self, machine): + """Handle machine changes (including WCS selection/offset changes).""" + if self.items: + self._update_ui() + + def _initialize_providers_ui(self): + """ + Creates all widgets for all providers one time and adds them to the + appropriate container in a hidden state. + """ + for provider in self.providers: + widgets = provider.create_widgets() + if provider.separate_group: + expander = Expander() + expander.set_expanded(True) + if provider.group_title: + expander.set_title(provider.group_title) + container = Gtk.ListBox() + container.set_selection_mode(Gtk.SelectionMode.NONE) + expander.set_child(container) + for widget in widgets: + widget.set_visible(False) + container.append(widget) + self._separate_groups.append((provider, widgets, expander)) + expander.set_margin_top(6) + self.append(expander) + else: + self._provider_widget_map.append((provider, widgets)) + for widget in widgets: + widget.set_visible(False) + self._rows_container.append(widget) + + def set_items(self, items: list[DocItem] | None): + """Sets the currently selected items and updates the UI.""" + for item in self.items: + item.updated.disconnect(self._on_item_data_changed) + item.transform_changed.disconnect(self._on_item_data_changed) + + self.items = items or [] + + count = len(self.items) + if count == 1: + self._main_expander.set_subtitle(_("1 item selected")) + elif count > 1: + self._main_expander.set_subtitle( + _("{count} items selected").format(count=count) + ) + else: + self._main_expander.set_subtitle("") + + for item in self.items: + item.updated.connect(self._on_item_data_changed) + item.transform_changed.connect(self._on_item_data_changed) + + self._update_ui() + + def _on_item_data_changed( + self, item, *, old_matrix: Optional["Matrix"] = None + ): + """ + Handles data changes from the DocItem model by updating the UI to + reflect the new state. + """ + logger.debug( + f"Item data changed for {item.name}, updating properties UI." + ) + self._update_ui() + + def _update_ui(self): + """ + Updates the UI by querying all registered property providers and + managing the visibility and content of their persistent widgets. + """ + if not self.items: + self._main_expander.set_sensitive(False) + self._main_expander.set_title(_("Item Properties")) + for provider, widgets in self._provider_widget_map: + for widget in widgets: + widget.set_visible(False) + for provider, widgets, expander in self._separate_groups: + for widget in widgets: + widget.set_visible(False) + expander.set_visible(False) + return + + self._main_expander.set_sensitive(True) + self._update_title(self.items[0]) + + for provider, widgets in self._provider_widget_map: + can_handle = provider.can_handle(self.items) + for widget in widgets: + widget.set_visible(can_handle) + + if can_handle: + provider.update_widgets(self.editor, self.items) + + for provider, widgets, expander in self._separate_groups: + can_handle = provider.can_handle(self.items) + for widget in widgets: + widget.set_visible(can_handle) + expander.set_visible(can_handle) + + if can_handle: + provider.update_widgets(self.editor, self.items) + expander.set_subtitle(provider.group_subtitle) + + def _update_title(self, item: DocItem): + """Sets the main title of the expander based on selection.""" + if len(self.items) > 1: + self._main_expander.set_title(_("Multiple Items")) + elif isinstance(item, StockItem): + self._main_expander.set_title(_("Stock Properties")) + elif isinstance(item, WorkPiece): + self._main_expander.set_title(_("Workpiece Properties")) + elif isinstance(item, Group): + self._main_expander.set_title(_("Group Properties")) + else: + self._main_expander.set_title(_("Item Properties")) diff --git a/rayforge/ui_gtk/doceditor/layer_column.py b/rayforge/ui_gtk/doceditor/layer_column.py new file mode 100644 index 000000000..5713551fc --- /dev/null +++ b/rayforge/ui_gtk/doceditor/layer_column.py @@ -0,0 +1,836 @@ +import json +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from blinker import Signal +from gi.repository import Adw, Gdk, Gio, Gtk, Pango + +from ...context import get_context +from ...core.doc import Doc +from ...core.group import Group +from ...core.item import DocItem +from ...core.layer import Layer +from ...core.source_asset import SourceAsset +from ...core.stock_asset import StockAsset +from ...core.workpiece import WorkPiece +from ..icons import get_icon +from ..shared.gtk import apply_css +from . import import_handler +from .group_row import GroupRow +from .layer_settings_dialog import LayerSettingsDialog +from .workflow_row import WorkflowRow +from .workpiece_row import WorkpieceRow + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + from ...ui_gtk.mainwindow import MainWindow + +logger = logging.getLogger(__name__) + +css = """ +.layer-column { + background-color: alpha(@theme_fg_color, 0.03); + border-radius: 8px; + border: 1px solid @borders; + min-width: 160px; +} +.layer-column.active-layer-column { + border-color: @accent_bg_color; + background-color: alpha(@accent_bg_color, 0.05); +} +.layer-column-header { + padding: 6px 8px; + border-bottom: 1px solid @borders; + border-radius: 8px 8px 0 0; + background-color: alpha(@theme_fg_color, 0.05); +} +.layer-column.active-layer-column .layer-column-header { + background-color: alpha(@accent_bg_color, 0.1); +} +.layer-column-header button.flat { + min-width: 28px; + min-height: 28px; + padding: 2px; +} +.layer-column-header .dim-label { + font-size: smaller; +} +.layer-workpiece-list { + background-color: transparent; + padding: 0; +} +.layer-workpiece-list > row { + background-color: transparent; + border-radius: 4px; + padding: 1px 4px; + margin: 0; + border: none; +} +.layer-workpiece-list > row > * { + margin: -1px -4px; + padding: 1px 4px; +} +.layer-workpiece-list > row:drop(active) { + background-color: transparent; + outline: none; +} +.layer-workpiece-list > row.drop-above { + box-shadow: inset 0 2px 0 0 @accent_bg_color; +} +.layer-workpiece-list > row.drop-below { + box-shadow: inset 0 -2px 0 0 @accent_bg_color; +} +.layer-workpiece-list > row.selected-row { + background-color: alpha(@accent_bg_color, 0.2); +} +.layer-column.drop-left { + box-shadow: inset 3px 0 0 0 @accent_bg_color; +} +.layer-column.drop-right { + box-shadow: inset -3px 0 0 0 @accent_bg_color; +} +""" + +_LAYER_UID_PREFIX = "layer:" + + +class LayerColumn(Gtk.Box): + dragging = False + + def __init__( + self, + doc: Doc, + layer: Layer, + editor: "DocEditor", + can_delete: bool = True, + ): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + apply_css(css) + self.add_css_class("layer-column") + self.set_margin_end(6) + self.set_hexpand(False) + + self.doc = doc + self.layer = layer + self.editor = editor + self._row_items = {} + self._ordered_items: list = [] + self._selected_uids: set = set() + self._selection_anchor = None + self._potential_drop_index = -1 + self._drop_shift_held = False + + self.edit_item_requested = Signal() + self.select_items_requested = Signal() + self.move_to_layer_requested = Signal() + + self._build_header(can_delete) + self._build_workflow_row() + self._build_workpiece_list() + self._setup_layer_drag_source() + + self._click_gesture = Gtk.GestureClick() + self._click_gesture.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + self._click_gesture.connect("pressed", self._on_column_clicked) + self._click_gesture.connect("released", self._on_column_released) + self.add_controller(self._click_gesture) + + self._context_popover: Gtk.PopoverMenu | None = None + self._row_drag_happened: bool = False + + right_click = Gtk.GestureClick() + right_click.set_button(Gdk.BUTTON_SECONDARY) + right_click.connect("pressed", self._on_right_click_pressed) + self.add_controller(right_click) + + self._connect_signals() + self._update_style() + self._update_subtitle() + + def do_measure(self, orientation, for_size): + min_, nat, min_bl, nat_bl = super().do_measure(orientation, for_size) + if orientation == Gtk.Orientation.HORIZONTAL: + nat = min(nat, 400) + return min_, nat, min_bl, nat_bl + + def _build_header(self, can_delete: bool): + self.header = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=4 + ) + self.header.add_css_class("layer-column-header") + self.header.set_hexpand(True) + + self.drag_label = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=4 + ) + + self.icon_container = Gtk.Box() + self.icon_container.set_valign(Gtk.Align.CENTER) + self.icon_container.set_margin_start(3) + self.icon_container.set_margin_end(3) + self.drag_label.append(self.icon_container) + + self.name_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.name_box.set_hexpand(True) + self.name_box.set_halign(Gtk.Align.START) + self.name_box.set_valign(Gtk.Align.CENTER) + + name_label = Gtk.Label() + name_label.set_text(self.layer.name) + name_label.set_halign(Gtk.Align.START) + name_label.set_ellipsize(Pango.EllipsizeMode.END) + self.name_label = name_label + self.name_box.append(name_label) + + subtitle_label = Gtk.Label() + subtitle_label.set_halign(Gtk.Align.START) + subtitle_label.set_ellipsize(Pango.EllipsizeMode.END) + subtitle_label.add_css_class("dim-label") + self.subtitle_label = subtitle_label + self.name_box.append(subtitle_label) + + self.drag_label.append(self.name_box) + + self.header.append(self.drag_label) + + self.settings_button = Gtk.Button(child=get_icon("settings-symbolic")) + self.settings_button.add_css_class("flat") + self.settings_button.set_tooltip_text(_("Layer Settings")) + self.settings_button.connect("clicked", self._on_settings_clicked) + self.header.append(self.settings_button) + + self.delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + self.delete_button.add_css_class("flat") + self.delete_button.set_tooltip_text(_("Delete this layer")) + self.delete_button.set_visible(can_delete) + self.delete_button.connect("clicked", self._on_delete_clicked) + self.header.append(self.delete_button) + + self.visibility_on_icon = get_icon("visibility-on-symbolic") + self.visibility_off_icon = get_icon("visibility-off-symbolic") + + self.visibility_button = Gtk.ToggleButton() + self.visibility_button.set_active(self.layer.visible) + self.visibility_button.set_child( + self.visibility_on_icon + if self.layer.visible + else self.visibility_off_icon + ) + self.visibility_button.add_css_class("flat") + self.visibility_button.set_tooltip_text(_("Toggle layer visibility")) + self.visibility_button.connect("clicked", self._on_visibility_clicked) + self.header.append(self.visibility_button) + + self.append(self.header) + self._update_icon() + + def _build_workflow_row(self): + self.workflow_row = WorkflowRow(self.editor, self.layer) + self.append(self.workflow_row) + + def _build_workpiece_list(self): + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_vexpand(True) + scrolled.set_hexpand(True) + + self.listbox = Gtk.ListBox() + self.listbox.add_css_class("layer-workpiece-list") + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + + drop_target = Gtk.DropTarget.new( + str, + Gdk.DragAction.MOVE | Gdk.DragAction.COPY, + ) + drop_target.connect("accept", self._on_drop_accept) + drop_target.connect("enter", self._on_drop_enter) + drop_target.connect("motion", self._on_drop_motion) + drop_target.connect("drop", self._on_drop) + drop_target.connect("leave", self._on_drop_leave) + self.add_controller(drop_target) + + scrolled.set_child(self.listbox) + self.append(scrolled) + + self._rebuild_workpiece_list() + + def _setup_layer_drag_source(self): + drag_source = Gtk.DragSource() + drag_source.set_actions(Gdk.DragAction.MOVE) + drag_source.connect("prepare", self._on_layer_drag_prepare) + drag_source.connect("drag-begin", self._on_layer_drag_begin) + drag_source.connect("drag-end", self._on_layer_drag_end) + drag_source.connect("drag-cancel", self._on_layer_drag_end) + self.header.add_controller(drag_source) + + @staticmethod + def _setup_row_drag_source(row, item_row): + drag_source = Gtk.DragSource() + drag_source.set_actions(Gdk.DragAction.MOVE) + drag_source.connect("prepare", item_row._on_drag_prepare) + row.add_controller(drag_source) + + @staticmethod + def _on_layer_drag_begin(drag_source, drag): + LayerColumn.dragging = True + + @staticmethod + def _on_layer_drag_end(drag_source, drag, delete): + LayerColumn.dragging = False + + @staticmethod + def _on_layer_drag_cancel(drag_source, drag, reason): + LayerColumn.dragging = False + + def _on_layer_drag_prepare(self, drag_source, x, y): + snapshot = Gtk.Snapshot() + Gtk.Widget.do_snapshot(self.drag_label, snapshot) + paintable = snapshot.to_paintable() + if paintable: + drag_source.set_icon(paintable, x, y) + return Gdk.ContentProvider.new_for_value( + _LAYER_UID_PREFIX + self.layer.uid + ) + + @staticmethod + def _parse_layer_uid(value): + if value and value.startswith(_LAYER_UID_PREFIX): + return value[len(_LAYER_UID_PREFIX) :] + return None + + @staticmethod + def _remove_layer_drop_markers_from(widget): + child = widget.get_first_child() + while child: + child.remove_css_class("drop-left") + child.remove_css_class("drop-right") + child = child.get_next_sibling() + + def _rebuild_workpiece_list(self): + child = self.listbox.get_first_child() + while child: + next_child = child.get_next_sibling() + self.listbox.remove(child) + child = next_child + self._row_items.clear() + self._ordered_items.clear() + + for item in self.layer.get_content_items(): + row = Gtk.ListBoxRow() + if isinstance(item, Group): + item_row = GroupRow(item, on_rename=self._on_rename_item) + elif isinstance(item, WorkPiece): + item_row = WorkpieceRow(item, on_rename=self._on_rename_item) + else: + continue + row.set_child(item_row) + self._row_items[row] = item + self._ordered_items.append(item) + self._setup_row_drag_source(row, item_row) + self.listbox.append(row) + + def _on_rename_item(self, item: DocItem, new_name: str): + self.editor.edit.rename_item(item, new_name) + + def start_item_rename(self, item: DocItem) -> bool: + """Starts in-place renaming of the given item, if present in this + column.""" + for row, row_item in self._row_items.items(): + if row_item is not item: + continue + row.get_child().start_rename() + return True + return False + + def update_row_selection(self, selected_uids: set): + self._selected_uids = { + uid + for uid in selected_uids + if uid in {i.uid for i in self._ordered_items} + } + if self._selected_uids: + for item in reversed(self._ordered_items): + if item.uid in self._selected_uids: + self._selection_anchor = item + break + child = self.listbox.get_first_child() + while child: + if isinstance(child, Gtk.ListBoxRow): + item = self._row_items.get(child) + if item and item.uid in selected_uids: + child.add_css_class("selected-row") + else: + child.remove_css_class("selected-row") + child = child.get_next_sibling() + + def _update_icon(self): + if old := self.icon_container.get_first_child(): + self.icon_container.remove(old) + icon_name = ( + "rotary-symbolic" + if self.layer.rotary_enabled + else "layer-symbolic" + ) + icon = get_icon(icon_name) + rgba = Gdk.RGBA() + rgba.parse(self.layer.color) + dark = Adw.StyleManager.get_default().get_dark() + alpha = 0.35 if dark else 0.9 + bg = ( + f"rgba({int(rgba.red * 255)},{int(rgba.green * 255)}," + f"{int(rgba.blue * 255)},{alpha})" + ) + css_class = f"layer-icon-{self.layer.uid[:8]}" + icon.set_css_classes([css_class]) + apply_css( + f".{css_class} " + f"{{ background: {bg}; border-radius: 4px; " + f"padding: 4px; }}" + ) + self.icon_container.append(icon) + + def _update_style(self): + if self.layer.active: + self.add_css_class("active-layer-column") + else: + self.remove_css_class("active-layer-column") + + def _update_ui(self): + self.name_label.set_text(self.layer.name) + self._update_icon() + self._update_subtitle() + self.visibility_button.set_active(self.layer.visible) + if self.layer.visible: + self.visibility_button.set_child(self.visibility_on_icon) + else: + self.visibility_button.set_child(self.visibility_off_icon) + + def _update_subtitle(self): + machine = get_context().machine + module_name = None + if machine and self.layer.rotary_module_uid: + rm = machine.get_rotary_module_by_uid(self.layer.rotary_module_uid) + if rm: + module_name = rm.name + self.subtitle_label.set_text(self.layer.get_subtitle(module_name)) + + def _connect_signals(self): + self.layer.updated.connect(self._on_layer_updated) + 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.descendant_added.disconnect( + self._on_layer_structure_changed + ) + self.layer.descendant_removed.disconnect( + self._on_layer_structure_changed + ) + self.doc.active_layer_changed.disconnect(self._on_active_layer_changed) + + def _on_layer_updated(self, sender, **kwargs): + self._update_ui() + self._rebuild_workpiece_list() + + def _on_layer_structure_changed(self, sender, **kwargs): + self.workflow_row.refresh() + self._rebuild_workpiece_list() + + def _on_active_layer_changed(self, sender, **kwargs): + self._update_style() + + def _on_settings_clicked(self, button): + toplevel = self.get_ancestor(Gtk.Window) + if not toplevel: + return + dialog = LayerSettingsDialog( + self.layer, transient_for=toplevel, editor=self.editor + ) + dialog.present() + + def _on_delete_clicked(self, button): + self.editor.layer.delete_layer(self.layer) + + def _on_visibility_clicked(self, button): + new_visibility = button.get_active() + if new_visibility == self.layer.visible: + return + self.editor.layer.set_layer_visibility(self.layer, new_visibility) + + def _on_column_clicked(self, gesture, n_press, x, y): + self._pressed_clicked_item = None + picked = self.pick(x, y, Gtk.PickFlags.DEFAULT) + if picked is not None: + widget = picked + while widget and widget is not self: + if isinstance(widget, Gtk.ListBoxRow): + item = self._row_items.get(widget) + if item is None: + break + if n_press == 1: + if not self._handle_item_click(item, gesture): + return + elif n_press == 2 and isinstance(item, WorkPiece): + self._on_workpiece_double_clicked(item) + gesture.set_state(Gtk.EventSequenceState.DENIED) + return + if isinstance(widget, Gtk.Button): + gesture.set_state(Gtk.EventSequenceState.DENIED) + return + widget = widget.get_parent() + if self.doc.active_layer is not self.layer: + self.editor.layer.set_active_layer(self.layer) + + def _on_column_released(self, gesture, n_press, x, y): + if ( + self._pressed_clicked_item + and self._pressed_clicked_item.uid in self._selected_uids + and len(self._selected_uids) > 1 + ): + self._selection_anchor = self._pressed_clicked_item + self.select_items_requested.send( + self, + items=[self._pressed_clicked_item], + extend=False, + ) + self._pressed_clicked_item = None + + def _handle_item_click(self, item, gesture) -> bool: + event = gesture.get_current_event() + modifiers = ( + event.get_modifier_state() if event else Gdk.ModifierType(0) + ) + shift = bool(modifiers & Gdk.ModifierType.SHIFT_MASK) + ctrl = bool(modifiers & Gdk.ModifierType.CONTROL_MASK) + + if shift: + if ( + not self._selection_anchor + or self._selection_anchor not in self._ordered_items + ): + self._selection_anchor = item + if item in self._ordered_items: + anchor_idx = self._ordered_items.index(self._selection_anchor) + click_idx = self._ordered_items.index(item) + lo = min(anchor_idx, click_idx) + hi = max(anchor_idx, click_idx) + selected = self._ordered_items[lo : hi + 1] + else: + selected = [item] + self.select_items_requested.send( + self, + items=selected, + extend=True, + ) + return True + elif ctrl: + self._selection_anchor = item + selected = [ + i for i in self._ordered_items if i.uid in self._selected_uids + ] + if item in selected: + selected = [i for i in selected if i is not item] + else: + selected.append(item) + self.select_items_requested.send( + self, + items=selected, + extend=True, + ) + return True + elif item.uid not in self._selected_uids: + self._selection_anchor = item + self.select_items_requested.send( + self, + items=[item], + extend=False, + ) + return True + elif len(self._selected_uids) > 1: + self._pressed_clicked_item = item + return False + return False + + def _on_workpiece_double_clicked(self, wp): + if not wp.geometry_provider_uid: + return + asset = self.doc.get_asset_by_uid(wp.geometry_provider_uid) + if not asset: + return + action_name = type(asset).edit_item_action + if not action_name: + return + self.edit_item_requested.send(self, item=wp, action_name=action_name) + + def _on_right_click_pressed(self, gesture, n_press, x, y): + widget = self.pick(x, y, Gtk.PickFlags.DEFAULT) + clicked_item = None + while widget and widget is not self: + if isinstance(widget, Gtk.ListBoxRow): + clicked_item = self._row_items.get(widget) + break + widget = widget.get_parent() + + if clicked_item is None: + self._show_empty_context_menu(gesture) + else: + if clicked_item.uid not in self._selected_uids: + self.select_items_requested.send( + self, + items=[clicked_item], + extend=False, + ) + else: + selected = [ + i + for i in self._ordered_items + if i.uid in self._selected_uids + ] + selected.remove(clicked_item) + selected.insert(0, clicked_item) + self.select_items_requested.send( + self, + items=selected, + extend=False, + ) + self._show_item_context_menu(gesture, clicked_item) + + def _popup_context_menu(self, menu: Gio.Menu, gesture: Gtk.Gesture): + if self._context_popover: + self._context_popover.unparent() + popover = Gtk.PopoverMenu.new_from_model(menu) + popover.set_parent(self) + popover.set_has_arrow(False) + ok, rect = gesture.get_bounding_box() + if ok: + popover.set_pointing_to(rect) + self._context_popover = popover + popover.popup() + + def _show_empty_context_menu(self, gesture): + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("Paste"), "win.paste")) + self._popup_context_menu(menu, gesture) + + def _show_item_context_menu(self, gesture, item): + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("Rename"), "win.rename-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(_("Copy"), "win.copy")) + menu.append_item(Gio.MenuItem.new(_("Cut"), "win.cut")) + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Delete"), "win.remove")) + self._popup_context_menu(menu, gesture) + + def _on_drop(self, drop_target, value, x, y): + if not value: + logger.debug("Drop: rejected, empty value") + return False + + asset_uids = self._parse_asset_uids(value) + if asset_uids is not None: + self._remove_drop_markers() + return self._handle_asset_drop(asset_uids) + + dragged_item = self._find_item_by_uid(value) + if not dragged_item: + self._remove_drop_markers() + logger.debug("Drop: rejected, item not found uid=%r", value[:8]) + return False + + drop_index = self._potential_drop_index + self._remove_drop_markers() + self._drop_shift_held = False + + if dragged_item.uid in self._selected_uids: + items_to_move = [ + i for i in self._ordered_items if i.uid in self._selected_uids + ] + else: + items_to_move = [dragged_item] + + if not items_to_move: + return False + + source_layer = cast(WorkPiece, items_to_move[0]).layer + if source_layer is self.layer: + return self._handle_reorder_drop(items_to_move, drop_index) + + self.move_to_layer_requested.send( + self, items=items_to_move, target_layer=self.layer + ) + return True + + def _handle_reorder_drop(self, items, drop_index): + current_items = list(self.layer.get_content_items()) + if drop_index == -1: + drop_index = len(current_items) + + new_order = [i for i in current_items if i not in items] + for i in reversed(items): + if drop_index >= len(new_order): + new_order.append(i) + else: + new_order.insert(drop_index, i) + self.editor.layer.reorder_content_items(self.layer, new_order) + return True + + def _remove_drop_markers(self): + child = self.listbox.get_first_child() + while child: + child.remove_css_class("drop-above") + child.remove_css_class("drop-below") + child = child.get_next_sibling() + self._potential_drop_index = -1 + + def _parse_asset_uids(self, value: str): + try: + uids = json.loads(value) + if isinstance(uids, list) and len(uids) > 0: + return uids + except (json.JSONDecodeError, ValueError): + pass + return None + + def _handle_asset_drop(self, asset_uids: list) -> bool: + for asset_uid in asset_uids: + asset = self.doc.get_asset_by_uid(asset_uid) + if isinstance(asset, StockAsset): + return False + if not asset or not asset.is_draggable_to_canvas: + continue + + success = False + for asset_uid in asset_uids: + asset = self.doc.get_asset_by_uid(asset_uid) + if not asset or not asset.is_draggable_to_canvas: + continue + pos = self._get_center_position() + try: + if isinstance(asset, SourceAsset): + self._create_source_instance(asset, pos) + success = True + else: + self.editor.edit.add_geometry_provider_instance( + asset_uid, pos, target_layer=self.layer + ) + success = True + except Exception: + logger.exception( + "Error creating instance from asset %s", asset_uid[:8] + ) + return success + + def _create_source_instance( + self, + asset: SourceAsset, + pos: tuple, + ): + win = self.get_ancestor(Gtk.Window) + if not win: + return + import_handler.start_reimport( + cast("MainWindow", win), + self.editor, + asset, + pos, + target_layer=self.layer, + ) + + def _get_center_position(self) -> tuple: + machine = get_context().machine + if machine: + return machine.panel.work_area_center() + return (50.0, 50.0) + + def _on_drop_accept(self, drop_target, drop): + if LayerColumn.dragging: + return False + formats = drop.get_formats() if drop else None + logger.debug( + "Accept(%s): formats=%s", + self.layer.name, + formats.to_string() if formats else None, + ) + return True + + def _on_drop_enter(self, drop_target, x, y): + logger.debug("Enter(%s): x=%d y=%d", self.layer.name, x, y) + return Gdk.DragAction.MOVE + + def _on_drop_motion(self, drop_target, x, y): + if LayerColumn.dragging: + return 0 + + drop = drop_target.get_drop() + if drop and drop.get_actions() & Gdk.DragAction.COPY: + self._remove_drop_markers() + return Gdk.DragAction.COPY + + self._remove_drop_markers() + + fallback_index = len(self.layer.get_content_items()) + coords = self.translate_coordinates(self.listbox, x, y) + if not coords: + logger.debug( + "Motion(%s): translate_coordinates returned None, " + "x=%d y=%d fallback=%d", + self.layer.name, + x, + y, + fallback_index, + ) + self._potential_drop_index = fallback_index + return Gdk.DragAction.MOVE + + lb_x, lb_y = coords + target_row = self._find_row_at(lb_x, lb_y) + if not target_row: + logger.debug( + "Motion(%s): no row at (%d, %d), fallback=%d", + self.layer.name, + lb_x, + lb_y, + fallback_index, + ) + self._potential_drop_index = fallback_index + return Gdk.DragAction.MOVE + + row_alloc = target_row.get_allocation() + row_center = row_alloc.y + row_alloc.height / 2 + + if lb_y < row_center: + target_row.add_css_class("drop-above") + self._potential_drop_index = target_row.get_index() + else: + target_row.add_css_class("drop-below") + self._potential_drop_index = target_row.get_index() + 1 + + return Gdk.DragAction.MOVE + + def _on_drop_leave(self, drop_target): + logger.debug("Leave(%s)", self.layer.name) + self._remove_drop_markers() + + def _find_row_at(self, x, y): + picked = self.listbox.pick(x, y, Gtk.PickFlags.DEFAULT) + while picked: + if isinstance(picked, Gtk.ListBoxRow): + return picked + picked = picked.get_parent() + return None + + def _find_item_by_uid(self, uid: str) -> DocItem | None: + for layer in self.doc.layers: + for item in layer.get_content_items(): + if item.uid == uid: + return item + for wp in layer.all_workpieces: + if wp.uid == uid: + return wp + return None diff --git a/rayforge/ui_gtk/doceditor/layer_settings_dialog.py b/rayforge/ui_gtk/doceditor/layer_settings_dialog.py new file mode 100644 index 000000000..951f5425b --- /dev/null +++ b/rayforge/ui_gtk/doceditor/layer_settings_dialog.py @@ -0,0 +1,281 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, Optional + +from gi.repository import Adw, Gdk, Gtk + +from ...context import get_context +from ...core.layer import Layer +from ..icons import get_icon +from ..machine.wcs_dialog import WcsDialog +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..shared.pref_rows.length_spin_row import LengthSpinRow + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + + +class LayerSettingsDialog(PatchedDialogWindow): + """Dialog for configuring layer-level settings including rotary.""" + + def __init__( + self, + layer: Layer, + transient_for: Gtk.Window, + editor: Optional["DocEditor"] = None, + **kwargs, + ): + super().__init__(transient_for=transient_for, **kwargs) + self.layer = layer + self.editor = editor + self._is_initializing = True + + self.set_title(_("{name} - Settings").format(name=layer.name)) + self.set_default_size(600, -1) + self.set_modal(False) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + + header = Adw.HeaderBar() + main_box.append(header) + + close_button = Gtk.Button(label=_("Close")) + close_button.add_css_class("suggested-action") + close_button.connect("clicked", lambda w: self.close()) + header.pack_end(close_button) + + content = Adw.PreferencesPage() + main_box.append(content) + + general_group = Adw.PreferencesGroup( + title=_("General"), + description=_( + "Basic layer settings such as appearance and " + "coordinate system." + ), + ) + content.add(general_group) + + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.set_text(layer.name) + self.name_row.connect("changed", self._on_name_changed) + general_group.add(self.name_row) + + color_dialog = Gtk.ColorDialog() + color_dialog.set_with_alpha(False) + self.color_button = Gtk.ColorDialogButton(dialog=color_dialog) + rgba = Gdk.RGBA() + rgba.parse(layer.color) + self.color_button.set_rgba(rgba) + self.color_button.connect("notify::rgba", self._on_color_changed) + + color_row = Adw.ActionRow( + title=_("Layer Color"), + subtitle=_("Color used for operations in this layer"), + ) + color_row.add_suffix(self.color_button) + general_group.add(color_row) + + self._populate_wcs_store() + self.wcs_row = Adw.ComboRow( + title=_("Coordinate System"), + subtitle=_( + "The work coordinate system origin to use for this layer. " + "By default, use the WCS selected in the main " + "window" + ), + model=self._wcs_store, + ) + self.edit_offsets_btn = Gtk.Button(child=get_icon("edit-symbolic")) + self.edit_offsets_btn.set_tooltip_text(_("Edit Offsets Manually")) + self.edit_offsets_btn.add_css_class("flat") + self.edit_offsets_btn.set_valign(Gtk.Align.CENTER) + self.edit_offsets_btn.connect("clicked", self._on_edit_offsets_clicked) + self.wcs_row.add_suffix(self.edit_offsets_btn) + + self._select_current_wcs() + self._update_edit_button_sensitivity() + self.wcs_row.connect("notify::selected", self._on_wcs_changed) + general_group.add(self.wcs_row) + + rotary_group = Adw.PreferencesGroup( + title=_("Rotary Attachment"), + description=_( + "Configure rotary attachment for cylindrical objects. " + "When enabled, Y-axis movements are converted to " + "rotational movements in degrees." + ), + ) + content.add(rotary_group) + + self.rotary_enabled_row = Adw.SwitchRow() + self.rotary_enabled_row.set_title(_("Enable Rotary Mode")) + self.rotary_enabled_row.set_subtitle( + _("Convert Y-axis to rotary axis") + ) + self.rotary_enabled_row.set_active(layer.rotary_enabled) + self.rotary_enabled_row.connect( + "notify::active", self._on_rotary_enabled_changed + ) + rotary_group.add(self.rotary_enabled_row) + + self._populate_module_store() + self.module_row = Adw.ComboRow( + title=_("Rotary Module"), + subtitle=_("Select the rotary module for this layer"), + model=self._module_store, + ) + self._select_current_module() + self.module_row.connect("notify::selected", self._on_module_changed) + self.module_row.set_sensitive(layer.rotary_enabled) + rotary_group.add(self.module_row) + + self.rotary_diameter_row = LengthSpinRow( + _("Object Diameter"), + _("Diameter of the cylindrical object"), + lower=1, + upper=1000, + value_in_base=layer.rotary_diameter, + ) + self.rotary_diameter_row.value_changed.connect( + self._on_rotary_diameter_changed + ) + self.rotary_diameter_row.set_sensitive(layer.rotary_enabled) + rotary_group.add(self.rotary_diameter_row) + + self._is_initializing = False + + has_modules = bool(self._module_uids) + if not has_modules: + self.module_row.set_sensitive(False) + + if not self.layer.rotary_module_uid and has_modules: + self.layer.set_rotary_module_uid(self._module_uids[0]) + + def _populate_wcs_store(self): + self._wcs_store = Gtk.StringList() + self._wcs_values: list[str | None] = [None] + self._wcs_store.append(_("Default")) + machine = get_context().machine + if machine: + for wcs in machine.supported_wcs: + self._wcs_store.append(wcs) + self._wcs_values.append(wcs) + + def _select_current_wcs(self): + wcs = self.layer.wcs + if wcs and wcs in self._wcs_values: + self.wcs_row.set_selected(self._wcs_values.index(wcs)) + else: + self.wcs_row.set_selected(0) + + def _get_selected_wcs(self) -> str | None: + idx = self.wcs_row.get_selected() + if idx < len(self._wcs_values): + return self._wcs_values[idx] + return None + + def _on_wcs_changed(self, row, _param): + if self._is_initializing: + return + wcs = self._get_selected_wcs() + self.layer.set_wcs(wcs) + self._update_edit_button_sensitivity() + + def _update_edit_button_sensitivity(self): + wcs = self._get_selected_wcs() + self.edit_offsets_btn.set_sensitive(wcs is not None) + + def _on_edit_offsets_clicked(self, button): + machine = get_context().machine + if not machine: + return + + root = self.get_root() + self._edit_dialog = WcsDialog( + machine=machine, + transient_for=root if isinstance(root, Gtk.Window) else None, + ) + self._edit_dialog.connect("destroy", self._on_edit_dialog_destroy) + self._edit_dialog.present() + + def _on_edit_dialog_destroy(self, *_): + self._edit_dialog = None + + def _populate_module_store(self): + self._module_store = Gtk.StringList() + self._module_uids: list[str] = [] + machine = get_context().machine + if machine: + for module in sorted( + machine.rotary_modules.values(), key=lambda m: m.name + ): + self._module_store.append(module.name) + self._module_uids.append(module.uid) + + def _select_current_module(self): + uid = self.layer.rotary_module_uid + if uid and uid in self._module_uids: + self.module_row.set_selected(self._module_uids.index(uid)) + elif self._module_uids: + self.module_row.set_selected(0) + self.layer.set_rotary_module_uid(self._module_uids[0]) + + def _on_rotary_enabled_changed(self, row, _): + if self._is_initializing: + return + enabled = row.get_active() + self.module_row.set_sensitive(enabled) + self.rotary_diameter_row.set_sensitive(enabled) + self.layer.set_rotary_enabled(enabled) + if enabled and self.layer.rotary_module_uid is None: + machine = get_context().machine + if machine: + default_rm = machine.get_default_rotary_module() + if default_rm: + self.layer.set_rotary_module_uid(default_rm.uid) + self.layer.set_rotary_diameter(default_rm.default_diameter) + self.rotary_diameter_row.set_value_in_base_units( + default_rm.default_diameter + ) + + def _on_module_changed(self, row, _param): + if self._is_initializing: + return + idx = row.get_selected() + if idx < len(self._module_uids): + uid = self._module_uids[idx] + self.layer.set_rotary_module_uid(uid) + machine = get_context().machine + if machine: + rm = machine.get_rotary_module_by_uid(uid) + if rm: + self.layer.set_rotary_diameter(rm.default_diameter) + self.rotary_diameter_row.set_value_in_base_units( + rm.default_diameter + ) + + def _on_rotary_diameter_changed(self, row): + if self._is_initializing: + return + diameter = self.rotary_diameter_row.get_value_in_base_units() + self.layer.set_rotary_diameter(diameter) + + def _on_color_changed(self, button, _param): + if self._is_initializing: + return + rgba = button.get_rgba() + r = round(rgba.red * 255) + g = round(rgba.green * 255) + b = round(rgba.blue * 255) + hex_color = f"#{r:02x}{g:02x}{b:02x}" + self.layer.set_color(hex_color) + + def _on_name_changed(self, row): + if self._is_initializing: + return + new_name = row.get_text().strip() + if not new_name: + return + if self.editor: + self.editor.layer.rename_layer(self.layer, new_name) diff --git a/rayforge/ui_gtk/doceditor/layers_tab.py b/rayforge/ui_gtk/doceditor/layers_tab.py new file mode 100644 index 000000000..a11c4c158 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/layers_tab.py @@ -0,0 +1,295 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from blinker import Signal +from gi.repository import Gdk, Gtk + +from ...core.doc import Doc +from ...core.item import DocItem +from ...core.layer import Layer +from ..icons import get_icon +from .layer_column import _LAYER_UID_PREFIX, LayerColumn + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class LayersTab(Gtk.Box): + def __init__(self, editor: "DocEditor"): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL) + self.editor = editor + self.doc = editor.doc + self._columns = [] + self._layer_drop_index = -1 + self._pan_offset_x = 0.0 + self._selected_items: list = [] + + self.edit_item_requested = Signal() + self.select_items_requested = Signal() + + self.scrolled = Gtk.ScrolledWindow() + self.scrolled.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER + ) + self.scrolled.set_hexpand(True) + self.scrolled.set_vexpand(True) + + self.columns_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=0 + ) + self.columns_box.set_margin_start(9) + self.columns_box.set_margin_top(9) + self.columns_box.set_margin_bottom(9) + self.columns_box.set_valign(Gtk.Align.FILL) + self.scrolled.set_child(self.columns_box) + self.append(self.scrolled) + + self._pan_gesture = Gtk.GestureDrag.new() + self._pan_gesture.set_button(Gdk.BUTTON_MIDDLE) + self._pan_gesture.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + self._pan_gesture.connect("drag-begin", self._on_pan_begin) + self._pan_gesture.connect("drag-update", self._on_pan_update) + self.scrolled.add_controller(self._pan_gesture) + + drop_target = Gtk.DropTarget.new(str, Gdk.DragAction.MOVE) + drop_target.connect("accept", self._on_layer_drop_accept) + drop_target.connect("enter", self._on_layer_drop_enter) + drop_target.connect("drop", self._on_layer_drop) + drop_target.connect("motion", self._on_layer_drop_motion) + drop_target.connect("leave", self._on_layer_drop_leave) + self.scrolled.add_controller(drop_target) + + add_button = Gtk.Button(child=get_icon("add-symbolic")) + add_button.add_css_class("flat") + add_button.set_tooltip_text(_("Add New Layer")) + add_button.set_valign(Gtk.Align.START) + add_button.set_margin_top(18) + add_button.set_margin_start(9) + add_button.set_margin_end(9) + add_button.connect("clicked", self._on_add_clicked) + self.append(add_button) + + self._connect_signals() + self._rebuild() + + def set_doc(self, doc: Doc): + if self.doc == doc: + return + self._disconnect_signals() + self.doc = doc + self._connect_signals() + self._rebuild() + + def _connect_signals(self): + self.doc.descendant_added.connect(self._on_structure_changed) + self.doc.descendant_removed.connect(self._on_structure_changed) + self.doc.updated.connect(self._on_doc_updated) + + def _disconnect_signals(self): + self.doc.descendant_added.disconnect(self._on_structure_changed) + self.doc.descendant_removed.disconnect(self._on_structure_changed) + self.doc.updated.disconnect(self._on_doc_updated) + + def do_destroy(self): + self._disconnect_signals() + + def _on_structure_changed(self, sender, **kwargs): + self._rebuild() + + def _on_doc_updated(self, sender, **kwargs): + current = [col.layer for col in self._columns] + if current != list(self.doc.layers): + self._rebuild() + + def _rebuild(self): + for col in self._columns: + self.columns_box.remove(col) + self._columns.clear() + + can_delete = len(list(self.doc.layers)) > 1 + for child in self.doc.children: + if not isinstance(child, Layer): + continue + col = LayerColumn( + self.doc, child, self.editor, can_delete=can_delete + ) + col.edit_item_requested.connect(self._on_column_edit_item) + col.select_items_requested.connect(self._on_column_select_items) + col.move_to_layer_requested.connect(self._on_column_move_to_layer) + self._columns.append(col) + self.columns_box.append(col) + + def _on_column_edit_item(self, sender, **kwargs): + self.edit_item_requested.send(sender, **kwargs) + + def _on_column_select_items(self, sender, **kwargs): + items = kwargs.get("items", []) + extend = kwargs.get("extend", False) + if extend: + other_layer = [ + i for i in self._selected_items if i.layer is not sender.layer + ] + same_layer = [ + i for i in self._selected_items if i.layer is sender.layer + ] + new_uids = {i.uid for i in items} + for i in same_layer: + if i.uid not in new_uids: + items.append(i) + items = other_layer + items + self._selected_items = items + self.select_items_requested.send(sender, items=items) + + def _on_column_move_to_layer(self, sender, **kwargs): + items = kwargs.get("items", []) + target_layer = kwargs.get("target_layer") + if not target_layer: + return + moved_uids = {i.uid for i in items} + selected_uids = {i.uid for i in self._selected_items} + if moved_uids & selected_uids: + items = list(self._selected_items) + self.editor.layer.move_items_to_layer(items, target_layer) + + def update_row_selection(self, selected_uids: set): + all_items = self.get_ordered_items() + self._selected_items = [i for i in all_items if i.uid in selected_uids] + for col in self._columns: + col.update_row_selection(selected_uids) + + def get_selected_items(self) -> list: + return list(self._selected_items) + + def get_ordered_items(self) -> list: + items = [] + for col in self._columns: + child = col.listbox.get_first_child() + while child: + if isinstance(child, Gtk.ListBoxRow): + item = col._row_items.get(child) + if item: + items.append(item) + child = child.get_next_sibling() + return items + + def start_item_rename(self, item: DocItem) -> bool: + """Starts in-place renaming of the given item in its layer column.""" + for col in self._columns: + if col.start_item_rename(item): + return True + return False + + def _on_add_clicked(self, button): + self.editor.layer.add_layer_and_set_active() + + def _find_column_at(self, x, y): + picked = self.scrolled.pick(x, y, Gtk.PickFlags.DEFAULT) + while picked: + if isinstance(picked, LayerColumn): + return picked + picked = picked.get_parent() + return None + + def _remove_layer_drop_markers(self): + LayerColumn._remove_layer_drop_markers_from(self.columns_box) + self._layer_drop_index = -1 + + @staticmethod + def _parse_layer_uid(value): + if value and value.startswith(_LAYER_UID_PREFIX): + return value[len(_LAYER_UID_PREFIX) :] + return None + + def _on_layer_drop(self, drop_target, value, x, y): + uid = self._parse_layer_uid(value) + if not uid: + return False + + layers = list(self.doc.layers) + source = None + for layer in layers: + if layer.uid == uid: + source = layer + break + if not source: + self._remove_layer_drop_markers() + return False + + drop_index = self._layer_drop_index + self._remove_layer_drop_markers() + + if drop_index == -1 or drop_index > len(layers): + return False + + source_index = layers.index(source) + + insert_index = drop_index + if source_index < insert_index: + insert_index -= 1 + + if source_index == insert_index: + return True + + new_order = list(layers) + new_order.pop(source_index) + new_order.insert(insert_index, source) + self.editor.layer.reorder_layers(new_order) + return True + + def _on_layer_drop_accept(self, drop_target, drop): + formats = drop.get_formats() if drop else None + logger.debug( + "Accept(columns_box): formats=%s", + formats.to_string() if formats else None, + ) + return True + + def _on_layer_drop_enter(self, drop_target, x, y): + logger.debug("Enter(columns_box): x=%d y=%d", x, y) + return Gdk.DragAction.MOVE + + def _on_layer_drop_motion(self, drop_target, x, y): + if not LayerColumn.dragging: + logger.debug("Motion(scrolled): rejected, not layer drag") + return 0 + + self._remove_layer_drop_markers() + + col = self._find_column_at(x, y) + if not col: + self._layer_drop_index = len(self._columns) + if self._columns: + self._columns[-1].add_css_class("drop-right") + return Gdk.DragAction.MOVE + + coords = self.scrolled.translate_coordinates(self.columns_box, x, y) + if not coords: + return Gdk.DragAction.MOVE + cb_x, _ = coords + + col_alloc = col.get_allocation() + col_center_x = col_alloc.x + col_alloc.width / 2 + + if cb_x < col_center_x: + col.add_css_class("drop-left") + self._layer_drop_index = self._columns.index(col) + else: + col.add_css_class("drop-right") + self._layer_drop_index = self._columns.index(col) + 1 + + return Gdk.DragAction.MOVE + + def _on_layer_drop_leave(self, drop_target): + self._remove_layer_drop_markers() + + def _on_pan_begin(self, gesture, start_x, start_y): + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + adj = self.scrolled.get_hadjustment() + self._pan_offset_x = adj.get_value() + + def _on_pan_update(self, gesture, offset_x, offset_y): + adj = self.scrolled.get_hadjustment() + adj.set_value(self._pan_offset_x - offset_x) diff --git a/rayforge/ui_gtk/doceditor/material_library_list.py b/rayforge/ui_gtk/doceditor/material_library_list.py new file mode 100644 index 000000000..90ad3abe2 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/material_library_list.py @@ -0,0 +1,326 @@ +"""Material library list UI components for Rayforge.""" + +import logging +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...core.material_library import MaterialLibrary +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton + +logger = logging.getLogger(__name__) + + +class LibraryRow(Gtk.Box): + """A widget representing a single Material Library in a ListBox.""" + + def __init__( + self, + library: MaterialLibrary, + on_delete_callback, + on_edit_callback, + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + logger.debug( + f"LibraryRow.__init__: Creating instance for library " + f"'{library.library_id if library is not None else 'None'}'" + ) + self.library = library + self.on_delete_callback = on_delete_callback + self.on_edit_callback = on_edit_callback + self.delete_button: Gtk.Button + self.edit_button: Gtk.Button + self.title_label: Gtk.Label + self.subtitle_label: Gtk.Label + self._setup_ui() + + def _setup_ui(self): + """Builds the user interface for the row.""" + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(labels_box) + + self.title_label = Gtk.Label( + label=self.library.display_name, + halign=Gtk.Align.START, + xalign=0, + ) + labels_box.append(self.title_label) + + self.subtitle_label = Gtk.Label( + label=self._get_subtitle_text(), + halign=Gtk.Align.START, + xalign=0, + ) + self.subtitle_label.add_css_class("dim-label") + labels_box.append(self.subtitle_label) + + # Add edit and delete buttons for writable libraries only + if not self.library.read_only: + # Suffix area for buttons + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + self.edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + self.edit_button.add_css_class("flat") + self.edit_button.connect("clicked", self._on_edit_clicked) + suffix_box.append(self.edit_button) + + self.delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + self.delete_button.add_css_class("flat") + self.delete_button.connect("clicked", self._on_delete_clicked) + suffix_box.append(self.delete_button) + + def _get_subtitle_text(self) -> str: + """Generates the subtitle text from library properties.""" + material_count = len(self.library) + if material_count == 1: + count_text = _("1 material") + else: + count_text = _("{count} materials").format(count=material_count) + + if self.library.read_only: + return _("{count} (Read-only)").format(count=count_text) + return count_text + + def _on_delete_clicked(self, button: Gtk.Button): + """Handle delete button click.""" + self.on_delete_callback(self.library) + + def _on_edit_clicked(self, button: Gtk.Button): + """Handle edit button click.""" + self.on_edit_callback(self.library) + + +class LibraryListWidget(PreferencesGroupWithButton): + """ + An Adwaita widget for displaying and managing a list of material libraries. + """ + + def __init__(self, **kwargs): + # Pass the correct selection mode to the parent constructor. + # This is the single source of truth for selection behavior. + super().__init__( + button_label=_("Add New Library"), + selection_mode=Gtk.SelectionMode.SINGLE, + empty_placeholder=_("No libraries found."), + **kwargs, + ) + self.library_selected = Signal() + # Keep a persistent reference to the Python widget objects + self._row_widgets: list[LibraryRow] = [] + self._setup_ui() + + def _setup_ui(self): + """Configures the widget's list box.""" + self.list_box.set_show_separators(True) + self.list_box.connect("row-selected", self._on_library_selected) + + def populate_and_select(self, select_name: str | None = None): + """ + Populates the list with libraries and selects a specific one. + + Args: + select_name: The name (ID) of the library to select. If None, + selects the first library in the list. + """ + material_mgr = get_context().material_mgr + libraries = sorted( + material_mgr.get_libraries(), + key=lambda lib: lib.display_name, + ) + # Clear the old references before creating new ones + self._row_widgets.clear() + # This now calls the corrected base class method. + self.set_items(libraries) + + row_to_select = None + if libraries: + if select_name: + i = 0 + while row := self.list_box.get_row_at_index(i): + child = row.get_child() + if ( + isinstance(child, LibraryRow) + and child.library.library_id == select_name + ): + row_to_select = row + break + i += 1 + else: + row_to_select = self.list_box.get_row_at_index(0) + + if row_to_select: + self.list_box.select_row(row_to_select) + elif not libraries: + # Ensure selection is cleared if no libraries exist + self._on_library_selected(self.list_box, None) + + def create_row_widget(self, item: MaterialLibrary) -> Gtk.Widget: + """Creates a LibraryRow for the given library.""" + logger.debug( + f"LibraryListEditor: Creating LibraryRow for library " + f"'{item.library_id}' (display: '{item.display_name}')" + ) + row_widget = LibraryRow( + item, self._on_delete_library, self._on_edit_library + ) + + # Store a reference to prevent garbage collection + self._row_widgets.append(row_widget) + return row_widget + + def _on_delete_library(self, library: MaterialLibrary): + """Handle library deletion with confirmation dialog.""" + material_mgr = get_context().material_mgr + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Delete '{name}'?").format(name=library.display_name), + body=_( + "The library folder and all its materials will be " + "permanently removed. This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + + def on_response(d, response_id): + if response_id == "delete": + if not material_mgr.remove_user_library(library.library_id): + logger.error( + f"Failed to remove library '{library.library_id}'" + ) + self.populate_and_select() + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _on_edit_library(self, library: MaterialLibrary): + """Handle library editing.""" + material_mgr = get_context().material_mgr + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Edit Library"), + body=_("Enter a new name for the library:"), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("save", _("Save")) + dialog.set_response_appearance( + "save", Adw.ResponseAppearance.SUGGESTED + ) + dialog.set_default_response("cancel") + + entry = Gtk.Entry(placeholder_text=_("Library name")) + entry.set_text(library.display_name) + dialog.set_extra_child(entry) + + # Connect Enter key handler + entry.connect("activate", lambda widget: dialog.response("save")) + + def on_response(d, response_id): + if response_id == "save": + new_display_name = entry.get_text().strip() + if ( + not new_display_name + or new_display_name == library.display_name + ): + d.destroy() + return + + # Update the library display name directly + library.set_display_name(new_display_name) + + # Save the changes to disk + if material_mgr.update_library(library.library_id): + self.populate_and_select(select_name=library.library_id) + else: + err_dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Error"), + body=_("Failed to rename library."), + ) + err_dialog.add_response("ok", _("OK")) + err_dialog.present() + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + entry.grab_focus() + + def _on_add_clicked(self, button: Gtk.Button): + """Handle add library button click.""" + material_mgr = get_context().material_mgr + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Add New Library"), + body=_("Enter a name for the new library folder:"), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("add", _("Add")) + dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("cancel") + + entry = Gtk.Entry(placeholder_text=_("Library name")) + dialog.set_extra_child(entry) + + # Connect Enter key handler + entry.connect("activate", lambda widget: dialog.response("add")) + + def on_response(d, response_id): + if response_id == "add": + display_name = entry.get_text().strip() + if not display_name: + d.destroy() + return + + new_lib_id = material_mgr.create_user_library(display_name) + if new_lib_id: + self.populate_and_select(select_name=new_lib_id) + else: + err_dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Error"), + body=_( + "Failed to create library. A folder with that " + "name may already exist." + ), + ) + err_dialog.add_response("ok", _("OK")) + err_dialog.present() + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + entry.grab_focus() + + def _on_library_selected( + self, listbox: Gtk.ListBox, row: Gtk.ListBoxRow | None + ): + """Handle library selection.""" + logger.debug("LibraryListEditor: Handling library selection") + library = None + selected_row = listbox.get_selected_row() + + if selected_row: + child = selected_row.get_child() + if isinstance(child, LibraryRow): + library = child.library + self.library_selected.send(self, library=library) diff --git a/rayforge/ui_gtk/doceditor/material_list.py b/rayforge/ui_gtk/doceditor/material_list.py new file mode 100644 index 000000000..ee251975d --- /dev/null +++ b/rayforge/ui_gtk/doceditor/material_list.py @@ -0,0 +1,319 @@ +"""Material list UI components for Rayforge.""" + +import logging +import uuid +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, Gdk, Gtk + +from ...context import get_context +from ...core.material import Material, MaterialAppearance +from ...core.material_library import MaterialLibrary +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from .add_material_dialog import AddMaterialDialog + +logger = logging.getLogger(__name__) + + +class MaterialRow(Gtk.Box): + """A widget representing a single Material in a ListBox.""" + + def __init__( + self, + material: Material, + library: MaterialLibrary, + on_delete_callback, + on_edit_callback, + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.material = material + self.library = library + self.on_delete_callback = on_delete_callback + self.on_edit_callback = on_edit_callback + self._setup_ui() + + def _setup_ui(self): + """Builds the user interface for the row.""" + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + color_box = Gtk.Box() + color_box.set_size_request(24, 24) + color_box.set_valign(Gtk.Align.CENTER) + color_class = f"material-color-{self.material.uid}" + color_box.add_css_class(color_class) + color_provider = Gtk.CssProvider() + display_color = self.material.get_display_color() + color_data = f".{color_class} {{ background-color: {display_color}; }}" + color_provider.load_from_string(color_data) + display = Gdk.Display.get_default() + if display: + Gtk.StyleContext.add_provider_for_display( + display, + color_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + self.prepend(color_box) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(labels_box) + + title_label = Gtk.Label( + label=self.material.name, + halign=Gtk.Align.START, + xalign=0, + ) + labels_box.append(title_label) + + subtitle_label = Gtk.Label( + label=self.material.category, + halign=Gtk.Align.START, + xalign=0, + ) + subtitle_label.add_css_class("dim-label") + labels_box.append(subtitle_label) + + if not self.library.read_only: + # Suffix area for buttons + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", self._on_edit_clicked) + suffix_box.append(edit_button) + + delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + delete_button.add_css_class("flat") + delete_button.connect("clicked", self._on_delete_clicked) + suffix_box.append(delete_button) + + def _on_delete_clicked(self, button: Gtk.Button): + """Handle the delete button being clicked.""" + self.on_delete_callback(self.material) + + def _on_edit_clicked(self, button: Gtk.Button): + """Handle the edit button being clicked.""" + self.on_edit_callback(self.material) + + +class MaterialListWidget(PreferencesGroupWithButton): + """ + An Adwaita widget for displaying materials from a selected library. + """ + + def __init__(self, **kwargs): + # This list correctly uses the default SelectionMode.NONE + super().__init__( + button_label=_("Add New Material"), + empty_placeholder=_("No materials in selected library."), + **kwargs, + ) + self.material_added = Signal() + self.material_deleted = Signal() + self._setup_ui() + self._current_library: MaterialLibrary | None = None + + def _setup_ui(self): + """Configures the widget's list box.""" + self.list_box.set_show_separators(True) + + def set_library(self, library: MaterialLibrary | None): + """Set the current library and update the materials list.""" + logger.debug( + f"MaterialListEditor: Setting library to " + f"'{library.library_id if library is not None else 'None'}'" + ) + self._current_library = library + self.add_button.set_sensitive( + library is not None and not library.read_only + ) + self._populate_materials() + + def _populate_materials(self): + """Populate the list with materials from the current library.""" + if self._current_library is None: + self.set_items([]) + return + + materials = sorted( + self._current_library.get_all_materials(), key=lambda m: m.name + ) + self.set_items(materials) + + def create_row_widget(self, item: Material) -> Gtk.Widget: + """Creates a MaterialRow for the given material.""" + assert self._current_library is not None + return MaterialRow( + item, + self._current_library, + self._on_delete_material, + self._on_edit_material, + ) + + def _on_delete_material(self, material: Material): + """Handle material deletion with confirmation.""" + if self._current_library is None: + return + + # Reject deletion if the material is still in use + root = self.get_root() + recipe_mgr = get_context().recipe_mgr + if recipe_mgr.is_material_in_use(material.uid): + err_dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Cannot Delete Material"), + body=_( + "This material is currently used by one or more recipes. " + "Please remove the recipes that use this material before " + "deleting it." + ), + ) + err_dialog.add_response("ok", _("OK")) + err_dialog.present() + return # Stop the deletion process + + # Ask for confirmation + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Delete '{name}'?").format(name=material.name), + body=_( + "The material will be permanently removed from the library. " + "This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + + def on_response(d, response_id): + if response_id == "delete": + if ( + self._current_library is not None + and self._current_library.remove_material(material.uid) + ): + self._populate_materials() + self.material_deleted.send( + self, library=self._current_library + ) + else: + logger.error(f"Failed to remove material '{material.uid}'") + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _on_edit_material(self, material: Material): + """Handle material editing.""" + if self._current_library is None: + return + + root = self.get_root() + dialog = AddMaterialDialog( + material=material, + transient_for=cast(Gtk.Window, root) if root else None, + ) + + def on_response(d, response_id): + if response_id in ("add", "save"): + data = d.get_material_data() + if data["name"] and self._current_library is not None: + self._update_material( + data, material, self._current_library + ) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _update_material( + self, data: dict, material: Material, library: MaterialLibrary + ): + """Update an existing material in the library.""" + # Update material properties + material.name = data["name"] + material.category = data["category"] + material.appearance.color = data["color"] + + # Save the updated material + if material.file_path: + try: + material.save_to_file(material.file_path) + self._populate_materials() + logger.info( + f"Updated material '{data['name']}' in library " + f"'{library.library_id}'" + ) + self.material_added.send(self, library=library) + except (OSError, ValueError) as e: + logger.error(f"Failed to update material: {e}") + root = self.get_root() + err_dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Error"), + body=_("Failed to update material."), + ) + err_dialog.add_response("ok", _("OK")) + err_dialog.present() + + def _on_add_clicked(self, button: Gtk.Button): + """Handle add material button click.""" + logger.debug("MaterialListEditor: Add material button clicked") + if self._current_library is None: + logger.error( + "MaterialListEditor: _on_add_clicked failed because " + "_current_library is None. The dialog will not be shown." + ) + return + + root = self.get_root() + dialog = AddMaterialDialog( + transient_for=cast(Gtk.Window, root) if root else None + ) + + def on_response(d, response_id): + if response_id == "add": + data = d.get_material_data() + if data["name"] and self._current_library is not None: + self._add_material(data, self._current_library) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _add_material(self, data: dict, library: MaterialLibrary): + """Add a new material to the current library.""" + material = Material( + uid=str(uuid.uuid4()), + name=data["name"], + description="", + category=data["category"], + appearance=MaterialAppearance(color=data["color"]), + ) + + if library.add_material(material): + self._populate_materials() + logger.info( + f"Added material '{data['name']}' to library " + f"'{library.library_id}'" + ) + self.material_added.send(self, library=library) + else: + root = self.get_root() + err_dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Error"), + body=_("Failed to add material to library."), + ) + err_dialog.add_response("ok", _("OK")) + err_dialog.present() diff --git a/rayforge/ui_gtk/doceditor/material_selector.py b/rayforge/ui_gtk/doceditor/material_selector.py new file mode 100644 index 000000000..963114047 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/material_selector.py @@ -0,0 +1,158 @@ +"""A dialog for selecting a material from available libraries.""" + +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...core.material import Material +from ...core.material_library import MaterialLibrary +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + + +css = """ +.material-selector-list { + background: none; +} +""" + + +class MaterialSelectorRow(Adw.ActionRow): + """A widget representing a single Material in the selector ListBox.""" + + def __init__(self, material: Material): + super().__init__(title=material.name, activatable=True) + self.material = material + + # Color indicator + color_box = Gtk.Box() + color_box.set_valign(Gtk.Align.CENTER) + color_box.set_size_request(24, 24) + color_box.add_css_class("material-color-selector") + color_provider = Gtk.CssProvider() + display_color = self.material.get_display_color() + color_data = ( + f".material-color-selector " + f"{{ background-color: {display_color}; }}" + ) + color_provider.load_from_string(color_data) + color_box.get_style_context().add_provider( + color_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + self.add_prefix(color_box) + + +class MaterialSelectorDialog(Adw.MessageDialog): + """A dialog for selecting a material.""" + + def __init__(self, parent: Gtk.Window, on_select_callback): + super().__init__(transient_for=parent) + self.on_select_callback = on_select_callback + self._current_library: MaterialLibrary | None = None + self._all_materials: list[Material] = [] + self.libraries: list[MaterialLibrary] = [] + + self.set_heading(_("Select Material")) + self.set_body(_("Choose a material from the available libraries.")) + + # This is the proper, targeted CSS for the ListBox. + # It manually creates the grouped, rounded-corner appearance. + apply_css(css) + + # Main content area + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_top(12) + self.set_extra_child(content_box) + + # Library dropdown + self.library_dropdown = Gtk.DropDown() + self.library_dropdown.connect( + "notify::selected-item", self._on_library_changed + ) + content_box.append(self.library_dropdown) + + # Search entry + self.search_entry = Gtk.SearchEntry() + self.search_entry.connect("search-changed", self._on_search_changed) + content_box.append(self.search_entry) + + # Scrolled window for the list + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + min_content_height=300, + vexpand=True, + ) + scrolled_window.add_css_class("card") + content_box.append(scrolled_window) + + # Material list + self.material_list = Gtk.ListBox() + self.material_list.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.material_list.add_css_class("material-selector-list") + self.material_list.connect( + "row-activated", self._on_material_activated + ) + scrolled_window.set_child(self.material_list) + + # Add response button + self.add_response("cancel", _("Cancel")) + self.set_default_response("cancel") + + self._populate_libraries() + + def _populate_libraries(self): + """Populates the library dropdown.""" + material_mgr = get_context().material_mgr + model = Gtk.StringList() + self.libraries = sorted( + material_mgr.get_libraries(), key=lambda lib: lib.display_name + ) + for lib in self.libraries: + model.append(lib.display_name) + + self.library_dropdown.set_model(model) + if self.libraries: + self.library_dropdown.set_selected(0) + + def _on_library_changed(self, dropdown, _): + """Handles library selection change.""" + selected_index = dropdown.get_selected() + if selected_index < 0 or selected_index >= len(self.libraries): + self._current_library = None + else: + self._current_library = self.libraries[selected_index] + + if self._current_library: + self._all_materials = self._current_library.get_all_materials() + else: + self._all_materials = [] + self._filter_and_populate_materials() + + def _on_search_changed(self, entry: Gtk.SearchEntry): + """Handles search text changes.""" + self._filter_and_populate_materials() + + def _filter_and_populate_materials(self): + """Filters and populates the material list based on search.""" + search_text = self.search_entry.get_text().lower() + + while child := self.material_list.get_row_at_index(0): + self.material_list.remove(child) + + for material in self._all_materials: + if search_text in material.name.lower(): + row = MaterialSelectorRow(material) + self.material_list.append(row) + + def _on_material_activated( + self, listbox: Gtk.ListBox, row: MaterialSelectorRow + ): + """Handles when a material is selected.""" + if isinstance(row, MaterialSelectorRow): + selected_material = row.material + self.on_select_callback(selected_material.uid) + self.close() diff --git a/rayforge/ui_gtk/doceditor/missing_features_dialog.py b/rayforge/ui_gtk/doceditor/missing_features_dialog.py new file mode 100644 index 000000000..268f7fb22 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/missing_features_dialog.py @@ -0,0 +1,34 @@ +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + + +class MissingFeaturesDialog(Adw.MessageDialog): + """ + Dialog shown when a document uses features that are not available. + + This happens when a document contains steps whose producer types + are not registered (e.g., because the addon providing them is not + installed). + """ + + def __init__(self, parent: Gtk.Window, missing_types: set[str]): + super().__init__(transient_for=parent, modal=True) + self.set_heading(_("Missing Features")) + + if len(missing_types) == 1: + msg = _( + "This document uses a feature that is not available: {}" + ).format(next(iter(missing_types))) + else: + types_list = ", ".join(sorted(missing_types)) + msg = _( + "This document uses features that are not available: {}" + ).format(types_list) + + msg += "\n\n" + _("The document can still be edited and saved.") + self.set_body(msg) + + self.add_response("ok", _("_OK")) + self.set_default_response("ok") + self.set_close_response("ok") diff --git a/rayforge/ui_gtk/doceditor/post_processor/__init__.py b/rayforge/ui_gtk/doceditor/post_processor/__init__.py new file mode 100644 index 000000000..410b11c76 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/post_processor/__init__.py @@ -0,0 +1 @@ +"""Post-processor UI infrastructure.""" diff --git a/rayforge/ui_gtk/doceditor/post_processor/groups/__init__.py b/rayforge/ui_gtk/doceditor/post_processor/groups/__init__.py new file mode 100644 index 000000000..46a092224 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/post_processor/groups/__init__.py @@ -0,0 +1,10 @@ +"""Settings groups for post-processing transformers.""" + +from .placeholder_group import PlaceholderSettingsGroup +from .transformer_group import ExpanderHost, TransformerSettingsGroup + +__all__ = [ + "ExpanderHost", + "PlaceholderSettingsGroup", + "TransformerSettingsGroup", +] diff --git a/rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py b/rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py new file mode 100644 index 000000000..341bc06e8 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/post_processor/groups/placeholder_group.py @@ -0,0 +1,46 @@ +"""Error display for a missing transformer widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw + +from .....pipeline.transformer.base import OpsTransformer +from .transformer_group import ( + ExpanderHost, + TransformerSettingsGroup, +) + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class PlaceholderSettingsGroup(TransformerSettingsGroup): + """ + Error display for missing transformer widget. + + This group is shown when a step's transformer type is not available. + """ + + def __init__( + self, + title: str, + transformer: OpsTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + transformer_type = type(transformer).__name__ + + error_row = Adw.ActionRow( + title=_("This feature is not available."), + subtitle=_( + "The required component '{}' could not be found. " + "The document can still be saved." + ).format(transformer_type), + ) + error_row.add_css_class("error") + self.add(error_row) diff --git a/rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py b/rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py new file mode 100644 index 000000000..72e9efc8c --- /dev/null +++ b/rayforge/ui_gtk/doceditor/post_processor/groups/transformer_group.py @@ -0,0 +1,240 @@ +"""Base for settings groups that manage a transformer.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Protocol + +from blinker import Signal +from gi.repository import Adw, GObject, Gtk + +from .....pipeline.transformer.base import OpsTransformer +from ....icons import get_icon +from ....shared.gtk import apply_css + +if TYPE_CHECKING: + from .....core.step import Step + +# Make the tri-state menu button look like a plain label + arrow (like +# an Adw.ComboRow) instead of a clickable button: no background, border, +# or hover/focus highlight. The theme styles the internal ``button`` +# child of the menubutton node, so the rule must target it. +_APPLY_MENU_CSS = """ +.recipe-apply-menu button, +.recipe-apply-menu button:hover, +.recipe-apply-menu button:active, +.recipe-apply-menu button:checked, +.recipe-apply-menu button:focus, +.recipe-apply-menu button:focus-visible, +.recipe-apply-menu button:focus-within { + background-color: transparent; + border: none; + box-shadow: none; +} +""" + + +class ExpanderHost(Protocol): + """A page that wraps transformer groups in expander rows. + + The group defers to the host: when ``use_expanders`` is set, the + host extracts the group's rows from ``_rows`` and reparents them + itself, so the group must not add them to its own hierarchy. + """ + + use_expanders: bool = True + + +class TransformerSettingsGroup(Adw.PreferencesGroup): + """ + Base class for settings groups managing a post-processing + transformer. + + The group is a pure UI widget: it renders the transformer's + parameters from the :class:`OpsTransformer` instance it is given + and announces user changes via the :attr:`param_changed` signal. + It never writes to an editor, history manager, or backing dict — + the host page decides how to persist the announced changes. + + Two enable controls are supported: + + * **Step mode** (default): an enable/disable switch is added as the + first row and the remaining rows are gated by it. + * **Tri-state mode** (``tri_state=True``): a menu button with three + options (unchanged / enabled / disabled) replaces the switch. The + button is exposed as :attr:`tri_state_button` so the host page can + place it as an expander suffix. The new state is announced via the + :attr:`tri_state_changed` signal; the page decides what the states + mean for its backing store. + """ + + #: Tri-state states. + STATE_UNCHANGED = 0 + STATE_ENABLED = 1 + STATE_DISABLED = 2 + + def __init__( + self, + title: str, + transformer: OpsTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + tri_state: bool = False, + initial_state: int | None = None, + **kwargs, + ): + """ + Args: + title: The title for the preferences group. + transformer: The OpsTransformer instance this group configures. + page: The host page. When it uses expanders, rows are only + tracked in :attr:`_rows` for the host to reparent. + step: Optional Step object used as read-only context (e.g. + for auto-distance calculation). ``None`` in recipe + mode. + tri_state: When True, build a tri-state apply button instead + of an enable switch. + initial_state: The initial tri-state (one of the + :attr:`STATE_*` constants). Defaults to enabled/ + disabled based on ``transformer.enabled``. + """ + super().__init__( + title=title, + description=transformer.description, + **kwargs, + ) + self.param_changed = Signal() + self.tri_state_changed = Signal() + self.transformer = transformer + self.page = page + self.step = step + self._rows: list[Gtk.Widget] = [] + self.enable_switch: Adw.SwitchRow | None = None + self.tri_state_button: Gtk.MenuButton | None = None + self._tri_state_label: Gtk.Label | None = None + self._tri_state = self.STATE_UNCHANGED + + if tri_state: + if initial_state is None: + initial_state = ( + self.STATE_ENABLED + if transformer.enabled + else self.STATE_DISABLED + ) + self._add_tri_state(transformer, initial_state) + else: + self._add_enable_switch(transformer) + + def add(self, child: Gtk.Widget) -> None: + self._rows.append(child) + if not self.page.use_expanders: + super().add(child) + control = self.tri_state_button or self.enable_switch + if control is not None and child is not control: + child.set_sensitive(self._is_enabled()) + + def _add_enable_switch(self, transformer: OpsTransformer) -> None: + switch_row = Adw.SwitchRow( + title=_("Enable {}").format(transformer.label), + ) + switch_row.set_active(transformer.enabled) + self.add(switch_row) + self.enable_switch = switch_row + switch_row.connect("notify::active", self._on_enable_toggled) + + def _on_enable_toggled( + self, row: Adw.SwitchRow, _pspec: GObject.ParamSpec + ) -> None: + self.param_changed.send( + self, + key="enabled", + value=row.get_active(), + name=_("Toggle {}").format(self.transformer.label), + ) + self._update_sensitivity() + + def _add_tri_state( + self, transformer: OpsTransformer, initial_state: int + ) -> None: + """Build the tri-state apply control.""" + self._tri_state = initial_state + + labels = self._tri_state_labels() + label_widget = Gtk.Label(label=labels[initial_state]) + self._tri_state_label = label_widget + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + button_box.append(label_widget) + button_box.append(get_icon("pan-down-symbolic")) + + menu_button = Gtk.MenuButton() + apply_css(_APPLY_MENU_CSS) + menu_button.add_css_class("flat") + menu_button.add_css_class("recipe-apply-menu") + menu_button.set_child(button_box) + + popover = Gtk.Popover() + list_box = Gtk.ListBox() + list_box.set_selection_mode(Gtk.SelectionMode.NONE) + list_box.add_css_class("popover-list") + popover.set_child(list_box) + menu_button.set_popover(popover) + + for state, label in enumerate(labels): + row = Gtk.ListBoxRow() + row_button = Gtk.Button(label=label) + row_button.set_has_frame(False) + row_button.set_hexpand(True) + row_button.connect( + "clicked", + lambda _b, s=state: ( + self._on_tri_state_selected(s), + popover.popdown(), + ), + ) + row.set_child(row_button) + list_box.append(row) + + self.tri_state_button = menu_button + + @staticmethod + def _tri_state_labels() -> tuple[str, str, str]: + return (_("Leave Unchanged"), _("Enabled"), _("Disabled")) + + def _on_tri_state_selected(self, state: int) -> None: + """Apply a tri-state selection and announce the change.""" + self._tri_state = state + if self._tri_state_label is not None: + self._tri_state_label.set_label(self._tri_state_labels()[state]) + self.tri_state_changed.send(self, state=state) + self._update_sensitivity() + + def get_tri_state(self) -> int: + """The current tri-state (one of the :attr:`STATE_*` constants).""" + return self._tri_state + + def _is_enabled(self) -> bool: + """Whether the enable control currently enables the transformer. + + In tri-state mode only the ``STATE_ENABLED`` state counts as + enabled; the other states gate the rows off. + """ + if self.tri_state_button is not None: + return self._tri_state == self.STATE_ENABLED + assert self.enable_switch is not None + return self.enable_switch.get_active() + + def _update_sensitivity(self) -> None: + enabled = self._is_enabled() + for row in self._rows: + if row is not self.enable_switch: + row.set_sensitive(enabled) + + def is_unsupported(self) -> bool: + """ + Whether this transformer is enabled but cannot take effect on + the active machine (e.g. the driver handles the feature + itself). + + Subclasses override this to flag expander-level warnings. Returns + False by default. + """ + return False diff --git a/rayforge/ui_gtk/doceditor/post_processor/registry.py b/rayforge/ui_gtk/doceditor/post_processor/registry.py new file mode 100644 index 000000000..e746d72e2 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/post_processor/registry.py @@ -0,0 +1,87 @@ +"""Registry mapping transformer classes to their settings widget classes.""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rayforge.pipeline.transformer.base import OpsTransformer + +logger = logging.getLogger(__name__) + + +class TransformerWidgetRegistry: + """ + Registry for post-processor transformer settings widget classes. + + Maps an :class:`OpsTransformer` subclass to the + :class:`TransformerSettingsGroup` subclass that renders its + settings UI. Addons register their widgets at module-import time; + pages look up widget classes directly from the singleton — no + pluggy hook call needed. + + Satisfies the :class:`~rayforge.addon_mgr.addon_manager.AddonRegistry` + protocol for automatic cleanup on addon unload. + """ + + def __init__(self): + self._widgets: dict[type, type] = {} + self._addon_items: dict[str, set[type]] = {} + + def register( + self, + transformer_cls: "type[OpsTransformer]", + widget_cls: type, + addon_name: str | None = None, + ) -> None: + """ + Register a widget class for a transformer type. + + Args: + transformer_cls: The OpsTransformer subclass. + widget_cls: The TransformerSettingsGroup subclass that + renders its settings. + addon_name: Optional name of the addon registering this + widget. Used for cleanup when the addon is unloaded. + """ + self._widgets[transformer_cls] = widget_cls + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(transformer_cls) + logger.debug( + "Registered widget %s for transformer %s", + widget_cls.__name__, + transformer_cls.__name__, + ) + + def get(self, transformer_cls: type) -> type | None: + """ + Look up the widget class for a transformer type. + + Returns: + The widget class, or None if no widget is registered. + """ + return self._widgets.get(transformer_cls) + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all widgets registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of widgets unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for cls in items: + if cls in self._widgets: + del self._widgets[cls] + count += 1 + return count + + +transformer_widget_registry = TransformerWidgetRegistry() diff --git a/rayforge/ui_gtk/doceditor/property_providers/__init__.py b/rayforge/ui_gtk/doceditor/property_providers/__init__.py new file mode 100644 index 000000000..6d5113873 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/property_providers/__init__.py @@ -0,0 +1,20 @@ +from .base import PropertyProvider, property_provider_registry +from .transform import TransformPropertyProvider +from .workpiece import TabsPropertyProvider, WorkpieceInfoProvider + + +def register_builtin_providers(): + """Register all built-in property providers.""" + property_provider_registry.register(TransformPropertyProvider, "") + property_provider_registry.register(WorkpieceInfoProvider, "") + property_provider_registry.register(TabsPropertyProvider, "") + + +__all__ = [ + "PropertyProvider", + "TabsPropertyProvider", + "TransformPropertyProvider", + "WorkpieceInfoProvider", + "property_provider_registry", + "register_builtin_providers", +] diff --git a/rayforge/ui_gtk/doceditor/property_providers/base.py b/rayforge/ui_gtk/doceditor/property_providers/base.py new file mode 100644 index 000000000..18e49c724 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/property_providers/base.py @@ -0,0 +1,156 @@ +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from gi.repository import Gtk + +from ....core.item import DocItem + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class PropertyProviderRegistry: + """ + Registry for property provider classes. + + Allows registration of providers that can create UI widgets for + specific types of document items. Providers are sorted by priority + (lower priority appears first in UI). + """ + + def __init__(self): + self._providers: list[type[PropertyProvider]] = [] + self._addon_map: dict[type[PropertyProvider], str] = {} + + def register( + self, provider_cls: type["PropertyProvider"], addon_name: str + ) -> None: + """ + Register a property provider class. + + Providers are sorted by priority when instances are created. + + Args: + provider_cls: The provider class to register + addon_name: Optional addon name for cleanup during unload + """ + if provider_cls not in self._providers: + self._providers.append(provider_cls) + if addon_name: + self._addon_map[provider_cls] = addon_name + logger.debug( + f"Registered property provider: {provider_cls.__name__}" + ) + + def unregister(self, provider_cls: type["PropertyProvider"]) -> bool: + """ + Unregister a property provider class. + + Returns True if the provider was found and removed. + """ + if provider_cls in self._providers: + self._providers.remove(provider_cls) + self._addon_map.pop(provider_cls, None) + return True + return False + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all providers registered by a specific addon. + + Args: + addon_name: The name of the addon to clean up + + Returns: + The number of providers unregistered + """ + to_remove = [ + cls for cls, name in self._addon_map.items() if name == addon_name + ] + for cls in to_remove: + self._providers.remove(cls) + del self._addon_map[cls] + if to_remove: + logger.debug( + f"Unregistered {len(to_remove)} property providers " + f"from addon '{addon_name}'" + ) + return len(to_remove) + + def create_instances(self) -> list["PropertyProvider"]: + """ + Create instances of all registered providers, sorted by priority. + + Lower priority values appear first in the UI. + + Returns a list of provider instances ready for use. + """ + sorted_providers = sorted( + self._providers, + key=lambda cls: getattr(cls, "priority", 100), + ) + return [cls() for cls in sorted_providers] + + def all(self) -> list[type["PropertyProvider"]]: + """Return all registered provider classes.""" + return self._providers.copy() + + +property_provider_registry = PropertyProviderRegistry() + + +class PropertyProvider(ABC): + """ + Defines the contract for a component that can provide UI for a specific + aspect of one or more DocItems. + """ + + priority: int = 100 + """Lower priority values appear first in the UI.""" + + separate_group: bool = False + """If True, this provider's widgets get their own Expander card.""" + + group_title: str = "" + """Title for the separate Expander card (used when separate_group=True).""" + + group_subtitle: str = "" + """Subtitle for the separate Expander, updated by update_widgets().""" + + def __init__(self): + self.editor: DocEditor + self.items: list[DocItem] = [] + self._in_update: bool = False + # A list of all widgets created by this provider to manage them + self._rows: list[Gtk.Widget] = [] + logger.debug( + f"PropertyProvider '{self.__class__.__name__}' initialized." + ) + + @abstractmethod + def can_handle(self, items: list[DocItem]) -> bool: + """ + Returns True if this provider is applicable to the given selection + of items. + """ + ... + + @abstractmethod + def create_widgets(self) -> list[Gtk.Widget]: + """ + Creates the necessary Gtk.Widget instances for this provider. + This method is called only once. The created widgets should be + stored as instance members for later access by `update_widgets`. + """ + ... + + @abstractmethod + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + """ + Updates the state of the widgets created by `create_widgets` to + reflect the properties of the currently selected items. + """ + ... diff --git a/rayforge/ui_gtk/doceditor/property_providers/transform.py b/rayforge/ui_gtk/doceditor/property_providers/transform.py new file mode 100644 index 000000000..f4b460d25 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/property_providers/transform.py @@ -0,0 +1,688 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ....context import get_context +from ....core.group import Group +from ....core.item import DocItem +from ....core.stock import StockItem +from ....core.workpiece import WorkPiece +from ....doceditor.transform_cmd import TransformCmd +from ....shared.units.formatter import format_value +from ...icons import get_icon +from ...shared.pref_rows.angle_spin_row import AngleSpinRow +from ...shared.pref_rows.base import SpinRow +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from .base import PropertyProvider + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +default_dim = 100, 100 +logger = logging.getLogger(__name__) + + +class TransformPropertyProvider(PropertyProvider): + """Provides UI for common transformation properties (pos, size, angle).""" + + priority = 10 + + def can_handle(self, items: list[DocItem]) -> bool: + return bool(items) + + def create_widgets(self) -> list[Gtk.Widget]: + """Creates the widgets for transform properties once.""" + logger.debug("Creating transform property widgets.") + self._rows = [] + self._create_position_rows() + self._create_size_rows() + self._create_angle_shear_rows() + return self._rows + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + """Updates the transform widgets with data from the selected items.""" + logger.debug(f"Updating transform widgets for {len(items)} items.") + self.editor = editor + self.items = items + + is_multi = len(items) > 1 + machine = get_context().machine + + # Calculate X/Y position in REFERENCE coordinates. For a + # multi-selection, this is the position of the combined bounding + # box (the same corner the machine origin refers to), so the spin + # rows show where the group as a whole sits. + if machine: + panel = machine.panel + + if is_multi: + pos_machine = TransformCmd.get_position_group(items) + else: + pos_machine = panel.world_item_to_machine( + items[0].pos, items[0].size + ) + + if pos_machine is not None: + pos_machine_x, pos_machine_y = pos_machine + + # Get reference offset + # - WCS mode: offset is in MACHINE space, use directly + # - Workarea mode: offset is in WORLD space, transform to + # MACHINE + if machine.wcs_origin_is_workarea_origin: + offset_world = machine.get_reference_offset() + offset_machine = panel.world_point_to_machine( + offset_world[0], offset_world[1] + ) + else: + offset_machine = machine.get_reference_offset()[:2] + + pos_ref_x = pos_machine_x - offset_machine[0] + pos_ref_y = pos_machine_y - offset_machine[1] + + # Update subtitles to indicate the coordinate system. The + # unit is shown in its own label, not in the subtitle. + wcs_name = machine.active_wcs + fmt = _("Relative to {wcs} origin").format(wcs=wcs_name) + self.x_row.set_subtitle(fmt) + self.y_row.set_subtitle(fmt) + else: + pos_ref_x, pos_ref_y = 0.0, 0.0 + else: + # Fallback if no machine + if is_multi: + ref = TransformCmd.get_position_group(items) + pos_ref_x, pos_ref_y = ref if ref is not None else (0.0, 0.0) + else: + pos_ref_x, pos_ref_y = items[0].pos + self.x_row.set_subtitle("") + self.y_row.set_subtitle("") + + # ── Size (group bbox for multi, per-item for single) ── + if is_multi: + size_world = TransformCmd.get_size_group(items) or (0.0, 0.0) + else: + size_world = items[0].size + + # ── Angle / Shear (anchor represents group) ── + if is_multi: + angle_local = TransformCmd.get_angle_group(items) or 0.0 + shear_local = TransformCmd.get_shear_group(items) or 0.0 + else: + angle_local = items[0].angle + shear_local = items[0].shear + + # Use a safe re-entrant pattern for updating widgets + was_in_update = getattr(self, "_in_update", False) + self._in_update = True + try: + # Use a fixed rounding precision in base units (mm) to + # prevent float noise. The helpers handle display-side + # rounding via the unit's precision. + rnd = 4 + + width_rounded = round(size_world[0], rnd) + height_rounded = round(size_world[1], rnd) + x_rounded = round(pos_ref_x, rnd) + y_rounded = round(pos_ref_y, rnd) + angle_rounded = round(-angle_local, self.angle_row.get_digits()) + shear_rounded = round(shear_local, self.shear_row.get_digits()) + + # Check before setting to avoid signal emission loop + if ( + abs(self.width_row.get_value_in_base_units() - width_rounded) + > 1e-9 + ): + logger.debug(f"Setting width UI to {width_rounded}") + self.width_row.set_value_in_base_units(width_rounded) + if ( + abs(self.height_row.get_value_in_base_units() - height_rounded) + > 1e-9 + ): + logger.debug(f"Setting height UI to {height_rounded}") + self.height_row.set_value_in_base_units(height_rounded) + + if abs(self.x_row.get_value_in_base_units() - x_rounded) > 1e-9: + logger.debug(f"Setting X UI to {x_rounded}") + self.x_row.set_value_in_base_units(x_rounded) + + if abs(self.y_row.get_value_in_base_units() - y_rounded) > 1e-9: + logger.debug(f"Setting Y UI to {y_rounded}") + self.y_row.set_value_in_base_units(y_rounded) + + if abs(self.angle_row.get_value() - angle_rounded) > 1e-9: + logger.debug(f"Setting angle UI to {angle_rounded}") + self.angle_row.set_value(angle_rounded) + if abs(self.shear_row.get_value() - shear_rounded) > 1e-9: + logger.debug(f"Setting shear UI to {shear_rounded}") + self.shear_row.set_value(shear_rounded) + finally: + self._in_update = was_in_update + + self._update_row_visibility_and_details() + + def _create_position_rows(self): + # X Position Entry + self.x_row = LengthSpinRow( + _("X Position"), + _("Zero is on the left side"), + lower=-10000, + upper=10000, + ) + self.x_row.value_changed.connect(self._on_x_changed) + + # X Reset Button + self.reset_x_button = self._create_reset_button( + _("Reset X position to 0"), self._on_reset_x_clicked + ) + self.x_row.add_suffix(self.reset_x_button) + + # Y Position Entry + self.y_row = LengthSpinRow( + _("Y Position"), + lower=-10000, + upper=10000, + ) + self.y_row.value_changed.connect(self._on_y_changed) + + self.reset_y_button = self._create_reset_button( + _("Reset Y position to 0"), self._on_reset_y_clicked + ) + self.y_row.add_suffix(self.reset_y_button) + + self._rows.extend([self.x_row, self.y_row]) + + def _create_size_rows(self): + # Fixed Ratio Switch + self.fixed_ratio_switch = Adw.SwitchRow( + title=_("Fixed Ratio"), active=True + ) + self.fixed_ratio_switch.connect( + "notify::active", self._on_fixed_ratio_toggled + ) + + # Width Entry + self.width_row = LengthSpinRow( + _("Width"), + lower=1, + upper=10000, + ) + self.width_row.value_changed.connect(self._on_width_changed) + + # Height Entry + self.height_row = LengthSpinRow( + _("Height"), + lower=1, + upper=10000, + ) + self.height_row.value_changed.connect(self._on_height_changed) + + # Reset Buttons + self.reset_width_button = self._create_reset_button( + _("Reset to natural width"), + lambda btn: self._on_reset_dimension_clicked(btn, "width"), + ) + self.width_row.add_suffix(self.reset_width_button) + + self.reset_height_button = self._create_reset_button( + _("Reset to natural height"), + lambda btn: self._on_reset_dimension_clicked(btn, "height"), + ) + self.height_row.add_suffix(self.reset_height_button) + + self.reset_aspect_button = self._create_reset_button( + _("Reset to natural aspect ratio"), self._on_reset_aspect_clicked + ) + self.fixed_ratio_switch.add_suffix(self.reset_aspect_button) + + self._rows.extend( + [self.fixed_ratio_switch, self.width_row, self.height_row] + ) + + def _create_angle_shear_rows(self): + # Angle Entry + self.angle_row = AngleSpinRow( + _("Angle"), + _("Clockwise is positive"), + digits=2, + ) + self.angle_row.value_changed.connect(self._on_angle_changed) + + # Shear Entry + self.shear_row = SpinRow( + _("Shear"), + _("Horizontal shear angle"), + lower=-85, + upper=85, + digits=2, + ) + self.shear_row.value_changed.connect(self._on_shear_changed) + + # Reset Buttons + self.reset_angle_button = self._create_reset_button( + _("Reset angle to 0°"), self._on_reset_angle_clicked + ) + self.angle_row.add_suffix(self.reset_angle_button) + + self.reset_shear_button = self._create_reset_button( + _("Reset shear to 0°"), self._on_reset_shear_clicked + ) + self.shear_row.add_suffix(self.reset_shear_button) + + self._rows.extend([self.angle_row, self.shear_row]) + + def _update_row_visibility_and_details(self): + item = self.items[0] + size_capable = (WorkPiece, StockItem, Group) + all_have_size = bool(self.items) and all( + isinstance(i, size_capable) for i in self.items + ) + is_single_item_with_size = len(self.items) == 1 and all_have_size + + self.fixed_ratio_switch.set_sensitive(all_have_size) + self.reset_width_button.set_sensitive(is_single_item_with_size) + self.reset_height_button.set_sensitive(is_single_item_with_size) + self.reset_aspect_button.set_sensitive(is_single_item_with_size) + self.shear_row.set_visible(not isinstance(item, Group)) + + if is_single_item_with_size: + natural_width, natural_height = None, None + + if isinstance(item, (WorkPiece, StockItem)): + machine = get_context().machine + if machine: + __, __, wa_w, wa_h = machine.work_area + bounds = (wa_w, wa_h) + else: + bounds = default_dim + natural_width, natural_height = item.get_default_size(*bounds) + elif item.natural_size: + natural_width, natural_height = item.natural_size + + if natural_width is not None and natural_height is not None: + self.width_row.set_subtitle( + _("Natural: {val}").format( + val=format_value(natural_width, "length") + ) + ) + self.height_row.set_subtitle( + _("Natural: {val}").format( + val=format_value(natural_height, "length") + ) + ) + else: + self.width_row.set_subtitle("") + self.height_row.set_subtitle("") + else: + self.width_row.set_subtitle("") + self.height_row.set_subtitle("") + + def _create_reset_button(self, tooltip_text, on_clicked): + icon = get_icon("undo-symbolic") + button = Gtk.Button() + button.set_child(icon) + button.set_valign(Gtk.Align.CENTER) + button.set_tooltip_text(tooltip_text) + button.connect("clicked", on_clicked) + return button + + def _on_width_changed(self, _row): + logger.debug(f"_on_width_changed called. _in_update={self._in_update}") + if self._in_update or not self.items: + return + self._in_update = True + try: + new_width_from_ui = self.width_row.get_value_in_base_units() + if new_width_from_ui is None: + logger.debug("Width change ignored, no value from UI.") + return + + logger.debug(f"Handling width change to {new_width_from_ui}") + + if len(self.items) > 1: + self.editor.transform.set_size_group( + self.items, + width=new_width_from_ui, + height=None, + fixed_ratio=self.fixed_ratio_switch.get_active(), + ) + else: + self.editor.transform.set_size( + items=self.items, + width=new_width_from_ui, + height=None, + fixed_ratio=self.fixed_ratio_switch.get_active(), + ) + finally: + self._in_update = False + logger.debug("_on_width_changed finished.") + + def _on_height_changed(self, _row): + logger.debug( + f"_on_height_changed called. _in_update={self._in_update}" + ) + if self._in_update or not self.items: + return + self._in_update = True + try: + new_height_from_ui = self.height_row.get_value_in_base_units() + if new_height_from_ui is None: + logger.debug("Height change ignored, no value from UI.") + return + + logger.debug(f"Handling height change to {new_height_from_ui}") + + if len(self.items) > 1: + self.editor.transform.set_size_group( + self.items, + height=new_height_from_ui, + fixed_ratio=self.fixed_ratio_switch.get_active(), + ) + else: + self.editor.transform.set_size( + items=self.items, + height=new_height_from_ui, + fixed_ratio=self.fixed_ratio_switch.get_active(), + ) + finally: + self._in_update = False + logger.debug("_on_height_changed finished.") + + def _on_x_changed(self, _row): + logger.debug(f"_on_x_changed called. _in_update={self._in_update}") + if self._in_update or not self.items: + return + self._in_update = True + try: + new_x_wcs = self.x_row.get_value_in_base_units() + if new_x_wcs is None: + logger.debug("X change ignored, no value from UI.") + return + + # Convert reference coordinates to machine coordinates + machine = get_context().machine + if machine: + if machine.wcs_origin_is_workarea_origin: + # Workarea mode: offset is in WORLD space, transform it + offset_world = machine.get_reference_offset() + offset_machine = machine.panel.world_point_to_machine( + offset_world[0], offset_world[1] + ) + else: + # WCS mode: offset is already in MACHINE space + offset_machine = machine.get_reference_offset()[:2] + else: + offset_machine = (0.0, 0.0) + new_x_machine = new_x_wcs + offset_machine[0] + current_y_wcs = self.y_row.get_value_in_base_units() + current_y_machine = current_y_wcs + offset_machine[1] + + logger.debug(f"Handling X change to {new_x_machine} (machine)") + if len(self.items) > 1: + self.editor.transform.set_position_group( + self.items, new_x_machine, current_y_machine + ) + else: + self.editor.transform.set_position( + self.items, new_x_machine, current_y_machine + ) + finally: + self._in_update = False + logger.debug("_on_x_changed finished.") + + def _on_y_changed(self, _row): + logger.debug(f"_on_y_changed called. _in_update={self._in_update}") + if self._in_update or not self.items: + return + self._in_update = True + try: + new_y_wcs = self.y_row.get_value_in_base_units() + if new_y_wcs is None: + logger.debug("Y change ignored, no value from UI.") + return + + # Convert reference coordinates to machine coordinates + machine = get_context().machine + if machine: + if machine.wcs_origin_is_workarea_origin: + # Workarea mode: offset is in WORLD space, transform it + offset_world = machine.get_reference_offset() + offset_machine = machine.panel.world_point_to_machine( + offset_world[0], offset_world[1] + ) + else: + # WCS mode: offset is already in MACHINE space + offset_machine = machine.get_reference_offset()[:2] + else: + offset_machine = (0.0, 0.0) + new_y_machine = new_y_wcs + offset_machine[1] + current_x_wcs = self.x_row.get_value_in_base_units() + current_x_machine = current_x_wcs + offset_machine[0] + + logger.debug(f"Handling Y change to {new_y_machine} (machine)") + if len(self.items) > 1: + self.editor.transform.set_position_group( + self.items, current_x_machine, new_y_machine + ) + else: + self.editor.transform.set_position( + self.items, current_x_machine, new_y_machine + ) + finally: + self._in_update = False + logger.debug("_on_y_changed finished.") + + def _on_angle_changed(self, spin_row): + logger.debug(f"_on_angle_changed called. _in_update={self._in_update}") + if self._in_update or not self.items: + return + self._in_update = True + try: + new_angle_from_ui = spin_row.get_value() + new_angle = -new_angle_from_ui + logger.debug(f"Handling angle change to {new_angle}") + + if len(self.items) > 1: + self.editor.transform.set_angle_group(self.items, new_angle) + else: + self.editor.transform.set_angle(self.items, new_angle) + finally: + self._in_update = False + logger.debug("_on_angle_changed finished.") + + def _on_shear_changed(self, spin_row): + logger.debug(f"_on_shear_changed called. _in_update={self._in_update}") + if self._in_update or not self.items: + return + self._in_update = True + try: + new_shear_from_ui = spin_row.get_value() + logger.debug(f"Handling shear change to {new_shear_from_ui}") + + if len(self.items) > 1: + self.editor.transform.set_shear_group( + self.items, new_shear_from_ui + ) + else: + self.editor.transform.set_shear(self.items, new_shear_from_ui) + finally: + self._in_update = False + logger.debug("_on_shear_changed finished.") + + def _on_fixed_ratio_toggled(self, switch_row, GParamSpec): + is_ratio_lockable = self.items and isinstance( + self.items[0], (WorkPiece, StockItem, Group) + ) + if not is_ratio_lockable: + switch_row.set_sensitive(False) + else: + switch_row.set_sensitive(True) + + def _on_reset_aspect_clicked(self, button): + if not self.items: + return + + # Simple logic: reset height based on current width and natural aspect + items_to_resize = [] + sizes_to_set = [] + + for item in self.items: + if not isinstance(item, (WorkPiece, StockItem, Group)): + continue + + current_width = item.size[0] + + default_aspect = None + if isinstance(item, (WorkPiece, StockItem)): + default_aspect = item.get_natural_aspect_ratio() + elif item.natural_size: + nw, nh = item.natural_size + default_aspect = nw / nh if nh > 0 else None + + if default_aspect and default_aspect > 0: + new_height = current_width / default_aspect + items_to_resize.append(item) + sizes_to_set.append((current_width, new_height)) + + if items_to_resize: + self.editor.transform.set_size( + items=items_to_resize, + sizes=sizes_to_set, + ) + + def _on_reset_dimension_clicked(self, button, dimension_to_reset: str): + if not self.items: + return + + items_to_resize = [] + sizes_to_set = [] + + for item in self.items: + if not isinstance(item, (WorkPiece, StockItem, Group)): + continue + + natural_width, natural_height = item.natural_size + current_width, current_height = item.size + + new_width = current_width + new_height = current_height + + if dimension_to_reset == "width": + new_width = natural_width + if self.fixed_ratio_switch.get_active(): + # Recalculate height to match new width + current aspect + current_aspect = item.get_current_aspect_ratio() + if current_aspect: + new_height = new_width / current_aspect + else: + new_height = natural_height + if self.fixed_ratio_switch.get_active(): + current_aspect = item.get_current_aspect_ratio() + if current_aspect: + new_width = new_height * current_aspect + + if (new_width, new_height) != item.size: + items_to_resize.append(item) + sizes_to_set.append((new_width, new_height)) + + if items_to_resize: + self.editor.transform.set_size( + items=items_to_resize, + sizes=sizes_to_set, + ) + + def _on_reset_angle_clicked(self, button): + if not self.items: + return + if len(self.items) > 1: + self.editor.transform.reset_angle_group(self.items) + else: + items_to_reset = [item for item in self.items if item.angle != 0.0] + if items_to_reset: + self.editor.transform.set_angle(items_to_reset, 0.0) + + def _on_reset_shear_clicked(self, button): + if not self.items: + return + if len(self.items) > 1: + self.editor.transform.reset_shear_group(self.items) + else: + items_to_reset = [item for item in self.items if item.shear != 0.0] + if items_to_reset: + self.editor.transform.set_shear(items_to_reset, 0.0) + + def _on_reset_x_clicked(self, button): + if not self.items: + return + + # Reset to coordinate origin (X=0 in reference coordinates) + machine = get_context().machine + if machine: + if machine.wcs_origin_is_workarea_origin: + # Workarea mode: offset is in WORLD space, transform it + offset_world = machine.get_reference_offset() + offset_machine = machine.panel.world_point_to_machine( + offset_world[0], offset_world[1] + ) + else: + # WCS mode: offset is already in MACHINE space + offset_machine = machine.get_reference_offset()[:2] + else: + offset_machine = (0.0, 0.0) + + if len(self.items) > 1: + # Keep current Y, reset X to the reference origin + cur_y_wcs = self.y_row.get_value_in_base_units() + cur_y_machine = cur_y_wcs + offset_machine[1] + self.editor.transform.set_position_group( + self.items, offset_machine[0], cur_y_machine + ) + else: + target_x_machine = offset_machine[0] + + # Get current Y in reference coordinates and convert to machine + current_y_wcs = self.y_row.get_value_in_base_units() + current_y_machine = current_y_wcs + offset_machine[1] + + self.editor.transform.set_position( + self.items, target_x_machine, current_y_machine + ) + + def _on_reset_y_clicked(self, button): + if not self.items: + return + + # Reset to coordinate origin (Y=0 in reference coordinates) + machine = get_context().machine + if machine: + if machine.wcs_origin_is_workarea_origin: + # Workarea mode: offset is in WORLD space, transform it + offset_world = machine.get_reference_offset() + offset_machine = machine.panel.world_point_to_machine( + offset_world[0], offset_world[1] + ) + else: + # WCS mode: offset is already in MACHINE space + offset_machine = machine.get_reference_offset()[:2] + else: + offset_machine = (0.0, 0.0) + + if len(self.items) > 1: + # Keep current X, reset Y to the reference origin + cur_x_wcs = self.x_row.get_value_in_base_units() + cur_x_machine = cur_x_wcs + offset_machine[0] + self.editor.transform.set_position_group( + self.items, cur_x_machine, offset_machine[1] + ) + else: + target_y_machine = offset_machine[1] + + # Get current X in reference coordinates and convert to machine + current_x_wcs = self.x_row.get_value_in_base_units() + current_x_machine = current_x_wcs + offset_machine[0] + + self.editor.transform.set_position( + self.items, current_x_machine, target_y_machine + ) diff --git a/rayforge/ui_gtk/doceditor/property_providers/workpiece.py b/rayforge/ui_gtk/doceditor/property_providers/workpiece.py new file mode 100644 index 000000000..7f87f38d8 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/property_providers/workpiece.py @@ -0,0 +1,254 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from gi.repository import Adw, Gio, GLib, Gtk + +from ....core.item import DocItem +from ....core.workpiece import WorkPiece +from ...icons import get_icon +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from ..image_metadata_dialog import ImageMetadataDialog +from .base import PropertyProvider + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class WorkpieceInfoProvider(PropertyProvider): + """Provides UI for Workpiece-specific info (source file, metadata).""" + + priority = 20 + + def can_handle(self, items: list[DocItem]) -> bool: + return len(items) == 1 and isinstance(items[0], WorkPiece) + + def create_widgets(self) -> list[Gtk.Widget]: + """Creates the widgets for workpiece info properties.""" + logger.debug("Creating workpiece info property widgets.") + # Source File Row + self.source_file_row = Adw.ActionRow(title=_("Source File")) + self.metadata_info_button = Gtk.Button( + child=get_icon("info-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Show Image Metadata"), + ) + self.metadata_info_button.connect( + "clicked", self._on_metadata_info_clicked + ) + self.source_file_row.add_suffix(self.metadata_info_button) + + self.open_source_button = Gtk.Button( + child=get_icon("open-in-new-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Show in File Browser"), + ) + self.open_source_button.connect( + "clicked", self._on_open_source_file_clicked + ) + self.source_file_row.add_suffix(self.open_source_button) + + # Vector count row + self.vector_count_row = Adw.ActionRow(title=_("Vector Commands")) + + return [self.source_file_row, self.vector_count_row] + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + """Updates the workpiece info widgets with new data.""" + logger.debug( + f"Updating workpiece info widgets for {len(items)} items." + ) + self.editor = editor + self.items = items + workpiece = cast(WorkPiece, self.items[0]) + + self._update_source_file_row(workpiece) + + is_debug_and_has_vectors = ( + logging.getLogger().getEffectiveLevel() == logging.DEBUG + and workpiece.boundaries is not None + ) + self.vector_count_row.set_visible(is_debug_and_has_vectors) + if is_debug_and_has_vectors: + vectors = len(workpiece.boundaries) if workpiece.boundaries else 0 + self.vector_count_row.set_subtitle( + _("{count} commands").format(count=vectors) + ) + + def _update_source_file_row(self, workpiece: WorkPiece): + file_path = workpiece.source_file + if file_path: + if file_path.is_file(): + self.source_file_row.set_subtitle(file_path.name) + self.open_source_button.set_sensitive(True) + source = workpiece.source + has_metadata = bool( + source and source.metadata and len(source.metadata) > 0 + ) + self.metadata_info_button.set_sensitive(has_metadata) + else: + self.source_file_row.set_subtitle( + _("{name} (not found)").format(name=file_path.name) + ) + self.open_source_button.set_sensitive(False) + self.metadata_info_button.set_sensitive(False) + else: + self.source_file_row.set_subtitle(_("(No source file)")) + self.open_source_button.set_sensitive(False) + self.metadata_info_button.set_sensitive(False) + + def _on_open_source_file_clicked(self, button): + workpiece = cast(WorkPiece, self.items[0]) + file_path = workpiece.source_file + if file_path and file_path.is_file(): + try: + gio_file = Gio.File.new_for_path(str(file_path.resolve())) + launcher = Gtk.FileLauncher.new(gio_file) + window = cast( + Gtk.Window, self.source_file_row.get_ancestor(Gtk.Window) + ) + launcher.open_containing_folder(window, None, None) + except GLib.Error as e: + logger.error(f"Failed to show file in browser: {e}") + + def _on_metadata_info_clicked(self, button): + workpiece = cast(WorkPiece, self.items[0]) + source = workpiece.source + if not source or not source.metadata: + return + + root = self.source_file_row.get_root() + dialog = ImageMetadataDialog( + parent=root if isinstance(root, Gtk.Window) else None + ) + dialog.set_metadata(source) + dialog.present() + + +class TabsPropertyProvider(PropertyProvider): + """Provides UI for managing tabs on a Workpiece.""" + + priority = 30 + + def can_handle(self, items: list[DocItem]) -> bool: + return ( + len(items) == 1 + and isinstance(items[0], WorkPiece) + and items[0].boundaries is not None + ) + + def create_widgets(self) -> list[Gtk.Widget]: + """Creates the widgets for tab properties.""" + logger.debug("Creating tabs property widgets.") + self._rows = [] + + # Tabs Switch + self.tabs_row = Adw.SwitchRow(title=_("Tabs")) + self.tabs_row.connect("notify::active", self._on_tabs_enabled_toggled) + + self.clear_tabs_button = Gtk.Button( + child=get_icon("clear-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Remove all tabs"), + ) + self.clear_tabs_button.connect("clicked", self._on_clear_tabs_clicked) + self.tabs_row.add_suffix(self.clear_tabs_button) + self._rows.append(self.tabs_row) + + # Tab Width Entry + self.tab_width_row = LengthSpinRow( + _("Tab Width"), + _("Length along the path"), + lower=0.1, + upper=100.0, + value_in_base=1.0, + ) + self.tab_width_row.value_changed.connect(self._on_tab_width_changed) + self.reset_tab_width_button = Gtk.Button( + child=get_icon("undo-symbolic") + ) + self.reset_tab_width_button.set_valign(Gtk.Align.CENTER) + self.reset_tab_width_button.set_tooltip_text( + _("Reset tab width to default (1.0)") + ) + self.reset_tab_width_button.connect( + "clicked", self._on_reset_tab_width_clicked + ) + self.tab_width_row.add_suffix(self.reset_tab_width_button) + self._rows.append(self.tab_width_row) + + return self._rows + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + """Updates the tabs widgets with new data.""" + logger.debug(f"Updating tabs property widgets for {len(items)} items.") + self.editor = editor + self.items = items + workpiece = cast(WorkPiece, self.items[0]) + self._update_tabs_rows(workpiece) + + def _update_tabs_rows(self, workpiece: WorkPiece): + self._in_update = True + try: + self.tabs_row.set_active(workpiece.tabs_enabled) + finally: + self._in_update = False + + self.tab_width_row.set_visible(workpiece.tabs_enabled) + self.clear_tabs_button.set_sensitive(bool(workpiece.tabs)) + self.tabs_row.set_subtitle( + _("{num_tabs} tabs").format(num_tabs=len(workpiece.tabs)) + ) + + if workpiece.tabs_enabled: + if workpiece.tabs: + first_tab_width = workpiece.tabs[0].width + self.tab_width_row.set_value_in_base_units(first_tab_width) + if not all(t.width == first_tab_width for t in workpiece.tabs): + self.tab_width_row.set_subtitle(_("Mixed values")) + else: + self.tab_width_row.set_subtitle(_("Length along the path")) + self.tab_width_row.set_sensitive(True) + self.reset_tab_width_button.set_sensitive(True) + else: + self.tab_width_row.set_value_in_base_units(1.0) + self.tab_width_row.set_subtitle(_("Length along the path")) + self.tab_width_row.set_sensitive(False) + self.reset_tab_width_button.set_sensitive(False) + else: + self.tab_width_row.set_value_in_base_units(1.0) + self.tab_width_row.set_subtitle(_("Length along the path")) + self.tab_width_row.set_sensitive(False) + self.reset_tab_width_button.set_sensitive(False) + + def _on_clear_tabs_clicked(self, button): + workpiece = cast(WorkPiece, self.items[0]) + self.editor.tab.clear_tabs(workpiece) + + def _on_tabs_enabled_toggled(self, switch, GParamSpec): + logger.debug( + f"_on_tabs_enabled_toggled called. _in_update={self._in_update}" + ) + if self._in_update: + return + workpiece = cast(WorkPiece, self.items[0]) + new_value = switch.get_active() + self.editor.tab.set_workpiece_tabs_enabled(workpiece, new_value) + + def _on_tab_width_changed(self, row): + logger.debug( + f"_on_tab_width_changed called. _in_update={self._in_update}" + ) + if self._in_update: + return + workpiece = cast(WorkPiece, self.items[0]) + new_width = self.tab_width_row.get_value_in_base_units() + if new_width is None or new_width <= 0: + return + self.editor.tab.set_workpiece_tab_width(workpiece, new_width) + + def _on_reset_tab_width_clicked(self, button): + workpiece = cast(WorkPiece, self.items[0]) + self.editor.tab.set_workpiece_tab_width(workpiece, 1.0) diff --git a/rayforge/ui_gtk/doceditor/recipes/__init__.py b/rayforge/ui_gtk/doceditor/recipes/__init__.py new file mode 100644 index 000000000..c57450b5b --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/__init__.py @@ -0,0 +1,11 @@ +"""Recipe editor UI widgets.""" + +from .edit_recipe_dialog import AddEditRecipeDialog +from .recipe_list import RecipeListWidget +from .recipe_selector_dialog import RecipeSelectorDialog + +__all__ = [ + "AddEditRecipeDialog", + "RecipeListWidget", + "RecipeSelectorDialog", +] diff --git a/rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py b/rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py new file mode 100644 index 000000000..625bb5a70 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/edit_recipe_dialog.py @@ -0,0 +1,336 @@ +import logging +from gettext import gettext as _ +from typing import Any + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ....core.recipe import Recipe +from ....core.step import Step +from ....core.step_registry import step_registry +from ....core.varset import VarSet +from ...icons import get_icon +from ...shared.patched_dialog_window import PatchedDialogWindow +from .pages.applicability import RecipeApplicabilityPage +from .pages.general import RecipeGeneralPage +from .pages.post_processing import RecipePostProcessingPage +from .pages.settings import RecipeSettingsPage + +logger = logging.getLogger(__name__) + + +class AddEditRecipeDialog(PatchedDialogWindow): + """A multi-page window for creating or editing a Recipe. + + The dialog is a thin orchestrator over three dedicated page + widgets: :class:`RecipeGeneralPage`, + :class:`RecipeApplicabilityPage`, and one or more + :class:`RecipeSettingsPage` instances (rebuilt whenever the + task/step type selection changes). + """ + + def __init__( + self, parent: Gtk.Window | None, recipe: Recipe | None = None + ): + super().__init__(transient_for=parent, modal=True) + self.response = Signal() + self.recipe = recipe + + is_editing = recipe is not None + title = _("Edit Recipe") if is_editing else _("Add New Recipe") + self.set_title(title) + self.set_default_size(850, 700) + + # Store the intended response ID for the positive action + self._positive_response_id = "save" if is_editing else "add" + + # --- Layout --- + toolbar_view = Adw.ToolbarView() + self.set_content(toolbar_view) + + header_bar = Adw.HeaderBar() + toolbar_view.add_top_bar(header_bar) + + # Cancel Button + cancel_btn = Gtk.Button(label=_("Cancel")) + cancel_btn.connect("clicked", lambda w: self._send_response("cancel")) + header_bar.pack_start(cancel_btn) + + # Save/Add Button + save_label = _("Save") if is_editing else _("Add") + self.save_btn = Gtk.Button(label=save_label) + self.save_btn.add_css_class("suggested-action") + self.save_btn.connect( + "clicked", + lambda w: self._send_response(self._positive_response_id), + ) + header_bar.pack_end(self.save_btn) + + # View Stack + self.view_stack = Adw.ViewStack() + toolbar_view.set_content(self.view_stack) + + # --- Custom Switcher (Icon + Text horizontal) --- + self.switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.switcher_box.add_css_class("linked") + header_bar.set_title_widget(self.switcher_box) + + # Page name -> toggle button (for radio grouping + teardown). + self._tab_buttons: dict[str, Gtk.ToggleButton] = {} + # View-stack name -> settings page (rebuilt dynamically). + self._settings_pages: dict[str, RecipeSettingsPage] = {} + # The post-processing page (rebuilt dynamically). + self._post_processing_page: RecipePostProcessingPage | None = None + # Stable name used for the post-processing view-stack page. + self._pp_tab_name = "post-processing" + + # --- Pages --- + self.general_page = RecipeGeneralPage(recipe) + self._add_page( + self.general_page, "general", _("General"), "settings-symbolic" + ) + self.general_page.name_changed.connect(self._update_save_sensitivity) + self.general_page.submit_requested.connect( + lambda *_: self._send_response(self._positive_response_id) + ) + + self.applicability_page = RecipeApplicabilityPage(recipe) + self._add_page( + self.applicability_page, + "applicability", + _("Applicability"), + "query-symbolic", + ) + self.applicability_page.selection_changed.connect( + self._rebuild_settings + ) + + # --- Initial selection + settings --- + self.applicability_page.restore_selection( + list(recipe.target_step_types) if recipe else [] + ) + self._rebuild_settings() + self._update_save_sensitivity() + + # Default to the General tab. + self._tab_buttons["general"].set_active(True) + + # --- Tab wiring ----------------------------------------------------- + + def _create_tab_child(self, text: str, icon_name: str) -> Gtk.Widget: + """Creates a box with an icon and a label for the toggle button.""" + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.append(get_icon(icon_name)) + box.append(Gtk.Label(label=text)) + return box + + def _add_page( + self, + page: Gtk.Widget, + name: str, + title: str, + icon_name: str, + ): + """Register a page in the view stack with a toggle button. + + The first page registered becomes the radio-group root; every + subsequent button joins its group so the tabs are mutually + exclusive. + """ + group = self._tab_buttons["general"] if self._tab_buttons else None + button = Gtk.ToggleButton(group=group) if group else Gtk.ToggleButton() + button.set_child(self._create_tab_child(title, icon_name)) + button.connect("toggled", self._on_tab_toggled, name) + self.switcher_box.append(button) + self.view_stack.add_named(page, name) + self._tab_buttons[name] = button + + def _on_tab_toggled(self, button, page_name): + if button.get_active(): + self.view_stack.set_visible_child_name(page_name) + + def _send_response(self, response_id: str): + self.response.send(self, response_id=response_id) + + def _update_save_sensitivity(self, *_args): + self.save_btn.set_sensitive(bool(self.general_page.get_name())) + + # --- Settings pages ------------------------------------------------- + + def _current_step_classes(self) -> list[type[Step]]: + """Resolve the step classes for the current step-type selection. + + Returns an empty list for the generic (Any) selection. + """ + step_types = self.applicability_page.get_step_types() + classes = [step_registry.get(name) for name in step_types] + return [c for c in classes if c is not None] + + def _current_settings_groups(self) -> list[tuple[str, VarSet]]: + """Resolve the (title, varset) groups for the current selection. + + With exactly one step type targeted, that step's full groups are + shown. With several, only the settings common to all of them are + offered (:meth:`Step.common_recipe_varset_groups`). With none, + the base ``Step`` groups (universal motion settings) are used. + """ + classes = self._current_step_classes() + + if len(classes) == 1: + return classes[0].recipe_varset_groups() + if classes: + return Step.common_recipe_varset_groups(classes) + return Step.recipe_varset_groups() + + def _current_transformer_dicts(self) -> list[dict[str, Any]]: + """Resolve the transformer dicts for the current selection. + + The common transformers across the selected step types are + overlaid with the recipe's stored values (matched by name). When + the recipe carries a dict for a transformer that is no longer + common, the stored dict is dropped. Dicts for transformers that + appear in the common set but not in the recipe are taken from the + step type defaults. + """ + classes = self._current_step_classes() + common_dicts = Step.common_transformer_dicts(classes) + if not common_dicts: + return [] + + recipe_dicts = self.recipe.transformer_dicts if self.recipe else [] + recipe_by_name = { + d.get("name"): d for d in recipe_dicts if d.get("name") + } + + result: list[dict[str, Any]] = [] + for common_dict in common_dicts: + name = common_dict.get("name") + stored = recipe_by_name.get(name) if name else None + # Keep stored params but use the structural common + # dict as the base to guarantee key consistency. + merged = dict(common_dict) + if stored is not None: + merged.update(stored) + # Every dict carries an explicit apply state; defaults to + # "Leave unchanged". + merged.setdefault("recipe_apply", False) + result.append(merged) + return result + + def _rebuild_settings(self, *_args): + """Rebuild the dynamic settings tabs from the current selection. + + Laser step types split into a "Laser" page (inherited process + settings) and a "Step Settings" page (step-specific attributes). + A capability-only selection yields a single "Settings" page; + "Any"/"Any" yields the base Step settings. + + The post-processing tab is added (or rebuilt) when the current + selection shares common transformers, and torn down otherwise. + """ + groups = self._current_settings_groups() + + # Keep the user on a settings or post-processing page if one was + # visible. + post_processing_was_visible = ( + self._pp_tab_name in self._tab_buttons + and self._tab_buttons[self._pp_tab_name].get_active() + ) + settings_was_visible = not ( + self._tab_buttons["general"].get_active() + or self._tab_buttons["applicability"].get_active() + or post_processing_was_visible + ) + + # Tear down existing settings pages. + for name, page in self._settings_pages.items(): + self.switcher_box.remove(self._tab_buttons[name]) + self.view_stack.remove(page) + self._settings_pages.clear() + # Drop their button entries too. + for name in [ + n for n in list(self._tab_buttons) if n.startswith("settings-") + ]: + del self._tab_buttons[name] + + for index, (group_title, varset) in enumerate(groups): + name = f"settings-{index}" + icon_name = ( + "laser-on-symbolic" + if group_title == _("Laser") + else "step-settings-symbolic" + ) + page = RecipeSettingsPage(group_title) + page.populate(varset) + if self.recipe: + page.set_values(self.recipe.settings) + self._add_page(page, name, group_title, icon_name) + self._settings_pages[name] = page + + # Rebuild the post-processing tab. + self._rebuild_post_processing() + + if settings_was_visible and self._settings_pages: + first_name = next(iter(self._settings_pages)) + self._tab_buttons[first_name].set_active(True) + elif ( + post_processing_was_visible + and self._post_processing_page is not None + ): + self._tab_buttons[self._pp_tab_name].set_active(True) + + def _rebuild_post_processing(self) -> None: + """Build or tear down the post-processing tab from the selection. + + When the selected step types share common transformers, a tab is + added (or rebuilt) with a :class:`RecipePostProcessingPage`. + When there are no common transformers, the existing tab (if any) + is removed. + """ + transformer_dicts = self._current_transformer_dicts() + + # Tear down any existing post-processing page first. + if self._post_processing_page is not None: + self.switcher_box.remove(self._tab_buttons[self._pp_tab_name]) + self.view_stack.remove(self._post_processing_page) + del self._tab_buttons[self._pp_tab_name] + self._post_processing_page = None + + if not transformer_dicts: + return + + page = RecipePostProcessingPage(transformer_dicts) + self._add_page( + page, + self._pp_tab_name, + _("Post Processing"), + "step-settings-symbolic", + ) + self._post_processing_page = page + + # --- Result --------------------------------------------------------- + + def get_recipe_data(self) -> dict[str, Any]: + # Merge values from all settings pages. + settings: dict[str, Any] = {} + for page in self._settings_pages.values(): + settings.update(page.get_values()) + final_settings = {k: v for k, v in settings.items() if v is not None} + + transformer_dicts = ( + self._post_processing_page.get_transformer_dicts() + if self._post_processing_page is not None + else [] + ) + + return { + "name": self.general_page.get_name(), + "description": self.general_page.get_description(), + "target_machine_id": self.applicability_page.get_machine_id(), + "target_step_types": self.applicability_page.get_step_types(), + "material_uid": self.applicability_page.get_material_uid(), + "min_thickness_mm": self.applicability_page.get_min_thickness(), + "max_thickness_mm": self.applicability_page.get_max_thickness(), + "settings": final_settings, + "transformer_dicts": transformer_dicts, + } diff --git a/rayforge/ui_gtk/doceditor/recipes/pages/__init__.py b/rayforge/ui_gtk/doceditor/recipes/pages/__init__.py new file mode 100644 index 000000000..15b5c2ddb --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/pages/__init__.py @@ -0,0 +1,13 @@ +"""Dedicated page widgets for the recipe editor dialog.""" + +from .applicability import RecipeApplicabilityPage +from .general import RecipeGeneralPage +from .post_processing import RecipePostProcessingPage +from .settings import RecipeSettingsPage + +__all__ = [ + "RecipeApplicabilityPage", + "RecipeGeneralPage", + "RecipePostProcessingPage", + "RecipeSettingsPage", +] diff --git a/rayforge/ui_gtk/doceditor/recipes/pages/applicability.py b/rayforge/ui_gtk/doceditor/recipes/pages/applicability.py new file mode 100644 index 000000000..dabdeae49 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/pages/applicability.py @@ -0,0 +1,244 @@ +"""The recipe editor's applicability page: when a recipe matches.""" + +import logging +from gettext import gettext as _ +from typing import Any, cast + +from blinker import Signal +from gi.repository import Adw, Gtk + +from .....context import get_context +from .....core.step_registry import step_registry +from ....icons import get_icon +from ....shared.optional_spin_row import OptionalSpinRowController +from ...material_selector import MaterialSelectorDialog +from ...step_type_selection_dialog import StepTypeSelectionDialog + +logger = logging.getLogger(__name__) + + +class RecipeApplicabilityPage(Adw.PreferencesPage): + """The applicability criteria: when a recipe should be suggested. + + Emits :attr:`selection_changed` whenever the step type selection + changes, so the dialog can rebuild the settings pages. + """ + + def __init__(self, recipe: Any | None = None, **kwargs): + super().__init__(**kwargs) + self.selection_changed = Signal() + + self._recipe = recipe + self._machine_ids: list[str | None] = [None] + self._selected_step_types: list[str] = list( + recipe.target_step_types if recipe else [] + ) + self._selected_material_uid: str | None = ( + recipe.material_uid if recipe else None + ) + + group = Adw.PreferencesGroup( + title=_("Applicability"), + description=_( + "Define when this recipe should be suggested. " + "Leave fields blank to match any value." + ), + ) + self.add(group) + + self._build_machine_row(group) + self._build_step_types_row(group) + self._build_material_row(group) + self._build_thickness_rows(group) + + # --- Builders ------------------------------------------------------- + + def _build_machine_row(self, group): + machine_mgr = get_context().machine_mgr + machine_labels = [_("Any")] + for machine in machine_mgr.get_machines(): + machine_labels.append(machine.name) + self._machine_ids.append(machine.id) + self.machine_row = Adw.ComboRow( + title=_("Machine"), model=Gtk.StringList.new(machine_labels) + ) + group.add(self.machine_row) + + target = self._recipe.target_machine_id if self._recipe else None + if target and target in self._machine_ids: + self.machine_row.set_selected(self._machine_ids.index(target)) + else: + if target: + logger.warning("Recipe machine ID '%s' not found.", target) + self.machine_row.set_selected(0) + + def _build_step_types_row(self, group): + self.step_types_row = Adw.ActionRow( + title=_("Step Types"), + subtitle=_( + "The step types this recipe applies to. Leave empty to " + "match any step type." + ), + activatable=True, + ) + self.step_types_row.connect("activated", self._on_step_types_clicked) + select_btn = Gtk.Button(label=_("Select...")) + select_btn.set_valign(Gtk.Align.CENTER) + select_btn.connect("clicked", self._on_step_types_clicked) + self.step_types_row.add_suffix(select_btn) + clear_btn = Gtk.Button(child=get_icon("clear-symbolic")) + clear_btn.set_valign(Gtk.Align.CENTER) + clear_btn.set_tooltip_text(_("Clear Step Types Selection")) + clear_btn.connect("clicked", self._on_clear_step_types) + self.step_types_row.add_suffix(clear_btn) + group.add(self.step_types_row) + self._update_step_types_display() + + def _build_material_row(self, group): + self.material_row = Adw.ActionRow(title=_("Material")) + select_btn = Gtk.Button(label=_("Select...")) + select_btn.set_valign(Gtk.Align.CENTER) + select_btn.connect("clicked", self._on_select_material) + self.material_row.add_suffix(select_btn) + clear_btn = Gtk.Button(child=get_icon("clear-symbolic")) + clear_btn.set_valign(Gtk.Align.CENTER) + clear_btn.set_tooltip_text(_("Clear Material Selection")) + clear_btn.connect("clicked", self._on_clear_material) + self.material_row.add_suffix(clear_btn) + group.add(self.material_row) + self._update_material_display() + + def _build_thickness_rows(self, group): + self.min_thickness_controller = OptionalSpinRowController( + group, + _("Min Thickness"), + _("Minimum stock thickness for this recipe to apply"), + "length", + ) + self.max_thickness_controller = OptionalSpinRowController( + group, + _("Max Thickness"), + _("Maximum stock thickness for this recipe to apply"), + "length", + ) + if self._recipe: + self.min_thickness_controller.set_value( + self._recipe.min_thickness_mm + ) + self.max_thickness_controller.set_value( + self._recipe.max_thickness_mm + ) + self.min_thickness_controller.changed.connect( + self._on_min_thickness_changed + ) + self.max_thickness_controller.changed.connect( + self._on_max_thickness_changed + ) + + # --- Selection handling -------------------------------------------- + + def _on_step_types_clicked(self, _widget): + root = self.get_root() + parent: Gtk.Window | None = ( + root if isinstance(root, Gtk.Window) else None + ) + dialog = StepTypeSelectionDialog( + parent=cast(Gtk.Window, parent), + selected=set(self._selected_step_types), + on_select_callback=self._on_step_types_selected, + ) + dialog.present() + + def _on_step_types_selected(self, step_types: list[str]): + if step_types == self._selected_step_types: + return + self._selected_step_types = step_types + self._update_step_types_display() + self.selection_changed.send(self) + + def _on_clear_step_types(self, _button): + if not self._selected_step_types: + return + self._selected_step_types = [] + self._update_step_types_display() + self.selection_changed.send(self) + + def restore_selection(self, target_step_types: list[str]): + """Restore the step type selection from a saved recipe.""" + self._selected_step_types = list(target_step_types) + self._update_step_types_display() + + # --- Getters -------------------------------------------------------- + + def get_step_types(self) -> list[str]: + return list(self._selected_step_types) + + def get_machine_id(self) -> str | None: + return self._machine_ids[self.machine_row.get_selected()] + + def get_material_uid(self) -> str | None: + return self._selected_material_uid + + def get_min_thickness(self) -> float | None: + return self.min_thickness_controller.get_value() + + def get_max_thickness(self) -> float | None: + return self.max_thickness_controller.get_value() + + def _update_step_types_display(self): + if not self._selected_step_types: + self.step_types_row.set_subtitle(_("Any")) + return + labels = [] + for name in self._selected_step_types: + step_class = step_registry.get(name) + if step_class is not None: + labels.append(step_class.TYPELABEL) + else: + labels.append(name) + text = ", ".join(labels) + if len(text) > 120: + text = text[:120].rstrip() + _("…") + self.step_types_row.set_subtitle(text) + + # --- Material / thickness handlers --------------------------------- + + def _on_min_thickness_changed(self, controller: OptionalSpinRowController): + min_val = controller.get_spin_value_in_base() + if self.max_thickness_controller.get_spin_value_in_base() < min_val: + self.max_thickness_controller.set_spin_value_in_base(min_val) + + def _on_max_thickness_changed(self, controller: OptionalSpinRowController): + max_val = controller.get_spin_value_in_base() + if self.min_thickness_controller.get_spin_value_in_base() > max_val: + self.min_thickness_controller.set_spin_value_in_base(max_val) + + def _on_select_material(self, _button): + root = self.get_root() + parent: Gtk.Window | None = ( + root if isinstance(root, Gtk.Window) else None + ) + dialog = MaterialSelectorDialog( + parent=cast(Gtk.Window, parent), + on_select_callback=self._on_material_selected, + ) + dialog.present() + + def _on_material_selected(self, material_uid: str): + self._selected_material_uid = material_uid + self._update_material_display() + + def _on_clear_material(self, _button): + self._selected_material_uid = None + self._update_material_display() + + def _update_material_display(self): + if self._selected_material_uid: + material = get_context().material_mgr.get_material( + self._selected_material_uid + ) + self.material_row.set_subtitle( + material.name if material else _("Not Found") + ) + else: + self.material_row.set_subtitle(_("Any")) diff --git a/rayforge/ui_gtk/doceditor/recipes/pages/general.py b/rayforge/ui_gtk/doceditor/recipes/pages/general.py new file mode 100644 index 000000000..a75f097c8 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/pages/general.py @@ -0,0 +1,49 @@ +"""The recipe editor's general page: name and description.""" + +from gettext import gettext as _ +from typing import Any + +from blinker import Signal +from gi.repository import Adw + + +class RecipeGeneralPage(Adw.PreferencesPage): + """The recipe's name and description.""" + + def __init__(self, recipe: Any | None = None, **kwargs): + super().__init__(**kwargs) + self.name_changed = Signal() + self.submit_requested = Signal() + + group = Adw.PreferencesGroup( + title=_("Recipe"), + description=_( + "A named preset of settings that can be " + "automatically applied later." + ), + ) + self.add(group) + + self.name_row = Adw.EntryRow(title=_("Name")) + if recipe: + self.name_row.set_text(recipe.name) + self.name_row.connect("notify::text", self._on_name_changed) + self.name_row.connect("activate", self._on_name_activated) + group.add(self.name_row) + + self.desc_row = Adw.EntryRow(title=_("Description")) + if recipe: + self.desc_row.set_text(recipe.description) + group.add(self.desc_row) + + def _on_name_changed(self, entry_row, _pspec): + self.name_changed.send(self) + + def _on_name_activated(self, _entry_row): + self.submit_requested.send(self) + + def get_name(self) -> str: + return self.name_row.get_text().strip() + + def get_description(self) -> str: + return self.desc_row.get_text().strip() diff --git a/rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py b/rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py new file mode 100644 index 000000000..4c1cd9c5a --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/pages/post_processing.py @@ -0,0 +1,183 @@ +"""Recipe-mode post-processing transformers settings page.""" + +from __future__ import annotations + +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, Gtk + +from .....pipeline.transformer import OpsTransformer +from .....pipeline.transformer.placeholder import PlaceholderTransformer +from ....shared.preferences_page import TrackedPreferencesPage +from ...post_processor.groups import ( + PlaceholderSettingsGroup, + TransformerSettingsGroup, +) +from ...post_processor.registry import transformer_widget_registry + + +class RecipePostProcessingPage(TrackedPreferencesPage): + """A page for editing transformer settings stored on a recipe. + + Unlike the step-mode page this one has no editor or step: it owns + the transformer dicts and mutates them directly when the widgets + announce changes. Each group is built in tri-state mode and wrapped + in an :class:`Adw.ExpanderRow` whose suffix carries the group's + tri-state button: + + - **Leave Unchanged** (``recipe_apply=False``): the recipe will not + touch this transformer when applied. + - **Enabled** (``recipe_apply=True``, ``enabled=True``): the recipe + sets the transformer on and stamps its params. + - **Disabled** (``recipe_apply=True``, ``enabled=False``): the recipe + turns the transformer off. + """ + + use_expanders = True + + def __init__(self, transformer_dicts: list[dict[str, Any]] | None = None): + super().__init__() + self.key = "post-processing" + self.path_prefix = "/recipe/" + + self._main_group = Adw.PreferencesGroup( + title=_("Post Processing"), + description=_( + "Transformer settings applied by this recipe. When multiple " + "step types are selected, only transformers common to " + "all of them are shown." + ), + ) + self.add(self._main_group) + self._group_dicts: dict[TransformerSettingsGroup, dict] = {} + self._has_expanders = False + self.populate(transformer_dicts or []) + + # -- Public API ----------------------------------------------------- + + def get_transformer_dicts(self) -> list[dict[str, Any]]: + """Return the (possibly mutated) transformer dicts.""" + return list(self._group_dicts.values()) + + # -- Construction --------------------------------------------------- + + def populate(self, transformer_dicts: list[dict[str, Any]]) -> None: + """Build groups for the given transformer dicts.""" + # Deduplicate by object identity (same dict can be in both lists) + seen_ids: set[int] = set() + unique_transformer_dicts: list[dict[str, Any]] = [] + for t_dict in transformer_dicts or []: + dict_id = id(t_dict) + if dict_id not in seen_ids: + seen_ids.add(dict_id) + unique_transformer_dicts.append(t_dict) + + for t_dict in unique_transformer_dicts: + transformer = OpsTransformer.from_dict(t_dict) + widget_cls = transformer_widget_registry.get(type(transformer)) + if widget_cls: + group = widget_cls( + transformer.label, + transformer, + self, + tri_state=True, + initial_state=self._initial_state(t_dict), + ) + elif isinstance(transformer, PlaceholderTransformer): + group = PlaceholderSettingsGroup( + transformer.label, + transformer, + self, + tri_state=True, + initial_state=self._initial_state(t_dict), + ) + else: + continue + self._group_dicts[group] = t_dict + self._add_group(group, t_dict) + + if not self._has_expanders: + self._show_empty_state() + + @staticmethod + def _initial_state(t_dict: dict[str, Any]) -> int: + """Map a recipe dict's apply state to a tri-state constant.""" + if t_dict.get("recipe_apply", False): + return ( + TransformerSettingsGroup.STATE_ENABLED + if t_dict.get("enabled", True) + else TransformerSettingsGroup.STATE_DISABLED + ) + return TransformerSettingsGroup.STATE_UNCHANGED + + def _add_group( + self, + group: TransformerSettingsGroup, + t_dict: dict, + ) -> None: + title = group.get_title() + subtitle = group.get_description() + + expander = Adw.ExpanderRow(title=title or "") + if subtitle: + expander.set_subtitle(subtitle) + expander.set_expanded(False) + + for row in group._rows: + expander.add_row(row) + + button = group.tri_state_button + if button is not None: + button.set_valign(Gtk.Align.CENTER) + expander.add_suffix(button) + + group.param_changed.connect(self._on_param_changed) + group.tri_state_changed.connect(self._on_tri_state_changed) + + self._main_group.add(expander) + self._has_expanders = True + + def _show_empty_state(self) -> None: + """Render the empty-state message when no groups were added.""" + placeholder_label = Gtk.Label( + label=_("No post-processing options available for this step."), + halign=Gtk.Align.CENTER, + margin_top=24, + margin_bottom=24, + wrap=True, + ) + placeholder_label.add_css_class("dim-label") + self._main_group.add(placeholder_label) + + # -- Change handlers ------------------------------------------------ + + def _on_param_changed( + self, + group: TransformerSettingsGroup, + *, + key: str, + value: Any, + name: str, + ) -> None: + """Persist a widget's announced change via direct dict mutation.""" + t_dict = self._group_dicts.get(group) + if t_dict is None: + return + t_dict[key] = value + + def _on_tri_state_changed( + self, group: TransformerSettingsGroup, *, state: int + ) -> None: + """Persist a tri-state selection onto the backing dict.""" + t_dict = self._group_dicts.get(group) + if t_dict is None: + return + if state == TransformerSettingsGroup.STATE_ENABLED: + t_dict["recipe_apply"] = True + t_dict["enabled"] = True + elif state == TransformerSettingsGroup.STATE_DISABLED: + t_dict["recipe_apply"] = True + t_dict["enabled"] = False + else: + t_dict["recipe_apply"] = False diff --git a/rayforge/ui_gtk/doceditor/recipes/pages/settings.py b/rayforge/ui_gtk/doceditor/recipes/pages/settings.py new file mode 100644 index 000000000..70a261dfa --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/pages/settings.py @@ -0,0 +1,45 @@ +"""The recipe editor's settings page: one group of process settings.""" + +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw + +from .....core.varset import VarSet +from ....varset.varsetwidget import VarSetWidget + + +class RecipeSettingsPage(Adw.PreferencesPage): + """One group of recipe process settings. + + Wraps a :class:`VarSetWidget` titled ``title`` (e.g. "Laser", + "Step Settings"). The dialog creates one instance per + :meth:`~rayforge.core.step.Step.recipe_varset_groups` entry. + """ + + def __init__(self, title: str, **kwargs): + super().__init__(**kwargs) + self.group_title = title + self._widget = VarSetWidget( + title=title, + description=_( + "The settings that will be applied by this recipe. " + "When multiple step types are selected, only settings " + "common to all of them are shown." + ), + ) + self.add(self._widget) + + def populate(self, varset: VarSet): + self._widget.populate(varset) + + def set_values(self, values: dict[str, Any]): + self._widget.set_values(values) + + def get_values(self) -> dict[str, Any]: + return self._widget.get_values() + + @property + def keys(self): + """The setting keys rendered on this page.""" + return list(self._widget.widget_map.keys()) diff --git a/rayforge/ui_gtk/doceditor/recipes/recipe_list.py b/rayforge/ui_gtk/doceditor/recipes/recipe_list.py new file mode 100644 index 000000000..5303df252 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/recipe_list.py @@ -0,0 +1,221 @@ +import logging +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, Gtk, Pango + +from ....context import get_context +from ....core.recipe import Recipe +from ....shared.units.formatter import format_value +from ...icons import get_icon +from ...shared.preferences_group import PreferencesGroupWithButton +from .edit_recipe_dialog import AddEditRecipeDialog + +logger = logging.getLogger(__name__) + + +class RecipeRow(Gtk.Box): + """A widget representing a single Recipe in a ListBox.""" + + def __init__(self, recipe: Recipe, on_delete, on_edit): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.recipe = recipe + + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + icon = get_icon(recipe.get_icon_name()) + icon.set_valign(Gtk.Align.CENTER) + self.append(icon) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, hexpand=True + ) + self.append(labels_box) + + title = Gtk.Label(label=recipe.name, halign=Gtk.Align.START, xalign=0) + title.set_ellipsize(Pango.EllipsizeMode.END) + labels_box.append(title) + + subtitle = Gtk.Label( + label=self._get_subtitle(), + halign=Gtk.Align.START, + xalign=0, + ) + subtitle.set_ellipsize(Pango.EllipsizeMode.END) + subtitle.add_css_class("dim-label") + labels_box.append(subtitle) + + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", lambda w: on_edit(recipe)) + suffix_box.append(edit_button) + + delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + delete_button.add_css_class("flat") + delete_button.connect("clicked", lambda w: on_delete(recipe)) + suffix_box.append(delete_button) + + def _get_subtitle(self) -> str: + parts = [] + context = get_context() + + # 1. Machine + if self.recipe.target_machine_id: + machine = context.machine_mgr.get_machine_by_id( + self.recipe.target_machine_id + ) + parts.append(machine.name if machine else _("Unknown Machine")) + + # 2. Step types + step_types_label = self.recipe.get_step_type_label() + if step_types_label: + parts.append(step_types_label) + + # 3. Material + if self.recipe.material_uid: + material = context.material_mgr.get_material( + self.recipe.material_uid + ) + parts.append(material.name if material else _("Unknown Material")) + + # 4. Thickness + if self.recipe.min_thickness_mm is not None: + min_formatted = format_value( + self.recipe.min_thickness_mm, "length" + ) + if self.recipe.max_thickness_mm == self.recipe.min_thickness_mm: + parts.append(min_formatted) + elif self.recipe.max_thickness_mm is not None: + max_formatted = format_value( + self.recipe.max_thickness_mm, "length" + ) + parts.append(f"{min_formatted} - {max_formatted}") + + if not parts: + return _("Any") + return " · ".join(parts) + + +class RecipeListWidget(PreferencesGroupWithButton): + """Displays a list of recipes and allows adding/editing/deleting them.""" + + def __init__(self, **kwargs): + super().__init__(button_label=_("Add New Recipe"), **kwargs) + self.recipes_changed = Signal() + + placeholder = Gtk.Label( + label=_("No recipes found."), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_show_separators(True) + + self.populate_recipes() + + def populate_recipes(self): + recipe_mgr = get_context().recipe_mgr + recipes = sorted( + recipe_mgr.get_all_recipes(), key=lambda r: r.name.lower() + ) + self.set_items(recipes) + + def create_row_widget(self, item: Recipe) -> Gtk.Widget: + return RecipeRow(item, self._on_delete_recipe, self._on_edit_recipe) + + def _on_add_clicked(self, button): + root = self.get_root() + parent_window = ( + cast(Gtk.Window, root) if isinstance(root, Gtk.Window) else None + ) + dialog = AddEditRecipeDialog(parent=parent_window) + + def on_response(d, *, response_id: str): + if response_id == "add": + data = d.get_recipe_data() + if data["name"]: + new_recipe = Recipe( + name=data["name"], + description=data["description"], + target_step_types=data["target_step_types"], + target_machine_id=data["target_machine_id"], + material_uid=data["material_uid"], + min_thickness_mm=data["min_thickness_mm"], + max_thickness_mm=data["max_thickness_mm"], + settings=data["settings"], + transformer_dicts=data["transformer_dicts"], + ) + get_context().recipe_mgr.add_recipe(new_recipe) + self.populate_recipes() + self.recipes_changed.send(self) + d.close() + + dialog.response.connect(on_response, weak=False) + dialog.present() + + def _on_edit_recipe(self, recipe: Recipe): + root = self.get_root() + parent_window = ( + cast(Gtk.Window, root) if isinstance(root, Gtk.Window) else None + ) + dialog = AddEditRecipeDialog(parent=parent_window, recipe=recipe) + + def on_response(d, *, response_id: str): + if response_id == "save": + data = d.get_recipe_data() + if data["name"]: + recipe.name = data["name"] + recipe.description = data["description"] + recipe.target_step_types = data["target_step_types"] + recipe.target_machine_id = data["target_machine_id"] + recipe.material_uid = data["material_uid"] + recipe.min_thickness_mm = data["min_thickness_mm"] + recipe.max_thickness_mm = data["max_thickness_mm"] + recipe.settings = data["settings"] + recipe.transformer_dicts = data["transformer_dicts"] + get_context().recipe_mgr.save_recipe(recipe) + self.populate_recipes() + self.recipes_changed.send(self) + d.close() + + dialog.response.connect(on_response, weak=False) + dialog.present() + + def _on_delete_recipe(self, recipe: Recipe): + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=( + cast(Gtk.Window, root) + if isinstance(root, Gtk.Window) + else None + ), + heading=_("Delete '{name}'?").format(name=recipe.name), + body=_( + "The recipe will be permanently removed. " + "This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + + def on_response(d, response_id): + if response_id == "delete": + get_context().recipe_mgr.delete_recipe(recipe.uid) + self.populate_recipes() + self.recipes_changed.send(self) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() diff --git a/rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py b/rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py new file mode 100644 index 000000000..631a8d0d3 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/recipes/recipe_selector_dialog.py @@ -0,0 +1,150 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ....context import get_context +from ....core.recipe import Recipe +from ...icons import get_icon +from ...shared.gtk import apply_css + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +css = """ +.recipe-selector-list { + background: none; +} +""" + + +class RecipeSelectorDialog(Adw.MessageDialog): + """ + A dialog for selecting a recipe from a filterable list. + + The dialog is confirmed by activating a row (double-click or Enter). + """ + + class _RecipeRow(Adw.ActionRow): + """A custom row to hold a reference to its recipe.""" + + def __init__(self, recipe: Recipe, **kwargs): + super().__init__(**kwargs) + self.recipe: Recipe = recipe + + # Add icon as a prefix + icon = get_icon(recipe.get_icon_name()) + self.add_prefix(icon) + + def __init__( + self, + parent: Gtk.Window, + editor: "DocEditor", + on_select_callback: Callable[[Recipe], None], + step_type: str | None = None, + ): + super().__init__(transient_for=parent) + self.editor = editor + self.step_type = step_type + self.on_select_callback = on_select_callback + self._all_recipes: list[Recipe] = [] + + self.set_heading(_("Select Recipe")) + self.set_body(_("Choose a recipe to apply to the current step.")) + + apply_css(css) + + # Main content area + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_top(12) + self.set_extra_child(content_box) + + self.filter_switch = Adw.SwitchRow( + title=_("Show only compatible recipes") + ) + self.filter_switch.set_active(True) + self.filter_switch.connect( + "notify::active", lambda *_: self._filter_and_populate_list() + ) + content_box.append(self.filter_switch) + + self.search_entry = Gtk.SearchEntry() + self.search_entry.connect( + "search-changed", lambda *_: self._filter_and_populate_list() + ) + content_box.append(self.search_entry) + + # Scrolled window for the list + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + min_content_height=300, + vexpand=True, + ) + scrolled_window.add_css_class("card") + content_box.append(scrolled_window) + + # Recipe list + self.recipe_list = Gtk.ListBox() + self.recipe_list.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.recipe_list.add_css_class("recipe-selector-list") + self.recipe_list.connect("row-activated", self._on_recipe_activated) + scrolled_window.set_child(self.recipe_list) + + # Add response button + self.add_response("cancel", _("Cancel")) + self.set_default_response("cancel") + + self._populate_recipes() + + def _populate_recipes(self): + """Fetches all recipes and populates the list for the first time.""" + recipe_mgr = get_context().recipe_mgr + self._all_recipes = sorted( + recipe_mgr.get_all_recipes(), key=lambda r: r.name.lower() + ) + self._filter_and_populate_list() + + def _filter_and_populate_list(self): + """Filters recipes based on search and compatibility switch.""" + search_text = self.search_entry.get_text().lower() + show_compatible_only = self.filter_switch.get_active() + + # Get context for compatibility check - use all stock items from doc + stock_items = self.editor.doc.stock_items + machine = self.editor.context.machine + + # Clear existing rows + while child := self.recipe_list.get_row_at_index(0): + self.recipe_list.remove(child) + + for recipe in self._all_recipes: + # Filter by search text + if search_text and search_text not in recipe.name.lower(): + continue + + # Filter by compatibility + if show_compatible_only and not recipe.matches( + stock_items, + machine, + step_type=self.step_type, + ): + continue + + row = self._RecipeRow( + recipe=recipe, + title=recipe.name, + subtitle=recipe.get_step_type_label() or _("Any"), + activatable=True, + ) + self.recipe_list.append(row) + + def _on_recipe_activated(self, listbox: Gtk.ListBox, row: _RecipeRow): + """Handles when a recipe is selected by activation.""" + self.on_select_callback(row.recipe) + self.close() diff --git a/rayforge/ui_gtk/doceditor/step_box.py b/rayforge/ui_gtk/doceditor/step_box.py new file mode 100644 index 000000000..9f3ccdb72 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_box.py @@ -0,0 +1,146 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from blinker import Signal +from gi.repository import Gtk, Pango + +from ...context import get_context +from ...core.step import Step +from ...core.undo.property_cmd import ChangePropertyCommand +from ..icons import get_icon +from ..shared.number_badge import NumberBadge +from ..shared.tag import TagWidget +from .step_settings.dialog import StepSettingsDialog + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + + +class StepBox(Gtk.Box): + def __init__( + self, + editor: "DocEditor", + step: Step, + step_number: int = 0, + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.set_margin_start(4) + self.set_margin_end(4) + self.set_margin_top(4) + self.set_margin_bottom(4) + self.editor = editor + self.doc = editor.doc + self.step = step + self.step_number = step_number + self.delete_clicked = Signal() + + self.badge = NumberBadge(step_number) + self.badge.set_valign(Gtk.Align.CENTER) + self.append(self.badge) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + content.set_hexpand(True) + content.set_valign(Gtk.Align.CENTER) + self.append(content) + + title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + content.append(title_row) + + self.title_label = Gtk.Label(xalign=0) + self.title_label.set_ellipsize(Pango.EllipsizeMode.END) + self.title_label.set_max_width_chars(40) + self.title_label.set_hexpand(True) + title_row.append(self.title_label) + + self.mode_tag = TagWidget(active=False) + self.mode_tag_label = Gtk.Label() + self.mode_tag.append(self.mode_tag_label) + title_row.append(self.mode_tag) + + self.subtitle_label = Gtk.Label(xalign=0) + self.subtitle_label.add_css_class("caption") + self.subtitle_label.add_css_class("dim-label") + self.subtitle_label.set_ellipsize(Pango.EllipsizeMode.END) + self.subtitle_label.set_max_width_chars(40) + self.subtitle_label.set_hexpand(True) + content.append(self.subtitle_label) + + self.visibility_switch = Gtk.Switch() + self.visibility_switch.set_active(step.visible) + self.visibility_switch.set_valign(Gtk.Align.CENTER) + self.append(self.visibility_switch) + self.visibility_switch.connect("state-set", self.on_switch_state_set) + + button = Gtk.Button() + button.set_child(get_icon("settings-symbolic")) + button.set_valign(Gtk.Align.CENTER) + self.append(button) + button.connect("clicked", self.on_button_properties_clicked) + + button = Gtk.Button() + button.set_child(get_icon("delete-symbolic")) + button.set_valign(Gtk.Align.CENTER) + self.append(button) + button.connect("clicked", self.on_button_delete_clicked) + + self.step.updated.connect(self.on_step_changed) + self.step.visibility_changed.connect(self.on_step_changed) + get_context().config.changed.connect(self.on_step_changed) + self.on_step_changed(self.step) + + def do_destroy(self): + """Overrides GObject.Object.do_destroy to disconnect signals.""" + self.step.updated.disconnect(self.on_step_changed) + self.step.visibility_changed.disconnect(self.on_step_changed) + get_context().config.changed.disconnect(self.on_step_changed) + + def set_step_number(self, number: int): + self.step_number = number + self.badge.set_number(number) + + def on_step_changed(self, sender, **kwargs): + self.title_label.set_text(self.step.name) + self.subtitle_label.set_text(self.step.get_summary()) + + mode = self.step.get_operation_mode_short() + if mode: + self.mode_tag.set_visible(True) + self.mode_tag_label.set_text(mode) + else: + self.mode_tag.set_visible(False) + + is_visible = self.step.visible + self.visibility_switch.set_active(is_visible) + self.badge.set_dimmed(not is_visible) + self._update_badge_color() + + def _update_badge_color(self): + if not self.step.visible: + self.badge.set_color(None) + return + + machine = get_context().machine + if not machine or not machine.heads: + self.badge.set_color(None) + return + head = self.step.get_selected_head(machine) + color = self.step.get_operation_color(head) if head else None + self.badge.set_color(color) + + def on_switch_state_set(self, switch, state): + command = ChangePropertyCommand( + target=self.step, + property_name="visible", + new_value=state, + setter_method_name="set_visible", + name=_("Toggle step visibility"), + ) + self.doc.history_manager.execute(command) + + def on_button_properties_clicked(self, button): + StepSettingsDialog.present_for_step( + self.editor, self.step, self.get_root() + ) + + def on_button_delete_clicked(self, button): + self.delete_clicked.send(self, step=self.step) diff --git a/rayforge/ui_gtk/doceditor/step_settings/__init__.py b/rayforge/ui_gtk/doceditor/step_settings/__init__.py new file mode 100644 index 000000000..ac13b1eae --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/__init__.py @@ -0,0 +1,8 @@ +"""Step settings UI: pages, rows, and settings groups.""" + +from .groups import PlaceholderSettingsGroup, TransformerSettingsGroup + +__all__ = [ + "PlaceholderSettingsGroup", + "TransformerSettingsGroup", +] diff --git a/rayforge/ui_gtk/doceditor/step_settings/dialog.py b/rayforge/ui_gtk/doceditor/step_settings/dialog.py new file mode 100644 index 000000000..faeb63251 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/dialog.py @@ -0,0 +1,236 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING, ClassVar + +from gi.repository import Adw, Gtk + +from rayforge.context import get_context +from rayforge.core.step import Step +from rayforge.ui_gtk.doceditor.step_settings.page_registry import ( + step_settings_page_registry, +) +from rayforge.ui_gtk.doceditor.step_settings.pages import ( + GeneralStepSettingsPage, + PostProcessingPage, + StepSettingsPage, +) +from rayforge.ui_gtk.icons import get_icon +from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class StepSettingsDialog(PatchedDialogWindow): + _open_dialogs: ClassVar[dict[int, "StepSettingsDialog"]] = {} + + def __init__( + self, + editor: "DocEditor", + step: Step, + **kwargs, + ): + super().__init__(skip_usage_tracking=True, **kwargs) + self.editor = editor + self.step = step + self.set_title(_("{name} Settings").format(name=step.name)) + + # Adw.ToolbarView provides areas for a header, content, and bottom bar. + main_view = Adw.ToolbarView() + self.set_content(main_view) + + # A HeaderBar provides the window decorations (close button, etc.) + header = Adw.HeaderBar() + main_view.add_top_bar(header) + + # Gtk.Stack holds the pages. + self.stack = Gtk.Stack() + main_view.set_content(self.stack) + + # Set a reasonable default size to avoid being too narrow + self.set_default_size(600, 750) + + # Destroy window on close to prevent leaks + self.set_hide_on_close(False) + self.connect("close-request", self._on_close_request) + + # --- Main Step Settings + addon-provided extra pages --- + context = get_context() + self.general_view: StepSettingsPage | None = None + self._extra_pages: list[tuple[str, StepSettingsPage, str | None]] = [] + if context: + page_cls = step_settings_page_registry.get( + self.step.ASSEMBLER_NAME + ) + if page_cls: + page = page_cls(self.editor, self.step) + self.general_view = page + for method_name, title, icon_name in page.extra_pages: + self.add_settings_page( + title, + getattr(page, method_name)(), + icon_name, + ) + if self.general_view is None: + self.general_view = GeneralStepSettingsPage(self.editor, self.step) + scrolled_page1 = Gtk.ScrolledWindow( + child=self.general_view, + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + ) + self.stack.add_named(scrolled_page1, "step-settings") + + self._extra_page_names = [] + for index, (title, page, icon_name) in enumerate( + self._extra_pages, start=1 + ): + scrolled = Gtk.ScrolledWindow( + child=page, + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + ) + page_name = f"settings-{index}" + self.stack.add_named(scrolled, page_name) + self._extra_page_names.append(page_name) + + # --- Post Processing Settings --- + self.post_processing_view = PostProcessingPage(self.editor, self.step) + scrolled_page2 = Gtk.ScrolledWindow( + child=self.post_processing_view, + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + ) + self.stack.add_named(scrolled_page2, "post-processing") + + # --- Build the custom switcher --- + switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + switcher_box.add_css_class("linked") + header.set_title_widget(switcher_box) + + self.btn_step_settings = Gtk.ToggleButton() + self.btn_step_settings.set_child( + self._create_tab_title(_("Step Settings"), "laser-path-symbolic") + ) + self.btn_step_settings.connect( + "toggled", self._on_tab_toggled, self.stack, "step-settings" + ) + switcher_box.append(self.btn_step_settings) + + self._extra_buttons = [] + for index, (title, page, icon_name) in enumerate( + self._extra_pages, start=1 + ): + button = Gtk.ToggleButton(group=self.btn_step_settings) + button.set_child( + self._create_tab_title(title, icon_name or "settings-symbolic") + ) + button.connect( + "toggled", + self._on_tab_toggled, + self.stack, + f"settings-{index}", + ) + switcher_box.append(button) + self._extra_buttons.append(button) + + self.btn_post_processing = Gtk.ToggleButton( + group=self.btn_step_settings + ) + self.btn_post_processing.set_child( + self._create_tab_title( + _("Post Processing"), "post-processor-symbolic" + ) + ) + self.btn_post_processing.connect( + "toggled", self._on_tab_toggled, self.stack, "post-processing" + ) + switcher_box.append(self.btn_post_processing) + + has_post_processors = bool( + step.per_step_transformers_dicts + or step.per_workpiece_transformers_dicts + ) + self.btn_post_processing.set_visible(has_post_processors) + + # Default to step-settings page + self.btn_step_settings.set_active(True) + if self.general_view is not None: + self.general_view._sync_widgets_to_model() + + def set_step_settings_page(self, page: StepSettingsPage): + """Set the step's main settings page.""" + self.general_view = page + + def add_settings_page( + self, + title: str, + page: StepSettingsPage, + icon_name: str | None = None, + ): + """Add an additional settings page tab.""" + self._extra_pages.append((title, page, icon_name)) + + @classmethod + def present_for_step( + cls, + editor: "DocEditor", + step: Step, + parent_window: Gtk.Root | None, + ) -> "StepSettingsDialog": + existing = cls._open_dialogs.get(id(step)) + if existing: + existing.present() + return existing + dialog = cls(editor, step, transient_for=parent_window) + cls._open_dialogs[id(step)] = dialog + dialog.connect("close-request", cls._on_dialog_closed) + dialog.present() + return dialog + + @classmethod + def _on_dialog_closed(cls, dialog: "StepSettingsDialog", *args) -> bool: + cls._open_dialogs.pop(id(dialog.step), None) + return False + + def set_initial_page(self, page: str): + """Set the initial visible page after dialog construction.""" + if page == "post-processing": + self.btn_post_processing.set_active(True) + return + for index, (page_title, extra_page, icon_name) in enumerate( + self._extra_pages + ): + if ( + page.lower() == page_title.lower() + or page == f"settings-{index + 1}" + ): + self._extra_buttons[index].set_active(True) + return + self.btn_step_settings.set_active(True) + + def _on_tab_toggled(self, button, stack, page_name): + """Callback to switch the Gtk.Stack page.""" + if button.get_active(): + stack.set_visible_child_name(page_name) + + def _create_tab_title(self, title_str: str, icon_name: str) -> Gtk.Widget: + """Creates a box with an icon and a label for a tab button.""" + icon = get_icon(icon_name) + label = Gtk.Label(label=title_str) + box = Gtk.Box(spacing=6, orientation=Gtk.Orientation.HORIZONTAL) + box.append(icon) + box.append(label) + return box + + def _on_close_request(self, window): + # Clean up debounce timers in all settings pages to prevent GLib + # warnings when the window is closed. + pages = ( + [self.general_view] + + [page for _, page, _ in self._extra_pages] + + [self.post_processing_view] + ) + for page in pages: + cleanup = getattr(page, "_cleanup", None) + if callable(cleanup): + cleanup() + return False # Allow the window to close diff --git a/rayforge/ui_gtk/doceditor/step_settings/groups/__init__.py b/rayforge/ui_gtk/doceditor/step_settings/groups/__init__.py new file mode 100644 index 000000000..9b83b6fca --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/groups/__init__.py @@ -0,0 +1,18 @@ +"""Settings groups (backward-compat re-exports). + +The groups moved to +``rayforge/ui_gtk/doceditor/post_processor/groups/``; this module +re-exports them for existing importers. +""" + +from ...post_processor.groups import ( + ExpanderHost, + PlaceholderSettingsGroup, + TransformerSettingsGroup, +) + +__all__ = [ + "ExpanderHost", + "PlaceholderSettingsGroup", + "TransformerSettingsGroup", +] diff --git a/rayforge/ui_gtk/doceditor/step_settings/page_registry.py b/rayforge/ui_gtk/doceditor/step_settings/page_registry.py new file mode 100644 index 000000000..39064fb92 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/page_registry.py @@ -0,0 +1,88 @@ +"""Registry mapping step assembler names to their settings page classes.""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rayforge.ui_gtk.doceditor.step_settings.pages.base import ( + StepSettingsPage, + ) + +logger = logging.getLogger(__name__) + + +class StepSettingsPageRegistry: + """ + Registry for step settings page classes. + + Maps a step's assembler name (``step.ASSEMBLER_NAME``) to the + :class:`StepSettingsPage` subclass that renders its settings. + Addons register their pages ahead of time via the + ``register_step_settings_pages`` hook; the step settings dialog + looks up the page class directly from the singleton. + + Satisfies the :class:`~rayforge.addon_mgr.addon_manager.AddonRegistry` + protocol for automatic cleanup on addon unload. + """ + + def __init__(self): + self._pages: dict[str, type] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, + assembler_name: str, + page_cls: "type[StepSettingsPage]", + addon_name: str | None = None, + ) -> None: + """ + Register a settings page class for an assembler name. + + Args: + assembler_name: The step assembler name (``step.ASSEMBLER_NAME``). + page_cls: The StepSettingsPage subclass. + addon_name: Optional name of the addon registering this + page. Used for cleanup when the addon is unloaded. + """ + self._pages[assembler_name] = page_cls + if addon_name: + if addon_name not in self._addon_items: + self._addon_items[addon_name] = set() + self._addon_items[addon_name].add(assembler_name) + logger.debug( + "Registered settings page %s for assembler %s", + page_cls.__name__, + assembler_name, + ) + + def get(self, assembler_name: str) -> type | None: + """ + Look up the settings page class for an assembler name. + + Returns: + The page class, or None if not registered. + """ + return self._pages.get(assembler_name) + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Unregister all pages registered by a specific addon. + + Args: + addon_name: The name of the addon. + + Returns: + The number of pages unregistered. + """ + if addon_name not in self._addon_items: + return 0 + items = self._addon_items.pop(addon_name) + count = 0 + for name in items: + if name in self._pages: + del self._pages[name] + count += 1 + return count + + +step_settings_page_registry = StepSettingsPageRegistry() diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py b/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py new file mode 100644 index 000000000..ee114efa2 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py @@ -0,0 +1,11 @@ +"""Step settings pages.""" + +from .base import StepSettingsPage +from .general import GeneralStepSettingsPage +from .post_processing import PostProcessingPage + +__all__ = [ + "GeneralStepSettingsPage", + "PostProcessingPage", + "StepSettingsPage", +] diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/base.py b/rayforge/ui_gtk/doceditor/step_settings/pages/base.py new file mode 100644 index 000000000..c4c5f7cc1 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/base.py @@ -0,0 +1,173 @@ +"""Base class for a step's settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, ClassVar + +from gi.repository import Adw, GLib, Gtk + +from .....core.undo.property_cmd import ChangePropertyCommand +from .....machine.models.spindle import SpindleHead +from .....shared.util.glib import DebounceMixin +from ....shared.preferences_page import TrackedPreferencesPage +from ..recipe_control_widget import RecipeControlWidget +from ..rows import CoolantRow, StepRow + +if TYPE_CHECKING: + from .....doceditor.editor import DocEditor + + +def _to_widget(item: Any, editor: "DocEditor", step: Any) -> Gtk.Widget: + if isinstance(item, type): + item = item(editor, step) + if isinstance(item, StepRow): + return item.widget + return item + + +class StepSettingsPage(DebounceMixin, TrackedPreferencesPage): + """Base class for a step type's settings page. + + Subclasses compose row widgets into titled sections via + ``add_section``. The page normally starts with a section holding + the step name and the recipe control; set ``show_identity`` to + False to omit it (for auxiliary pages). + """ + + show_identity = True + + #: Declares extra settings pages as ``(method_name, title, + #: icon_name)`` tuples. Each method returns a :class:`StepSettingsPage` + #: that the step settings dialog adds as an additional tab. + extra_pages: ClassVar[tuple[tuple[str, str, str], ...]] = () + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__() + self.editor = editor + self.step = step + self.doc = editor.doc + self.history_manager = editor.doc.history_manager + producer_type = step.ASSEMBLER_NAME or "unknown" + self.key = f"{producer_type.lower()}/step-settings" + self.path_prefix = "/step-settings/" + self._sections: list[Adw.PreferencesGroup] = [] + self._rows: list[Any] = [] + if self.show_identity: + self._add_identity_section() + self._add_cooling_section() + + def _add_identity_section(self): + name_row = Adw.EntryRow(title=_("Name")) + name_row.set_text(self.step.name) + name_row.connect("changed", self._on_name_changed) + self.recipe_control = RecipeControlWidget(self.editor, self.step) + self.recipe_control.recipe_applied.connect(self._on_recipe_applied) + self.add_section( + _("General"), + name_row, + self.recipe_control, + description=_("Step name and recipe settings."), + ) + + def _on_name_changed(self, row): + new_name = row.get_text().strip() + if not new_name or new_name == self.step.name: + return + self.editor.step.rename_step(self.step, new_name) + + def _on_recipe_applied(self, *args): + self._sync_widgets_to_model() + + def _add_cooling_section(self): + """Add the coolant section, hidden unless a spindle head is used.""" + self.coolant_row = CoolantRow(self.editor, self.step) + self.coolant_section = self.add_section( + _("Cooling"), + self.coolant_row, + description=_("Coolant used while this operation runs."), + ) + self.step.updated.connect(self._update_cooling_section_visibility) + self._update_cooling_section_visibility() + + def _update_cooling_section_visibility(self, *args): + self.coolant_section.set_visible( + isinstance(self.get_selected_head(), SpindleHead) + ) + + def get_machine(self): + return getattr(self.editor.context, "machine", None) + + def get_selected_head(self): + machine = self.get_machine() + if machine is None: + return None + return self.step.get_selected_head(machine) + + def set_step_property( + self, + key: str, + new_value: Any, + name: str | None = None, + ): + current = getattr(self.step, key, None) + if current == new_value: + return + + def _notify(): + self.step.updated.send(self.step) + + setter_name = f"set_{key}" + setter = getattr(self.step, setter_name, None) + command = ChangePropertyCommand( + target=self.step, + property_name=key, + new_value=new_value, + setter_method_name=setter_name if setter else None, + name=name or _("Change {key}").format(key=key.replace("_", " ")), + on_change_callback=None if setter else _notify, + ) + self.history_manager.execute(command) + + def add_section( + self, + title: str | None, + *rows: Any, + description: str | None = None, + ) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup() + if title: + group.set_title(title) + if description: + group.set_description(description) + for item in rows: + if isinstance(item, type): + item = item(self.editor, self.step) + self._rows.append(item) + group.add(_to_widget(item, self.editor, self.step)) + self.add(group) + self._sections.append(group) + return group + + def add_row(self, row: Any): + if not self._sections: + self.add_section(None) + self._rows.append(row) + self._sections[-1].add(_to_widget(row, self.editor, self.step)) + + def add_group(self, group: Adw.PreferencesGroup): + self.add(group) + self._sections.append(group) + + def _sync_widgets_to_model(self, *args): + for row in self._rows: + resync = getattr(row, "resync", None) + if callable(resync): + resync() + + def _cleanup(self): + if self._debounce_timer > 0: + GLib.source_remove(self._debounce_timer) + self._debounce_timer = 0 + for row in self._rows: + cleanup = getattr(row, "cleanup", None) + if callable(cleanup): + cleanup() diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/general.py b/rayforge/ui_gtk/doceditor/step_settings/pages/general.py new file mode 100644 index 000000000..18898eb9e --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/general.py @@ -0,0 +1,10 @@ +"""Fallback step settings page.""" + +from rayforge.ui_gtk.doceditor.step_settings.pages.base import StepSettingsPage + + +class GeneralStepSettingsPage(StepSettingsPage): + """Fallback step settings page when no addon provides one.""" + + key = "" + path_prefix = "/step-settings/" diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py b/rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py new file mode 100644 index 000000000..50c702135 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py @@ -0,0 +1,199 @@ +"""Step-mode post-processing transformers settings page.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, GObject, Gtk + +from .....context import get_context +from .....core.step import Step +from .....pipeline.transformer import OpsTransformer +from .....pipeline.transformer.placeholder import PlaceholderTransformer +from ....icons import get_icon +from ....shared.preferences_page import TrackedPreferencesPage +from ...post_processor.groups import ( + PlaceholderSettingsGroup, + TransformerSettingsGroup, +) +from ...post_processor.registry import transformer_widget_registry + +if TYPE_CHECKING: + from .....doceditor.editor import DocEditor + + +class PostProcessingPage(TrackedPreferencesPage): + """A page for the post-processing transformers of a Step. + + The transformer widgets are pure UI: they announce parameter + changes via ``param_changed`` and the page persists them through + the editor's undoable command path (``editor.step.set_step_param``). + """ + + use_expanders = True + + def __init__(self, editor: "DocEditor", step: Step): + super().__init__() + self.editor = editor + self.step = step + producer_type = step.ASSEMBLER_NAME or "unknown" + producer_key = producer_type.lower() + self.key = f"{producer_key}/post-processing" + self.path_prefix = "/step-settings/" + + self._main_group = Adw.PreferencesGroup( + title=_("Post Processing"), + description=_( + "Transformers applied to this step's generated toolpath." + ), + ) + self.add(self._main_group) + self._has_expanders = False + self._group_dicts: dict[TransformerSettingsGroup, dict] = {} + + all_transformer_dicts = ( + step.per_workpiece_transformers_dicts or [] + ) + (step.per_step_transformers_dicts or []) + + self.populate(all_transformer_dicts) + + def populate(self, transformer_dicts: list[dict]) -> None: + """Build groups for the given transformer dicts.""" + # Deduplicate by object identity (same dict can be in both lists) + seen_ids: set[int] = set() + unique_transformer_dicts: list[dict] = [] + for t_dict in transformer_dicts or []: + dict_id = id(t_dict) + if dict_id not in seen_ids: + seen_ids.add(dict_id) + unique_transformer_dicts.append(t_dict) + + for t_dict in unique_transformer_dicts: + transformer = OpsTransformer.from_dict(t_dict) + widget_cls = transformer_widget_registry.get(type(transformer)) + if widget_cls: + group = widget_cls( + transformer.label, + transformer, + self, + step=self.step, + ) + elif isinstance(transformer, PlaceholderTransformer): + group = PlaceholderSettingsGroup( + transformer.label, + transformer, + self, + step=self.step, + ) + else: + continue + self._group_dicts[group] = t_dict + self._add_group(group, t_dict) + + if not self._has_expanders: + self._show_empty_state() + + def _show_empty_state(self) -> None: + """Render the empty-state message when no groups were added.""" + placeholder_label = Gtk.Label( + label=_("No post-processing options available for this step."), + halign=Gtk.Align.CENTER, + margin_top=24, + margin_bottom=24, + wrap=True, + ) + placeholder_label.add_css_class("dim-label") + self._main_group.add(placeholder_label) + + def _add_group( + self, + group: TransformerSettingsGroup, + t_dict: dict, + ) -> None: + group.param_changed.connect(self._on_param_changed) + + title = group.get_title() + subtitle = group.get_description() + rows = group._rows + + expander = Adw.ExpanderRow(title=title or "") + if subtitle: + expander.set_subtitle(subtitle) + expander.set_expanded(False) + + warning_icon = get_icon("warning-symbolic") + warning_icon.set_valign(Gtk.Align.CENTER) + expander.add_prefix(warning_icon) + + def _update_warning_icon( + grp: TransformerSettingsGroup = group, + ico: Gtk.Image = warning_icon, + ) -> None: + ico.set_visible(grp.is_unsupported()) + + enable_switch_row: Adw.SwitchRow | None = None + for row in rows: + if isinstance(row, Adw.SwitchRow) and enable_switch_row is None: + enable_switch_row = row + switch = Gtk.Switch() + switch.set_active(row.get_active()) + switch.set_valign(Gtk.Align.CENTER) + expander.add_suffix(switch) + + def _on_header_toggled( + sw: Gtk.Switch, + _pspec: GObject.ParamSpec, + orig: Adw.SwitchRow = row, + ) -> None: + if orig.get_active() != sw.get_active(): + orig.set_active(sw.get_active()) + + switch.connect("notify::active", _on_header_toggled) + + def _on_orig_toggled( + r: Adw.SwitchRow, + _pspec: GObject.ParamSpec, + sw: Gtk.Switch = switch, + ) -> None: + if sw.get_active() != r.get_active(): + sw.set_active(r.get_active()) + + row.connect("notify::active", _on_orig_toggled) + row.connect( + "notify::active", + lambda *_: _update_warning_icon(), + ) + else: + expander.add_row(row) + + machine = get_context().machine + if machine: + machine.changed.connect(lambda *_: _update_warning_icon()) + + _update_warning_icon() + self._main_group.add(expander) + self._has_expanders = True + + def _on_param_changed( + self, + group: TransformerSettingsGroup, + *, + key: str, + value: Any, + name: str, + ) -> None: + """Persist a widget's announced change via the editor.""" + t_dict = self._group_dicts[group] + if t_dict in self.step.per_step_transformers_dicts: + callback = self.step.per_step_transformer_changed.send + else: + callback = self._send_step_updated + self.editor.step.set_step_param( + target_dict=t_dict, + key=key, + new_value=value, + name=name, + on_change_callback=callback, + ) + + def _send_step_updated(self) -> None: + self.step.updated.send(self.step) diff --git a/rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py b/rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py new file mode 100644 index 000000000..5944c892e --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py @@ -0,0 +1,310 @@ +import copy +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, cast + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ....context import get_context +from ....core.recipe import Recipe +from ....core.step import Step +from ....core.undo.property_cmd import ChangePropertyCommand +from ..recipes.edit_recipe_dialog import AddEditRecipeDialog +from ..recipes.recipe_selector_dialog import RecipeSelectorDialog + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class RecipeControlWidget(Adw.ActionRow): + """ + A widget for managing recipe application within the StepSettingsDialog. + """ + + recipe_applied = Signal() + + def __init__(self, editor: "DocEditor", step: Step, **kwargs): + super().__init__(**kwargs) + self.editor = editor + self.step = step + self.set_title(_("Recipe")) + + # "Choose..." Button + choose_button = Gtk.Button(label=_("Choose...")) + choose_button.set_valign(Gtk.Align.CENTER) + choose_button.connect("clicked", self._on_choose_clicked) + self.add_suffix(choose_button) + + # "Save As..." Button + save_as_button = Gtk.Button(label=_("Save As...")) + save_as_button.set_valign(Gtk.Align.CENTER) + save_as_button.connect("clicked", self._on_save_as_clicked) + self.add_suffix(save_as_button) + + # "Update" Button + self.update_button = Gtk.Button(label=_("Update")) + self.update_button.set_valign(Gtk.Align.CENTER) + self.update_button.add_css_class("suggested-action") + self.update_button.connect("clicked", self._on_update_clicked) + self.add_suffix(self.update_button) + + self.step.updated.connect(self._update_ui) + self._update_ui(self.step) + + def _get_step_settings(self) -> dict[str, Any]: + """Extracts recipe-relevant settings from the step. + + Uses the step class's :meth:`~rayforge.core.step.Step.recipe_keys`, + which derives the canonical list of recipe-eligible attributes + from the step's recipe varset (replacing the older + capability-key lookup). + """ + settings = {} + for key in type(self.step).recipe_keys(): + if hasattr(self.step, key): + settings[key] = getattr(self.step, key) + return settings + + def _get_step_transformers(self) -> list[dict[str, Any]]: + """Deep-copy the step's transformer dicts, deduped by name. + + Returns a list of fresh dict copies combining the step's + ``per_workpiece_transformers_dicts`` and + ``per_step_transformers_dicts`` (a single dict can appear in + both lists and is shared by reference, so dedup is by name + keeping the first occurrence). Each copy is given a + ``recipe_apply=True`` default so the recipe will stamp the + transformer when applied. + """ + by_name: dict[str, dict[str, Any]] = {} + for d in list(self.step.per_workpiece_transformers_dicts) + list( + self.step.per_step_transformers_dicts + ): + name = d.get("name") + if not name or name in by_name: + continue + copy_d = copy.deepcopy(d) + copy_d.setdefault("recipe_apply", True) + by_name[name] = copy_d + return list(by_name.values()) + + def _update_ui(self, sender, **kwargs): + """Updates the subtitle and button visibility.""" + recipe_mgr = get_context().recipe_mgr + current_recipe = None + is_modified = False + + if self.step.applied_recipe_uid: + current_recipe = recipe_mgr.get_recipe_by_id( + self.step.applied_recipe_uid + ) + + if current_recipe: + self.set_subtitle(current_recipe.name) + + # Check if settings have diverged from the recipe by asking + # the recipe to compare itself against the step. + if not current_recipe.matches_step_settings(self.step): + is_modified = True + if not current_recipe.matches_step_transformers(self.step): + is_modified = True + else: + self.set_subtitle(_("Manual Settings")) + + self.update_button.set_visible(is_modified) + + def _on_choose_clicked(self, button: Gtk.Button): + """Opens the recipe selector dialog.""" + parent_window = cast(Gtk.Window, self.get_root()) + dialog = RecipeSelectorDialog( + parent=parent_window, + editor=self.editor, + on_select_callback=self._apply_recipe, + step_type=type(self.step).__name__, + ) + dialog.present() + + def _apply_recipe(self, recipe: Recipe): + """Applies a selected recipe to the step via an undoable command.""" + with self.editor.doc.history_manager.transaction( + _("Apply Recipe '{name}'").format(name=recipe.name) + ) as t: + # Set recipe UID + t.execute( + ChangePropertyCommand( + target=self.step, + property_name="applied_recipe_uid", + new_value=recipe.uid, + on_change_callback=( + lambda: (self.step.updated.send(self.step), None)[1] + ), + ) + ) + # Set each setting the recipe carries; skip keys this step + # does not own. + for key, value in recipe.settings.items(): + if not hasattr(self.step, key): + continue + t.execute( + ChangePropertyCommand( + target=self.step, + property_name=key, + new_value=value, + on_change_callback=( + lambda: (self.step.updated.send(self.step), None)[ + 1 + ] + ), + ) + ) + # Apply transformer settings: for each recipe transformer with + # recipe_apply=True, find the step's matching dict by name and + # overwrite its params with undoable commands. + self._apply_recipe_transformers(t, recipe.transformer_dicts) + # Signal to the parent dialog that its widgets need to be synced + self.recipe_applied.send(self) + self._update_ui(self.step) + + def _apply_recipe_transformers( + self, transaction: Any, transformer_dicts: list[dict[str, Any]] + ) -> None: + """Apply recipe transformer settings to the step's transformers. + + For each recipe dict with ``recipe_apply=True``, find the + matching step dict by ``name`` (searching + ``per_step_transformers_dicts`` first, then + ``per_workpiece_transformers_dicts``). For each param key + (except ``name`` and ``recipe_apply``), emit an undoable + ``set_step_param`` command. The appropriate step callback + matches the step-mode post-processing page's logic. + """ + step_dicts_by_name: dict[str, dict[str, Any]] = {} + for d in list(self.step.per_step_transformers_dicts) + list( + self.step.per_workpiece_transformers_dicts + ): + name = d.get("name") + if name and name not in step_dicts_by_name: + step_dicts_by_name[name] = d + + for recipe_dict in transformer_dicts or []: + if not recipe_dict.get("recipe_apply", True): + continue + name = recipe_dict.get("name") + if not name: + continue + step_dict = step_dicts_by_name.get(name) + if step_dict is None: + continue + is_per_step = step_dict in (self.step.per_step_transformers_dicts) + callback = ( + self.step.per_step_transformer_changed.send + if is_per_step + else self._send_step_updated + ) + for key, value in recipe_dict.items(): + if key in ("name", "recipe_apply"): + continue + self.editor.step.set_step_param( + target_dict=step_dict, + key=key, + new_value=value, + name=_("Apply Recipe Transformer"), + on_change_callback=callback, + ) + + def _send_step_updated(self) -> None: + self.step.updated.send(self.step) + + def _on_save_as_clicked(self, button: Gtk.Button): + """Saves the current step settings as a new recipe.""" + # 1. Gather context - get first stock item from document + stock_items = self.editor.doc.stock_items + stock_item = stock_items[0] if stock_items else None + step_class = type(self.step) + + # 2. Create a template Recipe object to pre-fill the dialog + template_recipe = Recipe( + name=_("New {label} Recipe").format( + label=step_class.TYPELABEL or step_class.__name__ + ), + settings=self._get_step_settings(), + transformer_dicts=self._get_step_transformers(), + target_step_types=[step_class.__name__], + target_machine_id=self.editor.context.machine.id + if self.editor.context.machine + else None, + material_uid=stock_item.material_uid if stock_item else None, + min_thickness_mm=stock_item.thickness if stock_item else None, + max_thickness_mm=stock_item.thickness if stock_item else None, + ) + + # 3. Open the full recipe editor dialog + parent_window = cast(Gtk.Window, self.get_root()) + dialog = AddEditRecipeDialog( + parent=parent_window, recipe=template_recipe + ) + dialog.response.connect(self._on_save_as_dialog_response, weak=False) + dialog.present() + + def _on_save_as_dialog_response( + self, dialog: AddEditRecipeDialog, *, response_id: str + ): + if response_id in ("add", "save"): + data = dialog.get_recipe_data() + if data["name"]: + new_recipe = Recipe(**data) + recipe_mgr = get_context().recipe_mgr + recipe_mgr.add_recipe(new_recipe) + + # Now that the recipe is saved, apply it to the current step + command = ChangePropertyCommand( + target=self.step, + property_name="applied_recipe_uid", + new_value=new_recipe.uid, + name=_("Set Applied Recipe"), + ) + self.editor.doc.history_manager.execute(command) + dialog.close() + + def _on_update_clicked(self, button: Gtk.Button): + """Updates the applied recipe with the current step settings.""" + if not self.step.applied_recipe_uid: + return + + recipe_mgr = get_context().recipe_mgr + recipe = recipe_mgr.get_recipe_by_id(self.step.applied_recipe_uid) + if not recipe: + return + + # Show confirmation dialog + parent_window = cast(Gtk.Window, self.get_root()) + dialog = Adw.MessageDialog( + transient_for=parent_window, + heading=_("Update Recipe '{name}'?").format(name=recipe.name), + body=_( + "This will permanently overwrite the saved recipe with the " + "current step settings. This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("update", _("Update")) + dialog.set_response_appearance( + "update", Adw.ResponseAppearance.SUGGESTED + ) + dialog.connect("response", self._on_update_dialog_response, recipe) + dialog.present() + + def _on_update_dialog_response( + self, dialog: Adw.MessageDialog, response_id: str, recipe: Recipe + ): + if response_id == "update": + recipe.settings = self._get_step_settings() + recipe.transformer_dicts = self._get_step_transformers() + get_context().recipe_mgr.save_recipe(recipe) + # Manually trigger a UI update, as the step model itself didn't + # change + self._update_ui(self.step) + dialog.destroy() diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/__init__.py b/rayforge/ui_gtk/doceditor/step_settings/rows/__init__.py new file mode 100644 index 000000000..8e8063be7 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/__init__.py @@ -0,0 +1,23 @@ +"""Core row widgets for step settings.""" + +from .combo_row import ComboRow +from .coolant_row import CoolantRow +from .cut_speed_row import CutSpeedRow +from .head_row import HeadRow +from .slider_row import SliderRow +from .spin_row import SpinRow +from .step_row import StepRow +from .switch_row import SwitchRow +from .travel_speed_row import TravelSpeedRow + +__all__ = [ + "ComboRow", + "CoolantRow", + "CutSpeedRow", + "HeadRow", + "SliderRow", + "SpinRow", + "StepRow", + "SwitchRow", + "TravelSpeedRow", +] diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/combo_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/combo_row.py new file mode 100644 index 000000000..2034feb77 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/combo_row.py @@ -0,0 +1,63 @@ +"""Generic combo row for an enum-like step attribute.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, Gtk + +from .step_row import StepRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class ComboRow(StepRow): + """A combo row bound to an enum-like step attribute. + + ``choices`` is a sequence of ``(label, value)`` pairs where + ``value`` is what is stored on the step. + """ + + def __init__( + self, + editor: "DocEditor", + step: Any, + attr: str, + title: str, + choices: Sequence[tuple[str, Any]], + subtitle: str | None = None, + ): + self._choices = list(choices) + self._labels = [label for label, _ in choices] + self._title = title + self._subtitle = subtitle + StepRow.__init__(self, editor, step) + self.attr = attr + self.widget.connect("notify::selected", self._on_selected) + self._sync_from_step() + self._sync_dependencies() + + def build_widget(self) -> Adw.ComboRow: + model = Gtk.StringList.new(self._labels) + if self._subtitle: + return Adw.ComboRow( + title=self._title, + subtitle=self._subtitle, + model=model, + ) + return Adw.ComboRow(title=self._title, model=model) + + def _on_selected(self, row, pspec): + if self._syncing: + return + idx = self.widget.get_selected() + if idx == Gtk.INVALID_LIST_POSITION: + return + self.commit(self._choices[idx][1]) + + def set_widget_value(self, value): + for i, (label, stored) in enumerate(self._choices): + if stored == value: + if self.widget.get_selected() != i: + self.widget.set_selected(i) + return diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py new file mode 100644 index 000000000..bb5cbdbd8 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/coolant_row.py @@ -0,0 +1,55 @@ +"""Coolant method selection row for step settings.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw +from raygeo.ops.state import CoolantMode + +from rayforge.ui_gtk.icons import get_icon + +from .combo_row import ComboRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class CoolantRow(ComboRow): + """A combo row bound to the ``coolant_method`` step attribute. + + Shows an exclamation mark when the selected method is not + supported by the machine's currently selected head. + """ + + def __init__(self, editor: "DocEditor", step: Any): + choices = [ + (_("Off"), CoolantMode.OFF), + (_("Flood"), CoolantMode.FLOOD), + (_("Mist"), CoolantMode.MIST), + ] + super().__init__( + editor, + step, + "coolant_method", + _("Cooling"), + choices, + _("Coolant delivered to the workpiece while cutting"), + ) + + def build_widget(self) -> Adw.ComboRow: + row = super().build_widget() + self._warning_icon = get_icon("warning-symbolic") + self._warning_icon.set_tooltip_text( + _("This cooling method is not supported by the current machine") + ) + self._warning_icon.set_visible(False) + row.add_suffix(self._warning_icon) + return row + + def _sync_dependencies(self): + machine = self.get_machine() + if machine is None: + self._warning_icon.set_visible(False) + return + unsupported = self.step.get_unsupported_coolant_methods(machine) + self._warning_icon.set_visible(bool(unsupported)) diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py new file mode 100644 index 000000000..8d27fc779 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/cut_speed_row.py @@ -0,0 +1,37 @@ +"""Cut speed row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from .spin_row import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class CutSpeedRow(SpinRow): + """A spin row bound to the base ``Step.cut_speed`` attribute.""" + + def __init__( + self, + editor: "DocEditor", + step: Any, + title: str = _("Cut Speed"), + ): + super().__init__( + editor, + step, + "cut_speed", + title, + _("Speed of the cutting operation"), + 1.0, + float(getattr(step, "max_cut_speed", 10000.0)), + 10.0, + 0, + is_int=True, + ) + + def _sync_dependencies(self): + max_speed = getattr(self.step, "max_cut_speed", None) + if max_speed: + self.set_range(1.0, float(max_speed)) diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py new file mode 100644 index 000000000..a8caf8a1e --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/head_row.py @@ -0,0 +1,63 @@ +"""Laser head selection row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from blinker import Signal +from gi.repository import Adw, Gtk + +from rayforge.core.capability import MachineCapability + +from .step_row import StepRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class HeadRow(StepRow): + """A combo row for the base ``Step.selected_head_uid`` attribute. + + The row emits ``head_changed`` instead of committing directly: + the head change is a transaction (kerf sync, PWM defaults) that + is domain-specific, so the owning settings widget performs it. + """ + + def __init__(self, editor: "DocEditor", step: Any): + machine = getattr(editor.context, "machine", None) + heads = machine.heads if machine else [] + self._heads = [ + h for h in heads if h.machine_capability is MachineCapability.LASER + ] + self._machine = machine + self.head_changed = Signal() + StepRow.__init__(self, editor, step) + self.attr = "selected_head_uid" + self.set_visible(machine is not None) + self.widget.connect("notify::selected", self._on_selected) + self._sync_from_step() + self._sync_dependencies() + + def build_widget(self) -> Adw.ComboRow: + labels = [_("None")] + [h.name for h in self._heads] + return Adw.ComboRow( + title=_("Laser Head"), + model=Gtk.StringList.new(labels), + ) + + def _on_selected(self, row, pspec): + idx = self.widget.get_selected() + if idx == Gtk.INVALID_LIST_POSITION: + return + uid = None if idx == 0 else self._heads[idx - 1].uid + if uid == getattr(self.step, "selected_head_uid", None): + return + self.head_changed.send(self, head_uid=uid) + + def set_widget_value(self, value): + idx = 0 + for i, head in enumerate(self._heads, start=1): + if head.uid == value: + idx = i + break + if self.widget.get_selected() != idx: + self.widget.set_selected(idx) diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/slider_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/slider_row.py new file mode 100644 index 000000000..d6d898a3d --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/slider_row.py @@ -0,0 +1,88 @@ +"""Generic slider row for one numeric step attribute.""" + +import locale +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, Gtk + +from rayforge.ui_gtk.shared.slider import create_slider + +from .step_row import DebouncedMixin, StepRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class SliderRow(DebouncedMixin, StepRow): + """A slider row bound to one numeric step attribute.""" + + def __init__( + self, + editor: "DocEditor", + step: Any, + attr: str, + title: str, + subtitle: str | None, + lower: float, + upper: float, + step_inc: float, + digits: int, + ): + self._adj = Gtk.Adjustment( + lower=lower, + upper=upper, + step_increment=step_inc, + page_increment=step_inc * 10, + ) + self._digits = digits + self._title = title + self._subtitle = subtitle + DebouncedMixin.__init__(self) + StepRow.__init__(self, editor, step) + self.attr = attr + self._scale.connect("value-changed", self._on_scale) + self._sync_from_step() + self._sync_dependencies() + + def build_widget(self) -> Adw.ActionRow: + if self._subtitle: + row = Adw.ActionRow(title=self._title, subtitle=self._subtitle) + else: + row = Adw.ActionRow(title=self._title) + value_text = self._format(self._adj.get_value()) + self._value_label = Gtk.Label(label=value_text) + self._value_label.add_css_class("dim-label") + self._value_label.set_width_chars(6) + self._scale = create_slider( + adjustment=self._adj, + digits=self._digits, + draw_value=False, + ) + row.add_suffix(self._value_label) + row.add_suffix(self._scale) + return row + + def _format(self, value: float) -> str: + return locale.format_string(f"%.{self._digits}f", value) + + def _on_scale(self, scale): + self._value_label.set_text(self._format(self._adj.get_value())) + if self._syncing: + return + self._debounced(self.commit, self._adj.get_value()) + + def set_widget_value(self, value): + if value is None: + return + target = float(value) + if abs(self._adj.get_value() - target) > 1e-9: + self._adj.set_value(target) + self._value_label.set_text(self._format(target)) + + def set_range(self, lower: float, upper: float): + if ( + abs(self._adj.get_lower() - lower) > 1e-9 + or abs(self._adj.get_upper() - upper) > 1e-9 + ): + self._adj.set_lower(lower) + self._adj.set_upper(upper) diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/spin_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/spin_row.py new file mode 100644 index 000000000..56fa5a6ba --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/spin_row.py @@ -0,0 +1,107 @@ +"""Generic spin row for one numeric step attribute.""" + +from typing import TYPE_CHECKING, Any + +from rayforge.ui_gtk.shared import pref_rows +from rayforge.ui_gtk.shared.pref_rows import ( + AccelerationSpinRow, + LengthSpinRow, + SpeedSpinRow, +) + +from .step_row import DebouncedMixin, StepRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + +_UNIT_ROW_CLASSES = { + "length": LengthSpinRow, + "speed": SpeedSpinRow, + "acceleration": AccelerationSpinRow, +} + + +class SpinRow(DebouncedMixin, StepRow): + """A spin row bound to one numeric step attribute.""" + + def __init__( + self, + editor: "DocEditor", + step: Any, + attr: str, + title: str, + subtitle: str | None, + lower: float, + upper: float, + step_inc: float, + digits: int, + is_int: bool = False, + quantity: str | None = None, + ): + self.is_int = is_int + self._digits = digits + self._title = title + self._subtitle = subtitle + self.quantity = quantity + self._lower = lower + self._upper = upper + self._step_inc = step_inc + DebouncedMixin.__init__(self) + StepRow.__init__(self, editor, step) + self.attr = attr + self.widget.value_changed.connect(self._on_changed) + self._sync_from_step() + self._sync_dependencies() + + def build_widget(self): + if self.quantity in _UNIT_ROW_CLASSES: + cls = _UNIT_ROW_CLASSES[self.quantity] + return cls( + self._title, + self._subtitle, + lower=self._lower, + upper=self._upper, + step_increment=self._step_inc, + digits=self._digits, + ) + return pref_rows.SpinRow( + self._title, + self._subtitle, + lower=self._lower, + upper=self._upper, + step_increment=self._step_inc, + digits=self._digits, + ) + + def _on_changed(self, *args): + if self._syncing: + return + if self.quantity: + value = self.widget.get_value_in_base_units() + if self.is_int: + value = round(value) + else: + value = ( + self.widget.get_int_value() + if self.is_int + else self.widget.get_value() + ) + self._debounced(self.commit, value) + + def set_widget_value(self, value): + if value is None: + return + if self.quantity: + self.widget.set_value_in_base_units(float(value)) + return + target = float(value) + if abs(self.widget.get_value() - target) > 1e-9: + self.widget.set_value(target) + + def set_range(self, lower: float, upper: float): + adj = self.widget.get_adjustment() + if ( + abs(adj.get_lower() - lower) > 1e-9 + or abs(adj.get_upper() - upper) > 1e-9 + ): + self.widget.set_range(lower, upper) diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py new file mode 100644 index 000000000..f3279b7ae --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/step_row.py @@ -0,0 +1,148 @@ +"""Base row wrapper for step settings. + +A ``StepRow`` wraps an ``Adw.PreferencesRow`` widget (exposed as +``.widget``) because several Adw row types are final GTypes and +cannot be subclassed. Every row edits one step attribute and +re-syncs its value and dependent state whenever the step changes. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import GLib + +from rayforge.core.undo import ChangePropertyCommand + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class StepRow: + """Base wrapper for a row that edits one step attribute. + + Subclasses set ``attr`` to the step attribute they edit, build + the underlying widget in ``build_widget``, and may override + ``set_widget_value`` and ``_sync_dependencies``. + """ + + attr: str = "" + + def __init__(self, editor: "DocEditor", step: Any): + self.editor = editor + self.step = step + self.history_manager = editor.history_manager + self._syncing = False + self.widget: Any = self.build_widget() + step.updated.connect(self._on_step_updated) + + def build_widget(self) -> Any: + raise NotImplementedError + + def _on_step_updated(self, *args): + # Do not clobber an in-progress edit: a row with uncommitted + # user input would otherwise be overwritten by the external + # step change. + if not self._has_pending_edit(): + self._sync_from_step() + self._sync_dependencies() + + def _has_pending_edit(self) -> bool: + """Whether the row has uncommitted user input.""" + return False + + def _sync_from_step(self): + if not self.attr: + return + self._syncing = True + try: + self.set_widget_value(getattr(self.step, self.attr, None)) + finally: + self._syncing = False + + def resync(self): + """Force the widget to reflect the current step value. + + Unlike the ``updated``-driven sync, this ignores any pending + (debounced) user edit so a recipe application or model reset is + shown immediately. + """ + cancel = getattr(self, "cancel_pending", None) + if callable(cancel): + cancel() + self._sync_from_step() + self._sync_dependencies() + + def set_widget_value(self, value: Any): + pass + + def _sync_dependencies(self): + pass + + def cleanup(self): + pass + + def set_visible(self, visible: bool): + self.widget.set_visible(visible) + + def set_sensitive(self, sensitive: bool): + self.widget.set_sensitive(sensitive) + + def get_machine(self): + return getattr(self.editor.context, "machine", None) + + def get_selected_head(self): + machine = self.get_machine() + if machine is None: + return None + return self.step.get_selected_head(machine) + + def commit(self, value: Any, name: str | None = None): + if not self.attr: + return + if getattr(self.step, self.attr, None) == value: + return + setter_name = f"set_{self.attr}" + setter = getattr(self.step, setter_name, None) + command = ChangePropertyCommand( + target=self.step, + property_name=self.attr, + new_value=value, + setter_method_name=setter_name if setter else None, + name=name + or _("Change {key}").format(key=self.attr.replace("_", " ")), + on_change_callback=None + if setter + else lambda: self.step.updated.send(self.step), + ) + self.history_manager.execute(command) + + +class DebouncedMixin: + """Debounced commit helper for rows that fire frequent changes.""" + + def __init__(self): + self._debounce_timer = 0 + + def _debounced(self, callback, *args): + if self._debounce_timer > 0: + GLib.source_remove(self._debounce_timer) + self._debounce_timer = GLib.timeout_add( + 300, self._fire_debounce, callback, args + ) + + def _fire_debounce(self, callback, args): + self._debounce_timer = 0 + callback(*args) + return GLib.SOURCE_REMOVE + + def _has_pending_edit(self) -> bool: + return self._debounce_timer > 0 + + def cancel_pending(self): + """Cancel a scheduled debounce without committing it.""" + if self._debounce_timer > 0: + GLib.source_remove(self._debounce_timer) + self._debounce_timer = 0 + + def cleanup(self): + self.cancel_pending() diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/switch_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/switch_row.py new file mode 100644 index 000000000..f2650a1e6 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/switch_row.py @@ -0,0 +1,44 @@ +"""Generic switch row for a boolean step attribute.""" + +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw + +from .step_row import StepRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class SwitchRow(StepRow): + """A switch row bound to a boolean step attribute.""" + + def __init__( + self, + editor: "DocEditor", + step: Any, + attr: str, + title: str, + subtitle: str | None = None, + ): + self._title = title + self._subtitle = subtitle + StepRow.__init__(self, editor, step) + self.attr = attr + self.widget.connect("notify::active", self._on_active) + self._sync_from_step() + self._sync_dependencies() + + def build_widget(self) -> Adw.SwitchRow: + if self._subtitle: + return Adw.SwitchRow(title=self._title, subtitle=self._subtitle) + return Adw.SwitchRow(title=self._title) + + def _on_active(self, row, pspec): + if self._syncing: + return + self.commit(self.widget.get_active()) + + def set_widget_value(self, value): + if value is not None and self.widget.get_active() != bool(value): + self.widget.set_active(bool(value)) diff --git a/rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py b/rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py new file mode 100644 index 000000000..856dd37b9 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/rows/travel_speed_row.py @@ -0,0 +1,37 @@ +"""Travel speed row widget.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from .spin_row import SpinRow + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + +class TravelSpeedRow(SpinRow): + """A spin row bound to the base ``Step.travel_speed`` attribute.""" + + def __init__( + self, + editor: "DocEditor", + step: Any, + title: str = _("Travel Speed"), + ): + super().__init__( + editor, + step, + "travel_speed", + title, + _("Speed of rapid positioning moves"), + 1.0, + float(getattr(step, "max_travel_speed", 10000.0)), + 10.0, + 0, + is_int=True, + ) + + def _sync_dependencies(self): + max_speed = getattr(self.step, "max_travel_speed", None) + if max_speed: + self.set_range(1.0, float(max_speed)) diff --git a/rayforge/ui_gtk/doceditor/step_type_selection_dialog.py b/rayforge/ui_gtk/doceditor/step_type_selection_dialog.py new file mode 100644 index 000000000..d19a73a7d --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_type_selection_dialog.py @@ -0,0 +1,127 @@ +"""A multi-select dialog for choosing step types (recipe targeting).""" + +from collections.abc import Callable +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...core.step_registry import step_registry +from ..icons import get_icon +from ..shared.gtk import apply_css + +css = """ +.step-type-selector-list { + background: none; +} +""" + + +class StepTypeSelectionDialog(Adw.MessageDialog): + """A searchable, multi-select list of registered step types. + + Mirrors the look of :class:`RecipeSelectorDialog`. Each row shows + the step's icon and ``TYPELABEL`` with a check button suffix. The + selection is returned (in list order) via ``on_select_callback``. + """ + + class _StepTypeRow(Adw.ActionRow): + def __init__(self, step_class_name: str, **kwargs): + super().__init__(**kwargs) + self.step_type = step_class_name + + def __init__( + self, + parent: Gtk.Window | None, + selected: set[str], + on_select_callback: Callable[[list[str]], None], + ): + super().__init__(transient_for=parent) + self.on_select_callback = on_select_callback + self._selected: set[str] = set(selected) + + self.set_heading(_("Select Step Types")) + self.set_body(_("Choose which step types this recipe applies to.")) + + apply_css(css) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_top(12) + self.set_extra_child(content_box) + + self.search_entry = Gtk.SearchEntry(placeholder_text=_("Search...")) + self.search_entry.connect( + "search-changed", lambda *_: self._filter_list() + ) + content_box.append(self.search_entry) + + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + min_content_height=300, + vexpand=True, + ) + scrolled_window.add_css_class("card") + content_box.append(scrolled_window) + + self.list_box = Gtk.ListBox() + self.list_box.set_selection_mode(Gtk.SelectionMode.NONE) + self.list_box.add_css_class("step-type-selector-list") + scrolled_window.set_child(self.list_box) + + self._all_rows: list[StepTypeSelectionDialog._StepTypeRow] = [] + self._populate() + + self.add_response("cancel", _("Cancel")) + self.add_response("apply", _("Apply")) + self.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED) + self.set_default_response("apply") + self.connect("response", self._on_response) + + def _populate(self): + step_classes = [ + cls for cls in step_registry.all_steps().values() if not cls.HIDDEN + ] + step_classes.sort(key=lambda c: c.TYPELABEL or c.__name__) + + for cls in step_classes: + name = cls.__name__ + row = self._StepTypeRow( + step_class_name=name, + title=cls.TYPELABEL or name, + ) + + icon = get_icon(cls.ICON or "step-symbolic") + icon.set_valign(Gtk.Align.CENTER) + row.add_prefix(icon) + + check = Gtk.CheckButton() + check.set_active(name in self._selected) + check.set_valign(Gtk.Align.CENTER) + check.connect("notify::active", self._on_check_toggled, name) + row.add_suffix(check) + + self.list_box.append(row) + self._all_rows.append(row) + + def _on_check_toggled(self, check: Gtk.CheckButton, _pspec, name: str): + if check.get_active(): + self._selected.add(name) + else: + self._selected.discard(name) + + def _filter_list(self): + search_text = self.search_entry.get_text().lower() + for row in self._all_rows: + label = row.get_title().lower() + row.set_visible(not search_text or search_text in label) + + def _on_response(self, _dialog, response_id: str): + if response_id == "apply": + # Preserve the visible list order. + ordered = [ + row.step_type + for row in self._all_rows + if row.step_type in self._selected + ] + self.on_select_callback(ordered) + self.close() diff --git a/rayforge/ui_gtk/doceditor/stock_properties_dialog.py b/rayforge/ui_gtk/doceditor/stock_properties_dialog.py new file mode 100644 index 000000000..1c2e288d4 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/stock_properties_dialog.py @@ -0,0 +1,217 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, GLib, Gtk + +from ...context import get_context +from ...core.stock import StockItem +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from .material_selector import MaterialSelectorDialog + +if TYPE_CHECKING: + from ...core.material import Material + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class StockPropertiesDialog(PatchedDialogWindow): + """ + A non-modal window for editing stock item properties. + """ + + def __init__( + self, parent: Gtk.Window, stock_item: StockItem, editor: "DocEditor" + ): + super().__init__(transient_for=parent) + self.stock_item = stock_item + self.editor = editor + self.doc = editor.doc + + # Used to delay updates from continuous-change widgets + self._debounce_timer = 0 + self._debounced_callback = None + self._debounced_args: tuple = () + + # Connect to stock item updates to refresh UI + self.stock_item.updated.connect(self.on_stock_item_updated) + + # Make sure to disconnect when the dialog is destroyed + self.connect("destroy", self._on_destroy) + + self.set_title(_("Stock Properties")) + self.set_default_size(500, 400) + self.set_modal(False) + self.set_resizable(True) + + # Create a vertical box to hold the header bar and the content + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + + # Add a header bar for title and window controls (like close) + header = Adw.HeaderBar() + main_box.append(header) + + # Create the main content + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content_box.set_margin_top(24) + content_box.set_margin_bottom(24) + content_box.set_margin_start(24) + content_box.set_margin_end(24) + main_box.append(content_box) + + # Properties group + properties_group = Adw.PreferencesGroup() + + # Name field + self.name_row = Adw.EntryRow() + self.name_row.set_title(_("Name")) + self.name_row.set_text(self.stock_item.name) + self.name_row.connect("changed", self.on_name_changed) + properties_group.add(self.name_row) + + # Thickness field + self.thickness_row = LengthSpinRow( + _("Thickness"), + _("Material thickness"), + upper=999, + ) + if self.stock_item.thickness is not None: + self.thickness_row.set_value_in_base_units( + self.stock_item.thickness + ) + self.thickness_row.value_changed.connect(self.on_thickness_changed) + properties_group.add(self.thickness_row) + + # Material display row + self.material_row = Adw.ActionRow() + self.material_row.set_title(_("Material")) + + # Add a button to open the material selector + self.material_button = Gtk.Button(label=_("Select")) + self.material_button.set_valign(Gtk.Align.CENTER) + self.material_button.connect("clicked", self.on_select_material) + self.material_row.add_suffix(self.material_button) + + properties_group.add(self.material_row) + + # Initialize material display + self._update_material_display() + + content_box.append(properties_group) + + 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) + + def _debounce(self, callback, *args, delay_ms=300): + """ + Debounce a callback function to avoid excessive updates. + """ + if self._debounce_timer: + GLib.source_remove(self._debounce_timer) + self._debounce_timer = 0 + + self._debounced_callback = callback + self._debounced_args = args + self._debounce_timer = GLib.timeout_add( + delay_ms, self._on_debounce_timer + ) + + def _on_debounce_timer(self): + """ + Called when the debounce timer expires. + """ + self._debounce_timer = 0 + if self._debounced_callback: + callback = self._debounced_callback + args = self._debounced_args + self._debounced_callback = None + self._debounced_args = () + callback(*args) + return False # Don't repeat the timer + + def on_name_changed(self, entry): + """Handle name entry changes with instant apply.""" + new_name = entry.get_text() + if new_name and new_name != self.stock_item.name: + self._debounce(self._apply_name_change, new_name) + + def on_thickness_changed(self, row: LengthSpinRow): + """Handle thickness changes with instant apply.""" + new_thickness = row.get_value_in_base_units() + if new_thickness != self.stock_item.thickness: + self._debounce(self._apply_thickness_change, new_thickness) + + def on_select_material(self, button: Gtk.Button): + """Shows the material selector dialog.""" + dialog = MaterialSelectorDialog( + parent=self, on_select_callback=self._on_material_selected + ) + dialog.present() + + def _on_material_selected(self, material_uid: str | None): + """Callback for when a material is selected from the dialog.""" + if material_uid is not None: + self.editor.stock.set_stock_material(self.stock_item, material_uid) + + def _apply_name_change(self, new_name): + """Apply the name change.""" + stock_asset = self.stock_item.stock_asset + if stock_asset and new_name and new_name != stock_asset.name: + self.editor.asset.rename_asset(stock_asset, new_name) + + def on_stock_item_updated(self, sender, **kwargs): + """Update the UI when the stock item changes.""" + # Update name if it has changed + if self.name_row.get_text() != self.stock_item.name: + self.name_row.set_text(self.stock_item.name) + + # Update the thickness field if it has changed + if self.stock_item.thickness is not None: + self.thickness_row.set_value_in_base_units( + self.stock_item.thickness + ) + + # Update the material display if it has changed + self._update_material_display() + + def _apply_thickness_change(self, new_thickness): + """Apply the thickness change.""" + if new_thickness != self.stock_item.thickness: + self.editor.stock.set_stock_thickness( + self.stock_item, new_thickness + ) + + def _update_material_display(self): + """Update the material display label.""" + if not self.stock_item.material_uid: + self.material_row.set_subtitle(_("None")) + return + + material = self.stock_item.material + if material: + library_name = self._get_material_library_name(material) + if library_name: + self.material_row.set_subtitle( + f"{library_name}: {material.name}" + ) + else: + self.material_row.set_subtitle(material.name) + else: + self.material_row.set_subtitle( + f"❓ {self.stock_item.material_uid}" + ) + + def _get_material_library_name(self, material: "Material") -> str | None: + """Get the display name of the library that contains this material.""" + material_mgr = get_context().material_mgr + # Search through all libraries to find which one contains this material + for library in material_mgr.get_libraries(): + if library.get_material(material.uid): + return library.display_name + + return None diff --git a/rayforge/ui_gtk/doceditor/workflow_row.py b/rayforge/ui_gtk/doceditor/workflow_row.py new file mode 100644 index 000000000..d644e04a0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/workflow_row.py @@ -0,0 +1,450 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Gdk, Gio, Gtk + +from ...context import get_context +from ...core.step_registry import step_registry +from ...core.undo.list_cmd import ListItemCommand, ReorderListCommand +from ..icons import get_icon +from ..shared.gtk import apply_css +from ..shared.popover_menu import PopoverMenu +from .step_settings.dialog import StepSettingsDialog + +if TYPE_CHECKING: + from ...core.layer import Layer + from ...core.workflow import Workflow + from ...doceditor.editor import DocEditor + +css = """ +.workflow-row { + min-height: 36px; + padding: 0px 3px; + margin-bottom: 3px; + background-color: alpha(@theme_fg_color, 0.04); + border-bottom: 1px solid @borders; +} +.workflow-step-button { + min-width: 28px; + min-height: 28px; + padding: 0px; + margin: 2px; + border-radius: 6px; +} +.workflow-step-button:hover { + background-color: alpha(@theme_fg_color, 0.08); +} +.workflow-arrow { + margin: 0 -1px; +} +.workflow-drop-indicator { + min-width: 2px; + min-height: 24px; + background-color: @accent_color; + border-radius: 1px; +} +""" + +_FALLBACK_ICON = "laser-path-symbolic" + + +class WorkflowRow(Gtk.Box): + def __init__( + self, + editor: "DocEditor", + layer: "Layer", + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL) + apply_css(css) + self.add_css_class("workflow-row") + self.set_hexpand(True) + + self.editor = editor + self.layer = layer + self._workflow: Workflow | None = None + self._drag_source_uid: str | None = None + self._potential_drop_index: int = -1 + self._step_buttons: list = [] + self._btn_uids: dict = {} + self._drop_indicator: Gtk.Box | None = None + self._context_popover: Gtk.PopoverMenu | None = None + self._context_step = None + + actions = Gio.SimpleActionGroup() + delete_action = Gio.SimpleAction.new("delete", None) + delete_action.connect("activate", self._on_delete_action) + actions.add_action(delete_action) + self.insert_action_group("step", actions) + + self._setup_drag_source() + self._setup_drop_target() + + self._connect_machine() + self._connect_workflow() + self._rebuild() + + right_click = Gtk.GestureClick() + right_click.set_button(Gdk.BUTTON_SECONDARY) + right_click.connect("pressed", self._on_right_click_pressed) + self.add_controller(right_click) + + def _setup_drag_source(self): + self._drag_source = Gtk.DragSource() + self._drag_source.set_actions(Gdk.DragAction.MOVE) + self._drag_source.connect("prepare", self._on_drag_prepare) + self._drag_source.connect("drag-end", self._on_drag_end) + self._drag_source.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + self.add_controller(self._drag_source) + + def _setup_drop_target(self): + self._drop_target = Gtk.DropTarget.new(str, Gdk.DragAction.MOVE) + self._drop_target.connect("drop", self._on_drop) + self._drop_target.connect("motion", self._on_drop_motion) + self._drop_target.connect("leave", self._on_drop_leave) + self.add_controller(self._drop_target) + + def _connect_machine(self): + machine = get_context().machine + if machine: + machine.changed.connect(self._on_machine_changed) + + def _disconnect_machine(self): + machine = get_context().machine + if machine: + try: + machine.changed.disconnect(self._on_machine_changed) + except (TypeError, ValueError): + pass + + def _on_machine_changed(self, sender, **kwargs): + self._rebuild() + + def _connect_workflow(self): + self._disconnect_workflow() + self._workflow = self.layer.workflow + if self._workflow: + 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 + ) + self._workflow.updated.connect(self._on_workflow_changed) + + def _disconnect_workflow(self): + if not self._workflow: + return + try: + self._workflow.descendant_added.disconnect( + self._on_workflow_changed + ) + self._workflow.descendant_removed.disconnect( + self._on_workflow_changed + ) + self._workflow.descendant_updated.disconnect( + self._on_workflow_changed + ) + self._workflow.updated.disconnect(self._on_workflow_changed) + except (TypeError, ValueError): + pass + self._workflow = None + + def refresh(self): + current_workflow = self.layer.workflow + if current_workflow is not self._workflow: + self._connect_workflow() + self._rebuild() + + def _on_workflow_changed(self, sender, **kwargs): + self._rebuild() + + def _get_step_icon(self, step) -> str: + return step.ICON or _FALLBACK_ICON + + def _get_step_color(self, step) -> str | None: + if not step.visible: + return None + machine = get_context().machine + if not machine or not machine.heads: + return None + head = step.get_selected_head(machine) + if head is None: + return None + return step.get_operation_color(head) + + def _apply_step_color(self, button: Gtk.Button, step): + color = self._get_step_color(step) + if not color: + return + class_name = f"step-color-{color.lstrip('#').lower()}" + color_css = ( + f".workflow-step-button.{class_name} {{" + f" border: 2px solid {color};" + "}" + ) + apply_css(color_css) + button.add_css_class(class_name) + + def _rebuild(self): + self._drag_source_uid = None + self._potential_drop_index = -1 + self._step_buttons = [] + self._btn_uids = {} + + child = self.get_first_child() + while child: + next_child = child.get_next_sibling() + self.remove(child) + child = next_child + + workflow = self._workflow + if not workflow or not workflow.steps: + label = Gtk.Label(label=_("No Operations")) + label.add_css_class("dim-label") + label.add_css_class("caption") + label.set_margin_start(6) + self.append(label) + else: + for i, step in enumerate(workflow.steps): + if i > 0: + arrow = get_icon("go-next-symbolic") + arrow.add_css_class("workflow-arrow") + arrow.set_valign(Gtk.Align.CENTER) + self.append(arrow) + + icon = get_icon(self._get_step_icon(step)) + icon.set_pixel_size(18) + + button = Gtk.Button(child=icon) + button.add_css_class("workflow-step-button") + button.add_css_class("flat") + button.set_tooltip_text(step.name) + button.set_valign(Gtk.Align.CENTER) + button.connect( + "clicked", self._make_step_clicked_handler(step) + ) + self._apply_step_color(button, step) + self._btn_uids[id(button)] = step.uid + self._step_buttons.append(button) + self.append(button) + + spacer = Gtk.Box() + spacer.set_hexpand(True) + self.append(spacer) + + add_icon = get_icon("add-symbolic") + add_btn = Gtk.Button(child=add_icon) + add_btn.add_css_class("flat") + add_btn.set_tooltip_text(_("Add Step")) + add_btn.set_valign(Gtk.Align.CENTER) + add_btn.connect("clicked", self._on_add_step_clicked) + self.append(add_btn) + + def _step_uid_at(self, x: float) -> str | None: + for btn in self._step_buttons: + alloc = btn.get_allocation() + if alloc.x <= x < alloc.x + alloc.width: + return self._btn_uids.get(id(btn)) + return None + + def _btn_at(self, x: float) -> Gtk.Button | None: + for btn in self._step_buttons: + alloc = btn.get_allocation() + if alloc.x <= x < alloc.x + alloc.width: + return btn + return None + + def _step_index_at(self, x: float) -> int: + for i, btn in enumerate(self._step_buttons): + alloc = btn.get_allocation() + if x < alloc.x + alloc.width / 2: + return i + return len(self._step_buttons) + + def _on_drag_prepare(self, source, x, y): + uid = self._step_uid_at(x) + if not uid: + return None + + btn = self._btn_at(x) + if btn: + snapshot = Gtk.Snapshot() + Gtk.Widget.do_snapshot(btn, snapshot) + paintable = snapshot.to_paintable() + if paintable: + btn_alloc = btn.get_allocation() + source.set_icon( + paintable, btn_alloc.width / 2, btn_alloc.height / 2 + ) + + self._drag_source_uid = uid + self._potential_drop_index = -1 + return Gdk.ContentProvider.new_for_value(uid) + + def _on_drag_end(self, source, drag, delete_data): + if delete_data and self._potential_drop_index != -1: + self._commit_reorder() + self._drag_source_uid = None + self._potential_drop_index = -1 + self._remove_drop_indicator() + + def _on_drop(self, drop_target, value, x, y): + if not self._drag_source_uid: + return False + return self._potential_drop_index != -1 + + def _on_drop_motion(self, drop_target, x, y): + if not self._drag_source_uid: + return Gdk.DragAction(0) + self._potential_drop_index = self._step_index_at(x) + self._show_drop_indicator(self._potential_drop_index) + return Gdk.DragAction.MOVE + + def _on_drop_leave(self, drop_target): + self._potential_drop_index = -1 + self._remove_drop_indicator() + + def _show_drop_indicator(self, index: int): + self._remove_drop_indicator() + if index < 0: + return + indicator = Gtk.Box() + indicator.add_css_class("workflow-drop-indicator") + indicator.set_valign(Gtk.Align.CENTER) + self._drop_indicator = indicator + if index >= len(self._step_buttons): + last_btn = self._step_buttons[-1] if self._step_buttons else None + if last_btn: + self.insert_child_after(indicator, last_btn) + else: + self.prepend(indicator) + else: + btn = self._step_buttons[index] + prev = btn.get_prev_sibling() + if prev: + self.insert_child_after(indicator, prev) + else: + self.prepend(indicator) + + def _remove_drop_indicator(self): + if self._drop_indicator: + self.remove(self._drop_indicator) + self._drop_indicator = None + + def _commit_reorder(self): + workflow = self._workflow + if not workflow or not workflow.doc: + return + steps = list(workflow.steps) + source_index = None + for i, s in enumerate(steps): + if s.uid == self._drag_source_uid: + source_index = i + break + if source_index is None: + return + target_index = self._potential_drop_index + if source_index == target_index: + return + new_order = list(steps) + moved = new_order.pop(source_index) + insert_at = target_index + if source_index < target_index: + insert_at -= 1 + new_order.insert(insert_at, moved) + command = ReorderListCommand( + target_obj=workflow, + list_property_name="steps", + new_list=new_order, + setter_method_name="set_steps", + name=_("Reorder steps"), + ) + workflow.doc.history_manager.execute(command) + + def _make_step_clicked_handler(self, step): + def handler(button): + StepSettingsDialog.present_for_step( + self.editor, step, self.get_root() + ) + + return handler + + def _on_add_step_clicked(self, button): + workflow = self._workflow + if not workflow or not workflow.doc: + return + machine = self.editor.context.machine + popup = PopoverMenu( + step_factories=step_registry.get_factories( + machine.get_capabilities() if machine else None + ), + context=self.editor.context, + ) + popup.set_parent(self) + popup.popup() + popup.connect("closed", self._on_add_step_dialog_response) + + def _on_add_step_dialog_response(self, popup): + workflow = self._workflow + if not workflow or not workflow.doc: + return + if popup.selected_item: + step_factory = popup.selected_item + new_step = step_factory(self.editor.context) + self.editor.step.apply_best_recipe_to_step(new_step) + command = ListItemCommand( + owner_obj=workflow, + item=new_step, + undo_command="remove_step", + redo_command="add_step", + name=_("Add step '{name}'").format(name=new_step.name), + ) + workflow.doc.history_manager.execute(command) + StepSettingsDialog.present_for_step( + self.editor, new_step, self.get_root() + ) + + def _on_right_click_pressed(self, gesture, n_press, x, y): + uid = self._step_uid_at(x) + workflow = self._workflow + if not uid or not workflow: + return + step = next((s for s in workflow.steps if s.uid == uid), None) + if step is None: + return + self._context_step = step + menu = Gio.Menu.new() + menu.append_item(Gio.MenuItem.new(_("Delete"), "step.delete")) + self._popup_context_menu(menu, gesture) + + def _popup_context_menu(self, menu: Gio.Menu, gesture: Gtk.Gesture): + if self._context_popover: + self._context_popover.unparent() + popover = Gtk.PopoverMenu.new_from_model(menu) + popover.set_parent(self) + popover.set_has_arrow(False) + ok, rect = gesture.get_bounding_box() + if ok: + popover.set_pointing_to(rect) + self._context_popover = popover + popover.popup() + + def _on_delete_action(self, action, param): + workflow = self._workflow + step = self._context_step + if not workflow or not workflow.doc or step is None: + return + new_list = [s for s in workflow.steps if s is not step] + command = ReorderListCommand( + target_obj=workflow, + list_property_name="steps", + new_list=new_list, + setter_method_name="set_steps", + name=_("Remove step '{name}'").format(name=step.name), + ) + workflow.doc.history_manager.execute(command) + + def do_destroy(self): + self._disconnect_machine() + self._disconnect_workflow() diff --git a/rayforge/ui_gtk/doceditor/workflow_view.py b/rayforge/ui_gtk/doceditor/workflow_view.py new file mode 100644 index 000000000..ee01dd37e --- /dev/null +++ b/rayforge/ui_gtk/doceditor/workflow_view.py @@ -0,0 +1,200 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, cast + +from gi.repository import Gtk + +from ...core.step_registry import step_registry +from ...core.undo.list_cmd import ListItemCommand, ReorderListCommand +from ...core.workflow import Workflow +from ..shared.draglist import DragListBox +from ..shared.expander import ExpanderWithButton +from ..shared.popover_menu import PopoverMenu +from .step_box import StepBox +from .step_settings.dialog import StepSettingsDialog + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class WorkflowView(ExpanderWithButton): + """ + A widget that displays a collapsible, reorderable list of Steps + for a given Workflow. + """ + + def __init__( + self, + editor: "DocEditor", + workflow: Workflow, + **kwargs, + ): + super().__init__(button_label=_("Add New Step..."), **kwargs) + self.workflow: Workflow | None = None + self.editor = editor + self.set_expanded(True) + + self.draglist = DragListBox() + self.draglist.reordered.connect(self.on_workflow_reordered) + self.append_content(self.draglist) + + self.add_button.connect("clicked", self.on_button_add_clicked) + + self.set_workflow(workflow) + + def set_workflow(self, workflow: Workflow | None): + """Sets the view to display a different workflow.""" + if self.workflow: + try: + # Disconnect old handlers + self.workflow.updated.disconnect(self.on_workflow_changed) + self.workflow.descendant_added.disconnect( + self.on_workflow_changed + ) + self.workflow.descendant_removed.disconnect( + self.on_workflow_changed + ) + self.workflow.descendant_updated.disconnect( + self.on_workflow_changed + ) + except (TypeError, ValueError): + pass + + self.workflow = workflow + self.set_visible(bool(self.workflow)) + + if self.workflow: + # 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.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_changed(self, sender, **kwargs): + """ + Handles any change to the workflow (structural or property) by + updating the UI completely. + """ + if not self.workflow: + return + + # Update metadata + count = len(self.workflow.steps) + self.set_title(self.workflow.name) + self.set_subtitle( + _("{count} step").format(count=count) + if count == 1 + else _("{count} steps").format(count=count) + ) + + # Rebuild the list of step widgets + self.update_list() + + def update_list(self): + """ + Re-populates the draglist to match the state of the workflow's steps. + """ + if not self.workflow or not self.workflow.doc: + return + + # Check if the list of steps is already in sync to avoid unnecessary + # rebuilds. + current_steps = [row.data for row in self.draglist] # type: ignore + if current_steps == self.workflow.steps: + # The list structure is the same, just tell each stepbox to + # update its summary. + for i, row in enumerate(self.draglist): + row = cast(Gtk.ListBoxRow, row) + stepbox = row.stepbox # type: ignore + stepbox.set_step_number(i + 1) + stepbox.on_step_changed(stepbox.step) + return + + # If the list structure has changed, rebuild it completely. + self.draglist.remove_all() + for seq, step in enumerate(self.workflow, start=1): + row = Gtk.ListBoxRow() + row.data = step # type: ignore # Store model for reordering + stepbox = StepBox( + self.editor, + step, + step_number=seq, + ) + stepbox.delete_clicked.connect(self.on_button_delete_clicked) + row.stepbox = stepbox # type: ignore + row.set_child(stepbox) + self.draglist.add_row(row) + + def on_button_add_clicked(self, button): + """Shows a popup to select and add a new step type.""" + if not self.workflow or not self.workflow.doc: + return + + machine = self.editor.context.machine + popup = PopoverMenu( + step_factories=step_registry.get_factories( + machine.get_capabilities() if machine else None + ), + context=self.editor.context, + ) + popup.set_parent(button) + popup.popup() + popup.connect("closed", self.on_add_dialog_response) + + def on_add_dialog_response(self, popup: PopoverMenu): + """Handles the creation of a new step after the popup closes.""" + if not self.workflow or not self.workflow.doc: + return + if popup.selected_item: + step_factory = popup.selected_item + new_step = step_factory(self.editor.context) + + # Apply best recipe using helper method + self.editor.step.apply_best_recipe_to_step(new_step) + + command = ListItemCommand( + owner_obj=self.workflow, + item=new_step, + undo_command="remove_step", + redo_command="add_step", + name=_("Add step '{name}'").format(name=new_step.name), + ) + self.workflow.doc.history_manager.execute(command) + + # Open the step settings dialog for the new step + StepSettingsDialog.present_for_step( + self.editor, new_step, self.get_root() + ) + + def on_button_delete_clicked(self, sender, step, **kwargs): + """Handles deletion of a step with an undoable command.""" + if not self.workflow or not self.workflow.doc: + return + new_list = [s for s in self.workflow.steps if s is not step] + command = ReorderListCommand( + target_obj=self.workflow, + list_property_name="steps", + new_list=new_list, + setter_method_name="set_steps", + name=_("Remove step '{name}'").format(name=step.name), + ) + self.workflow.doc.history_manager.execute(command) + + def on_workflow_reordered(self, sender, **kwargs): + """Handles reordering of steps with an undoable command.""" + if not self.workflow or not self.workflow.doc: + return + new_order = [row.data for row in self.draglist] # type: ignore + command = ReorderListCommand( + target_obj=self.workflow, + list_property_name="steps", + new_list=new_order, + setter_method_name="set_steps", + name=_("Reorder steps"), + ) + self.workflow.doc.history_manager.execute(command) diff --git a/rayforge/ui_gtk/doceditor/workpiece_row.py b/rayforge/ui_gtk/doceditor/workpiece_row.py new file mode 100644 index 000000000..cde370aa9 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/workpiece_row.py @@ -0,0 +1,202 @@ +import logging + +from gi.repository import Gdk, Gtk, Pango + +from ...core.workpiece import WorkPiece +from ..icons import get_icon +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + +_ICON_MAP = { + ".svg": "file-svg-generic-symbolic", + ".png": "file-png-generic-symbolic", + ".jpg": "file-jpg-generic-symbolic", + ".jpeg": "file-jpg-generic-symbolic", + ".dxf": "file-dxf-generic-symbolic", + ".pdf": "file-pdf-generic-symbolic", + ".rd": "file-rd-generic-symbolic", +} + +_RENAME_CSS = """ +.layer-workpiece-list .layer-rename-entry { + min-height: 0; + padding: 1px 6px; +} +""" + + +class WorkpieceRow(Gtk.Box): + def __init__(self, workpiece: WorkPiece, on_rename=None): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.workpiece = workpiece + self._on_rename = on_rename + self._rename_entry = None + self._rename_click_controller = None + self.set_margin_start(6) + self.set_margin_end(6) + self.set_margin_top(4) + self.set_margin_bottom(4) + + apply_css(_RENAME_CSS) + + icon_name = self._get_icon_name() + self.icon = get_icon(icon_name) + self.icon.set_valign(Gtk.Align.CENTER) + self.append(self.icon) + + self.name_label = Gtk.Label() + self.name_label.set_hexpand(True) + self.name_label.set_halign(Gtk.Align.START) + self.name_label.set_valign(Gtk.Align.CENTER) + self.name_label.set_ellipsize(Pango.EllipsizeMode.END) + self.append(self.name_label) + + click = Gtk.GestureClick() + click.set_button(Gdk.BUTTON_PRIMARY) + click.connect("pressed", self._on_double_clicked) + self.add_controller(click) + + self._update_ui() + + workpiece.updated.connect(self._on_workpiece_updated) + + def do_destroy(self): + self.workpiece.updated.disconnect(self._on_workpiece_updated) + self._remove_rename_click_controller() + + def get_drag_content(self) -> Gdk.ContentProvider: + return Gdk.ContentProvider.new_for_value(self.workpiece.uid) + + def _get_icon_name(self) -> str: + source = self.workpiece.source + if source and source.source_file: + suffix = source.source_file.suffix.lower() + return _ICON_MAP.get(suffix, "image-x-generic-symbolic") + return "image-x-generic-symbolic" + + def _get_display_name(self) -> str: + display_name = self.workpiece.name + if not display_name: + source = self.workpiece.source + if source and source.name: + display_name = source.name + return display_name + + def _update_ui(self): + self.name_label.set_text(self._get_display_name()) + + def _on_workpiece_updated(self, sender, **kwargs): + self._update_ui() + + def _on_drag_prepare(self, drag_source, x, y): + logger.debug( + "DragPrepare(%s): uid=%s", + self.workpiece.name, + self.workpiece.uid[:8], + ) + snapshot = Gtk.Snapshot() + WorkpieceRow.do_snapshot(self, snapshot) + paintable = snapshot.to_paintable() + if paintable: + drag_source.set_icon(paintable, x, y) + return self.get_drag_content() + + def _on_double_clicked(self, gesture, n_press, x, y): + if n_press != 2: + return + if self.workpiece.geometry_provider_uid: + return + self.start_rename() + + def start_rename(self): + """Starts in-place editing of the item name.""" + if self._rename_entry is not None: + return + entry = Gtk.Entry() + entry.set_text(self._get_display_name()) + entry.select_region(0, -1) + entry.set_hexpand(True) + entry.set_halign(Gtk.Align.START) + entry.set_valign(Gtk.Align.CENTER) + entry.add_css_class("layer-rename-entry") + entry.connect("activate", self._on_rename_committed) + focus_controller = Gtk.EventControllerFocus.new() + focus_controller.connect("leave", self._on_rename_focus_out) + entry.add_controller(focus_controller) + key_controller = Gtk.EventControllerKey.new() + key_controller.connect("key-pressed", self._on_rename_key_pressed) + entry.add_controller(key_controller) + self._rename_entry = entry + self.remove(self.name_label) + self.append(entry) + entry.grab_focus() + self._install_rename_click_capture() + + def _install_rename_click_capture(self): + """Closes the editor when clicking anywhere outside the entry.""" + root = self.get_ancestor(Gtk.Window) + if root is None: + return + controller = Gtk.GestureClick() + controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + controller.connect("pressed", self._on_rename_root_click) + root.add_controller(controller) + self._rename_click_controller = controller + + def _remove_rename_click_controller(self): + if self._rename_click_controller is None: + return + widget = self._rename_click_controller.get_widget() + if widget: + widget.remove_controller(self._rename_click_controller) + self._rename_click_controller = None + + def _on_rename_root_click(self, gesture, n_press, x, y): + if self._rename_entry is None: + return + entry = self._rename_entry + root = self.get_ancestor(Gtk.Window) + picked = root.pick(x, y, Gtk.PickFlags.DEFAULT) if root else None + while picked is not None and picked is not root: + if picked is entry: + return + picked = picked.get_parent() + self._finish_rename(entry) + + def _on_rename_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_Escape: + self._cancel_rename() + return True + return False + + def _on_rename_committed(self, entry): + self._finish_rename(entry) + + def _on_rename_focus_out(self, *args): + self._finish_rename(self._rename_entry) + + def _cancel_rename(self): + if self._rename_entry is None: + return + self._replace_name_widget() + self._update_ui() + + def _finish_rename(self, entry): + if self._rename_entry is None: + return + new_name = entry.get_text().strip() + self._replace_name_widget() + if new_name and new_name != self.workpiece.name: + if self._on_rename: + self._on_rename(self.workpiece, new_name) + else: + self._update_ui() + + def _replace_name_widget(self): + if self._rename_entry is None: + return + self._remove_rename_click_controller() + self.remove(self._rename_entry) + self._rename_entry = None + self.append(self.name_label) diff --git a/rayforge/ui_gtk/icons.py b/rayforge/ui_gtk/icons.py new file mode 100644 index 000000000..8f4e22fba --- /dev/null +++ b/rayforge/ui_gtk/icons.py @@ -0,0 +1,111 @@ +import importlib.resources +import logging +import pathlib +from functools import lru_cache + +from gi.repository import GdkPixbuf, Gio, GLib, Gtk + +from ..resources import icons # type: ignore + +logger = logging.getLogger(__name__) + +_icon_search_paths: list[pathlib.Path] = [] + +# Global cache for loaded icons to avoid repeated expensive operations +# We cache the Gio.Icon or icon name, not the Gtk.Image widget itself +_icon_cache: dict[str, Gio.Icon | str] = {} + + +def register_icon_path(path): + """ + Register an additional directory to search for icons. + + Addons should call this to make their icons available via + ``get_icon()``. Registered paths are searched before the + built-in ``rayforge.resources.icons`` package. + """ + p = pathlib.Path(path) + if p.is_dir() and p not in _icon_search_paths: + _icon_search_paths.append(p) + + +def get_icon_path(icon_name) -> pathlib.Path: + """ + Retrieve the path of an icon, searching addon-registered paths + first, then the built-in resource directory. + """ + filename = f"{icon_name}.svg" + for search_path in _icon_search_paths: + candidate = search_path / filename + if candidate.is_file(): + return candidate + with importlib.resources.path(icons, filename) as path: + return path + + +def get_icon(icon_name: str) -> Gtk.Image: + """ + Retrieve a Gtk.Image, prioritizing a local file from the resource + directory before falling back to the system theme. + + Icons are cached to avoid repeated expensive loading operations. + """ + # Check cache first + if icon_name in _icon_cache: + cached_value = _icon_cache[icon_name] + if isinstance(cached_value, Gio.Icon): + return Gtk.Image.new_from_gicon(cached_value) + else: # icon name string + return Gtk.Image.new_from_icon_name(cached_value) + + # First, attempt to load the icon from a local file path. + path = get_icon_path(icon_name) + if path and path.is_file(): + logger.debug(f"Using local icon for '{icon_name}' from: {path}") + try: + icon_file = Gio.File.new_for_path(str(path)) + icon = Gio.FileIcon.new(icon_file) + _icon_cache[icon_name] = icon + return Gtk.Image.new_from_gicon(icon) + except GLib.Error as e: + logger.error(f"Failed to load local icon '{icon_name}': {e}") + # Continue to fallback... + + # If local file doesn't exist or failed to load, fall back to the theme. + logger.debug(f"Icon for '{icon_name}' not found. Falling back to theme.") + _icon_cache[icon_name] = icon_name + return Gtk.Image.new_from_icon_name(icon_name) + + +@lru_cache +def get_icon_pixbuf(icon_name: str, size: int = 24): + """ + Retrieve a GdkPixbuf for Cairo rendering, prioritizing a local file + from the resource directory. + + Args: + icon_name: Name of the icon (without .svg extension) + size: Size of the icon in pixels + + Returns: + GdkPixbuf.Pixbuf: The loaded icon as a pixbuf, or None if failed + """ + # First, attempt to load the icon from a local file path. + path = get_icon_path(icon_name) + if path and path.is_file(): + logger.debug(f"Using local icon for '{icon_name}' from: {path}") + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), size, size, True + ) + return pixbuf + except GLib.Error as e: + logger.error(f"Failed to load local icon '{icon_name}': {e}") + + # Return None if icon couldn't be loaded + return None + + +def clear_icon_cache(): + """Clear the icon cache. Useful for testing or theme changes.""" + _icon_cache.clear() diff --git a/rayforge/ui_gtk/machine/__init__.py b/rayforge/ui_gtk/machine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/machine/advanced_preferences_page.py b/rayforge/ui_gtk/machine/advanced_preferences_page.py new file mode 100644 index 000000000..b09bd90f1 --- /dev/null +++ b/rayforge/ui_gtk/machine/advanced_preferences_page.py @@ -0,0 +1,137 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw + +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class AdvancedPreferencesPage(TrackedPreferencesPage): + key = "advanced" + path_prefix = "/machine-settings/" + + def __init__(self, machine, **kwargs): + super().__init__( + title=_("Advanced"), + icon_name="machine-settings-advanced-symbolic", + **kwargs, + ) + self.machine = machine + + path_group = Adw.PreferencesGroup(title=_("Path Processing")) + path_group.set_description( + _("Configure how paths are processed and optimized.") + ) + self.add(path_group) + + self.arcs_row = Adw.SwitchRow( + title=_("Support Arcs"), + subtitle=_( + "Generate arc commands for smoother paths. " + "Disable if your machine does not support arcs" + ), + ) + self.arcs_row.set_active(self.machine.supports_arcs) + self.arcs_row.connect("notify::active", self.on_arcs_changed) + path_group.add(self.arcs_row) + + self.curves_row = Adw.SwitchRow( + title=_("Support Bézier Curves"), + subtitle=_( + "Generate native cubic Bézier commands. " + "Disable if your machine does not support them" + ), + ) + self.curves_row.set_active(self.machine.supports_curves) + self.curves_row.connect("notify::active", self.on_curves_changed) + path_group.add(self.curves_row) + + self.arc_tolerance_row = LengthSpinRow( + _("Arc and Curve Tolerance"), + _( + "Maximum deviation from original path when " + "fitting arcs and curves. Lower values " + "drastically increase processing time and job size" + ), + lower=0.001, + upper=10.0, + step_increment=0.001, + digits=3, + value_in_base=self.machine.arc_tolerance, + ) + self.arc_tolerance_row.set_width_chars(5) + self.arc_tolerance_row.set_sensitive(self.machine.supports_arcs) + self.arc_tolerance_row.value_changed.connect( + self.on_arc_tolerance_changed + ) + path_group.add(self.arc_tolerance_row) + + homing_group = Adw.PreferencesGroup(title=_("Homing and Startup")) + homing_group.set_description( + _( + "Configure homing behavior and startup settings, " + "including automatic homing and alarm handling." + ) + ) + self.add(homing_group) + + home_on_start_row = Adw.SwitchRow() + home_on_start_row.set_title(_("Home On Start")) + home_on_start_row.set_subtitle( + _("Send a homing command when the application starts") + ) + home_on_start_row.set_active(machine.home_on_start) + home_on_start_row.connect( + "notify::active", self.on_home_on_start_changed + ) + homing_group.add(home_on_start_row) + + single_axis_homing_row = Adw.SwitchRow() + single_axis_homing_row.set_title(_("Allow Single Axis Homing")) + single_axis_homing_row.set_subtitle( + _("Enable individual axis homing controls in the jog dialog") + ) + single_axis_homing_row.set_active(machine.single_axis_homing_enabled) + single_axis_homing_row.connect( + "notify::active", self.on_single_axis_homing_changed + ) + homing_group.add(single_axis_homing_row) + + clear_alarm_row = Adw.SwitchRow() + clear_alarm_row.set_title(_("Clear Alarm On Connect")) + clear_alarm_row.set_subtitle( + _( + "Automatically send an unlock command if " + "connected in an ALARM state" + ) + ) + clear_alarm_row.set_active(machine.clear_alarm_on_connect) + clear_alarm_row.connect( + "notify::active", self.on_clear_alarm_on_connect_changed + ) + homing_group.add(clear_alarm_row) + + 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()) + self.arc_tolerance_row.set_sensitive(self.machine.supports_arcs) + + def on_arc_tolerance_changed(self, spinrow): + """Update to machine's arc tolerance when value changes.""" + self.machine.set_arc_tolerance(spinrow.get_value_in_base_units()) + + def on_curves_changed(self, switch_row, _param): + """Update the machine's curve support when the value changes.""" + self.machine.set_supports_curves(switch_row.get_active()) + + def on_home_on_start_changed(self, row, _): + self.machine.set_home_on_start(row.get_active()) + + def on_single_axis_homing_changed(self, row, _): + self.machine.set_single_axis_homing_enabled(row.get_active()) + + def on_clear_alarm_on_connect_changed(self, row, _): + self.machine.set_clear_alarm_on_connect(row.get_active()) diff --git a/rayforge/ui_gtk/machine/capabilities_page.py b/rayforge/ui_gtk/machine/capabilities_page.py new file mode 100644 index 000000000..283fdc4b0 --- /dev/null +++ b/rayforge/ui_gtk/machine/capabilities_page.py @@ -0,0 +1,83 @@ +from gettext import gettext as _ + +from gi.repository import Adw + +from ...core.capability import MachineCapability +from ...machine.models.laser import LaserHead +from ...machine.models.machine import Machine +from ...machine.models.spindle import SpindleHead +from ..shared.preferences_page import TrackedPreferencesPage + + +class CapabilitiesPage(TrackedPreferencesPage): + """Machine settings page showing the machine's capabilities.""" + + key = "capabilities" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("Capabilities"), + icon_name="settings-symbolic", + **kwargs, + ) + self.machine = machine + self._rows: list[Adw.ActionRow] = [] + + self.capability_group = Adw.PreferencesGroup( + title=_("Machine Capabilities"), + description=_( + "Capabilities are inferred from the machine's heads, " + "rotary modules, and any explicit configuration. They " + "control which steps are offered when adding to a " + "workflow." + ), + ) + self.add(self.capability_group) + + self.machine.changed.connect(self._refresh) + self._refresh() + + self.connect("destroy", self._on_destroy) + + def _source_text(self, capability: MachineCapability) -> str: + """Describes where a capability comes from.""" + sources = [] + for head in self.machine.heads: + if head.machine_capability == capability: + if isinstance(head, LaserHead): + sources.append(_("Laser Head")) + elif isinstance(head, SpindleHead): + sources.append(_("Spindle Head")) + else: + sources.append(head.name) + if capability == MachineCapability.ROTARY: + for module in self.machine.rotary_modules.values(): + sources.append(module.name) + if self.machine._explicit_capabilities and ( + capability in self.machine._explicit_capabilities + ): + sources.append(_("explicit configuration")) + if not sources: + return _("unknown source") + return ", ".join(sources) + + def _refresh(self, sender=None, **kwargs): + """Rebuilds the capability list from the machine.""" + for row in self._rows: + self.capability_group.remove(row) + self._rows.clear() + + for cap in sorted( + self.machine.get_capabilities(), key=lambda c: c.value + ): + row = Adw.ActionRow( + title=cap.label, + subtitle=f"{cap.description} · {self._source_text(cap)}", + ) + self.capability_group.add(row) + self._rows.append(row) + + def _on_destroy(self, *args): + """Disconnects signals to prevent memory leaks.""" + self.machine.changed.disconnect(self._refresh) diff --git a/rayforge/ui_gtk/machine/connection_status_widget.py b/rayforge/ui_gtk/machine/connection_status_widget.py new file mode 100644 index 000000000..3949d2751 --- /dev/null +++ b/rayforge/ui_gtk/machine/connection_status_widget.py @@ -0,0 +1,116 @@ +from gettext import gettext as _ + +from gi.repository import Gtk + +from ...machine.driver.dummy import NoDeviceDriver +from ...machine.models.machine import Machine +from ...machine.transport.transport import ( + TRANSPORT_STATUS_LABELS, + TransportStatus, +) +from ..icons import get_icon + + +class ConnectionStatusIconWidget(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + + # Placeholder for the image widget + self.status_image: Gtk.Widget | None = None + + # Set the initial status + self.set_status(TransportStatus.DISCONNECTED) + + def set_status(self, status): + """Update the status icon based on the given status.""" + icon_name = self._get_icon_name_for_status(status) + + # Get the new image widget from the helper + new_image = get_icon(icon_name) + + # Remove the old image if it exists + if self.status_image is not None: + self.remove(self.status_image) + + # Set and add the new image + self.status_image = new_image + if self.status_image: + self.append(self.status_image) + + def _get_icon_name_for_status(self, status): + """Map the status to an appropriate icon name.""" + if status == TransportStatus.UNKNOWN: + return "question-box-symbolic" + elif status == TransportStatus.IDLE: + return "status-idle-symbolic" + elif status == TransportStatus.CONNECTING: + return "status-connecting-symbolic" + elif status == TransportStatus.CONNECTED: + return "status-connected-symbolic" + elif status == TransportStatus.ERROR: + return "error-symbolic" + elif ( + status == TransportStatus.CLOSING + or status == TransportStatus.DISCONNECTED + ): + return "status-offline-symbolic" + elif status == TransportStatus.SLEEPING: + return "sleep-symbolic" + else: + return "status-offline-symbolic" # Default icon + + +class ConnectionStatusWidget(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.machine: Machine | None = None + + self.label = Gtk.Label() + self.append(self.label) + + self.icon = ConnectionStatusIconWidget() + self.append(self.icon) + + self._update_display(TransportStatus.DISCONNECTED) + + def set_machine(self, machine: Machine | None): + if self.machine: + try: + self.machine.connection_status_changed.disconnect( + self._on_connection_status_changed + ) + except TypeError: + pass # Was not connected + + self.machine = machine + + if self.machine: + self.machine.connection_status_changed.connect( + self._on_connection_status_changed + ) + # Set initial state from the machine object + self._update_display(self.machine.connection_status) + else: + self._update_display(None) + + def _on_connection_status_changed( + self, + machine: Machine, + status: TransportStatus, + message: str | None = None, + ): + self._update_display(status) + + def _update_display(self, status: TransportStatus | None): + is_nodriver = not self.machine or isinstance( + self.machine.driver, NoDeviceDriver + ) + + if is_nodriver or status is None: + self.label.set_label(_("Disconnected")) + self.icon.set_status(TransportStatus.DISCONNECTED) + else: + self.label.set_label( + TRANSPORT_STATUS_LABELS.get(status, _("Disconnected")) + ) + self.icon.set_status(status) diff --git a/rayforge/ui_gtk/machine/console.py b/rayforge/ui_gtk/machine/console.py new file mode 100644 index 000000000..ccb785b58 --- /dev/null +++ b/rayforge/ui_gtk/machine/console.py @@ -0,0 +1,573 @@ +import logging +import warnings +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Gdk, GLib, Gtk + +from ...logging_setup import ( + UILogFilter, + get_ui_formatter, + get_ui_log_records, +) +from ...machine.driver.dummy import NoDeviceDriver +from ...machine.models.machine import Machine +from ...usage import get_usage_tracker +from ..icons import get_icon +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + +css = """ +.terminal { + font-family: Monospace; + font-size: 10pt; +} +.console-input { + font-family: Monospace; + font-size: 10pt; + background-color: transparent; + border: none; + padding: 4px; +} +.console-input-scrolled { + background-color: alpha(@window_fg_color, 0.05); + border-radius: 4px; +} +""" + + +class Console(Gtk.Box): + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + + self.set_margin_start(0) + self.set_margin_end(0) + self.set_margin_top(9) + self.set_margin_bottom(9) + + self._show_verbose = False + self._command_history: list[str] = [] + self._history_index = -1 + self._history_max = 1000 + self._machine: Machine | None = None + self._max_input_lines = 5 + self._single_line_height = 24 + self._auto_scroll = True + + self._setup_ui() + self._setup_tags() + self.command_submitted = Signal() + self._populate_history() + + def _setup_ui(self): + self.terminal = Gtk.TextView() + self.terminal.set_editable(False) + self.terminal.set_cursor_visible(False) + self.terminal.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.terminal.add_css_class("terminal") + apply_css(css) + + self.scrolled_window = Gtk.ScrolledWindow() + self.scrolled_window.set_vexpand(True) + self.scrolled_window.set_hexpand(True) + self.scrolled_window.set_child(self.terminal) + + scroll_controller = Gtk.EventControllerScroll( + flags=Gtk.EventControllerScrollFlags.VERTICAL + ) + scroll_controller.connect("scroll", self._on_user_scroll) + self.scrolled_window.add_controller(scroll_controller) + + self.overlay = Gtk.Overlay() + self.overlay.set_child(self.scrolled_window) + + self.verbose_toggle = Gtk.ToggleButton() + self.verbose_toggle.set_active(False) + self.verbose_toggle.set_tooltip_text( + _("Show verbose output (status polls)") + ) + self.verbose_toggle.connect("toggled", self._on_verbose_toggled) + self.verbose_toggle.set_margin_top(9) + self.verbose_toggle.set_margin_end(9) + self.verbose_toggle.set_halign(Gtk.Align.END) + self.verbose_toggle.set_valign(Gtk.Align.START) + verbose_icon = get_icon("code-symbolic") + self.verbose_toggle.set_child(verbose_icon) + self.overlay.add_overlay(self.verbose_toggle) + + self.search_entry = Gtk.SearchEntry() + self.search_bar = Gtk.SearchBar(child=self.search_entry) + self.search_bar.set_key_capture_widget(self) + self.search_entry.connect("search-changed", self._on_search_changed) + self.search_entry.connect("stop-search", self._on_stop_search) + + self.append(self.search_bar) + self.append(self.overlay) + + search_ctrl = Gtk.EventControllerKey() + search_ctrl.connect("key-pressed", self._on_search_key_pressed) + self.add_controller(search_ctrl) + + entry_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + entry_box.set_margin_top(0) + + self.input_scrolled = Gtk.ScrolledWindow() + self.input_scrolled.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + self.input_scrolled.set_min_content_height(self._single_line_height) + self.input_scrolled.set_max_content_height( + self._single_line_height * self._max_input_lines + ) + self.input_scrolled.set_propagate_natural_height(True) + self.input_scrolled.add_css_class("console-input-scrolled") + + self.console_input = Gtk.TextView() + self.console_input.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.console_input.add_css_class("console-input") + self.console_input.set_hexpand(True) + apply_css(css) + + self.input_buffer = self.console_input.get_buffer() + self.input_buffer.connect("changed", self._on_input_changed) + + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_input_key_pressed) + self.console_input.add_controller(key_controller) + + self.input_scrolled.set_child(self.console_input) + entry_box.append(self.input_scrolled) + + self.append(entry_box) + + def _setup_tags(self): + tag_table = self.terminal.get_buffer().get_tag_table() + # GTK deprecates get_style_context()/lookup_color() in 4.10, but the + # replacement (gtk_widget_lookup_color) is not exposed by PyGObject. + # The GTK authors clarified that these warnings only mean the code + # breaks on GTK 5 which does not even exist yet, so they are safe + # to ignore for now. + # https://gitlab.gnome.org/GNOME/gtk/-/work_items/5262#note_1575295 + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + style_context = self.terminal.get_style_context() + self._create_tags(tag_table, style_context) + + def _create_tags(self, tag_table, style_context): + self._create_tag( + tag_table, + "timestamp", + foreground=self._lookup_theme_color( + style_context, "dim_label_color", "#888A85" + ), + ) + self._create_tag( + tag_table, + "user_command", + foreground=self._lookup_theme_color( + style_context, "accent_color", "#729FCF" + ), + weight=700, + ) + self._create_tag( + tag_table, + "error", + foreground=self._lookup_theme_color( + style_context, "error_color", "#EF2929" + ), + ) + self._create_tag( + tag_table, + "warning", + foreground=self._lookup_theme_color( + style_context, "warning_color", "#F57900" + ), + ) + self._create_tag( + tag_table, + "status_dim", + foreground=self._lookup_theme_color( + style_context, "dim_label_color", "#888A85" + ), + ) + self._create_tag( + tag_table, + "status_state", + foreground=self._lookup_theme_color( + style_context, "success_color", "#8AE234" + ), + ) + self._create_tag( + tag_table, + "status_coord", + foreground=self._lookup_theme_color( + style_context, "accent_color", "#729FCF" + ), + ) + found, color = style_context.lookup_color("theme_selected_bg_color") + self.search_tag = self._create_tag( + tag_table, + "search", + background=color.to_string() if found and color else "#4A90D9", + ) + + def _lookup_theme_color( + self, context: Gtk.StyleContext, name: str, fallback: str + ) -> str: + found, color = context.lookup_color(name) + if found and color: + return color.to_string() + return fallback + + def _create_tag( + self, tag_table: Gtk.TextTagTable, name: str, **properties + ): + tag = Gtk.TextTag.new(name) + for prop, value in properties.items(): + tag.set_property(prop, value) + tag_table.add(tag) + return tag + + def set_machine(self, machine: Machine | None): + self._machine = machine + self._update_sensitivity() + + def _populate_history(self): + ui_formatter = get_ui_formatter() + if not ui_formatter: + return + + ui_filter = UILogFilter() + machine_id = self._machine.id if self._machine else None + log_records = [ + record + for record in get_ui_log_records() + if ui_filter.filter(record) + and ( + self._show_verbose + or record.__dict__.get("log_category") + not in UILogFilter.VERBOSE_CATEGORIES + ) + and ( + machine_id is None + or record.__dict__.get("machine_id") is None + or record.__dict__.get("machine_id") == machine_id + ) + ] + + text_buffer = self.terminal.get_buffer() + text_buffer.set_text("", -1) + for record in log_records: + formatted = ui_formatter.format(record) + category = record.__dict__.get("log_category") + self._append_highlighted(formatted, category) + GLib.idle_add(self._scroll_to_bottom_and_track) + + def _is_at_bottom(self) -> bool: + vadjustment = self.scrolled_window.get_vadjustment() + page_size = vadjustment.get_page_size() + if page_size == 0: + return True + max_value = vadjustment.get_upper() - page_size + threshold = min(page_size * 0.1, 50.0) + return vadjustment.get_value() >= max_value - threshold + + def _on_user_scroll(self, controller, dx, dy): + if dy < 0: + self._auto_scroll = False + elif self._is_at_bottom(): + self._auto_scroll = True + return False + + def _highlight_status_poll( + self, text_buffer, message: str, base_offset: int + ): + if not (message.startswith("<") and message.endswith(">")): + text_buffer.insert(text_buffer.get_end_iter(), f"{message}\n", -1) + return + + content = message[1:-1] + parts = content.split("|") + full_text = "<" + "|".join(parts) + ">\n" + + text_buffer.insert(text_buffer.get_end_iter(), full_text, -1) + + offset = base_offset + text_buffer.apply_tag_by_name( + "status_dim", + text_buffer.get_iter_at_offset(offset), + text_buffer.get_iter_at_offset(offset + 1), + ) + offset += 1 + + for i, part in enumerate(parts): + if ":" in part: + key, value = part.split(":", 1) + text_buffer.apply_tag_by_name( + "status_coord", + text_buffer.get_iter_at_offset(offset), + text_buffer.get_iter_at_offset(offset + len(key)), + ) + offset += len(key) + 1 + len(value) + else: + text_buffer.apply_tag_by_name( + "status_state", + text_buffer.get_iter_at_offset(offset), + text_buffer.get_iter_at_offset(offset + len(part)), + ) + offset += len(part) + + if i < len(parts) - 1: + text_buffer.apply_tag_by_name( + "status_dim", + text_buffer.get_iter_at_offset(offset), + text_buffer.get_iter_at_offset(offset + 1), + ) + offset += 1 + + text_buffer.apply_tag_by_name( + "status_dim", + text_buffer.get_iter_at_offset(offset), + text_buffer.get_iter_at_offset(offset + 1), + ) + + def _append_highlighted(self, message: str, category: str | None): + text_buffer = self.terminal.get_buffer() + end_iter = text_buffer.get_end_iter() + + if category == "USER_COMMAND": + start_offset = end_iter.get_offset() + text_buffer.insert(end_iter, f"{message}\n", -1) + start_iter = text_buffer.get_iter_at_offset(start_offset) + end_iter = text_buffer.get_end_iter() + text_buffer.apply_tag_by_name("user_command", start_iter, end_iter) + elif category == "STATUS_POLL": + if message and len(message) > 19 and message[19] == " ": + timestamp = message[:19] + rest = message[20:] + start_offset = end_iter.get_offset() + text_buffer.insert(end_iter, f"{timestamp} ", -1) + tag_start = text_buffer.get_iter_at_offset(start_offset) + tag_end = text_buffer.get_iter_at_offset(start_offset + 20) + text_buffer.apply_tag_by_name("timestamp", tag_start, tag_end) + self._highlight_status_poll( + text_buffer, rest, start_offset + 20 + ) + else: + start_offset = end_iter.get_offset() + self._highlight_status_poll(text_buffer, message, start_offset) + elif category == "ERROR": + if message.startswith("ERROR "): + start_offset = end_iter.get_offset() + text_buffer.insert(end_iter, "ERROR ", -1) + tag_start = text_buffer.get_iter_at_offset(start_offset) + tag_end = text_buffer.get_iter_at_offset(start_offset + 5) + text_buffer.apply_tag_by_name("error", tag_start, tag_end) + end_iter = text_buffer.get_end_iter() + text_buffer.insert(end_iter, f"{message[6:]}\n", -1) + else: + text_buffer.insert(end_iter, f"{message}\n", -1) + elif category == "WARNING": + if message.startswith("WARN "): + start_offset = end_iter.get_offset() + text_buffer.insert(end_iter, "WARN ", -1) + tag_start = text_buffer.get_iter_at_offset(start_offset) + tag_end = text_buffer.get_iter_at_offset(start_offset + 4) + text_buffer.apply_tag_by_name("warning", tag_start, tag_end) + end_iter = text_buffer.get_end_iter() + text_buffer.insert(end_iter, f"{message[5:]}\n", -1) + else: + text_buffer.insert(end_iter, f"{message}\n", -1) + elif message and len(message) > 19 and message[19] == " ": + timestamp = message[:19] + rest = message[20:] + start_offset = end_iter.get_offset() + text_buffer.insert(end_iter, f"{timestamp} ", -1) + tag_start = text_buffer.get_iter_at_offset(start_offset) + tag_end = text_buffer.get_iter_at_offset(start_offset + 20) + text_buffer.apply_tag_by_name("timestamp", tag_start, tag_end) + end_iter = text_buffer.get_end_iter() + text_buffer.insert(end_iter, f"{rest}\n", -1) + else: + text_buffer.insert(end_iter, f"{message}\n", -1) + + def append_to_terminal(self, message: str, category: str | None = None): + self._append_highlighted(message, category) + if self._auto_scroll: + self._scroll_to_bottom() + + def _scroll_to_bottom(self): + text_buffer = self.terminal.get_buffer() + end_iter = text_buffer.get_end_iter() + mark = text_buffer.create_mark("end_mark", end_iter, False) + self.terminal.scroll_to_mark(mark, 0.0, False, 0.0, 0.0) + text_buffer.delete_mark(mark) + + def _scroll_to_bottom_and_track(self): + self._auto_scroll = True + self._scroll_to_bottom() + + def on_log_received( + self, + sender, + message: str | None = None, + category: str | None = None, + machine_id: str | None = None, + ): + if not message: + return + if self._machine and machine_id and machine_id != self._machine.id: + return + if ( + category in UILogFilter.VERBOSE_CATEGORIES + and not self._show_verbose + ): + return + GLib.idle_add(self.append_to_terminal, message, category) + + def _on_verbose_toggled(self, button): + self._show_verbose = button.get_active() + self._populate_history() + + def _on_search_changed(self, search_entry): + buffer = self.terminal.get_buffer() + text = search_entry.get_text() + + buffer.remove_tag( + self.search_tag, buffer.get_start_iter(), buffer.get_end_iter() + ) + + if not text: + return + + first_match = None + current_iter = buffer.get_start_iter() + while True: + try: + result = current_iter.forward_search( + text, Gtk.TextSearchFlags.CASE_INSENSITIVE, None + ) + if result is None: + break + start, end = result + if first_match is None: + first_match = start + buffer.apply_tag(self.search_tag, start, end) + current_iter = end + except GLib.Error: + break + + if first_match is not None: + self.terminal.scroll_to_iter(first_match, 0.0, True, 0.5, 0.5) + + def _on_stop_search(self, search_entry): + self.search_bar.set_search_mode(False) + + def _on_search_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_f and state & Gdk.ModifierType.CONTROL_MASK: + self.search_bar.set_search_mode(True) + self.search_entry.grab_focus() + return True + return False + + def _get_input_text(self) -> str: + start = self.input_buffer.get_start_iter() + end = self.input_buffer.get_end_iter() + return self.input_buffer.get_text(start, end, False) + + def _set_input_text(self, text: str): + self.input_buffer.set_text(text, -1) + + def _on_input_changed(self, buffer): + line_count = buffer.get_line_count() + height = min( + line_count * self._single_line_height, + self._single_line_height * self._max_input_lines, + ) + self.input_scrolled.set_min_content_height( + max(height, self._single_line_height) + ) + + def _on_input_key_pressed( + self, controller, keyval, keycode, state + ) -> bool: + if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter: + if state & Gdk.ModifierType.SHIFT_MASK: + return False + self._send_commands() + return True + elif keyval == Gdk.KEY_Up: + self._navigate_history(-1) + return True + elif keyval == Gdk.KEY_Down: + self._navigate_history(1) + return True + return False + + def _send_commands(self): + machine = self._machine + if not machine: + return + + text = self._get_input_text().strip() + if not text: + return + + is_dummy = isinstance(machine.driver, NoDeviceDriver) + is_connected = machine.is_connected() + + if not is_connected and not is_dummy: + logger.error( + "Machine not connected", + extra={"log_category": "ERROR", "machine_id": machine.id}, + ) + return + + commands = [line.strip() for line in text.split("\n") if line.strip()] + self._set_input_text("") + + get_usage_tracker().track_page_view("/console/send", "Console Send") + + for command in commands: + self._add_to_history(command) + self.command_submitted.send(self, command=command, machine=machine) + + def _add_to_history(self, command: str): + if self._command_history and self._command_history[-1] == command: + return + self._command_history.append(command) + if len(self._command_history) > self._history_max: + self._command_history.pop(0) + self._history_index = len(self._command_history) + + def _navigate_history(self, direction: int): + if not self._command_history: + return + + new_index = self._history_index + direction + + if new_index < 0: + new_index = 0 + elif new_index >= len(self._command_history): + new_index = len(self._command_history) + self._set_input_text("") + self._history_index = new_index + return + + self._history_index = new_index + command = self._command_history[new_index] + self._set_input_text(command) + + def _update_sensitivity(self): + if not self._machine: + sensitive = False + else: + is_dummy = isinstance(self._machine.driver, NoDeviceDriver) + is_connected = self._machine.is_connected() + sensitive = is_connected or is_dummy + + self.console_input.set_sensitive(sensitive) + + def on_machine_state_changed(self, machine, state): + self._update_sensitivity() diff --git a/rayforge/ui_gtk/machine/device_settings_page.py b/rayforge/ui_gtk/machine/device_settings_page.py new file mode 100644 index 000000000..051649ed2 --- /dev/null +++ b/rayforge/ui_gtk/machine/device_settings_page.py @@ -0,0 +1,412 @@ +import logging +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Adw, Gdk, GLib, Gtk + +from ...context import get_context +from ...machine.driver.driver import ( + DeviceStatus, + ResourceBusyError, +) +from ..icons import get_icon +from ..shared.preferences_page import TrackedPreferencesPage +from ..varset.varsetwidget import VarSet, VarSetWidget + +logger = logging.getLogger(__name__) + + +class DeviceSettingsPage(TrackedPreferencesPage): + """ + A preferences page for reading and writing device settings. + """ + + key = "device" + path_prefix = "/machine-settings/" + + def __init__(self, machine, **kwargs): + super().__init__( + title=_("Device"), + icon_name="hardware-symbolic", + **kwargs, + ) + logger.debug("__init__") + self.machine = machine + self._current_error = None + self._is_updating_from_model = False + self._varset_widgets = [] + self._error_timeout_id = 0 + self._warning_rows = [] + self._dismissed_warnings = set() + self._not_connected_warning_dismissed = False + self._is_busy = False + + self.show_toast = Signal() + + # Create a single main group for all static content + self.main_group = Adw.PreferencesGroup() + self.add(self.main_group) + self._main_group_title = _("Device Settings") + self._main_group_desc = _( + "Read or apply settings directly to the device." + ) + + # Create header controls once and store them + self.spinner = Gtk.Spinner() + self.read_button = Gtk.Button(child=get_icon("refresh-symbolic")) + self.read_button.set_tooltip_text(_("Read from Device")) + self.read_button.connect("clicked", self._on_read_clicked) + self.header_box = Gtk.Box(spacing=6) + self.header_box.append(self.spinner) + self.header_box.append(self.read_button) + self.main_group.set_header_suffix(self.header_box) + + # Banners + self.unsupported_banner = Adw.Banner( + title=_( + "The current driver does not support reading device settings." + ) + ) + self.main_group.add(self.unsupported_banner) + + # Error row with copy and close buttons + self.error_row = Adw.ActionRow(use_markup=True, activatable=False) + self.error_row.add_prefix(get_icon("error-symbolic")) + self.error_row.add_css_class("error") + + copy_button = Gtk.Button(child=get_icon("copy-symbolic")) + copy_button.set_tooltip_text(_("Copy Error Details")) + copy_button.add_css_class("flat") + copy_button.set_valign(Gtk.Align.CENTER) + copy_button.connect("clicked", self._on_copy_error_clicked) + self.error_row.add_suffix(copy_button) + + error_close_button = Gtk.Button(child=get_icon("close-symbolic")) + error_close_button.set_tooltip_text(_("Dismiss Error")) + error_close_button.add_css_class("flat") + error_close_button.set_valign(Gtk.Align.CENTER) + error_close_button.connect("clicked", self._on_error_dismissed) + self.error_row.add_suffix(error_close_button) + self.main_group.add(self.error_row) + + # Dismissible warning rows + items = [ + _( + "Editing these values can be dangerous and may render your" + " machine inoperable!" + ), + _( + "The device may restart or temporarily disconnect after a" + " setting is changed." + ), + ] + for item in items: + warning_row = Adw.ActionRow(title=item, activatable=False) + warning_row.add_prefix(get_icon("warning-symbolic")) + warning_row.add_css_class("warning") + + close_button = Gtk.Button(child=get_icon("close-symbolic")) + close_button.set_tooltip_text(_("Dismiss Warning")) + close_button.add_css_class("flat") + close_button.set_valign(Gtk.Align.CENTER) + close_button.connect( + "clicked", self._on_warning_dismissed, warning_row + ) + warning_row.add_suffix(close_button) + self.main_group.add(warning_row) + self._warning_rows.append(warning_row) + + # A group and row to prompt the user to load settings + self.prompt_group = Adw.PreferencesGroup() + prompt_row = Adw.ActionRow( + title=_( + "Click the refresh button to load settings from the device." + ), + activatable=False, + ) + prompt_row.add_prefix(get_icon("info-symbolic")) + self.prompt_group.add(prompt_row) + self.add(self.prompt_group) + + # Signal Connections & Initial State + self.machine.changed.connect(self._on_machine_config_changed) + get_context().config.changed.connect(self._on_machine_config_changed) + self.machine.connection_status_changed.connect( + self._on_connection_status_changed + ) + self.machine.state_changed.connect(self._on_state_changed) + self.machine.settings_updated.connect(self._on_settings_op_success) + self.machine.setting_applied.connect(self._on_setting_applied) + self.machine.settings_error.connect(self._on_settings_op_error) + self.connect("destroy", self.on_destroy) + + self._update_ui_state() + logger.debug("__init__ finished.") + + def on_destroy(self, _widget): + logger.debug("on_destroy: Disconnecting signals.") + self.machine.changed.disconnect(self._on_machine_config_changed) + get_context().config.changed.disconnect( + self._on_machine_config_changed + ) + self.machine.connection_status_changed.disconnect( + self._on_connection_status_changed + ) + self.machine.state_changed.disconnect(self._on_state_changed) + self.machine.settings_updated.disconnect(self._on_settings_op_success) + self.machine.setting_applied.disconnect(self._on_setting_applied) + self.machine.settings_error.disconnect(self._on_settings_op_error) + if self._error_timeout_id > 0: + GLib.source_remove(self._error_timeout_id) + + def _on_machine_config_changed(self, sender, **kwargs): + logger.debug("_on_machine_config_changed: Rebuilding UI.") + if self.machine.id not in get_context().machine_mgr.machines: + logger.debug("_on_machine_config_changed: Machine removed.") + return + self._not_connected_warning_dismissed = False + for widget in self._varset_widgets: + self.remove(widget) + self._varset_widgets.clear() + self._update_ui_state() + + def _on_connection_status_changed(self, sender, **kwargs): + logger.debug("_on_connection_status_changed: Updating UI.") + self._not_connected_warning_dismissed = False + self._update_ui_state() + + def _on_state_changed(self, sender, state, **kwargs): + logger.debug("_on_state_changed: Updating UI.") + self._update_ui_state() + + def _rebuild_settings_widgets(self, var_sets: list[VarSet]): + for widget in self._varset_widgets: + self.remove(widget) + self._varset_widgets.clear() + + if not var_sets: + return + + for var_set in var_sets: + widget = VarSetWidget(explicit_apply=True) + widget.set_title(GLib.markup_escape_text(var_set.title or "")) + if var_set.description: + widget.set_description( + GLib.markup_escape_text(var_set.description) + ) + widget.populate(var_set) + widget.data_changed.connect(self._on_setting_apply) + self.add(widget) + self._varset_widgets.append(widget) + logger.debug(f"Created {len(self._varset_widgets)} widgets.") + + def _update_ui_state(self): + logger.debug(f"_update_ui_state: Starting (is_busy={self._is_busy}).") + if self.machine.id not in get_context().machine_mgr.machines: + logger.debug("_update_ui_state: Machine removed, skipping.") + return + is_supported = self.machine.driver.supports_settings + is_connected = self.machine.is_connected() + is_running = self.machine.device_state.status == DeviceStatus.RUN + has_settings_to_show = is_supported and len(self._varset_widgets) > 0 + + # Control banners + self.unsupported_banner.set_revealed(not is_supported) + + # Control the state of the single main group + self.main_group.set_title( + self._main_group_title if is_supported else "" + ) + self.main_group.set_description( + self._main_group_desc if is_supported else "" + ) + self.header_box.set_visible(is_supported) + + for row in self._warning_rows: + row.set_visible( + is_supported and row not in self._dismissed_warnings + ) + + has_op_error = self._current_error is not None + is_not_connected_state = ( + is_supported + and not is_connected + and not self._not_connected_warning_dismissed + ) + + # The error row now also shows connection status. + self.error_row.set_visible(has_op_error or is_not_connected_state) + if has_op_error: + self.error_row.set_title(_("Operation failed")) + self.error_row.set_subtitle(self._current_error or "") + elif is_not_connected_state: + self.error_row.set_title(_("Machine Not Connected")) + self.error_row.set_subtitle(_("The machine is not connected.")) + + # The main group is visible if any of its contents are. + is_any_warning_visible = any( + row.get_visible() for row in self._warning_rows + ) + self.main_group.set_visible( + self.unsupported_banner.get_revealed() + or self.error_row.get_visible() + or is_any_warning_visible + or has_settings_to_show + ) + + # Control visibility of the dynamic settings widgets + for widget in self._varset_widgets: + widget.set_visible(has_settings_to_show) + + # Control visibility of prompt group + show_prompt = ( + is_supported + and not has_settings_to_show + and not self._is_busy + and not self.error_row.get_visible() + ) + self.prompt_group.set_visible(show_prompt) + + if self._is_busy: + self.spinner.start() + self.read_button.set_sensitive(False) + else: + self.spinner.stop() + self.read_button.set_sensitive(is_connected and not is_running) + + for widget in self._varset_widgets: + widget.set_apply_buttons_sensitive( + is_connected and not is_running and not self._is_busy + ) + logger.debug("_update_ui_state: Finished.") + + def _on_settings_op_success(self, sender, var_sets: list[VarSet]): + logger.debug("Success signal received with var_sets (from read).") + + scrolled_window = self.get_ancestor(Gtk.ScrolledWindow) + adj = ( + cast(Gtk.ScrolledWindow, scrolled_window).get_vadjustment() + if scrolled_window + else None + ) + scroll_value = ( + adj.get_value() if adj and self._varset_widgets else None + ) + + self._is_busy = False + self._clear_error_state() + + self._rebuild_settings_widgets(var_sets) + self._update_ui_state() + + if adj and scroll_value is not None: + + def restore_scroll(): + max_scroll = adj.get_upper() - adj.get_page_size() + if scroll_value <= max_scroll: + adj.set_value(scroll_value) + return GLib.SOURCE_REMOVE + + GLib.idle_add(restore_scroll) + + def _on_setting_applied(self, sender): + """Handles the successful application of a single setting.""" + logger.debug("Success signal received for applying setting.") + self._is_busy = False + self._clear_error_state() + self.show_toast.send(self, message=_("Setting applied successfully.")) + self._update_ui_state() + + def _on_settings_op_error(self, sender, error): + logger.debug(f"Error signal received: {error}") + self._is_busy = False + # Clear any stale settings from the UI by passing an empty list + self._rebuild_settings_widgets([]) + + # Friendly error message for resource busy + if isinstance(error, ResourceBusyError): + msg = _("Cannot connect: Used by '{machine}'").format( + machine=error.owner_name + ) + self._show_error(msg) + else: + self._show_error(str(error)) + + self._update_ui_state() + + def _on_setting_apply(self, sender: VarSetWidget, key: str): + self._not_connected_warning_dismissed = False + if self._is_busy: + return + if self._is_updating_from_model: + return + + new_value = sender.get_values().get(key) + if new_value is None: + return + + logger.debug(f"_on_setting_apply: Applying key '{key}'.") + self._is_busy = True + self._clear_error_state() + self._update_ui_state() + self.machine.apply_setting(key, new_value) + + def _on_read_clicked(self, _button=None): + self._not_connected_warning_dismissed = False + if self._is_busy: + return + + logger.debug("_on_read_clicked: Read button clicked.") + self._is_busy = True + self._clear_error_state() + self._update_ui_state() + self.machine.refresh_settings() + + def _on_warning_dismissed(self, _button, row_to_dismiss): + self._dismissed_warnings.add(row_to_dismiss) + row_to_dismiss.set_visible(False) + self._update_ui_state() + + def _on_error_dismissed(self, _button): + """Hides the error row and cancels the auto-hide timer.""" + # If the reason for the error row is the connection status, + # mark it as dismissed by the user. + if not self.machine.is_connected(): + self._not_connected_warning_dismissed = True + + self._clear_error_state() + self._update_ui_state() + + def _on_copy_error_clicked(self, _button): + """Copies the current error message to the clipboard.""" + if self._current_error: + clipboard = self.get_clipboard() + provider = Gdk.ContentProvider.new_for_value(self._current_error) + clipboard.set_content(provider) + + def _on_activate_clicked(self, _banner): + """Handler for the 'Activate Machine' button.""" + logger.debug(f"Activating machine: {self.machine.name}") + get_context().config.set_machine(self.machine) + self.show_toast.send(self, message=_("Machine activated.")) + + def _on_error_timeout(self): + self._clear_error_state() + self._update_ui_state() + return GLib.SOURCE_REMOVE + + def _clear_error_state(self): + if self._error_timeout_id > 0: + GLib.source_remove(self._error_timeout_id) + self._error_timeout_id = 0 + self._current_error = None + + def _show_error(self, error_message: str): + logger.debug(f"_show_error: Displaying '{error_message}'.") + self._clear_error_state() + self._current_error = error_message + self._update_ui_state() + self._error_timeout_id = GLib.timeout_add_seconds( + 8, self._on_error_timeout + ) diff --git a/rayforge/ui_gtk/machine/dialect_editor.py b/rayforge/ui_gtk/machine/dialect_editor.py new file mode 100644 index 000000000..3c2bcead8 --- /dev/null +++ b/rayforge/ui_gtk/machine/dialect_editor.py @@ -0,0 +1,309 @@ +import copy +import re +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from ...machine.models.dialect import GcodeDialect +from ...pipeline.encoder.context import GcodeContext +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..varset.varsetwidget import VarSetWidget +from .template_selector import DialectTemplateSelectorDialog + + +def _text_to_list(text: str) -> list[str]: + """ + Converts a single string with newlines to a list of non-empty strings. + """ + return [line for line in text.strip().split("\n") if line.strip()] + + +def _get_template_validation_error( + template: str, allowed_vars: set[str] +) -> str | None: + """ + Validates a template's syntax and variable names, returning an error + string if invalid, or None if valid. + """ + # 1. Check for basic syntax errors first. + if "{{" in template or "}}" in template: + return _("Escaped braces {{ or }} are not supported.") + + depth = 0 + in_brace = False + for char in template: + if char == "{": + if in_brace: + return _("Nested braces are not allowed.") + depth += 1 + in_brace = True + elif char == "}": + if not in_brace: + return _("Unmatched closing brace '}' found.") + depth -= 1 + in_brace = False + + if depth != 0: + return _("Unmatched opening brace '{' found.") + + # 2. Syntax is valid, now check the variables themselves. + found_vars = re.findall(r"\{([^}]+)\}", template) + invalid_vars = [] + for var in found_vars: + if not var: + return _("Empty braces '{}' are not allowed.") + # Strip format specifier (e.g., from 'power:.0f' to 'power') + base_var = var.split(":")[0] + if base_var not in allowed_vars: + invalid_vars.append(var) + + if invalid_vars: + return _("Unsupported variable(s): {vars}").format( + vars=", ".join(f"{{{v}}}" for v in invalid_vars) + ) + + return None # All checks passed + + +class DialectEditorDialog(PatchedDialogWindow): + """ + A dialog window for creating or editing a G-code dialect. + This dialog is driven by VarSets provided by the GcodeDialect model itself. + """ + + def __init__( + self, + parent: Gtk.Window, + dialect: GcodeDialect, + ): + super().__init__(transient_for=parent) + self.set_default_size(600, 500) + + self.dialect = copy.deepcopy(dialect) + self.saved = False + self.supported_template_vars = ( + GcodeContext.get_template_variable_docs() + ) + script_vars_docs = GcodeContext.get_docs("job") + self.supported_script_vars = {var[0] for var in script_vars_docs} + + title = ( + _("Edit Dialect: {label}").format(label=self.dialect.label) + if self.dialect.is_custom + else _("New Dialect") + ) + self.set_title(title) + self.set_default_size(800, 800) + + header = Adw.HeaderBar() + cancel_button = Gtk.Button(label=_("Cancel")) + cancel_button.connect("clicked", lambda w: self.close()) + header.pack_start(cancel_button) + + self.save_button = Gtk.Button(label=_("Save")) + self.save_button.get_style_context().add_class("suggested-action") + self.save_button.connect("clicked", self._on_save_clicked) + header.pack_end(self.save_button) + + self.update_from_template_button = Gtk.Button( + label=_("Update from Template") + ) + self.update_from_template_button.connect( + "clicked", self._on_update_from_template_clicked + ) + header.pack_end(self.update_from_template_button) + + # Get the editor definition from the model + varsets = self.dialect.get_editor_varsets() + + self.info_widget = VarSetWidget() + self.settings_widget = VarSetWidget() + self.templates_widget = VarSetWidget() + self.scripts_widget = VarSetWidget() + + self.info_widget.populate(varsets["info"]) + self.settings_widget.populate(varsets["settings"]) + self.templates_widget.populate(varsets["templates"]) + self.scripts_widget.populate(varsets["scripts"]) + + form_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + form_box.set_margin_top(20) + form_box.set_margin_start(50) + form_box.set_margin_end(50) + form_box.set_margin_bottom(50) + form_box.append(self.info_widget) + form_box.append(self.settings_widget) + form_box.append(self.templates_widget) + form_box.append(self.scripts_widget) + + scrolled_content = Gtk.ScrolledWindow(child=form_box) + scrolled_content.set_vexpand(True) + + main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_vbox.append(header) + main_vbox.append(scrolled_content) + self.set_content(main_vbox) + + self._connect_validation_signals() + self._validate_all_rows() # Set initial state + + def _connect_validation_signals(self): + """Connects `changed` signals for all relevant input widgets.""" + # Info section (Label) + label_row = self.info_widget.widget_map.get("label", (None,))[0] + if isinstance(label_row, Adw.EntryRow): + label_row.connect("changed", lambda r: self._validate_all_rows()) + + # Templates section + for key, (row, var) in self.templates_widget.widget_map.items(): + if isinstance(row, Adw.EntryRow): + row.connect("changed", self._on_row_changed, row, key, False) + + # Scripts section + for key, (row, var) in self.scripts_widget.widget_map.items(): + text_view = getattr(row, "core_widget", None) + if isinstance(text_view, Gtk.TextView): + buffer = text_view.get_buffer() + buffer.connect("changed", self._on_row_changed, row, key, True) + + def _set_row_error(self, row: Adw.PreferencesRow, error_msg: str | None): + """Applies or removes an error state from a row.""" + error_widget = getattr(row, "_error_icon_widget", None) + + if error_msg: + if not error_widget: + error_widget = get_icon("error-symbolic") + if isinstance( + row, (Adw.ActionRow, Adw.ExpanderRow, Adw.EntryRow) + ): + row.add_suffix(error_widget) + row._error_icon_widget = ( # type: ignore[attr-defined] + error_widget + ) + row.add_css_class("error") + error_widget.set_tooltip_text(error_msg) + error_widget.set_visible(True) + else: + row.remove_css_class("error") + if error_widget: + error_widget.set_visible(False) + + def _on_row_changed( + self, widget, row: Adw.PreferencesRow, key: str, is_script: bool + ): + """Callback for when a template or script field changes.""" + error_msg = None + if is_script: + text_view = getattr(row, "core_widget", None) + if isinstance(text_view, Gtk.TextView): + buffer = text_view.get_buffer() + start, end = buffer.get_start_iter(), buffer.get_end_iter() + content = buffer.get_text(start, end, True) + # Find the first error in any line of the script + for line in content.splitlines(): + error_msg = _get_template_validation_error( + line, self.supported_script_vars + ) + if error_msg: + break + elif isinstance(row, Adw.EntryRow): + content = row.get_text() + allowed = self.supported_template_vars.get(key) + if allowed is not None: + error_msg = _get_template_validation_error(content, allowed) + + self._set_row_error(row, error_msg) + self._validate_all_rows() + + def _validate_all_rows(self): + """Checks all rows for errors and updates Save button sensitivity.""" + is_valid = True + # Check label + label_row = cast( + Adw.EntryRow, self.info_widget.widget_map.get("label", (None,))[0] + ) + if not label_row or not label_row.get_text().strip(): + is_valid = False + self._set_row_error(label_row, _("Label cannot be empty.")) + else: + self._set_row_error(label_row, None) + + # Check all other rows for the 'error' class + for group in (self.templates_widget, self.scripts_widget): + for row, _var in group.widget_map.values(): + if row.has_css_class("error"): + is_valid = False + break + if not is_valid: + break + + self.save_button.set_sensitive(is_valid) + + def _update_dialect_from_ui(self): + """Updates the dialect object from the values in the VarSetWidgets.""" + all_values = {} + all_values.update(self.info_widget.get_values()) + all_values.update(self.settings_widget.get_values()) + all_values.update(self.templates_widget.get_values()) + all_values.update(self.scripts_widget.get_values()) + + for key, value in all_values.items(): + if key in ("preamble", "postscript"): + # Convert multi-line text back to list of strings + setattr(self.dialect, key, _text_to_list(value)) + elif hasattr(self.dialect, key): + setattr(self.dialect, key, value) + + def _on_save_clicked(self, button: Gtk.Button): + # Validation is now continuous, so we can just save. + self._update_dialect_from_ui() + self.saved = True + self.close() + + def _on_update_from_template_clicked(self, button: Gtk.Button): + """Opens template selector to update dialect from a template.""" + parent = cast(Gtk.Window, self.get_transient_for()) + dialog = DialectTemplateSelectorDialog( + transient_for=parent, + title=_("Update from Template"), + body=_( + "Select a template to copy its settings. " + "Your label and description will be preserved." + ), + on_selected=self._on_template_selected, + ) + dialog.present() + + def _on_template_selected(self, template: GcodeDialect): + """Updates the dialect from the selected template.""" + current_label = self.dialect.label + current_description = self.dialect.description + + preserved_fields = {"uid", "is_custom", "label", "description"} + for field in template.__dataclass_fields__: + if field not in preserved_fields: + value = getattr(template, field) + setattr(self.dialect, field, copy.deepcopy(value)) + + self.dialect.label = current_label + self.dialect.description = current_description + + self._refresh_ui_from_dialect() + self.present() + + def _refresh_ui_from_dialect(self): + """Refreshes the UI widgets from the dialect object.""" + self.info_widget.clear_dynamic_rows() + self.settings_widget.clear_dynamic_rows() + self.templates_widget.clear_dynamic_rows() + self.scripts_widget.clear_dynamic_rows() + + varsets = self.dialect.get_editor_varsets() + self.info_widget.populate(varsets["info"]) + self.settings_widget.populate(varsets["settings"]) + self.templates_widget.populate(varsets["templates"]) + self.scripts_widget.populate(varsets["scripts"]) + self._connect_validation_signals() + self._validate_all_rows() diff --git a/rayforge/ui_gtk/machine/dialect_list.py b/rayforge/ui_gtk/machine/dialect_list.py new file mode 100644 index 000000000..76c2a83dd --- /dev/null +++ b/rayforge/ui_gtk/machine/dialect_list.py @@ -0,0 +1,221 @@ +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...machine.models.dialect import GcodeDialect +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from .dialect_editor import DialectEditorDialog +from .template_selector import DialectTemplateSelectorDialog + + +class DialectRow(Gtk.Box): + """A widget representing a single Dialect in a ListBox.""" + + def __init__(self, dialect: GcodeDialect, machine=None): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.dialect = dialect + self.dialect_mgr = get_context().dialect_mgr + self.machine = machine + self._setup_ui() + + def _setup_ui(self): + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + info_box.set_hexpand(True) + info_box.set_valign(Gtk.Align.CENTER) + self.append(info_box) + + title_label = Gtk.Label( + label=self.dialect.label, + halign=Gtk.Align.START, + xalign=0, + ) + info_box.append(title_label) + + if self.dialect.description: + desc_label = Gtk.Label( + label=self.dialect.description, + halign=Gtk.Align.START, + xalign=0, + wrap=True, + ) + desc_label.add_css_class("dim-label") + desc_label.add_css_class("caption") + info_box.append(desc_label) + + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", self._on_edit_clicked) + suffix_box.append(edit_button) + + delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + delete_button.add_css_class("flat") + delete_button.connect("clicked", self._on_delete_clicked) + suffix_box.append(delete_button) + + self.select_button = Gtk.ToggleButton() + self.select_button.add_css_class("flat") + self.select_button.set_child(get_icon("check-symbolic")) + self.select_button.set_tooltip_text(_("Select this dialect")) + self._toggle_handler_id = self.select_button.connect( + "toggled", self._on_select_toggled + ) + self.select_button.set_valign(Gtk.Align.CENTER) + suffix_box.append(self.select_button) + + self._update_selection_state() + + def _update_selection_state(self): + if self.machine: + is_selected = self.machine.dialect_uid == self.dialect.uid + if self._toggle_handler_id is not None: + self.select_button.handler_block(self._toggle_handler_id) + self.select_button.set_active(is_selected) + if self._toggle_handler_id is not None: + self.select_button.handler_unblock(self._toggle_handler_id) + + def _on_select_toggled(self, button: Gtk.ToggleButton): + if button.get_active(): + if self.machine and self.machine.dialect_uid != self.dialect.uid: + self.machine.set_dialect_uid(self.dialect.uid) + else: + # Prevent deselecting - always keep one dialect selected + button.set_active(True) + + def _on_edit_clicked(self, button: Gtk.Button): + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + dialog = DialectEditorDialog(parent, self.dialect) + dialog.connect("close-request", self._on_edit_dialog_closed) + dialog.present() + + def _on_edit_dialog_closed(self, dialog: DialectEditorDialog): + if dialog.saved: + self.dialect_mgr.update_dialect(dialog.dialect) + + def _on_delete_clicked(self, button: Gtk.Button): + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + dialog = Adw.MessageDialog( + transient_for=parent, + heading=_("Delete '{label}'?").format(label=self.dialect.label), + body=_( + "This custom dialect will be permanently removed. " + "This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.connect("response", self._on_delete_response) + dialog.present() + + def _on_delete_response(self, dialog: Adw.MessageDialog, response_id: str): + if response_id == "delete": + machines = get_context().machine_mgr.get_machines() + machines_using = self.dialect_mgr.get_machines_using_dialect( + self.dialect, machines + ) + if machines_using: + machine_names = ", ".join(m.name for m in machines_using) + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + error_dialog = Adw.MessageDialog( + transient_for=parent, + heading=_("Cannot Delete Dialect"), + body=_( + "This dialect is still used by the following " + "machine(s): {machines}" + ).format(machines=machine_names), + ) + error_dialog.add_response("ok", _("OK")) + error_dialog.set_default_response("ok") + error_dialog.present() + return + self.dialect_mgr.delete_dialect(self.dialect, machines) + + +class DialectListEditor(PreferencesGroupWithButton): + """An Adwaita widget for managing a list of G-code dialects.""" + + def __init__(self, machine=None, **kwargs): + super().__init__(button_label=_("Create from Template"), **kwargs) + self.machine = machine + self.dialect_mgr = get_context().dialect_mgr + self._row_widgets: list[DialectRow] = [] + self._setup_ui() + self.dialect_mgr.dialects_changed.connect(self._on_dialects_changed) + if self.machine: + self.machine.changed.connect(self._on_machine_changed) + self.connect("destroy", self._on_destroy) + self._on_dialects_changed() + + def _setup_ui(self): + """Configures the widget and its placeholder.""" + placeholder = Gtk.Label( + label=_("No custom dialects configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + + def _on_destroy(self, *args): + self.dialect_mgr.dialects_changed.disconnect(self._on_dialects_changed) + if self.machine: + self.machine.changed.disconnect(self._on_machine_changed) + + def _on_machine_changed(self, sender, **kwargs): + """Update selection state when machine changes.""" + for row in self._row_widgets: + row._update_selection_state() + + def _on_dialects_changed(self, sender=None, **kwargs): + """Callback to rebuild the list when the dialect manager signals.""" + self._row_widgets.clear() + all_dialects = get_context().dialect_mgr.get_all() + custom_dialects = [d for d in all_dialects if d.is_custom] + sorted_dialects = sorted(custom_dialects, key=lambda d: d.label) + self.set_items(sorted_dialects) + + def create_row_widget(self, item: GcodeDialect) -> Gtk.Widget: + """Creates a DialectRow for the given dialect item.""" + row = DialectRow(item, self.machine) + self._row_widgets.append(row) + return row + + def _on_add_clicked(self, button: Gtk.Button): + """Handles the 'Create from Template' button click.""" + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + dialog = DialectTemplateSelectorDialog( + transient_for=parent, + on_selected=self._on_template_selected, + ) + dialog.present() + + def _on_template_selected(self, template: GcodeDialect): + """Called when a template is selected from the dialog.""" + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + new_label = _("{label} (Copy)").format(label=template.label) + new_dialect = template.copy_as_custom(new_label=new_label) + + editor_dialog = DialectEditorDialog(parent, new_dialect) + editor_dialog.connect( + "close-request", self._on_new_dialect_dialog_closed + ) + editor_dialog.present() + + def _on_new_dialect_dialog_closed(self, dialog: DialectEditorDialog): + """Adds the new dialect if it was saved.""" + if dialog.saved: + self.dialect_mgr.add_dialect(dialog.dialect) diff --git a/rayforge/ui_gtk/machine/gcode_editor.py b/rayforge/ui_gtk/machine/gcode_editor.py new file mode 100644 index 000000000..a007a1588 --- /dev/null +++ b/rayforge/ui_gtk/machine/gcode_editor.py @@ -0,0 +1,292 @@ +from gettext import gettext as _ + +from gi.repository import Adw, Gdk, GLib, Gtk + +from ...machine.models.macro import Macro +from ...pipeline.encoder.context import GcodeContext +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow + +# Define characters that are not allowed in macro names +FORBIDDEN_NAME_CHARS = "();[]{}<>" + + +class GcodeEditorDialog(PatchedDialogWindow): + """A generic modal dialog for editing a G-code macro.""" + + def __init__( + self, + parent: Gtk.Window, + macro: Macro, + *, + allow_name_edit: bool = False, + existing_macros: list[Macro] | None = None, + variable_context_level: str = "job", + ): + """ + Initializes the macro editor dialog. + + Args: + parent: The parent window. + macro: The macro to be edited. + allow_name_edit: If True, shows an entry row to edit the macro + name. + existing_macros: A list of other macros to check for name + uniqueness. + variable_context_level: The context level for variable + documentation. + """ + super().__init__(modal=True, transient_for=parent) + self.macro = macro + self.saved = False + self._allow_name_edit = allow_name_edit + self.existing_macros = existing_macros or [] + self.variable_context_level = variable_context_level + self.set_title(_("Edit Macro")) + self.set_size_request(750, 700) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + + header = Adw.HeaderBar() + main_box.append(header) + + cancel_button = Gtk.Button(label=_("Cancel")) + cancel_button.connect("clicked", lambda w: self.close()) + header.pack_start(cancel_button) + + # Button for Variables + variables_button = Gtk.MenuButton( + child=get_icon("variable-symbolic"), + tooltip_text=_("Insert Variable"), + ) + header.pack_start(variables_button) + self._build_variables_popover(variables_button) + + # Button for Macros (now always shown) + macros_button = Gtk.MenuButton( + child=get_icon("code-symbolic"), + tooltip_text=_("Include Macro"), + ) + header.pack_start(macros_button) + self._build_macros_popover(macros_button) + + self.save_button = Gtk.Button(label=_("Save")) + self.save_button.add_css_class("suggested-action") + self.save_button.connect("clicked", self._on_save_clicked) + header.pack_end(self.save_button) + + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.set_text(self.macro.name) + self.name_row.set_margin_top(6) + + self.error_label = Gtk.Label(halign=Gtk.Align.START, margin_start=12) + self.error_label.add_css_class("error") + + if self._allow_name_edit: + main_box.append(self.name_row) + main_box.append(self.error_label) + self.name_row.connect("notify::text", self._validate_name) + else: + self.set_title( + _("Edit Macro for {name}").format(name=self.macro.name) + ) + + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vexpand=True, + margin_top=6, + margin_bottom=6, + margin_start=6, + margin_end=6, + ) + main_box.append(scrolled_window) + + self.text_view = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD_CHAR, + pixels_above_lines=2, + pixels_below_lines=2, + left_margin=6, + right_margin=6, + ) + self.text_view.add_css_class("monospace") + buffer = self.text_view.get_buffer() + buffer.set_text("\n".join(self.macro.code), -1) + scrolled_window.set_child(self.text_view) + + # Add a key controller to listen for the Escape key + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self.add_controller(key_controller) + + # Run initial validation + self._validate_name() + + def _on_popover_closed(self, popover: Gtk.Popover): + """Ensure the text view regains focus when a popover is closed.""" + self.text_view.grab_focus() + + def _build_variables_popover(self, parent_button: Gtk.MenuButton): + """Creates and populates the popover with variable documentation.""" + self.variables_popover = Gtk.Popover() + parent_button.set_popover(self.variables_popover) + self.variables_popover.connect("closed", self._on_popover_closed) + + clamp = Adw.Clamp(maximum_size=350) + self.variables_popover.set_child(clamp) + + popover_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=6, + margin_top=6, + margin_bottom=6, + margin_start=6, + margin_end=6, + ) + clamp.set_child(popover_box) + + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, min_content_height=250 + ) + popover_box.append(scrolled_window) + + list_box = Gtk.ListBox() + list_box.add_css_class("boxed-list") + scrolled_window.set_child(list_box) + + # Variables section + var_title = Gtk.Label(xalign=0, margin_bottom=6, margin_top=6) + var_title.add_css_class("title-4") + var_title.set_text(_("Available Variables")) + var_header_row = Gtk.ListBoxRow(child=var_title, selectable=False) + list_box.append(var_header_row) + + variables = GcodeContext.get_docs(self.variable_context_level) + for var, desc in variables: + row = Adw.ActionRow(subtitle=desc, activatable=True) + escaped_var = GLib.markup_escape_text(f"{{{var}}}") + row.set_title( + f'{escaped_var}' + ) + row.set_use_markup(True) + row.connect("activated", self._on_variable_activated, var) + list_box.append(row) + + def _build_macros_popover(self, parent_button: Gtk.MenuButton): + """Creates and populates the popover for including other macros.""" + self.macros_popover = Gtk.Popover() + parent_button.set_popover(self.macros_popover) + self.macros_popover.connect("closed", self._on_popover_closed) + + clamp = Adw.Clamp(maximum_size=350) + self.macros_popover.set_child(clamp) + + popover_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=6, + margin_top=6, + margin_bottom=6, + margin_start=6, + margin_end=6, + ) + clamp.set_child(popover_box) + + scrolled_window = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, min_content_height=150 + ) + popover_box.append(scrolled_window) + + list_box = Gtk.ListBox() + list_box.add_css_class("boxed-list") + scrolled_window.set_child(list_box) + + macros_to_include = [ + m for m in self.existing_macros if m.uid != self.macro.uid + ] + + if macros_to_include: + for macro in sorted(macros_to_include, key=lambda s: s.name): + row = Adw.ActionRow(title=macro.name, activatable=True) + row.connect("activated", self._on_macro_activated, macro.name) + list_box.append(row) + else: + placeholder = Gtk.Label(label=_("No other macros to include.")) + placeholder.add_css_class("dim-label") + placeholder.set_margin_top(12) + placeholder.set_margin_bottom(12) + list_box.append( + Gtk.ListBoxRow(child=placeholder, selectable=False) + ) + + def _insert_text_at_cursor(self, text: str): + """Helper to insert text at the current cursor position.""" + buffer = self.text_view.get_buffer() + insert_mark = buffer.get_insert() + iterator = buffer.get_iter_at_mark(insert_mark) + buffer.insert(iterator, text, -1) + + def _on_variable_activated(self, row: Adw.ActionRow, variable_name: str): + """Called when a variable row is clicked.""" + self._insert_text_at_cursor(f"{{{variable_name}}}") + self.variables_popover.popdown() + + def _on_macro_activated(self, row: Adw.ActionRow, macro_name: str): + """Called when a macro include row is clicked.""" + self._insert_text_at_cursor(f"@include({macro_name})") + self.macros_popover.popdown() + + def _on_key_pressed(self, controller, keyval, keycode, state): + """Handler for key press events on the window.""" + if keyval == Gdk.KEY_Escape: + self.close() + return True # Event handled, stop propagation + return False + + def _validate_name(self, *args): + """Checks the validity of the macro name and updates UI feedback.""" + if not self._allow_name_edit: + self.save_button.set_sensitive(True) + return + + name = self.name_row.get_text() + error_message = "" + + if not name.strip(): + error_message = _("Name cannot be empty.") + elif any(char in name for char in FORBIDDEN_NAME_CHARS): + error_message = _( + "Name contains invalid characters: {chars}" + ).format(chars=FORBIDDEN_NAME_CHARS) + else: + for other_macro in self.existing_macros: + # Check for name collision, ignoring the macro we are editing + if ( + other_macro.name == name + and other_macro.uid != self.macro.uid + ): + error_message = _( + "This name is already used by another macro." + ) + break + + if error_message: + self.error_label.set_label(error_message) + self.error_label.set_visible(True) + self.save_button.set_sensitive(False) + else: + self.error_label.set_visible(False) + self.save_button.set_sensitive(True) + + def _on_save_clicked(self, button: Gtk.Button): + """Stores the UI content into the macro object and closes.""" + buffer = self.text_view.get_buffer() + start, end = buffer.get_start_iter(), buffer.get_end_iter() + text = buffer.get_text(start, end, include_hidden_chars=True) + + if self._allow_name_edit: + self.macro.name = self.name_row.get_text() + self.macro.code = text.splitlines() + + self.saved = True + self.close() diff --git a/rayforge/ui_gtk/machine/gcode_settings_page.py b/rayforge/ui_gtk/machine/gcode_settings_page.py new file mode 100644 index 000000000..5456723e8 --- /dev/null +++ b/rayforge/ui_gtk/machine/gcode_settings_page.py @@ -0,0 +1,54 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw + +from ..shared.pref_rows.base import SpinRow +from ..shared.preferences_page import TrackedPreferencesPage +from .dialect_list import DialectListEditor + +logger = logging.getLogger(__name__) + + +class GcodeSettingsPage(TrackedPreferencesPage): + key = "gcode" + path_prefix = "/machine-settings/" + + def __init__(self, machine, **kwargs): + super().__init__( + title=_("G-code"), + icon_name="gcode-symbolic", + **kwargs, + ) + self.machine = machine + + precision_group = Adw.PreferencesGroup(title=_("Precision")) + precision_group.set_description( + _("Configure the numeric precision of coordinate output.") + ) + self.add(precision_group) + + self.precision_row = SpinRow( + _("G-code Precision"), + _("Number of decimal places for coordinates"), + lower=1, + upper=8, + page_increment=1, + value=self.machine.gcode_precision, + ) + self.precision_row.value_changed.connect(self.on_precision_changed) + precision_group.add(self.precision_row) + + dialect_editor_group = DialectListEditor( + machine=self.machine, + title=_("Dialect"), + description=_( + "Select, create and manage G-code dialect definitions." + ), + ) + self.add(dialect_editor_group) + + def on_precision_changed(self, spinrow): + """Update the machine's G-code precision when the value changes.""" + value = spinrow.get_int_value() + self.machine.set_gcode_precision(value) diff --git a/rayforge/ui_gtk/machine/general_preferences_page.py b/rayforge/ui_gtk/machine/general_preferences_page.py new file mode 100644 index 000000000..6b9ca3346 --- /dev/null +++ b/rayforge/ui_gtk/machine/general_preferences_page.py @@ -0,0 +1,421 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...machine.driver import drivers, get_driver_cls +from ...machine.models.machine import Machine +from ...shared.units.system import UnitSystem +from ..icons import get_icon +from ..shared.pref_rows.acceleration_spin_row import AccelerationSpinRow +from ..shared.pref_rows.speed_spin_row import SpeedSpinRow +from ..shared.preferences_page import TrackedPreferencesPage +from ..varset.varsetwidget import VarSetWidget + +logger = logging.getLogger(__name__) + +UNIT_SYSTEM_LABELS = { + UnitSystem.METRIC: _("Metric (mm)"), + UnitSystem.IMPERIAL: _("Imperial (inches)"), +} +UNIT_SYSTEM_ORDER = [UnitSystem.METRIC, UnitSystem.IMPERIAL] + + +class GeneralPreferencesPage(TrackedPreferencesPage): + key = "general" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("General"), + icon_name="machine-settings-general-symbolic", + **kwargs, + ) + self.machine = machine + self._is_initializing = True + self._current_driver_name = self.machine.driver_name + + # Error Banner Group + error_group = Adw.PreferencesGroup() + self.add(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) + # Hide the group if the banner is not revealed to avoid extra spacing + self.error_banner.connect( + "notify::revealed", + lambda banner, _: error_group.set_visible(banner.get_revealed()), + ) + error_group.set_visible(False) + + # Group for Machine Name + name_group = Adw.PreferencesGroup(title=_("Machine")) + name_group.set_description( + _("Basic machine identification and configuration.") + ) + self.add(name_group) + + # Machine Name + name_row = Adw.EntryRow(title=_("Name")) + name_row.set_text(self.machine.name) + name_row.connect("notify::text", self.on_name_changed) + name_group.add(name_row) + + self.driver_group = VarSetWidget(title=_("Driver Settings")) + self.driver_group.set_description( + _("Connection and communication settings for the machine driver.") + ) + self.driver_group.data_changed.connect(self.on_driver_param_changed) + self.add(self.driver_group) + + # Driver selector + self.driver_store = Gtk.StringList() + for d in drivers: + self.driver_store.append(d.label) + + self.combo_row = Adw.ComboRow( + title=_("Select driver"), + model=self.driver_store, + ) + self.combo_row.set_use_subtitle(True) + self.driver_group.add(self.combo_row) + + # Set up a custom factory to display both title and subtitle in the + # dropdown + factory = Gtk.SignalListItemFactory() + factory.connect("setup", self.on_factory_setup) + factory.connect("bind", self.on_factory_bind) + self.combo_row.set_factory(factory) + + # Get the driver class from driver_name (not from driver instance + # which may not be ready yet) + driver_cls = None + if self.machine.driver_name: + driver_cls = get_driver_cls(self.machine.driver_name) + + # Perform the initial population of the driver VarSet + if driver_cls: + initial_var_set = driver_cls.get_setup_vars() + initial_var_set.set_values(self.machine.driver_args) + logger.debug( + f"GeneralPreferences: driver_args={self.machine.driver_args}, " + f"var_set values={initial_var_set.get_values()}" + ) + self.driver_group.populate(initial_var_set) + else: + # No driver selected yet, clear the widget + self.driver_group.clear_dynamic_rows() + + # Connect to the machine's changed signal to get updates + self.machine.changed.connect(self._on_machine_changed) + self.connect("destroy", self._on_destroy) + + # Connect the signal for the combo row + self.combo_row.connect("notify::selected", self.on_combo_row_changed) + + # Now, set the initial selection and update its title/subtitle + if driver_cls: + selected_index = drivers.index(driver_cls) + self.combo_row.set_selected(selected_index) + # Manually set title/subtitle for the initial state + self.combo_row.set_title(driver_cls.label) + self.combo_row.set_subtitle(driver_cls.subtitle) + else: + self.combo_row.set_title(_("Select driver")) + self.combo_row.set_subtitle("") + + # Group for Speeds + speeds_group = Adw.PreferencesGroup( + title=_("Speeds & Acceleration") + ) + speeds_group.set_description( + _( + "Movement parameters used for job time estimation " + "and path optimization." + ) + ) + self.add(speeds_group) + + # Max Travel Speed + self.travel_speed_row = SpeedSpinRow( + _("Max Travel Speed"), + _("Maximum rapid movement speed"), + upper=60000, # Increased upper limit for mm/min + digits=0, + ) + self.travel_speed_row.set_value_in_base_units( + self.machine.max_travel_speed + ) + self.travel_speed_row.value_changed.connect( + self.on_travel_speed_changed + ) + speeds_group.add(self.travel_speed_row) + + # Max Cut Speed + self.cut_speed_row = SpeedSpinRow( + _("Max Cut Speed"), + _("Maximum cutting speed"), + upper=60000, # Increased upper limit for mm/min + digits=0, + ) + self.cut_speed_row.set_value_in_base_units(self.machine.max_cut_speed) + self.cut_speed_row.value_changed.connect(self.on_cut_speed_changed) + speeds_group.add(self.cut_speed_row) + + # Acceleration + self.acceleration_row = AccelerationSpinRow( + _("Acceleration"), + _( + "Used for time estimations and calculating the " + "default overscan distance" + ), + lower=1, + upper=100000, + digits=0, + ) + self.acceleration_row.set_value_in_base_units( + self.machine.acceleration + ) + self.acceleration_row.value_changed.connect( + self.on_acceleration_changed + ) + speeds_group.add(self.acceleration_row) + + # Machine Unit System group + units_group = Adw.PreferencesGroup(title=_("Unit System")) + units_group.set_description( + _( + "The unit system used when emitting G-code and " + "communicating with the device. This setting is independent " + "of the units used in the user interface." + ) + ) + self.add(units_group) + + # Unit system selector + unit_labels = [UNIT_SYSTEM_LABELS[u] for u in UNIT_SYSTEM_ORDER] + self.unit_system_row = Adw.ComboRow( + title=_("Machine Unit System"), + model=Gtk.StringList.new(unit_labels), + ) + current_idx = UNIT_SYSTEM_ORDER.index(self.machine.unit_system) + self.unit_system_row.set_selected(current_idx) + self.unit_system_row.connect( + "notify::selected", self.on_unit_system_changed + ) + units_group.add(self.unit_system_row) + + # Preamble mismatch warning row + self.unit_warning_row = Adw.ActionRow( + activatable=False, + ) + self.unit_warning_row.add_prefix(get_icon("warning-symbolic")) + self.unit_warning_row.add_css_class("warning") + self.unit_warning_row.set_visible(False) + units_group.add(self.unit_warning_row) + + # Initial check for errors + self._update_error_state() + + # Initialization is complete. + self._is_initializing = False + + # Update controls based on driver features + self._update_travel_speed_state() + self._update_unit_warning() + + def _on_machine_changed(self, sender, **kwargs): + """ + Handler for the machine's changed signal. This is triggered when + the driver or its configuration changes, allowing the UI to update. + """ + self._update_error_state() + + # ONLY repopulate the driver settings if the driver *class* has + # actually changed. This prevents a full UI rebuild (and focus loss) + # when just a parameter value is changed. + if self.machine.driver_name != self._current_driver_name: + self._current_driver_name = self.machine.driver_name + driver_cls = self.machine.driver.__class__ + var_set = driver_cls.get_setup_vars() + var_set.set_values(self.machine.driver_args) + self.driver_group.populate(var_set) + + # Update controls based on new driver features + self._update_travel_speed_state() + self._sync_unit_system_widgets() + self._update_unit_warning() + + def _sync_unit_system_widgets(self): + """ + Synchronizes the unit system selector with the machine model + without triggering change handlers. + """ + was_initializing = self._is_initializing + self._is_initializing = True + try: + idx = UNIT_SYSTEM_ORDER.index(self.machine.unit_system) + self.unit_system_row.set_selected(idx) + except ValueError: + pass + finally: + self._is_initializing = was_initializing + + def _on_destroy(self, *args): + """Disconnects signals to prevent memory leaks.""" + self.machine.changed.disconnect(self._on_machine_changed) + + def _update_error_state(self): + """Shows or hides the error banner based on all possible errors.""" + errors = [] + if self.machine.precheck_error: + errors.append( + _("Configuration required: {error}").format( + error=self.machine.precheck_error + ) + ) + if self.machine.driver and self.machine.driver.state.error: + errors.append( + _("Error: {error}").format( + error=self.machine.driver.state.error.title + ) + ) + + if errors: + full_error_msg = " \n".join(errors) + self.error_banner.set_title(full_error_msg) + self.error_banner.set_revealed(True) + else: + self.error_banner.set_revealed(False) + + def on_driver_param_changed(self, sender, **kwargs): + if self._is_initializing: + return + values = self.driver_group.get_values() + self.machine.set_driver_args(values) + + def on_factory_setup(self, factory, list_item): + row = Adw.ActionRow() + list_item.set_child(row) + + def on_factory_bind(self, factory, list_item): + index = list_item.get_position() + driver_cls = drivers[index] + row = list_item.get_child() + row.set_title(driver_cls.label) + row.set_subtitle(driver_cls.subtitle) + + def on_combo_row_changed(self, combo_row, _param): + if self._is_initializing: + return + + selected_index = combo_row.get_selected() + if selected_index < 0: + self.combo_row.set_title(_("Select driver")) + self.combo_row.set_subtitle("") + self.driver_group.clear_dynamic_rows() + return # No driver selected + + driver_cls = drivers[selected_index] + + self.combo_row.set_title(driver_cls.label) + self.combo_row.set_subtitle(driver_cls.subtitle) + + # If the user selected a new driver, update the machine model. + # The `machine.changed` signal will then trigger _on_machine_changed + # to update the UI, including the driver settings widgets. + if self.machine.driver_name != driver_cls.__name__: + self.machine.set_driver(driver_cls, {}) + + def on_name_changed(self, entry_row, _): + """Update the machine name when the text changes.""" + self.machine.set_name(entry_row.get_text()) + + def on_travel_speed_changed(self, row: SpeedSpinRow): + """Update the max travel speed when the value changes.""" + if self._is_initializing: + return + value = row.get_value_in_base_units() + self.machine.set_max_travel_speed(int(value)) + + def on_cut_speed_changed(self, row: SpeedSpinRow): + """Update the max cut speed when the value changes.""" + if self._is_initializing: + return + value = row.get_value_in_base_units() + self.machine.set_max_cut_speed(int(value)) + + def on_acceleration_changed(self, row: AccelerationSpinRow): + """Update the acceleration when the value changes.""" + if self._is_initializing: + return + value = row.get_value_in_base_units() + self.machine.set_acceleration(int(value)) + + def _update_travel_speed_state(self): + """Update the travel speed row based on dialect features.""" + if self._is_initializing: + return + + if self.machine.dialect and self.machine.dialect.can_g0_with_speed: + self.travel_speed_row.set_sensitive(True) + self.travel_speed_row.set_subtitle( + _("Maximum rapid movement speed") + ) + else: + self.travel_speed_row.set_sensitive(False) + self.travel_speed_row.set_subtitle( + _("Not supported by the driver") + ) + + def on_unit_system_changed(self, combo_row, _): + if self._is_initializing: + return + idx = combo_row.get_selected() + if 0 <= idx < len(UNIT_SYSTEM_ORDER): + self.machine.set_unit_system(UNIT_SYSTEM_ORDER[idx]) + self._update_unit_warning() + + def _update_unit_warning(self): + """ + Show a warning when the dialect preamble's G20/G21 unit + command does not match the configured machine unit system. + """ + dialect = self.machine.dialect + if dialect is None: + self.unit_warning_row.set_visible(False) + return + + preamble = " ".join(dialect.preamble) + has_g20 = "G20" in preamble + has_g21 = "G21" in preamble + is_imperial = self.machine.unit_system == UnitSystem.IMPERIAL + + mismatch = (is_imperial and has_g21 and not has_g20) or ( + not is_imperial and has_g20 and not has_g21 + ) + if mismatch: + if is_imperial: + self.unit_warning_row.set_title( + _( + "The preamble contains G21 (millimeters) but " + "the machine unit system is set to imperial. " + "G-code values will be emitted in inches — " + "ensure your preamble matches." + ) + ) + else: + self.unit_warning_row.set_title( + _( + "The preamble contains G20 (inches) but the " + "machine unit system is set to metric. G-code " + "values will be emitted in millimeters — " + "ensure your preamble matches." + ) + ) + self.unit_warning_row.set_visible(True) + else: + self.unit_warning_row.set_visible(False) diff --git a/rayforge/ui_gtk/machine/hardware_page.py b/rayforge/ui_gtk/machine/hardware_page.py new file mode 100644 index 000000000..5f623deab --- /dev/null +++ b/rayforge/ui_gtk/machine/hardware_page.py @@ -0,0 +1,352 @@ +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk +from raygeo.ops.axis import Axis + +from ...machine.models.machine import Machine, Origin +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.preferences_page import TrackedPreferencesPage + + +class HardwarePage(TrackedPreferencesPage): + key = "hardware" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("Hardware"), + icon_name="hardware-symbolic", + **kwargs, + ) + self.machine = machine + self._is_initializing = True + + axes_group = Adw.PreferencesGroup(title=_("Axes")) + axes_group.set_description( + _("Configure the axis extents and coordinate system.") + ) + self.add(axes_group) + + self.x_extent_row = LengthSpinRow( + _("X Extent"), + _("Full X-axis travel range"), + lower=50, + upper=10000, + value_in_base=self.machine.axis_extents[0], + ) + self.x_extent_row.value_changed.connect(self.on_x_extent_changed) + axes_group.add(self.x_extent_row) + + self.y_extent_row = LengthSpinRow( + _("Y Extent"), + _("Full Y-axis travel range"), + lower=50, + upper=10000, + value_in_base=self.machine.axis_extents[1], + ) + self.y_extent_row.value_changed.connect(self.on_y_extent_changed) + axes_group.add(self.y_extent_row) + + origin_store = Gtk.StringList() + origin_store.append(_("Bottom Left")) + origin_store.append(_("Top Left")) + origin_store.append(_("Top Right")) + origin_store.append(_("Bottom Right")) + origin_combo_row = Adw.ComboRow( + title=_("Coordinate Origin (0,0)"), + subtitle=_( + "The physical corner where coordinates are zero after homing" + ), + model=origin_store, + ) + + # In languages with long text, the combo row doesn't allocate enough + # width for the dropdown, so we have to manually set the list box + # width. This is a bit hacky but it works. + combo_child_box = origin_combo_row.get_last_child() + if combo_child_box: + suffix_box = combo_child_box.get_last_child() + if suffix_box: + list_box = cast(Gtk.ListBox, suffix_box.get_first_child()) + if list_box: + list_box.set_size_request(100, -1) + origin_combo_row.set_selected( + { + Origin.BOTTOM_LEFT: 0, + Origin.TOP_LEFT: 1, + Origin.TOP_RIGHT: 2, + Origin.BOTTOM_RIGHT: 3, + }.get(self.machine.origin, 0) + ) + origin_combo_row.connect("notify::selected", self.on_origin_changed) + self.origin_combo_row = origin_combo_row + axes_group.add(origin_combo_row) + + self.reverse_x_axis_row = Adw.SwitchRow() + self.reverse_x_axis_row.set_title(_("Reverse X-Axis Direction")) + self.reverse_x_axis_row.set_subtitle( + _("Makes coordinate values negative") + ) + self.reverse_x_axis_row.set_active(machine.reverse_x_axis) + self.reverse_x_axis_row.connect( + "notify::active", self.on_reverse_x_changed + ) + axes_group.add(self.reverse_x_axis_row) + + self.reverse_y_axis_row = Adw.SwitchRow() + self.reverse_y_axis_row.set_title(_("Reverse Y-Axis Direction")) + self.reverse_y_axis_row.set_subtitle( + _("Makes coordinate values negative") + ) + self.reverse_y_axis_row.set_active(machine.reverse_y_axis) + self.reverse_y_axis_row.connect( + "notify::active", self.on_reverse_y_changed + ) + axes_group.add(self.reverse_y_axis_row) + + self.reverse_z_axis_row = Adw.SwitchRow() + self.reverse_z_axis_row.set_title(_("Reverse Z-Axis Direction")) + self.reverse_z_axis_row.set_subtitle( + _( + "Enable if a positive Z command (e.g., G0 Z10) moves the head " + "down" + ) + ) + self.reverse_z_axis_row.set_active(machine.reverse_z_axis) + self.reverse_z_axis_row.connect( + "notify::active", self.on_reverse_z_changed + ) + axes_group.add(self.reverse_z_axis_row) + + work_area_group = Adw.PreferencesGroup(title=_("Work Area")) + work_area_group.set_description( + _("Margins define the unusable space around the axis extents.") + ) + self.add(work_area_group) + + ml, mt, mr, mb = self.machine.work_margins + + self.margin_left_row = LengthSpinRow( + _("Left Margin"), + _("Unusable space from left edge"), + upper=10000, + value_in_base=ml, + ) + self.margin_left_row.value_changed.connect(self.on_margins_changed) + work_area_group.add(self.margin_left_row) + + self.margin_top_row = LengthSpinRow( + _("Top Margin"), + _("Unusable space from top edge"), + upper=10000, + value_in_base=mt, + ) + self.margin_top_row.value_changed.connect(self.on_margins_changed) + work_area_group.add(self.margin_top_row) + + self.margin_right_row = LengthSpinRow( + _("Right Margin"), + _("Unusable space from right edge"), + upper=10000, + value_in_base=mr, + ) + self.margin_right_row.value_changed.connect(self.on_margins_changed) + work_area_group.add(self.margin_right_row) + + self.margin_bottom_row = LengthSpinRow( + _("Bottom Margin"), + _("Unusable space from bottom edge"), + upper=10000, + value_in_base=mb, + ) + self.margin_bottom_row.value_changed.connect(self.on_margins_changed) + work_area_group.add(self.margin_bottom_row) + + self.wcs_origin_row = Adw.SwitchRow() + self.wcs_origin_row.set_title(_("Workarea Origin Is Coordinate Zero")) + self.wcs_origin_row.set_subtitle( + _( + "Treat workarea origin as coordinate zero. " + "Hides WCS controls and uses workarea margins as offsets." + ) + ) + self.wcs_origin_row.set_active(machine.wcs_origin_is_workarea_origin) + self.wcs_origin_row.connect( + "notify::active", self.on_wcs_origin_is_workarea_origin_changed + ) + work_area_group.add(self.wcs_origin_row) + + soft_limits_group = Adw.PreferencesGroup(title=_("Soft Limits")) + soft_limits_group.set_description( + _( + "Configurable safety bounds for jogging. " + "Leave disabled to use work surface bounds." + ) + ) + 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_subtitle( + _("Override work surface bounds with custom limits") + ) + has_custom_limits = self.machine.soft_limits is not None + 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.connect( + "notify::active", self.on_soft_limits_enabled_changed + ) + soft_limits_group.add(self.soft_limits_enabled_row) + + self.soft_x_min_row = LengthSpinRow( + _("X Min"), + _("Minimum X coordinate"), + upper=self.machine.axis_extents[0], + 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) + soft_limits_group.add(self.soft_x_min_row) + + self.soft_y_min_row = LengthSpinRow( + _("Y Min"), + _("Minimum Y coordinate"), + upper=self.machine.axis_extents[1], + 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) + soft_limits_group.add(self.soft_y_min_row) + + self.soft_x_max_row = LengthSpinRow( + _("X Max"), + _("Maximum X coordinate"), + upper=self.machine.axis_extents[0], + 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) + soft_limits_group.add(self.soft_x_max_row) + + self.soft_y_max_row = LengthSpinRow( + _("Y Max"), + _("Maximum Y coordinate"), + upper=self.machine.axis_extents[1], + 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) + soft_limits_group.add(self.soft_y_max_row) + + self.machine.changed.connect(self._on_machine_changed) + self.connect("destroy", self._on_destroy) + + self._is_initializing = False + self._update_soft_limits_ui() + self._update_z_axis_state() + + def _on_machine_changed(self, sender, **kwargs): + if self._is_initializing: + return + self._update_z_axis_state() + self._update_axis_extents_ui() + self._update_soft_limits_ui() + + def _update_axis_extents_ui(self): + self.x_extent_row.set_value_in_base_units(self.machine.axis_extents[0]) + self.y_extent_row.set_value_in_base_units(self.machine.axis_extents[1]) + + def _update_soft_limits_ui(self): + w, h = self.machine.axis_extents + self.soft_x_min_row.set_range(0.0, w) + self.soft_x_max_row.set_range(0.0, w) + self.soft_y_min_row.set_range(0.0, h) + self.soft_y_max_row.set_range(0.0, h) + limits = self.machine.soft_limits or (0, 0, w, h) + self.soft_x_min_row.set_value_in_base_units(limits[0]) + self.soft_y_min_row.set_value_in_base_units(limits[1]) + self.soft_x_max_row.set_value_in_base_units(limits[2]) + self.soft_y_max_row.set_value_in_base_units(limits[3]) + + def _on_destroy(self, *args): + self.machine.changed.disconnect(self._on_machine_changed) + + def on_origin_changed(self, row, _): + selected_index = row.get_selected() + origin_map = { + 0: Origin.BOTTOM_LEFT, + 1: Origin.TOP_LEFT, + 2: Origin.TOP_RIGHT, + 3: Origin.BOTTOM_RIGHT, + } + origin = origin_map.get(selected_index, Origin.BOTTOM_LEFT) + self.machine.set_origin(origin) + + def on_reverse_x_changed(self, row, _): + self.machine.set_reverse_x_axis(row.get_active()) + + def on_reverse_y_changed(self, row, _): + self.machine.set_reverse_y_axis(row.get_active()) + + def on_reverse_z_changed(self, row, _): + self.machine.set_reverse_z_axis(row.get_active()) + + def on_x_extent_changed(self, row): + x = self.x_extent_row.get_value_in_base_units() + y = self.machine.axis_extents[1] + self.machine.set_axis_extents(x, y) + + def on_y_extent_changed(self, row): + x = self.machine.axis_extents[0] + y = self.y_extent_row.get_value_in_base_units() + self.machine.set_axis_extents(x, y) + + def on_margins_changed(self, row): + ml = self.margin_left_row.get_value_in_base_units() + mt = self.margin_top_row.get_value_in_base_units() + mr = self.margin_right_row.get_value_in_base_units() + mb = self.margin_bottom_row.get_value_in_base_units() + + extent_w, extent_h = self.machine.axis_extents + ml = max(0, min(ml, extent_w - 1)) + mr = max(0, min(mr, extent_w - ml - 1)) + mt = max(0, min(mt, extent_h - 1)) + mb = max(0, min(mb, extent_h - mt - 1)) + + self.machine.set_work_margins(ml, mt, mr, mb) + + def on_wcs_origin_is_workarea_origin_changed(self, row, _): + self.machine.set_wcs_origin_is_workarea_origin(row.get_active()) + + def on_soft_limits_enabled_changed(self, row, _): + enabled = row.get_active() + self.soft_x_min_row.set_sensitive(enabled) + self.soft_y_min_row.set_sensitive(enabled) + self.soft_x_max_row.set_sensitive(enabled) + self.soft_y_max_row.set_sensitive(enabled) + + if enabled: + x_min = self.soft_x_min_row.get_value_in_base_units() + y_min = self.soft_y_min_row.get_value_in_base_units() + x_max = self.soft_x_max_row.get_value_in_base_units() + y_max = self.soft_y_max_row.get_value_in_base_units() + self.machine.set_soft_limits(x_min, y_min, x_max, y_max) + else: + self.machine.clear_soft_limits() + + def on_soft_limits_changed(self, row): + if not self.soft_limits_enabled_row.get_active(): + return + x_min = self.soft_x_min_row.get_value_in_base_units() + y_min = self.soft_y_min_row.get_value_in_base_units() + x_max = self.soft_x_max_row.get_value_in_base_units() + y_max = self.soft_y_max_row.get_value_in_base_units() + self.machine.set_soft_limits(x_min, y_min, x_max, y_max) + + def _update_z_axis_state(self): + if self._is_initializing: + return + + has_z = self.machine.can_jog(Axis.Z) + self.reverse_z_axis_row.set_visible(has_z) diff --git a/rayforge/ui_gtk/machine/head_preferences_page.py b/rayforge/ui_gtk/machine/head_preferences_page.py new file mode 100644 index 000000000..06b0148ff --- /dev/null +++ b/rayforge/ui_gtk/machine/head_preferences_page.py @@ -0,0 +1,1089 @@ +from gettext import gettext as _ +from pathlib import Path +from typing import cast + +from gi.repository import Adw, Gdk, Gtk +from raygeo.ops.state import CoolantMode + +from ...context import get_context +from ...core.model import Model +from ...machine.models.head import Head +from ...machine.models.laser import LaserHead, LaserType +from ...machine.models.machine import Machine +from ...machine.models.spindle import SpindleHead +from ...shared.util.glib import DebounceMixin +from ..icons import get_icon +from ..shared.model_selection_dialog import ModelSelectionDialog +from ..shared.pref_rows.angle_spin_row import AngleSpinRow +from ..shared.pref_rows.base import SpinRow +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.pref_rows.speed_spin_row import SpeedSpinRow +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage +from ..sim3d.renderer.model_renderer import get_model_extent + + +class HeadRow(Gtk.Box): + """A widget representing a single head in the ListBox.""" + + def __init__(self, machine: Machine, head: Head): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.machine = machine + self.head = head + self.delete_button: Gtk.Button + self.title_label: Gtk.Label + self.subtitle_label: Gtk.Label + self._setup_ui() + + def _setup_ui(self): + """Builds the user interface for the row.""" + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(labels_box) + + self.title_label = Gtk.Label( + label=self.head.name, + halign=Gtk.Align.START, + xalign=0, + ) + labels_box.append(self.title_label) + + self.subtitle_label = Gtk.Label( + label=self._get_subtitle_text(), + halign=Gtk.Align.START, + xalign=0, + wrap=True, + ) + self.subtitle_label.add_css_class("dim-label") + labels_box.append(self.subtitle_label) + + self.delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + self.delete_button.add_css_class("flat") + self.delete_button.connect("clicked", self._on_remove_clicked) + self.append(self.delete_button) + + def _get_subtitle_text(self) -> str: + """Generates the subtitle text from head properties.""" + if isinstance(self.head, SpindleHead): + return _("Tool {tool_number}, {min_rpm}-{max_rpm} rpm").format( + tool_number=self.head.tool_number, + min_rpm=self.head.min_rpm, + max_rpm=self.head.max_rpm, + ) + if isinstance(self.head, LaserHead): + spot_x, spot_y = self.head.spot_size_mm + spot_x_str = f"{spot_x:.2f}".rstrip("0").rstrip(".") + spot_y_str = f"{spot_y:.2f}".rstrip("0").rstrip(".") + + return _( + "Tool {tool_number}, max power {max_power}, " + "spot size {spot_x}x{spot_y}" + ).format( + tool_number=self.head.tool_number, + max_power=self.head.max_power, + spot_x=spot_x_str, + spot_y=spot_y_str, + ) + return _("Tool {tool_number}").format( + tool_number=self.head.tool_number + ) + + def _on_remove_clicked(self, button: Gtk.Button): + """Asks the machine to remove the associated head.""" + self.machine.remove_head(self.head) + + +class HeadListEditor(PreferencesGroupWithButton): + """ + An Adwaita widget for displaying and managing the machine's heads. + """ + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + button_label=_("Add New Head"), + selection_mode=Gtk.SelectionMode.SINGLE, + **kwargs, + ) + self.machine = machine + self._setup_ui() + self.machine.changed.connect(self._on_machine_changed) + self._on_machine_changed(self.machine) # Initial population + + def _setup_ui(self): + """Configures the widget's list box and placeholder.""" + placeholder = Gtk.Label( + label=_("No heads configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.list_box.set_show_separators(True) + + def _on_machine_changed(self, sender: Machine, **kwargs): + """ + Callback to rebuild the list efficiently when the machine model + changes. + """ + selected_head = None + selected_row = self.list_box.get_selected_row() + if selected_row: + head_row = cast(HeadRow, selected_row.get_child()) + selected_head = head_row.head + + # Get current number of rows + row_count = 0 + while self.list_box.get_row_at_index(row_count): + row_count += 1 + + # Update or add rows to match machine.heads + new_selection_index = -1 + for i, head in enumerate(self.machine.heads): + if head == selected_head: + new_selection_index = i + + if i < row_count: + # Update existing row + row = self.list_box.get_row_at_index(i) + if not row: + continue + head_row = cast(HeadRow, row.get_child()) + head_row.head = head + head_row.title_label.set_label(head.name) + head_row.subtitle_label.set_label( + head_row._get_subtitle_text() + ) + else: + # Add new row + list_box_row = Gtk.ListBoxRow() + list_box_row.set_child(self.create_row_widget(head)) + self.list_box.append(list_box_row) + + # Remove extra rows + while row_count > len(self.machine.heads): + last_row = self.list_box.get_row_at_index(row_count - 1) + if last_row: + self.list_box.remove(last_row) + row_count -= 1 + + # Enforce at least one head by managing delete button sensitivity. + can_delete = len(self.machine.heads) > 1 + tooltip = None if can_delete else _("At least one head is required") + current_row_index = 0 + while True: + row = self.list_box.get_row_at_index(current_row_index) + if not row: + break + head_row = cast(HeadRow, row.get_child()) + head_row.delete_button.set_sensitive(can_delete) + head_row.delete_button.set_tooltip_text(tooltip) + current_row_index += 1 + + # Restore selection + if new_selection_index >= 0: + row = self.list_box.get_row_at_index(new_selection_index) + self.list_box.select_row(row) + elif len(self.machine.heads) > 0: + row = self.list_box.get_row_at_index(0) + self.list_box.select_row(row) + else: + # Manually trigger selection changed handler for empty state + if self.list_box.get_selected_row(): + self.list_box.unselect_all() + else: + self.list_box.emit("row-selected", None) + + def create_row_widget(self, item: Head) -> Gtk.Widget: + """Creates a HeadRow for the given head item.""" + return HeadRow(self.machine, item) + + def _create_add_button(self, button_label: str) -> Gtk.Widget: + """Creates a MenuButton with a chooser for the head type.""" + menu_btn = Gtk.MenuButton() + content = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + halign=Gtk.Align.CENTER, + margin_top=10, + margin_end=12, + margin_bottom=10, + margin_start=12, + ) + content.append(get_icon("add-symbolic")) + content.append(Gtk.Label(label=button_label)) + menu_btn.set_child(content) + + popover = Gtk.Popover() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + laser_button = Gtk.Button(label=_("Laser")) + spindle_button = Gtk.Button(label=_("Spindle")) + laser_button.add_css_class("flat") + spindle_button.add_css_class("flat") + laser_button.connect( + "clicked", + lambda *args: self._add_head(LaserHead(), _("New Laser")), + ) + spindle_button.connect( + "clicked", + lambda *args: self._add_head(SpindleHead(), _("New Spindle")), + ) + vbox.append(laser_button) + vbox.append(spindle_button) + popover.set_child(vbox) + menu_btn.set_popover(popover) + return menu_btn + + def _add_head(self, head: Head, default_name: str): + """Adds a new head and selects its row.""" + popover = cast(Gtk.MenuButton, self.add_button).get_popover() + if popover: + popover.popdown() + head.name = default_name + self.machine.add_head(head) + + # The machine.changed signal has already run and updated the UI. + # Now, select the newly added row, which is the last one. + new_row_index = len(self.machine.heads) - 1 + if new_row_index >= 0: + row = self.list_box.get_row_at_index(new_row_index) + self.list_box.select_row(row) + + +class HeadModelGroup(Adw.PreferencesGroup): + """3D model and transform editing shared by all head types.""" + + def __init__(self): + super().__init__( + title=_("3D Model"), + description=_("Select and configure a 3D model for this head."), + ) + self._head: Head | None = None + self._setup_ui() + + def _setup_ui(self): + """Builds the model and transform rows.""" + self.model_row = Adw.ActionRow( + title=_("Model"), + activatable=True, + ) + self.model_row.connect("activated", self._on_model_activated) + self.model_row.add_suffix(get_icon("go-next-symbolic")) + self.add(self.model_row) + + self.scale_row = SpinRow( + _("Scale"), + _("Uniform scale factor for the model"), + lower=0.01, + upper=1000, + digits=2, + ) + self.scale_row.value_changed.connect(self._on_scale_changed) + self.add(self.scale_row) + + self.rx_row = AngleSpinRow( + _("X Rotation"), + _("Degrees around the X axis"), + ) + self.rx_row.value_changed.connect(self._on_rotation_changed) + self.add(self.rx_row) + + self.ry_row = AngleSpinRow( + _("Y Rotation"), + _("Degrees around the Y axis"), + ) + self.ry_row.value_changed.connect(self._on_rotation_changed) + self.add(self.ry_row) + + self.rz_row = AngleSpinRow( + _("Z Rotation"), + _("Degrees around the Z axis"), + ) + self.rz_row.value_changed.connect(self._on_rotation_changed) + self.add(self.rz_row) + + def set_head(self, head: Head | None): + """Syncs the rows with the given head.""" + self._head = head + if head is None: + return + self.scale_row.set_value(head.get_scale()) + rx, ry, rz = head.get_rotation() + self.rx_row.set_value(rx) + self.ry_row.set_value(ry) + self.rz_row.set_value(rz) + self._update_model_subtitle(head) + + def _update_model_subtitle(self, head: Head): + if head.model_path: + model_mgr = get_context().model_mgr + model = Model.from_path(Path(head.model_path)) + resolved = model_mgr.resolve(model) + if resolved: + self.model_row.set_subtitle(resolved.stem) + return + self.model_row.set_subtitle(_("None")) + + def _on_model_activated(self, row): + head = self._head + if not head: + return + + root = self.get_root() + dialog = ModelSelectionDialog( + current_model_path=head.model_path, + transient_for=cast(Gtk.Window, root) if root else None, + ) + + def on_response(d, response_id): + if response_id != "select": + d.destroy() + return + selected_path = d.get_selected_model_path() + if selected_path != head.model_path: + head.set_model_path(selected_path) + if selected_path is not None: + self._apply_model_scale(head, selected_path) + self._update_model_subtitle(head) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _apply_model_scale(self, head: Head, model_path: str): + resolved = get_context().model_mgr.resolve( + Model.from_path(Path(model_path)) + ) + if resolved is None: + return + extent = get_model_extent(resolved) + if extent and extent > 1e-6: + head.set_scale(40.0 / extent) + self.scale_row.set_value(head.get_scale()) + + def _on_scale_changed(self, _spinrow): + if self._head: + self._head.set_scale(self.scale_row.get_value()) + + def _on_rotation_changed(self, _spinrow): + if not self._head: + return + rx = self.rx_row.get_value() + ry = self.ry_row.get_value() + rz = self.rz_row.get_value() + self._head.set_rotation(rx, ry, rz) + + +class LaserHeadDetailWidget(DebounceMixin): + """Owns the PreferencesGroups for editing a LaserHead.""" + + def __init__(self): + super().__init__() + self._head: LaserHead | None = None + self._handler_ids = {} + self._laser_type_values = [ + LaserType.DIODE, + LaserType.CO2, + LaserType.FIBER, + ] + + self.properties_group = Adw.PreferencesGroup( + title=_("Laser Properties"), + description=_("Configure the selected laser head."), + ) + self.pwm_group = Adw.PreferencesGroup( + title=_("PWM"), + description=_( + "Pulse Width Modulation settings for frequency " + "and pulse width control." + ), + ) + self.frame_group = Adw.PreferencesGroup( + title=_("Framing"), + description=_( + "Settings for the frame outline operation that " + "traces the job boundary." + ), + ) + self.model_group = HeadModelGroup() + self.groups: list[Adw.PreferencesGroup] = [ + self.properties_group, + self.pwm_group, + self.frame_group, + self.model_group, + ] + self._build_ui() + + def _build_ui(self): + """Builds the laser-specific rows.""" + self.name_row = Adw.EntryRow(title=_("Name")) + self._handler_ids["name"] = self.name_row.connect( + "changed", self._on_name_changed + ) + self.properties_group.add(self.name_row) + + self.tool_number_row = SpinRow( + _("Tool Number"), + _("G-code tool number (e.g., T0, T1)"), + lower=-32768, + upper=65535, + page_increment=1, + value=0, + ) + self.tool_number_row.value_changed.connect( + self._on_tool_number_changed + ) + self.properties_group.add(self.tool_number_row) + + laser_type_store = Gtk.StringList() + laser_type_store.append(_("Diode")) + laser_type_store.append(_("CO₂")) + laser_type_store.append(_("Fiber")) + self.laser_type_row = Adw.ComboRow( + title=_("Laser Type"), + subtitle=_("Type of laser tube or diode"), + model=laser_type_store, + ) + self._handler_ids["laser_type"] = self.laser_type_row.connect( + "notify::selected", self._on_laser_type_changed + ) + self.properties_group.add(self.laser_type_row) + + self.max_power_row = SpinRow( + _("Max Power"), + _("Maximum power value in GCode"), + upper=100000, + value=0, + ) + self.max_power_row.value_changed.connect(self._on_max_power_changed) + self.properties_group.add(self.max_power_row) + + self.focus_power_row = SpinRow( + _("Focus Power"), + _("Power value in percent to use when focusing. 0 to disable"), + upper=100, + step_increment=0.1, + digits=2, + value=0, + ) + self.focus_power_row.value_changed.connect( + self._on_focus_power_changed + ) + self.properties_group.add(self.focus_power_row) + + self.spot_size_x_row = LengthSpinRow( + _("Spot Size X"), + _("Size of the laser spot in the X direction"), + lower=0.01, + upper=10.0, + step_increment=0.01, + page_increment=0.05, + value_in_base=0.1, + ) + self.spot_size_x_row.value_changed.connect(self._on_spot_size_changed) + self.properties_group.add(self.spot_size_x_row) + + self.spot_size_y_row = LengthSpinRow( + _("Spot Size Y"), + _("Size of the laser spot in the Y direction"), + lower=0.01, + upper=10.0, + step_increment=0.01, + page_increment=0.05, + value_in_base=0.1, + ) + self.spot_size_y_row.value_changed.connect(self._on_spot_size_changed) + self.properties_group.add(self.spot_size_y_row) + + self.cut_color_button = Gtk.ColorButton() + self.cut_color_button.set_size_request(32, 32) + self.cut_color_row = Adw.ActionRow( + title=_("Cut Color"), + subtitle=_("Color for cutting operations"), + activatable_widget=self.cut_color_button, + ) + self.cut_color_row.add_suffix(self.cut_color_button) + self._handler_ids["cut_color"] = self.cut_color_button.connect( + "color-set", self._on_cut_color_changed + ) + self.properties_group.add(self.cut_color_row) + + self.raster_color_button = Gtk.ColorButton() + self.raster_color_button.set_size_request(32, 32) + self.raster_color_row = Adw.ActionRow( + title=_("Raster Color"), + subtitle=_("Color for engraving/raster operations"), + activatable_widget=self.raster_color_button, + ) + self.raster_color_row.add_suffix(self.raster_color_button) + self._handler_ids["raster_color"] = self.raster_color_button.connect( + "color-set", self._on_raster_color_changed + ) + self.properties_group.add(self.raster_color_row) + + self.focal_distance_row = LengthSpinRow( + _("Focal Distance"), + _("Distance from the laser head to the work surface (Z offset)"), + upper=10000, + value_in_base=0, + ) + self.focal_distance_row.value_changed.connect( + self._on_focal_distance_changed + ) + self.properties_group.add(self.focal_distance_row) + + self.pwm_frequency_row = SpinRow( + _("PWM Frequency"), + _("Default PWM frequency in Hz"), + lower=1, + upper=100000, + step_increment=100, + ) + self.pwm_frequency_row.value_changed.connect( + self._on_pwm_frequency_changed + ) + self.pwm_group.add(self.pwm_frequency_row) + + self.max_pwm_frequency_row = SpinRow( + _("Max PWM Frequency"), + _("Maximum supported PWM frequency in Hz"), + lower=1, + upper=100000, + step_increment=100, + ) + self.max_pwm_frequency_row.value_changed.connect( + self._on_max_pwm_frequency_changed + ) + self.pwm_group.add(self.max_pwm_frequency_row) + + self.pulse_width_row = SpinRow( + _("Pulse Width"), + _("Default pulse width in µs"), + lower=1, + upper=100000, + ) + self.pulse_width_row.value_changed.connect( + self._on_pulse_width_changed + ) + self.pwm_group.add(self.pulse_width_row) + + self.min_pulse_width_row = SpinRow( + _("Min Pulse Width"), + _("Minimum pulse width in µs"), + lower=1, + upper=100000, + ) + self.min_pulse_width_row.value_changed.connect( + self._on_min_pulse_width_changed + ) + self.pwm_group.add(self.min_pulse_width_row) + + self.max_pulse_width_row = SpinRow( + _("Max Pulse Width"), + _("Maximum pulse width in µs"), + lower=1, + upper=100000, + ) + self.max_pulse_width_row.value_changed.connect( + self._on_max_pulse_width_changed + ) + self.pwm_group.add(self.max_pulse_width_row) + + self.frame_power_row = SpinRow( + _("Frame Power"), + _("Power value in percent to use when framing. 0 to disable"), + upper=100, + step_increment=0.1, + digits=2, + value=0, + ) + self.frame_power_row.value_changed.connect( + self._on_frame_power_changed + ) + self.frame_group.add(self.frame_power_row) + + self.frame_speed_row = SpeedSpinRow( + _("Frame Speed"), + _( + "Speed for frame outline. Leave at 0 to use " + "the machine's max travel speed" + ), + upper=60000, + digits=0, + ) + self.frame_speed_row.value_changed.connect( + self._on_frame_speed_changed + ) + self.frame_group.add(self.frame_speed_row) + + self.frame_repeat_row = SpinRow( + _("Repeat Count"), + _("Number of times to trace the frame outline"), + lower=1, + upper=100, + page_increment=5, + value=1, + ) + self.frame_repeat_row.value_changed.connect( + self._on_frame_repeat_changed + ) + self.frame_group.add(self.frame_repeat_row) + + self.frame_corner_pause_row = SpinRow( + _("Pause at Corners"), + _( + "Pause duration in seconds at each corner " + "of the frame outline. 0 to disable" + ), + upper=10, + step_increment=0.1, + digits=1, + value=0, + ) + self.frame_corner_pause_row.value_changed.connect( + self._on_frame_corner_pause_changed + ) + self.frame_group.add(self.frame_corner_pause_row) + + def set_head(self, head: LaserHead | None): + """Syncs the laser rows with the given head.""" + self._head = head + self.model_group.set_head(head) + if head is None: + for group in self.groups: + group.set_visible(False) + return + for group in self.groups: + group.set_visible(True) + + # Block handlers to prevent feedback loop + self.name_row.handler_block(self._handler_ids["name"]) + self.laser_type_row.handler_block(self._handler_ids["laser_type"]) + self.cut_color_button.handler_block(self._handler_ids["cut_color"]) + self.raster_color_button.handler_block( + self._handler_ids["raster_color"] + ) + + self.name_row.set_text(head.name) + self.tool_number_row.set_value(head.tool_number) + self.max_power_row.set_value(head.max_power) + self.focus_power_row.set_value(head.focus_power_percent * 100) + spot_x, spot_y = head.spot_size_mm + self.spot_size_x_row.set_value_in_base_units(spot_x) + self.spot_size_y_row.set_value_in_base_units(spot_y) + self._set_color_button(self.cut_color_button, head.cut_color) + self._set_color_button(self.raster_color_button, head.raster_color) + self.focal_distance_row.set_value_in_base_units(head.focal_distance) + self.frame_power_row.set_value(head.frame_power_percent * 100) + self.frame_speed_row.set_value_in_base_units(head.frame_speed) + self.frame_repeat_row.set_value(head.frame_repeat_count) + self.frame_corner_pause_row.set_value(head.frame_corner_pause) + + try: + type_idx = self._laser_type_values.index(head.laser_type) + except ValueError: + type_idx = 0 + self.laser_type_row.set_selected(type_idx) + + self.pwm_frequency_row.set_value(head.pwm_frequency) + self.max_pwm_frequency_row.set_value(head.max_pwm_frequency) + self.pulse_width_row.set_value(head.pulse_width) + self.min_pulse_width_row.set_value(head.min_pulse_width) + self.max_pulse_width_row.set_value(head.max_pulse_width) + self._update_pwm_visibility() + + # Unblock handlers + self.name_row.handler_unblock(self._handler_ids["name"]) + self.laser_type_row.handler_unblock(self._handler_ids["laser_type"]) + self.cut_color_button.handler_unblock(self._handler_ids["cut_color"]) + self.raster_color_button.handler_unblock( + self._handler_ids["raster_color"] + ) + + def _on_name_changed(self, entry_row): + """Update the name of the selected laser.""" + if self._head: + self._head.set_name(entry_row.get_text()) + + def _on_tool_number_changed(self, spinrow): + """Update the tool number of the selected laser.""" + if self._head: + self._head.set_tool_number(spinrow.get_int_value()) + + def _on_max_power_changed(self, spinrow): + """Update the max power of the selected laser.""" + if self._head: + self._head.set_max_power(spinrow.get_int_value()) + + def _on_frame_power_changed(self, spinrow): + """Update the frame power of the selected laser.""" + if self._head: + self._head.set_frame_power(spinrow.get_value() / 100) + + def _on_focus_power_changed(self, spinrow): + """Update the focus power of the selected laser.""" + if self._head: + self._head.set_focus_power(spinrow.get_value() / 100) + + def _on_spot_size_changed(self, spinrow): + """Update the spot size of the selected laser.""" + if not self._head: + return + x = self.spot_size_x_row.get_value_in_base_units() + y = self.spot_size_y_row.get_value_in_base_units() + self._head.set_spot_size(x, y) + + def _set_color_button(self, button: Gtk.ColorButton, hex_color: str): + """Set the color button from a hex color string.""" + rgba = Gdk.RGBA() + if not rgba.parse(hex_color): + rgba.parse("#ff00ff") + button.set_rgba(rgba) + + def _get_hex_color(self, button: Gtk.ColorButton) -> str: + """Get the hex color string from a color button.""" + rgba = button.get_rgba() + r = int(rgba.red * 255) + g = int(rgba.green * 255) + b = int(rgba.blue * 255) + return f"#{r:02x}{g:02x}{b:02x}" + + def _on_cut_color_changed(self, button: Gtk.ColorButton): + """Update the cut color of the selected laser.""" + if self._head: + self._head.set_cut_color(self._get_hex_color(button)) + + def _on_raster_color_changed(self, button: Gtk.ColorButton): + """Update the raster color of the selected laser.""" + if self._head: + self._head.set_raster_color(self._get_hex_color(button)) + + def _on_frame_speed_changed(self, spinrow): + """Update the frame speed of the selected laser.""" + if not self._head: + return + value = self.frame_speed_row.get_value_in_base_units() + self._head.set_frame_speed(int(value)) + + def _on_frame_repeat_changed(self, spinrow): + """Update the frame repeat count of the selected laser.""" + if self._head: + self._head.set_frame_repeat_count(spinrow.get_int_value()) + + def _on_frame_corner_pause_changed(self, spinrow): + """Update the frame corner pause of the selected laser.""" + if self._head: + self._head.set_frame_corner_pause(spinrow.get_value()) + + def _on_focal_distance_changed(self, spinrow): + if self._head: + self._head.set_focal_distance( + self.focal_distance_row.get_value_in_base_units() + ) + + def _on_laser_type_changed(self, row, _param): + if not self._head: + return + selected = row.get_selected() + if selected < len(self._laser_type_values): + self._head.set_laser_type(self._laser_type_values[selected]) + self._update_pwm_visibility() + + def _update_pwm_visibility(self): + if self._head is not None: + show_pwm = self._head.laser_type.supports_pwm + else: + show_pwm = False + self.pwm_group.set_visible(show_pwm) + + def _apply_pwm_fields(self, laser): + laser.set_max_pwm_frequency(self.max_pwm_frequency_row.get_int_value()) + laser.set_pwm_frequency(self.pwm_frequency_row.get_int_value()) + laser.set_max_pulse_width(self.max_pulse_width_row.get_int_value()) + laser.set_min_pulse_width(self.min_pulse_width_row.get_int_value()) + laser.set_pulse_width(self.pulse_width_row.get_int_value()) + + def _on_pwm_frequency_changed(self, spinrow): + if not self._head: + return + value = spinrow.get_int_value() + max_val = self.max_pwm_frequency_row.get_int_value() + if value > max_val: + self.max_pwm_frequency_row.set_value(value) + self._debounce(self._apply_pwm_fields, self._head) + + def _on_max_pwm_frequency_changed(self, spinrow): + if not self._head: + return + max_val = spinrow.get_int_value() + freq_val = self.pwm_frequency_row.get_int_value() + if freq_val > max_val: + self.pwm_frequency_row.set_value(max_val) + self._debounce(self._apply_pwm_fields, self._head) + + def _on_pulse_width_changed(self, spinrow): + if not self._head: + return + value = spinrow.get_int_value() + min_val = self.min_pulse_width_row.get_int_value() + max_val = self.max_pulse_width_row.get_int_value() + if value < min_val: + self.min_pulse_width_row.set_value(value) + if value > max_val: + self.max_pulse_width_row.set_value(value) + self._debounce(self._apply_pwm_fields, self._head) + + def _on_min_pulse_width_changed(self, spinrow): + if not self._head: + return + min_val = spinrow.get_int_value() + max_val = self.max_pulse_width_row.get_int_value() + if min_val > max_val: + self.max_pulse_width_row.set_value(min_val) + pw_val = self.pulse_width_row.get_int_value() + if pw_val < min_val: + self.pulse_width_row.set_value(min_val) + self._debounce(self._apply_pwm_fields, self._head) + + def _on_max_pulse_width_changed(self, spinrow): + if not self._head: + return + max_val = spinrow.get_int_value() + min_val = self.min_pulse_width_row.get_int_value() + if max_val < min_val: + self.min_pulse_width_row.set_value(max_val) + pw_val = self.pulse_width_row.get_int_value() + if pw_val > max_val: + self.pulse_width_row.set_value(max_val) + self._debounce(self._apply_pwm_fields, self._head) + + +class SpindleHeadDetailWidget: + """Owns the PreferencesGroups for editing a SpindleHead.""" + + def __init__(self): + self._head: SpindleHead | None = None + self._handler_ids = {} + + self.properties_group = Adw.PreferencesGroup( + title=_("Spindle Properties"), + description=_("Configure the selected spindle head."), + ) + self.model_group = HeadModelGroup() + self.groups: list[Adw.PreferencesGroup] = [ + self.properties_group, + self.model_group, + ] + self._build_ui() + + def _build_ui(self): + """Builds the spindle-specific rows.""" + self.name_row = Adw.EntryRow(title=_("Name")) + self._handler_ids["name"] = self.name_row.connect( + "changed", self._on_name_changed + ) + self.properties_group.add(self.name_row) + + self.tool_number_row = SpinRow( + _("Tool Number"), + _("G-code tool number (e.g., T0, T1)"), + lower=-32768, + upper=65535, + page_increment=1, + value=0, + ) + self.tool_number_row.value_changed.connect( + self._on_tool_number_changed + ) + self.properties_group.add(self.tool_number_row) + + self.min_rpm_row = SpinRow( + _("Min RPM"), + _("Minimum spindle speed"), + upper=100000, + step_increment=100, + value=1000, + ) + self.min_rpm_row.value_changed.connect(self._on_min_rpm_changed) + self.properties_group.add(self.min_rpm_row) + + self.max_rpm_row = SpinRow( + _("Max RPM"), + _("Maximum spindle speed"), + upper=100000, + step_increment=100, + value=20000, + ) + self.max_rpm_row.value_changed.connect(self._on_max_rpm_changed) + self.properties_group.add(self.max_rpm_row) + + self.flood_cooling_row = Adw.SwitchRow( + title=_("Supports Flood Coolant"), + subtitle=_("Coolant delivered to the workpiece as a flood"), + ) + self._handler_ids["flood_cooling"] = self.flood_cooling_row.connect( + "notify::active", self._on_cooling_method_toggled + ) + self.properties_group.add(self.flood_cooling_row) + + self.mist_cooling_row = Adw.SwitchRow( + title=_("Supports Mist Coolant"), + subtitle=_("Coolant delivered to the workpiece as a mist"), + ) + self._handler_ids["mist_cooling"] = self.mist_cooling_row.connect( + "notify::active", self._on_cooling_method_toggled + ) + self.properties_group.add(self.mist_cooling_row) + + def set_head(self, head: SpindleHead | None): + """Syncs the spindle rows with the given head.""" + self._head = head + self.model_group.set_head(head) + if head is None: + for group in self.groups: + group.set_visible(False) + return + for group in self.groups: + group.set_visible(True) + + self.name_row.handler_block(self._handler_ids["name"]) + self.flood_cooling_row.handler_block( + self._handler_ids["flood_cooling"] + ) + self.mist_cooling_row.handler_block(self._handler_ids["mist_cooling"]) + + self.name_row.set_text(head.name) + self.tool_number_row.set_value(head.tool_number) + self.min_rpm_row.set_value(head.min_rpm) + self.max_rpm_row.set_value(head.max_rpm) + self.flood_cooling_row.set_active( + CoolantMode.FLOOD in head.cooling_methods + ) + self.mist_cooling_row.set_active( + CoolantMode.MIST in head.cooling_methods + ) + + self.name_row.handler_unblock(self._handler_ids["name"]) + self.flood_cooling_row.handler_unblock( + self._handler_ids["flood_cooling"] + ) + self.mist_cooling_row.handler_unblock( + self._handler_ids["mist_cooling"] + ) + + def _on_name_changed(self, entry_row): + """Update the name of the selected spindle.""" + if self._head: + self._head.set_name(entry_row.get_text()) + + def _on_tool_number_changed(self, spinrow): + """Update the tool number of the selected spindle.""" + if self._head: + self._head.set_tool_number(spinrow.get_int_value()) + + def _on_max_rpm_changed(self, spinrow): + """Update the max RPM of the selected spindle.""" + if self._head: + self._head.set_max_rpm(spinrow.get_int_value()) + + def _on_min_rpm_changed(self, spinrow): + """Update the min RPM of the selected spindle.""" + if self._head: + self._head.set_min_rpm(spinrow.get_int_value()) + + def _on_cooling_method_toggled(self, row, _param): + """Updates the supported coolant methods of the selected spindle.""" + if not self._head: + return + methods = set(self._head.cooling_methods) + method = ( + CoolantMode.FLOOD + if row is self.flood_cooling_row + else CoolantMode.MIST + ) + if row.get_active(): + methods.add(method) + else: + methods.discard(method) + self._head.set_cooling_methods(methods) + + +class HeadPreferencesPage(TrackedPreferencesPage): + """Machine settings page for managing all heads.""" + + key = "heads" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("Heads"), + icon_name="settings-symbolic", + **kwargs, + ) + self.machine = machine + + self.head_list_editor = HeadListEditor( + machine=self.machine, + title=_("Heads"), + description=_( + "You can configure multiple lasers or spindles if your machine" + " supports it." + ), + ) + self.add(self.head_list_editor) + + self.laser_widget = LaserHeadDetailWidget() + for group in self.laser_widget.groups: + self.add(group) + + self.spindle_widget = SpindleHeadDetailWidget() + for group in self.spindle_widget.groups: + self.add(group) + + # Connect signals + self.head_list_editor.list_box.connect( + "row-selected", self._on_head_selected + ) + + # The initial selection is set inside the HeadListEditor's + # constructor, which runs before this signal handler is connected. + # Manually trigger the handler now to sync the UI with the initial + # state. + initial_row = self.head_list_editor.list_box.get_selected_row() + self._on_head_selected(self.head_list_editor.list_box, initial_row) + + self.connect("destroy", self._on_destroy) + + def _get_selected_head(self) -> Head | None: + selected_row = self.head_list_editor.list_box.get_selected_row() + if not selected_row: + return None + # The child of the ListBoxRow is our custom HeadRow + head_row = cast(HeadRow, selected_row.get_child()) + return head_row.head + + def _on_head_selected(self, listbox, row): + """Swap the detail widget based on the selected head's type.""" + head = self._get_selected_head() + if isinstance(head, LaserHead): + self.spindle_widget.set_head(None) + self.laser_widget.set_head(head) + elif isinstance(head, SpindleHead): + self.laser_widget.set_head(None) + self.spindle_widget.set_head(head) + else: + self.laser_widget.set_head(None) + self.spindle_widget.set_head(None) + + def _on_destroy(self, *args): + """Disconnects signals to prevent memory leaks.""" + self.machine.changed.disconnect( + self.head_list_editor._on_machine_changed + ) diff --git a/rayforge/ui_gtk/machine/hook_list.py b/rayforge/ui_gtk/machine/hook_list.py new file mode 100644 index 000000000..b74bdc496 --- /dev/null +++ b/rayforge/ui_gtk/machine/hook_list.py @@ -0,0 +1,184 @@ +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from ...machine.models.machine import Machine +from ...machine.models.macro import Macro, MacroTrigger +from ..icons import get_icon +from .gcode_editor import GcodeEditorDialog + + +class HookList(Adw.PreferencesGroup): + """ + An Adwaita widget for displaying and managing a static list of G-code + hooks. + """ + + def __init__(self, machine: Machine, **kwargs): + super().__init__(title=_("G-code Hooks"), **kwargs) + self.machine = machine + # Store references to the rows and their widgets to update them later + self.trigger_widgets: dict[ + MacroTrigger, tuple[Adw.ActionRow, Gtk.Button, Gtk.Switch] + ] = {} + self._setup_ui() + # Connect to machine changes to update row state if a macro is + # added/removed, or if the dialect changes. + self.machine.changed.connect(self._on_machine_changed) + + def _setup_ui(self): + """Builds the user interface for the editor.""" + self.set_description( + _( + "Add custom G-code to be executed " + "at specific points in the job." + ) + ) + + for trigger in MacroTrigger: + row = Adw.ActionRow() + row.set_title(trigger.label()) + + # Switch to enable/disable + switch = Gtk.Switch(valign=Gtk.Align.CENTER) + switch.connect("notify::active", self._on_enable_toggled, trigger) + row.add_suffix(switch) + + # "Reset to Default" button + reset_button = Gtk.Button(valign=Gtk.Align.CENTER) + reset_button.set_child(get_icon("undo-symbolic")) + reset_button.add_css_class("flat") + reset_button.set_tooltip_text(_("Reset to Default")) + reset_button.connect("clicked", self._on_reset_clicked, trigger) + row.add_suffix(reset_button) + + # "Edit" button + edit_button = Gtk.Button(valign=Gtk.Align.CENTER) + edit_button.set_child(get_icon("edit-symbolic")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", self._on_edit_clicked, trigger) + row.add_suffix(edit_button) + row.set_activatable_widget(edit_button) + + self.add(row) + + # Store references to the created widgets, keyed by trigger + self.trigger_widgets[trigger] = (row, reset_button, switch) + self._update_row_state(trigger) + + def _update_row_state(self, trigger: MacroTrigger): + """Sets the row's subtitle and widget visibility.""" + row, reset_button, switch = self.trigger_widgets[trigger] + + row.set_subtitle(trigger.description()) + + # A hook is considered "customized" if its key exists in the + # dictionary, + # regardless of whether the code is empty or not. + is_customized = trigger in self.machine.hookmacros + + reset_button.set_visible(is_customized) + switch.set_visible(is_customized) + + macro = self.machine.hookmacros.get(trigger) + if macro: + switch.set_active(macro.enabled) + + def _on_machine_changed(self, sender: Machine, **kwargs): + """Update all row styles when the machine model changes.""" + for trigger in self.trigger_widgets: + self._update_row_state(trigger) + + def _on_enable_toggled(self, switch: Gtk.Switch, _, trigger: MacroTrigger): + """Handles the state change of the enable/disable switch for a hook.""" + macro = self.machine.hookmacros.get(trigger) + if macro: + is_active = switch.get_active() + if macro.enabled != is_active: + macro.enabled = is_active + self.machine.changed.send(self.machine) + + def _on_reset_clicked(self, button: Gtk.Button, trigger: MacroTrigger): + """Shows a confirmation dialog before resetting a hook macro.""" + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + hook_name = trigger.label() + + dialog = Adw.MessageDialog( + transient_for=parent, + heading=_("Reset '{hook_name}' to Default?").format( + hook_name=hook_name + ), + body=_( + "This will remove your custom G-code for this hook. " + "The machine will revert to using its built-in default " + "macro. This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("reset", _("Reset")) + dialog.set_response_appearance( + "reset", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + dialog.connect("response", self._on_reset_response, trigger) + dialog.present() + + def _on_reset_response( + self, + dialog: Adw.MessageDialog, + response_id: str, + trigger: MacroTrigger, + ): + """Handles the response from the reset confirmation dialog.""" + if response_id != "reset": + return + + if trigger in self.machine.hookmacros: + del self.machine.hookmacros[trigger] + self.machine.changed.send(self.machine) + + def _on_edit_clicked(self, button: Gtk.Button, trigger: MacroTrigger): + """Handles the 'Edit' button click for a specific trigger.""" + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + + # If a macro already exists (even if empty), edit it directly. + # Otherwise, create a new one. + existing_macro = self.machine.hookmacros.get(trigger) + if existing_macro is not None: + macro_to_edit = existing_macro + else: + macro_to_edit = Macro( + name=trigger.label(), + code=[_("# Your G-code here")], + ) + + # Pass the list of available macros for the include popover + existing_macros = list(self.machine.macros.values()) + + editor_dialog = GcodeEditorDialog( + parent, + macro_to_edit, + allow_name_edit=False, + existing_macros=existing_macros, + ) + editor_dialog.connect( + "close-request", + self._on_edit_dialog_closed, + trigger, + macro_to_edit, + ) + editor_dialog.present() + + def _on_edit_dialog_closed( + self, dialog: GcodeEditorDialog, trigger: MacroTrigger, macro: Macro + ): + """ + Handles closing the editor. If saved, updates the macro in the + machine model's hook dictionary. + """ + if dialog.saved: + # Always save the macro, even if its code is empty. + # This correctly represents the user's intent. + self.machine.hookmacros[trigger] = macro + self.machine.changed.send(self.machine) diff --git a/rayforge/ui_gtk/machine/hooks_macros_page.py b/rayforge/ui_gtk/machine/hooks_macros_page.py new file mode 100644 index 000000000..f90e9619a --- /dev/null +++ b/rayforge/ui_gtk/machine/hooks_macros_page.py @@ -0,0 +1,29 @@ +from gettext import gettext as _ + +from ...machine.models.machine import Machine +from ..shared.preferences_page import TrackedPreferencesPage +from .hook_list import HookList +from .macro_list import MacroListEditor + + +class HooksMacrosPage(TrackedPreferencesPage): + key = "hooks-macros" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("Hooks & Macros"), + icon_name="code-symbolic", + **kwargs, + ) + self.machine = machine + + hook_list = HookList(machine=self.machine) + self.add(hook_list) + + macro_editor = MacroListEditor( + machine=self.machine, + title=_("Macros"), + description=_("Create and manage reusable G-code snippets."), + ) + self.add(macro_editor) diff --git a/rayforge/ui_gtk/machine/jog_widget.py b/rayforge/ui_gtk/machine/jog_widget.py new file mode 100644 index 000000000..6f4bebafc --- /dev/null +++ b/rayforge/ui_gtk/machine/jog_widget.py @@ -0,0 +1,501 @@ +from gettext import gettext as _ + +from gi.repository import Gdk, Graphene, Gsk, Gtk +from raygeo.ops.axis import Axis + +from ...machine.cmd import MachineCmd +from ...machine.models.machine import JogDirection, Machine +from ..icons import get_icon + +_GAP = 12 +_SPACING = 6 +_MAX_HEIGHT = 4 * 60 + 3 * _SPACING + + +class JogWidget(Gtk.Widget): + """Widget for manually jogging the machine.""" + + def __init__(self, show_actions: bool = True, **kwargs): + super().__init__(**kwargs) + + self._jog_grid = Gtk.Grid() + self._jog_grid.set_parent(self) + self._jog_grid.set_row_spacing(_SPACING) + self._jog_grid.set_column_spacing(_SPACING) + self._jog_grid.set_row_homogeneous(True) + self._jog_grid.set_column_homogeneous(True) + + self._show_actions = show_actions + + self._action_grid = Gtk.Grid() + self._action_grid.set_parent(self) + self._action_grid.set_row_spacing(_SPACING) + self._action_grid.set_row_homogeneous(True) + self._action_grid.set_visible(show_actions) + + self.machine: Machine | None = None + self.machine_cmd: MachineCmd | None = None + self.jog_speed = 1000 + self.jog_distance = 10.0 + self._buttons = [] + + self.set_focusable(True) + + def create_button(icon_name, tooltip): + button = Gtk.Button() + button.set_size_request(60, 60) + button.set_tooltip_text(tooltip) + icon = get_icon(icon_name) + button.set_child(icon) + button.set_hexpand(True) + button.set_vexpand(True) + self._buttons.append(button) + return button + + # Row 0: NW - N - NE + self.north_west_btn = create_button( + "arrow-north-west-symbolic", _("Move North-West") + ) + self.north_west_btn.connect("clicked", self._on_x_minus_y_plus_clicked) + self._jog_grid.attach(self.north_west_btn, 0, 0, 1, 1) + + self.north_btn = create_button("arrow-north-symbolic", _("Move North")) + self.north_btn.connect("clicked", self._on_y_plus_clicked) + self._jog_grid.attach(self.north_btn, 1, 0, 1, 1) + + self.north_east_btn = create_button( + "arrow-north-east-symbolic", _("Move North-East") + ) + self.north_east_btn.connect("clicked", self._on_x_plus_y_plus_clicked) + self._jog_grid.attach(self.north_east_btn, 2, 0, 1, 1) + + # Row 1: W - Home - E + self.west_btn = create_button( + "arrow-west-symbolic", _("Move West (Left)") + ) + self.west_btn.connect("clicked", self._on_x_minus_clicked) + self._jog_grid.attach(self.west_btn, 0, 1, 1, 1) + + self.home_all_btn = create_button("home-symbolic", _("Home All")) + self.home_all_btn.connect("clicked", self._on_home_all_clicked) + self._jog_grid.attach(self.home_all_btn, 1, 1, 1, 1) + + self.east_btn = create_button( + "arrow-east-symbolic", _("Move East (Right)") + ) + self.east_btn.connect("clicked", self._on_x_plus_clicked) + self._jog_grid.attach(self.east_btn, 2, 1, 1, 1) + + # Row 2: SW - S - SE + self.south_west_btn = create_button( + "arrow-south-west-symbolic", _("Move South-West") + ) + self.south_west_btn.connect( + "clicked", self._on_x_minus_y_minus_clicked + ) + self._jog_grid.attach(self.south_west_btn, 0, 2, 1, 1) + + self.south_btn = create_button("arrow-south-symbolic", _("Move South")) + self.south_btn.connect("clicked", self._on_y_minus_clicked) + self._jog_grid.attach(self.south_btn, 1, 2, 1, 1) + + self.south_east_btn = create_button( + "arrow-south-east-symbolic", _("Move South-East") + ) + self.south_east_btn.connect("clicked", self._on_x_plus_y_minus_clicked) + self._jog_grid.attach(self.south_east_btn, 2, 2, 1, 1) + + # Row 3: home x - home y - home z + self.home_x_btn = create_button("home-x-symbolic", _("Home X")) + self.home_x_btn.connect("clicked", self._on_home_x_clicked) + self._jog_grid.attach(self.home_x_btn, 0, 3, 1, 1) + + self.home_y_btn = create_button("home-y-symbolic", _("Home Y")) + self.home_y_btn.connect("clicked", self._on_home_y_clicked) + self._jog_grid.attach(self.home_y_btn, 1, 3, 1, 1) + + self.home_z_btn = create_button("home-z-symbolic", _("Home Z")) + self.home_z_btn.connect("clicked", self._on_home_z_clicked) + self._jog_grid.attach(self.home_z_btn, 2, 3, 1, 1) + + # Action column (separate grid for extra gap) + self.send_btn = create_button("send-symbolic", _("Send to machine")) + self.send_btn.add_css_class("suggested-action") + self.send_btn.connect("clicked", self._on_send_clicked) + self._action_grid.attach(self.send_btn, 0, 0, 1, 1) + + self.z_plus_btn = create_button( + "arrow-z-up-symbolic", _("Increase Z-Distance") + ) + self.z_plus_btn.connect("clicked", self._on_z_plus_clicked) + self._action_grid.attach(self.z_plus_btn, 0, 1, 1, 1) + + self.z_minus_btn = create_button( + "arrow-z-down-symbolic", _("Decrease Z-Distance") + ) + self.z_minus_btn.connect("clicked", self._on_z_minus_clicked) + self._action_grid.attach(self.z_minus_btn, 0, 2, 1, 1) + + self.cancel_btn = create_button( + "stop-symbolic", _("Cancel running job") + ) + self.cancel_btn.add_css_class("destructive-action") + self.cancel_btn.connect("clicked", self._on_cancel_clicked) + self._action_grid.attach(self.cancel_btn, 0, 3, 1, 1) + + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self.add_controller(key_controller) + + self._update_button_sensitivity() + + @staticmethod + def _calc_grid_widths(height): + cell_h = (height - 3 * _SPACING) / 4 + jog_w = 3 * cell_h + 2 * _SPACING + act_w = cell_h + return jog_w, act_w + + def do_get_request_mode(self): + return Gtk.SizeRequestMode.WIDTH_FOR_HEIGHT + + def do_measure(self, orientation, for_size): + if orientation == Gtk.Orientation.HORIZONTAL: + h = for_size if for_size > 0 else _MAX_HEIGHT + jog_w, act_w = self._calc_grid_widths(h) + total = int(jog_w) + if self._show_actions: + total += _GAP + int(act_w) + return (total, total, -1, -1) + m = self._jog_grid.measure(orientation, for_size) + return (m[0], min(m[1], _MAX_HEIGHT), -1, -1) + + def do_size_allocate(self, width, height, baseline): + jog_w, act_w = self._calc_grid_widths(height) + + if self._show_actions: + total_needed = jog_w + _GAP + act_w + if width > total_needed: + extra = width - total_needed + jog_w += extra * 3 / 4 + act_w += extra / 4 + act_w = int(act_w) + else: + jog_w = width + + jog_w = int(jog_w) + + self._jog_grid.allocate(jog_w, height, baseline, None) + + if self._show_actions: + transform = Gsk.Transform().translate( + Graphene.Point().init(jog_w + _GAP, 0) + ) + self._action_grid.allocate(act_w, height, baseline, transform) + else: + self._action_grid.allocate(0, 0, -1, None) + + def set_machine( + self, machine: Machine | None, machine_cmd: MachineCmd | None + ): + """Set the machine this widget controls.""" + if self.machine: + self.machine.state_changed.disconnect( + self._on_machine_state_changed + ) + self.machine.connection_status_changed.disconnect( + self._on_connection_status_changed + ) + self.machine.changed.disconnect(self._on_machine_changed) + + self.machine = machine + self.machine_cmd = machine_cmd + + if self.machine: + self.machine.state_changed.connect(self._on_machine_state_changed) + self.machine.connection_status_changed.connect( + self._on_connection_status_changed + ) + self.machine.changed.connect(self._on_machine_changed) + + self._update_button_sensitivity() + self._update_limit_status() + + def _on_machine_changed(self, sender, **kwargs): + self._update_button_sensitivity() + self._update_limit_status() + + def _jog_deltas(self, *directions: JogDirection) -> dict[Axis, float]: + """Aggregate native-axis deltas for one or more visual + directions.""" + if not self.machine: + return {} + deltas: dict[Axis, float] = {} + for direction in directions: + for axis, delta in self.machine.panel.calculate_jog( + direction, self.jog_distance + ).items(): + deltas[axis] = deltas.get(axis, 0.0) + delta + return deltas + + def _can_jog_direction(self, direction: JogDirection) -> bool: + """Whether the machine can jog every axis a direction drives.""" + if not self.machine: + return False + return all( + self.machine.can_jog(axis) for axis in self._jog_deltas(direction) + ) + + def _update_button_sensitivity(self): + """Update button sensitivity based on machine capabilities.""" + # Default all buttons to disabled + self.east_btn.set_sensitive(False) + self.west_btn.set_sensitive(False) + self.north_btn.set_sensitive(False) + self.south_btn.set_sensitive(False) + self.north_east_btn.set_sensitive(False) + self.north_west_btn.set_sensitive(False) + self.south_east_btn.set_sensitive(False) + self.south_west_btn.set_sensitive(False) + self.z_plus_btn.set_sensitive(False) + self.z_minus_btn.set_sensitive(False) + self.home_x_btn.set_sensitive(False) + self.home_y_btn.set_sensitive(False) + self.home_z_btn.set_sensitive(False) + self.home_all_btn.set_sensitive(False) + self.send_btn.set_sensitive(False) + self.cancel_btn.set_sensitive(False) + + # Only enable buttons if machine exists, is connected + if self.machine is None or not self.machine.is_connected(): + return + + # Type assertion to help Pylance understand machine is not None + machine: Machine = self.machine # type: ignore + + # Jog buttons - a direction is joggable when every native axis it + # drives is supported (under rotation a visual axis may map to + # the orthogonal native axis) + can_jog_east = self._can_jog_direction(JogDirection.EAST) + can_jog_west = self._can_jog_direction(JogDirection.WEST) + can_jog_north = self._can_jog_direction(JogDirection.NORTH) + can_jog_south = self._can_jog_direction(JogDirection.SOUTH) + self.east_btn.set_sensitive(can_jog_east) + self.west_btn.set_sensitive(can_jog_west) + self.north_btn.set_sensitive(can_jog_north) + self.south_btn.set_sensitive(can_jog_south) + + # Diagonal buttons - need both cardinal directions + self.north_east_btn.set_sensitive(can_jog_east and can_jog_north) + self.north_west_btn.set_sensitive(can_jog_west and can_jog_north) + self.south_east_btn.set_sensitive(can_jog_east and can_jog_south) + self.south_west_btn.set_sensitive(can_jog_west and can_jog_south) + + self.z_plus_btn.set_sensitive(self._can_jog_direction(JogDirection.UP)) + self.z_minus_btn.set_sensitive( + self._can_jog_direction(JogDirection.DOWN) + ) + + # Home buttons - only enable if single axis homing is supported + single_axis_homing = machine.single_axis_homing_enabled + self.home_x_btn.set_sensitive( + machine.can_home(Axis.X) and single_axis_homing + ) + self.home_y_btn.set_sensitive( + machine.can_home(Axis.Y) and single_axis_homing + ) + self.home_z_btn.set_sensitive( + machine.can_home(Axis.Z) and single_axis_homing + ) + self.home_all_btn.set_sensitive(True) + + # Send and Cancel buttons - always enabled when connected + self.send_btn.set_sensitive(True) + self.cancel_btn.set_sensitive(True) + + # Hide home buttons if single axis homing is not supported + home_visible = single_axis_homing + self.home_x_btn.set_visible(home_visible) + self.home_y_btn.set_visible(home_visible) + self.home_z_btn.set_visible(home_visible) + + self._update_limit_status() + + def _update_limit_status(self): + """Update button styling based on whether jog would exceed limits.""" + if not self.machine or not self.machine.is_connected(): + return + + machine = self.machine + + buttons = [ + self.east_btn, + self.west_btn, + self.north_btn, + self.south_btn, + self.z_plus_btn, + self.z_minus_btn, + self.north_east_btn, + self.north_west_btn, + self.south_east_btn, + self.south_west_btn, + ] + for button in buttons: + button.remove_css_class("warning") + button.remove_css_class("destructive-action") + + if not machine.soft_limits_enabled: + return + + def exceeds(*directions: JogDirection) -> bool: + if not self.machine: + return False + return any( + self.machine.would_jog_exceed_limits(axis, delta) + for axis, delta in self._jog_deltas(*directions).items() + ) + + if exceeds(JogDirection.EAST): + self.east_btn.add_css_class("warning") + if exceeds(JogDirection.WEST): + self.west_btn.add_css_class("warning") + if exceeds(JogDirection.NORTH): + self.north_btn.add_css_class("warning") + if exceeds(JogDirection.SOUTH): + self.south_btn.add_css_class("warning") + if exceeds(JogDirection.UP): + self.z_plus_btn.add_css_class("warning") + if exceeds(JogDirection.DOWN): + self.z_minus_btn.add_css_class("warning") + + # Diagonal buttons + if exceeds(JogDirection.EAST, JogDirection.NORTH): + self.north_east_btn.add_css_class("warning") + if exceeds(JogDirection.WEST, JogDirection.NORTH): + self.north_west_btn.add_css_class("warning") + if exceeds(JogDirection.EAST, JogDirection.SOUTH): + self.south_east_btn.add_css_class("warning") + if exceeds(JogDirection.WEST, JogDirection.SOUTH): + self.south_west_btn.add_css_class("warning") + + def _on_machine_state_changed(self, machine, state): + """Handle machine state changes to update limit status.""" + self._update_limit_status() + + def _on_connection_status_changed(self, sender, **kwargs): + """Handle connection status changes to update button sensitivity.""" + self._update_button_sensitivity() + + def _perform_jog(self, deltas: dict[Axis, float]): + """ + Helper to jog multiple axes simultaneously by sending a single + command dictionary. + """ + if not self.machine or not self.machine_cmd: + return + + if deltas: + self.machine_cmd.jog(self.machine, deltas, self.jog_speed) + + def _perform_visual_jog(self, *directions: JogDirection): + """Jog according to one or more visual directions.""" + if not self.machine: + return + self._perform_jog(self._jog_deltas(*directions)) + + def _on_x_plus_clicked(self, button): + """Handle Right (East) button click.""" + self._perform_visual_jog(JogDirection.EAST) + + def _on_x_minus_clicked(self, button): + """Handle Left (West) button click.""" + self._perform_visual_jog(JogDirection.WEST) + + def _on_y_plus_clicked(self, button): + """Handle Away (North) button click.""" + self._perform_visual_jog(JogDirection.NORTH) + + def _on_y_minus_clicked(self, button): + """Handle Toward (South) button click.""" + self._perform_visual_jog(JogDirection.SOUTH) + + def _on_z_plus_clicked(self, button): + """Handle Up button click.""" + self._perform_visual_jog(JogDirection.UP) + + def _on_z_minus_clicked(self, button): + """Handle Down button click.""" + self._perform_visual_jog(JogDirection.DOWN) + + def _on_x_plus_y_plus_clicked(self, button): + """Handle Right-Away diagonal button click.""" + self._perform_visual_jog(JogDirection.EAST, JogDirection.NORTH) + + def _on_x_minus_y_plus_clicked(self, button): + """Handle Left-Away diagonal button click.""" + self._perform_visual_jog(JogDirection.WEST, JogDirection.NORTH) + + def _on_x_plus_y_minus_clicked(self, button): + """Handle Right-Toward diagonal button click.""" + self._perform_visual_jog(JogDirection.EAST, JogDirection.SOUTH) + + def _on_x_minus_y_minus_clicked(self, button): + """Handle Left-Toward diagonal button click.""" + self._perform_visual_jog(JogDirection.WEST, JogDirection.SOUTH) + + def _on_home_all_clicked(self, button): + """Handle Home All button click.""" + if self.machine and self.machine_cmd: + self.machine_cmd.home(self.machine) + + def _on_home_x_clicked(self, button): + """Handle Home X button click.""" + if self.machine and self.machine_cmd: + self.machine_cmd.home(self.machine, Axis.X) + + def _on_home_y_clicked(self, button): + """Handle Home Y button click.""" + if self.machine and self.machine_cmd: + self.machine_cmd.home(self.machine, Axis.Y) + + def _on_home_z_clicked(self, button): + """Handle Home Z button click.""" + if self.machine and self.machine_cmd: + self.machine_cmd.home(self.machine, Axis.Z) + + def _on_send_clicked(self, button): + """Handle Send button click.""" + if self.machine and self.machine_cmd: + self.machine_cmd.run_send_job(self.machine) + + def _on_cancel_clicked(self, button): + """Handle Cancel button click.""" + if self.machine and self.machine_cmd: + self.machine_cmd.cancel_job(self.machine) + + def _on_key_pressed(self, controller, keyval, keycode, state): + """Handle key press events for cursor key jogging.""" + if not self.machine or not self.machine.is_connected(): + return False + + # Map cursor keys to jog actions + if keyval == Gdk.KEY_Up: + self._on_y_plus_clicked(None) # Away + return True + elif keyval == Gdk.KEY_Down: + self._on_y_minus_clicked(None) # Toward + return True + elif keyval == Gdk.KEY_Left: + self._on_x_minus_clicked(None) # Left + return True + elif keyval == Gdk.KEY_Right: + self._on_x_plus_clicked(None) # Right + return True + elif keyval == Gdk.KEY_Page_Up: + self._on_z_plus_clicked(None) # Up + return True + elif keyval == Gdk.KEY_Page_Down: + self._on_z_minus_clicked(None) # Down + return True + + return False diff --git a/rayforge/ui_gtk/machine/laser_control_widget.py b/rayforge/ui_gtk/machine/laser_control_widget.py new file mode 100644 index 000000000..1368ff925 --- /dev/null +++ b/rayforge/ui_gtk/machine/laser_control_widget.py @@ -0,0 +1,311 @@ +from gettext import gettext as _ + +from gi.repository import Adw, GLib, Gtk + +from ...machine.cmd import MachineCmd +from ...machine.models.laser import Laser, LaserHead +from ...machine.models.machine import Machine +from ..icons import get_icon +from ..shared.gtk import apply_css +from ..shared.pref_rows.base import SpinRow +from ..shared.slider import create_slider + +_POWER_CSS = """ +entry.power-value { + min-width: 4em; +} +""" +apply_css(_POWER_CSS) + + +class LaserControlWidget(Gtk.Box): + """Widget for manual laser on/off control with power and duration.""" + + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + + self.machine: Machine | None = None + self.machine_cmd: MachineCmd | None = None + self._is_on = False + self._timer_source_id: int | None = None + self._remaining_ms: int = 0 + + self._group = Adw.PreferencesGroup() + self._group.add_css_class("compact") + + self._head_row = Adw.ComboRow(title=_("Laser Head")) + self._head_row.connect( + "notify::selected", self._on_head_selection_changed + ) + self._toggle_btn = Gtk.ToggleButton() + self._toggle_btn.set_child(get_icon("laser-off-symbolic")) + self._toggle_btn.add_css_class("flat") + self._toggle_btn.set_valign(Gtk.Align.CENTER) + self._toggle_btn.set_tooltip_text(_("Toggle laser on/off")) + self._toggle_btn.connect("clicked", self._on_toggle_clicked) + self._head_row.add_suffix(self._toggle_btn) + self._group.add(self._head_row) + + self._power_adj = Gtk.Adjustment( + value=1.0, + lower=0, + upper=100, + step_increment=0.5, + page_increment=10, + ) + self._power_scale = create_slider( + adjustment=self._power_adj, + digits=1, + draw_value=False, + ) + self._power_entry = Gtk.Entry() + self._power_entry.set_width_chars(5) + self._power_entry.set_max_width_chars(5) + self._power_entry.set_hexpand(False) + self._power_entry.set_halign(Gtk.Align.END) + self._power_entry.set_alignment(1.0) + self._power_entry.set_has_frame(False) + self._power_entry.add_css_class("power-value") + + def update_power_entry(s): + self._power_entry.set_text(f"{s.get_value():.1f} %") + + self._power_scale.connect("value-changed", update_power_entry) + update_power_entry(self._power_scale) + + def commit_power_entry(e): + text = e.get_text().rstrip(" %") + try: + self._power_adj.set_value(float(text)) + except ValueError: + update_power_entry(self._power_scale) + + self._power_entry.connect("activate", commit_power_entry) + focus_ctrl = Gtk.EventControllerFocus() + focus_ctrl.connect( + "leave", lambda c: commit_power_entry(self._power_entry) + ) + self._power_entry.add_controller(focus_ctrl) + + self._power_row = Adw.ActionRow(title=_("Power")) + self._power_row.set_subtitle(_("Laser power in percent")) + suffix_box = Gtk.Box(spacing=6) + suffix_box.set_hexpand(False) + suffix_box.append(self._power_entry) + suffix_box.append(self._power_scale) + self._power_row.add_suffix(suffix_box) + self._group.add(self._power_row) + + self._frequency_row = SpinRow( + _("Frequency"), + _("PWM frequency in Hz"), + lower=1, + upper=100000, + step_increment=100, + ) + self._group.add(self._frequency_row) + + self._pulse_width_row = SpinRow( + _("Pulse Width"), + _("Pulse width in µs"), + lower=1, + upper=100000, + ) + self._group.add(self._pulse_width_row) + + self._duration_row = SpinRow( + _("Duration"), + _("Seconds (0 = continuous)"), + upper=3600, + step_increment=0.5, + digits=1, + ) + self._group.add(self._duration_row) + + self.append(self._group) + + self._countdown_label = Gtk.Label() + self._countdown_label.add_css_class("dim-label") + self._countdown_label.set_visible(False) + self.append(self._countdown_label) + + self.connect("destroy", self._on_destroy) + self._update_sensitivity() + self._update_pwm_visibility() + + def set_machine( + self, machine: Machine | None, machine_cmd: MachineCmd | None + ): + if self.machine: + self.machine.connection_status_changed.disconnect( + self._on_connection_status_changed + ) + self.machine.changed.disconnect(self._on_machine_changed) + self.machine.controller.laser_power_changed.disconnect( + self._on_laser_power_changed + ) + self.machine = machine + self.machine_cmd = machine_cmd + if self.machine: + self.machine.connection_status_changed.connect( + self._on_connection_status_changed + ) + self.machine.changed.connect(self._on_machine_changed) + self.machine.controller.laser_power_changed.connect( + self._on_laser_power_changed + ) + self._rebuild_head_model() + self._update_sensitivity() + + def _rebuild_head_model(self): + if not self.machine: + return + laser_heads = [ + h for h in self.machine.heads if isinstance(h, LaserHead) + ] + model = Gtk.StringList.new([h.name for h in laser_heads]) + self._head_row.set_model(model) + if laser_heads: + self._head_row.set_selected(0) + self._sync_head_fields(laser_heads[0]) + + def _get_selected_head(self) -> Laser | None: + if not self.machine: + return None + laser_heads = [ + h for h in self.machine.heads if isinstance(h, LaserHead) + ] + idx = self._head_row.get_selected() + if 0 <= idx < len(laser_heads): + return laser_heads[idx] + return None + + def _sync_head_fields(self, head: Laser): + self._head_row.set_subtitle( + _("Tool {tool_number}, max power {max_power}").format( + tool_number=head.tool_number, max_power=head.max_power + ) + ) + self._power_adj.set_value(head.focus_power_percent * 100) + self._frequency_row.set_range(1, head.max_pwm_frequency) + self._frequency_row.set_value(head.pwm_frequency) + self._pulse_width_row.set_range( + head.min_pulse_width, head.max_pulse_width + ) + self._pulse_width_row.set_value(head.pulse_width) + self._update_pwm_visibility() + + def _update_pwm_visibility(self): + head = self._get_selected_head() + show = head is not None and head.laser_type.supports_pwm + self._frequency_row.set_visible(show) + self._pulse_width_row.set_visible(show) + + def _on_head_selection_changed(self, row, _pspec): + head = self._get_selected_head() + if head: + self._sync_head_fields(head) + + def _on_machine_changed(self, sender): + self._rebuild_head_model() + + def _on_laser_power_changed(self, sender, *, head, percent): + is_on = percent > 0 + if is_on != self._is_on: + self._cancel_timer() + self._is_on = is_on + self._update_toggle_ui() + + def _on_connection_status_changed(self, sender, **kwargs): + if self.machine and not self.machine.is_connected() and self._is_on: + self._cancel_timer() + self._is_on = False + self._update_toggle_ui() + self._update_sensitivity() + + def _update_sensitivity(self): + has_heads = self.machine is not None and len(self.machine.heads) > 0 + connected = self.machine is not None and self.machine.is_connected() + self._head_row.set_sensitive(has_heads) + self._power_row.set_sensitive(has_heads) + self._frequency_row.set_sensitive(has_heads) + self._pulse_width_row.set_sensitive(has_heads) + self._duration_row.set_sensitive(has_heads) + self._toggle_btn.set_sensitive(connected and has_heads) + + def _on_toggle_clicked(self, button): + if self._is_on: + self._turn_off() + else: + self._turn_on() + + def _turn_on(self): + head = self._get_selected_head() + if not head or not self.machine or not self.machine_cmd: + return + if not self.machine.is_connected(): + return + + percent = self._power_adj.get_value() / 100.0 + self.machine_cmd.set_focus_power(head, percent, self.machine) + + self._is_on = True + self._update_toggle_ui() + self._update_sensitivity() + + duration_s = self._duration_row.get_value() + if duration_s > 0: + self._remaining_ms = int(duration_s * 1000) + self._update_countdown_label() + self._countdown_label.set_visible(True) + self._timer_source_id = GLib.timeout_add(100, self._on_timer_tick) + + def _turn_off(self): + self._cancel_timer() + head = self._get_selected_head() + if ( + head + and self.machine + and self.machine.is_connected() + and self.machine_cmd + ): + self.machine_cmd.set_focus_power(head, 0, self.machine) + self._is_on = False + self._update_toggle_ui() + self._update_sensitivity() + + def _cancel_timer(self): + if self._timer_source_id is not None: + GLib.source_remove(self._timer_source_id) + self._timer_source_id = None + self._countdown_label.set_visible(False) + self._remaining_ms = 0 + + def _on_timer_tick(self) -> bool: + self._remaining_ms -= 100 + if self._remaining_ms <= 0: + self._turn_off() + return GLib.SOURCE_REMOVE + self._update_countdown_label() + return GLib.SOURCE_CONTINUE + + def _update_countdown_label(self): + secs = max(0, self._remaining_ms / 1000.0) + self._countdown_label.set_label( + _("{seconds:.1f} s remaining").format(seconds=secs) + ) + + def _update_toggle_ui(self): + if self._is_on: + self._toggle_btn.set_active(True) + self._toggle_btn.set_child(get_icon("laser-off-symbolic")) + self._toggle_btn.add_css_class("destructive-action") + else: + self._toggle_btn.set_active(False) + self._toggle_btn.set_child(get_icon("laser-on-symbolic")) + self._toggle_btn.remove_css_class("destructive-action") + + def _on_destroy(self, widget): + if self._timer_source_id is not None: + GLib.source_remove(self._timer_source_id) + self._timer_source_id = None diff --git a/rayforge/ui_gtk/machine/lbdev_import_dialog.py b/rayforge/ui_gtk/machine/lbdev_import_dialog.py new file mode 100644 index 000000000..92e5fe7e6 --- /dev/null +++ b/rayforge/ui_gtk/machine/lbdev_import_dialog.py @@ -0,0 +1,83 @@ +from collections.abc import Callable +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...machine.device.lightburn_importer import ImportSummary + + +class LBDevImportDialog(Adw.MessageDialog): + """Modal dialog warning about incomplete LightBurn imports. + + Displays a warning that the imported profile may be incomplete, + followed by a summary table of the values that were mapped. + The user may proceed with the import or cancel. + """ + + def __init__( + self, + parent: Gtk.Window, + summary: ImportSummary, + on_import: Callable[[], None] | None = None, + **kwargs, + ): + super().__init__( + transient_for=parent, + modal=True, + heading=_("Import LightBurn profile?"), + body=_( + "LightBurn device profiles contain only basic machine " + "settings. The imported profile may be incomplete. " + "After import, please review and configure any " + "additional settings such as laser heads, homing, " + "end stops, G-code dialect, macros, and rotary " + "modules." + ), + **kwargs, + ) + self._on_import = on_import + + self.set_size_request(500, -1) + self._build_extra_child(summary) + + self.add_response("cancel", _("Cancel")) + self.set_default_response("cancel") + self.set_close_response("cancel") + self.add_response("import", _("Import Anyway")) + self.set_response_appearance( + "import", Adw.ResponseAppearance.SUGGESTED + ) + self.connect("response", self._on_response) + + def _build_extra_child(self, summary: ImportSummary): + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + outer.set_margin_top(12) + + heading = Gtk.Label( + label=_("The following values will be imported:"), + xalign=0.0, + ) + heading.add_css_class("caption-heading") + outer.append(heading) + + list_box = Gtk.ListBox(css_classes=["boxed-list"]) + list_box.set_selection_mode(Gtk.SelectionMode.NONE) + + for field, value in summary.to_items(): + row = Adw.ActionRow(title=field, subtitle=value) + list_box.append(row) + + outer.append(list_box) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_propagate_natural_height(True) + scrolled.set_max_content_height(300) + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_child(outer) + + self.set_extra_child(scrolled) + + def _on_response(self, dialog, response_id): + self.destroy() + if response_id == "import" and self._on_import: + self._on_import() diff --git a/rayforge/ui_gtk/machine/machine_dropdown.py b/rayforge/ui_gtk/machine/machine_dropdown.py new file mode 100644 index 000000000..fa8026fc9 --- /dev/null +++ b/rayforge/ui_gtk/machine/machine_dropdown.py @@ -0,0 +1,272 @@ +import logging +from gettext import gettext as _ +from typing import cast + +from blinker import Signal +from gi.repository import Gio, GObject, Gtk, Pango + +from ...context import get_context +from ...machine.driver.driver import ( + DEVICE_STATUS_LABELS, + DeviceStatus, +) +from ...machine.driver.dummy import NoDeviceDriver +from ...machine.models.machine import Machine +from ...machine.transport.transport import TransportStatus +from ...shared.util.time_format import format_seconds +from ..icons import get_icon + +logger = logging.getLogger(__name__) + + +class MachineListItem(GObject.Object): + __gtype_name__ = "MachineListItem" + + def __init__(self, machine: Machine): + super().__init__() + self.machine = machine + + +def _get_connection_icon_name(status: TransportStatus) -> str: + if status == TransportStatus.UNKNOWN: + return "question-box-symbolic" + elif status == TransportStatus.IDLE: + return "status-idle-symbolic" + elif status == TransportStatus.CONNECTING: + return "status-connecting-symbolic" + elif status == TransportStatus.CONNECTED: + return "status-connected-symbolic" + elif status == TransportStatus.ERROR: + return "error-symbolic" + elif ( + status == TransportStatus.CLOSING + or status == TransportStatus.DISCONNECTED + ): + return "status-offline-symbolic" + elif status == TransportStatus.SLEEPING: + return "sleep-symbolic" + else: + return "status-offline-symbolic" + + +def _get_status_text( + machine: Machine, eta_seconds: float | None = None +) -> str: + is_nodriver = isinstance(machine.driver, NoDeviceDriver) + if is_nodriver: + return _("No driver") + status = machine.device_state.status + text = DEVICE_STATUS_LABELS.get(status, _("Unknown")) + if ( + status == DeviceStatus.RUN + and eta_seconds is not None + and eta_seconds > 0 + ): + text = f"{text} · {format_seconds(eta_seconds)}" + return text + + +def _get_connection_status(machine: Machine) -> TransportStatus: + if isinstance(machine.driver, NoDeviceDriver): + return TransportStatus.DISCONNECTED + return machine.connection_status + + +class MachineDropdown(Gtk.DropDown): + """ + A dropdown for selecting the active machine, showing connection state + and machine status in each entry. + """ + + __gtype_name__ = "MachineDropdown" + + def __init__(self, **kwargs): + self.machine_selected = Signal() + self._model = Gio.ListStore.new(MachineListItem) + self._eta_seconds: float | None = None + self._status_label_refs: dict = {} + + expression = Gtk.ClosureExpression.new( + str, + lambda item: item.machine.name if item else _("Select Machine"), + None, + ) + + super().__init__(model=self._model, expression=expression, **kwargs) + + factory = Gtk.SignalListItemFactory() + factory.connect("setup", self._on_factory_setup) + factory.connect("bind", self._on_factory_bind) + factory.connect("unbind", self._on_factory_unbind) + self.set_factory(factory) + + self.add_css_class("machine-dropdown") + self.set_tooltip_text(_("Select active machine")) + self._selection_changed_handler_id = self.connect( + "notify::selected-item", self._on_user_selection_changed + ) + + self._signal_refs = [] + + context = get_context() + context.machine_mgr.machine_added.connect( + self.update_model_and_selection + ) + context.machine_mgr.machine_removed.connect( + self.update_model_and_selection + ) + context.machine_mgr.machine_updated.connect( + self.update_model_and_selection + ) + context.config.changed.connect(self.update_model_and_selection) + + self.update_model_and_selection() + + def _on_factory_setup(self, factory, list_item): + box = Gtk.Box(spacing=8) + + icon_box = Gtk.Box(valign=Gtk.Align.CENTER) + box.append(icon_box) + + text_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.CENTER + ) + name_label = Gtk.Label( + xalign=0, + ellipsize=Pango.EllipsizeMode.END, + ) + status_label = Gtk.Label( + xalign=0, + ellipsize=Pango.EllipsizeMode.END, + ) + status_label.add_css_class("caption") + status_label.add_css_class("dim-label") + + text_box.append(name_label) + text_box.append(status_label) + box.append(text_box) + + list_item.set_child(box) + list_item._signal_refs = [] + + def _on_factory_bind(self, factory, list_item): + box = list_item.get_child() + icon_box = box.get_first_child() + text_box = icon_box.get_next_sibling() + name_label = text_box.get_first_child() + status_label = name_label.get_next_sibling() + + list_item_obj: MachineListItem | None = list_item.get_item() + if not list_item_obj: + return + + machine = list_item_obj.machine + name_label.set_text(machine.name) + status_label.set_text(_get_status_text(machine)) + + conn_status = _get_connection_status(machine) + icon_name = _get_connection_icon_name(conn_status) + new_img = get_icon(icon_name) + child = icon_box.get_first_child() + if child: + icon_box.remove(child) + icon_box.append(new_img) + + for ref in list_item._signal_refs: + try: + ref[0].disconnect(ref[1]) + except TypeError: + pass + + refs = [] + + def on_state_changed(m, state, lbl=status_label): + lbl.set_text(_get_status_text(m, self._get_eta_for_machine(m))) + + def on_conn_changed(m, status, message=None, ibox=icon_box): + conn = _get_connection_status(m) + img = get_icon(_get_connection_icon_name(conn)) + old = ibox.get_first_child() + if old: + ibox.remove(old) + ibox.append(img) + + machine.state_changed.connect(on_state_changed) + refs.append((machine, on_state_changed)) + + machine.connection_status_changed.connect(on_conn_changed) + refs.append((machine, on_conn_changed)) + + self._status_label_refs[id(machine)] = status_label + + list_item._signal_refs = refs + + def _on_factory_unbind(self, factory, list_item): + list_item_obj: MachineListItem | None = list_item.get_item() + if list_item_obj: + self._status_label_refs.pop(id(list_item_obj.machine), None) + for ref in list_item._signal_refs: + try: + ref[0].disconnect(ref[1]) + except TypeError: + pass + list_item._signal_refs = [] + + def _get_eta_for_machine(self, machine: Machine) -> float | None: + context = get_context() + if context.config.machine and context.config.machine.id == machine.id: + return self._eta_seconds + return None + + def update_eta(self, eta_seconds: float | None): + """Update the ETA for the active machine's status label.""" + self._eta_seconds = eta_seconds + context = get_context() + machine = context.config.machine + if not machine: + return + label = self._status_label_refs.get(id(machine)) + if label: + label.set_text(_get_status_text(machine, eta_seconds)) + + def update_model_and_selection(self, *args, **kwargs): + logger.debug("Syncing machine dropdown model and selection.") + context = get_context() + machines = sorted( + context.machine_mgr.machines.values(), key=lambda m: m.name + ) + + self.handler_block(self._selection_changed_handler_id) + + try: + self._model.remove_all() + selected_index = -1 + for i, machine in enumerate(machines): + self._model.append(MachineListItem(machine)) + if context.machine and machine.id == context.machine.id: + selected_index = i + + if selected_index >= 0 and self.get_selected() != selected_index: + self.set_selected(selected_index) + elif ( + selected_index < 0 + and self.get_selected() >= 0 + and len(self._model) > 0 + ): + self.set_selected(0) + finally: + self.handler_unblock(self._selection_changed_handler_id) + + def _on_user_selection_changed(self, dropdown, param): + selected_list_item = cast( + MachineListItem | None, self.get_selected_item() + ) + + if selected_list_item: + logger.info( + f"User selected '{selected_list_item.machine.name}'. " + "Emitting 'machine_selected' signal." + ) + self.machine_selected.send( + self, machine=selected_list_item.machine + ) diff --git a/rayforge/ui_gtk/machine/macro_list.py b/rayforge/ui_gtk/machine/macro_list.py new file mode 100644 index 000000000..8357b5d5a --- /dev/null +++ b/rayforge/ui_gtk/machine/macro_list.py @@ -0,0 +1,147 @@ +from gettext import gettext as _ +from typing import cast + +from gi.repository import Gtk + +from ...machine.models.machine import Machine +from ...machine.models.macro import Macro +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from .gcode_editor import GcodeEditorDialog + + +class MacroRow(Gtk.Box): + """A widget representing a single Macro in a ListBox.""" + + def __init__(self, machine: Machine, macro: Macro): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.machine = machine + self.macro = macro + self._setup_ui() + + def _setup_ui(self): + """Builds the user interface for the row.""" + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + title_label = Gtk.Label( + label=self.macro.name, + halign=Gtk.Align.START, + hexpand=True, + xalign=0, + ) + self.append(title_label) + + # Suffix area for switch and buttons + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + switch = Gtk.Switch(valign=Gtk.Align.CENTER) + switch.set_active(self.macro.enabled) + switch.connect("notify::active", self._on_enable_toggled) + suffix_box.append(switch) + + edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", self._on_edit_clicked) + suffix_box.append(edit_button) + + delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + delete_button.add_css_class("flat") + delete_button.connect("clicked", self._on_remove_clicked) + suffix_box.append(delete_button) + + def _on_enable_toggled(self, switch: Gtk.Switch, _): + """Handles the state change of the enable/disable switch.""" + is_active = switch.get_active() + if self.macro.enabled != is_active: + self.macro.enabled = is_active + self.machine.changed.send(self.machine) + + def _on_remove_clicked(self, button: Gtk.Button): + """Asks the machine to remove the associated macro.""" + self.machine.remove_macro(self.macro.uid) + + def _on_edit_clicked(self, button: Gtk.Button): + """Opens the dialog to edit the macro.""" + parent_window = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + + # Pass the list of other macros for uniqueness validation + existing_macros = list(self.machine.macros.values()) + + dialog = GcodeEditorDialog( + parent_window, + self.macro, + allow_name_edit=True, + existing_macros=existing_macros, + ) + dialog.connect("close-request", self._on_edit_dialog_closed) + dialog.present() + + def _on_edit_dialog_closed(self, dialog: GcodeEditorDialog): + """Signals a machine change if the macro was saved.""" + if dialog.saved: + self.machine.changed.send(self.machine) + + +class MacroListEditor(PreferencesGroupWithButton): + """ + An Adwaita widget for displaying and managing a list of G-code macros. + """ + + def __init__(self, machine: Machine, **kwargs): + super().__init__(button_label=_("Add New Macro"), **kwargs) + self.machine = machine + self._setup_ui() + self.machine.changed.connect(self._on_machine_changed) + self._on_machine_changed(self.machine) # Initial population + + def _setup_ui(self): + """Configures the widget and its placeholder.""" + placeholder = Gtk.Label( + label=_("No macros configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + + def _on_machine_changed(self, sender: Machine, **kwargs): + """Callback to rebuild the list when the machine model changes.""" + sorted_macros = sorted( + self.machine.macros.values(), key=lambda m: m.name + ) + self.set_items(sorted_macros) + + def create_row_widget(self, item: Macro) -> Gtk.Widget: + """Creates a MacroRow for the given macro item.""" + return MacroRow(self.machine, item) + + def _on_add_clicked(self, button: Gtk.Button): + """Handles the 'Add New Macro' button click.""" + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + new_macro = Macro(name=_("New Macro")) + + # Pass the list of existing macros for uniqueness validation + existing_macros = list(self.machine.macros.values()) + + editor_dialog = GcodeEditorDialog( + parent, + new_macro, + allow_name_edit=True, + existing_macros=existing_macros, + ) + editor_dialog.connect( + "close-request", self._on_new_macro_editor_closed, new_macro + ) + editor_dialog.present() + + def _on_new_macro_editor_closed( + self, dialog: GcodeEditorDialog, new_macro: Macro + ): + """Asks the machine to add the new macro if it was saved.""" + if dialog.saved: + self.machine.add_macro(new_macro) diff --git a/rayforge/ui_gtk/machine/maintenance_page.py b/rayforge/ui_gtk/machine/maintenance_page.py new file mode 100644 index 000000000..658839aa4 --- /dev/null +++ b/rayforge/ui_gtk/machine/maintenance_page.py @@ -0,0 +1,412 @@ +import logging +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from ...machine.models.machine import Machine +from ...machine.models.machine_hours import ResettableCounter +from ...shared.util.time_format import format_hours_to_hm +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..shared.pref_rows.base import SpinRow +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class CounterRow(Gtk.Box): + """A widget representing a single counter in a ListBox.""" + + def __init__(self, machine: Machine, counter: ResettableCounter): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.machine = machine + self.counter = counter + self._setup_ui() + + def _setup_ui(self): + """Builds the user interface for the row.""" + # Match margins exactly to MacroRow + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + # Icon + icon = get_icon("hourglass-symbolic") + self.append(icon) + + # Title and Time Container + info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + info_box.set_hexpand(True) # This pushes the buttons to the right + info_box.set_valign(Gtk.Align.CENTER) + self.append(info_box) + + # Title + title_label = Gtk.Label( + label=self.counter.name, + halign=Gtk.Align.START, + xalign=0, + ) + info_box.append(title_label) + + # Time Subtitle + subtitle_text = format_hours_to_hm(self.counter.value) + + # If there is a notification threshold, show it as a limit + # (e.g., "10h / 100h") + if self.counter.notify_at is not None: + subtitle_text += f" / {format_hours_to_hm(self.counter.notify_at)}" + + value_label = Gtk.Label( + label=subtitle_text, + halign=Gtk.Align.START, + xalign=0, + ) + value_label.add_css_class("dim-label") + info_box.append(value_label) + + # Suffix area for buttons + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + # Reset button + reset_button = Gtk.Button(child=get_icon("refresh-symbolic")) + reset_button.set_tooltip_text(_("Reset Counter")) + reset_button.add_css_class("flat") + reset_button.connect("clicked", self._on_reset_clicked) + suffix_box.append(reset_button) + + # Edit button + edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + edit_button.set_tooltip_text(_("Edit Counter")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", self._on_edit_clicked) + suffix_box.append(edit_button) + + # Remove button + remove_button = Gtk.Button(child=get_icon("delete-symbolic")) + remove_button.set_tooltip_text(_("Remove Counter")) + remove_button.add_css_class("flat") + remove_button.connect("clicked", self._on_remove_clicked) + suffix_box.append(remove_button) + + def _on_reset_clicked(self, button: Gtk.Button): + """Ask for confirmation, then reset.""" + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, self.get_root()), + heading=_("Reset Counter?"), + body=_("This will reset the accumulated hours to zero."), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("reset", _("Reset")) + dialog.set_response_appearance( + "reset", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.connect("response", self._on_reset_response) + dialog.present() + + def _on_reset_response(self, dialog: Adw.MessageDialog, response: str): + """Handle reset confirmation response.""" + if response == "reset": + self.machine.machine_hours.reset_counter(self.counter.uid) + logger.info(f"Reset counter: {self.counter.uid}") + + def _on_edit_clicked(self, button: Gtk.Button): + """Handle edit button click.""" + parent_window = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + dialog = CounterEditDialog(parent_window, self.machine, self.counter) + dialog.connect("close-request", self._on_edit_dialog_closed) + dialog.present() + + def _on_edit_dialog_closed(self, dialog): + """Handle edit dialog closure.""" + if dialog.saved: + self.machine.machine_hours.update_counter(self.counter) + logger.info(f"Edited counter: {self.counter.uid}") + + def _on_remove_clicked(self, button: Gtk.Button): + """Ask for confirmation, then remove.""" + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, self.get_root()), + heading=_("Remove Counter?"), + body=_( + "Are you sure you want to remove this counter? This action " + "cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("remove", _("Remove")) + dialog.set_response_appearance( + "remove", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.connect("response", self._on_remove_response) + dialog.present() + + def _on_remove_response(self, dialog: Adw.MessageDialog, response: str): + """Handle remove confirmation response.""" + if response == "remove": + self.machine.machine_hours.remove_counter(self.counter.uid) + logger.info(f"Removed counter: {self.counter.uid}") + + +class CounterListEditor(PreferencesGroupWithButton): + """ + An Adwaita widget for displaying and managing a list of + maintenance counters. + """ + + def __init__(self, machine: Machine, **kwargs): + super().__init__(button_label=_("Add Counter"), **kwargs) + self.machine = machine + self._setup_ui() + + # Only listen to machine_hours.changed. + self.machine.machine_hours.changed.connect( + self._on_machine_hours_changed + ) + + # Initial population + self._on_machine_hours_changed(self.machine.machine_hours) + + def _setup_ui(self): + """Configures the widget and its placeholder.""" + placeholder = Gtk.Label( + label=_("No counters configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + + def _on_machine_hours_changed(self, sender, **kwargs): + """Callback to rebuild list when machine hours change.""" + self._update_ui() + + def _update_ui(self): + """Update the list of counters.""" + sorted_counters = sorted( + self.machine.machine_hours.counters.values(), + key=lambda c: c.name, + ) + self.set_items(sorted_counters) + + def create_row_widget(self, item: ResettableCounter) -> Gtk.Widget: + """Creates a CounterRow for the given counter item.""" + return CounterRow(self.machine, item) + + def _on_add_clicked(self, button: Gtk.Button): + """Handle 'Add Counter' button click.""" + parent = cast(Gtk.Window, self.get_ancestor(Gtk.Window)) + new_counter = ResettableCounter(name=_("New Counter")) + dialog = CounterEditDialog(parent, self.machine, new_counter) + dialog.connect( + "close-request", self._on_new_counter_dialog_closed, new_counter + ) + dialog.present() + + def _on_new_counter_dialog_closed( + self, dialog, new_counter: ResettableCounter + ): + """Handle new counter dialog closure.""" + if dialog.saved: + self.machine.machine_hours.add_counter(new_counter) + logger.info(f"Added new counter: {new_counter.name}") + + +class CounterEditDialog(PatchedDialogWindow): + """Dialog for editing counter settings.""" + + def __init__( + self, + transient_for: Gtk.Window, + machine: Machine, + counter: ResettableCounter, + ): + super().__init__( + title=_("Edit Counter"), + transient_for=transient_for, + modal=True, + default_width=450, + default_height=400, + ) + self.machine = machine + self.counter = counter + self.saved = False + self._setup_ui() + + def _setup_ui(self): + """Builds the dialog UI using Adw.ToolbarView.""" + toolbar_view = Adw.ToolbarView() + self.set_content(toolbar_view) + + # Header Bar + header_bar = Adw.HeaderBar() + toolbar_view.add_top_bar(header_bar) + + close_button = Gtk.Button(label=_("Cancel")) + close_button.connect("clicked", self._on_close_clicked) + header_bar.pack_start(close_button) + + save_button = Gtk.Button(label=_("Save")) + save_button.add_css_class("suggested-action") + save_button.connect("clicked", self._on_save_clicked) + header_bar.pack_end(save_button) + + # Content - Use PreferencesPage for proper background styling + page = Adw.PreferencesPage() + toolbar_view.set_content(page) + + # Main Group + group = Adw.PreferencesGroup() + page.add(group) + + # Name entry + name_row = Adw.EntryRow(title=_("Name")) + name_row.set_text(self.counter.name) + group.add(name_row) + self._name_row = name_row + + # Notification interval + notify_row = SpinRow( + _("Notification Interval"), + _( + "Show notification when counter reaches this value (hours). " + "Set to 0 to disable." + ), + upper=100000, + step_increment=0.1, + digits=1, + ) + if self.counter.notify_at is not None: + notify_row.set_value(self.counter.notify_at) + group.add(notify_row) + self._notify_row = notify_row + + def _on_save_clicked(self, button: Gtk.Button): + """Handle save button click by applying UI values to the model.""" + self.saved = True + + # Apply changes to the counter object + self.counter.name = self._name_row.get_text() + + notify_val = self._notify_row.get_value() + self.counter.notify_at = notify_val if notify_val > 0 else None + + self.close() + + def _on_close_clicked(self, button: Gtk.Button): + """Handle close button click.""" + self.close() + + +class MaintenancePage(TrackedPreferencesPage): + """ + A preferences page for viewing and managing machine hours. + """ + + key = "maintenance" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("Maintenance"), + **kwargs, + ) + self.machine = machine + + # Group for Total Hours + total_group = Adw.PreferencesGroup(title=_("Total Hours")) + total_group.set_description( + _("Cumulative operating time tracked by the machine.") + ) + self.add(total_group) + + self.total_hours_row = Adw.ActionRow( + title=_("Total Operating Hours"), + subtitle=_("Cumulative machine operating time"), + activatable=False, + ) + self.total_hours_row.add_prefix(get_icon("hourglass-symbolic")) + + # Add Reset Button for Total Hours + reset_total_btn = Gtk.Button(child=get_icon("refresh-symbolic")) + reset_total_btn.set_tooltip_text(_("Reset Total Hours")) + reset_total_btn.add_css_class("flat") + reset_total_btn.set_valign(Gtk.Align.CENTER) + reset_total_btn.connect("clicked", self._on_reset_total_clicked) + + self.total_hours_row.add_suffix(reset_total_btn) + total_group.add(self.total_hours_row) + + # Group for Resettable Counters + self.counters_group = CounterListEditor( + machine=machine, title=_("Maintenance Counters") + ) + self.counters_group.set_description( + _( + "Track maintenance intervals with resettable " + "counters. Use for laser tubes, lubrication, etc." + ) + ) + self.add(self.counters_group) + + # Connect signals + self.machine.changed.connect(self._on_machine_changed) + self.machine.machine_hours.changed.connect( + self._on_machine_hours_changed + ) + self.connect("destroy", self._on_destroy) + + # Initial UI update + self._update_ui() + + def _on_destroy(self, *args): + """Disconnect signals to prevent memory leaks.""" + self.machine.changed.disconnect(self._on_machine_changed) + self.machine.machine_hours.changed.disconnect( + self._on_machine_hours_changed + ) + + def _on_machine_changed(self, sender, **kwargs): + """Handle machine configuration changes.""" + self._update_ui() + + def _on_machine_hours_changed(self, sender, **kwargs): + """Handle machine hours changes.""" + self._update_ui() + + def _update_ui(self): + """Update UI with current machine hours data.""" + total_hours = self.machine.machine_hours.total_hours + self.total_hours_row.set_subtitle( + _("{time} total").format(time=format_hours_to_hm(total_hours)) + ) + + def _on_reset_total_clicked(self, button: Gtk.Button): + """Ask for confirmation, then reset total hours.""" + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, self.get_root()), + heading=_("Reset Total Hours?"), + body=_( + "This will reset the total cumulative operating hours to " + "zero. Maintenance counters will not be affected." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("reset", _("Reset")) + dialog.set_response_appearance( + "reset", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.connect("response", self._on_reset_total_response) + dialog.present() + + def _on_reset_total_response( + self, dialog: Adw.MessageDialog, response: str + ): + """Handle reset confirmation response for total hours.""" + if response == "reset": + self.machine.machine_hours.reset_total_hours() + logger.info("Reset total machine hours") diff --git a/rayforge/ui_gtk/machine/nogo_zones_page.py b/rayforge/ui_gtk/machine/nogo_zones_page.py new file mode 100644 index 000000000..8e4cf9ec1 --- /dev/null +++ b/rayforge/ui_gtk/machine/nogo_zones_page.py @@ -0,0 +1,432 @@ +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from ...machine.models.machine import Machine +from ...machine.models.zone import Zone, ZoneShape +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage + + +class ZoneRow(Gtk.Box): + def __init__(self, machine: Machine, zone: Zone): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.machine = machine + self.zone = zone + self._setup_ui() + + def _setup_ui(self): + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + info_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(info_box) + + self.title_label = Gtk.Label( + label=self.zone.name, + halign=Gtk.Align.START, + xalign=0, + ) + info_box.append(self.title_label) + + self.subtitle_label = Gtk.Label( + label=self._get_subtitle_text(), + halign=Gtk.Align.START, + xalign=0, + ) + self.subtitle_label.add_css_class("dim-label") + self.subtitle_label.add_css_class("caption") + info_box.append(self.subtitle_label) + + self.enabled_switch = Gtk.Switch( + active=self.zone.enabled, + valign=Gtk.Align.CENTER, + ) + self.enabled_switch.connect("notify::active", self._on_enabled) + self.append(self.enabled_switch) + + remove_button = Gtk.Button( + icon_name="edit-delete-symbolic", + valign=Gtk.Align.CENTER, + css_classes=["flat", "destructive-action"], + ) + remove_button.connect("clicked", self._on_remove) + self.append(remove_button) + + def _get_subtitle_text(self) -> str: + shape_labels = { + ZoneShape.RECT: _("Rectangle"), + ZoneShape.BOX: _("Box"), + ZoneShape.CYLINDER: _("Cylinder"), + } + return shape_labels.get(self.zone.shape, str(self.zone.shape)) + + def _on_enabled(self, switch, _param): + self.zone.set_enabled(switch.get_active()) + + def _on_remove(self, button): + self.machine.remove_nogo_zone(self.zone) + + +class ZoneListEditor(PreferencesGroupWithButton): + def __init__(self, machine: Machine, **kwargs): + super().__init__(button_label=_("Add Zone"), **kwargs) + self.machine = machine + self._row_widgets: list[ZoneRow] = [] + self._known_uids: set[str] = set() + self._setup_ui() + self.machine.changed.connect(self._on_machine_changed) + self._rebuild() + + def _setup_ui(self): + placeholder = Gtk.Label( + label=_("No no-go zones configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.list_box.set_show_separators(True) + + def _on_machine_changed(self, sender, **kwargs): + current_uids = set(self.machine.nogo_zones.keys()) + if current_uids != self._known_uids: + self._rebuild() + return + + i = 0 + while True: + row = self.list_box.get_row_at_index(i) + if not row: + break + zone_row = cast(ZoneRow, row.get_child()) + zone = zone_row.zone + zone_row.title_label.set_label(zone.name) + zone_row.subtitle_label.set_label(zone_row._get_subtitle_text()) + zone_row.enabled_switch.set_active(zone.enabled) + i += 1 + + def _rebuild(self): + sorted_zones = sorted( + self.machine.nogo_zones.values(), key=lambda z: z.name + ) + self._known_uids = set(self.machine.nogo_zones.keys()) + + selected_zone = None + selected_row = self.list_box.get_selected_row() + if selected_row: + widget = cast(ZoneRow, selected_row.get_child()) + selected_zone = widget.zone + + while True: + row = self.list_box.get_row_at_index(0) + if not row: + break + self.list_box.remove(row) + self._row_widgets.clear() + + new_selection_index = -1 + for i, zone in enumerate(sorted_zones): + if zone is selected_zone: + new_selection_index = i + list_box_row = Gtk.ListBoxRow() + list_box_row.set_child(self.create_row_widget(zone)) + self.list_box.append(list_box_row) + + if new_selection_index >= 0: + row = self.list_box.get_row_at_index(new_selection_index) + self.list_box.select_row(row) + elif sorted_zones: + row = self.list_box.get_row_at_index(0) + self.list_box.select_row(row) + else: + if self.list_box.get_selected_row(): + self.list_box.unselect_all() + else: + self.list_box.emit("row-selected", None) + + def create_row_widget(self, item: Zone) -> Gtk.Widget: + row = ZoneRow(self.machine, item) + self._row_widgets.append(row) + return row + + def _on_add_clicked(self, button: Gtk.Button): + new_zone = Zone() + new_zone.name = _("New Zone") + self.machine.add_nogo_zone(new_zone) + self._rebuild() + + sorted_zones = sorted( + self.machine.nogo_zones.values(), key=lambda z: z.name + ) + idx = next( + i for i, z in enumerate(sorted_zones) if z.uid == new_zone.uid + ) + row = self.list_box.get_row_at_index(idx) + if row: + self.list_box.select_row(row) + + +class NogoZonesPage(TrackedPreferencesPage): + key = "nogo-zones" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("No-Go Zones"), + icon_name="action-unavailable-symbolic", + **kwargs, + ) + self.machine = machine + self._is_updating = True + + zones_group = Adw.PreferencesGroup( + title=_("No-Go Zones"), + description=_( + "Define restricted areas on the work surface. A warning " + "will be shown before running or exporting a job whose " + "toolpath enters any enabled no-go zone." + ), + ) + self.add(zones_group) + + self.zone_list_editor = ZoneListEditor(machine=self.machine) + zones_group.add(self.zone_list_editor) + + self.config_group = Adw.PreferencesGroup( + title=_("Zone Properties"), + description=_("Configure the selected zone."), + ) + self.add(self.config_group) + + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.connect("changed", self._on_name_changed) + self.name_row.connect("activate", self._on_name_applied) + name_focus_ctrl = Gtk.EventControllerFocus() + name_focus_ctrl.connect("leave", self._on_name_focus_left) + self.name_row.add_controller(name_focus_ctrl) + self.config_group.add(self.name_row) + + shape_store = Gtk.StringList() + shape_store.append(_("Rectangle")) + shape_store.append(_("Box")) + shape_store.append(_("Cylinder")) + self.shape_row = Adw.ComboRow( + title=_("Shape"), + subtitle=_("Zone geometry shape"), + model=shape_store, + ) + self.shape_row.connect("notify::selected", self._on_shape_changed) + self.config_group.add(self.shape_row) + + self.x_row = LengthSpinRow( + _("X"), + _("X position in {wcs}").replace( + "{wcs}", self.machine.machine_space_wcs_display_name + ), + lower=-10000, + upper=10000, + ) + self.x_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.x_row) + + self.y_row = LengthSpinRow( + _("Y"), + _("Y position in {wcs}").replace( + "{wcs}", self.machine.machine_space_wcs_display_name + ), + lower=-10000, + upper=10000, + ) + self.y_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.y_row) + + self.z_row = LengthSpinRow( + _("Z"), + _("Z position in {wcs}").replace( + "{wcs}", self.machine.machine_space_wcs_display_name + ), + lower=-10000, + upper=10000, + ) + self.z_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.z_row) + + self.w_row = LengthSpinRow( + _("Width"), + _("Width"), + upper=10000, + ) + self.w_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.w_row) + + self.h_row = LengthSpinRow( + _("Height"), + _("Height"), + upper=10000, + ) + self.h_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.h_row) + + self.d_row = LengthSpinRow( + _("Depth"), + _("Depth (Z extent)"), + upper=10000, + ) + self.d_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.d_row) + + self.radius_row = LengthSpinRow( + _("Radius"), + _("Cylinder radius"), + upper=10000, + ) + self.radius_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.radius_row) + + self.cyl_height_row = LengthSpinRow( + _("Cylinder Height"), + _("Cylinder height"), + upper=10000, + ) + self.cyl_height_row.value_changed.connect(self._on_param_changed) + self.config_group.add(self.cyl_height_row) + + self.zone_list_editor.list_box.connect( + "row-selected", self._on_zone_selected + ) + + self.machine.changed.connect(self._on_machine_changed) + self.connect("destroy", self._on_destroy) + + self._is_updating = False + initial_row = self.zone_list_editor.list_box.get_selected_row() + self._on_zone_selected(self.zone_list_editor.list_box, initial_row) + + def _get_selected_zone(self) -> Zone | None: + selected_row = self.zone_list_editor.list_box.get_selected_row() + if not selected_row: + return None + zone_row = cast(ZoneRow, selected_row.get_child()) + return zone_row.zone + + def _on_zone_selected(self, listbox, row): + has_selection = row is not None + self.config_group.set_visible(has_selection) + if not has_selection: + return + + zone = self._get_selected_zone() + if not zone: + return + + self._is_updating = True + + self.name_row.set_text(zone.name) + shape_map = { + ZoneShape.RECT: 0, + ZoneShape.BOX: 1, + ZoneShape.CYLINDER: 2, + } + self.shape_row.set_selected(shape_map.get(zone.shape, 0)) + self.x_row.set_value_in_base_units(zone.params.get("x", 0.0)) + self.y_row.set_value_in_base_units(zone.params.get("y", 0.0)) + self.z_row.set_value_in_base_units(zone.params.get("z", 0.0)) + self.w_row.set_value_in_base_units(zone.params.get("w", 10.0)) + self.h_row.set_value_in_base_units(zone.params.get("h", 10.0)) + self.d_row.set_value_in_base_units(zone.params.get("d", 10.0)) + self.radius_row.set_value_in_base_units(zone.params.get("radius", 5.0)) + self.cyl_height_row.set_value_in_base_units( + zone.params.get("height", 10.0) + ) + self._update_field_visibility(zone) + + self._is_updating = False + + def _update_field_visibility(self, zone: Zone): + is_3d = zone.shape in (ZoneShape.BOX, ZoneShape.CYLINDER) + self.z_row.set_visible(is_3d) + self.d_row.set_visible(zone.shape == ZoneShape.BOX) + self.radius_row.set_visible(zone.shape == ZoneShape.CYLINDER) + self.cyl_height_row.set_visible(zone.shape == ZoneShape.CYLINDER) + + def _on_name_changed(self, entry_row): + if self._is_updating: + return + zone = self._get_selected_zone() + if zone: + zone.set_name(entry_row.get_text()) + + def _on_name_applied(self, entry_row): + self.zone_list_editor._rebuild() + + def _on_name_focus_left(self, controller): + if not self._is_updating: + self.zone_list_editor._rebuild() + + def _on_shape_changed(self, row, _param): + if self._is_updating: + return + zone = self._get_selected_zone() + if not zone: + return + shape_map = { + 0: ZoneShape.RECT, + 1: ZoneShape.BOX, + 2: ZoneShape.CYLINDER, + } + new_shape = shape_map.get(row.get_selected(), ZoneShape.RECT) + if new_shape == zone.shape: + return + self._is_updating = True + zone.set_shape(new_shape) + self.z_row.set_value_in_base_units(zone.params.get("z", 0.0)) + self.w_row.set_value_in_base_units(zone.params.get("w", 10.0)) + self.h_row.set_value_in_base_units(zone.params.get("h", 10.0)) + self.d_row.set_value_in_base_units(zone.params.get("d", 10.0)) + self.radius_row.set_value_in_base_units(zone.params.get("radius", 5.0)) + self.cyl_height_row.set_value_in_base_units( + zone.params.get("height", 10.0) + ) + self._update_field_visibility(zone) + self._is_updating = False + + def _on_param_changed(self, _helper): + if self._is_updating: + return + zone = self._get_selected_zone() + if not zone: + return + + self._is_updating = True + zone.set_param("x", self.x_row.get_value_in_base_units()) + zone.set_param("y", self.y_row.get_value_in_base_units()) + if zone.shape in (ZoneShape.BOX, ZoneShape.CYLINDER): + zone.set_param("z", self.z_row.get_value_in_base_units()) + zone.set_param("w", self.w_row.get_value_in_base_units()) + zone.set_param("h", self.h_row.get_value_in_base_units()) + if zone.shape == ZoneShape.BOX: + zone.set_param("d", self.d_row.get_value_in_base_units()) + elif zone.shape == ZoneShape.CYLINDER: + zone.set_param("radius", self.radius_row.get_value_in_base_units()) + zone.set_param( + "height", self.cyl_height_row.get_value_in_base_units() + ) + self._is_updating = False + + def _on_machine_changed(self, sender, **kwargs): + pass + + def _on_destroy(self, *args): + self.machine.changed.disconnect(self._on_machine_changed) + self.machine.changed.disconnect( + self.zone_list_editor._on_machine_changed + ) diff --git a/rayforge/ui_gtk/machine/profile_importer.py b/rayforge/ui_gtk/machine/profile_importer.py new file mode 100644 index 000000000..d938385cd --- /dev/null +++ b/rayforge/ui_gtk/machine/profile_importer.py @@ -0,0 +1,137 @@ +import logging +import zipfile +from collections.abc import Callable +from gettext import gettext as _ +from pathlib import Path + +import yaml +from gi.repository import Gio, GLib, Gtk + +from ...context import get_context +from ...machine.device.lightburn_importer import convert_to_profile +from ...machine.device.profile import DeviceProfile +from .lbdev_import_dialog import LBDevImportDialog + +logger = logging.getLogger(__name__) + + +def open_profile_file( + parent: Gtk.Window, + callback: Callable[[DeviceProfile | None, str | None], None], +): + """Open a file dialog to import a device profile. + + Supports Rayforge ``.rfdevice.zip`` and LightBurn ``.lbdev`` + files. + + *callback* is called with ``(profile, None)`` on success or + ``(None, error_message)`` on failure. If the user cancels the + dialog, *callback* is not invoked. + """ + filter_list = Gio.ListStore.new(Gtk.FileFilter) + + rf_filter = Gtk.FileFilter() + rf_filter.set_name(_("Device Profile archives")) + rf_filter.add_pattern("*.rfdevice.zip") + filter_list.append(rf_filter) + + lb_filter = Gtk.FileFilter() + lb_filter.set_name(_("LightBurn device profiles")) + lb_filter.add_pattern("*.lbdev") + filter_list.append(lb_filter) + + all_filter = Gtk.FileFilter() + all_filter.set_name(_("All files")) + all_filter.add_pattern("*") + filter_list.append(all_filter) + + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Import Device Profile")) + dialog.set_filters(filter_list) + dialog.set_default_filter(rf_filter) + dialog.open( + parent, + None, + _on_file_selected, + (parent, callback), + ) + + +def open_profile_zip( + parent: Gtk.Window, + callback: Callable[[DeviceProfile | None, str | None], None], +): + """Legacy entry point -- delegates to :func:`open_profile_file`.""" + open_profile_file(parent, callback) + + +def _on_file_selected(dialog, result, user_data): + parent, callback = user_data + try: + file = dialog.open_finish(result) + except GLib.Error: + return + if not file: + return + + file_path = Path(file.get_path()) + + if file_path.suffix.lower() == ".lbdev": + _handle_lbdev(parent, file_path, callback) + else: + _handle_zip(file_path, callback) + + +def _handle_zip(file_path: Path, callback): + mgr = get_context().device_profile_mgr + try: + profile = mgr.install_from_zip(file_path) + except ( + OSError, + ValueError, + TypeError, + RuntimeError, + zipfile.BadZipFile, + yaml.YAMLError, + ) as e: + logger.error(f"Import failed: {e}") + callback(None, str(e)) + return + callback(profile, None) + + +def _handle_lbdev( + parent: Gtk.Window, + file_path: Path, + callback: Callable[[DeviceProfile | None, str | None], None], +): + try: + _profile, summary = convert_to_profile(file_path) + except (OSError, ValueError, TypeError) as e: + logger.error(f"LightBurn import failed: {e}") + callback(None, str(e)) + return + + def on_import(): + _install_lbdev_and_callback(file_path, callback) + + dialog = LBDevImportDialog( + parent=parent, + summary=summary, + on_import=on_import, + ) + dialog.present() + + +def _install_lbdev_and_callback( + file_path: Path, + callback: Callable[[DeviceProfile | None, str | None], None], +): + mgr = get_context().device_profile_mgr + try: + installed_profile, _ = mgr.install_from_lbdev(file_path) + except (OSError, ValueError, TypeError, RuntimeError) as e: + logger.error(f"LightBurn install failed: {e}") + callback(None, str(e)) + return + callback(installed_profile, None) diff --git a/rayforge/ui_gtk/machine/rotary_module_page.py b/rayforge/ui_gtk/machine/rotary_module_page.py new file mode 100644 index 000000000..33af1741e --- /dev/null +++ b/rayforge/ui_gtk/machine/rotary_module_page.py @@ -0,0 +1,794 @@ +from gettext import gettext as _ +from pathlib import Path +from typing import cast + +from gi.repository import Adw, Gtk +from raygeo.ops.axis import Axis + +from ...context import get_context +from ...core.model import Model +from ...machine.models.machine import Machine +from ...machine.models.rotary_module import ( + RotaryMode, + RotaryModule, + RotaryType, +) +from ..icons import get_icon +from ..shared.model_selection_dialog import ModelSelectionDialog +from ..shared.pref_rows.angle_spin_row import AngleSpinRow +from ..shared.pref_rows.base import SpinRow +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage +from ..sim3d.renderer.model_renderer import get_model_extent + + +class RotaryModuleRow(Gtk.Box): + """A widget representing a single RotaryModule in a ListBox.""" + + def __init__(self, machine: Machine, module: RotaryModule): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.machine = machine + self.module = module + self._toggle_handler_id = None + self._setup_ui() + + def _setup_ui(self): + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + info_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(info_box) + + self.title_label = Gtk.Label( + label=self.module.name, + halign=Gtk.Align.START, + xalign=0, + ) + info_box.append(self.title_label) + + self.subtitle_label = Gtk.Label( + label=self._get_subtitle_text(), + halign=Gtk.Align.START, + xalign=0, + wrap=True, + ) + self.subtitle_label.add_css_class("dim-label") + info_box.append(self.subtitle_label) + + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + self.delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + self.delete_button.add_css_class("flat") + self.delete_button.connect("clicked", self._on_remove_clicked) + suffix_box.append(self.delete_button) + + self.select_button = Gtk.ToggleButton() + self.select_button.add_css_class("flat") + self.select_button.set_child(get_icon("check-symbolic")) + self.select_button.set_tooltip_text(_("Set as default")) + self._toggle_handler_id = self.select_button.connect( + "toggled", self._on_select_toggled + ) + self.select_button.set_valign(Gtk.Align.CENTER) + suffix_box.append(self.select_button) + + self._update_selection_state() + + def _get_subtitle_text(self) -> str: + if self.module.mode == RotaryMode.AXIS_REPLACEMENT: + mode_label = _("Axis Replacement") + else: + mode_label = _("True 4th Axis") + axis = self.module.axis.name + return _("{mode}, Axis {axis}").format(mode=mode_label, axis=axis) + + def _update_selection_state(self): + is_default = ( + self.machine.default_rotary_module_uid is not None + and self.machine.default_rotary_module_uid == self.module.uid + ) + if self._toggle_handler_id is not None: + self.select_button.handler_block(self._toggle_handler_id) + self.select_button.set_active(is_default) + if self._toggle_handler_id is not None: + self.select_button.handler_unblock(self._toggle_handler_id) + + def _on_select_toggled(self, button: Gtk.ToggleButton): + if not button.get_active(): + button.set_active(True) + return + + self.machine.set_default_rotary_module_uid(self.module.uid) + + def _on_remove_clicked(self, button: Gtk.Button): + self.machine.remove_rotary_module(self.module) + + +class RotaryModuleListEditor(PreferencesGroupWithButton): + """An Adwaita widget for managing a list of rotary modules.""" + + def __init__(self, machine: Machine, **kwargs): + super().__init__(button_label=_("Add Rotary Module"), **kwargs) + self.machine = machine + self._row_widgets: list[RotaryModuleRow] = [] + self._known_uids: set[str] = set() + self._setup_ui() + self.machine.changed.connect(self._on_machine_changed) + self._rebuild() + + def _setup_ui(self): + placeholder = Gtk.Label( + label=_("No rotary modules configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.list_box.set_show_separators(True) + + def _on_machine_changed(self, sender, **kwargs): + current_uids = set(self.machine.rotary_modules.keys()) + if current_uids != self._known_uids: + self._rebuild() + return + + i = 0 + while True: + row = self.list_box.get_row_at_index(i) + if not row: + break + module_row = cast(RotaryModuleRow, row.get_child()) + module = module_row.module + module_row.title_label.set_label(module.name) + module_row.subtitle_label.set_label( + module_row._get_subtitle_text() + ) + module_row._update_selection_state() + i += 1 + + def _rebuild(self): + sorted_modules = sorted( + self.machine.rotary_modules.values(), key=lambda m: m.name + ) + self._known_uids = set(self.machine.rotary_modules.keys()) + + selected_module = None + selected_row = self.list_box.get_selected_row() + if selected_row: + widget = cast(RotaryModuleRow, selected_row.get_child()) + selected_module = widget.module + + while True: + row = self.list_box.get_row_at_index(0) + if not row: + break + self.list_box.remove(row) + self._row_widgets.clear() + + new_selection_index = -1 + for i, module in enumerate(sorted_modules): + if module is selected_module: + new_selection_index = i + list_box_row = Gtk.ListBoxRow() + list_box_row.set_child(self.create_row_widget(module)) + self.list_box.append(list_box_row) + + if new_selection_index >= 0: + row = self.list_box.get_row_at_index(new_selection_index) + self.list_box.select_row(row) + elif sorted_modules: + row = self.list_box.get_row_at_index(0) + self.list_box.select_row(row) + else: + if self.list_box.get_selected_row(): + self.list_box.unselect_all() + else: + self.list_box.emit("row-selected", None) + + def create_row_widget(self, item: RotaryModule) -> Gtk.Widget: + row = RotaryModuleRow(self.machine, item) + self._row_widgets.append(row) + return row + + def _on_add_clicked(self, button: Gtk.Button): + new_module = RotaryModule() + new_module.name = _("New Rotary Module") + self.machine.add_rotary_module(new_module) + + if self.machine.default_rotary_module_uid is None: + self.machine.set_default_rotary_module_uid(new_module.uid) + + self._rebuild() + + sorted_modules = sorted( + self.machine.rotary_modules.values(), key=lambda m: m.name + ) + idx = next( + i for i, m in enumerate(sorted_modules) if m.uid == new_module.uid + ) + row = self.list_box.get_row_at_index(idx) + if row: + self.list_box.select_row(row) + + +class RotaryModulePage(TrackedPreferencesPage): + key = "rotary-module" + path_prefix = "/machine-settings/" + + def __init__(self, machine: Machine, **kwargs): + super().__init__( + title=_("Rotary Module"), + icon_name="rotary-symbolic", + **kwargs, + ) + self.machine = machine + self._is_updating = True + + defaults_group = Adw.PreferencesGroup( + title=_("Rotary Defaults"), + description=_("Default settings applied to new layers."), + ) + self.add(defaults_group) + + self.rotary_enabled_default_row = Adw.SwitchRow( + title=_("Enable Rotary by Default"), + subtitle=_("New layers will default to rotary mode"), + ) + self.rotary_enabled_default_row.set_active( + machine.rotary_enabled_default + ) + self.rotary_enabled_default_row.connect( + "notify::active", self._on_rotary_enabled_default_changed + ) + defaults_group.add(self.rotary_enabled_default_row) + + modules_group = Adw.PreferencesGroup( + title=_("Modules"), + description=_( + "Define the physical rotary modules attached to your " + "machine. Select one as the default." + ), + ) + self.add(modules_group) + + self.module_list_editor = RotaryModuleListEditor( + machine=self.machine, + ) + modules_group.add(self.module_list_editor) + + self.general_group = Adw.PreferencesGroup( + title=_("General"), + ) + self.add(self.general_group) + + self.model_group = Adw.PreferencesGroup( + title=_("Model"), + ) + self.add(self.model_group) + + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.connect("changed", self._on_name_changed) + self.name_row.connect("activate", self._on_name_applied) + name_focus_ctrl = Gtk.EventControllerFocus() + name_focus_ctrl.connect("leave", self._on_name_focus_left) + self.name_row.add_controller(name_focus_ctrl) + self.general_group.add(self.name_row) + + mode_store = Gtk.StringList() + mode_store.append(_("True 4th Axis")) + mode_store.append(_("Axis Replacement")) + self._mode_values = [ + RotaryMode.TRUE_4TH_AXIS, + RotaryMode.AXIS_REPLACEMENT, + ] + self.mode_row = Adw.ComboRow( + title=_("Connection Mode"), + subtitle=_( + "How the rotary is connected to the machine controller" + ), + model=mode_store, + ) + self.mode_row.connect("notify::selected", self._on_mode_changed) + self.general_group.add(self.mode_row) + + self._valid_axes: list[Axis] = [] + + module_axis_store = Gtk.StringList() + self.module_axis_row = Adw.ComboRow( + title=_("Axis"), + subtitle=_("Axis letter for this module"), + model=module_axis_store, + ) + self.module_axis_row.connect( + "notify::selected", self._on_module_axis_changed + ) + self.general_group.add(self.module_axis_row) + + self.reverse_axis_row = Adw.SwitchRow( + title=_("Reversed Axis"), + subtitle=_("Reverse the rotation direction of the rotary axis"), + ) + self.reverse_axis_row.connect( + "notify::active", self._on_reverse_axis_changed + ) + self.general_group.add(self.reverse_axis_row) + + self.axis_position_x_row = LengthSpinRow( + _("Axis Offset X"), + _("Offset from module position to rotation axis (X)"), + lower=-10000, + upper=10000, + ) + self.axis_position_x_row.value_changed.connect( + self._on_axis_position_changed + ) + self.general_group.add(self.axis_position_x_row) + + self.axis_position_y_row = LengthSpinRow( + _("Axis Offset Y"), + _("Offset from module position to rotation axis (Y)"), + lower=-10000, + upper=10000, + ) + self.axis_position_y_row.value_changed.connect( + self._on_axis_position_changed + ) + self.general_group.add(self.axis_position_y_row) + + self.axis_position_z_row = LengthSpinRow( + _("Axis Offset Z"), + _("Offset from module position to rotation axis (Z)"), + lower=-10000, + upper=10000, + ) + self.axis_position_z_row.value_changed.connect( + self._on_axis_position_changed + ) + self.general_group.add(self.axis_position_z_row) + + rotary_type_store = Gtk.StringList() + rotary_type_store.append(_("Jaws / Chuck")) + rotary_type_store.append(_("Rollers")) + self._rotary_type_values = [ + RotaryType.JAWS, + RotaryType.ROLLERS, + ] + self.rotary_type_row = Adw.ComboRow( + title=_("Drive Type"), + subtitle=_("How the rotary module drives the workpiece rotation"), + model=rotary_type_store, + ) + self.rotary_type_row.connect( + "notify::selected", self._on_rotary_type_changed + ) + self.general_group.add(self.rotary_type_row) + + self.roller_diameter_row = LengthSpinRow( + _("Roller Diameter"), + _("Diameter of the drive roller"), + upper=10000, + ) + self.roller_diameter_row.value_changed.connect( + self._on_roller_diameter_changed + ) + self.general_group.add(self.roller_diameter_row) + + self.mm_per_rotation_row = SpinRow( + _("Travel per Rotation"), + _( + "Firmware distance for one full 360° rotation. " + "0 = raw circumferential output." + ), + upper=100000, + digits=2, + ) + self.mm_per_rotation_row.value_changed.connect( + self._on_mm_per_rotation_changed + ) + self.general_group.add(self.mm_per_rotation_row) + + self.default_diameter_row = LengthSpinRow( + _("Default Workpiece Diameter"), + _("Default diameter for new layers using this module"), + lower=1, + upper=10000, + ) + self.default_diameter_row.value_changed.connect( + self._on_default_diameter_changed + ) + self.general_group.add(self.default_diameter_row) + + self.max_workpiece_length_row = LengthSpinRow( + _("Max Workpiece Length"), + _("Maximum workpiece length this module can accommodate"), + lower=1, + upper=10000, + ) + self.max_workpiece_length_row.value_changed.connect( + self._on_max_workpiece_length_changed + ) + self.general_group.add(self.max_workpiece_length_row) + + self.model_row = Adw.ActionRow( + title=_("Model"), + activatable=True, + ) + self.model_row.connect("activated", self._on_model_activated) + self.model_row.add_suffix(get_icon("go-next-symbolic")) + self.model_group.add(self.model_row) + + self.scale_row = SpinRow( + _("Scale"), + _("Uniform scale factor for the model"), + lower=0.01, + upper=1000, + digits=2, + ) + self.scale_row.value_changed.connect(self._on_scale_changed) + self.model_group.add(self.scale_row) + + self.x_row = LengthSpinRow( + _("X Position"), + _("X coordinate in machine space"), + lower=-10000, + upper=10000, + ) + self.x_row.value_changed.connect(self._on_position_changed) + self.model_group.add(self.x_row) + + self.y_row = LengthSpinRow( + _("Y Position"), + _("Y coordinate in machine space"), + lower=-10000, + upper=10000, + ) + self.y_row.value_changed.connect(self._on_position_changed) + self.model_group.add(self.y_row) + + self.z_row = LengthSpinRow( + _("Z Position"), + _("Z coordinate in machine space"), + lower=-10000, + upper=10000, + ) + self.z_row.value_changed.connect(self._on_position_changed) + self.model_group.add(self.z_row) + + self.rx_row = AngleSpinRow( + _("X Rotation"), + _("Degrees around the X axis"), + ) + self.rx_row.value_changed.connect(self._on_rotation_changed) + self.model_group.add(self.rx_row) + + self.ry_row = AngleSpinRow( + _("Y Rotation"), + _("Degrees around the Y axis"), + ) + self.ry_row.value_changed.connect(self._on_rotation_changed) + self.model_group.add(self.ry_row) + + self.rz_row = AngleSpinRow( + _("Z Rotation"), + _("Degrees around the Z axis"), + ) + self.rz_row.value_changed.connect(self._on_rotation_changed) + self.model_group.add(self.rz_row) + + self.module_list_editor.list_box.connect( + "row-selected", self._on_module_selected + ) + + self.machine.changed.connect(self._on_machine_changed) + self.connect("map", self._on_page_mapped) + self.connect("destroy", self._on_destroy) + + self._is_updating = False + initial_row = self.module_list_editor.list_box.get_selected_row() + self._on_module_selected(self.module_list_editor.list_box, initial_row) + + def _get_selected_module(self) -> RotaryModule | None: + selected_row = self.module_list_editor.list_box.get_selected_row() + if not selected_row: + return None + module_row = cast(RotaryModuleRow, selected_row.get_child()) + return module_row.module + + def _on_module_selected(self, listbox, row): + has_selection = row is not None + for g in (self.general_group, self.model_group): + g.set_visible(has_selection) + if not has_selection: + return + + module = self._get_selected_module() + if not module: + return + + self._is_updating = True + + self.name_row.set_text(module.name) + + try: + mode_idx = self._mode_values.index(module.mode) + except ValueError: + mode_idx = 0 + self.mode_row.set_selected(mode_idx) + + scale = self.machine.unit_system.scale_from_mm + self.mm_per_rotation_row.set_value(module.mm_per_rotation * scale) + self._update_mode_dependent_rows(module) + + self.default_diameter_row.set_value_in_base_units( + module.default_diameter + ) + self.max_workpiece_length_row.set_value_in_base_units( + module.max_workpiece_length + ) + + try: + type_idx = self._rotary_type_values.index(module.rotary_type) + except ValueError: + type_idx = 0 + self.rotary_type_row.set_selected(type_idx) + self._update_type_dependent_rows(module) + + self.roller_diameter_row.set_value_in_base_units( + module.roller_diameter + ) + self.reverse_axis_row.set_active(module.reverse_axis) + self.axis_position_x_row.set_value_in_base_units( + float(module.axis_position[0]) + ) + self.axis_position_y_row.set_value_in_base_units( + float(module.axis_position[1]) + ) + self.axis_position_z_row.set_value_in_base_units( + float(module.axis_position[2]) + ) + self._update_model_subtitle(module) + t = module.transform + self.x_row.set_value_in_base_units(float(t[0, 3])) + self.y_row.set_value_in_base_units(float(t[1, 3])) + self.z_row.set_value_in_base_units(float(t[2, 3])) + rx, ry, rz = module.get_rotation() + self.rx_row.set_value(rx) + self.ry_row.set_value(ry) + self.rz_row.set_value(rz) + self.scale_row.set_value(module.get_scale()) + + self._is_updating = False + + def _update_model_subtitle(self, module: RotaryModule): + if module.model_path: + model_mgr = get_context().model_mgr + model = Model.from_path(Path(module.model_path)) + resolved = model_mgr.resolve(model) + if resolved: + self.model_row.set_subtitle(resolved.stem) + return + self.model_row.set_subtitle(_("None")) + + def _on_rotary_enabled_default_changed(self, row, _param): + if self._is_updating: + return + self.machine.set_rotary_enabled_default(row.get_active()) + + def _on_name_changed(self, entry_row): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_name(entry_row.get_text()) + + def _on_name_applied(self, entry_row): + self.module_list_editor._rebuild() + + def _on_name_focus_left(self, controller): + if not self._is_updating: + self.module_list_editor._rebuild() + + def _on_module_axis_changed(self, row, _param): + if self._is_updating: + return + module = self._get_selected_module() + if not module: + return + selected = row.get_selected() + if selected < len(self._valid_axes): + module.set_axis(self._valid_axes[selected]) + + def _get_valid_axes_for_mode(self, mode: RotaryMode) -> list[Axis]: + if mode == RotaryMode.TRUE_4TH_AXIS: + return [Axis.A, Axis.B, Axis.C, Axis.U] + return [Axis.Y, Axis.Z] + + def _update_axis_dropdown(self, module: RotaryModule): + axes = self._get_valid_axes_for_mode(module.mode) + self._valid_axes = axes + store = Gtk.StringList() + for a in axes: + store.append(a.name or "") + self.module_axis_row.set_model(store) + try: + selected = axes.index(module.axis) + except ValueError: + selected = 0 + if axes: + module.set_axis(axes[0]) + self.module_axis_row.set_selected(selected) + + def _update_mode_dependent_rows(self, module: RotaryModule): + is_replacement = module.mode == RotaryMode.AXIS_REPLACEMENT + self.mm_per_rotation_row.set_visible(is_replacement) + self._update_axis_dropdown(module) + + def _update_type_dependent_rows(self, module: RotaryModule): + is_roller = module.rotary_type == RotaryType.ROLLERS + self.roller_diameter_row.set_visible(is_roller) + + def _on_mode_changed(self, row, _param): + if self._is_updating: + return + module = self._get_selected_module() + if not module: + return + selected = row.get_selected() + if selected < len(self._mode_values): + module.set_mode(self._mode_values[selected]) + self._update_mode_dependent_rows(module) + + def _on_rotary_type_changed(self, row, _param): + if self._is_updating: + return + module = self._get_selected_module() + if not module: + return + selected = row.get_selected() + if selected < len(self._rotary_type_values): + module.set_rotary_type(self._rotary_type_values[selected]) + self._update_type_dependent_rows(module) + + def _on_mm_per_rotation_changed(self, spinrow): + if self._is_updating: + return + module = self._get_selected_module() + if module: + scale = self.machine.unit_system.scale_from_mm + module.set_mm_per_rotation(spinrow.get_value() / scale) + + def _on_default_diameter_changed(self, helper): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_default_diameter( + self.default_diameter_row.get_value_in_base_units() + ) + + def _on_max_workpiece_length_changed(self, helper): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_max_workpiece_length( + self.max_workpiece_length_row.get_value_in_base_units() + ) + + def _on_roller_diameter_changed(self, helper): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_roller_diameter( + self.roller_diameter_row.get_value_in_base_units() + ) + + def _on_reverse_axis_changed(self, switchrow, _param): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_reverse_axis(switchrow.get_active()) + + def _on_axis_position_changed(self, helper): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_axis_position( + self.axis_position_x_row.get_value_in_base_units(), + self.axis_position_y_row.get_value_in_base_units(), + self.axis_position_z_row.get_value_in_base_units(), + ) + + def _on_model_activated(self, row): + module = self._get_selected_module() + if not module: + return + + root = self.get_root() + dialog = ModelSelectionDialog( + current_model_path=module.model_path, + transient_for=cast(Gtk.Window, root) if root else None, + ) + + def on_response(d, response_id): + if response_id != "select": + d.destroy() + return + selected_path = d.get_selected_model_path() + if selected_path != module.model_path: + module.set_model_path(selected_path) + if selected_path is not None: + self._apply_model_scale(module, selected_path) + self._update_model_subtitle(module) + self.module_list_editor._rebuild() + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _apply_model_scale(self, module, model_path): + resolved = get_context().model_mgr.resolve( + Model.from_path(Path(model_path)) + ) + if resolved is None: + return + extent = get_model_extent(resolved) + if extent and extent > 1e-6: + module.set_scale(module.default_diameter / extent) + + def _on_position_changed(self, helper): + if self._is_updating: + return + module = self._get_selected_module() + if not module: + return + x = self.x_row.get_value_in_base_units() + y = self.y_row.get_value_in_base_units() + z = self.z_row.get_value_in_base_units() + module.set_position(x, y, z) + + def _on_rotation_changed(self, _spinrow): + if self._is_updating: + return + module = self._get_selected_module() + if not module: + return + rx = self.rx_row.get_value() + ry = self.ry_row.get_value() + rz = self.rz_row.get_value() + module.set_rotation(rx, ry, rz) + + def _on_scale_changed(self, _spinrow): + if self._is_updating: + return + module = self._get_selected_module() + if module: + module.set_scale(self.scale_row.get_value()) + + def _on_machine_changed(self, sender, **kwargs): + if self._is_updating: + return + self.rotary_enabled_default_row.set_active( + self.machine.rotary_enabled_default + ) + + def _on_page_mapped(self, widget): + if not self._is_updating: + self.module_list_editor._rebuild() + + def _on_destroy(self, *args): + self.machine.changed.disconnect(self._on_machine_changed) + self.machine.changed.disconnect( + self.module_list_editor._on_machine_changed + ) diff --git a/rayforge/ui_gtk/machine/settings_dialog.py b/rayforge/ui_gtk/machine/settings_dialog.py new file mode 100644 index 000000000..f25b4f538 --- /dev/null +++ b/rayforge/ui_gtk/machine/settings_dialog.py @@ -0,0 +1,426 @@ +import logging +import webbrowser +from gettext import gettext as _ +from pathlib import Path + +from gi.repository import Adw, Gdk, GLib, Gtk + +from ... import const +from ...camera.models import Camera +from ...camera.v4l import display_name +from ...context import get_context +from ...machine.driver import ( + DRIVER_MATURITY_LABELS, + DriverMaturity, + get_driver_cls, +) +from ...machine.models.machine import Machine +from ..camera.camera_preferences_page import CameraPreferencesPage +from ..icons import get_icon +from ..shared.gtk import apply_css +from ..shared.patched_dialog_window import PatchedDialogWindow +from .advanced_preferences_page import AdvancedPreferencesPage +from .capabilities_page import CapabilitiesPage +from .device_settings_page import DeviceSettingsPage +from .gcode_settings_page import GcodeSettingsPage +from .general_preferences_page import GeneralPreferencesPage +from .hardware_page import HardwarePage +from .head_preferences_page import HeadPreferencesPage +from .hooks_macros_page import HooksMacrosPage +from .maintenance_page import MaintenancePage +from .nogo_zones_page import NogoZonesPage +from .rotary_module_page import RotaryModulePage + +logger = logging.getLogger(__name__) + +apply_css(""" +.maturity-warning { + background-color: alpha(@warning_color, 0.15); + padding: 10px 28px; +} +.maturity-link { + text-decoration: underline; +} +""") + + +class MachineSettingsDialog(PatchedDialogWindow): + def __init__( + self, + *, + machine: Machine, + transient_for=None, + initial_page: str | None = None, + **kwargs, + ): + super().__init__(skip_usage_tracking=True, **kwargs) + if transient_for: + self.set_transient_for(transient_for) + self.machine = machine + self._row_to_page_name = {} + self._initial_page = initial_page + self._gcode_row: Gtk.ListBoxRow | None = None + self._gcode_stack_page: Gtk.StackPage | None = None + if machine.name: + self.set_title( + _("{machine_name} - Machine Settings").format( + machine_name=machine.name + ) + ) + else: + self.set_title(_("Machine Settings")) + self.set_default_size(800, 800) + + # --- Layout --- + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + + # Main layout container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.toast_overlay.set_child(main_box) + + # Header bar + header_bar = Adw.HeaderBar() + export_button = Gtk.Button(child=get_icon("share-symbolic")) + export_button.set_tooltip_text(_("Export Machine Profile")) + export_button.add_css_class("flat") + export_button.connect("clicked", self._on_export_clicked) + header_bar.pack_end(export_button) + main_box.append(header_bar) + + # Maturity warning banner + self.maturity_banner = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + hexpand=True, + ) + self.maturity_banner.add_css_class("maturity-warning") + self._maturity_icon = get_icon("warning-symbolic") + self._maturity_icon.add_css_class("warning") + self._maturity_label = Gtk.Label(wrap=True, xalign=0, hexpand=True) + self._maturity_label.add_css_class("warning-label") + + self._maturity_link = Gtk.Label( + label=_("Report an issue"), + wrap=False, + xalign=0, + hexpand=False, + ) + self._maturity_link.add_css_class("warning-label") + self._maturity_link.add_css_class("maturity-link") + link_click = Gtk.GestureClick.new() + link_click.connect( + "pressed", + lambda *_: webbrowser.open(const.ISSUES_URL), + ) + self._maturity_link.add_controller(link_click) + link_motion = Gtk.EventControllerMotion() + link_motion.connect( + "enter", + lambda *_: self._maturity_link.set_cursor( + Gdk.Cursor.new_from_name("pointer") + ), + ) + link_motion.connect( + "leave", + lambda *_: self._maturity_link.set_cursor(None), + ) + self._maturity_link.add_controller(link_motion) + + self.maturity_banner.append(self._maturity_icon) + self.maturity_banner.append(self._maturity_label) + self.maturity_banner.append(self._maturity_link) + self.maturity_banner.set_visible(False) + main_box.append(self.maturity_banner) + + # Navigation Split View for sidebar and content + split_view = Adw.NavigationSplitView(vexpand=True) + main_box.append(split_view) + + # Sidebar + self.sidebar_list = Gtk.ListBox( + selection_mode=Gtk.SelectionMode.SINGLE, + css_classes=["navigation-sidebar"], + ) + sidebar_page = Adw.NavigationPage.new( + self.sidebar_list, _("Categories") + ) + split_view.set_sidebar(sidebar_page) + + # Content Stack + self.content_stack = Gtk.Stack() + + # --- Page 1: General --- + general_page = GeneralPreferencesPage(machine=self.machine) + self.content_stack.add_titled(general_page, "general", _("General")) + + # --- Page 2: Hardware --- + hardware_page = HardwarePage(machine=self.machine) + self.content_stack.add_titled(hardware_page, "hardware", _("Hardware")) + + # --- Page 3: Advanced --- + advanced_page = AdvancedPreferencesPage(machine=self.machine) + self.content_stack.add_titled(advanced_page, "advanced", _("Advanced")) + + # --- Page 4: G-code --- + gcode_page = GcodeSettingsPage(machine=self.machine) + self.content_stack.add_titled(gcode_page, "gcode", _("G-code")) + self._gcode_stack_page = self.content_stack.get_page(gcode_page) + + # --- Page 5: Hooks & Macros --- + hooks_macros_page = HooksMacrosPage(machine=self.machine) + self.content_stack.add_titled( + hooks_macros_page, "hooks-macros", _("Hooks & Macros") + ) + + # --- Page 6: Device --- + device_page = DeviceSettingsPage(machine=self.machine) + device_page.show_toast.connect(self._on_show_toast) + self.content_stack.add_titled(device_page, "device", _("Device")) + + # --- Page 7: Heads --- + heads_page = HeadPreferencesPage(machine=self.machine) + self.content_stack.add_titled(heads_page, "heads", _("Heads")) + + # --- Page 8: Rotary Module --- + rotary_module_page = RotaryModulePage(machine=self.machine) + self.content_stack.add_titled( + rotary_module_page, "rotary-module", _("Rotary Module") + ) + + # --- Page 9: No-Go Zones --- + nogo_zones_page = NogoZonesPage(machine=self.machine) + self.content_stack.add_titled( + nogo_zones_page, "nogo-zones", _("No-Go Zones") + ) + + # --- Page 10: Camera --- + self.camera_page = CameraPreferencesPage() + self.camera_page.camera_add_requested.connect( + self._on_camera_add_requested + ) + self.camera_page.camera_remove_requested.connect( + self._on_camera_remove_requested + ) + self.content_stack.add_titled(self.camera_page, "camera", _("Camera")) + + # --- Page 11: Maintenance --- + maintenance_page = MaintenancePage(machine=self.machine) + self.content_stack.add_titled( + maintenance_page, "maintenance", _("Maintenance") + ) + + # --- Page 12: Capabilities --- + capabilities_page = CapabilitiesPage(machine=self.machine) + self.content_stack.add_titled( + capabilities_page, "capabilities", _("Capabilities") + ) + + # Create the content's NavigationPage wrapper + pages = self.content_stack.get_pages() + first_stack_page = pages.get_item(0) # type: ignore + initial_title = first_stack_page.get_title() + self.content_page = Adw.NavigationPage.new( + self.content_stack, initial_title + ) + split_view.set_content(self.content_page) + + # Populate sidebar with rows + self._add_sidebar_row( + _("General"), "machine-settings-general-symbolic", "general" + ) + self._add_sidebar_row(_("Hardware"), "hardware-symbolic", "hardware") + self._add_sidebar_row( + _("Advanced"), "machine-settings-advanced-symbolic", "advanced" + ) + self._add_sidebar_row(_("G-code"), "gcode-symbolic", "gcode") + self._gcode_row = self.sidebar_list.get_row_at_index(3) + self._add_sidebar_row( + _("Hooks & Macros"), "code-symbolic", "hooks-macros" + ) + self._add_sidebar_row(_("Device"), "settings-symbolic", "device") + self._add_sidebar_row(_("Heads"), "laser-on-symbolic", "heads") + self._add_sidebar_row( + _("Rotary Module"), "rotary-symbolic", "rotary-module" + ) + self._add_sidebar_row( + _("No-Go Zones"), "action-unavailable-symbolic", "nogo-zones" + ) + self._add_sidebar_row(_("Camera"), "camera-on-symbolic", "camera") + self._add_sidebar_row( + _("Maintenance"), "timer-symbolic", "maintenance" + ) + self._add_sidebar_row( + _("Capabilities"), "settings-symbolic", "capabilities" + ) + + # Connect sidebar selection + self.sidebar_list.connect("row-selected", self._on_row_selected) + + # Sync UI with CameraManager signals + camera_mgr = get_context().camera_mgr + camera_mgr.controller_added.connect(self._sync_camera_page) + camera_mgr.controller_removed.connect(self._sync_camera_page) + self.connect("destroy", self._on_destroy) + + # React to driver changes (e.g. show/hide G-code page) + self.machine.changed.connect(self._on_machine_changed) + + # Initial population of all dependent pages + self._sync_camera_page() + self._update_gcode_page_visibility() + self._update_maturity_banner() + + # Select the specified page or first row by default + if self._initial_page: + for row, page_name in self._row_to_page_name.items(): + if page_name == self._initial_page: + self.sidebar_list.select_row(row) + break + else: + self.sidebar_list.select_row(self.sidebar_list.get_row_at_index(0)) + + def _on_machine_changed(self, sender=None, **kwargs): + self._update_gcode_page_visibility() + self._update_maturity_banner() + + def _update_maturity_banner(self): + maturity = DriverMaturity.STABLE + if self.machine.driver_name: + driver_cls = get_driver_cls(self.machine.driver_name) + maturity = driver_cls.maturity + label = DRIVER_MATURITY_LABELS.get(maturity, "") + if label: + self._maturity_label.set_text(label) + self.maturity_banner.set_visible(True) + else: + self.maturity_banner.set_visible(False) + + def _update_gcode_page_visibility(self): + uses_gcode = True + if self.machine.driver_name: + driver_cls = get_driver_cls(self.machine.driver_name) + uses_gcode = driver_cls.uses_gcode + + if self._gcode_stack_page: + self._gcode_stack_page.set_visible(uses_gcode) + if self._gcode_row: + self._gcode_row.set_visible(uses_gcode) + + if not uses_gcode: + selected = self.sidebar_list.get_selected_row() + if selected is self._gcode_row: + self.sidebar_list.select_row( + self.sidebar_list.get_row_at_index(0) + ) + + def _on_export_clicked(self, button): + """Opens a folder chooser to export the machine as a zip.""" + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Export Machine Profile")) + dialog.select_folder(self, None, self._on_export_folder_selected) + + def _on_export_folder_selected(self, dialog, result): + try: + folder = dialog.select_folder_finish(result) + except GLib.Error: + return + if not folder: + return + dest = Path(folder.get_path()) + context = get_context() + try: + zip_path = context.device_profile_mgr.export_machine( + self.machine, dest, context.model_mgr + ) + self.toast_overlay.add_toast( + Adw.Toast( + title=_("Exported to {path}").format(path=zip_path.name), + timeout=5, + ) + ) + except (OSError, ValueError, RuntimeError) as e: + logger.error(f"Export failed: {e}") + self.toast_overlay.add_toast( + Adw.Toast(title=_("Export failed: {error}").format(error=e)) + ) + + def _add_sidebar_row( + self, label_text: str, icon_name: str, page_name: str + ): + """Adds a row to the sidebar with an icon and label.""" + row = Gtk.ListBoxRow() + box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + margin_start=12, + margin_end=12, + margin_top=6, + margin_bottom=6, + ) + icon = get_icon(icon_name) + label = Gtk.Label(label=label_text, xalign=0) + box.append(icon) + box.append(label) + row.set_child(box) + self._row_to_page_name[row] = page_name + self.sidebar_list.append(row) + + def _on_row_selected(self, listbox, row): + """Handler for when a row is selected in the sidebar.""" + if row: + page_name = self._row_to_page_name[row] + self.content_stack.set_visible_child_name(page_name) + child = self.content_stack.get_child_by_name(page_name) + if child: + stack_page = self.content_stack.get_page(child) + if stack_page: + title = stack_page.get_title() + if title: + self.content_page.set_title(title) + + def _on_show_toast(self, sender, message: str): + """ + Handler to show the toast when requested by the child page. + """ + self.toast_overlay.add_toast(Adw.Toast(title=message, timeout=5)) + + def _on_camera_add_requested(self, sender, *, device_id: str): + """Handles the request to add a new camera to the machine.""" + if any(c.device_id == device_id for c in self.machine.cameras): + return # Safety check + + new_camera = Camera( + display_name(device_id), + device_id, + ) + new_camera.enabled = True + self.machine.add_camera(new_camera) + # The machine.changed signal will handle the UI update + + def _on_camera_remove_requested(self, sender, *, camera: Camera): + """Handles the request to remove a camera from the machine.""" + camera.enabled = False + self.machine.remove_camera(camera) + # The machine.changed signal will handle the UI update + + def _sync_camera_page(self, sender=None, **kwargs): + """Updates child pages that depend on the list of live controllers.""" + camera_mgr = get_context().camera_mgr + # Get all live controllers and filter them for this specific + # machine + all_controllers = camera_mgr.controllers + machine_camera_device_ids = {c.device_id for c in self.machine.cameras} + relevant_controllers = [ + c + for c in all_controllers + if c.config.device_id in machine_camera_device_ids + ] + self.camera_page.set_controllers(relevant_controllers) + + def _on_destroy(self, *args): + """Disconnects signals to prevent memory leaks.""" + camera_mgr = get_context().camera_mgr + camera_mgr.controller_added.disconnect(self._sync_camera_page) + camera_mgr.controller_removed.disconnect(self._sync_camera_page) + self.machine.changed.disconnect(self._on_machine_changed) diff --git a/rayforge/ui_gtk/machine/status_widget.py b/rayforge/ui_gtk/machine/status_widget.py new file mode 100644 index 000000000..496e34793 --- /dev/null +++ b/rayforge/ui_gtk/machine/status_widget.py @@ -0,0 +1,123 @@ +from gettext import gettext as _ + +from gi.repository import Gtk + +from ...machine.driver.driver import ( + DEVICE_STATUS_LABELS, + DeviceState, + DeviceStatus, +) +from ...machine.driver.dummy import NoDeviceDriver +from ...machine.models.machine import Machine +from ..icons import get_icon + + +class MachineStatusIconWidget(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + + # Placeholder for the image widget + self.status_image: Gtk.Widget | None = None + + # Set the initial status + self.set_status(DeviceStatus.UNKNOWN) + + def set_status(self, status): + """Update the status icon based on the given status.""" + icon_name = self._get_icon_name_for_status(status) + + # Get the new image widget from the helper + new_image = get_icon(icon_name) + + # Remove the old image if it exists + if self.status_image is not None: + self.remove(self.status_image) + + # Set and add the new image + self.status_image = new_image + if self.status_image: + self.append(self.status_image) + + def _get_icon_name_for_status(self, status): + """Map the status to an appropriate symbolic icon name.""" + if status == DeviceStatus.UNKNOWN: + return "question-box-symbolic" + elif status == DeviceStatus.IDLE: + return "status-idle-symbolic" + elif status == DeviceStatus.RUN: + return "play-arrow-symbolic" + elif status == DeviceStatus.HOLD: + return "pause-symbolic" + elif status == DeviceStatus.JOG: + return "jog-symbolic" + elif status == DeviceStatus.ALARM: + return "alarm-symbolic" + elif status == DeviceStatus.DOOR: + return "door-symbolic" + elif status == DeviceStatus.CHECK: + return "status-check-symbolic" + elif status == DeviceStatus.HOME: + return "home-symbolic" + elif status == DeviceStatus.SLEEP: + return "sleep-symbolic" + elif status == DeviceStatus.TOOL: + return "machine-settings-general-symbolic" + elif status == DeviceStatus.QUEUE: + return "batch-symbolic" + elif status == DeviceStatus.LOCK: + return "lock-symbolic" + elif status == DeviceStatus.UNLOCK: + return "lock-open-symbolic" + elif status == DeviceStatus.CYCLE: + return "refresh-symbolic" + elif status == DeviceStatus.TEST: + return "test-symbolic" + else: + return "status-offline-symbolic" + + +class MachineStatusWidget(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.machine: Machine | None = None + + self.label = Gtk.Label() + self.append(self.label) + + self.icon = MachineStatusIconWidget() + self.append(self.icon) + + self._update_display(DeviceState()) # Initial default state + + def set_machine(self, machine: Machine | None): + if self.machine: + try: + self.machine.state_changed.disconnect(self._on_state_changed) + except TypeError: + pass # Was not connected + + self.machine = machine + + if self.machine: + self.machine.state_changed.connect(self._on_state_changed) + self._update_display(self.machine.device_state) + else: + self._update_display(None) + + def _on_state_changed(self, machine: Machine, state: DeviceState): + self._update_display(state) + + def _update_display(self, state: DeviceState | None): + is_nodriver = not self.machine or isinstance( + self.machine.driver, NoDeviceDriver + ) + status = state.status if state else DeviceStatus.UNKNOWN + + if is_nodriver: + self.label.set_label(_("No driver")) + self.icon.set_status(DeviceStatus.UNKNOWN) + else: + self.label.set_label( + DEVICE_STATUS_LABELS.get(status, _("Unknown")) + ) + self.icon.set_status(status) diff --git a/rayforge/ui_gtk/machine/template_selector.py b/rayforge/ui_gtk/machine/template_selector.py new file mode 100644 index 000000000..a35eae89e --- /dev/null +++ b/rayforge/ui_gtk/machine/template_selector.py @@ -0,0 +1,100 @@ +from collections.abc import Callable +from gettext import gettext as _ + +from gi.repository import Adw, GLib, Gtk + +from ...machine.models.dialect import BUILTIN_DIALECTS, GcodeDialect +from ..shared.gtk import apply_css + +css = """ +.dialect-template-list { + background: none; +} +""" + + +class DialectTemplateSelectorDialog(Adw.MessageDialog): + """ + A dialog for selecting a dialect template from the built-in dialects. + + The dialog is confirmed by activating a row (double-click or Enter). + """ + + class _TemplateRow(Adw.ActionRow): + """A custom row to hold a reference to its dialect template.""" + + def __init__(self, template: GcodeDialect, **kwargs): + super().__init__(**kwargs) + self.template: GcodeDialect = template + + def __init__( + self, + on_selected: Callable[[GcodeDialect], None] | None = None, + title: str | None = None, + body: str | None = None, + **kwargs, + ): + """Initializes the Dialect Template Selector dialog. + + Args: + on_selected: Callback called when a template is selected. + title: Dialog heading text. + body: Dialog body text. + """ + super().__init__(**kwargs) + self._on_selected = on_selected + self.set_heading(title or _("Select a Template")) + self.set_body( + body or _("Choose a built-in dialect as a starting point.") + ) + self.set_transient_for(kwargs.get("transient_for")) + + apply_css(css) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content.set_margin_top(12) + content.set_size_request(460, 400) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + scrolled_window.set_min_content_height(250) + scrolled_window.set_vexpand(True) + scrolled_window.add_css_class("card") + content.append(scrolled_window) + + self.template_list_box = Gtk.ListBox() + self.template_list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.template_list_box.add_css_class("dialect-template-list") + self.template_list_box.connect("row-activated", self._on_row_activated) + scrolled_window.set_child(self.template_list_box) + + self._populate_template_list() + + self.set_extra_child(content) + + self.add_response("cancel", _("Cancel")) + self.set_default_response("cancel") + + def _populate_template_list(self): + """Fills the list box with available built-in dialect templates.""" + sorted_templates = sorted( + BUILTIN_DIALECTS, key=lambda t: t.label.lower() + ) + + for template in sorted_templates: + subtitle = GLib.markup_escape_text(template.description, -1) + row = self._TemplateRow( + template=template, + title=template.label, + subtitle=subtitle, + activatable=True, + ) + self.template_list_box.append(row) + + def _on_row_activated(self, listbox: Gtk.ListBox, row: _TemplateRow): + """Handles row activation, calls callback, and closes the dialog.""" + if self._on_selected: + self._on_selected(row.template) + self.close() diff --git a/rayforge/ui_gtk/machine/unified_wizard.py b/rayforge/ui_gtk/machine/unified_wizard.py new file mode 100644 index 000000000..93a6d7167 --- /dev/null +++ b/rayforge/ui_gtk/machine/unified_wizard.py @@ -0,0 +1,692 @@ +"""The Unified Machine Configuration Wizard. + +Orchestrates the multi-step machine setup flow. Holds a +working ``DeviceProfile`` in memory and routes between pages +dynamically based on the user's choices (known profile vs unknown, +probe-capable controller vs none, AI-lookup available vs not, etc.). + +Emits ``machine_created`` with the resulting live ``Machine`` once the +user completes the final review step. + +Replaces the legacy ``MachineProfileSelectorDialog`` + ``ConfigWizard`` +pair. +""" + +import logging +from gettext import gettext as _ +from typing import Any + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import Camera +from ...camera.v4l import display_name +from ...context import get_context +from ...core.ai.spec_lookup import is_ai_configured +from ...machine.device.profile import ( + DeviceMeta, + DeviceProfile, + MachineConfig, +) +from ...machine.driver import get_driver_cls +from ...machine.driver.dummy import NoDeviceDriver +from ..camera.wizard.wizard import CameraWizard +from ..shared.patched_dialog_window import PatchedDialogWindow +from .wizard_pages import WizardPage, empty_profile +from .wizard_pages.ai_lookup_page import AILookupPage +from .wizard_pages.camera_page import CameraPage +from .wizard_pages.connection_page import ConnectionPage +from .wizard_pages.controller_page import ControllerPage +from .wizard_pages.hardware_page import HardwarePage +from .wizard_pages.head_page import HeadPage +from .wizard_pages.probe_page import ProbePage +from .wizard_pages.profile_page import ProfilePage +from .wizard_pages.provider_page import AIProviderPage +from .wizard_pages.review_page import ReviewPage +from .wizard_pages.rotary_page import RotaryPage + +logger = logging.getLogger(__name__) + + +# Ordered list of step names the wizard knows about. Adaptive routing +# may skip individual entries based on the user's choices. +_STEP_ORDER: list[str] = [ + "profile", + "controller", + "connect", + "probe", + "ai_provider", + "ai_lookup", + "hardware", + "head", + "rotary", + "camera", + "review", +] + + +class UnifiedWizard(PatchedDialogWindow): + """The unified add-machine wizard dialog. + + A ``PatchedDialogWindow`` subclass that hosts one + :class:`WizardPage` at a time, drives a footer with Back / Next / + Create / Cancel buttons, and manages the in-memory working + ``DeviceProfile``. The orchestrator decides — based on the current + page's outcome — which page to show next. + """ + + def __init__(self, **kwargs): + self.profile_created = Signal() + super().__init__( + transient_for=kwargs.pop("transient_for", None), + modal=True, + default_width=760, + default_height=640, + title=_("Add a Machine"), + **kwargs, + ) + + self.profile: DeviceProfile = empty_profile() + # Aux state for pages that need to carry session-only fields + # not part of DeviceProfile (e.g. axis reversals applied at + # machine-creation time). + self.aux_state: dict[str, Any] = {} + # Set when the user picks a known profile or import on Step 1; + # None for "Other / unknown machine". Used to skip the AI + # lookup steps when the specs are already in the profile. + self._source: dict[str, Any] | None = None + + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.toast_overlay.set_child(content) + + self.header = Adw.HeaderBar() + content.append(self.header) + + self._main_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + margin_top=12, + margin_bottom=12, + margin_start=12, + margin_end=12, + ) + content.append(self._main_box) + + self.stack = Gtk.Stack() + self.stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + ) + self.stack.set_vexpand(True) + self.stack.connect("notify::visible-child", self._on_page_changed) + self._main_box.append(self.stack) + + # Lazily-built pages keyed by step name. We construct each + # page only when the user visits it so that pages that touch + # hardware (e.g. probe) only initialize when relevant. + self._pages: dict[str, WizardPage] = {} + + # History stack — supports the Back button. + self._history: list[str] = [] + + # Steps we deliberately won't re-enter when the user presses + # Back — populated as the user proceeds (e.g. "controller" gets + # added when the user picks a known profile or import). + self._skipped_steps_set: set = set() + + self._build_buttons(self._main_box) + + # Initial state: profile page is step 1. + self._navigate_to("profile", record_history=False) + + # ----- public API ---------------------------------------------------- + + def show_error(self, heading: str, body: str) -> None: + """Convenience: surface a transient error to the user.""" + toast = Adw.Toast.new(f"{heading}: {body}" if body else heading) + toast.set_timeout(5) + self.toast_overlay.add_toast(toast) + + # ----- footer ------------------------------------------------------- + + def _build_buttons(self, main_box: Gtk.Box) -> None: + self._button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + halign=Gtk.Align.END, + margin_top=12, + ) + # Like the camera calibration wizard, the button bar lives + # inside the main box, directly under the stack, so it shares + # the content margins. + main_box.append(self._button_box) + + self.back_btn = Gtk.Button(label=_("Back")) + self.back_btn.add_css_class("flat") + self.back_btn.connect("clicked", self._on_back_clicked) + self.back_btn.set_visible(False) + self._button_box.append(self.back_btn) + + self.cancel_btn = Gtk.Button(label=_("Cancel")) + self.cancel_btn.add_css_class("flat") + self.cancel_btn.connect("clicked", lambda _: self.close()) + self._button_box.append(self.cancel_btn) + + self.skip_btn = Gtk.Button(label=_("Skip")) + self.skip_btn.add_css_class("flat") + self.skip_btn.connect("clicked", self._on_skip_clicked) + self.skip_btn.set_visible(False) + self._button_box.append(self.skip_btn) + + # Slot for page-specific action buttons (e.g. "Probe Now", + # "Look Up Specs"). Repopulated per page in _update_footer(). + self._action_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + ) + self._button_box.append(self._action_box) + self._footer_action_buttons: list[Gtk.Button] = [] + + self.next_btn = Gtk.Button(label=_("Next")) + self.next_btn.add_css_class("suggested-action") + self.next_btn.connect("clicked", self._on_next_clicked) + self._button_box.append(self.next_btn) + + self.create_btn = Gtk.Button(label=_("Create Machine")) + self.create_btn.add_css_class("suggested-action") + self.create_btn.connect("clicked", self._on_create_clicked) + self.create_btn.set_visible(False) + self._button_box.append(self.create_btn) + + # ----- lazy page access --------------------------------------------- + + def _get_page(self, name: str) -> WizardPage | None: + # Step 1 traverses the wizard in declared order — only "next", + # "back", "skip", and the adaptive router call this. + if name == "profile": + cls = ProfilePage + elif name == "controller": + cls = ControllerPage + elif name == "connect": + cls = ConnectionPage + elif name == "probe": + cls = ProbePage + elif name == "ai_provider": + cls = AIProviderPage + elif name == "ai_lookup": + cls = AILookupPage + elif name == "hardware": + cls = HardwarePage + elif name == "head": + cls = HeadPage + elif name == "rotary": + cls = RotaryPage + elif name == "camera": + cls = CameraPage + elif name == "review": + cls = ReviewPage + else: + return None + + if name in self._pages: + return self._pages[name] + + try: + page = cls(self) + page.ready_changed.connect(self._on_ready_changed) + self._pages[name] = page + self.stack.add_named(page, name) + + # Wire page-specific signals + self._wire_page_signals(page, name) + + return page + except Exception: + logger.exception("Failed to build page %s", name) + return None + + def _wire_page_signals(self, page: WizardPage, name: str) -> None: + if isinstance(page, ProfilePage): + page.source_selected.connect(self._on_profile_source_selected) + elif isinstance(page, ControllerPage): + page.controller_selected.connect(self._on_controller_selected) + elif isinstance(page, ProbePage): + page.probe_succeeded.connect(self._on_probe_succeeded) + + # ----- navigation ---------------------------------------------------- + + def _navigate_to(self, name: str, *, record_history: bool = True) -> None: + if record_history: + previous = self.stack.get_visible_child_name() + if previous is not None: + self._history.append(previous) + + page = self._get_page(name) + if page is None: + logger.error("Unknown wizard step: %s", name) + return + + page.enter(self.profile) + self.stack.set_visible_child_name(name) + self._update_footer(name, page) + + def _on_page_changed(self, _stack, _param) -> None: + name = self.stack.get_visible_child_name() + if not name: + return + self._update_footer(name, self._get_page(name)) + + def _update_footer(self, name: str, page: WizardPage | None) -> None: + if page is None: + return + + # Repopulate the page-action slot so every interactive + # affordance lives on the button bar. + for btn in self._footer_action_buttons: + self._action_box.remove(btn) + self._footer_action_buttons.clear() + for btn in page.footer_buttons(): + self._action_box.append(btn) + self._footer_action_buttons.append(btn) + + # Header title reflects the current step. + self.set_title(page.title or _("Add a Machine")) + + # Back button visible when there's history. + self.back_btn.set_visible(bool(self._history)) + + # Skip button is only meaningful on optional steps where it is + # semantically distinct from Next: the AI provider page (skip = + # decline AI), rotary, and camera. The probe and AI lookup pages + # are always-ready, so Skip there would duplicate Next. + if name in ("ai_provider", "rotary", "camera"): + self.skip_btn.set_visible(True) + else: + self.skip_btn.set_visible(False) + + # Next vs Create: Create replaces Next on the final step + # ("review"). Pages that opt out of the generic Next (e.g. + # Step 1, which advances via explicit source selection) hide + # it entirely so no dead buttons sit on the footer. + self.next_btn.set_visible(name != "review" and page.next_on_footer) + self.create_btn.set_visible(name == "review") + + # Next sensitivity follows page readiness. + self.next_btn.set_sensitive(bool(page.ready)) + self.create_btn.set_sensitive(True) + + def _on_ready_changed(self, page: WizardPage, **kwargs) -> None: + if page is not self.stack.get_visible_child(): + return + name = self.stack.get_visible_child_name() + if name == "review": + self.create_btn.set_sensitive(True) + else: + self.next_btn.set_sensitive(page.ready) + + # ----- footer button handlers -------------------------------------- + + def _on_next_clicked(self, _btn: Gtk.Button) -> None: + name = self.stack.get_visible_child_name() + if name is None: + return + page = self._get_page(name) + if page is None or not page.apply_to_profile(self.profile): + return + if name == "camera": + self._after_camera_next() + return + next_step = self._next_step_after(name) + if next_step is None: + return + self._navigate_to(next_step) + + def _on_skip_clicked(self, _btn: Gtk.Button) -> None: + name = self.stack.get_visible_child_name() + if name is None: + return + next_step = self._next_step_after(name) + if next_step is None: + return + self._navigate_to(next_step) + + def _on_back_clicked(self, _btn: Gtk.Button | None) -> None: + if not self._history: + return + prev = self._history.pop() + # Re-enter previous page; historical loads don't push the + # still-current page onto history again. + page = self._get_page(prev) + if page is None: + return + page.enter(self.profile) + self.stack.set_visible_child_name(prev) + self.back_btn.set_visible(bool(self._history)) + + def _on_create_clicked(self, _btn: Gtk.Button) -> None: + page = self._get_page("review") + if page is not None and not page.apply_to_profile(self.profile): + return + try: + machine = self._materialize_machine() + except Exception as exc: + logger.exception("Failed to create machine") + self.show_error(_("Could not create machine"), str(exc)) + return + self.profile_created.send(self, profile=self.profile, machine=machine) + self.close() + + def _materialize_machine(self): + context = get_context() + machine = self.profile.create_machine(context) + + # Apply session-only aux_state on the live machine. + reverse = self.aux_state.get("reverse", {}) + if reverse.get("x"): + machine.set_reverse_x_axis(True) + if reverse.get("y"): + machine.set_reverse_y_axis(True) + if reverse.get("z"): + machine.set_reverse_z_axis(True) + return machine + + # ----- adaptive routing -------------------------------------------- + + def _source_kind(self) -> str | None: + return self._source.get("kind") if self._source else None + + def _ai_entry_step(self) -> str: + """Where the wizard enters the AI flow after probing/connection. + + A known profile or import already carries the machine specs, + so the AI provider / lookup steps are skipped entirely. A known + *profile* also trusts the work-area and head specs, so the + hardware and head steps are skipped too (the user still adds + rotary modules / cameras). Imports are not 100% reliable, so + the user is walked through hardware and head with prefilled + values they can correct. For "Other / unknown machine", the + user is first asked on the provider page when none is + configured; otherwise the lookup page comes up directly. + """ + kind = self._source_kind() + if kind == "profile": + self._skipped_steps_set.update( + {"ai_provider", "ai_lookup", "hardware", "head"} + ) + return "rotary" + if kind == "import": + self._skipped_steps_set.update({"ai_provider", "ai_lookup"}) + return "hardware" + return "ai_lookup" if is_ai_configured() else "ai_provider" + + def _next_step_after(self, name: str) -> str | None: + """Decides the next step using the adaptive routing rules.""" + mc = self.profile.machine_config + + if name == "profile": + # Routing is set by source_selected signal. If we got here + # via the plain Next button on the profile page (shouldn't + # normally happen since the page emits source_selected), + # fall through to controller. + return "controller" + + if name == "controller": + # `None` controller skips Steps 3 & 4 entirely. + if not mc.driver: + self._skipped_steps_set.update({"connect", "probe"}) + return self._ai_entry_step() + return "connect" + + if name == "connect": + # A known profile already carries trusted specs, so skip + # the auto-discovery probe entirely. Imports are not fully + # reliable, so the user may still probe (when the driver + # supports it) to verify/correct the imported values. + if self._source_kind() == "profile": + self._skipped_steps_set.update({"probe"}) + return self._ai_entry_step() + # Probe page only if driver supports probing. + driver_cls = None + if mc.driver: + driver_cls = get_driver_cls(mc.driver) + # The NoDeviceDriver fallback means "None — G-code + # export only"; there is nothing to probe with. + if driver_cls is NoDeviceDriver: + driver_cls = None + if driver_cls is not None and driver_cls.supports_probing: + return "probe" + self._skipped_steps_set.update({"probe"}) + return self._ai_entry_step() + + if name == "probe": + return self._ai_entry_step() + + if name == "ai_provider": + # Next means the provider was configured; Skip means the + # user declined AI, so skip the lookup page entirely. + return "ai_lookup" if is_ai_configured() else "hardware" + + if name == "ai_lookup": + return "hardware" + + if name == "hardware": + return "head" + + if name == "head": + return "rotary" + + if name == "rotary": + return "camera" + + if name == "camera": + return "review" + + return None + + # ----- page-specific signal handlers -------------------------------- + + def _on_controller_selected(self, sender, *, driver: str | None) -> None: + """Step 2: the user picked a controller tile — advance at once.""" + self.profile.machine_config.driver = driver + next_step = self._next_step_after("controller") + if next_step is None: + return + self._navigate_to(next_step) + + def _on_profile_source_selected( + self, sender, *, kind: str, profile: DeviceProfile | None + ) -> None: + """Step 1: the user picked a starting point.""" + if kind == "other": + # Start fresh; the controller page takes over. + self.profile = empty_profile() + self.aux_state = {} + self._source = None + elif kind in ("profile", "import") and profile is not None: + # Adopt the picked profile's data as our working state; we + # still require Step 3 (Connection) per design decision #5. + self.profile = self._clone_profile(profile) + self.aux_state = {} + # Stash chosen source for later sanity feedback + self._source = {"kind": kind, "profile": profile} + else: + self.profile = empty_profile() + self.aux_state = {} + self._source = None + + # Step 1 → Step 3 for known/import, Step 1 → Step 2 for "Other". + # Navigate with history recording so "profile" stays on the + # stack: the user can press Back to change their source choice. + target: str + if kind == "other": + target = "controller" + else: + # Jump straight to connection (the picked profile fixes the + # controller); Back returns to the profile picker. + self._skipped_steps_set.add("controller") + target = "connect" + self._navigate_to(target) + + def _on_probe_succeeded( + self, sender, *, profile: DeviceProfile, warnings: list[str] + ) -> None: + """Step 4: the probe merged values into a working profile.""" + # Merge probed machine_config fields into our working profile. + # Probe returns a full DeviceProfile; we overlay every + # non-None field of its machine_config onto our profile. + src = profile.machine_config + dst = self.profile.machine_config + for field_name in ( + "driver_args", + "driver_config", + "axis_extents", + "origin", + "max_travel_speed", + "max_cut_speed", + "acceleration", + "single_axis_homing_enabled", + "home_on_start", + "heads", + "unit_system", + ): + value = getattr(src, field_name, None) + if value is not None: + setattr(dst, field_name, value) + for text in warnings: + logger.info("Probing warning: %s", text) + + # ----- camera workflow ---------------------------------------------- + + def _after_camera_next(self) -> None: + """Step 10: when cameras were enabled, route into the camera + workflow (lens calibration) for the first enabled device before + landing on Review.""" + page = self._get_page("camera") + enabled = ( + page.selected_device_ids() if isinstance(page, CameraPage) else [] + ) + self._navigate_to("review") + if not enabled: + return + if not self._launch_camera_workflow(enabled[0]): + self.show_error( + _("Camera setup unavailable"), + _( + "Calibrate this camera later from the machine " + "settings page." + ), + ) + + def _launch_camera_workflow(self, device_id: str) -> bool: + """Present the camera workflow dialog for *device_id*. + + Returns False when the camera backend is unavailable so the + caller can fall back gracefully. + """ + try: + config = Camera(name=display_name(device_id), device_id=device_id) + controller = CameraController(config) + dialog = CameraWizard(self, controller) + except Exception: + logger.exception("Failed to start camera workflow") + return False + dialog.present() + return True + + # ----- helpers ------------------------------------------------------- + + def _clone_profile(self, src: DeviceProfile) -> DeviceProfile: + """Adopt an existing profile's data into our working copy.""" + return DeviceProfile( + meta=_clone_meta(src), + machine_config=_clone_machine_config(src.machine_config), + dialect_config=dict(src.dialect_config), + source_dir=src.source_dir, + ) + + def close(self): + super().close() + + def do_close_request(self, *args) -> bool: + return False + + +def _clone_meta(src) -> Any: + return DeviceMeta( + name=src.name, + vendor=src.meta.vendor, + model=src.meta.model, + description=src.meta.description, + api_version=src.meta.api_version, + ) + + +def _clone_machine_config(src) -> Any: + """Deep-copy a MachineConfig into a new mutable instance.""" + out = MachineConfig() + for attr in ( + "driver", + "driver_args", + "driver_config", + "gcode_precision", + "supports_arcs", + "supports_curves", + "axis_extents", + "work_margins", + "soft_limits", + "origin", + "max_travel_speed", + "max_cut_speed", + "home_on_start", + "acceleration", + "single_axis_homing_enabled", + "rotary_enabled_default", + "unit_system", + "heads", + "capabilities", + "hookmacros", + "rotary_modules", + "nogo_zones", + "cameras", + ): + value = getattr(src, attr, None) + if isinstance(value, dict): + value = dict(value) + elif isinstance(value, list) and ( + attr + in ( + "heads", + "hookmacros", + "rotary_modules", + "nogo_zones", + "cameras", + "capabilities", + "driver_args", + ) + ): + # Lists of dicts and tuples need to be copied with the + # inner structures preserved too. + value = _copy_list_of_containers(value) + setattr(out, attr, value) + return out + + +def _copy_list_of_containers(value: list) -> list: + """Shallow-but-not-too-shallow copy of a list of containers.""" + out = [] + for item in value: + if isinstance(item, dict): + out.append(dict(item)) + elif isinstance(item, list): + out.append(_copy_list_of_containers(item)) + elif isinstance(item, tuple): + out.append(tuple(item)) + else: + out.append(item) + return out + + +__all__ = ["_STEP_ORDER", "UnifiedWizard"] diff --git a/rayforge/ui_gtk/machine/wcs_dialog.py b/rayforge/ui_gtk/machine/wcs_dialog.py new file mode 100644 index 000000000..ac8918704 --- /dev/null +++ b/rayforge/ui_gtk/machine/wcs_dialog.py @@ -0,0 +1,72 @@ +from gettext import gettext as _ + +from gi.repository import Adw + +from ...machine.models.machine import Machine +from ...shared.tasker import task_mgr +from ..shared.pref_rows.length_spin_row import LengthSpinRow + + +class WcsDialog(Adw.MessageDialog): + def __init__(self, machine: Machine, **kwargs): + super().__init__( + heading=_("Edit Work Offsets"), + body=_( + "Enter the offset from Machine Zero to Work Zero for " + "the active WCS." + ), + **kwargs, + ) + self.machine = machine + self.add_response("cancel", _("Cancel")) + self.add_response("save", _("Save")) + self.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) + self.set_default_response("save") + self.set_close_response("cancel") + + off_x, off_y, off_z = machine.get_active_wcs_offset() + wcs_label = machine.get_wcs_label(machine.active_wcs) + + group = Adw.PreferencesGroup() + + self._label_row = Adw.EntryRow(title=_("Label"), text=wcs_label) + group.add(self._label_row) + + self._row_x = LengthSpinRow( + _("X Offset"), + lower=-10000, + upper=10000, + value_in_base=off_x, + ) + group.add(self._row_x) + + self._row_y = LengthSpinRow( + _("Y Offset"), + lower=-10000, + upper=10000, + value_in_base=off_y, + ) + group.add(self._row_y) + + self._row_z = LengthSpinRow( + _("Z Offset"), + lower=-10000, + upper=10000, + value_in_base=off_z, + ) + group.add(self._row_z) + + self.set_extra_child(group) + + self.connect("response", self._on_response) + + def _on_response(self, dlg, response): + if response == "save": + label = self._label_row.get_text() + nx = self._row_x.get_value_in_base_units() + ny = self._row_y.get_value_in_base_units() + nz = self._row_z.get_value_in_base_units() + self.machine.set_wcs_label(self.machine.active_wcs, label) + task_mgr.add_coroutine( + lambda ctx: self.machine.set_work_origin(nx, ny, nz) + ) diff --git a/rayforge/ui_gtk/machine/wizard_pages/__init__.py b/rayforge/ui_gtk/machine/wizard_pages/__init__.py new file mode 100644 index 000000000..c22496deb --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/__init__.py @@ -0,0 +1,151 @@ +"""Per-step pages for the Unified Machine Configuration Wizard. + +Each page is a self-contained widget responsible for a single stage of +the wizard (Step 1: profile pick, Step 2: controller choice, Step 3: +connection, etc.). Pages communicate back to the orchestrator +(:class:`~rayforge.ui_gtk.machine.unified_wizard.UnifiedWizard`) via +the ``ready`` flag and ``apply_to_profile`` mechanism rather than +emitting signals directly, so they remain testable in isolation. + +Pages never mutate the working ``DeviceProfile`` themselves — they +hand back a partial dict / call :meth:`Step.apply_to_profile` so the +orchestrator keeps single-source-of-truth state and can re-route +later steps adaptively. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ....machine.device.profile import DeviceMeta, DeviceProfile, MachineConfig + +if TYPE_CHECKING: + from ..unified_wizard import UnifiedWizard + + +class WizardPage(Adw.Bin): + """Base class for all wizard step pages. + + Subclasses MUST override :meth:`build_ui` to populate the page, + and SHOULD override :meth:`enter` (called each time the orchestrator + shows the page) and :meth:`apply_to_profile` (called before + navigating away, e.g. on "Next"). + + The base class wires up a vertical scrolled container with the + standard 24px page margins so subclasses only need to append + content to ``self.content``. + """ + + # Step number (1..10) for diagnostics / navigation. Subclasses + # MUST set this. + step_number: int = 0 + # Short human-readable title shown in the wizard header. + title: str = "" + # Optional subtitle / descriptive blurb. + subtitle: str = "" + # When False the wizard hides the "Next" button on this page: the + # page advances only through its own explicit affordances (e.g. + # Step 1, which fires source_selected on row activation). + next_on_footer: bool = True + + def __init__(self, wizard: "UnifiedWizard", **kwargs): + super().__init__(**kwargs) + self.wizard = wizard + self.ready: bool = False + # Fires with ``ready=True/False`` whenever the page activates + # or deactivates its Next/Create affordance. The orchestrator + # connects to this to drive the footer button sensitivity. + self.ready_changed = Signal() + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_vexpand(True) + scrolled.set_hexpand(True) + self.set_child(scrolled) + + self.content = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=12, + margin_top=24, + margin_bottom=24, + margin_start=24, + margin_end=24, + ) + scrolled.set_child(self.content) + self.build_ui() + + # ----- subclass hooks ------------------------------------------------- + + def build_ui(self) -> None: + """Populate ``self.content`` with the page's widgets.""" + + def enter(self, profile: DeviceProfile) -> None: + """Called each time the orchestrator switches to this page. + + Subclasses can prefill widgets from *profile* (and metadata + stored alongside it on the wizard, like ``wizard.aux_state``). + Default implementation does nothing. + """ + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + """Push this page's UI values into *profile*. + + Returns ``True`` if the orchestrator may advance to the next + step, ``False`` to abort the navigation (e.g. on a validation + error). Subclasses should keep this idempotent and cheap; the + orchestrator may call it redundantly to refresh state. + Default implementation returns ``True``. + """ + return True + + def footer_buttons(self) -> list[Gtk.Button]: + """Action buttons this page wants in the wizard's footer bar. + + Pages construct their buttons once in :meth:`build_ui` and + return the references here. The orchestrator places them in + the footer's action slot each time the page is shown, keeping + every interactive affordance on the button bar (the camera + calibration wizard layout). Default returns no buttons. + """ + return [] + + # ----- helpers --------------------------------------------------------- + + def set_ready(self, ready: bool) -> None: + """Signal that the page's "Next"/"Create" affordance can activate.""" + if ready == self.ready: + return + self.ready = ready + self.ready_changed.send(self, ready=ready) + + +def empty_profile() -> DeviceProfile: + """A blank working profile used as the wizard's initial state.""" + return DeviceProfile( + meta=DeviceMeta(name=_("New Machine")), + machine_config=MachineConfig(), + dialect_config={}, + source_dir=None, + ) + + +def _makePreferencesGroup( + title: str | None = None, + description: str | None = None, +) -> Adw.PreferencesGroup: + """Helper constructor that omits None titles entirely.""" + kwargs: dict[str, Any] = {} + if title: + kwargs["title"] = title + if description: + kwargs["description"] = description + return Adw.PreferencesGroup(**kwargs) + + +__all__ = [ + "WizardPage", + "_makePreferencesGroup", + "empty_profile", +] diff --git a/rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py b/rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py new file mode 100644 index 000000000..23add463d --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/ai_lookup_page.py @@ -0,0 +1,454 @@ +"""Step 6 — AI spec lookup. + +Queries the configured AI provider (default OpenAI-compatible +provider via :func:`~rayforge.core.ai.spec_lookup.lookup_machine_specs`) +by ``vendor + model`` and surfaces the returned fields as suggestion +rows the user can accept, edit, or reject: + +* **AI not configured** → friendly "Configure AI in Settings…" link; + the page auto-skips after the user dismisses the prompt. +* **AI returns a field** → shows as a switch row with the + LLM-proposed value; each row starts switched on (accepted) and the + user toggles off anything they don't want. Toggling on/off is the + explicit accept/reject affordance. +* **Lookup errors / no parseable JSON** → degrades gracefully and + shows an informational banner; the user moves on. + +While a lookup runs, a thin pulsing progress bar (mirroring the AI +workpiece generator dialog) gives the user live feedback. +""" + +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from blinker import Signal +from gi.repository import Adw, GLib, GObject, Gtk + +from ....context import get_context +from ....core.ai.spec_lookup import is_ai_configured, lookup_machine_specs +from ....machine.device.profile import DeviceProfile, MachineConfig +from ....machine.models.machine import Origin +from ....shared.tasker import Task, task_mgr +from ....shared.tasker.context import ExecutionContext +from . import WizardPage, _makePreferencesGroup + +if TYPE_CHECKING: + from ..unified_wizard import UnifiedWizard + +# Fields the AI may return and the wizard knows how to merge. Each +# entry maps the JSON key to (target_machine_config_attr, label, +# kind). ``kind`` is one of: +# "tuple2" — pair of floats (axis_extents, spot_size_mm) +# "int" — integer scalar +# "bool" — boolean +# "string" — string scalar (origin) +# "head_laser" — head field, laser family +# "head_spindle" — head field, spindle family +_FIELD_SPEC: list[tuple[str, str, str]] = [ + ("axis_extents", _("Work area (X, Y)"), "tuple2_mm"), + ("max_travel_speed", _("Max travel speed"), "int"), + ("max_cut_speed", _("Max cut speed"), "int"), + ("acceleration", _("Acceleration"), "int"), + ("origin", _("Coordinate origin"), "string"), + ("head_type", _("Head type"), "head_kind"), + ("max_power", _("Head max power (S-value)"), "head_laser"), + ("max_rpm", _("Head max RPM"), "head_spindle"), + ("min_rpm", _("Head min RPM"), "head_spindle"), + ("spot_size_mm", _("Spot size (X, Y)"), "head_laser_tuple2"), + ("pwm_frequency", _("PWM frequency (Hz)"), "head_laser"), + ("focal_distance", _("Focal distance"), "head_laser"), + ("home_on_start", _("Home on start"), "bool"), +] + + +def _format_value(value: Any, kind: str) -> str: + if value is None: + return "" + if kind == "tuple2_mm": + a, b = value + return f"{a} × {b}" + if kind == "head_laser_tuple2": + a, b = value + return f"{a} × {b}" + if isinstance(value, bool): + return _("Yes") if value else _("No") + return str(value) + + +class AILookupPage(WizardPage): + step_number = 6 + title = _("AI Spec Lookup") + subtitle = _( + "If your machine is a known commercial model, the AI can " + "pre-fill specification values from the manufacturer's " + "documentation." + ) + + # Sent once the user advances with accepted suggestions. Payload + # is the dict of accepted suggestions. + def __init__(self, wizard: "UnifiedWizard", **kwargs: Any) -> None: + self.suggestions_applied = Signal() + self._pulse_source_id = None + self._progress_bar: Gtk.ProgressBar | None = None + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + self._install_progress_bar() + + self.group = _makePreferencesGroup( + title=_("Vendor & Model"), + description=_( + "Enter the machine's vendor (manufacturer) and model " + "name. The more specific, the better — e.g. " + '"Sculpfun" / "S30 Pro".' + ), + ) + self.content.append(self.group) + + self.vendor_row = Adw.EntryRow(title=_("Vendor (e.g. Sculpfun)")) + self.group.add(self.vendor_row) + + self.model_row = Adw.EntryRow(title=_("Model (e.g. S30 Pro)")) + self.model_row.connect("notify::text", self._on_inputs_changed) + self.vendor_row.connect("notify::text", self._on_inputs_changed) + self.group.add(self.model_row) + + self.lookup_button = Gtk.Button(label=_("Look Up Specs")) + self.lookup_button.add_css_class("suggested-action") + self.lookup_button.connect("clicked", lambda *_: self._run_lookup()) + self.lookup_button.set_sensitive(False) + + self.banner = Adw.Bin() + self.content.append(self.banner) + + self.suggestions_group = _makePreferencesGroup( + title=_("Suggestions"), + description=_( + "Suggested values are switched on; turn off any you " + "don't want applied." + ), + ) + self.suggestions_group.set_visible(False) + self.content.append(self.suggestions_group) + + self._rows: list[Adw.SwitchRow] = [] + self._accepted: dict[str, Any] = {} + + # If AI isn't configured, surface a friendly message instead. + if not is_ai_configured(): + self._show_not_configured_banner() + else: + self.banner.set_child(None) + + # Always ready: the user can skip even when no AI is set. + self.set_ready(True) + + def _install_progress_bar(self) -> None: + """Pin a thin pulse bar under the scrollable content, mirroring + the AI workpiece generator dialog's busy affordance.""" + scrolled = self.get_child() + if scrolled is None: + return + scrolled.set_vexpand(True) + # Unparent the scrolled window first: re-appending a widget + # that is still the page's child would fail with a GTK + # "child already has a parent" critical and orphan the content. + self.set_child(None) + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + outer.append(scrolled) + self._progress_bar = Gtk.ProgressBar(hexpand=True) + self._progress_bar.add_css_class("thin-progress-bar") + self._progress_bar.set_visible(False) + outer.append(self._progress_bar) + self.set_child(outer) + + provider = Gtk.CssProvider() + provider.load_from_string( + """ + progressbar.thin-progress-bar { + min-height: 5px; + } + """ + ) + Gtk.StyleContext.add_provider_for_display( + self.get_display(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _start_pulse(self) -> None: + if self._progress_bar is None: + return + self._progress_bar.set_visible(True) + self._progress_bar.pulse() + self._pulse_source_id = GLib.timeout_add(100, self._on_pulse_timeout) + + def _stop_pulse(self) -> None: + if self._pulse_source_id: + GLib.source_remove(self._pulse_source_id) + self._pulse_source_id = None + if self._progress_bar is None: + return + self._progress_bar.set_visible(False) + + def _on_pulse_timeout(self) -> bool: + if self._progress_bar is not None: + self._progress_bar.pulse() + return True + + def _show_not_configured_banner(self) -> None: + box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + ) + box.add_css_class("card") + box.set_margin_top(6) + label = Gtk.Label( + label=_( + "No AI provider is configured in Settings. " + "Configure one to enable automatic spec lookup, " + "or skip this step and enter the values by hand." + ), + wrap=True, + xalign=0.0, + hexpand=True, + ) + label.set_margin_start(12) + label.set_margin_end(12) + label.set_margin_top(12) + label.set_margin_bottom(12) + box.append(label) + self.banner.set_child(box) + self.lookup_button.set_sensitive(False) + + def _on_inputs_changed( + self, _entry: Adw.EntryRow | None, _param: GObject.ParamSpec | None + ) -> None: + has_input = bool(self.vendor_row.get_text()) or bool( + self.model_row.get_text() + ) + if is_ai_configured() and has_input: + self.lookup_button.set_sensitive(True) + else: + self.lookup_button.set_sensitive(False) + + def enter(self, profile: DeviceProfile) -> None: + # If we already have vendor/model metadata from a profile + # selection, prefill. + meta = profile.meta + if meta.vendor: + self.vendor_row.set_text(meta.vendor) + if meta.model: + self.model_row.set_text(meta.model) + self._on_inputs_changed(None, None) + # The page never auto-advances; the user must click "Next" / + # "Skip" via the wizard footer. + + def footer_buttons(self) -> list[Gtk.Button]: + return [self.lookup_button] + + # ----- async lookup -------------------------------------------------- + + def _run_lookup(self) -> None: + vendor = self.vendor_row.get_text().strip() + model = self.model_row.get_text().strip() + if not (vendor or model): + return + self.lookup_button.set_sensitive(False) + self.lookup_button.set_label(_("Looking up…")) + self._start_pulse() + + context = get_context() + + async def _coro(exec_ctx: ExecutionContext) -> dict[str, Any]: + return { + "specs": await lookup_machine_specs(vendor, model, context), + "vendor": vendor, + "model": model, + } + + task_mgr.add_coroutine( + _coro, + key="unified_wizard_ai_lookup", + when_done=self._on_lookup_done, + ) + + def _on_lookup_done(self, task: Task) -> None: + def _update(): + self._stop_pulse() + self.lookup_button.set_sensitive(True) + self.lookup_button.set_label(_("Look Up Specs")) + try: + result = task.result() + except Exception as exc: # noqa: BLE001 - async task boundary + self._show_lookup_error(str(exc)) + return + if task.get_status() != "completed": + self._show_lookup_error(_("Lookup failed")) + return + specs: dict[str, Any] = result.get("specs", {}) + if not specs: + self._show_lookup_error( + _( + "The AI couldn't return specifications for " + "this machine. You can enter the values " + "manually in the next steps." + ) + ) + return + self._render_suggestions(specs) + self.suggestions_group.set_visible(True) + + task_mgr.schedule_on_main_thread(_update) + + def _show_lookup_error(self, message: str) -> None: + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + box.add_css_class("card") + label = Gtk.Label(label=message, wrap=True, xalign=0.0, hexpand=True) + label.set_margin_start(12) + label.set_margin_end(12) + label.set_margin_top(12) + label.set_margin_bottom(12) + box.append(label) + self.banner.set_child(box) + + def _render_suggestions(self, specs: dict[str, Any]) -> None: + for row in self._rows: + self.suggestions_group.remove(row) + self._rows.clear() + self._accepted.clear() + + for key, label, kind in _FIELD_SPEC: + if key not in specs: + continue + value = specs[key] + if value is None: + continue + row = Adw.SwitchRow( + title=label, + subtitle=_("AI suggests: {value}").format( + value=_format_value(value, kind) + ), + ) + # Suggested values start accepted; the user toggles any off. + row.set_active(True) + self._accepted[key] = value + row.connect( + "notify::active", self._make_toggle_handler(key, value) + ) + self.suggestions_group.add(row) + self._rows.append(row) + + def _make_toggle_handler( + self, key: str, value: Any + ) -> Callable[[Adw.SwitchRow, GObject.ParamSpec], None]: + """Return a notify::active handler bound to this suggestion. + + ``notify::active`` delivers ``(switch, pspec)``, so plain + lambda defaults (``k=key``) would be clobbered by the pspec + argument; the nested function pins the captured values + instead. + """ + + def _on_active_changed( + switch: Adw.SwitchRow, _pspec: GObject.ParamSpec + ) -> None: + self._on_suggestion_toggled(key, value, switch.get_active()) + + return _on_active_changed + + def _on_suggestion_toggled( + self, key: str, value: Any, active: bool + ) -> None: + if active: + self._accepted[key] = value + else: + self._accepted.pop(key, None) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + meta = profile.meta + vendor = self.vendor_row.get_text().strip() + model = self.model_row.get_text().strip() + if vendor and not meta.vendor: + meta.vendor = vendor + if model and not meta.model: + meta.model = model + + if not self._accepted: + return True + + mc = profile.machine_config + for key, value in self._accepted.items(): + if key == "axis_extents": + try: + a, b = value + mc.axis_extents = (float(a), float(b)) + except (TypeError, ValueError): + continue + elif key == "max_travel_speed": + mc.max_travel_speed = int(value) + elif key == "max_cut_speed": + mc.max_cut_speed = int(value) + elif key == "acceleration": + mc.acceleration = int(value) + elif key == "origin": + try: + mc.origin = Origin(str(value).lower()) + except ValueError: + continue + elif key == "home_on_start": + mc.home_on_start = bool(value) + elif key in ( + "head_type", + "max_power", + "max_rpm", + "min_rpm", + "spot_size_mm", + "pwm_frequency", + "focal_distance", + ): + self._merge_head_field(mc, key, value) + + self.suggestions_applied.send(self, accepted=self._accepted) + return True + + # ----- head merge ---------------------------------------------------- + + def _merge_head_field( + self, mc: MachineConfig, key: str, value: Any + ) -> None: + head = mc.heads[0] if mc.heads else {} + + if key == "head_type": + kind = str(value).lower() + if "spindle" in kind: + head["head_class"] = "SpindleHead" + else: + head["head_class"] = "LaserHead" + head.setdefault("name", _("Main Head")) + elif key == "max_power": + head.setdefault("name", _("Main Head")) + head["max_power"] = int(value) + elif key == "max_rpm": + head["max_rpm"] = int(value) + elif key == "min_rpm": + head["min_rpm"] = int(value) + elif key == "spot_size_mm": + try: + a, b = value + head["spot_size_mm"] = [float(a), float(b)] + except (TypeError, ValueError): + pass + elif key == "pwm_frequency": + head["pwm_frequency"] = int(value) + elif key == "focal_distance": + head["focal_distance"] = float(value) + + if mc.heads: + mc.heads[0] = head + else: + mc.heads = [head] + + +__all__ = ["AILookupPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/camera_page.py b/rayforge/ui_gtk/machine/wizard_pages/camera_page.py new file mode 100644 index 000000000..4972bfd02 --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/camera_page.py @@ -0,0 +1,125 @@ +"""Step 10 — Camera setup (optional). + +Collects which V4L devices the user wants attached to this machine +and launches the per-device camera wizard for the full setup +(detection, image settings, lens calibration, world alignment): + +* Detect V4L devices +* Pick a camera + resolution +* Image settings (WB, brightness, contrast, denoise, transparency) +* Calibrate lens (Charuco frames, OpenCV solvePnP, de-distortion) — + optional +* Align image ↔ world point pairs + +Here we only collect which V4L devices the user wants enabled; the +detailed per-device setup runs on demand. If the user opts out, no +`cameras` entry is written and the existing machine-level default +applies. +""" + +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw + +from ....camera.models.camera import Camera +from ....camera.v4l import display_name, get_sorted_by_id_paths +from ....machine.device.profile import DeviceProfile +from . import WizardPage, _makePreferencesGroup + + +class CameraPage(WizardPage): + step_number = 10 + title = _("Cameras") + subtitle = _( + "Optional. Configure any cameras you want to use for " + "preview and alignment." + ) + + def __init__(self, wizard, **kwargs): + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + self.cameras_group = _makePreferencesGroup( + title=_("Cameras"), + description=_( + "Set up cameras now or do it later from machine " + "settings. The wizard records which V4L devices you " + "mark as 'enabled'; detailed lens calibration is " + "performed on the camera settings page." + ), + ) + self.content.append(self.cameras_group) + + # Each row is a 2-tuple of (substr_for_by_id_path, switch_row). + self._device_id_for_row: dict[int, str] = {} + # Holds both real SwitchRows and the single empty-state + # ActionRow shown when no cameras are detected. + self._switch_rows: list[Adw.PreferencesRow] = [] + # Detection is deferred to `enter()` so we re-scan each time + # the page is shown (USB cameras may have been plugged in + # since the wizard was opened). + + self.set_ready(True) + + def selected_device_ids(self) -> list[str]: + """Device IDs the user has enabled on this page.""" + ids: list[str] = [] + for row in self._switch_rows: + if not isinstance(row, Adw.SwitchRow): + continue + if not row.get_active(): + continue + by_id = self._device_id_for_row.get(id(row)) + if by_id: + ids.append(by_id) + return ids + + def enter(self, profile: DeviceProfile) -> None: + # Clear previous rows + for row in self._switch_rows: + self.cameras_group.remove(row) + self._switch_rows.clear() + self._device_id_for_row.clear() + + try: + by_id_paths = get_sorted_by_id_paths() + except OSError: + by_id_paths = [] + + if not by_id_paths: + empty = Adw.ActionRow( + title=_("No cameras detected"), + subtitle=_("You can add cameras later from machine settings."), + ) + self.cameras_group.add(empty) + self._switch_rows.append(empty) + return + + already_added = { + (c.get("device_id")) + for c in (profile.machine_config.cameras or []) + if c.get("device_id") + } + + for by_id in by_id_paths: + row = Adw.SwitchRow( + title=display_name(by_id), + subtitle=by_id, + ) + row.set_active(by_id in already_added) + self.cameras_group.add(row) + self._switch_rows.append(row) + self._device_id_for_row[id(row)] = by_id + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + selected: list[dict[str, Any]] = [] + for by_id in self.selected_device_ids(): + cam = Camera(name=display_name(by_id), device_id=by_id) + cam.enabled = True + selected.append(cam.to_dict()) + profile.machine_config.cameras = selected or None + return True + + +__all__ = ["CameraPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/connection_page.py b/rayforge/ui_gtk/machine/wizard_pages/connection_page.py new file mode 100644 index 000000000..7c4f8cb37 --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/connection_page.py @@ -0,0 +1,129 @@ +"""Step 3 — Connection parameters. + +Always required: even after picking a known profile or importing a +snapshot, the user must supply host-specific values like the USB +device path, IP address, hostname, or OctoPrint API key. The page +prefills any defaults the profile supplies and leaves the rest blank. + +Reuses :class:`~rayforge.ui_gtk.varset.varsetwidget.VarSetWidget` to +render the driver's ``get_setup_vars()`` definition, mirroring the +pattern in the legacy ``config_wizard.py``. +""" + +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ....machine.device.profile import DeviceProfile +from ....machine.driver import get_driver_cls +from ....machine.driver.driver import Driver +from ...varset.varsetwidget import VarSetWidget +from . import WizardPage, _makePreferencesGroup + + +class ConnectionPage(WizardPage): + step_number = 3 + title = _("Connection") + subtitle = _("Enter the connection parameters for your device.") + + def __init__(self, wizard, **kwargs): + self._driver_cls: type[Driver] | None = None + self._required_keys: set = set() + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + self.group = _makePreferencesGroup( + title=_("Connection"), + description=_( + "Enter the connection parameters your machine " + "requires. The exact fields depend on the controller " + "you chose in the previous step." + ), + ) + self.content.append(self.group) + + self.driver_row = Adw.ActionRow( + title=_("Driver"), + subtitle=_("Fixed by the chosen profile"), + ) + self.group.add(self.driver_row) + + self.connect_widget = VarSetWidget() + self.connect_widget.data_changed.connect(self._on_data_changed) + self.content.append(self.connect_widget) + + # Spacer to keep the surrounding layout from looking cramped. + self.content.append(Gtk.Box(vexpand=True)) + + def enter(self, profile: DeviceProfile) -> None: + """Repopulate the form from the working profile.""" + driver_name = profile.machine_config.driver + if not driver_name: + self.driver_row.set_title(_("Driver")) + self.driver_row.set_subtitle(_("None — G-code export only")) + self.connect_widget.clear_dynamic_rows() + self.set_ready(True) + self._driver_cls = None + return + + driver_cls = get_driver_cls(driver_name) + self._driver_cls = driver_cls + self.driver_row.set_title(driver_cls.label) + self.driver_row.set_subtitle(driver_cls.subtitle or "") + + var_set = driver_cls.get_setup_vars() + # Vars without a usable default are the host-specific values the + # user must supply (USB path, hostname, API key, …). The page + # stays unready until every one of them is filled. + self._required_keys = { + var.key for var in var_set if var.default in (None, "") + } + # If the working profile carries saved driver_args (e.g. via + # import), prefill the var set before rendering. + saved_args = profile.machine_config.driver_args or {} + if saved_args: + for var in var_set: + if saved_args.get(var.key): + var.value = saved_args[var.key] + self.connect_widget.populate(var_set) + self._refresh_ready() + + def _on_data_changed(self, sender, **kwargs) -> None: + self._refresh_ready() + + def _refresh_ready(self) -> None: + """Ready when there's no driver or all required vars are set.""" + if self._driver_cls is None: + self.set_ready(True) + return + try: + values = self.connect_widget.get_values() + except ValueError: + self.set_ready(False) + return + for key in self._required_keys: + if values.get(key) in (None, ""): + self.set_ready(False) + return + self.set_ready(True) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + if self._driver_cls is None: + profile.machine_config.driver_args = None + return True + try: + values = self.connect_widget.get_values() + except ValueError as exc: + self.wizard.show_error(_("Invalid input"), str(exc)) + return False + # Drop empty-string / None values so we don't blur defaults. + cleaned: dict = {} + for key, value in values.items(): + if value in (None, ""): + continue + cleaned[key] = value + profile.machine_config.driver_args = cleaned or None + return True + + +__all__ = ["ConnectionPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/controller_page.py b/rayforge/ui_gtk/machine/wizard_pages/controller_page.py new file mode 100644 index 000000000..a963506e0 --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/controller_page.py @@ -0,0 +1,200 @@ +"""Step 2 — Choose controller. + +Lists every available driver (GRBL, Ruida, Smoothieware, +OctoPrint, Marlin, plus the ``NoDeviceDriver`` affordance for +G-code-only export) as a grid of large icon buttons so the user +can pick the firmware / protocol family at a glance. There is no +default selection: the user must consciously choose a controller +(or "None") before the wizard will let them proceed. +""" + +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Gtk + +from ....machine.device.profile import DeviceProfile +from ....machine.driver import drivers +from ....machine.driver.driver import Driver +from ...icons import get_icon +from . import WizardPage, _makePreferencesGroup + +# Symbolic icon shown on each driver's tile. New drivers without an +# entry fall back to a generic device icon. +_DRIVER_ICONS: dict[str, str] = { + "GrblNetworkDriver": "network-wired-symbolic", + "GrblTelnetDriver": "network-wired-symbolic", + "RuidaDriver": "network-wired-symbolic", + "GrblSerialDriver": "drive-removable-media-symbolic", + "GrblSerialSimpleDriver": "drive-removable-media-symbolic", + "MarlinSerialDriver": "drive-removable-media-symbolic", + "OctoPrintDriver": "network-server-symbolic", + "SmoothieDriver": "computer-symbolic", +} +_FALLBACK_ICON = "drive-harddisk-symbolic" +_EXPORT_ONLY_ICON = "document-save-symbolic" + + +class ControllerPage(WizardPage): + step_number = 2 + title = _("Choose Controller") + subtitle = _("What kind of controller board does this machine use?") + + def __init__(self, wizard, **kwargs): + # Fired when the user picks a controller tile; the wizard + # applies the choice and advances immediately. + self.controller_selected = Signal() + # Pre-compute the sorted, de-duplicated driver list before + # build_ui() runs (the base __init__ calls build_ui last). + driver_set: list[type[Driver]] = [] + seen_classnames: set = set() + for d in drivers: + if d.__name__ in seen_classnames: + continue + # Hide the bare NoDeviceDriver from the controller list: it + # is offered as an explicit tile below so the user-facing + # label is friendlier. + if d.__name__ == "NoDeviceDriver": + continue + driver_set.append(d) + seen_classnames.add(d.__name__) + + self._drivers: list[type[Driver]] = sorted( + driver_set, key=lambda d: d.label.lower() + ) + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + self.group = _makePreferencesGroup( + title=_("Controller"), + description=_( + "Pick the firmware / protocol family for this " + "machine. If you aren't sure, choose the closest " + "match — you can refine individual settings later." + ), + ) + self.content.append(self.group) + + self.flow_box = Gtk.FlowBox() + self.flow_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.flow_box.set_homogeneous(True) + self.flow_box.set_min_children_per_line(2) + self.flow_box.set_max_children_per_line(4) + self.flow_box.set_column_spacing(12) + self.flow_box.set_row_spacing(12) + self.flow_box.set_activate_on_single_click(True) + self.flow_box.connect("child-activated", self._on_child_activated) + self.content.append(self.flow_box) + + self._tiles: list[Gtk.FlowBoxChild] = [] + for index, d in enumerate(self._drivers): + self._tiles.append( + self._make_tile( + index, + d.label, + d.subtitle or "", + _DRIVER_ICONS.get(d.__name__, _FALLBACK_ICON), + ) + ) + # Sentinel for None / export-only. + self._tiles.append( + self._make_tile( + len(self._drivers), + _("None — G-code export only"), + _("No physical controller; export G-code to a file"), + _EXPORT_ONLY_ICON, + ) + ) + for tile in self._tiles: + self.flow_box.append(tile) + + # No default selection: the user must consciously pick a + # controller (or "None") before proceeding. + self.set_ready(False) + + def _make_tile( + self, + index: int, + title: str, + subtitle: str, + icon_name: str, + ) -> Gtk.FlowBoxChild: + child = Gtk.FlowBoxChild() + button = Gtk.Button() + button.add_css_class("flat") + button.add_css_class("card") + button.connect("clicked", self._on_tile_clicked, child) + + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=6, + margin_top=16, + margin_bottom=16, + margin_start=12, + margin_end=12, + ) + image = get_icon(icon_name) + image.set_pixel_size(40) + title_label = Gtk.Label( + label=title, wrap=True, justify=Gtk.Justification.CENTER + ) + title_label.add_css_class("title-4") + subtitle_label = Gtk.Label( + label=subtitle, wrap=True, justify=Gtk.Justification.CENTER + ) + subtitle_label.add_css_class("dim-label") + box.append(image) + box.append(title_label) + box.append(subtitle_label) + button.set_child(box) + child.set_child(button) + return child + + def enter(self, profile: DeviceProfile) -> None: + """Re-select the tile matching the working profile's driver.""" + self.flow_box.unselect_all() + driver_name = profile.machine_config.driver + if driver_name: + for index, d in enumerate(self._drivers): + if d.__name__ == driver_name: + self.flow_box.select_child(self._tiles[index]) + self.set_ready(True) + return + self.set_ready(False) + + # ----- selection ----------------------------------------------------- + + def _on_tile_clicked( + self, button: Gtk.Button, child: Gtk.FlowBoxChild + ) -> None: + self._select_child(child) + + def _on_child_activated( + self, flow_box: Gtk.FlowBox, child: Gtk.FlowBoxChild + ) -> None: + self._select_child(child) + + def _select_child(self, child: Gtk.FlowBoxChild) -> None: + self.flow_box.select_child(child) + self.set_ready(True) + index = self._tiles.index(child) + driver_name: str | None + if index < len(self._drivers): + driver_name = self._drivers[index].__name__ + else: + driver_name = None + self.controller_selected.send(self, driver=driver_name) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + selected = self.flow_box.get_selected_children() + if not selected: + return False + index = self._tiles.index(selected[0]) + if index < len(self._drivers): + profile.machine_config.driver = self._drivers[index].__name__ + else: + profile.machine_config.driver = None + return True + + +__all__ = ["ControllerPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py b/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py new file mode 100644 index 000000000..ae5e6335b --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py @@ -0,0 +1,396 @@ +"""Step 7 — Hardware configuration. + +Surfaces the work-area X/Y extents, coordinate origin, soft-limit, +work-margins, axis-direction flags, max speeds and acceleration. The +reuse target is the existing ``HardwarePage`` widget set (see +``hardware_page.py``), but since that widget operates on a live +``Machine`` and the wizard holds an in-memory ``DeviceProfile``, +we rebuild a compact set of rows directly bound to *profile*. The +grouping (Axes / Work Area / Soft Limits) mirrors the device-settings +hardware page so the two look consistent. +""" + +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ....machine.device.profile import DeviceProfile +from ....machine.models.machine import Origin +from ...shared.pref_rows.acceleration_spin_row import AccelerationSpinRow +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from ...shared.pref_rows.speed_spin_row import SpeedSpinRow +from . import WizardPage, _makePreferencesGroup + +_ORIGIN_INDEX_TO_ENUM = { + 0: Origin.BOTTOM_LEFT, + 1: Origin.TOP_LEFT, + 2: Origin.TOP_RIGHT, + 3: Origin.BOTTOM_RIGHT, +} +_ORIGIN_ENUM_TO_INDEX = {v: k for k, v in _ORIGIN_INDEX_TO_ENUM.items()} + +# Sensible starting points surfaced when a profile carries no values; +# they mirror the Machine model defaults (machine.py). +_DEFAULT_TRAVEL_SPEED = 3000.0 +_DEFAULT_CUT_SPEED = 1000.0 +_DEFAULT_ACCELERATION = 1000.0 + + +class HardwarePage(WizardPage): + step_number = 7 + title = _("Hardware") + subtitle = _("Work area, origin, speeds and acceleration.") + + def __init__(self, wizard, **kwargs): + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + # Grouping mirrors the device-settings HardwarePage: Axes, + # Work Area (margins), Soft Limits, then wizard-only Speeds + # and Behavior groups. + axes_group = _makePreferencesGroup( + title=_("Axes"), + description=_("Configure the axis extents and coordinate system."), + ) + self.content.append(axes_group) + + self.x_row = LengthSpinRow( + _("X Extent"), + _("Full X-axis travel range"), + lower=10, + upper=10000, + ) + axes_group.add(self.x_row) + + self.y_row = LengthSpinRow( + _("Y Extent"), + _("Full Y-axis travel range"), + lower=10, + upper=10000, + ) + axes_group.add(self.y_row) + + origin_store = Gtk.StringList() + for label in ( + _("Bottom Left"), + _("Top Left"), + _("Top Right"), + _("Bottom Right"), + ): + origin_store.append(label) + self.origin_row = Adw.ComboRow( + title=_("Coordinate Origin (0,0)"), + subtitle=_( + "Physical corner where coordinates are zero after homing" + ), + model=origin_store, + ) + axes_group.add(self.origin_row) + + # Direction reversals. + self.reverse_x_row = Adw.SwitchRow( + title=_("Reverse X-Axis Direction"), + subtitle=_("Makes coordinate values negative"), + ) + axes_group.add(self.reverse_x_row) + self.reverse_y_row = Adw.SwitchRow( + title=_("Reverse Y-Axis Direction"), + subtitle=_("Makes coordinate values negative"), + ) + axes_group.add(self.reverse_y_row) + self.reverse_z_row = Adw.SwitchRow( + title=_("Reverse Z-Axis Direction"), + subtitle=_("Enable if +Z moves head down"), + ) + axes_group.add(self.reverse_z_row) + + # Working margins. + margins_group = _makePreferencesGroup( + title=_("Work Area"), + description=_( + "Margins define the unusable space around the axis extents." + ), + ) + self.content.append(margins_group) + + # Work margins — four explicit rows so pyright follows the + # attribute bindings (we read these back from apply_to_profile + # and enter()). + self.margin_left_row = self._build_margin_row( + margins_group, + _("Left Margin"), + _("Unusable space from left edge"), + ) + self.margin_top_row = self._build_margin_row( + margins_group, + _("Top Margin"), + _("Unusable space from top edge"), + ) + self.margin_right_row = self._build_margin_row( + margins_group, + _("Right Margin"), + _("Unusable space from right edge"), + ) + self.margin_bottom_row = self._build_margin_row( + margins_group, + _("Bottom Margin"), + _("Unusable space from bottom edge"), + ) + + # Soft limits. + self.soft_limits_group = _makePreferencesGroup( + title=_("Soft Limits"), + description=_( + "Configurable safety bounds for jogging. " + "Leave disabled to use work surface bounds." + ), + ) + 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"), + ) + self.soft_limits_enabled_row.connect( + "notify::active", self._on_soft_limits_toggle + ) + self.soft_limits_group.add(self.soft_limits_enabled_row) + + self.soft_x_min_row = self._build_soft_limit_row( + _("X Min"), _("Minimum X coordinate") + ) + self.soft_y_min_row = self._build_soft_limit_row( + _("Y Min"), _("Minimum Y coordinate") + ) + self.soft_x_max_row = self._build_soft_limit_row( + _("X Max"), _("Maximum X coordinate") + ) + self.soft_y_max_row = self._build_soft_limit_row( + _("Y Max"), _("Maximum Y coordinate") + ) + + # Speeds / accel. + speed_group = _makePreferencesGroup( + title=_("Speeds"), + description=_("Limits in machine units per minute."), + ) + self.content.append(speed_group) + + self.travel_speed_row = SpeedSpinRow( + _("Max Travel Speed"), + _("Maximum rapid movement speed"), + upper=60000, + step_increment=100, + digits=0, + ) + speed_group.add(self.travel_speed_row) + + self.cut_speed_row = SpeedSpinRow( + _("Max Cut Speed"), + _("Maximum cutting speed"), + upper=60000, + step_increment=100, + digits=0, + ) + speed_group.add(self.cut_speed_row) + + self.accel_row = AccelerationSpinRow( + _("Acceleration"), + _( + "Used for time estimations and calculating the " + "default overscan distance" + ), + upper=10000, + digits=0, + ) + speed_group.add(self.accel_row) + + # Behavior. + behavior_group = _makePreferencesGroup(title=_("Behavior")) + self.content.append(behavior_group) + + self.home_on_start_row = Adw.SwitchRow( + title=_("Home on Start"), + subtitle=_("Run homing cycle when machine connects"), + ) + behavior_group.add(self.home_on_start_row) + + self.single_axis_homing_row = Adw.SwitchRow( + title=_("Single-Axis Homing"), + subtitle=_("Allow homing individual axes"), + ) + behavior_group.add(self.single_axis_homing_row) + + # Whenever the user touches the soft-limits toggle or any of + # the extents, we may need to clamp soft-limit adjustments. + self.x_row.value_changed.connect(self._on_extents_changed) + self.y_row.value_changed.connect(self._on_extents_changed) + + # The page is always consider-ready because the user can skip + # fields they don't know yet (defaults are sensible). The + # orchestrator will surface sanity-check warnings at Review. + self.set_ready(True) + + # ----- row builders --------------------------------------------------- + + def _build_margin_row( + self, group: Adw.PreferencesGroup, title: str, subtitle: str + ) -> LengthSpinRow: + row = LengthSpinRow( + title=title, + subtitle=subtitle, + upper=10000, + ) + group.add(row) + return row + + def _build_soft_limit_row( + self, title: str, subtitle: str + ) -> LengthSpinRow: + row = LengthSpinRow( + title=title, + subtitle=subtitle, + upper=10000, + ) + row.set_sensitive(False) + self.soft_limits_group.add(row) + return row + + def _on_extents_changed(self, row) -> None: + x = self.x_row.get_value_in_base_units() + y = self.y_row.get_value_in_base_units() + self.soft_x_min_row.set_range(0.0, x) + self.soft_x_max_row.set_range(0.0, x) + self.soft_y_min_row.set_range(0.0, y) + self.soft_y_max_row.set_range(0.0, y) + + def _on_soft_limits_toggle(self, row, _param) -> None: + enabled = row.get_active() + self.soft_x_min_row.set_sensitive(enabled) + self.soft_y_min_row.set_sensitive(enabled) + self.soft_x_max_row.set_sensitive(enabled) + self.soft_y_max_row.set_sensitive(enabled) + + # ----- profile binding ----------------------------------------------- + + def enter(self, profile: DeviceProfile) -> None: + mc = profile.machine_config + + if mc.axis_extents: + self.x_row.set_value_in_base_units(mc.axis_extents[0]) + self.y_row.set_value_in_base_units(mc.axis_extents[1]) + else: + self.x_row.set_value_in_base_units(100.0) + self.y_row.set_value_in_base_units(100.0) + + origin = mc.origin or Origin.BOTTOM_LEFT + self.origin_row.set_selected(_ORIGIN_ENUM_TO_INDEX.get(origin, 0)) + + # directional reversal flags aren't on MachineConfig; they live + # on Machine directly. We treat them as ephemeral session state + # via wizard.aux_state, defaulting to False. + reverse = self.wizard.aux_state.setdefault("reverse", {}) + self.reverse_x_row.set_active(reverse.get("x", False)) + self.reverse_y_row.set_active(reverse.get("y", False)) + self.reverse_z_row.set_active(reverse.get("z", False)) + + margins = mc.work_margins or (0.0, 0.0, 0.0, 0.0) + self.margin_left_row.set_value_in_base_units(margins[0]) + self.margin_top_row.set_value_in_base_units(margins[1]) + self.margin_right_row.set_value_in_base_units(margins[2]) + self.margin_bottom_row.set_value_in_base_units(margins[3]) + + soft = mc.soft_limits + 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( + self.x_row.get_value_in_base_units() + ) + self.soft_y_max_row.set_value_in_base_units( + self.y_row.get_value_in_base_units() + ) + self._on_soft_limits_toggle(self.soft_limits_enabled_row, None) + + if mc.max_travel_speed is not None: + self.travel_speed_row.set_value_in_base_units(mc.max_travel_speed) + else: + self.travel_speed_row.set_value_in_base_units( + _DEFAULT_TRAVEL_SPEED + ) + if mc.max_cut_speed is not None: + self.cut_speed_row.set_value_in_base_units(mc.max_cut_speed) + else: + self.cut_speed_row.set_value_in_base_units(_DEFAULT_CUT_SPEED) + if mc.acceleration is not None: + self.accel_row.set_value_in_base_units(mc.acceleration) + else: + self.accel_row.set_value_in_base_units(_DEFAULT_ACCELERATION) + + self.home_on_start_row.set_active(bool(mc.home_on_start)) + self.single_axis_homing_row.set_active( + bool(mc.single_axis_homing_enabled) + ) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + mc = profile.machine_config + + x = self.x_row.get_value_in_base_units() + y = self.y_row.get_value_in_base_units() + if x > 0 and y > 0: + mc.axis_extents = (float(x), float(y)) + + mc.origin = _ORIGIN_INDEX_TO_ENUM.get( + self.origin_row.get_selected(), Origin.BOTTOM_LEFT + ) + + # stash reversals to aux_state (defers to Machine during + # create_machine); the orchestrator applies them post-creation. + reverse = self.wizard.aux_state.setdefault("reverse", {}) + reverse["x"] = self.reverse_x_row.get_active() + reverse["y"] = self.reverse_y_row.get_active() + reverse["z"] = self.reverse_z_row.get_active() + + margins = ( + self.margin_left_row.get_value_in_base_units(), + self.margin_top_row.get_value_in_base_units(), + self.margin_right_row.get_value_in_base_units(), + self.margin_bottom_row.get_value_in_base_units(), + ) + if any(m > 0 for m in margins): + mc.work_margins = margins + else: + mc.work_margins = None + + if self.soft_limits_enabled_row.get_active(): + mc.soft_limits = ( + self.soft_x_min_row.get_value_in_base_units(), + self.soft_y_min_row.get_value_in_base_units(), + self.soft_x_max_row.get_value_in_base_units(), + self.soft_y_max_row.get_value_in_base_units(), + ) + else: + mc.soft_limits = None + + travel = self.travel_speed_row.get_value_in_base_units() + cut = self.cut_speed_row.get_value_in_base_units() + accel = self.accel_row.get_value_in_base_units() + mc.max_travel_speed = int(travel) if travel > 0 else None + mc.max_cut_speed = int(cut) if cut > 0 else None + mc.acceleration = int(accel) if accel > 0 else None + + mc.home_on_start = self.home_on_start_row.get_active() or None + mc.single_axis_homing_enabled = ( + self.single_axis_homing_row.get_active() or None + ) + return True + + +__all__ = ["HardwarePage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/head_page.py b/rayforge/ui_gtk/machine/wizard_pages/head_page.py new file mode 100644 index 000000000..757a75786 --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/head_page.py @@ -0,0 +1,213 @@ +"""Step 8 — Head configuration. + +Lets the user declare whether the machine has a laser or a spindle head +and capture the key head-specific params (max power, spot size, PWM +freq, focal distance, framing, max/min RPM for spindle, etc.). Mirrors +the most important widgets from the live +:mod:`~rayforge.ui_gtk.machine.head_preferences_page` page but binds to +the wizard's working ``DeviceProfile``. + +The wizard seeds a single head; if the user has multiple heads they'll +add more via machine settings later. +""" + +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, Gtk + +from ....machine.device.profile import DeviceProfile +from ....machine.driver import get_driver_cls +from ....machine.driver.dummy import NoDeviceDriver +from ....machine.models.laser import LaserHead +from ...shared.pref_rows.base import SpinRow +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from . import WizardPage, _makePreferencesGroup + +# Index 0 == laser, 1 == spindle. +_HEAD_LASER = 0 +_HEAD_SPINDLE = 1 + + +def _is_spindle_head_dict(head: dict[str, Any] | None) -> bool: + if not head: + return False + cls = (head.get("head_class") or "").lower() + return "spindle" in cls or "max_rpm" in head + + +class HeadPage(WizardPage): + step_number = 8 + title = _("Head") + subtitle = _("What's attached to the gantry: a laser, a spindle, or both?") + + def __init__(self, wizard, **kwargs): + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + head_group = _makePreferencesGroup( + title=_("Head Type"), + description=_("Pick the primary head for this machine."), + ) + self.content.append(head_group) + + store = Gtk.StringList() + store.append(_("Laser Head")) + store.append(_("Spindle Head")) + self.head_type_row = Adw.ComboRow( + title=_("Head Type"), + subtitle=_("Type of tool attached to this machine"), + model=store, + ) + self.head_type_row.connect( + "notify::selected", self._on_head_type_changed + ) + head_group.add(self.head_type_row) + + self.head_name_row = Adw.EntryRow(title=_("Head Name")) + head_group.add(self.head_name_row) + + # ----- shared / laser fields ------------------------------- + self.laser_group = _makePreferencesGroup(title=_("Laser Settings")) + self.content.append(self.laser_group) + + self.max_power_row = SpinRow( + _("Max Power (S-value)"), + _("Max laser power value in GCode"), + lower=1, + upper=100000, + step_increment=100, + value=1000, + ) + self.laser_group.add(self.max_power_row) + + self.spot_x_row = LengthSpinRow( + _("Spot Size X"), + _("Laser beam width on X axis"), + upper=10, + digits=3, + value_in_base=0.1, + ) + self.laser_group.add(self.spot_x_row) + + self.spot_y_row = LengthSpinRow( + _("Spot Size Y"), + _("Laser beam width on Y axis"), + upper=10, + digits=3, + value_in_base=0.1, + ) + self.laser_group.add(self.spot_y_row) + + self.pwm_freq_row = SpinRow( + _("PWM Frequency (Hz)"), + _("Laser modulation frequency"), + lower=1, + upper=100000, + step_increment=100, + value=500, + ) + self.laser_group.add(self.pwm_freq_row) + + self.focal_distance_row = LengthSpinRow( + _("Focal Distance"), + _("Lens-to-workpiece distance"), + upper=1000, + value_in_base=0, + ) + self.laser_group.add(self.focal_distance_row) + + # ----- spindle fields ---------------------------------- + self.spindle_group = _makePreferencesGroup(title=_("Spindle")) + self.content.append(self.spindle_group) + + self.max_rpm_row = SpinRow( + _("Max RPM"), + lower=1, + upper=100000, + step_increment=100, + value=20000, + ) + self.spindle_group.add(self.max_rpm_row) + + self.min_rpm_row = SpinRow( + _("Min RPM"), + lower=1, + upper=100000, + step_increment=100, + value=1000, + ) + self.spindle_group.add(self.min_rpm_row) + + self._on_head_type_changed(self.head_type_row, None) + self.set_ready(True) + + def _on_head_type_changed(self, row, _param) -> None: + is_spindle = row.get_selected() == _HEAD_SPINDLE + self.laser_group.set_visible(not is_spindle) + self.spindle_group.set_visible(is_spindle) + if not is_spindle: + self._update_pwm_visibility() + + def _update_pwm_visibility(self) -> None: + """Hide PWM fields when the chosen driver doesn't support PWM.""" + driver_name = self.wizard.profile.machine_config.driver + if not driver_name: + self.pwm_freq_row.set_visible(False) + return + driver_cls = get_driver_cls(driver_name) + if driver_cls is NoDeviceDriver: + self.pwm_freq_row.set_visible(False) + return + # ``supports_pwm`` is an instance method but the overrides don't + # use ``self``; build a bare instance to query without a live + # machine. + probe_driver = driver_cls.__new__(driver_cls) + probe_head = LaserHead() + self.pwm_freq_row.set_visible(probe_driver.supports_pwm(probe_head)) + + # ----- profile binding -------------------------------------------- + + def enter(self, profile: DeviceProfile) -> None: + heads = profile.machine_config.heads or [] + head: dict[str, Any] = heads[0] if heads else {} + if _is_spindle_head_dict(head): + self.head_type_row.set_selected(_HEAD_SPINDLE) + self.max_rpm_row.set_value(head.get("max_rpm", 20000)) + self.min_rpm_row.set_value(head.get("min_rpm", 1000)) + else: + self.head_type_row.set_selected(_HEAD_LASER) + self.max_power_row.set_value(head.get("max_power", 1000)) + spot = head.get("spot_size_mm") or (0.1, 0.1) + self.spot_x_row.set_value_in_base_units(spot[0]) + self.spot_y_row.set_value_in_base_units(spot[1]) + self.pwm_freq_row.set_value(head.get("pwm_frequency", 500)) + self.focal_distance_row.set_value_in_base_units( + head.get("focal_distance", 0) + ) + self._update_pwm_visibility() + self.head_name_row.set_text(head.get("name", "")) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + head: dict[str, Any] = {"name": self.head_name_row.get_text() or ""} + if self.head_type_row.get_selected() == _HEAD_SPINDLE: + head["head_class"] = "SpindleHead" + head["max_rpm"] = int(self.max_rpm_row.get_value()) + head["min_rpm"] = int(self.min_rpm_row.get_value()) + else: + head["head_class"] = "LaserHead" + head["max_power"] = int(self.max_power_row.get_value()) + head["spot_size_mm"] = [ + self.spot_x_row.get_value_in_base_units(), + self.spot_y_row.get_value_in_base_units(), + ] + if self.pwm_freq_row.get_visible(): + head["pwm_frequency"] = int(self.pwm_freq_row.get_value()) + head["focal_distance"] = ( + self.focal_distance_row.get_value_in_base_units() + ) + profile.machine_config.heads = [head] + return True + + +__all__ = ["HeadPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/probe_page.py b/rayforge/ui_gtk/machine/wizard_pages/probe_page.py new file mode 100644 index 000000000..59d8284ee --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/probe_page.py @@ -0,0 +1,194 @@ +"""Step 4 — Connect + auto-detect. + +Only shown when the chosen driver's ``supports_probing`` is True +(GRBL and Marlin today). Reuses the driver's classmethod ``probe()`` +plumbing originally developed for the legacy ``ConfigWizard``. + +The page offers: + +* **Probe now** — attempts to connect to the device using the + Step-3 connection parameters and read its configuration + ($I, axis extents $130/$131, speeds $110/$111, accel, laser mode, + RX buffer size). On success the resulting ``DeviceProfile`` fields + are merged into the working profile and Step 6/7 inputs are + prefilled and marked verified. +* **Skip** — the user forgoes probing now; wizard falls through to + Step 5 (AI lookup, if applicable) or Step 6 manual entry. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ....context import get_context +from ....machine.device.profile import DeviceProfile +from ....machine.driver import Driver, get_driver_cls +from ....machine.driver.driver import DriverPrecheckError +from ....shared.tasker import Task, task_mgr +from ....shared.tasker.context import ExecutionContext +from . import WizardPage, _makePreferencesGroup + +if TYPE_CHECKING: + from ..unified_wizard import UnifiedWizard + + +class ProbePage(WizardPage): + step_number = 4 + title = _("Discover Device") + subtitle = _( + "Connect to the device and read its configuration, " + "or skip to enter the values manually." + ) + + # Sent after a successful probe. Payload ``(profile, warnings)`` + # where ``profile`` is the working profile with probed values + # merged in; warnings is a list of human-readable strings. + def __init__(self, wizard: "UnifiedWizard", **kwargs: Any) -> None: + self._driver_cls: type[Driver] | None = None + # True once probing has been attempted on this page instance, + # so re-entering via Back does not auto-restart a probe. + self._probed: bool = False + self.probe_succeeded = Signal() + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + self.group = _makePreferencesGroup( + title=_("Probing"), + description=_( + "Auto-discover the machine's working area, " + "speeds, and firmware capabilities by reading its " + "settings over the connection." + ), + ) + self.content.append(self.group) + + self.status_row = Adw.ActionRow( + title=_("Status"), + subtitle=_("Idle"), + ) + self.group.add(self.status_row) + + self.spinner = Gtk.Spinner() + self.spinner.set_halign(Gtk.Align.CENTER) + self.spinner.set_size_request(32, 32) + self.spinner.set_visible(False) + self.content.append(self.spinner) + + # Probe / Retry live on the wizard footer button bar (see + # footer_buttons()). A single button relabels itself between + # "Probe Now" and "Retry" so there is never a redundant pair. + self.probe_button = Gtk.Button(label=_("Probe Now")) + self.probe_button.add_css_class("suggested-action") + self.probe_button.connect("clicked", lambda *_: self._start_probe()) + + # The page is considered ready by default so the orchestrator's + # Next button stays sensitive. Skip is the minimal action. + # The orchestrator may disable Next while a probe is in + # flight via set_ready. + self.set_ready(True) + + def footer_buttons(self) -> list[Gtk.Button]: + return [self.probe_button] + + def enter(self, profile: DeviceProfile) -> None: + driver_name = profile.machine_config.driver + self._driver_cls = get_driver_cls(driver_name) if driver_name else None + self._reset_status() + # Auto-start probing the first time the page is shown, but not + # when the user navigates back to it. + if not self._probed: + self._probed = True + self._start_probe() + + 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.spinner.stop() + self.spinner.set_visible(False) + self.probe_button.set_label(_("Probe Now")) + self.probe_button.set_sensitive(True) + self.set_ready(True) + + # ----- probe coroutine ---------------------------------------------- + + def _start_probe(self) -> None: + driver_cls = self._driver_cls + if driver_cls is None: + return + profile = self.wizard.profile + params = dict(profile.machine_config.driver_args or {}) + + # Lightweight precheck (var_set validation, port format, ...). + try: + driver_cls.precheck(**params) + except DriverPrecheckError as exc: + self._show_error(str(exc)) + return + + self.status_row.set_title(_("Probing…")) + self.status_row.set_subtitle( + _("Connecting to device and reading settings") + ) + self.status_row.remove_css_class("error") + self.spinner.set_visible(True) + self.spinner.start() + self.probe_button.set_sensitive(False) + self.probe_button.set_label(_("Probing…")) + self.set_ready(False) + + context = get_context() + + async def _coroutine( + exec_ctx: ExecutionContext, + ) -> tuple[DeviceProfile, list[str]]: + return await driver_cls.probe(context, **params) + + task_mgr.add_coroutine( + _coroutine, + key="unified_wizard_probe", + when_done=self._on_probe_done, + ) + + def _on_probe_done(self, task: Task) -> None: + # Marshal back to the GTK main thread before touching widgets. + def _update(): + try: + result = task.result() + except Exception as exc: # noqa: BLE001 - async task boundary + self._show_error(str(exc) or _("Probe failed")) + return + + if task.get_status() != "completed": + msg = _("Probe failed") + self._show_error(msg) + return + + profile, warnings = result + self.status_row.set_title(_("Probe succeeded")) + self.status_row.set_subtitle( + _("Working area and speeds auto-detected.") + ) + self.spinner.stop() + self.spinner.set_visible(False) + self.probe_button.set_label(_("Probe Now")) + self.probe_button.set_sensitive(False) + self.set_ready(True) + self.probe_succeeded.send(self, profile=profile, warnings=warnings) + + task_mgr.schedule_on_main_thread(_update) + + def _show_error(self, message: str) -> None: + self.status_row.set_title(_("Error")) + self.status_row.set_subtitle(message) + self.status_row.add_css_class("error") + self.spinner.stop() + self.spinner.set_visible(False) + self.probe_button.set_label(_("Retry")) + self.probe_button.set_sensitive(True) + self.set_ready(True) + + +__all__ = ["ProbePage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/profile_page.py b/rayforge/ui_gtk/machine/wizard_pages/profile_page.py new file mode 100644 index 000000000..d78387f6b --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/profile_page.py @@ -0,0 +1,139 @@ +"""Step 1 — Pick source. + +Offers the user three entry points: + +* **Known profile**: pick a built-in / installed device profile from a + searchable list. The chosen profile pre-fills driver + dims + head; + Step 3 (Connection) is still required because profiles never carry + host-specific values like the USB device path or OctoPrint API key. +* **Other / unknown machine**: skip to Step 2 where the user picks the + controller class manually. +* **Import .lbdev / .zip**: pull a snapshot from disk. Same treatment + as a known profile but Step 3 is also prefilled with any connection + args saved in the snapshot. +""" + +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Adw, Gtk + +from ....context import get_context +from ....machine.device.profile import DeviceProfile +from ..profile_importer import open_profile_file +from . import WizardPage, _makePreferencesGroup + + +class _ProfileRow(Adw.ActionRow): + """A custom row to hold a reference to its device profile.""" + + def __init__(self, profile: DeviceProfile, **kwargs): + super().__init__(**kwargs) + self.profile: DeviceProfile = profile + + +class ProfilePage(WizardPage): + step_number = 1 + title = _("Add a Machine") + subtitle = _("Pick a starting point for the new machine.") + # The page advances only through explicit source selection (row + # activation, Import, or Unlisted Device, so the wizard's + # generic "Next" button is hidden here — it would be a dead button. + next_on_footer = False + + # Selected source signal, sent with a source-kind tag and an + # optional profile payload. The orchestrator uses this to choose + # the next page. + def __init__(self, wizard, **kwargs): + self._all_profiles: list[DeviceProfile] = [] + self.source_selected = Signal() + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + group = _makePreferencesGroup( + title=_("Machine Templates"), + description=_( + "Pick a built-in profile to pre-fill common " + "settings. You will still be asked for " + "connection-specific values." + ), + ) + self.content.append(group) + + self.search_entry = Gtk.SearchEntry() + self.search_entry.set_placeholder_text(_("Search devices…")) + self.search_entry.connect( + "search-changed", lambda *_: self._filter_and_populate_list() + ) + self.content.append(self.search_entry) + + self.list_box = Gtk.ListBox() + self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.list_box.add_css_class("frame") + self.list_box.add_css_class("card") + self.list_box.connect("row-activated", self._on_row_activated) + self.content.append(self.list_box) + + # Import / Other affordances live on the wizard footer button + # bar (see footer_buttons()). + self.import_button = Gtk.Button(label=_("Import from File…")) + self.import_button.connect("clicked", self._on_import_clicked) + + self.other_button = Gtk.Button(label=_("Device Not Listed")) + self.other_button.add_css_class("suggested-action") + self.other_button.connect("clicked", self._on_other_clicked) + + # The page is always ready because the user can always go to + # "Other…" or pick a profile — row activation emits + # source_selected directly and the orchestrator moves on. + self.set_ready(True) + + def footer_buttons(self) -> list[Gtk.Button]: + return [self.import_button, self.other_button] + + def enter(self, profile: DeviceProfile) -> None: + self._all_profiles = list(get_context().device_profile_mgr.get_all()) + self._filter_and_populate_list() + + def _filter_and_populate_list(self) -> None: + search_text = self.search_entry.get_text().lower() + + while child := self.list_box.get_row_at_index(0): + self.list_box.remove(child) + + for pkg in self._all_profiles: + if ( + search_text + and search_text not in pkg.name.lower() + and search_text not in (pkg.meta.description or "").lower() + ): + continue + row = _ProfileRow( + profile=pkg, + title=pkg.name, + subtitle=pkg.meta.description or "", + activatable=True, + ) + self.list_box.append(row) + + # ----- selection handlers ------------------------------------------- + + def _on_row_activated(self, listbox: Gtk.ListBox, row: _ProfileRow): + self.source_selected.send(self, kind="profile", profile=row.profile) + + def _on_import_clicked(self, button: Gtk.Button): + open_profile_file(self.wizard, self._on_import_result) + + def _on_import_result( + self, profile: DeviceProfile | None, error: str | None + ) -> None: + if error is not None or profile is None: + self.wizard.show_error(_("Import Failed"), error or "") + return + self.source_selected.send(self, kind="import", profile=profile) + + def _on_other_clicked(self, button: Gtk.Button): + self.source_selected.send(self, kind="other", profile=None) + + +__all__ = ["ProfilePage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/provider_page.py b/rayforge/ui_gtk/machine/wizard_pages/provider_page.py new file mode 100644 index 000000000..419a64cfc --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/provider_page.py @@ -0,0 +1,100 @@ +"""Step 5 — AI provider configuration (shown only when needed). + +Inserted between the probe step and the AI spec lookup step when no AI +provider is configured yet. Lets the user point the wizard at an +OpenAI-compatible endpoint (base URL + API key) so Step 6 can query it +for known machine specifications. Skipping the page routes straight to +manual entry (Step 7), skipping the AI lookup entirely. +""" + +import uuid +from gettext import gettext as _ + +from gi.repository import Adw + +from ....context import get_context +from ....core.ai.provider import AIProviderConfig, AIProviderType +from ....machine.device.profile import DeviceProfile +from . import WizardPage, _makePreferencesGroup + +_DEFAULT_BASE_URL = "https://api.openai.com/v1" + + +class AIProviderPage(WizardPage): + step_number = 5 + title = _("AI Provider") + subtitle = _( + "Configure an AI provider so the wizard can pre-fill " + "known machine specifications." + ) + + def __init__(self, wizard, **kwargs): + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + group = _makePreferencesGroup( + title=_("AI Provider"), + description=_( + "Enter an OpenAI-compatible endpoint. This is only " + "used for the automatic spec lookup; you can also " + "skip and enter the values by hand." + ), + ) + self.content.append(group) + + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.set_text(_("Default Provider")) + self.name_row.connect("changed", self._on_inputs_changed) + group.add(self.name_row) + + self.base_url_row = Adw.EntryRow(title=_("Base URL")) + self.base_url_row.set_text(_DEFAULT_BASE_URL) + self.base_url_row.connect("changed", self._on_inputs_changed) + group.add(self.base_url_row) + + self.api_key_row = Adw.PasswordEntryRow(title=_("API Key")) + self.api_key_row.connect("changed", self._on_inputs_changed) + group.add(self.api_key_row) + + self.model_row = Adw.EntryRow(title=_("Default Model (optional)")) + self.model_row.connect("changed", self._on_inputs_changed) + group.add(self.model_row) + + # Ready only when the essential fields are filled in, so Next + # ("use this provider") and Skip ("no AI, enter values by hand") + # stay semantically distinct. + self._refresh_ready() + + def _on_inputs_changed(self, _row, _param=None) -> None: + self._refresh_ready() + + def _refresh_ready(self) -> None: + ready = bool( + self.name_row.get_text().strip() + and self.base_url_row.get_text().strip() + and self.api_key_row.get_text().strip() + ) + self.set_ready(ready) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + name = self.name_row.get_text().strip() + base_url = self.base_url_row.get_text().strip() + api_key = self.api_key_row.get_text().strip() + default_model = self.model_row.get_text().strip() + if not (name and base_url and api_key): + return True + + config = AIProviderConfig( + id=str(uuid.uuid4())[:8], + name=name, + provider_type=AIProviderType.OPENAI_COMPATIBLE, + api_key=api_key, + base_url=base_url, + default_model=default_model, + enabled=True, + ) + get_context().ai_service.add_provider(config) + return True + + +__all__ = ["AIProviderPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/review_page.py b/rayforge/ui_gtk/machine/wizard_pages/review_page.py new file mode 100644 index 000000000..2df229f28 --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/review_page.py @@ -0,0 +1,249 @@ +"""Step 11 — Review & name. + +Summarizes every value the wizard has collected so far, runs a +static config sanity pass, and lets the user pick a final name for +the new machine. The "Create Machine" button at the wizard footer +is the user's commit point — the orchestrator calls +``DeviceProfile.create_machine()`` and hands the live ``Machine`` +back to the caller via the ``machine_created`` signal. +""" + +from gettext import gettext as _ + +from gi.repository import Adw + +from ....machine.device.profile import DeviceProfile +from ....machine.driver import get_driver_cls +from ....machine.models.machine import Origin +from ....shared.units.system import UnitSystem +from . import WizardPage, _makePreferencesGroup + + +def _format_tuple(value) -> str: + if value is None: + return _("—") + try: + return " × ".join(str(v) for v in value) + except TypeError: + return str(value) + + +def _format(value) -> str: + if value is None: + return _("—") + if isinstance(value, bool): + return _("Yes") if value else _("No") + return str(value) + + +def _format_bool(value) -> str: + """Boolean with an unset (None) state collapsed to "No".""" + return _("Yes") if value else _("No") + + +_ORIGIN_LABELS = { + Origin.BOTTOM_LEFT: _("Bottom Left"), + Origin.TOP_LEFT: _("Top Left"), + Origin.TOP_RIGHT: _("Top Right"), + Origin.BOTTOM_RIGHT: _("Bottom Right"), +} + +_UNIT_SYSTEM_LABELS = { + UnitSystem.METRIC: _("Metric (mm)"), + UnitSystem.IMPERIAL: _("Imperial (inches)"), +} + +_SECRET_ARG_KEYS = ("api_key", "password", "secret", "token") + + +def _format_connection(mc) -> str: + """Human-readable summary of the connection arguments. + + Renders ``driver_args`` as "Label: value" pairs using the + driver's own setup-var labels instead of dumping the raw dict, + and masks secret-ish values (API keys, passwords). + """ + args = mc.driver_args or {} + if not args: + return _("—") + labels: dict[str, str] = {} + if mc.driver: + try: + d = get_driver_cls(mc.driver) + for var in d.get_setup_vars(): + labels[var.key] = var.label + except (ValueError, TypeError): + labels = {} + parts: list[str] = [] + for key, value in args.items(): + label = labels.get(key) or key + if any(s in key.lower() for s in _SECRET_ARG_KEYS): + value = "••••••••" + else: + value = _format(value) + parts.append(f"{label}: {value}") + return ", ".join(parts) + + +def _prefill_name(profile: DeviceProfile) -> str: + """Machine-name suggestion for the review page. + + Keeps an explicit profile name, otherwise composes one from the + vendor + model entered on the AI lookup page (e.g. "Sculpfun S30 + Pro") and falls back to the default placeholder. + """ + name = (profile.meta.name or "").strip() + if name and name != _("New Machine"): + return name + parts = [p for p in (profile.meta.vendor, profile.meta.model) if p] + return " ".join(parts) if parts else _("New Machine") + + +class ReviewPage(WizardPage): + step_number = 11 + title = _("Review & Name") + subtitle = _("Final name and sanity check before creating the machine.") + + def __init__(self, wizard, **kwargs): + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + name_group = _makePreferencesGroup( + title=_("Name"), description=_("A friendly name for this machine.") + ) + self.content.append(name_group) + + self.name_row = Adw.EntryRow(title=_("Machine Name")) + name_group.add(self.name_row) + + self.summary_group = _makePreferencesGroup(title=_("Summary")) + self.content.append(self.summary_group) + self._summary_rows: list[Adw.ActionRow] = [] + + # Warnings surface config issues (e.g. driver missing) but + # do not block machine creation — they round-trip into the + # settings dialog where the user can fix them. + self.warnings_group = _makePreferencesGroup(title=_("Warnings")) + self.warnings_group.set_visible(False) + self.content.append(self.warnings_group) + self._warning_rows: list[Adw.ActionRow] = [] + + self.set_ready(True) + + def enter(self, profile: DeviceProfile) -> None: + self.name_row.set_text(_prefill_name(profile)) + self._populate_summary(profile) + self._populate_warnings(profile) + + def _populate_summary(self, profile: DeviceProfile) -> None: + for row in self._summary_rows: + self.summary_group.remove(row) + self._summary_rows.clear() + + mc = profile.machine_config + + driver_label = _("None (G-code export only)") + if mc.driver: + d = get_driver_cls(mc.driver) + # get_driver_cls returns NoDeviceDriver when the class + # name is not in the registry; use the class name as a + # fallback label so the user knows the configured driver + # couldn't be looked up. + if d.__name__ == "NoDeviceDriver" and d.__name__ != mc.driver: + driver_label = _("Unknown driver: {}").format(mc.driver) + else: + driver_label = d.label + + origin = mc.origin if mc.origin is not None else Origin.BOTTOM_LEFT + origin_label = _ORIGIN_LABELS[origin] + unit_system = mc.unit_system or UnitSystem.METRIC + unit_system_label = _UNIT_SYSTEM_LABELS[unit_system] + rows_data = [ + (_("Driver"), driver_label), + (_("Connection"), _format_connection(mc)), + (_("Work Area X×Y"), _format_tuple(mc.axis_extents)), + (_("Origin"), origin_label), + (_("Unit System"), unit_system_label), + (_("Max Travel Speed"), _format(mc.max_travel_speed)), + (_("Max Cut Speed"), _format(mc.max_cut_speed)), + (_("Acceleration"), _format(mc.acceleration)), + (_("Home on Start"), _format_bool(mc.home_on_start)), + (_("Heads"), str(len(mc.heads or []))), + (_("Rotary Modules"), str(len(mc.rotary_modules or []))), + (_("Cameras"), str(len(mc.cameras or []))), + ] + for label, value in rows_data: + row = Adw.ActionRow(title=label, subtitle=value) + self.summary_group.add(row) + self._summary_rows.append(row) + + def _populate_warnings(self, profile: DeviceProfile) -> None: + warnings: list[str] = self._check_profile(profile) + for row in self._warning_rows: + self.warnings_group.remove(row) + self._warning_rows.clear() + if not warnings: + self.warnings_group.set_visible(False) + return + for text in warnings: + row = Adw.ActionRow(title=text) + row.add_css_class("warning") + self.warnings_group.add(row) + self._warning_rows.append(row) + self.warnings_group.set_visible(True) + + def _check_profile(self, profile: DeviceProfile) -> list[str]: + warnings: list[str] = [] + mc = profile.machine_config + + if not mc.driver: + warnings.append( + _( + "No driver selected — this machine will only " + "export G-code to files; it cannot run jobs." + ) + ) + + toplefts = mc.axis_extents or (0, 0) + if not toplefts or min(toplefts) <= 0: + warnings.append( + _("Work area dimensions are unset or non-positive.") + ) + + if not (mc.heads or []): + warnings.append(_("No head is configured for this machine.")) + else: + for idx, head in enumerate(mc.heads): + cls = (head.get("head_class") or "").lower() + if "laser" in cls and "max_power" not in head: + warnings.append( + _( + "Head #{n} looks like a laser but has " + "no max_power setting." + ).format(n=idx + 1) + ) + if "spindle" in cls and "max_rpm" not in head: + warnings.append( + _( + "Head #{n} looks like a spindle but has " + "no max_rpm setting." + ).format(n=idx + 1) + ) + + if not profile.meta.name or not profile.meta.name.strip(): + warnings.append(_("Machine name is blank.")) + + return warnings + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + name = self.name_row.get_text().strip() + if not name: + self.wizard.show_error( + _("Missing name"), _("Please enter a name.") + ) + return False + profile.meta.name = name + return True + + +__all__ = ["ReviewPage"] diff --git a/rayforge/ui_gtk/machine/wizard_pages/rotary_page.py b/rayforge/ui_gtk/machine/wizard_pages/rotary_page.py new file mode 100644 index 000000000..4e16ce7d4 --- /dev/null +++ b/rayforge/ui_gtk/machine/wizard_pages/rotary_page.py @@ -0,0 +1,223 @@ +"""Step 9 — Rotary module (optional). + +Pick rotary type (jaws / rollers), axis (A / B / C), mode (true 4th +axis vs axis replacement), geometry (mm-per-rotation, default +diameter, max length, roller Ø), mount position, reverse-direction +flag and an optional 3D model path. + +The wizard seeds one ``RotaryModule``. Skipping the page (via the +wizard's "Skip" button) means no entry is written to +``MachineConfig.rotary_modules``. The page is intentionally written +against ``DeviceProfile`` rather than a live ``Machine`` so it can +plug into the wizard's in-memory model. + +Multiples: per the design "A machine may define multiple rotary +modules; the wizard seeds one and offers an `add another` affordance." +We keep things simple here — multiple modules can be added through +the machine settings page (``RotaryModulePage``) once the machine is +created. +""" + +from gettext import gettext as _ + +from gi.repository import Adw, Gtk +from raygeo.ops.axis import Axis + +from ....machine.device.profile import DeviceProfile +from ....machine.models.rotary_module import ( + RotaryMode, + RotaryModule, + RotaryType, +) +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from . import WizardPage, _makePreferencesGroup + +_AXIS_NAMES = ("A", "B", "C") +_AXIS_NAMED = {"A": Axis.A, "B": Axis.B, "C": Axis.C} +_MODE_TRUE_4TH = 0 +_TYPE_JAWS = 0 +_TYPE_ROLLERS = 1 + + +class RotaryPage(WizardPage): + step_number = 9 + title = _("Rotary Module") + subtitle = _( + "Optional. Set up a rotary attachment now or skip this " + "step to add one later from machine settings." + ) + + def __init__(self, wizard, **kwargs): + super().__init__(wizard, **kwargs) + + def build_ui(self) -> None: + self.details_group = _makePreferencesGroup( + title=_("Module"), + description=_("Pick rotary type, axis, mode, and geometry."), + ) + self.content.append(self.details_group) + + type_store = Gtk.StringList() + type_store.append(_("Jaws / chuck")) + type_store.append(_("Rollers")) + self.type_row = Adw.ComboRow( + title=_("Rotary Type"), + subtitle=_("How the workpiece is held"), + model=type_store, + ) + self.type_row.connect("notify::selected", self._on_type_changed) + self.details_group.add(self.type_row) + + axis_store = Gtk.StringList() + for name in _AXIS_NAMES: + axis_store.append(name) + self.axis_row = Adw.ComboRow( + title=_("Rotary Axis"), + subtitle=_("Which axis the rotary uses"), + model=axis_store, + ) + self.details_group.add(self.axis_row) + + mode_store = Gtk.StringList() + mode_store.append(_("True 4th Axis (keeps X/Y/Z)")) + mode_store.append(_("Axis Replacement (swaps e.g. Y for A)")) + self.mode_row = Adw.ComboRow(title=_("Mode"), model=mode_store) + self.mode_row.connect("notify::selected", self._on_mode_changed) + self.details_group.add(self.mode_row) + + # Geometry fields + self.mm_per_rotation_row = LengthSpinRow( + _("Length per Rotation"), + _("Auto-fetched from GRBL $101/$103 if probing"), + upper=100000, + digits=3, + value_in_base=0, + ) + self.details_group.add(self.mm_per_rotation_row) + + self.default_diameter_row = LengthSpinRow( + _("Default Workpiece Ø"), + upper=1000, + value_in_base=25.0, + ) + self.details_group.add(self.default_diameter_row) + + self.max_length_row = LengthSpinRow( + _("Max Workpiece Length"), + upper=10000, + value_in_base=300.0, + ) + self.details_group.add(self.max_length_row) + + self.roller_diameter_row = LengthSpinRow( + _("Roller Ø"), + _("Required when using roller-type rotary"), + upper=1000, + value_in_base=0.0, + ) + self.details_group.add(self.roller_diameter_row) + + self.reverse_row = Adw.SwitchRow( + title=_("Reverse Axis Direction"), + subtitle=_("Invert the rotary's rotation direction"), + ) + self.details_group.add(self.reverse_row) + + # The page is always ready; "Skip" on the footer is how the + # user opts out entirely. + self.set_ready(True) + self._on_type_changed(self.type_row, None) + self._on_mode_changed(self.mode_row, None) + + def _on_type_changed(self, row, _param) -> None: + # Roller Ø is only meaningful for roller-type rotational. + is_rollers = row.get_selected() == _TYPE_ROLLERS + self.roller_diameter_row.set_visible(is_rollers) + + def _on_mode_changed(self, row, _param) -> None: + # In axis-replacement mode, mm-per-rotation is unused — the + # axis inherits the replaced linear axis's settings. + is_axis_replacement = row.get_selected() != _MODE_TRUE_4TH + self.mm_per_rotation_row.set_visible(not is_axis_replacement) + + # ----- profile binding ----------------------------------------------- + + def enter(self, profile: DeviceProfile) -> None: + mc = profile.machine_config + modules = mc.rotary_modules or [] + if not modules: + return + first = modules[0] + self.type_row.set_selected( + _TYPE_JAWS + if (first.get("rotary_type", "jaws") == "jaws") + else _TYPE_ROLLERS + ) + axis_name = first.get("axis", "A") + try: + self.axis_row.set_selected(_AXIS_NAMES.index(axis_name)) + except ValueError: + self.axis_row.set_selected(0) + mode_val = first.get("mode", "true_4th_axis") + self.mode_row.set_selected( + 0 if mode_val == RotaryMode.TRUE_4TH_AXIS.value else 1 + ) + self.mm_per_rotation_row.set_value_in_base_units( + first.get("mm_per_rotation", 0) + ) + self.default_diameter_row.set_value_in_base_units( + first.get("default_diameter", 25.0) + ) + self.max_length_row.set_value_in_base_units( + first.get("max_workpiece_length", 300.0) + ) + self.roller_diameter_row.set_value_in_base_units( + first.get("roller_diameter", 0.0) + ) + self.reverse_row.set_active(bool(first.get("reverse_axis", False))) + + def apply_to_profile(self, profile: DeviceProfile) -> bool: + mc = profile.machine_config + + axis_idx = self.axis_row.get_selected() + axis_name = _AXIS_NAMES[axis_idx] if axis_idx >= 0 else "A" + mode_val = ( + RotaryMode.TRUE_4TH_AXIS.value + if self.mode_row.get_selected() == _MODE_TRUE_4TH + else RotaryMode.AXIS_REPLACEMENT.value + ) + type_val = ( + RotaryType.JAWS.value + if self.type_row.get_selected() == _TYPE_JAWS + else RotaryType.ROLLERS.value + ) + + # Build a fresh RotaryModule so the model canonicalizes the + # data and fills defaults we don't surface in the wizard. + module = RotaryModule() + module.name = _("Rotary Module") + module.axis = _AXIS_NAMED.get(axis_name, Axis.A) + module.mode = RotaryMode(mode_val) + module.rotary_type = RotaryType(type_val) + module.mm_per_rotation = ( + self.mm_per_rotation_row.get_value_in_base_units() + ) + module.default_diameter = ( + self.default_diameter_row.get_value_in_base_units() + ) + module.max_workpiece_length = ( + self.max_length_row.get_value_in_base_units() + ) + module.roller_diameter = ( + self.roller_diameter_row.get_value_in_base_units() + ) + module.reverse_axis = self.reverse_row.get_active() + + # Stash uid so when the orchestrator materializes the machine + # it can avoid duplicate-pathology: we hand back the dict form + # for MachineConfig.rotary_modules. + mc.rotary_modules = [module.to_dict()] + return True + + +__all__ = ["RotaryPage"] diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py new file mode 100644 index 000000000..2c1f4cfe2 --- /dev/null +++ b/rayforge/ui_gtk/main_menu.py @@ -0,0 +1,292 @@ +from gettext import gettext as _ + +from gi.repository import Gio, GLib, Gtk + +from ..machine.models.macro import Macro +from .action_registry import action_registry + + +class MainMenu(Gio.Menu): + """ + The main application menu model, inheriting from Gio.Menu. + Its constructor builds the entire menu structure. + """ + + def __init__(self): + super().__init__() + + # Store references to menus that can have addon items + self._addon_sections = {} + + # File Menu + file_menu = Gio.Menu() + file_io_group = Gio.Menu() + file_io_group.append(_("New"), "win.new") + file_io_group.append(_("Open..."), "win.open") + file_io_group.append(_("Save"), "win.save") + file_io_group.append(_("Save As..."), "win.save-as") + file_menu.append_section(None, file_io_group) + + # New "Open Recent" submenu + self.recent_files_menu = Gio.Menu() + self.dynamic_recent_files_section = Gio.Menu() + self.recent_files_menu.append_section( + None, self.dynamic_recent_files_section + ) + file_menu.append_submenu(_("Open Recent"), self.recent_files_menu) + + import_export_group = Gio.Menu() + import_export_group.append(_("Import..."), "win.import") + import_export_group.append(_("Export G-code..."), "win.export") + import_export_group.append( + _("Export Document..."), "win.export_document" + ) + file_menu.append_section(None, import_export_group) + + quit_group = Gio.Menu() + quit_group.append(_("Quit"), "win.quit") + file_menu.append_section(None, quit_group) + self.append_submenu(_("_File"), file_menu) + + # Edit Menu + edit_menu = Gio.Menu() + history_group = Gio.Menu() + history_group.append(_("Undo"), "win.undo") + history_group.append(_("Redo"), "win.redo") + edit_menu.append_section(None, history_group) + + clipboard_group = Gio.Menu() + clipboard_group.append(_("Cut"), "win.cut") + clipboard_group.append(_("Copy"), "win.copy") + clipboard_group.append(_("Paste"), "win.paste") + clipboard_group.append(_("Duplicate"), "win.duplicate") + edit_menu.append_section(None, clipboard_group) + + selection_group = Gio.Menu() + selection_group.append(_("Select All"), "win.select_all") + selection_group.append(_("Remove"), "win.remove") + selection_group.append(_("Clear Document"), "win.clear") + edit_menu.append_section(None, selection_group) + + settings_group = Gio.Menu() + settings_group.append(_("Settings"), "win.settings") + edit_menu.append_section(None, settings_group) + self.append_submenu(_("_Edit"), edit_menu) + + # View Menu + view_menu = Gio.Menu() + visibility_group = Gio.Menu() + visibility_group.append( + _("Show Right Panel"), "win.toggle_right_panel" + ) + visibility_group.append( + _("Show Bottom Panel"), "win.toggle_bottom_panel" + ) + view_menu.append_section(None, visibility_group) + + self._view_addon_group = visibility_group + + view_group = Gio.Menu() + view_group.append(_("3D View"), "win.show_3d_view") + view_menu.append_section(None, view_group) + + view_3d_commands = Gio.Menu() + view_3d_commands.append(_("Top View"), "win.view_top") + view_3d_commands.append(_("Front View"), "win.view_front") + view_3d_commands.append(_("Right View"), "win.view_right") + view_3d_commands.append(_("Left View"), "win.view_left") + view_3d_commands.append(_("Back View"), "win.view_back") + view_3d_commands.append(_("Isometric View"), "win.view_iso") + view_3d_commands.append( + _("Toggle Perspective"), "win.view_toggle_perspective" + ) + view_menu.append_section(None, view_3d_commands) + self.append_submenu(_("_View"), view_menu) + + # Object Menu + object_menu = Gio.Menu() + + other_group = Gio.Menu() + other_group.append(_("Split"), "win.split") + other_group.append(_("Export Object..."), "win.export-object") + object_menu.append_section(None, other_group) + + # Addon section for Object menu + self._addon_sections["object"] = Gio.Menu() + object_menu.append_section(None, self._addon_sections["object"]) + + tab_submenu = Gio.Menu() + tab_submenu.append( + _("Add Equidistant Tabs…"), "win.add-tabs-equidistant" + ) + tab_submenu.append(_("Add Cardinal Tabs"), "win.add-tabs-cardinal") + object_menu.append_submenu(_("Add Tabs"), tab_submenu) + self.append_submenu(_("_Object"), object_menu) + + # Arrange Menu + arrange_menu = Gio.Menu() + grouping_group = Gio.Menu() + grouping_group.append(_("Group"), "win.group") + grouping_group.append(_("Ungroup"), "win.ungroup") + arrange_menu.append_section(None, grouping_group) + + layer_group = Gio.Menu() + layer_group.append( + _("Move Selection to Layer Above"), "win.layer-move-up" + ) + layer_group.append( + _("Move Selection to Layer Below"), "win.layer-move-down" + ) + arrange_menu.append_section(None, layer_group) + + align_submenu = Gio.Menu() + align_submenu.append(_("Left"), "win.align-left") + align_submenu.append(_("Right"), "win.align-right") + align_submenu.append(_("Top"), "win.align-top") + align_submenu.append(_("Bottom"), "win.align-bottom") + align_submenu.append(_("Horizontally Center"), "win.align-h-center") + align_submenu.append(_("Vertically Center"), "win.align-v-center") + arrange_menu.append_submenu(_("Align"), align_submenu) + + distribute_submenu = Gio.Menu() + distribute_submenu.append(_("Spread Horizontally"), "win.spread-h") + distribute_submenu.append(_("Spread Vertically"), "win.spread-v") + arrange_menu.append_submenu(_("Distribute"), distribute_submenu) + + flip_submenu = Gio.Menu() + flip_submenu.append(_("Flip Horizontal"), "win.flip-horizontal") + flip_submenu.append(_("Flip Vertical"), "win.flip-vertical") + arrange_menu.append_submenu(_("Flip"), flip_submenu) + + array_submenu = Gio.Menu() + array_submenu.append(_("Grid"), "win.array-grid") + array_submenu.append(_("Point Rotation"), "win.array-point-rotation") + array_submenu.append(_("Circular"), "win.array-circular") + arrange_menu.append_submenu(_("Array"), array_submenu) + + self._layout_group = Gio.Menu() + arrange_menu.append_section(None, self._layout_group) + + self.append_submenu(_("Arrange"), arrange_menu) + + # Tools Menu + tools_menu = Gio.Menu() + + # Addon section for Tools menu + self._addon_sections["tools"] = Gio.Menu() + tools_menu.append_section(None, self._addon_sections["tools"]) + + self.append_submenu(_("_Tools"), tools_menu) + + # Machine Menu + machine_menu = Gio.Menu() + jog_group = Gio.Menu() + jog_group.append(_("Home"), "win.machine-home") + jog_group.append(_("Frame"), "win.machine-frame") + machine_menu.append_section(None, jog_group) + + # Macros submenu under jog controls + macros_menu = Gio.Menu() + # This section will be populated dynamically + self.dynamic_macros_section = Gio.Menu() + macros_menu.append_section(None, self.dynamic_macros_section) + machine_menu.append_submenu(_("Macros"), macros_menu) + + job_group = Gio.Menu() + job_group.append(_("Send Job"), "win.machine-send") + job_group.append(_("Pause / Resume Job"), "win.machine-hold") + job_group.append(_("Cancel Job"), "win.machine-cancel") + job_group.append(_("Clear Alarm"), "win.machine-clear-alarm") + machine_menu.append_section(None, job_group) + + machine_settings_group = Gio.Menu() + machine_settings_group.append( + _("Machine Settings"), "win.machine-settings" + ) + machine_menu.append_section(None, machine_settings_group) + + # Addon section for Machine menu + self._addon_sections["machine"] = Gio.Menu() + machine_menu.append_section(None, self._addon_sections["machine"]) + + self.append_submenu(_("_Machine"), machine_menu) + + # Help Menu + help_menu = Gio.Menu() + help_menu.append(_("About"), "win.about") + help_menu.append(_("Donate"), "win.donate") + help_menu.append(_("Save Debug Log"), "win.save_debug_log") + self.append_submenu(_("_Help"), help_menu) + + # Populate addon menu items + self._populate_addon_items() + self._populate_layout_group() + + # Connect to action registry changes + action_registry.changed.connect(self._on_action_registry_changed) + + def _on_action_registry_changed(self, sender): + """Handle action registry changes by refreshing addon items.""" + self._populate_addon_items() + self._populate_layout_group() + + def _populate_layout_group(self): + """Populate layout strategies in the Arrange menu.""" + self._layout_group.remove_all() + items = action_registry.get_menu_items("arrange") + for info in items: + if info.label: + self._layout_group.append( + info.label, f"win.{info.action_name}" + ) + + def _populate_addon_items(self): + """Populate addon menu items from the action registry.""" + for menu_id, section in self._addon_sections.items(): + section.remove_all() + items = action_registry.get_menu_items(menu_id) + for info in items: + if info.label: + menu_item = Gio.MenuItem.new( + info.label, f"win.{info.action_name}" + ) + section.append_item(menu_item) + + self._populate_view_addon_items() + + def _populate_view_addon_items(self): + """Populate view addon items into the visibility group.""" + n_static = 2 + group = self._view_addon_group + while group.get_n_items() > n_static: + group.remove(n_static) + items = action_registry.get_menu_items("view") + for info in items: + if info.label: + group.append(info.label, f"win.{info.action_name}") + + def update_macros_menu(self, macros: list[Macro]): + """Clears and rebuilds the dynamic macro execution menu items.""" + self.dynamic_macros_section.remove_all() + for macro in macros: + action_name = f"win.execute-macro('{macro.uid}')" + self.dynamic_macros_section.append(macro.name, action_name) + + def update_recent_files_menu(self, recent_infos: list[Gtk.RecentInfo]): + """Clears and rebuilds the dynamic recent files menu.""" + self.dynamic_recent_files_section.remove_all() + + if not recent_infos: + # Action is None, so it will appear insensitive + self.dynamic_recent_files_section.append( + _("(No Recent Items)"), None + ) + return + + for info in recent_infos: + # Use Glib.markup_escape to prevent issues with special chars + # in filenames. + display_name = GLib.markup_escape_text(info.get_display_name()) + uri = info.get_uri() + action_name = f"win.open-recent('{uri}')" + self.dynamic_recent_files_section.append(display_name, action_name) diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py new file mode 100644 index 000000000..82147acf9 --- /dev/null +++ b/rayforge/ui_gtk/mainwindow.py @@ -0,0 +1,2288 @@ +import asyncio +import logging +import webbrowser +from collections.abc import Callable, Coroutine +from concurrent.futures import Future +from gettext import gettext as _ +from pathlib import Path + +from gi.repository import Adw, Gdk, Gio, GLib, Gtk +from raygeo.ops.axis import Axis + +from .. import __version__, const +from ..addon_mgr.update_cmd import UpdateCommand +from ..context import get_context +from ..core.asset_registry import asset_type_registry +from ..core.group import Group +from ..core.item import DocItem +from ..core.registration import call_registration_hooks +from ..core.undo import Command, HistoryManager +from ..doceditor.editor import DocEditor +from ..machine.cmd import MachineCmd +from ..machine.driver.driver import DeviceState, DeviceStatus +from ..machine.driver.dummy import NoDeviceDriver +from ..machine.models.machine import Machine +from ..machine.sanity import CheckMode, SanityChecker +from ..machine.transport import TransportStatus +from ..pipeline.artifact import JobArtifact +from ..pipeline.artifact.handle import BaseArtifactHandle +from ..pipeline.encoder import MachineCodeOpMap +from ..shared.tasker import task_mgr +from ..shared.util.time_format import format_hours_to_hm +from ..updater import AppUpdateChecker +from ..usage import get_usage_tracker +from .about import AboutDialog +from .action_registry import action_registry +from .actions import ( + SHORTCUTS, + ActionManager, + action_extension_registry, +) +from .canvas import CanvasElement +from .canvas2d.drag_drop_cmd import DragDropCmd +from .canvas2d.elements.stock import StockElement +from .canvas2d.surface import WorkSurface +from .debug_log_dialog import DebugLogDialog +from .doceditor import file_dialogs +from .doceditor.bottom_panel import BottomPanel +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.workflow_view import WorkflowView +from .machine.machine_dropdown import MachineDropdown +from .machine.settings_dialog import MachineSettingsDialog +from .main_menu import MainMenu +from .project_cmd import ProjectCmd +from .settings.settings_dialog import SettingsWindow +from .shared.gtk import get_monitor_geometry +from .shared.progress_bar import ProgressBar +from .shared.sanity_check_dialog import SanityCheckDialog +from .shared.time_estimate_overlay import TimeEstimateOverlay +from .shared.usage_consent_dialog import UsageConsentDialog +from .shared.visibility_overlay import VisibilityOverlay +from .sim3d import Canvas3D +from .sim3d import initialized as canvas3d_initialized +from .sim3d.camera import ViewDirection +from .sim3d.playback_overlay import PlaybackOverlay +from .sim3d.viewport import ViewportConfig +from .toolbar import MainToolbar +from .view_mode_cmd import ViewModeCmd + +logger = logging.getLogger(__name__) + + +css = """ +.right-panel-overlay { + background-color: transparent; + border-radius: 8px; + margin: 6px 12px 12px 6px; + box-shadow: 0 2px 12px alpha(black, 0.2); +} + +.status-message-overlay { + background-color: @theme_bg_color; + border-radius: 6px; + padding: 4px 10px; + box-shadow: 0 2px 6px alpha(black, 0.15); +} + +.in-header-menubar { + margin-left: 6px; + box-shadow: none; +} + +.in-header-menubar item { + padding: 6px 12px 6px 12px; +} + +.menu separator { + border-top: 1px solid @borders; + margin-top: 5px; + margin-bottom: 5px; +} + +.warning-label { + color: @warning_color; + font-weight: bold; +} + +dropdown.machine-dropdown button { + padding-top: 2px; + padding-bottom: 2px; +} +""" + + +class CappedWidthBox(Gtk.Box): + """A Gtk.Box whose natural width is capped to a maximum value.""" + + def __init__(self, max_natural_width: int, **kwargs): + super().__init__(**kwargs) + self._max_natural_width = max_natural_width + + def do_measure(self, orientation, for_size): + minimum, natural, min_baseline, nat_baseline = super().do_measure( + orientation, for_size + ) + if orientation == Gtk.Orientation.HORIZONTAL: + minimum = min(minimum, self._max_natural_width) + natural = min(natural, self._max_natural_width) + return minimum, natural, min_baseline, nat_baseline + + +class MainWindow(Adw.ApplicationWindow): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_title(const.APP_NAME) + self._current_machine: Machine | None = None # For signal handling + self._last_bottom_panel_height = 200 + self._saved_bottom_panel_visible = False + self._old_doc = None # Track previous document for signal reconnection + self.canvas3d: Canvas3D | None = None + self._canvas3d_time_overlay: TimeEstimateOverlay | None = None + self._is_syncing_3d = False + + # The ToastOverlay will wrap the main content box + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + # Track active toasts so they can be cleared programmatically + self._active_toasts: list[Adw.Toast] = [] + + # The main content box is now the child of the ToastOverlay + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.toast_overlay.set_child(vbox) + + # Create the central document editor. This now owns the Doc and + # Pipeline. + context = get_context() + self.doc_editor = DocEditor(task_mgr, context) + context.addon_mgr.addon_state_changed.connect( + self._on_addon_state_changed + ) + self.machine_cmd = MachineCmd(self.doc_editor) + self.machine_cmd.job_started.connect(self._on_job_started) + + # Instantiate and connect the UpdateCommand's notification signal + self.update_cmd = UpdateCommand(task_mgr, context) + self.update_cmd.notification_requested.connect( + self._on_editor_notification + ) + + # Instantiate the app version update checker + self.app_update_checker = AppUpdateChecker(task_mgr, context) + self.app_update_checker.notification_requested.connect( + self._on_editor_notification + ) + + # Instantiate UI-specific command handlers + self.view_cmd = ViewModeCmd(self.doc_editor, self) + self.project_cmd = ProjectCmd(self, self.doc_editor) + + geometry = get_monitor_geometry() + if geometry: + self.set_default_size( + int(geometry.width * 0.8), int(geometry.height * 0.8) + ) + else: + self.set_default_size(1100, 800) + + # HeaderBar with left-aligned menu and centered title + self.header_bar = Adw.HeaderBar() + vbox.append(self.header_bar) + + # Create the menu model and the popover menubar + self.menu_model = MainMenu() + self.menubar = Gtk.PopoverMenuBar.new_from_model(self.menu_model) + self.menubar.add_css_class("in-header-menubar") + self.header_bar.pack_start(self.menubar) + + # Set up Recent Files manager + self.recent_manager = Gtk.RecentManager.get_default() + self.recent_manager.connect( + "changed", self.project_cmd.update_recent_files_menu + ) + self.project_cmd.update_recent_files_menu() + + # Create and set the centered title widget + window_title = Adw.WindowTitle( + title=self.get_title() or "", subtitle=__version__ or "" + ) + self.header_bar.set_title_widget(window_title) + + # Add machine selector to the header bar (right side) + self.machine_selector = MachineDropdown() + self.header_bar.pack_end(self.machine_selector) + + # Create a vertical paned for main content and bottom control panel + self.vertical_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) + self.vertical_paned.set_resize_start_child(True) + self.vertical_paned.set_resize_end_child(False) + self.vertical_paned.set_shrink_start_child(False) + self.vertical_paned.set_shrink_end_child(False) + + self._status_overlay = Gtk.Overlay() + self._status_overlay.set_child(self.vertical_paned) + + self._status_message_label = Gtk.Label( + halign=Gtk.Align.END, + valign=Gtk.Align.END, + margin_end=12, + margin_bottom=6, + ) + self._status_message_label.add_css_class("status-message-overlay") + self._status_message_label.set_visible(False) + self._status_overlay.add_overlay(self._status_message_label) + + vbox.append(self._status_overlay) + + # Create a stack for switching between main view and addon pages + self.main_stack = Gtk.Stack() + self.main_stack.set_vexpand(True) + self.main_stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_UP_DOWN + ) + self.vertical_paned.set_start_child(self.main_stack) + + # Create a container for the main UI + main_ui_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.main_stack.add_named(main_ui_box, "main") + + # Create and add the main toolbar. + self.toolbar = MainToolbar() + self._connect_toolbar_signals() + main_ui_box.append(self.toolbar) + + # Create an overlay so the right panel can float above the canvas. + self._canvas_overlay = Gtk.Overlay() + self._canvas_overlay.set_vexpand(True) + main_ui_box.append(self._canvas_overlay) + + # Apply styles + display = Gdk.Display.get_default() + if display: + provider = Gtk.CssProvider() + provider.load_from_string(css) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + # Determine initial machine dimensions for canvases. + context = get_context() + config = context.config + if config.machine: + viewport = ViewportConfig.from_machine(config.machine) + else: + viewport = ViewportConfig.default() + + self.surface = WorkSurface( + editor=self.doc_editor, + parent_window=self, + machine=config.machine, + cam_visible=True, # Will be set by action state + ) + self.surface.set_hexpand(True) + + # Initialize drag-and-drop command for the surface + self.drag_drop_cmd = DragDropCmd(self, self.surface) + self.surface.drag_drop_cmd = self.drag_drop_cmd + self.drag_drop_cmd.setup_drop_targets() + + # Set up action registry before registering actions + action_registry.set_window(self) + self.action_registry = action_registry + + # Let addons register action extension handlers before + # ActionManager.register_actions() invokes setup handlers. + call_registration_hooks(context.plugin_mgr, window_required=True) + + # Setup keyboard actions using the new ActionManager. + self.action_manager = ActionManager(self) + self.action_manager.register_actions() + + shortcut_controller = Gtk.ShortcutController() + self.action_manager.register_shortcuts(shortcut_controller) + self.add_controller(shortcut_controller) + + # Connect document signals + doc = self.doc_editor.doc + self._old_doc = doc # Track initial document for signal reconnection + self._initialize_document() + doc.updated.connect(self.on_doc_changed) + doc.descendant_added.connect(self.on_doc_changed) + doc.descendant_removed.connect(self.on_doc_changed) + doc.descendant_updated.connect(self.on_doc_changed) + doc.active_layer_changed.connect(self._on_active_layer_changed) + doc.history_manager.changed.connect(self.on_history_changed) + + # Connect editor signals + self.doc_editor.notification_requested.connect( + self._on_editor_notification + ) + self.doc_editor.document_settled.connect(self._on_document_settled) + self.doc_editor.saved_state_changed.connect( + self.project_cmd.on_saved_state_changed + ) + self.doc_editor.document_changed.connect(self._on_document_changed) + + # Create the view stack for 2D and 3D views + self.view_stack = Gtk.Stack() + self.view_stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + ) + self.view_stack.set_margin_start(12) + self.view_stack.set_hexpand(True) + + # The view stack is the base child of the canvas overlay + self._canvas_overlay.set_child(self.view_stack) + + # Wrap surface in an overlay to allow preview controls + self.surface_overlay = Gtk.Overlay() + self.surface_overlay.set_child(self.surface) + self._surface_vis_overlay = VisibilityOverlay( + show_workpiece=True, + show_camera=bool( + config.machine + and any(c.enabled for c in config.machine.cameras) + ), + show_tabs=True, + shortcuts=SHORTCUTS, + ) + self._surface_vis_overlay.set_margin_end(454) + self.surface_overlay.add_overlay(self._surface_vis_overlay) + self._time_estimate_overlay = TimeEstimateOverlay() + self.surface_overlay.add_overlay(self._time_estimate_overlay) + self.view_stack.add_named(self.surface_overlay, "2d") + + # Add a click handler to unfocus when clicking the "dead space" of the + # canvas area. This is the correct place for this handler, as it won't + # interfere with clicks on the sidebar. + canvas_click_gesture = Gtk.GestureClick.new() + canvas_click_gesture.connect( + "pressed", self._on_canvas_area_click_pressed + ) + # self.surface_overlay.add_controller(canvas_click_gesture) + + if canvas3d_initialized: + self._create_canvas3d(context, viewport) + + self._sync_view_toggle_actions() + + # Undo/Redo buttons are now connected to the doc via actions. + self.toolbar.undo_button.set_history_manager( + self.doc_editor.history_manager + ) + self.toolbar.redo_button.set_history_manager( + self.doc_editor.history_manager + ) + + # Create a vertical paned for the right pane content + self._right_pane = Gtk.ScrolledWindow() + self._right_pane.set_policy( + Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC + ) + self._right_pane.set_vexpand(True) + self._right_pane.add_css_class("right-panel-overlay") + self._right_pane.set_halign(Gtk.Align.END) + self._right_pane.set_valign(Gtk.Align.START) + self._right_pane.set_propagate_natural_height(True) + self._canvas_overlay.add_overlay(self._right_pane) + + # Create a vertical box to organize the content within the + # ScrolledWindow. + right_pane_box = CappedWidthBox( + 430, orientation=Gtk.Orientation.VERTICAL + ) + right_pane_box.set_size_request(430, -1) + self._right_pane.set_child(right_pane_box) + + # The WorkflowView will be updated when a layer is activated. + initial_workflow = self.doc_editor.doc.active_layer.workflow + assert initial_workflow, "Initial active layer must have a workflow" + self.workflowview = WorkflowView( + self.doc_editor, + initial_workflow, + ) + self.workflowview.set_margin_top(6) + self.workflowview.set_margin_end(12) + right_pane_box.append(self.workflowview) + + # Register built-in property providers before creating the widget + register_builtin_providers() + + # Add the WorkpiecePropertiesWidget + self.item_props_widget = DocItemPropertiesWidget( + editor=self.doc_editor + ) + item_props_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.item_props_widget.set_margin_top(6) + self.item_props_widget.set_margin_end(12) + item_props_container.append(self.item_props_widget) + + self.item_revealer = Gtk.Revealer() + self.item_revealer.set_child(item_props_container) + self.item_revealer.set_reveal_child(False) + self.item_revealer.set_transition_type( + Gtk.RevealerTransitionType.SLIDE_UP + ) + right_pane_box.append(self.item_revealer) + + # Connect signals for item selection and actions + self.surface.selection_changed.connect(self._on_selection_changed) + self.surface.elements_deleted.connect(self.on_elements_deleted) + self.surface.cut_requested.connect(self.on_cut_requested) + self.surface.copy_requested.connect(self.on_copy_requested) + self.surface.paste_requested.connect(self.on_paste_requested) + self.surface.duplicate_requested.connect(self.on_duplicate_requested) + self.surface.transform_initiated.connect( + self._on_surface_transform_initiated + ) + self.surface.transform_end.connect(self._on_surface_transform_end) + self.surface.work_zero_requested.connect(self._on_work_zero_requested) + self.surface.click_to_zero_cancelled.connect( + self._on_click_to_zero_cancelled + ) + + # Connect new signal from WorkSurface for edit item requests + self.surface.edit_item_requested.connect(self._on_edit_item_requested) + + # Create the control panel + config = get_context().config + self.bottom_panel = BottomPanel( + config.machine, self.doc_editor, self.machine_cmd + ) + self.bottom_panel.set_size_request(-1, self._last_bottom_panel_height) + self.bottom_panel.set_visible(True) + self.vertical_paned.set_end_child(self.bottom_panel) + + self.bottom_panel.gcode_viewer.line_activated.connect( + self._on_gcode_line_activated + ) + + # Connect edit item requests from the layers tab + self.bottom_panel.edit_item_requested.connect( + self._on_edit_item_requested + ) + self.bottom_panel.select_items_requested.connect( + self._on_select_items_requested + ) + + config = get_context().config + if config.bottom_panel: + self.bottom_panel.from_dict(config.bottom_panel) + + self.bottom_panel.tab_changed.connect(self._on_bottom_tab_changed) + self.bottom_panel.layout_changed.connect( + self._on_bottom_layout_changed + ) + + self.bottom_panel.click_to_zero_mode_changed.connect( + self._on_click_to_zero_mode_changed + ) + + self.bottom_panel.asset_browser.add_asset_requested.connect( + self.on_add_asset_requested + ) + self.bottom_panel.asset_browser.asset_activated.connect( + self.on_asset_activated + ) + + self.bottom_panel.set_get_bounds_callback( + self.surface.get_selection_bounds + ) + + self.view_stack.connect( + "notify::visible-child-name", self._on_view_stack_changed + ) + + # Connect to position signal to remember user's chosen height + self.vertical_paned.connect( + "notify::position", self._on_vertical_pane_position_changed + ) + + # Create and add the progress bar at the bottom of vbox + self.progress_bar = ProgressBar(task_mgr) + gesture = Gtk.GestureClick() + gesture.connect( + "pressed", lambda *args: self.on_status_bar_clicked(None) + ) + self.progress_bar.add_controller(gesture) + vbox.append(self.progress_bar) + + self.doc_editor.pipeline.job_time_updated.connect( + self._on_job_time_updated + ) + self.doc_editor.pipeline.job_generation_finished.connect( + self._on_job_generation_finished_for_preview + ) + + # Set up config signals. + config.changed.connect(self.on_config_changed) + task_mgr.tasks_updated.connect(self.on_running_tasks_changed) + self.needs_homing = ( + config.machine.home_on_start if config.machine else False + ) + + # Set initial state + self.on_config_changed(None) + + # Apply saved visibility state + self._apply_saved_visibility_state() + + # Notify addons that main window is ready + context.plugin_mgr.hook.main_window_ready(main_window=self) + + # Trigger startup tasks when window is shown + self.connect("map", self._trigger_startup_tasks) + + def _trigger_startup_tasks(self, widget): + """ + Runs once when the window is first shown. + """ + # Disconnect self to ensure it only runs once + self.disconnect_by_func(self._trigger_startup_tasks) + + # Initialize usage tracking based on saved consent + config = get_context().config + if config.has_consented_tracking: + get_usage_tracker().set_enabled(True) + get_usage_tracker().track_page_view("/view/2d", "2D View") + elif config.has_declined_tracking: + pass # Explicitly do nothing, respecting the user's choice + else: + dialog = UsageConsentDialog(self) + dialog.present() + + # Trigger the non-blocking check for addon updates + self.update_cmd.check_for_updates_on_startup() + + # Trigger the non-blocking check for app version updates + self.app_update_checker.check_on_startup() + + def _on_click_to_zero_mode_changed(self, sender, *, active: bool): + """Handle click-to-zero mode toggle from control panel.""" + self.surface.set_click_to_zero_mode(active) + + def _on_work_zero_requested(self, sender, *, x: float, y: float): + """Handle work zero request from canvas click.""" + config = get_context().config + if not config.machine: + return + + async def set_zero_func(ctx): + if config.machine: + await config.machine.set_work_origin(x, y, 0.0) + + task_mgr.add_coroutine(set_zero_func) + self.bottom_panel.set_click_to_zero_mode(False) + + def _on_click_to_zero_cancelled(self, sender): + """Handle click-to-zero mode cancellation.""" + self.bottom_panel.set_click_to_zero_mode(False) + + def _apply_saved_visibility_state(self): + """ + Applies the saved visibility state for control panel. + This should be called after actions are registered. + """ + config = get_context().config + + bottom_panel_action = self.action_manager.get_action( + "toggle_bottom_panel" + ) + if ( + bottom_panel_action + and config.bottom_panel + and config.bottom_panel.get("visible") + ): + bottom_panel_action.change_state(GLib.Variant.new_boolean(True)) + + def add_stack_page(self, name: str, widget: Gtk.Widget): + """Add a page to the main stack. + + This is a public API for addons to add their own pages to the + main stack (e.g., editor views). + + Args: + name: The name/identifier for the page + widget: The widget to add as a page + """ + self.main_stack.add_named(widget, name) + + def show_stack_page(self, name: str): + """Switch to a named page in the main stack. + + Args: + name: The name of the page to show + """ + self.main_stack.set_visible_child_name(name) + + def remove_stack_page(self, name: str): + """Remove a page from the main stack. + + Args: + name: The name of the page to remove + """ + child = self.main_stack.get_child_by_name(name) + if child: + self.main_stack.remove(child) + + def get_stack_page(self, name: str) -> Gtk.Widget | None: + """Get a page widget from the main stack by name. + + Args: + name: The name of the page to get + + Returns: + The widget if found, None otherwise + """ + return self.main_stack.get_child_by_name(name) + + def open_modal_page(self, name: str): + """Open a modal page, hiding auxiliary panels. + + This is used for full-screen editor modes (like the sketcher) that + should hide panels like the control panel. + + Args: + name: The name of the modal page to show + """ + self._saved_bottom_panel_visible = self.bottom_panel.get_visible() + if self._saved_bottom_panel_visible: + self.bottom_panel.set_visible(False) + self.main_stack.set_visible_child_name(name) + + def close_modal_page(self): + """Close the current modal page and return to main view. + + Restores the visibility of auxiliary panels that were hidden. + """ + if self._saved_bottom_panel_visible: + self.bottom_panel.set_visible(True) + self.main_stack.set_visible_child_name("main") + + def on_add_child(self, sender): + """Handler for adding a new stock item.""" + self.doc_editor.stock.add_stock() + + def on_add_asset_requested(self, sender, *, type_name: str): + """Handler for add asset requests, dispatches via action lookup.""" + asset_cls = asset_type_registry.get(type_name) + if asset_cls and asset_cls.add_action: + action = self.action_manager.get_action(asset_cls.add_action) + if action: + action.activate(None) + + def on_asset_activated(self, sender, *, asset): + """Handler for asset activation, dispatches via action lookup.""" + asset_cls = type(asset) + if asset_cls.activate_action: + action = self.action_manager.get_action(asset_cls.activate_action) + if action: + action.activate(GLib.Variant.new_string(asset.uid)) + + def _on_edit_item_requested(self, sender, *, item, action_name: str): + """Signal handler for edit item requests from the surface.""" + action = self.action_manager.get_action(action_name) + if action: + action.activate(GLib.Variant.new_string(item.uid)) + + def _on_select_items_requested(self, sender, *, items, **kwargs): + self.surface.select_items(items) + + def load_project(self, file_path: Path): + """Public method to load a project from a given path.""" + self.project_cmd.load_project(file_path) + + def _update_macros_menu(self, *args): + """Rebuilds the dynamic 'Macros' menu.""" + config = get_context().config + if not config.machine: + self.menu_model.update_macros_menu([]) + return + + macros = sorted( + config.machine.macros.values(), key=lambda m: m.name.lower() + ) + enabled_macros = [m for m in macros if m.enabled] + self.menu_model.update_macros_menu(enabled_macros) + + def on_execute_macro(self, action: Gio.SimpleAction, param: GLib.Variant): + """Handler for the 'execute-macro' action.""" + config = get_context().config + if not config.machine: + return + macro_uid = param.get_string() + logger.info(f"Executing macro: {macro_uid}") + self.machine_cmd.execute_macro_by_uid(config.machine, macro_uid) + + def _on_job_started(self, sender): + logger.debug("Job started") + self.machine_selector.update_eta(None) + self._update_actions_and_ui() + + def _on_addon_state_changed(self, sender, addon_name): + """Handle addon enable/disable to refresh action handlers.""" + action_extension_registry.invoke_setup_handlers(self.action_manager) + self.action_manager.update_action_states() + + def _on_job_progress_updated(self, metrics: dict): + """Callback for when job progress is updated.""" + eta_seconds = metrics.get("eta_seconds") + self.machine_selector.update_eta(eta_seconds) + + def _on_job_finished(self, sender): + """Handles the completion of a machine job.""" + logger.debug("Job finished") + self.machine_selector.update_eta(None) + + def _on_job_future_done(self, future: Future): + """Callback for when the job submission task completes or fails.""" + try: + # Check for exceptions during job assembly or submission. + future.result() + except Exception: + logger.exception("Job submission failed") + # If the submission failed, the driver's 'job_finished' signal + # will never fire, so we must stop the live view here to prevent + # the UI from getting stuck. + self.machine_selector.update_eta(None) + + # Ensure UI is updated (e.g. Cancel button disabled, others enabled) + self._update_actions_and_ui() + + def _on_bottom_tab_changed(self, sender, *, name: str): + if name == "gcode": + self.refresh_previews() + self._save_bottom_panel() + + def _on_bottom_layout_changed(self, sender): + self._save_bottom_panel() + + def _save_bottom_panel(self): + get_context().config.set_bottom_panel(self.bottom_panel.to_dict()) + + def _on_gcode_line_activated(self, sender, *, line_number: int): + """ + Handles the user activating a line in the G-code previewer. + Syncs the highlight and the 3D playback slider. + """ + # 1. Update the visual highlight to match the cursor, no scroll. + self.bottom_panel.gcode_viewer.highlight_line( + line_number, use_align=False + ) + + # 2. If 3D playback is active, sync the slider. + op_map = self.bottom_panel.gcode_viewer.op_map + op_index = op_map.op_for_line(line_number) if op_map else None + if op_index is not None: + self._is_syncing_3d = True + self._canvas3d_playback.set_playback_position(op_index) + if self.canvas3d: + self.canvas3d.queue_render() + self._is_syncing_3d = False + + def _on_3d_playback_step_changed(self, sender, *, ops_index: int): + """ + Handles the 3D playback slider changing. Syncs the G-code viewer + highlight to the corresponding line. + """ + if self._is_syncing_3d: + return + self.bottom_panel.gcode_viewer.highlight_op(ops_index) + + def _on_vertical_pane_position_changed(self, paned, param): + position = paned.get_position() + full_height = paned.get_height() + panel_height = full_height - position + if panel_height > 1: + self._last_bottom_panel_height = panel_height + + def _on_surface_transform_initiated(self, sender): + pass + + def _on_view_stack_changed(self, stack: Gtk.Stack, param): + """Handles logic when switching between 2D and 3D views.""" + child_name = stack.get_visible_child_name() + if child_name == "3d": + self._update_3d_view_content() + self._update_actions_and_ui() + + def _update_3d_view_content(self): + """ + Updates the 3D canvas by delegating to its internal update method. + This is now a fast, non-blocking operation. + """ + if not self.canvas3d: + return + if self.canvas3d.has_stale_job(): + self.refresh_previews() + self.canvas3d.update_scene_from_doc() + + def _update_gcode_preview( + self, gcode_string: str | None, op_map: MachineCodeOpMap | None + ): + """Updates the G-code preview panel from a pre-generated string.""" + if gcode_string is None: + self.bottom_panel.gcode_viewer.clear() + return + + self.bottom_panel.gcode_viewer.set_gcode(gcode_string) + if op_map: + self.bottom_panel.gcode_viewer.set_op_map(op_map) + + def on_show_3d_view( + self, action: Gio.SimpleAction, value: GLib.Variant | None + ): + """Delegates the view switching logic to the command module.""" + self.view_cmd.toggle_3d_view(action, value) + + def on_show_workpieces_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + self.surface.set_workpieces_visible(is_visible) + action.set_state(value) + config = get_context().config + config.canvas_view.show_workpieces = is_visible + config.changed.send(config) + + def on_toggle_camera_view_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + self.surface.set_camera_image_visibility(is_visible) + action.set_state(value) + config = get_context().config + config.canvas_view.show_camera = is_visible + config.changed.send(config) + + def on_toggle_travel_view_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + self.surface.set_show_travel_moves(is_visible) + if self.canvas3d is not None: + self.canvas3d.set_show_travel_moves(is_visible) + action.set_state(value) + config = get_context().config + config.canvas_view.show_travel_lines = is_visible + config.changed.send(config) + + def on_show_nogo_zones_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + self.surface.set_show_nogo_zones(is_visible) + if self.canvas3d is not None: + self.canvas3d.set_show_nogo_zones(is_visible) + action.set_state(value) + config = get_context().config + config.canvas_view.show_nogo_zones = is_visible + config.changed.send(config) + + def on_show_models_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + if self.canvas3d is not None: + self.canvas3d.set_show_models(is_visible) + action.set_state(value) + config = get_context().config + config.canvas_view.show_models = is_visible + config.changed.send(config) + + def on_show_grid_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + if self.canvas3d is not None: + self.canvas3d.set_show_grid(is_visible) + action.set_state(value) + config = get_context().config + config.canvas_view.show_grid = is_visible + config.changed.send(config) + + def on_view_top(self, action, param): + """Action handler to set the 3D view to top-down.""" + self.view_cmd.set_view(ViewDirection.TOP, self.canvas3d) + + def on_view_front(self, action, param): + """Action handler to set the 3D view to front.""" + self.view_cmd.set_view(ViewDirection.FRONT, self.canvas3d) + + def on_view_right(self, action, param): + """Action handler to set the 3D view to right.""" + self.view_cmd.set_view(ViewDirection.RIGHT, self.canvas3d) + + def on_view_left(self, action, param): + """Action handler to set the 3D view to left.""" + self.view_cmd.set_view(ViewDirection.LEFT, self.canvas3d) + + def on_view_back(self, action, param): + """Action handler to set the 3D view to back.""" + self.view_cmd.set_view(ViewDirection.BACK, self.canvas3d) + + def on_view_iso(self, action, param): + """Action handler to set the 3D view to isometric.""" + self.view_cmd.set_view(ViewDirection.ISO, self.canvas3d) + + def on_view_perspective_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + """Handles state changes for the perspective view action.""" + self.view_cmd.toggle_perspective(self.canvas3d, action, value) + + def _initialize_document(self): + """ + Adds required initial state to a new document, such as default + steps to workpiece layers. + """ + self.doc_editor.step.initialize_default_steps() + + def _sync_view_toggle_actions(self): + """ + Re-triggers each persisted view toggle action so that both the + canvas surfaces and the overlay buttons reflect the persisted + config values at startup. + """ + am = self.action_manager + cv = get_context().config.canvas_view + + am.get_action("show_workpieces").set_state( + GLib.Variant.new_boolean(not cv.show_workpieces) + ) + self.on_show_workpieces_state_change( + am.get_action("show_workpieces"), + GLib.Variant.new_boolean(cv.show_workpieces), + ) + + am.get_action("toggle_camera_view").set_state( + GLib.Variant.new_boolean(not cv.show_camera) + ) + self.on_toggle_camera_view_state_change( + am.get_action("toggle_camera_view"), + GLib.Variant.new_boolean(cv.show_camera), + ) + + am.get_action("toggle_travel_view").set_state( + GLib.Variant.new_boolean(not cv.show_travel_lines) + ) + self.on_toggle_travel_view_state_change( + am.get_action("toggle_travel_view"), + GLib.Variant.new_boolean(cv.show_travel_lines), + ) + + am.get_action("show_nogo_zones").set_state( + GLib.Variant.new_boolean(not cv.show_nogo_zones) + ) + self.on_show_nogo_zones_state_change( + am.get_action("show_nogo_zones"), + GLib.Variant.new_boolean(cv.show_nogo_zones), + ) + + am.get_action("show_models").set_state( + GLib.Variant.new_boolean(not cv.show_models) + ) + self.on_show_models_state_change( + am.get_action("show_models"), + GLib.Variant.new_boolean(cv.show_models), + ) + + am.get_action("show_grid").set_state( + GLib.Variant.new_boolean(not cv.show_grid) + ) + self.on_show_grid_state_change( + am.get_action("show_grid"), + GLib.Variant.new_boolean(cv.show_grid), + ) + + am.get_action("show_tabs").set_state( + GLib.Variant.new_boolean(not cv.show_tabs) + ) + am.on_show_tabs_state_change( + am.get_action("show_tabs"), + GLib.Variant.new_boolean(cv.show_tabs), + ) + + am.get_action("view_toggle_perspective").set_state( + GLib.Variant.new_boolean(not cv.perspective_mode) + ) + self.on_view_perspective_state_change( + am.get_action("view_toggle_perspective"), + GLib.Variant.new_boolean(cv.perspective_mode), + ) + + def _connect_toolbar_signals(self): + """Connects signals from the MainToolbar to their handlers. + Most buttons are connected via Gio.Actions. Only view-state toggles + and special widgets are connected here. + """ + self.toolbar.machine_warning_clicked.connect( + self.on_machine_warning_clicked + ) + self.machine_selector.machine_selected.connect( + self.on_machine_selected_by_selector + ) + + def on_zero_here_clicked(self, action, param): + """Handler for 'zero-here' action.""" + config = get_context().config + if not config.machine: + return + + # 'param' is likely "all" string from the action setup + axes_to_zero = Axis.X | Axis.Y | Axis.Z + + async def zero_func(ctx): + # Explicitly check again to satisfy type checker + if config.machine: + await config.machine.set_work_origin_here(axes_to_zero) + + # Launch async zeroing + task_mgr.add_coroutine(zero_func) + + def _on_canvas_area_click_pressed(self, gesture, n_press, x, y): + """ + Handler for clicks on the canvas overlay area (the 'dead space'). + It unfocuses any other widget and gives focus to the surface for + keyboard shortcuts. + """ + logger.debug("Clicked on canvas area dead space, focusing surface.") + self.surface.grab_focus() + + def on_machine_selected_by_selector(self, sender, *, machine: Machine): + """ + Handles the 'machine_selected' signal from the MachineSelector widget, + delegating the logic to the MachineManager. + """ + context = get_context() + context.machine_mgr.set_active_machine(machine) + + def _on_machine_status_changed(self, machine: Machine, state: DeviceState): + """Called when the active machine's state changes.""" + config = get_context().config + if ( + self.needs_homing + and config.machine + and config.machine.driver + and state.status == DeviceStatus.IDLE + ): + self.needs_homing = False + driver = config.machine.driver + task_mgr.add_coroutine(lambda ctx: driver.home()) + self._update_actions_and_ui() + + def _on_connection_status_changed( + self, + machine: Machine, + status: TransportStatus, + message: str | None = None, + ): + """Called when the active machine's connection status changes.""" + if ( + status == TransportStatus.CONNECTED + and machine.clear_alarm_on_connect + and machine.device_state.status == DeviceStatus.ALARM + ): + logger.info( + "Machine connected in ALARM state. Auto-clearing alarm." + ) + self.machine_cmd.clear_alarm(machine) + self._update_actions_and_ui() + + def _on_machine_hours_changed(self, sender, **kwargs): + """ + Called when machine hours change. Checks for maintenance notifications. + """ + due_counters = sender.consume_due_notifications() + for counter in due_counters: + msg = _( + "Maintenance Alert: {name} has reached its limit " + "({curr} / {limit})" + ).format( + name=counter.name, + curr=format_hours_to_hm(counter.value), + limit=format_hours_to_hm(counter.notify_at), + ) + self._on_editor_notification( + self, + msg, + persistent=True, + action_label=_("View Counters"), + action_callback=lambda: self._open_machine_hours_dialog(), + ) + + def _open_machine_hours_dialog(self): + """Opens the machine settings dialog on the Hours page.""" + config = get_context().config + if not config.machine: + return + dialog = MachineSettingsDialog( + machine=config.machine, + transient_for=self, + initial_page="hours", + ) + dialog.present() + + def on_history_changed( + self, history_manager: HistoryManager, command: Command + ): + self._update_actions_and_ui() + # After undo/redo, the document state may have changed in ways + # that require a full UI sync (e.g., layer visibility). + self.on_doc_changed(self.doc_editor.doc) + self._update_macros_menu() + + def on_doc_changed(self, sender, **kwargs): + # Synchronize UI elements that depend on the document model + self.surface.update_from_doc() + doc = self.doc_editor.doc + if doc.active_layer and doc.active_layer.workflow: + self.workflowview.set_workflow(doc.active_layer.workflow) + + # Sync the selectability of stock items based on active layer + self._sync_element_selectability() + + # Update button sensitivity and other state + self._update_actions_and_ui() + + def _sync_element_selectability(self): + """ + Updates the 'selectable' property of StockElements on the canvas + based on which layer is currently active and their visibility. + """ + # Find all StockElement instances currently on the canvas + for element in self.surface.find_by_type(StockElement): + # Stock items are only selectable when they are visible + element.selectable = element.visible + + def _on_active_layer_changed(self, sender): + """ + Handles activation of a new layer. Updates the workflow view and + resets the paste counter. + """ + logger.debug("Active layer changed, updating UI.") + # Reset the paste counter to ensure the next paste is in-place. + self.doc_editor.edit.reset_paste_counter() + + # Get the newly activated layer from the document + activated_layer = self.doc_editor.doc.active_layer + has_workflow = activated_layer.workflow is not None + + # Show/hide the workflow view based on the layer type + self.workflowview.set_visible(has_workflow) + + if has_workflow: + # For regular layers, update the workflow view with the + # new workflow + self.workflowview.set_workflow(activated_layer.workflow) + + def _on_document_changed(self, sender): + """ + Handles when a new document is set on the DocEditor. + Reconnects signal handlers to the new document and updates the UI. + """ + new_doc = self.doc_editor.doc + + # Disconnect from old document signals if they were connected + # We need to track the old doc to disconnect properly + if self._old_doc is not None: + self._old_doc.updated.disconnect(self.on_doc_changed) + self._old_doc.descendant_added.disconnect(self.on_doc_changed) + self._old_doc.descendant_removed.disconnect(self.on_doc_changed) + self._old_doc.descendant_updated.disconnect(self.on_doc_changed) + self._old_doc.active_layer_changed.disconnect( + self._on_active_layer_changed + ) + self._old_doc.history_manager.changed.disconnect( + self.on_history_changed + ) + + # Connect to new document's signals + new_doc.updated.connect(self.on_doc_changed) + new_doc.descendant_added.connect(self.on_doc_changed) + new_doc.descendant_removed.connect(self.on_doc_changed) + new_doc.descendant_updated.connect(self.on_doc_changed) + new_doc.active_layer_changed.connect(self._on_active_layer_changed) + new_doc.history_manager.changed.connect(self.on_history_changed) + + # Store reference to current doc for future disconnection + self._old_doc = new_doc + + # Update Undo/Redo buttons to listen to the new history manager + self.toolbar.undo_button.set_history_manager(new_doc.history_manager) + self.toolbar.redo_button.set_history_manager(new_doc.history_manager) + + # Update child views to point to the new document + self.bottom_panel.set_doc(new_doc) + + # Initialize new document + self._initialize_document() + + # Check for missing step types and show dialog if needed + missing_types = new_doc.missing_step_types + if missing_types: + dialog = MissingFeaturesDialog(self, missing_types) + dialog.present() + + # Trigger update to sync UI with new document + self.on_doc_changed(new_doc) + + # Update the UI with the new document's content + self.on_doc_changed(new_doc) + + def _on_editor_notification( + self, + sender, + message: str, + persistent: bool = False, + action_label: str | None = None, + action_callback: Callable | None = None, + ): + """ + Shows a toast when requested by the DocEditor. + If 'persistent' is True, the toast will have a dismiss button and + remain visible until closed. + If 'action_label' and 'action_callback' are provided, a button + will be added to the toast that triggers the callback. + """ + toast = Adw.Toast.new(message) + if persistent: + toast.set_timeout(0) # 0 = persistent + toast.set_priority(Adw.ToastPriority.HIGH) + + if action_label and action_callback: + toast.set_button_label(action_label) + # Connecting directly to 'button-clicked' is the simplest way + # to handle a callback without defining a GAction. + toast.connect("button-clicked", lambda t: action_callback()) + + self._add_toast(toast) + + def _add_toast(self, toast: Adw.Toast): + """Helper to add a toast to the overlay and track it.""" + self._active_toasts.append(toast) + # Connect to dismissed signal to clean up our reference + toast.connect("dismissed", self._on_toast_dismissed) + self.toast_overlay.add_toast(toast) + + def _on_toast_dismissed(self, toast): + """Removes the toast from the tracking list when dismissed.""" + if toast in self._active_toasts: + self._active_toasts.remove(toast) + + def _on_surface_transform_end(self, sender, *args, **kwargs): + """Clears all active toasts from the toast overlay.""" + logger.debug("Clearing all toasts from overlay.") + + # Iterate over a copy of the list because dismiss() triggers removal + for toast in list(self._active_toasts): + toast.dismiss() + + def _on_assembly_for_preview_finished( + self, + handle: BaseArtifactHandle | None, + error: Exception | None, + ): + """Callback for when the job assembly for previews is complete.""" + if error: + logger.error( + "Failed to aggregate ops for preview", + exc_info=error, + ) + # Release handle on error if it exists + if handle: + self.doc_editor.pipeline.artifact_store.release(handle) + handle = None + + # Schedule the UI update on the main thread, passing the handle. + # The handle will be released in the main thread callback. + GLib.idle_add(self._on_previews_ready, handle) + + def _on_previews_ready(self, handle: BaseArtifactHandle | None): + """ + Main-thread callback to distribute assembled Ops to all consumers. + This method is responsible for releasing the artifact handle. + """ + artifact_store = self.doc_editor.pipeline.artifact_store + + with artifact_store.checkout_handle(handle) as final_artifact: + if final_artifact is None: + if handle is None: + self._update_gcode_preview(None, None) + return + + logger.warning("Final artifact is None, not a JobArtifact") + return + + assert isinstance(final_artifact, JobArtifact) + + # 2. Update G-code Preview + is_gcode_visible = self.bottom_panel.is_item_visible("gcode") + is_3d_visible = self.view_stack.get_visible_child_name() == "3d" + + if final_artifact and (is_gcode_visible or is_3d_visible): + self._update_gcode_preview( + final_artifact.machine_code, final_artifact.op_map + ) + else: + self._update_gcode_preview(None, None) + + return GLib.SOURCE_REMOVE + + def refresh_previews(self): + """ + Public method to trigger a refresh of all data previews, like the + simulator and G-code view. + """ + if get_context().exit_after_settle: + return + + is_gcode_visible = self.bottom_panel.is_item_visible("gcode") + is_3d_visible = self.view_stack.get_visible_child_name() == "3d" + + if not is_gcode_visible and not is_3d_visible: + return + + config = get_context().config + if not config.machine: + # Pass None to clear previews if no machine is configured + self._on_previews_ready(None) + return + + # Try to use existing job artifact first + existing_handle = self.doc_editor.pipeline.get_existing_job_handle() + if existing_handle is not None: + # Use existing artifact without regenerating + self._on_previews_ready(existing_handle) + else: + # No existing artifact, trigger generation + self.doc_editor.file.assemble_job_in_background( + when_done=self._on_assembly_for_preview_finished + ) + + def _on_job_generation_finished_for_preview(self, sender, **kwargs): + """Refresh G-code preview after the pipeline finishes a + rebuild (e.g. triggered by a machine setting change).""" + if self.bottom_panel.is_item_visible("gcode"): + self.refresh_previews() + + def _create_canvas3d(self, context, viewport: ViewportConfig): + """ + Creates a Canvas3D instance and adds it to the view stack. + """ + self.canvas3d = Canvas3D( + context, + self.doc_editor, + viewport=viewport, + ) + self._canvas3d_overlay = Gtk.Overlay() + self._canvas3d_overlay.set_child(self.canvas3d) + self._canvas3d_vis_overlay = VisibilityOverlay( + show_workpiece=False, + show_models=True, + show_grid=True, + shortcuts=SHORTCUTS, + ) + self._canvas3d_vis_overlay.set_margin_end(454) + self._canvas3d_overlay.add_overlay(self._canvas3d_vis_overlay) + self._canvas3d_playback = PlaybackOverlay() + self.canvas3d.set_playback_overlay(self._canvas3d_playback) + self._canvas3d_playback.step_changed.connect( + self._on_3d_playback_step_changed + ) + self._canvas3d_time_overlay = TimeEstimateOverlay() + self._canvas3d_overlay.add_overlay(self._canvas3d_time_overlay) + + # The playback bar lives below the canvas instead of overlapping it, + # so the canvas area stays unobstructed. + self._canvas3d_overlay.set_vexpand(True) + self._canvas3d_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._canvas3d_page.append(self._canvas3d_overlay) + self._canvas3d_page.append(self._canvas3d_playback) + self.view_stack.add_named(self._canvas3d_page, "3d") + + def _on_document_settled(self, sender): + """ + Called when all background processing is complete. This is the main + hook for refreshing previews that depend on the final assembled job. + """ + self.refresh_previews() + self._update_actions_and_ui() + + def _on_selection_changed( + self, + sender, + elements: list[CanvasElement], + active_element: CanvasElement | None, + ): + """Handles the 'selection-changed' signal from the WorkSurface.""" + # Get all selected DocItems (WorkPieces, Groups, etc.) + selected_items = [ + elem.data for elem in elements if isinstance(elem.data, DocItem) + ] + + # Get the primary active item from the signal payload + active_item = ( + active_element.data + if active_element and isinstance(active_element.data, DocItem) + else None + ) + + # Reorder the list to put the active one first, if it exists + if active_item and active_item in selected_items: + selected_items.remove(active_item) + selected_items.insert(0, active_item) + + self.item_props_widget.set_items(selected_items) + self.item_revealer.set_reveal_child(bool(selected_items)) + self.bottom_panel.update_position_menu_sensitivity() + self._update_actions_and_ui() + selected_uids = {item.uid for item in selected_items} + self.bottom_panel.update_layer_selection(selected_uids) + + def on_config_changed(self, sender, **kwargs): + config = get_context().config + machine_changed = config.machine is not self._current_machine + + if machine_changed: + self._on_machine_signals_changed(config) + self._update_canvas3d(config.machine) + + # Update the control panel to use the new machine + self.bottom_panel.set_machine(config.machine, self.machine_cmd) + + # Update the main WorkSurface to use the new size + self.surface.set_machine(config.machine) + + # Show/hide camera toggle based on whether machine has cameras + has_cameras = bool( + config.machine and any(c.enabled for c in config.machine.cameras) + ) + self._surface_vis_overlay.set_camera_visible(has_cameras) + + self.surface.update_from_doc() + self._update_macros_menu() + + # Check for any pending notifications from the new machine immediately + if self._current_machine: + self._on_machine_hours_changed(self._current_machine.machine_hours) + + self._update_actions_and_ui() + self.apply_theme() + + def _on_machine_signals_changed(self, config): + # Disconnect from the previously active machine, if any + if self._current_machine: + self._current_machine.state_changed.disconnect( + self._on_machine_status_changed + ) + self._current_machine.connection_status_changed.disconnect( + self._on_connection_status_changed + ) + self._current_machine.job_finished.disconnect( + self._on_job_finished + ) + self._current_machine.changed.disconnect(self._update_macros_menu) + self._current_machine.machine_hours.changed.disconnect( + self._on_machine_hours_changed + ) + # The controller signal is sourced directly from the controller + # object rather than proxied through the machine. When the active + # machine is removed, its controller is torn down before this + # handler runs, so accessing ``controller`` would lazily fail + # with a ValueError. Skip the disconnect in that case: the + # controller (and its signal) is already gone, so there is + # nothing left to detach. + if self._current_machine.has_controller: + controller = self._current_machine.controller + controller.laser_power_changed.disconnect( + self._on_laser_power_changed + ) + + self._current_machine = config.machine + + # Connect to the new active machine's signals + if self._current_machine: + self._current_machine.state_changed.connect( + self._on_machine_status_changed + ) + self._current_machine.connection_status_changed.connect( + self._on_connection_status_changed + ) + self._current_machine.job_finished.connect(self._on_job_finished) + self._current_machine.changed.connect(self._update_macros_menu) + self._current_machine.machine_hours.changed.connect( + self._on_machine_hours_changed + ) + self._current_machine.controller.laser_power_changed.connect( + self._on_laser_power_changed + ) + + def _update_canvas3d(self, new_machine): + if self.canvas3d is None: + return + if new_machine: + viewport = ViewportConfig.from_machine(new_machine) + else: + viewport = ViewportConfig.default() + self.canvas3d.set_machine(viewport=viewport) + + def apply_theme(self): + """Reads the theme from config and applies it to the UI.""" + style_manager = Adw.StyleManager.get_default() + config = get_context().config + if config.theme == "light": + style_manager.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT) + elif config.theme == "dark": + style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK) + else: # "system" or any other invalid value + style_manager.set_color_scheme(Adw.ColorScheme.DEFAULT) + get_context().theme.bind(self) + + def on_running_tasks_changed(self, sender, tasks, progress): + self._update_actions_and_ui() + self._update_status_message(tasks) + + def _update_status_message(self, tasks): + if not tasks: + self._status_message_label.set_visible(False) + return + + oldest_task = tasks[0] + message = oldest_task.get_message() + status_text = message if message is not None else "" + + if status_text and len(tasks) > 1: + status_text += _(" (+{tasks} more)").format(tasks=len(tasks) - 1) + elif len(tasks) > 1: + status_text = _("{tasks} tasks").format(tasks=len(tasks)) + + self._status_message_label.set_text(status_text) + self._status_message_label.set_visible(bool(status_text)) + + def _update_actions_and_ui(self): + config = get_context().config + active_machine = config.machine + am = self.action_manager + doc = self.doc_editor.doc + + if not active_machine: + am.get_action("export").set_enabled(False) + am.get_action("machine-settings").set_enabled(False) + am.get_action("machine-home").set_enabled(False) + am.get_action("machine-frame").set_enabled(False) + am.get_action("machine-send").set_enabled(False) + am.get_action("machine-hold").set_enabled(False) + am.get_action("machine-cancel").set_enabled(False) + am.get_action("machine-clear-alarm").set_enabled(False) + am.get_action("execute-macro").set_enabled(False) + am.get_action("zero-here").set_enabled(False) + + self.toolbar.export_button.set_tooltip_text( + _("Select a machine to enable G-code export") + ) + self.toolbar.machine_warning_box.set_visible(False) + self.surface.set_laser_dot_visible(False) + else: + device_status = active_machine.device_state.status + conn_status = active_machine.connection_status + state = active_machine.device_state + active_driver = active_machine.driver + is_dummy = isinstance(active_driver, NoDeviceDriver) + + can_export = ( + doc.has_result() + and not task_mgr.has_tasks() + and not self.doc_editor.pipeline.is_data_stale + ) + am.get_action("export").set_enabled(can_export) + export_tooltip = _("Generate G-code") + if task_mgr.has_tasks(): + export_tooltip = _( + "Cannot export while other tasks are running" + ) + elif self.doc_editor.pipeline.is_data_stale: + export_tooltip = _( + "Pipeline needs recalculation before export. " + "Press F5 to recalculate." + ) + elif not doc.has_workpiece(): + export_tooltip = _("Add a workpiece to enable export") + elif not doc.has_result(): + export_tooltip = _( + "Add or enable a processing step to enable export" + ) + self.toolbar.export_button.set_tooltip_text(export_tooltip) + + if active_driver and active_driver.state.error: + self.toolbar.set_machine_warning( + active_driver.state.error.title, + active_driver.state.error.code, + active_driver.state.error.description, + ) + self.toolbar.machine_warning_box.set_visible(True) + else: + self.toolbar.machine_warning_box.set_visible(False) + am.get_action("machine-settings").set_enabled(True) + + # A job/task is running if the machine is not idle or a UI task is + # active. + machine_processing = ( + conn_status == TransportStatus.CONNECTED + and device_status != DeviceStatus.IDLE + ) + + is_job_or_task_active = ( + machine_processing + or task_mgr.has_tasks() + or self.machine_cmd.is_job_running + ) + + am.get_action("machine-home").set_enabled( + not is_job_or_task_active + ) + + can_frame = ( + active_machine.can_frame() + and doc.has_result() + and not is_job_or_task_active + ) + am.get_action("machine-frame").set_enabled(can_frame) + if not active_machine.can_frame(): + self.toolbar.frame_button.set_tooltip_text( + _("Configure frame power to enable") + ) + else: + self.toolbar.frame_button.set_tooltip_text( + _("Cycle laser head around the occupied area") + ) + + send_sensitive = ( + not isinstance(active_driver, NoDeviceDriver) + and (active_driver and not active_driver.state.error) + and conn_status == TransportStatus.CONNECTED + and doc.has_result() + and not is_job_or_task_active + and not self.doc_editor.pipeline.is_data_stale + ) + am.get_action("machine-send").set_enabled(send_sensitive) + if self.doc_editor.pipeline.is_data_stale: + self.toolbar.send_button.set_tooltip_text( + _( + "Pipeline needs recalculation before sending. " + "Press F5 to recalculate." + ) + ) + else: + self.toolbar.send_button.set_tooltip_text(_("Send to machine")) + + hold_sensitive = device_status in ( + DeviceStatus.RUN, + DeviceStatus.HOLD, + DeviceStatus.CYCLE, + ) + is_holding = device_status == DeviceStatus.HOLD + am.get_action("machine-hold").set_enabled(hold_sensitive) + am.get_action("machine-hold").set_state( + GLib.Variant.new_boolean(is_holding) + ) + if is_holding: + self.toolbar.hold_button.set_child(self.toolbar.hold_on_icon) + self.toolbar.hold_button.set_tooltip_text(_("Resume machine")) + else: + self.toolbar.hold_button.set_child(self.toolbar.hold_off_icon) + self.toolbar.hold_button.set_tooltip_text(_("Pause machine")) + + cancel_sensitive = conn_status == TransportStatus.CONNECTED + am.get_action("machine-cancel").set_enabled(cancel_sensitive) + + clear_alarm_sensitive = bool( + device_status == DeviceStatus.ALARM + or (active_driver and active_driver.state.error) + ) + am.get_action("machine-clear-alarm").set_enabled( + clear_alarm_sensitive + ) + if clear_alarm_sensitive: + self.toolbar.clear_alarm_button.add_css_class( + "suggested-action" + ) + else: + self.toolbar.clear_alarm_button.remove_css_class( + "suggested-action" + ) + + # Update focus button sensitivity + head = active_machine.get_default_laser_head() + can_focus = ( + head is not None + and head.focus_power_percent > 0 + and not is_job_or_task_active + ) + am.get_action("toggle-focus").set_enabled(can_focus) + + connected = conn_status == TransportStatus.CONNECTED + self.surface.set_laser_dot_visible(connected) + if state and connected: + x, y = state.machine_pos[:2] + if x is not None and y is not None: + self.surface.set_laser_dot_position(x, y) + + # Set macro action sensitivity + can_run_macros = connected and not is_job_or_task_active + am.get_action("execute-macro").set_enabled(can_run_macros) + + # WCS UI + is_g53 = ( + active_machine.active_wcs == active_machine.machine_space_wcs + ) + + # Allow zeroing if connected OR if it's the dummy driver + can_zero = ( + (connected or is_dummy) + and not is_g53 + and not is_job_or_task_active + ) + am.get_action("zero-here").set_enabled(can_zero) + + # Update actions that don't depend on the machine state + selected_elements = self.surface.get_selected_elements() + has_selection = len(selected_elements) > 0 + + am.get_action("undo").set_enabled( + self.doc_editor.history_manager.can_undo() + ) + am.get_action("redo").set_enabled( + self.doc_editor.history_manager.can_redo() + ) + am.get_action("cut").set_enabled(has_selection) + am.get_action("copy").set_enabled(has_selection) + am.get_action("paste").set_enabled(self.doc_editor.edit.can_paste()) + am.get_action("asset-paste").set_enabled( + self.bottom_panel.asset_browser.can_paste_assets() + ) + am.get_action("select_all").set_enabled(doc.has_workpiece()) + am.get_action("duplicate").set_enabled(has_selection) + am.get_action("rename-item").set_enabled(has_selection) + am.get_action("remove").set_enabled(has_selection) + am.get_action("clear").set_enabled(doc.has_workpiece()) + + # Update sensitivity for Grouping actions + can_group = len(selected_elements) >= 2 + am.get_action("group").set_enabled(can_group) + + can_ungroup = any( + isinstance(elem.data, Group) for elem in selected_elements + ) + am.get_action("ungroup").set_enabled(can_ungroup) + + # Update sensitivity for Layer actions + can_move_layers = has_selection and len(doc.layers) > 1 + am.get_action("layer-move-up").set_enabled(can_move_layers) + am.get_action("layer-move-down").set_enabled(can_move_layers) + + # Update sensitivity for 3D view actions + is_3d_view_active = self.view_stack.get_visible_child_name() == "3d" + can_show_3d = is_3d_view_active or canvas3d_initialized + am.get_action("show_3d_view").set_enabled(can_show_3d) + am.get_action("view_top").set_enabled(is_3d_view_active) + am.get_action("view_front").set_enabled(is_3d_view_active) + am.get_action("view_iso").set_enabled(is_3d_view_active) + am.get_action("view_toggle_perspective").set_enabled(is_3d_view_active) + + # Update sensitivity for Arrangement actions + can_distribute = len(self.surface.get_selected_workpieces()) >= 2 + am.get_action("align-h-center").set_enabled(has_selection) + am.get_action("align-v-center").set_enabled(has_selection) + am.get_action("align-left").set_enabled(has_selection) + am.get_action("align-right").set_enabled(has_selection) + am.get_action("align-top").set_enabled(has_selection) + am.get_action("align-bottom").set_enabled(has_selection) + am.get_action("spread-h").set_enabled(can_distribute) + am.get_action("spread-v").set_enabled(can_distribute) + self.toolbar.arrange_menu_button.set_sensitive(has_selection) + + # Update sensitivity for Tab buttons + show_tabs_action = am.get_action("show_tabs") + has_any_tabs = any(wp.tabs for wp in doc.all_workpieces) + show_tabs_action.set_enabled(has_any_tabs) + + def on_machine_warning_clicked(self, sender): + """Opens the machine settings dialog for the current machine.""" + config = get_context().config + if not config.machine: + return + dialog = MachineSettingsDialog( + machine=config.machine, + transient_for=self, + ) + dialog.present() + + def on_status_bar_clicked(self, sender): + action = self.action_manager.get_action("toggle_bottom_panel") + state = action.get_state() + if state: + new_state = not state.get_boolean() + action.change_state(GLib.Variant.new_boolean(new_state)) + else: + action.change_state(GLib.Variant.new_boolean(True)) + + def on_toggle_bottom_panel_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + action.set_state(value) + + if is_visible: + self.bottom_panel.set_visible(True) + full_height = self.vertical_paned.get_height() + self.vertical_paned.set_position( + full_height - self._last_bottom_panel_height + ) + get_usage_tracker().track_page_view( + "/bottom-panel/open", "Bottom Panel Opened" + ) + else: + self.bottom_panel.set_visible(False) + + self._save_bottom_panel() + + def on_toggle_right_panel_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + is_visible = value.get_boolean() + action.set_state(value) + self._right_pane.set_visible(is_visible) + get_context().config.set_right_panel_visible(is_visible) + + def _on_dialog_notification(self, sender, message: str = ""): + """Shows a toast when requested by a child dialog.""" + toast = Adw.Toast.new(message) + self._add_toast(toast) + + def on_quit_action(self, action, parameter): + self.close() + + def do_close_request(self): + """ + Handles the 'close-request' signal to check for unsaved changes. + For GTK signals, returning True PREVENTS the default handler from + running (i.e., stops the close). Returning False allows it. + """ + if self.doc_editor.is_saved: + return False # Allow the window to close + + self.project_cmd.show_unsaved_changes_dialog( + self._on_close_request_dialog_response + ) + return True # Prevent the window from closing until user responds + + def _on_close_request_dialog_response(self, response): + """Callback for unsaved changes dialog in do_close_request.""" + if response == "cancel": + return # Do nothing, window remains open. + + if response == "discard": + self.destroy() + return + + if response == "save": + self.project_cmd.on_save_project(None, None) + if self.doc_editor.is_saved: + self.destroy() + + def on_menu_import(self, action, param=None): + start_interactive_import(self, self.doc_editor) + + def on_open_clicked(self, sender): + self.on_menu_import(sender) + + def on_clear_clicked(self, action, param): + self.doc_editor.edit.clear_all_items() + + def on_recalculate_clicked(self, action, param): + self.doc_editor.pipeline.recalculate() + + def on_force_recalculate_clicked(self, action, param): + self.doc_editor.pipeline.recalculate(force=True) + + def _run_sanity_check_and_proceed(self, proceed_callback): + config = get_context().config + machine = config.machine + if not machine: + proceed_callback() + return + + checker = SanityChecker(machine) + + def _handle_ops(ops): + report = checker.check(ops, mode=CheckMode.FAST) + if report.is_clean: + proceed_callback() + else: + dialog = SanityCheckDialog( + parent=self, + report=report, + on_proceed=proceed_callback, + ) + dialog.present() + + existing = self.doc_editor.pipeline.get_existing_job_handle() + if existing is not None: + artifact_store = self.doc_editor.pipeline.artifact_store + try: + with artifact_store.checkout_handle(existing) as artifact: + if isinstance(artifact, JobArtifact): + _handle_ops(artifact.ops) + return + except (OSError, KeyError, ValueError, AttributeError): + logger.warning("Failed to run sanity check", exc_info=True) + proceed_callback() + return + + def _on_artifact_ready(handle, error): + if error or not handle: + proceed_callback() + return + try: + artifact_store = self.doc_editor.pipeline.artifact_store + with artifact_store.checkout_handle(handle) as artifact: + if isinstance(artifact, JobArtifact): + _handle_ops(artifact.ops) + return + except (OSError, KeyError, ValueError, AttributeError): + logger.warning("Failed to run sanity check", exc_info=True) + proceed_callback() + + self.doc_editor.file.assemble_job_in_background( + when_done=_on_artifact_ready + ) + + def on_export_clicked(self, action, param=None): + def _proceed(): + initial_name = None + if self.doc_editor.file_path: + initial_name = f"{self.doc_editor.file_path.stem}.gcode" + file_dialogs.show_export_gcode_dialog( + self, self._on_save_dialog_response, initial_name + ) + + self._run_sanity_check_and_proceed(_proceed) + + def on_export_document_clicked(self, action, param=None): + initial_name = "document.svg" + if self.doc_editor.file_path: + initial_name = f"{self.doc_editor.file_path.stem}.svg" + file_dialogs.show_export_document_dialog( + self, self._on_export_document_response, initial_name + ) + + def on_export_object_clicked(self, action, param=None): + selected = self.surface.get_selected_workpieces() + if len(selected) == 1: + file_dialogs.show_export_object_dialog( + self, self._on_export_object_response, selected[0] + ) + else: + self._on_editor_notification( + self, _("Please select a single object to export.") + ) + + def _on_export_object_response(self, dialog, result, user_data): + try: + file = dialog.save_finish(result) + if not file: + return + file_path = Path(file.get_path()) + + selected = self.surface.get_selected_workpieces() + if len(selected) != 1: + return + + self.doc_editor.file.export_object_to_path(file_path, selected[0]) + + except GLib.Error as e: + logger.error(f"Error exporting object: {e.message}") + + def _on_export_document_response(self, dialog, result, user_data): + try: + file = dialog.save_finish(result) + if not file: + return + file_path = Path(file.get_path()) + except GLib.Error as e: + logger.error(f"Error exporting document: {e.message}") + return + + self.doc_editor.file.export_document_to_path(file_path) + + def _on_save_dialog_response(self, dialog, result, user_data): + try: + file = dialog.save_finish(result) + if not file: + return + file_path = Path(file.get_path()) + except GLib.Error as e: + logger.error(f"Error saving file: {e.message}") + return + + # This is now a non-blocking call. + self.doc_editor.file.export_gcode_to_path(file_path) + + def on_home_clicked(self, action, param): + config = get_context().config + if not config.machine: + return + + # Disable focus mode when homing + focus_action = self.action_manager.get_action("toggle-focus") + focus_state = focus_action.get_state() + if focus_state and focus_state.get_boolean(): + focus_action.change_state(GLib.Variant.new_boolean(False)) + + self.machine_cmd.home(config.machine) + + def _run_machine_job(self, job_coroutine: Coroutine): + """ + Wraps a machine job coroutine in an asyncio.Task and handles + its completion or failure. + """ + fut = asyncio.run_coroutine_threadsafe(job_coroutine, task_mgr.loop) + # Add a callback to handle the result (or exception) of the task + fut.add_done_callback(self._on_job_future_done) + + def on_frame_clicked(self, action, param): + config = get_context().config + if not config.machine: + return + + # Disable focus mode when framing + focus_action = self.action_manager.get_action("toggle-focus") + focus_state = focus_action.get_state() + if focus_state and focus_state.get_boolean(): + focus_action.change_state(GLib.Variant.new_boolean(False)) + + # Get the coroutine object for the framing job + job_coro = self.machine_cmd.frame_job( + config.machine, on_progress=self._on_job_progress_updated + ) + # Run the job using the helper + self._run_machine_job(job_coro) + + def on_send_clicked(self, action, param): + config = get_context().config + machine = config.machine + if not machine: + return + + def _proceed(): + focus_action = self.action_manager.get_action("toggle-focus") + focus_state = focus_action.get_state() + if focus_state and focus_state.get_boolean(): + focus_action.change_state(GLib.Variant.new_boolean(False)) + + job_coro = self.machine_cmd.send_job( + machine, + on_progress=self._on_job_progress_updated, + ) + self._run_machine_job(job_coro) + + self._run_sanity_check_and_proceed(_proceed) + + def on_hold_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + """ + Handles the 'change-state' signal for the 'hold' action. + This is the correct handler for a stateful action. + """ + config = get_context().config + if not config.machine: + return + is_requesting_hold = value.get_boolean() + self.machine_cmd.set_hold(config.machine, is_requesting_hold) + action.set_state(value) + + def on_cancel_clicked(self, action, param): + config = get_context().config + if not config.machine: + return + self.machine_cmd.cancel_job(config.machine) + + def on_clear_alarm_clicked(self, action, param): + config = get_context().config + if not config.machine: + return + self.machine_cmd.clear_alarm(config.machine) + + def on_toggle_focus_state_change( + self, action: Gio.SimpleAction, value: GLib.Variant + ): + """ + Handles the 'change-state' signal for the 'toggle-focus' action. + This toggles the laser focus mode on/off. + """ + config = get_context().config + if not config.machine: + return + + is_focus_on = value.get_boolean() + head = config.machine.get_default_laser_head() + if head is None: + action.set_state(GLib.Variant.new_boolean(False)) + return + + if is_focus_on: + self.machine_cmd.set_focus_power(head, head.focus_power_percent) + else: + self.machine_cmd.set_focus_power(head, 0) + action.set_state(value) + + # Update the toolbar button icon + if is_focus_on: + self.toolbar.focus_button.set_child(self.toolbar.focus_off_icon) + else: + self.toolbar.focus_button.set_child(self.toolbar.focus_on_icon) + + def _on_laser_power_changed(self, sender, *, head, percent): + focus_action = self.action_manager.get_action("toggle-focus") + if focus_action is None: + return + is_on = percent > 0 + focus_action.set_state(GLib.Variant.new_boolean(is_on)) + + def on_elements_deleted(self, sender, elements: list[CanvasElement]): + """Handles the deletion signal from the WorkSurface.""" + items_to_delete = [ + elem.data for elem in elements if isinstance(elem.data, DocItem) + ] + if items_to_delete: + self.doc_editor.edit.remove_items( + items_to_delete, "Delete item(s)" + ) + + def on_cut_requested(self, sender, items: list[DocItem]): + """Handles the 'cut-requested' signal from the WorkSurface.""" + self.doc_editor.edit.cut_items(items) + self._update_actions_and_ui() + + def on_copy_requested(self, sender, items: list[DocItem]): + """ + Handles the 'copy-requested' signal from the WorkSurface. + """ + self.doc_editor.edit.copy_items(items) + self._update_actions_and_ui() + + def on_paste_requested(self, sender, *args): + """ + Handles the 'paste-requested' signal from the WorkSurface. + Checks for image data on system clipboard first, then falls back + to workpiece paste. + """ + # Priority 1: Check if system clipboard contains image data + if self.drag_drop_cmd.handle_clipboard_paste(): + return + + # Priority 2: Standard workpiece paste + newly_pasted = self.doc_editor.edit.paste_items() + if newly_pasted: + self.surface.select_items(newly_pasted) + self._update_actions_and_ui() + + def on_select_all(self, action, param): + """ + Selects all top-level items (workpieces and groups) in the document. + """ + self.surface.select_all() + + def on_duplicate_requested(self, sender, items: list[DocItem]): + """ + Handles the 'duplicate-requested' signal from the WorkSurface. + """ + newly_duplicated = self.doc_editor.edit.duplicate_items(items) + if newly_duplicated: + self.surface.select_items(newly_duplicated) + + def on_menu_cut(self, action, param): + selection = self.surface.get_selected_items() + if selection: + self.doc_editor.edit.cut_items(list(selection)) + self._update_actions_and_ui() + + def on_menu_copy(self, action, param): + selection = self.surface.get_selected_items() + if selection: + self.doc_editor.edit.copy_items(list(selection)) + self._update_actions_and_ui() + + def on_menu_duplicate(self, action, param): + selection = self.surface.get_selected_items() + if selection: + newly_duplicated = self.doc_editor.edit.duplicate_items( + list(selection) + ) + self.surface.select_items(newly_duplicated) + + def on_menu_rename(self, action, param): + selection = self.surface.get_selected_items() + if not selection: + return + item = selection[0] + if not isinstance(item, DocItem): + return + # Make sure the user can see the rename editor. + self.bottom_panel.set_visible(True) + area = self.bottom_panel.dock_layout.find_item_area("layers") + if area: + area.set_active_item("layers") + self.bottom_panel.layers_tab.start_item_rename(item) + + def on_menu_remove(self, action, param): + items = self.surface.get_selected_items() + if items: + self.doc_editor.edit.remove_items(list(items)) + + def show_about_dialog(self, action, param): + dialog = AboutDialog(transient_for=self) + dialog.present() + + def on_donate_clicked(self, action, param): + webbrowser.open("https://www.patreon.com/c/knipknap") + + def on_save_debug_log(self, action, param): + DebugLogDialog( + parent=self, + editor=self.doc_editor, + on_saved=lambda name: self._on_editor_notification( + self, + _("Debug log saved to {path}").format(path=name), + ), + on_error=lambda msg: self._on_editor_notification(self, msg), + ).present() + + def show_settings(self, action, param): + dialog = SettingsWindow(transient_for=self) + dialog.present() + dialog.connect("close-request", self._on_settings_dialog_closed) + + def show_machine_settings(self, action, param): + """Opens the machine settings dialog for the current machine.""" + config = get_context().config + if not config.machine: + return + dialog = MachineSettingsDialog( + machine=config.machine, + transient_for=self, + ) + dialog.present() + + def _on_settings_dialog_closed(self, dialog): + logger.debug("Settings dialog closed") + self.surface.grab_focus() # re-enables keyboard shortcuts + + def _on_job_time_updated(self, sender, *, total_seconds): + self._time_estimate_overlay.set_estimated_time(total_seconds) + if self._canvas3d_time_overlay is not None: + self._canvas3d_time_overlay.set_estimated_time(total_seconds) diff --git a/rayforge/ui_gtk/project_cmd.py b/rayforge/ui_gtk/project_cmd.py new file mode 100644 index 000000000..ea81bdb58 --- /dev/null +++ b/rayforge/ui_gtk/project_cmd.py @@ -0,0 +1,297 @@ +import logging +import sys +from collections.abc import Callable +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gio, GLib, Gtk + +from .. import __version__, const +from ..context import get_context +from ..usage import get_usage_tracker +from .doceditor import file_dialogs + +if TYPE_CHECKING: + from ..doceditor.editor import DocEditor + from .mainwindow import MainWindow + +logger = logging.getLogger(__name__) + + +class ProjectCmd: + """Handles project file operations (new, open, save, recent files).""" + + def __init__(self, win: "MainWindow", editor: "DocEditor"): + self._win = win + self._editor = editor + + def show_unsaved_changes_dialog(self, callback: Callable[[str], None]): + """ + Shows a dialog asking the user what to do with unsaved changes. + The callback will be called with the response: 'save', 'discard', + or 'cancel'. + """ + dialog = Adw.MessageDialog( + transient_for=self._win, + heading=_("Unsaved Changes"), + body=_( + "The current project has unsaved changes. " + "Do you want to save them?" + ), + ) + dialog.add_response("cancel", _("_Cancel")) + dialog.add_response("discard", _("_Don't Save")) + dialog.add_response("save", _("_Save")) + dialog.set_default_response("save") + dialog.set_close_response("cancel") + dialog.set_response_appearance( + "save", Adw.ResponseAppearance.SUGGESTED + ) + dialog.set_response_appearance( + "discard", Adw.ResponseAppearance.DESTRUCTIVE + ) + + def on_response(d, response_id): + d.destroy() + callback(response_id) + + dialog.connect("response", on_response) + dialog.present() + + def on_new_project(self, action, param): + """Action handler for creating a new project.""" + if self._editor.is_saved: + self._do_create_new_project() + else: + self.show_unsaved_changes_dialog( + self._on_new_project_dialog_response + ) + + def _on_new_project_dialog_response(self, response: str): + """Callback for unsaved changes dialog in on_new_project.""" + if response == "cancel": + return + if response == "save": + self.on_save_project(None, None) + if not self._editor.is_saved: + return + self._do_create_new_project() + + def _do_create_new_project(self): + """Actually creates a new project.""" + from ..core.doc import Doc + + new_doc = Doc() + + machine = self._editor.context.machine + if machine: + new_doc.active_layer.set_rotary_enabled( + machine.rotary_enabled_default + ) + default_rm = machine.get_default_rotary_module() + if default_rm: + new_doc.active_layer.set_rotary_diameter( + default_rm.default_diameter + ) + new_doc.active_layer.set_rotary_module_uid(default_rm.uid) + + self._editor.set_doc(new_doc) + self._editor.set_file_path(None) + self._editor.mark_as_saved() + + logger.info("Created new project") + self._win._on_editor_notification( + self._win, message=_("New project created") + ) + + def on_open_project(self, action, param): + """Action handler for opening a project file.""" + if self._editor.is_saved: + file_dialogs.show_open_project_dialog( + self._win, self._on_open_project_response + ) + else: + self.show_unsaved_changes_dialog( + self._on_open_project_dialog_response + ) + + def _on_open_project_dialog_response(self, response: str): + """Callback for unsaved changes dialog in on_open_project.""" + if response == "cancel": + return + if response == "save": + self.on_save_project(None, None) + if not self._editor.is_saved: + return + file_dialogs.show_open_project_dialog( + self._win, self._on_open_project_response + ) + + def _on_open_project_response(self, dialog, result, user_data): + """Callback for the open project dialog.""" + try: + file = dialog.open_finish(result) + if not file: + return + file_path = Path(file.get_path()) + self.load_project(file_path) + except GLib.Error as e: + logger.error(f"Error opening file: {e.message}") + return + + def on_save_project(self, action, param): + """Action handler for saving the current project.""" + file_path = self._editor.file_path + if file_path: + success = self._editor.file.save_project_to_path(file_path) + if success: + self._win.on_doc_changed(self._editor.doc) + self.add_to_recent_manager(file_path) + get_usage_tracker().track_page_view( + "/doc/project-save", "Project Save" + ) + else: + self.on_save_project_as(action, param) + + def on_save_project_as(self, action, param): + """Action handler for saving the project with a new name.""" + initial_name = None + if self._editor.file_path: + initial_name = self._editor.file_path.name + + file_dialogs.show_save_project_dialog( + self._win, self._on_save_project_response, initial_name + ) + + def _on_save_project_response(self, dialog, result, user_data): + """Callback for the save project dialog.""" + try: + file = dialog.save_finish(result) + if not file: + return + file_path = Path(file.get_path()) + + success = self._editor.file.save_project_to_path(file_path) + if success: + self._win.on_doc_changed(self._editor.doc) + self.add_to_recent_manager(file_path) + get_usage_tracker().track_page_view( + "/doc/project-save", "Project Save" + ) + except GLib.Error as e: + logger.error(f"Error saving file: {e.message}") + return + + def load_project(self, file_path: Path): + """ + Public method to load a project from a given path. + Updates recent files and tracks the last opened project. + """ + success = self._editor.file.load_project_from_path(file_path) + if success: + self._win.on_doc_changed(self._editor.doc) + self.add_to_recent_manager(file_path) + context = get_context() + context.config.set_last_opened_project(file_path) + get_usage_tracker().track_page_view( + "/doc/project-open", "Project Open" + ) + + def on_open_recent(self, action, param): + """Action handler for opening a file from the recent menu.""" + uri = param.get_string() + file = Gio.File.new_for_uri(uri) + path = file.get_path() + if path is None: + return + file_path = Path(path) + + if self._editor.is_saved: + self.load_project(file_path) + else: + + def on_response(response_id): + self._on_open_recent_dialog_response(response_id, file_path) + + self.show_unsaved_changes_dialog(on_response) + + def _on_open_recent_dialog_response(self, response: str, file_path: Path): + """Callback for unsaved changes dialog when opening a recent file.""" + if response == "cancel": + return + if response == "save": + self.on_save_project(None, None) + if not self._editor.is_saved: + return + self.load_project(file_path) + + def add_to_recent_manager(self, file_path: Path): + """Adds a project file path to the Gtk.RecentManager with metadata.""" + uri = file_path.resolve().as_uri() + app = self._win.get_application() + if not app: + logger.warning( + "Could not get application to register recent file." + ) + return + + app_id = app.get_application_id() + if not app_id: + logger.warning( + "Application ID is not set, cannot register recent file." + ) + return + + recent_data = Gtk.RecentData() + recent_data.display_name = file_path.name + recent_data.mime_type = const.MIME_TYPE_PROJECT + recent_data.app_name = app_id + recent_data.app_exec = f'"{sys.executable}" %f' + + self._win.recent_manager.add_full(uri, recent_data) + + def update_recent_files_menu(self, *args): + """ + Updates the 'Open Recent' submenu based on the contents of the + Gtk.RecentManager. + """ + app = self._win.get_application() + if not app: + self._win.menu_model.update_recent_files_menu([]) + return + + app_id = app.get_application_id() + if not app_id: + self._win.menu_model.update_recent_files_menu([]) + return + + items = self._win.recent_manager.get_items() + ryp_items = [ + info + for info in items + if info.get_uri().endswith(".ryp") + and app_id in info.get_applications() + ] + self._win.menu_model.update_recent_files_menu(ryp_items) + + def on_saved_state_changed(self, sender): + """Handles saved state changes from DocEditor.""" + self.update_window_title() + self._win.action_manager.update_action_states() + + def update_window_title(self): + """Updates the window title based on file name and saved state.""" + file_path = self._editor.file_path + is_saved = self._editor.is_saved + + doc_title = file_path.name if file_path else _("Untitled") + if is_saved: + title = f"{doc_title} - {const.APP_NAME}" + else: + title = f"{doc_title}* - {const.APP_NAME}" + + subtitle = __version__ or "" + + window_title = Adw.WindowTitle(title=title, subtitle=subtitle) + self._win.header_bar.set_title_widget(window_title) diff --git a/rayforge/ui_gtk/settings/__init__.py b/rayforge/ui_gtk/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/settings/addon_manager_page.py b/rayforge/ui_gtk/settings/addon_manager_page.py new file mode 100644 index 000000000..e4673e5e9 --- /dev/null +++ b/rayforge/ui_gtk/settings/addon_manager_page.py @@ -0,0 +1,68 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ..addon_manager.addon_list import AddonListWidget +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class AddonManagerPage(TrackedPreferencesPage): + """ + Widget for managing installed addons. + """ + + key = "addons" + + def __init__(self): + super().__init__( + title=_("Addons"), + icon_name="addon-symbolic", + ) + + # The list of addons, which is an Adw.PreferencesGroup + self.addon_list_widget = AddonListWidget( + title=_("Installed Addons"), + description=_("Install, update, and remove addons."), + ) + self.add(self.addon_list_widget) + + # The progress indicator must be wrapped in a PreferencesGroup + # to be added to a PreferencesPage. + progress_group = Adw.PreferencesGroup() + + self.progress_indicator = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + margin_top=12, + margin_bottom=12, + halign=Gtk.Align.CENTER, + ) + spinner = Gtk.Spinner() + spinner.start() + self.progress_label = Gtk.Label() + self.progress_indicator.append(spinner) + self.progress_indicator.append(self.progress_label) + self.progress_indicator.set_visible(False) # Hidden by default + + progress_group.add(self.progress_indicator) + self.add(progress_group) + + # Connect signals to control the progress indicator + self.addon_list_widget.install_started.connect( + self._on_install_started + ) + self.addon_list_widget.install_finished.connect( + self._on_install_finished + ) + + def _on_install_started(self, sender, message: str): + """Called when the list widget starts an installation.""" + self.progress_label.set_text(message) + self.progress_indicator.set_visible(True) + + def _on_install_finished(self, sender): + """Called when the installation is complete.""" + self.progress_indicator.set_visible(False) diff --git a/rayforge/ui_gtk/settings/ai_settings_page.py b/rayforge/ui_gtk/settings/ai_settings_page.py new file mode 100644 index 000000000..be0e40c9f --- /dev/null +++ b/rayforge/ui_gtk/settings/ai_settings_page.py @@ -0,0 +1,571 @@ +"""AI settings page with inline editor for Rayforge.""" + +import asyncio +import logging +import uuid +from collections.abc import Callable +from concurrent.futures import Future +from gettext import gettext as _ +from typing import Any, cast + +from blinker import Signal +from gi.repository import Adw, GLib, Gtk + +from ...context import get_context +from ...core.ai.ai_service import AIService +from ...core.ai.provider import AIProviderConfig, AIProviderType +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class ProviderRow(Gtk.Box): + """A widget representing a single AI provider in a ListBox.""" + + def __init__( + self, + provider_id: str, + config: AIProviderConfig, + is_default: bool, + on_toggle_enabled: Callable[[str, bool], None], + on_delete_callback: Callable[[str, str], None], + on_set_default_callback: Callable[[str], None], + ) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.provider_id = provider_id + self.config = config + self.is_default = is_default + self.on_toggle_enabled = on_toggle_enabled + self.on_delete_callback = on_delete_callback + self.on_set_default_callback = on_set_default_callback + self._setup_ui() + + def _setup_ui(self) -> None: + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + self.default_icon = get_icon("check-circle-symbolic") + self.default_icon.set_tooltip_text(_("Default provider")) + self.default_icon.set_valign(Gtk.Align.CENTER) + self.default_icon.set_visible(self.is_default) + self.append(self.default_icon) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True + ) + self.append(labels_box) + + self.title_label = Gtk.Label( + label=self.config.name, + halign=Gtk.Align.START, + xalign=0, + ) + self._update_title_style() + labels_box.append(self.title_label) + + self.subtitle_label = Gtk.Label( + label=self.config.provider_type.value.replace("_", " ").title(), + halign=Gtk.Align.START, + xalign=0, + ) + self.subtitle_label.add_css_class("dim-label") + self.subtitle_label.add_css_class("caption") + labels_box.append(self.subtitle_label) + + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + self.enable_switch = Gtk.Switch(valign=Gtk.Align.CENTER) + self.enable_switch.set_active(self.config.enabled) + self.enable_switch.set_tooltip_text( + _("Enable or disable this provider") + ) + self.enable_switch.connect("state-set", self._on_toggle_clicked) + suffix_box.append(self.enable_switch) + + self.default_btn = Gtk.Button(child=get_icon("check-symbolic")) + self.default_btn.add_css_class("flat") + self.default_btn.set_tooltip_text(_("Set as default")) + self.default_btn.set_visible(not self.is_default) + self.default_btn.connect("clicked", self._on_set_default_clicked) + suffix_box.append(self.default_btn) + + delete_btn = Gtk.Button(child=get_icon("delete-symbolic")) + delete_btn.add_css_class("flat") + delete_btn.connect("clicked", self._on_delete_clicked) + suffix_box.append(delete_btn) + + def update_from_config( + self, config: AIProviderConfig, is_default: bool + ) -> None: + self.config = config + self.is_default = is_default + self.title_label.set_label(config.name) + self._update_title_style() + self.default_icon.set_visible(is_default) + self.default_btn.set_visible(not is_default) + self.enable_switch.set_active(config.enabled) + + def _update_title_style(self) -> None: + if self.config.enabled: + self.title_label.remove_css_class("dim-label") + else: + self.title_label.add_css_class("dim-label") + + def _on_toggle_clicked(self, switch: Gtk.Switch, state: bool) -> bool: + self.on_toggle_enabled(self.provider_id, state) + return False + + def _on_delete_clicked(self, button: Gtk.Button) -> None: + self.on_delete_callback(self.provider_id, self.config.name) + + def _on_set_default_clicked(self, button: Gtk.Button) -> None: + self.on_set_default_callback(self.provider_id) + + +class ProviderListWidget(PreferencesGroupWithButton): + """Widget for displaying and managing a list of AI providers.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__( + button_label=_("Add Provider"), + selection_mode=Gtk.SelectionMode.SINGLE, + **kwargs, + ) + self.provider_selected = Signal() + self._row_widgets: dict[str, ProviderRow] = {} + self._setup_ui() + + def _setup_ui(self) -> None: + placeholder = Gtk.Label( + label=_("No providers configured"), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.connect("row-selected", self._on_provider_selected) + + def populate_and_select(self, select_id: str | None = None) -> None: + ai_service = get_context().ai_service + providers = list(ai_service.providers.items()) + default_id = ai_service.default_provider_id + + selected_row = self.list_box.get_selected_row() + selected_provider_id = None + if selected_row: + child = selected_row.get_child() + if isinstance(child, ProviderRow): + selected_provider_id = child.provider_id + + row_count = 0 + while self.list_box.get_row_at_index(row_count): + row_count += 1 + + new_selection_index = -1 + for i, (provider_id, config) in enumerate(providers): + if provider_id == selected_provider_id: + new_selection_index = i + + if i < row_count: + row = self.list_box.get_row_at_index(i) + if row: + provider_row = cast(ProviderRow, row.get_child()) + provider_row.update_from_config( + config, provider_id == default_id + ) + self._row_widgets[provider_id] = provider_row + else: + list_box_row = Gtk.ListBoxRow() + row_widget = self.create_row_widget((provider_id, config)) + list_box_row.set_child(row_widget) + self.list_box.append(list_box_row) + self._row_widgets[provider_id] = row_widget + + while row_count > len(providers): + last_row = self.list_box.get_row_at_index(row_count - 1) + if last_row: + self.list_box.remove(last_row) + row_count -= 1 + + if new_selection_index >= 0: + row = self.list_box.get_row_at_index(new_selection_index) + if row: + self.list_box.select_row(row) + elif len(providers) > 0: + row = self.list_box.get_row_at_index(0) + if row: + self.list_box.select_row(row) + else: + if self.list_box.get_selected_row(): + self.list_box.unselect_all() + else: + self._on_provider_selected(self.list_box, None) + + def get_row_for_provider(self, provider_id: str) -> ProviderRow | None: + return self._row_widgets.get(provider_id) + + def create_row_widget( + self, item: tuple[str, AIProviderConfig] + ) -> ProviderRow: + provider_id, config = item + row_widget = ProviderRow( + provider_id, + config, + provider_id == get_context().ai_service.default_provider_id, + self._on_toggle_enabled, + self._on_delete_provider, + self._on_set_default, + ) + return row_widget + + def _on_add_clicked(self, button: Gtk.Button) -> None: + ai_service = get_context().ai_service + new_config = AIProviderConfig( + id=str(uuid.uuid4())[:8], + name=_("New Provider"), + provider_type=AIProviderType.OPENAI_COMPATIBLE, + api_key="", + base_url="https://api.openai.com/v1", + default_model="", + enabled=True, + ) + ai_service.add_provider(new_config) + + def _on_toggle_enabled(self, provider_id: str, enabled: bool) -> None: + ai_service = get_context().ai_service + config = ai_service.get_config(provider_id) + if config: + new_config = AIProviderConfig( + id=config.id, + name=config.name, + provider_type=config.provider_type, + api_key=config.api_key, + base_url=config.base_url, + default_model=config.default_model, + enabled=enabled, + ) + ai_service.update_provider(new_config) + + def _on_delete_provider(self, provider_id: str, name: str) -> None: + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=cast(Gtk.Window, root) if root else None, + heading=_("Delete '{name}'?").format(name=name), + body=_( + "This AI provider will be permanently removed. " + "This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + + def on_response(d, response_id): + if response_id == "delete": + get_context().ai_service.remove_provider(provider_id) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + def _on_set_default(self, provider_id: str) -> None: + get_context().ai_service.default_provider_id = provider_id + self.populate_and_select(select_id=provider_id) + + def _on_provider_selected( + self, listbox: Gtk.ListBox, row: Gtk.ListBoxRow | None + ) -> None: + provider_id = None + config = None + selected_row = listbox.get_selected_row() + + if selected_row: + child = selected_row.get_child() + if isinstance(child, ProviderRow): + provider_id = child.provider_id + config = child.config + + self.provider_selected.send( + self, provider_id=provider_id, config=config + ) + + +class ProviderEditorWidget(Adw.PreferencesGroup): + """Inline editor widget for AI provider settings with instant apply.""" + + def __init__( + self, list_widget: "ProviderListWidget", **kwargs: Any + ) -> None: + super().__init__(**kwargs) + self.list_widget = list_widget + self.provider_id: str | None = None + self._updating = False + self._setup_ui() + + def _setup_ui(self) -> None: + self.name_row = Adw.EntryRow(title=_("Name")) + self.name_row.connect("changed", self._on_name_changed) + self.add(self.name_row) + + self.type_row = Adw.ComboRow( + title=_("Provider Type"), + model=Gtk.StringList.new([_("OpenAI Compatible")]), + ) + self.type_row.set_selected(0) + self.type_row.connect("notify::selected", self._on_field_changed) + self.add(self.type_row) + + self.base_url_row = Adw.EntryRow(title=_("Base URL")) + self.base_url_row.connect("changed", self._on_field_changed) + self.add(self.base_url_row) + + self.api_key_row = Adw.PasswordEntryRow(title=_("API Key")) + self.api_key_row.connect("changed", self._on_field_changed) + self.add(self.api_key_row) + + self.model_row = Adw.EntryRow(title=_("Default Model")) + self.model_row.connect("changed", self._on_field_changed) + self.add(self.model_row) + + self.test_row = Adw.ActionRow( + title=_("Connection Test"), + subtitle=_("Verify the provider configuration is working"), + ) + self.test_success_icon = get_icon("check-circle-symbolic") + self.test_success_icon.add_css_class("success") + self.test_success_icon.set_valign(Gtk.Align.CENTER) + self.test_success_icon.set_visible(False) + self.test_row.add_suffix(self.test_success_icon) + + self.test_btn = Gtk.Button(label=_("Test")) + self.test_btn.add_css_class("suggested-action") + self.test_btn.set_valign(Gtk.Align.CENTER) + self.test_btn.connect("clicked", self._on_test_clicked) + self.test_row.add_suffix(self.test_btn) + + self.test_error_icon = get_icon("warning-symbolic") + self.test_error_icon.add_css_class("error") + self.test_error_icon.set_valign(Gtk.Align.CENTER) + self.test_error_icon.set_visible(False) + self.test_row.add_suffix(self.test_error_icon) + + self.add(self.test_row) + + self._clear_form() + + def set_provider( + self, provider_id: str | None, config: AIProviderConfig | None + ) -> None: + self._updating = True + self.provider_id = provider_id + + if config: + self.set_title(_("Edit Provider")) + self.name_row.set_text(config.name) + self.api_key_row.set_text(config.api_key) + self.base_url_row.set_text(config.base_url) + self.model_row.set_text(config.default_model) + + if config.provider_type == AIProviderType.OPENAI_COMPATIBLE: + self.type_row.set_selected(0) + else: + self._clear_form() + self.set_title(_("Provider Settings")) + + self._clear_test_status() + self.set_visible(True) + self._updating = False + + def _clear_form(self) -> None: + self.name_row.set_text("") + self.api_key_row.set_text("") + self.base_url_row.set_text("") + self.model_row.set_text("") + self.type_row.set_selected(0) + self._clear_test_status() + + def _clear_test_status(self) -> bool: + self.test_row.set_subtitle( + _("Verify the provider configuration is working") + ) + self.test_success_icon.set_visible(False) + self.test_error_icon.set_visible(False) + self.test_btn.set_sensitive(True) + return False + + def _get_provider_type(self) -> AIProviderType: + idx = self.type_row.get_selected() + types = [AIProviderType.OPENAI_COMPATIBLE] + return types[idx] + + def _on_name_changed(self, entry_row: Adw.EntryRow) -> None: + if self._updating or not self.provider_id: + return + + name = entry_row.get_text().strip() + if not name: + return + + row = self.list_widget.get_row_for_provider(self.provider_id) + if row: + row.title_label.set_label(name) + + self._save_config() + + def _on_field_changed(self, *args: Any) -> None: + if self._updating or not self.provider_id: + return + + name = self.name_row.get_text().strip() + if not name: + return + + self._save_config() + + def _save_config(self) -> None: + if not self.provider_id: + return + + ai_service = get_context().ai_service + current_config = ai_service.get_config(self.provider_id) + if not current_config: + return + + name = self.name_row.get_text().strip() + api_key = self.api_key_row.get_text().strip() + base_url = self.base_url_row.get_text().strip() + default_model = self.model_row.get_text().strip() + + new_config = AIProviderConfig( + id=self.provider_id, + name=name, + provider_type=self._get_provider_type(), + api_key=api_key, + base_url=base_url, + default_model=default_model, + enabled=current_config.enabled, + ) + + self._updating = True + ai_service.update_provider(new_config) + self._updating = False + + row = self.list_widget.get_row_for_provider(self.provider_id) + if row: + row.config = new_config + + def _on_test_clicked(self, button: Gtk.Button) -> None: + self.test_row.set_subtitle(_("Testing...")) + self.test_success_icon.set_visible(False) + self.test_error_icon.set_visible(False) + self.test_btn.set_sensitive(False) + + async def do_test() -> tuple[bool, str]: + try: + test_config = AIProviderConfig( + id="test", + name=self.name_row.get_text() or "Test", + provider_type=self._get_provider_type(), + api_key=self.api_key_row.get_text(), + base_url=self.base_url_row.get_text(), + default_model=self.model_row.get_text(), + enabled=True, + ) + + from ...core.ai.openai_provider import OpenAICompatibleProvider + + provider = OpenAICompatibleProvider(test_config) + success, message = await provider.test_connection() + await provider.close() + + return success, message + except Exception as e: # noqa: BLE001 - async task boundary + return False, str(e) + + from ...shared.tasker import task_mgr + + future = asyncio.run_coroutine_threadsafe(do_test(), task_mgr.loop) + + def on_test_done(f: Future[tuple[bool, str]]) -> None: + try: + success, message = f.result() + GLib.idle_add(self._update_test_result, success, message) + except Exception as e: # noqa: BLE001 - async task boundary + GLib.idle_add(self._update_test_result, False, str(e)) + + future.add_done_callback(on_test_done) + + def _update_test_result(self, success: bool, message: str) -> None: + if success: + self.test_row.set_subtitle("") + self.test_success_icon.set_visible(True) + self.test_error_icon.set_visible(False) + GLib.timeout_add_seconds(5, self._clear_test_status) + else: + self.test_row.set_subtitle(message) + self.test_success_icon.set_visible(False) + self.test_error_icon.set_visible(True) + self.test_btn.set_sensitive(True) + + +class AISettingsPage(TrackedPreferencesPage): + """Settings page for configuring AI providers.""" + + key = "ai" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.set_title(_("AI")) + self.set_icon_name("ai-symbolic") + + self.provider_list = ProviderListWidget( + title=_("AI Providers"), + description=_( + "Configure AI providers for use by addons. " + "Addons can use these providers without needing " + "their own API keys." + ), + ) + self.add(self.provider_list) + + self.provider_editor = ProviderEditorWidget( + self.provider_list, visible=False + ) + self.add(self.provider_editor) + + self.provider_list.provider_selected.connect( + self._on_provider_selected + ) + + get_context().ai_service.changed.connect(self._on_service_changed) + + self.provider_list.populate_and_select() + + def _on_provider_selected( + self, + sender: ProviderListWidget, + provider_id: str | None, + config: AIProviderConfig | None, + ) -> None: + if provider_id and config: + self.provider_editor.set_provider(provider_id, config) + else: + self.provider_editor.set_visible(False) + + def _on_service_changed(self, sender: AIService) -> None: + if self.provider_editor._updating: + return + GLib.idle_add(self._refresh_after_change) + + def _refresh_after_change(self) -> None: + current_id = self.provider_editor.provider_id + self.provider_list.populate_and_select(select_id=current_id) diff --git a/rayforge/ui_gtk/settings/color_presets_page.py b/rayforge/ui_gtk/settings/color_presets_page.py new file mode 100644 index 000000000..8a575bd66 --- /dev/null +++ b/rayforge/ui_gtk/settings/color_presets_page.py @@ -0,0 +1,368 @@ +"""Settings page for managing color presets (color rules).""" + +import logging +import uuid +from gettext import gettext as _ +from typing import Any, cast + +from blinker import Signal +from gi.repository import Adw, Gdk, Gtk + +from ...context import get_context +from ...core.color import normalize_color +from ...core.color_preset import ColorPreset +from ...core.step_registry import step_registry +from ..icons import get_icon +from ..shared.preferences_group import PreferencesGroupWithButton +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +def _rgba_to_hex(rgba: Gdk.RGBA) -> str: + """Convert a Gdk.RGBA to a lowercase hex string.""" + r = round(rgba.red * 255) + g = round(rgba.green * 255) + b = round(rgba.blue * 255) + return f"#{r:02x}{g:02x}{b:02x}" + + +def _hex_to_rgba(color: str) -> Gdk.RGBA: + """Parse a hex color string into a Gdk.RGBA, defaulting to magenta.""" + rgba = Gdk.RGBA() + if not rgba.parse(color): + rgba.parse("#ff00ff") + return rgba + + +def _available_step_types() -> list[tuple[str, str]]: + """ + Returns (class name, typelabel) pairs for all non-hidden steps. + + The list is derived from ``step_registry`` so any registered step + type (including addon-provided ones) is selectable. + """ + entries = [] + for name, cls in step_registry.all_steps().items(): + if getattr(cls, "HIDDEN", False): + continue + entries.append((name, getattr(cls, "TYPELABEL", name))) + entries.sort(key=lambda e: e[1].lower()) + return entries + + +class ColorPresetDialog(Adw.MessageDialog): + """A dialog for creating or editing a ColorPreset.""" + + def __init__( + self, + parent: Gtk.Window | None, + preset: ColorPreset | None = None, + **kwargs, + ): + super().__init__(transient_for=parent, modal=True, **kwargs) + self.preset = preset + is_editing = preset is not None + + self.set_default_size(520, -1) + + if is_editing: + self.set_heading(_("Edit Color Rule")) + self.set_body(_("Update the color rule details:")) + self.add_response("cancel", _("Cancel")) + self.add_response("save", _("Save")) + self.set_response_appearance( + "save", Adw.ResponseAppearance.SUGGESTED + ) + self.set_default_response("save") + else: + self.set_heading(_("Add Color Rule")) + self.set_body(_("Map a color to a step type for SVG imports.")) + self.add_response("cancel", _("Cancel")) + self.add_response("add", _("Add")) + self.set_response_appearance( + "add", Adw.ResponseAppearance.SUGGESTED + ) + self.set_default_response("add") + + self._step_types = _available_step_types() + current_step_type = preset.step_type if preset else None + if current_step_type and current_step_type not in [ + name for name, _ in self._step_types + ]: + # Keep unavailable step types selectable so the preset is + # preserved (e.g. the providing addon was uninstalled). + self._step_types.append( + (current_step_type, f"{current_step_type} (unavailable)") + ) + + # --- Color picker --- + color_dialog = Gtk.ColorDialog() + color_dialog.set_with_alpha(False) + self.color_button = Gtk.ColorDialogButton(dialog=color_dialog) + self.color_button.set_size_request(48, 32) + self.color_button.set_rgba( + _hex_to_rgba(preset.color) if preset else _hex_to_rgba("#ff0000") + ) + self.color_row = Adw.ActionRow( + title=_("Color"), + subtitle=_("SVG color that triggers this rule"), + ) + self.color_row.add_suffix(self.color_button) + self.color_row.set_activatable_widget(self.color_button) + + # --- Label --- + self.label_row = Adw.EntryRow(title=_("Label (optional)")) + + # --- Step type --- + self.step_type_row = Adw.ComboRow( + title=_("Step Type"), + subtitle=_("Step type created when this color is imported"), + ) + self._step_type_model = Gtk.StringList() + for _name, typelabel in self._step_types: + self._step_type_model.append(typelabel) + self.step_type_row.set_model(self._step_type_model) + if current_step_type: + self.step_type_row.set_selected( + self._step_type_index(current_step_type) + ) + + group = Adw.PreferencesGroup() + group.add(self.label_row) + group.add(self.color_row) + group.add(self.step_type_row) + + self.set_extra_child(group) + + if preset: + self.label_row.set_text(preset.label) + + def _step_type_index(self, class_name: str) -> int: + for index, (name, _label) in enumerate(self._step_types): + if name == class_name: + return index + return 0 + + def _selected_step_type(self) -> str: + index = self.step_type_row.get_selected() + return self._step_types[index][0] + + def get_preset_data(self) -> dict[str, Any]: + """Returns the entered data as a dict suitable for ColorPreset.""" + return { + "color": _rgba_to_hex(self.color_button.get_rgba()), + "step_type": self._selected_step_type(), + "label": self.label_row.get_text().strip(), + } + + +class ColorPresetRow(Gtk.Box): + """A widget representing a single ColorPreset in a ListBox.""" + + def __init__(self, preset: ColorPreset, on_edit, on_delete): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.preset = preset + + self.set_margin_top(6) + self.set_margin_bottom(6) + self.set_margin_start(12) + self.set_margin_end(6) + + self.append(self._create_swatch(preset.color)) + + labels_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, hexpand=True + ) + self.append(labels_box) + + title_text = preset.label or _("Color {color}").format( + color=preset.color + ) + title = Gtk.Label(label=title_text, halign=Gtk.Align.START, xalign=0) + labels_box.append(title) + + subtitle = Gtk.Label( + label=self._get_subtitle(), + halign=Gtk.Align.START, + xalign=0, + ) + subtitle.add_css_class("dim-label") + labels_box.append(subtitle) + + suffix_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) + self.append(suffix_box) + + if self._step_type_unavailable(): + warning = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") + warning.set_tooltip_text( + _("This step type is not currently available.") + ) + suffix_box.append(warning) + + edit_button = Gtk.Button(child=get_icon("edit-symbolic")) + edit_button.add_css_class("flat") + edit_button.connect("clicked", lambda w: on_edit(preset)) + suffix_box.append(edit_button) + + delete_button = Gtk.Button(child=get_icon("delete-symbolic")) + delete_button.add_css_class("flat") + delete_button.connect("clicked", lambda w: on_delete(preset)) + suffix_box.append(delete_button) + + def _step_type_unavailable(self) -> bool: + return step_registry.get(self.preset.step_type) is None + + def _get_subtitle(self) -> str: + cls = step_registry.get(self.preset.step_type) + if cls is None: + return _("{step_type} (unavailable)").format( + step_type=self.preset.step_type + ) + typelabel = getattr(cls, "TYPELABEL", self.preset.step_type) + return f"{self.preset.step_type} · {typelabel}" + + @staticmethod + def _create_swatch(color: str) -> Gtk.Widget: + rgba = _hex_to_rgba(color) + swatch = Gtk.DrawingArea(width_request=24, height_request=24) + swatch.set_valign(Gtk.Align.CENTER) + + def on_draw(widget, cr, width, height): + cr.set_source_rgb(rgba.red, rgba.green, rgba.blue) + cr.paint() + + swatch.set_draw_func(on_draw) + return swatch + + +class ColorPresetListWidget(PreferencesGroupWithButton): + """Displays a list of color presets and allows adding/editing/deleting.""" + + def __init__(self, **kwargs): + super().__init__(button_label=_("Add Color Rule"), **kwargs) + self.color_presets_changed = Signal() + + placeholder = Gtk.Label( + label=_("No color rules found."), + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=12, + ) + placeholder.add_css_class("dim-label") + self.list_box.set_placeholder(placeholder) + self.list_box.set_show_separators(True) + + self.populate_presets() + + def populate_presets(self): + preset_mgr = get_context().color_preset_mgr + presets = sorted( + preset_mgr.all_presets(), key=lambda p: p.color.lower() + ) + self.set_items(presets) + + def create_row_widget(self, item: ColorPreset) -> Gtk.Widget: + return ColorPresetRow( + item, self._on_edit_preset, self._on_delete_preset + ) + + def _on_add_clicked(self, button): + root = self.get_root() + parent_window = ( + cast(Gtk.Window, root) if isinstance(root, Gtk.Window) else None + ) + dialog = ColorPresetDialog(parent=parent_window) + + def on_response(d, response_id): + if response_id == "add": + self._save_from_dialog(d) + d.close() + + dialog.connect("response", on_response) + dialog.present() + + def _on_edit_preset(self, preset: ColorPreset): + root = self.get_root() + parent_window = ( + cast(Gtk.Window, root) if isinstance(root, Gtk.Window) else None + ) + dialog = ColorPresetDialog(parent=parent_window, preset=preset) + + def on_response(d, response_id): + if response_id == "save": + self._save_from_dialog(d, existing=preset) + d.close() + + dialog.connect("response", on_response) + dialog.present() + + def _save_from_dialog( + self, dialog: ColorPresetDialog, existing: ColorPreset | None = None + ): + data = dialog.get_preset_data() + color = normalize_color(data["color"]) + if not color: + return + if existing and existing.color != color: + # Replacing the color changes the key; drop the old entry. + get_context().color_preset_mgr.delete_preset(existing.color) + preset = ColorPreset( + color=color, + step_type=data["step_type"], + label=data["label"], + uid=existing.uid if existing else str(uuid.uuid4()), + ) + get_context().color_preset_mgr.add_preset(preset) + self.populate_presets() + self.color_presets_changed.send(self) + + def _on_delete_preset(self, preset: ColorPreset): + root = self.get_root() + dialog = Adw.MessageDialog( + transient_for=( + cast(Gtk.Window, root) + if isinstance(root, Gtk.Window) + else None + ), + heading=_("Delete color rule '{color}'?").format( + color=preset.color + ), + body=_( + "The color rule will be permanently removed. " + "This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + + def on_response(d, response_id): + if response_id == "delete": + get_context().color_preset_mgr.delete_preset(preset.color) + self.populate_presets() + self.color_presets_changed.send(self) + d.destroy() + + dialog.connect("response", on_response) + dialog.present() + + +class ColorPresetPage(TrackedPreferencesPage): + """Widget for managing color rules.""" + + key = "color_presets" + + def __init__(self): + super().__init__(title=_("Color Rules"), icon_name="palette-symbolic") + + self.color_preset_list_editor = ColorPresetListWidget( + title=_("Color Rules"), + description=_( + "Map SVG colors to step types so they are applied " + "automatically when importing." + ), + ) + self.add(self.color_preset_list_editor) diff --git a/rayforge/ui_gtk/settings/general_preferences_page.py b/rayforge/ui_gtk/settings/general_preferences_page.py new file mode 100644 index 000000000..41776293f --- /dev/null +++ b/rayforge/ui_gtk/settings/general_preferences_page.py @@ -0,0 +1,530 @@ +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import ClassVar + +from gi.repository import Adw, GLib, Gtk + +from ...context import get_context +from ...core.config import OpsColorMode, StartupBehavior +from ...shared.units.definitions import ( + get_base_unit_for_quantity, + get_units_for_quantity, +) +from ...shared.util.localized import SUPPORTED_LANGUAGES +from ...ui_gtk.doceditor import file_dialogs +from ...usage import get_usage_tracker +from ..shared.pref_rows.base import SpinRow +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +def _get_language_label(code: str) -> str: + """Return a human-readable label for a language code.""" + labels = { + "en": _("English"), + "de": _("German"), + "es": _("Spanish"), + "fr": _("French"), + "pt": _("Portuguese"), + "uk": _("Ukrainian"), + "zh_CN": _("Chinese (Simplified)"), + "am": _("Amharic"), + } + return labels.get(code, code) + + +class GeneralPreferencesPage(TrackedPreferencesPage): + """ + Preferences page for general application settings. + This is distinct from the machine-specific general settings. + """ + + key = "general" + + # Map for converting between UI index and config string + THEME_MAP: ClassVar[list[str]] = ["system", "light", "dark"] + THEME_LABELS: ClassVar[list[str]] = [_("System"), _("Light"), _("Dark")] + + # Map for startup behavior options + STARTUP_BEHAVIOR_MAP: ClassVar[list[str]] = [ + StartupBehavior.NONE.value, + StartupBehavior.LAST_PROJECT.value, + StartupBehavior.SPECIFIC_PROJECT.value, + ] + STARTUP_BEHAVIOR_LABELS: ClassVar[list[str]] = [ + _("Open nothing"), + _("Open last project"), + _("Open specific project"), + ] + + # Map for ops color mode options + OPS_COLOR_MODE_MAP: ClassVar[list[str]] = [ + OpsColorMode.LASER.value, + OpsColorMode.LAYER.value, + ] + OPS_COLOR_MODE_LABELS: ClassVar[list[str]] = [ + _("Laser Color"), + _("Layer Color"), + ] + + # Language options: None (system default) + supported languages + LANGUAGE_MAP = [None] + SUPPORTED_LANGUAGES + LANGUAGE_LABELS = [_("System Default")] + [ + _get_language_label(code) for code in SUPPORTED_LANGUAGES + ] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_title(_("General")) + self.set_icon_name("ui-settings-symbolic") + + app_settings_group = Adw.PreferencesGroup() + app_settings_group.set_title(_("Appearance")) + app_settings_group.set_description( + _("Settings related to the application's look and feel.") + ) + self.add(app_settings_group) + + self.theme_row = Adw.ComboRow( + model=Gtk.StringList.new(self.THEME_LABELS) + ) + self.theme_row.set_title(_("Theme")) + + config = get_context().config + try: + selected_index = self.THEME_MAP.index(config.theme) + except ValueError: + selected_index = 0 + self.theme_row.set_selected(selected_index) + + self.theme_row.connect("notify::selected", self.on_theme_changed) + app_settings_group.add(self.theme_row) + + # Language selector + self.language_row = Adw.ComboRow( + model=Gtk.StringList.new(self.LANGUAGE_LABELS) + ) + self.language_row.set_title(_("Language")) + self.language_row.set_subtitle( + _("The application language. Changes require a restart.") + ) + config = get_context().config + try: + selected_index = self.LANGUAGE_MAP.index(config.language) + except ValueError: + selected_index = 0 + self.language_row.set_selected(selected_index) + self.language_row.connect("notify::selected", self.on_language_changed) + app_settings_group.add(self.language_row) + + self.ops_color_mode_row = Adw.ComboRow( + model=Gtk.StringList.new(self.OPS_COLOR_MODE_LABELS) + ) + self.ops_color_mode_row.set_title(_("Operation Colors")) + self.ops_color_mode_row.set_subtitle( + _( + "Choose whether operation colors represent the laser " + "or the layer" + ) + ) + try: + selected_index = [m.value for m in OpsColorMode].index( + config.ops_color_mode.value + ) + except ValueError: + selected_index = 0 + self.ops_color_mode_row.set_selected(selected_index) + self.ops_color_mode_row.connect( + "notify::selected", self.on_ops_color_mode_changed + ) + app_settings_group.add(self.ops_color_mode_row) + + # Units Preferences + units_group = Adw.PreferencesGroup() + units_group.set_title(_("Units")) + units_group.set_description( + _( + "Set the display units for various values throughout " + "the application." + ) + ) + self.add(units_group) + + # Length Unit Selector + self.length_units = get_units_for_quantity("length") + length_unit_labels = [u.label for u in self.length_units] + self.length_unit_row = Adw.ComboRow( + title=_("Length"), + model=Gtk.StringList.new(length_unit_labels), + ) + # Find and set the initial selection + try: + base_length_unit = get_base_unit_for_quantity("length") + current_unit_name = config.unit_preferences.get( + "length", base_length_unit.name if base_length_unit else None + ) + + if not current_unit_name: + raise ValueError("No length unit could be determined") + + unit_names = [u.name for u in self.length_units] + selected_index = unit_names.index(current_unit_name) + except (ValueError, AttributeError): + selected_index = 0 # Default to the first unit + self.length_unit_row.set_selected(selected_index) + + self.length_unit_row.connect( + "notify::selected", self.on_length_unit_changed + ) + units_group.add(self.length_unit_row) + + # Speed Unit Selector + self.speed_units = get_units_for_quantity("speed") + speed_unit_labels = [u.label for u in self.speed_units] + self.speed_unit_row = Adw.ComboRow( + title=_("Speed"), + model=Gtk.StringList.new(speed_unit_labels), + ) + # Find and set the initial selection + try: + base_speed_unit = get_base_unit_for_quantity("speed") + current_unit_name = config.unit_preferences.get( + "speed", base_speed_unit.name if base_speed_unit else None + ) + + if not current_unit_name: + raise ValueError("No speed unit could be determined") + + unit_names = [u.name for u in self.speed_units] + selected_index = unit_names.index(current_unit_name) + except (ValueError, AttributeError): + selected_index = 0 # Default to the first unit + self.speed_unit_row.set_selected(selected_index) + + self.speed_unit_row.connect( + "notify::selected", self.on_speed_unit_changed + ) + units_group.add(self.speed_unit_row) + + # Acceleration Unit Selector + self.acceleration_units = get_units_for_quantity("acceleration") + acceleration_unit_labels = [u.label for u in self.acceleration_units] + self.acceleration_unit_row = Adw.ComboRow( + title=_("Acceleration"), + model=Gtk.StringList.new(acceleration_unit_labels), + ) + # Find and set the initial selection + try: + base_acceleration_unit = get_base_unit_for_quantity("acceleration") + current_unit_name = config.unit_preferences.get( + "acceleration", + base_acceleration_unit.name + if base_acceleration_unit + else None, + ) + + if not current_unit_name: + raise ValueError("No acceleration unit could be determined") + + unit_names = [u.name for u in self.acceleration_units] + selected_index = unit_names.index(current_unit_name) + except (ValueError, AttributeError): + selected_index = 0 # Default to the first unit + self.acceleration_unit_row.set_selected(selected_index) + + self.acceleration_unit_row.connect( + "notify::selected", self.on_acceleration_unit_changed + ) + units_group.add(self.acceleration_unit_row) + + # Startup Preferences + startup_group = Adw.PreferencesGroup() + startup_group.set_title(_("Behavior")) + startup_group.set_description( + _("Configure advanced application behavior.") + ) + self.add(startup_group) + + self.auto_pipeline_row = Adw.SwitchRow( + title=_("Auto-update operations"), + subtitle=_( + "Recalculate operations automatically after each change. " + "Disable for manual recalculation via the toolbar button" + ), + ) + self.auto_pipeline_row.set_active(config.auto_pipeline) + self.auto_pipeline_row.connect( + "notify::active", self.on_auto_pipeline_changed + ) + startup_group.add(self.auto_pipeline_row) + + self.cache_budget_row = SpinRow( + _("Cache budget (MB)"), + _("Maximum memory for cache. High complexity scenes require more"), + lower=128, + upper=65536, + step_increment=128, + digits=0, + value=config.cache_budget_bytes / (1024 * 1024), + ) + self.cache_budget_row.value_changed.connect( + self.on_cache_budget_changed + ) + startup_group.add(self.cache_budget_row) + + self.check_updates_row = Adw.SwitchRow( + title=_("Check for updates"), + subtitle=_( + "Automatically check for new Rayforge versions on startup" + ), + ) + self.check_updates_row.set_active(config.check_for_app_updates) + self.check_updates_row.connect( + "notify::active", self.on_check_updates_changed + ) + startup_group.add(self.check_updates_row) + + # Startup behavior selector + self.startup_behavior_row = Adw.ComboRow( + title=_("Startup behavior"), + model=Gtk.StringList.new(self.STARTUP_BEHAVIOR_LABELS), + ) + config = get_context().config + try: + selected_index = self.STARTUP_BEHAVIOR_MAP.index( + config.startup_behavior + ) + except ValueError: + selected_index = 0 + self.startup_behavior_row.set_selected(selected_index) + + self.startup_behavior_row.connect( + "notify::selected", self.on_startup_behavior_changed + ) + startup_group.add(self.startup_behavior_row) + + # Specific project file selector (only shown when needed) + project_path_text = ( + str(config.startup_project_path) + if config.startup_project_path + else "" + ) + self.startup_project_row = Adw.EntryRow( + title=_("Project path"), + text=project_path_text, + ) + self.startup_project_row.set_show_apply_button(True) + self.startup_project_row.connect( + "apply", self.on_startup_project_path_apply + ) + startup_group.add(self.startup_project_row) + + # File picker button for the specific project + self.startup_project_button = Gtk.Button( + label=_("Browse..."), + halign=Gtk.Align.END, + ) + self.startup_project_button.set_valign(Gtk.Align.CENTER) + self.startup_project_button.connect( + "clicked", self.on_startup_project_browse_clicked + ) + self.startup_project_row.add_suffix(self.startup_project_button) + + # Bind the visibility of the project path row to the selection + self.startup_behavior_row.connect( + "notify::selected", self._update_startup_project_visibility + ) + self._update_startup_project_visibility() + + # Privacy Preferences + privacy_group = Adw.PreferencesGroup() + privacy_group.set_title(_("Privacy")) + privacy_group.set_description( + _( + "Help us improve Rayforge by allowing anonymous usage " + "reporting. No personal data is collected." + ) + ) + self.add(privacy_group) + + self.usage_consent_row = Adw.SwitchRow( + title=_("Report Anonymous Usage"), + subtitle=_("Help improve Rayforge"), + ) + self.usage_consent_row.set_active(config.has_consented_tracking) + self.usage_consent_row.connect( + "notify::active", self.on_usage_consent_changed + ) + privacy_group.add(self.usage_consent_row) + + learn_more_label = Gtk.Label( + label=_( + '
Learn more about usage tracking ' + "and privacy." + ), + use_markup=True, + halign=Gtk.Align.START, + margin_top=6, + ) + privacy_group.add(learn_more_label) + + def _update_startup_project_visibility(self, *args): + """Show/hide the project path row based on startup behavior.""" + selected_index = self.startup_behavior_row.get_selected() + should_show = selected_index == self.STARTUP_BEHAVIOR_MAP.index( + StartupBehavior.SPECIFIC_PROJECT.value + ) + self.startup_project_row.set_visible(should_show) + + def on_theme_changed(self, combo_row, _): + """Called when the user selects a new theme.""" + selected_index = combo_row.get_selected() + theme_string = self.THEME_MAP[selected_index] + get_context().config.set_theme(theme_string) + + def on_language_changed(self, combo_row, _param): + """Called when the user selects a new language. + + Saves the preference and prompts the user to restart the + application, since gettext translations are loaded at startup + and cannot be swapped at runtime. + """ + selected_index = combo_row.get_selected() + language = self.LANGUAGE_MAP[selected_index] + get_context().config.set_language(language) + + window = combo_row.get_ancestor(Adw.PreferencesWindow) + if window is None: + window = combo_row.get_ancestor(Gtk.Window) + if window is None: + return + + dialog = Adw.MessageDialog( + transient_for=window, + heading=_("Restart required"), + body=_( + "The language will take effect after restarting Rayforge. " + "Would you like to restart now?" + ), + ) + dialog.add_response("cancel", _("_Cancel")) + dialog.add_response("restart", _("_Restart")) + dialog.set_default_response("restart") + dialog.set_close_response("cancel") + dialog.set_response_appearance( + "restart", Adw.ResponseAppearance.SUGGESTED + ) + + def _on_response(dialog, response): + dialog.destroy() + if response == "restart": + app = window.get_application() + if app is None: + # SettingsWindow is an Adw.Window, not a + # Gtk.ApplicationWindow, so get_application() + # may return None. Walk up to the transient parent + # (the MainWindow) which does have the app. + parent = window.get_transient_for() + if parent is not None: + app = parent.get_application() + if app is not None: + app.request_restart() + + dialog.connect("response", _on_response) + dialog.present() + + def on_ops_color_mode_changed(self, combo_row, _): + """Called when the user selects a new ops color mode.""" + selected_index = combo_row.get_selected() + mode_string = self.OPS_COLOR_MODE_MAP[selected_index] + get_context().config.set_ops_color_mode(OpsColorMode(mode_string)) + + def on_length_unit_changed(self, combo_row, _): + """Called when the user selects a new length unit.""" + selected_index = combo_row.get_selected() + if selected_index >= 0: + selected_unit = self.length_units[selected_index] + get_context().config.set_unit_preference( + "length", selected_unit.name + ) + + def on_speed_unit_changed(self, combo_row, _): + """Called when the user selects a new speed unit.""" + selected_index = combo_row.get_selected() + if selected_index >= 0: + selected_unit = self.speed_units[selected_index] + get_context().config.set_unit_preference( + "speed", selected_unit.name + ) + + def on_acceleration_unit_changed(self, combo_row, _): + """Called when the user selects a new acceleration unit.""" + selected_index = combo_row.get_selected() + if selected_index >= 0: + selected_unit = self.acceleration_units[selected_index] + get_context().config.set_unit_preference( + "acceleration", selected_unit.name + ) + + def on_startup_behavior_changed(self, combo_row, _): + """Called when the user selects a new startup behavior.""" + selected_index = combo_row.get_selected() + if selected_index >= 0: + behavior_string = self.STARTUP_BEHAVIOR_MAP[selected_index] + behavior = StartupBehavior(behavior_string) + get_context().config.set_startup_behavior(behavior) + + def on_startup_project_path_apply(self, entry_row, *args): + """Called when the user applies the project path entry.""" + path_text = entry_row.get_text() + if path_text: + path = Path(path_text) + get_context().config.set_startup_project_path(path) + else: + get_context().config.set_startup_project_path(None) + + def on_startup_project_browse_clicked(self, button): + """Called when the user clicks the browse button.""" + window = button.get_ancestor(Adw.PreferencesWindow) + if not window: + window = button.get_ancestor(Gtk.Window) + + file_dialogs.show_open_project_dialog( + window, self._on_startup_project_dialog_response + ) + + def _on_startup_project_dialog_response(self, dialog, result, user_data): + """Callback for the startup project file dialog.""" + try: + file = dialog.open_finish(result) + if not file: + return + file_path = Path(file.get_path()) + self.startup_project_row.set_text(str(file_path)) + get_context().config.set_startup_project_path(file_path) + except GLib.Error as e: + logger.error(f"Error selecting file: {e.message}") + + def on_usage_consent_changed(self, switch_row, _): + """Called when the user toggles usage reporting.""" + consent = switch_row.get_active() + get_context().config.set_usage_consent(consent) + get_usage_tracker().set_enabled(consent) + + def on_auto_pipeline_changed(self, switch_row, _): + """Called when the user toggles auto pipeline mode.""" + enabled = switch_row.get_active() + get_context().config.set_auto_pipeline(enabled) + + def on_cache_budget_changed(self, row): + """Called when the user adjusts the cache budget.""" + mb = int(row.get_value()) + get_context().config.set_cache_budget_bytes(mb * 1024 * 1024) + + 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) diff --git a/rayforge/ui_gtk/settings/license_settings_page.py b/rayforge/ui_gtk/settings/license_settings_page.py new file mode 100644 index 000000000..8a385cbc1 --- /dev/null +++ b/rayforge/ui_gtk/settings/license_settings_page.py @@ -0,0 +1,239 @@ +import logging +import webbrowser +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, GLib, Gtk + +from ...context import get_context +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class LicenseSettingsPage(TrackedPreferencesPage): + """Settings page for managing licenses.""" + + key = "licenses" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_title(_("Licenses")) + self.set_icon_name("license-symbolic") + + self._groups: list[Adw.PreferencesGroup] = [] + self._build_ui() + + validator = get_context().license_validator + validator.changed.connect(self._on_license_changed) + + def _on_license_changed(self, sender): + GLib.idle_add(self._refresh_ui) + + def _build_ui(self): + self._build_patreon_section() + self._build_licenses_section() + self._build_license_required_section() + + def _add_group(self, group: Adw.PreferencesGroup) -> None: + self._groups.append(group) + self.add(group) + + def _get_addons_for_product_id(self, product_id: str) -> list[str]: + """Get list of addon names that use this product ID.""" + addon_mgr = get_context().addon_mgr + result = [] + for addon_name, addon in addon_mgr.get_all_addons().items(): + if addon.metadata.license: + product_ids = addon.metadata.license.get_all_product_ids() + if product_id in product_ids: + display = addon.metadata.display_name or addon_name + result.append(display) + return result + + def _build_patreon_section(self): + validator = get_context().license_validator + patreon = validator.get_provider("patreon") + + patreon_group = Adw.PreferencesGroup( + title=_("Patreon"), + description=_( + "Link your Patreon account for early access to new addons." + ), + ) + self._add_group(patreon_group) + + if patreon and patreon.is_configured(): + row = Adw.ActionRow( + title=_("Patreon Account Linked"), + subtitle=_("Early access addons are unlocked"), + ) + unlink_btn = Gtk.Button(label=_("Unlink")) + unlink_btn.add_css_class("destructive-action") + unlink_btn.set_valign(Gtk.Align.CENTER) + unlink_btn.connect("clicked", self._on_unlink_patreon) + row.add_suffix(unlink_btn) + patreon_group.add(row) + else: + row = Adw.ActionRow( + title=_("Link Patreon Account"), + subtitle=_("Get early access to premium addons"), + ) + link_btn = Gtk.Button(label=_("Link")) + link_btn.add_css_class("suggested-action") + link_btn.set_valign(Gtk.Align.CENTER) + link_btn.connect("clicked", self._on_link_patreon) + row.add_suffix(link_btn) + patreon_group.add(row) + + def _build_licenses_section(self): + validator = get_context().license_validator + licenses = validator.get_gumroad_licenses() + + licenses_group = Adw.PreferencesGroup( + title=_("Addon Licenses"), + description=_("Manage your purchased license keys."), + ) + self._add_group(licenses_group) + + if not licenses: + empty_row = Adw.ActionRow( + title=_("No licenses installed"), + subtitle=_( + "Purchase a premium addon and enter the license " + "key during installation." + ), + ) + empty_row.set_sensitive(False) + licenses_group.add(empty_row) + return + + for product_id, license_key in licenses.items(): + masked = ( + f"****{license_key[-4:]}" if len(license_key) > 4 else "****" + ) + + addon_names = self._get_addons_for_product_id(product_id) + if addon_names: + max_show = 3 + if len(addon_names) > max_show: + title = _("{addons} (+{count} more)").format( + addons=", ".join(addon_names[:max_show]), + count=len(addon_names) - max_show, + ) + else: + title = ", ".join(addon_names) + subtitle = _("Product ID: {id}").format(id=product_id) + else: + title = product_id + subtitle = masked + + row = Adw.ActionRow(title=title, subtitle=subtitle) + remove_btn = Gtk.Button(label=_("Remove")) + remove_btn.add_css_class("destructive-action") + remove_btn.set_valign(Gtk.Align.CENTER) + remove_btn.connect("clicked", self._on_remove_license, product_id) + row.add_suffix(remove_btn) + licenses_group.add(row) + + def _build_license_required_section(self): + addon_mgr = get_context().addon_mgr + license_required = addon_mgr.get_all_license_required_addons() + + if not license_required: + return + + section = Adw.PreferencesGroup( + title=_("Addons Requiring License"), + description=_("These addons need a valid license to be activated"), + ) + self._add_group(section) + + for addon_name, addon in license_required.items(): + display_name = addon.metadata.display_name or addon_name + row = Adw.ActionRow( + title=display_name, + subtitle=addon.license_message or _("License required"), + ) + + if addon.purchase_url: + buy_btn = Gtk.Button(label=_("Buy")) + buy_btn.set_valign(Gtk.Align.CENTER) + buy_btn.connect( + "clicked", self._on_buy_license, addon.purchase_url + ) + row.add_suffix(buy_btn) + + section.add(row) + + def _on_link_patreon(self, btn): + validator = get_context().license_validator + + def on_oauth_complete(success, error): + if success: + GLib.idle_add(self._refresh_ui) + elif error: + logger.error(f"Patreon OAuth failed: {error}") + + try: + result = validator.start_patreon_oauth(on_oauth_complete) + if result is None: + logger.warning( + "Patreon integration not configured. " + "Set RAYFORGE_PATREON_CLIENT_ID environment variable." + ) + return + _port, _thread = result + oauth_url = validator.get_patreon_oauth_url() + if oauth_url: + logger.info("Opening Patreon OAuth URL") + webbrowser.open(oauth_url) + except Exception: + logger.exception("Failed to start Patreon OAuth") + + def _on_unlink_patreon(self, btn): + validator = get_context().license_validator + validator.unlink_patreon() + self._refresh_ui() + + def _on_remove_license(self, btn, product_id): + dialog = Adw.MessageDialog( + transient_for=cast( + Gtk.Window | None, self.get_ancestor(Gtk.Window) + ), + modal=True, + heading=_("Remove License?"), + body=_( + "This license key will be removed. You may need to " + "re-enter it to use licensed addons." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("remove", _("Remove")) + dialog.set_response_appearance( + "remove", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + + dialog.connect( + "response", self._on_remove_license_response, product_id + ) + dialog.present() + + def _on_remove_license_response( + self, dialog, response_id: str, product_id + ): + if response_id == "remove": + validator = get_context().license_validator + validator.remove_gumroad_license(product_id) + self._refresh_ui() + dialog.close() + + def _on_buy_license(self, btn, purchase_url): + webbrowser.open(purchase_url) + + def _refresh_ui(self): + for group in self._groups: + self.remove(group) + self._groups.clear() + self._build_ui() diff --git a/rayforge/ui_gtk/settings/machine_settings_page.py b/rayforge/ui_gtk/settings/machine_settings_page.py new file mode 100644 index 000000000..8f0b91f96 --- /dev/null +++ b/rayforge/ui_gtk/settings/machine_settings_page.py @@ -0,0 +1,245 @@ +from gettext import gettext as _ +from typing import cast + +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...machine.device.profile import DeviceProfile +from ...machine.models.machine import Machine +from ..icons import get_icon +from ..machine.settings_dialog import MachineSettingsDialog +from ..machine.unified_wizard import UnifiedWizard +from ..shared.gtk import apply_css +from ..shared.preferences_page import TrackedPreferencesPage + +css = """ +.group-with-button-container > .list-box-in-card { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + +.group-with-button-container > .flat-bottom-button, +.group-with-button-container > .flat-bottom-button > .toggle { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + box-shadow: none; +} + +.list-box-in-card row:first-child:selected { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} +""" + + +class MachineSettingsPage(TrackedPreferencesPage): + """A settings page for adding, removing, and managing machines.""" + + key = "machines" + + def __init__(self, **kwargs): + """Initializes the Machine Settings page.""" + super().__init__(**kwargs) + self.set_title(_("Machines")) + self.set_icon_name("hardware-symbolic") + apply_css(css) + + self.machines_group = Adw.PreferencesGroup() + self.machines_group.set_title(_("Configured Machines")) + self.machines_group.set_description(_("Add or remove machines.")) + self.add(self.machines_group) + + container_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + container_box.add_css_class("card") + container_box.add_css_class("group-with-button-container") + self.machines_group.add(container_box) + + self.machine_list_box = Gtk.ListBox( + selection_mode=Gtk.SelectionMode.NONE, show_separators=True + ) + self.machine_list_box.add_css_class("list-box-in-card") + self.machine_list_box.add_css_class("frame") + container_box.append(self.machine_list_box) + + self.add_button = self._create_add_button() + self.add_button.add_css_class("darkbutton") + self.add_button.add_css_class("flat-bottom-button") + container_box.append(self.add_button) + + self._populate_machines_list() + + # Signals + context = get_context() + self.add_button.connect("clicked", self._on_add_machine_clicked) + context.machine_mgr.machine_added.connect( + self._on_machine_list_changed + ) + context.machine_mgr.machine_removed.connect( + self._on_machine_list_changed + ) + context.machine_mgr.machine_updated.connect( + self._on_machine_list_changed + ) + context.config.changed.connect(self._on_machine_list_changed) + + def _populate_machines_list(self): + """Clears and rebuilds the rows within the ListBox.""" + context = get_context() + machine_mgr = context.machine_mgr + config = context.config + + while child := self.machine_list_box.get_row_at_index(0): + self.machine_list_box.remove(child) + + sorted_machines = sorted( + machine_mgr.machines.values(), key=lambda m: m.name.lower() + ) + active_machine_id = config.machine.id if config.machine else None + + for machine in sorted_machines: + row = Adw.ActionRow(title=machine.name) + + # Use a box to hold an icon, ensuring consistent row alignment. + icon_placeholder = Gtk.Box() + icon_placeholder.set_valign(Gtk.Align.CENTER) + icon_placeholder.set_size_request(24, -1) + row.add_prefix(icon_placeholder) + + is_valid, error_msg = machine.validate_driver_setup() + + if not is_valid: + icon = get_icon("warning-symbolic") + icon.add_css_class("warning") + tooltip = error_msg or _( + "This machine has an invalid configuration." + ) + icon.set_tooltip_text(tooltip) + icon_placeholder.append(icon) + row.set_subtitle(tooltip) + elif machine.id == active_machine_id: + icon = get_icon("check-circle-symbolic") + icon.set_tooltip_text(_("This is the active machine.")) + icon_placeholder.append(icon) + row.set_subtitle(machine.id) + else: + row.set_subtitle(machine.id) + + buttons_box = Gtk.Box(spacing=6) + row.add_suffix(buttons_box) + + edit_button = Gtk.Button( + child=get_icon("edit-symbolic"), + valign=Gtk.Align.CENTER, + ) + edit_button.add_css_class("flat") + edit_button.connect( + "clicked", self._on_edit_machine_clicked, machine + ) + buttons_box.append(edit_button) + + delete_button = Gtk.Button( + child=get_icon("delete-symbolic"), + valign=Gtk.Align.CENTER, + ) + delete_button.add_css_class("flat") + delete_button.add_css_class("destructive-action") + delete_button.connect( + "clicked", self._on_delete_machine_clicked, machine + ) + buttons_box.append(delete_button) + + self.machine_list_box.append(row) + + def _on_machine_list_changed(self, sender, **kwargs): + """Handler to rebuild the list when machines change.""" + self._populate_machines_list() + + def _on_edit_machine_clicked(self, button, machine: Machine): + """Opens the detailed settings dialog for a specific machine.""" + dialog = MachineSettingsDialog( + machine=machine, + transient_for=self.get_ancestor(Gtk.Window), + ) + dialog.present() + + def _on_delete_machine_clicked(self, button, machine: Machine): + """Shows a confirmation dialog before deleting a machine.""" + dialog = Adw.MessageDialog( + transient_for=cast( + Gtk.Window | None, self.get_ancestor(Gtk.Window) + ), + modal=True, + heading=_("Delete ‘{name}’?").format(name=machine.name), + body=_( + "This machine profile and all its settings will be " + "permanently removed. This action cannot be undone." + ), + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("delete", _("Delete")) + dialog.set_response_appearance( + "delete", Adw.ResponseAppearance.DESTRUCTIVE + ) + dialog.set_default_response("cancel") + + dialog.connect("response", self._on_delete_confirm_response, machine) + dialog.present() + + def _on_delete_confirm_response( + self, dialog, response_id: str, machine: Machine + ): + """Handles the response from the delete confirmation dialog.""" + if response_id == "delete": + get_context().machine_mgr.remove_machine(machine.id) + dialog.close() + + def _on_add_machine_clicked(self, button): + """Shows the unified wizard to add a new machine.""" + dialog = UnifiedWizard( + transient_for=cast( + Gtk.Window | None, self.get_ancestor(Gtk.Window) + ) + ) + dialog.profile_created.connect(self._on_profile_selected_for_add) + dialog.present() + + def _on_profile_selected_for_add( + self, + sender, + *, + profile: DeviceProfile, + machine: Machine | None = None, + ): + """Creates a machine and opens its settings editor. + + The UnifiedWizard hands back the live ``Machine`` it created + via ``profile.create_machine(...)`` so we don't double-create. + """ + if machine is None: + machine = profile.create_machine(get_context()) + + editor_dialog = MachineSettingsDialog( + machine=machine, + transient_for=self.get_ancestor(Gtk.Window), + ) + editor_dialog.present() + + def _create_add_button(self) -> Gtk.Button: + """Creates the add button with icon and label.""" + button = Gtk.Button() + + button_content = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + halign=Gtk.Align.CENTER, + margin_top=10, + margin_end=12, + margin_bottom=10, + margin_start=12, + ) + button.set_child(button_content) + button_content.append(get_icon("add-symbolic")) + button_content.append(Gtk.Label(label=_("Add Machine"))) + return button diff --git a/rayforge/ui_gtk/settings/material_manager_page.py b/rayforge/ui_gtk/settings/material_manager_page.py new file mode 100644 index 000000000..9ca9e641d --- /dev/null +++ b/rayforge/ui_gtk/settings/material_manager_page.py @@ -0,0 +1,84 @@ +"""Material manager UI component for Rayforge.""" + +import logging +from gettext import gettext as _ + +from ...context import get_context +from ...core.material_library import MaterialLibrary +from ..doceditor.material_library_list import LibraryListWidget +from ..doceditor.material_list import MaterialListWidget +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class MaterialManagerPage(TrackedPreferencesPage): + """ + Widget for managing materials and libraries. + """ + + key = "materials" + + library_list_editor: LibraryListWidget + material_list_editor: MaterialListWidget + + def __init__(self): + """Initialize the material manager.""" + super().__init__( + title=_("Materials"), + icon_name="material-symbolic", + ) + + self.library_list_editor = LibraryListWidget( + title=_("Material Libraries"), + description=_( + "Manage your material libraries. Select a library to " + "view its materials." + ), + ) + self.add(self.library_list_editor) + + self.material_list_editor = MaterialListWidget( + title=_("Materials"), + description=_("Materials in the selected library."), + ) + self.add(self.material_list_editor) + + self.library_list_editor.library_selected.connect( + self._on_library_selected + ) + self.material_list_editor.material_added.connect( + self._on_material_event + ) + self.material_list_editor.material_deleted.connect( + self._on_material_event + ) + + get_context().material_mgr.libraries_changed.connect( + self._on_libraries_changed + ) + + self.library_list_editor.populate_and_select() + + def _on_library_selected( + self, sender, library: MaterialLibrary | None = None + ): + """Handle library selection change.""" + logger.debug( + f"MaterialManager: Library selected: " + f"'{library.library_id if library is not None else 'None'}'" + ) + self.material_list_editor.set_library(library) + + def _on_material_event(self, sender, library: MaterialLibrary): + """ + Handle a material being added to or removed from a library. + + This re-populates and re-selects the library list to ensure the + material count in the subtitle is updated. + """ + self.library_list_editor.populate_and_select(library.library_id) + + def _on_libraries_changed(self, sender): + """Handle libraries being added or removed.""" + self.library_list_editor.populate_and_select() diff --git a/rayforge/ui_gtk/settings/model_preview_widget.py b/rayforge/ui_gtk/settings/model_preview_widget.py new file mode 100644 index 000000000..eb9305e82 --- /dev/null +++ b/rayforge/ui_gtk/settings/model_preview_widget.py @@ -0,0 +1,255 @@ +"""Standalone 3D model preview widget for Rayforge settings.""" + +import logging +from pathlib import Path + +import numpy as np +from gi.repository import Gtk +from OpenGL import GL +from OpenGL.GL.shaders import ( + ShaderCompilationError, + ShaderLinkError, +) + +from ..sim3d.camera import Camera +from ..sim3d.gl_state import render_pass +from ..sim3d.renderer.base import BaseRenderer +from ..sim3d.renderer.model_renderer import _load_mesh_data +from ..sim3d.shader import Shader, SimpleShader + +logger = logging.getLogger(__name__) + + +class ModelPreviewWidget(Gtk.GLArea): + """A minimal GLArea widget that displays a single .glb model.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_has_depth_buffer(True) + self.set_size_request(512, 288) + self._camera: Camera | None = None + self._shader: Shader | None = None + self._renderer: _SimpleModelRenderer | None = None + self._mesh_data = None + self._drag_start: tuple | None = None + self._last_offset: tuple | None = None + self.connect("realize", self._on_realize) + self.connect("render", self._on_render) + self.connect("resize", self._on_resize) + motion = Gtk.GestureDrag() + motion.connect("drag-begin", self._on_drag_begin) + motion.connect("drag-update", self._on_drag_update) + self.add_controller(motion) + middle = Gtk.GestureDrag(button=2) + middle.connect("drag-begin", self._on_drag_begin) + middle.connect("drag-update", self._on_drag_update) + self.add_controller(middle) + scroll = Gtk.EventControllerScroll( + flags=Gtk.EventControllerScrollFlags.VERTICAL + ) + scroll.connect("scroll", self._on_scroll) + self.add_controller(scroll) + + def load_model(self, resolved_path: Path): + self._mesh_data = _load_mesh_data(resolved_path) + if self.get_realized(): + self._init_renderer() + + def _on_realize(self, area): + self.make_current() + GL.glEnable(GL.GL_DEPTH_TEST) + GL.glClearColor(0.12, 0.12, 0.14, 1.0) + try: + self._shader = SimpleShader() + except (ShaderCompilationError, ShaderLinkError) as e: + logger.error(f"Shader compilation failed: {e}") + return + if self._mesh_data: + self._init_renderer() + + def _init_renderer(self): + if self._shader is None or self._mesh_data is None: + return + self._renderer = _SimpleModelRenderer(self._mesh_data) + self._renderer.init_gl() + self._fit_camera() + + def _fit_camera(self): + if self._mesh_data is None: + return + direction = np.array([-0.6, -0.7, 0.4]) + direction = direction / np.linalg.norm(direction) + self._camera = Camera( + position=direction * 2.5, + target=np.zeros(3), + up=np.array([0.0, 0.0, 1.0]), + width=max(self.get_width(), 1), + height=max(self.get_height(), 1), + ) + + def _on_render(self, area, ctx): + if not self._camera or not self._renderer or not self._shader: + return False + color_bit = int(GL.GL_COLOR_BUFFER_BIT) + depth_bit = int(GL.GL_DEPTH_BUFFER_BIT) + GL.glClear(color_bit | depth_bit) + proj = self._camera.get_projection_matrix() + view = self._camera.get_view_matrix() + mvp = proj @ view + self._shader.reset_uniforms() + with render_pass(self._shader): + self._renderer.draw( + self._shader, mvp, camera_position=self._camera.position + ) + return True + + def _on_resize(self, area, w, h): + if self._camera: + self._camera.width = int(w) + self._camera.height = int(h) + + def _on_drag_begin(self, gesture, start_x, start_y): + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + self._drag_start = (start_x, start_y) + self._last_offset = (0.0, 0.0) + + def _on_drag_update(self, gesture, offset_x, offset_y): + if self._drag_start is None or self._camera is None: + return + if self._last_offset is None: + self._last_offset = (offset_x, offset_y) + return + dx = (offset_x - self._last_offset[0]) * 0.01 + dy = (offset_y - self._last_offset[1]) * 0.01 + self._last_offset = (offset_x, offset_y) + target = self._camera.target + forward = target - self._camera.position + forward = forward / np.linalg.norm(forward) + cam_up = self._camera.up.copy() + cam_right = np.cross(forward, cam_up) + norm = np.linalg.norm(cam_right) + if norm > 1e-6: + cam_right /= norm + self._camera.orbit(target, cam_up, -dx) + self._camera.orbit(target, cam_right, -dy) + self.queue_render() + + def _on_scroll(self, controller, dx, dy): + if self._camera: + self._camera.dolly(dy) + self.queue_render() + + +class _SimpleModelRenderer(BaseRenderer): + """Renders pre-loaded mesh data as GL_TRIANGLES.""" + + def __init__(self, mesh_data): + super().__init__() + self._mesh_data = mesh_data + self._vao: int = 0 + self._vbo_pos: int = 0 + self._vbo_norm: int = 0 + self._vbo_color: int = 0 + self._vertex_count: int = 0 + self._has_colors: bool = False + self._positions = None + self._normals = None + + def init_gl(self): + flat_indices = self._mesh_data.faces.flatten() + self._positions = self._mesh_data.positions[flat_indices].copy() + self._normals = self._mesh_data.normals[flat_indices] + bmin = self._positions.min(axis=0) + bmax = self._positions.max(axis=0) + center = (bmin + bmax) / 2.0 + extent = float((bmax - bmin).max()) + if extent > 1e-6: + self._positions = (self._positions - center) / extent + self._vertex_count = len(flat_indices) + + self._vao = self._create_vao() + self._vbo_pos = self._create_vbo() + self._vbo_norm = self._create_vbo() + + colors = None + if self._mesh_data.colors is not None: + colors = self._mesh_data.colors[flat_indices] + if colors.dtype != np.float32: + colors = colors.astype(np.float32) + self._has_colors = True + self._vbo_color = self._create_vbo() + + GL.glBindVertexArray(self._vao) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._vbo_pos) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self._positions.nbytes, + self._positions, + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._vbo_norm) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self._normals.nbytes, + self._normals, + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(2, 3, GL.GL_FLOAT, GL.GL_TRUE, 0, None) + GL.glEnableVertexAttribArray(2) + + if self._has_colors and colors is not None: + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._vbo_color) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + colors.nbytes, + colors, + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(1) + + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def prepare(self, ctx) -> None: + """No per-frame scene state to prepare.""" + + def render(self, ctx, shaders, **kwargs) -> None: + """ + Not used — the preview widget drives :meth:`draw` directly. + + This renderer is not part of the scene render registry, so the + uniform ``render`` entry point has no meaning here. + """ + raise NotImplementedError( + "_SimpleModelRenderer is driven via draw(), not render()." + ) + + def draw( + self, + shader: Shader, + mvp_matrix: np.ndarray, + camera_position: np.ndarray | None = None, + ): + if not self._vao: + return + shader.use() + shader.set_mat4("uMVP", mvp_matrix) + shader.set_float("uUseVertexColor", 1.0 if self._has_colors else 0.0) + shader.set_vec4("uColor", (0.55, 0.65, 0.75, 1.0)) + shader.set_float("uHasNormals", 1.0) + shader.set_vec3("uLightDir", (0.5, 0.8, 1.0)) + cam_pos = ( + camera_position.astype(np.float32) + if camera_position is not None + else np.zeros(3, dtype=np.float32) + ) + shader.set_vec3("uCameraPos", cam_pos) + + GL.glBindVertexArray(self._vao) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, self._vertex_count) + GL.glBindVertexArray(0) diff --git a/rayforge/ui_gtk/settings/recipe_manager_page.py b/rayforge/ui_gtk/settings/recipe_manager_page.py new file mode 100644 index 000000000..7a932866e --- /dev/null +++ b/rayforge/ui_gtk/settings/recipe_manager_page.py @@ -0,0 +1,32 @@ +import logging +from gettext import gettext as _ + +from ..doceditor.recipes.recipe_list import RecipeListWidget +from ..shared.preferences_page import TrackedPreferencesPage + +logger = logging.getLogger(__name__) + + +class RecipeManagerPage(TrackedPreferencesPage): + """ + Widget for managing recipes. + """ + + key = "recipes" + + def __init__(self): + super().__init__( + title=_("Recipes"), + icon_name="recipe-symbolic", + ) + + # For now, we only have one group for all user recipes. + # This structure allows for a library pane to be added later if needed. + self.recipe_list_editor = RecipeListWidget( + title=_("Recipes"), + description=_( + "Manage your saved recipes for different materials " + "and processes." + ), + ) + self.add(self.recipe_list_editor) diff --git a/rayforge/ui_gtk/settings/registry.py b/rayforge/ui_gtk/settings/registry.py new file mode 100644 index 000000000..eeae4f202 --- /dev/null +++ b/rayforge/ui_gtk/settings/registry.py @@ -0,0 +1,82 @@ +"""Registry for addon-contributed Settings dialog pages.""" + +import logging +from collections.abc import Callable + +from blinker import Signal + +logger = logging.getLogger(__name__) + + +class SettingsPageRegistry: + """ + Collects settings page classes contributed by addons. + + A page class is a no-argument widget constructor that exposes + ``get_title()`` and ``get_icon_name()`` (e.g. a + :class:`~rayforge.ui_gtk.shared.preferences_page.TrackedPreferencesPage` + subclass). The + :class:`~rayforge.ui_gtk.settings.settings_dialog.SettingsWindow` + instantiates each registered class when it is built. + + Implements the :class:`~rayforge.addon_mgr.addon_manager.AddonRegistry` + protocol so that pages are removed automatically when their addon is + unloaded. + + Emits the :attr:`changed` signal whenever pages are added or removed + so that open settings windows can rebuild themselves live. + """ + + def __init__(self) -> None: + self._pages: list[tuple[Callable[[], object], str]] = [] + self.changed = Signal() + + def register( + self, + page_class: Callable[[], object], + addon_name: str = "", + ) -> None: + """ + Register a settings page class. + + Re-registering the same class is a no-op (so an addon being + reloaded does not produce duplicate pages). + + Args: + page_class: A no-arg widget constructor. + addon_name: The canonical name of the contributing addon, + used for cleanup on unload. + """ + if any(cls is page_class for cls, _ in self._pages): + return + self._pages.append((page_class, addon_name)) + logger.debug( + f"Registered settings page {page_class!r} for '{addon_name}'" + ) + self.changed.send(self) + + def get_pages(self) -> list[Callable[[], object]]: + """Return all registered page classes in insertion order.""" + return [cls for cls, _ in self._pages] + + def unregister_all_from_addon(self, addon_name: str) -> int: + """ + Remove all pages registered by the named addon. + + Returns: + The number of pages removed. + """ + before = len(self._pages) + self._pages = [ + (cls, name) for cls, name in self._pages if name != addon_name + ] + removed = before - len(self._pages) + if removed: + logger.info( + f"Removed {removed} settings pages from '{addon_name}'" + ) + self.changed.send(self) + return removed + + +settings_page_registry = SettingsPageRegistry() diff --git a/rayforge/ui_gtk/settings/settings_dialog.py b/rayforge/ui_gtk/settings/settings_dialog.py new file mode 100644 index 000000000..2897b70aa --- /dev/null +++ b/rayforge/ui_gtk/settings/settings_dialog.py @@ -0,0 +1,205 @@ +from gettext import gettext as _ +from typing import ClassVar + +from gi.repository import Adw, Gtk + +from ..icons import get_icon +from ..shared.patched_dialog_window import PatchedDialogWindow +from .addon_manager_page import AddonManagerPage +from .ai_settings_page import AISettingsPage +from .color_presets_page import ColorPresetPage +from .general_preferences_page import GeneralPreferencesPage +from .license_settings_page import LicenseSettingsPage +from .machine_settings_page import MachineSettingsPage +from .material_manager_page import MaterialManagerPage +from .recipe_manager_page import RecipeManagerPage +from .registry import settings_page_registry + + +class SettingsWindow(PatchedDialogWindow): + """ + The main, non-modal settings window for the application. + + Addon-contributed settings pages are added and removed live when + addons are enabled or disabled while the window is open. + """ + + # Mapping of built-in page names to indices + PAGE_INDICES: ClassVar[dict[str, int]] = { + "general": 0, + "machines": 1, + "materials": 2, + "recipes": 3, + "color_presets": 4, + "ai": 5, + "addons": 6, + "licenses": 7, + } + + # Number of built-in (non-addon) pages, kept in sync with the + # _add_page calls in __init__. + _BUILTIN_PAGE_COUNT = 8 + + def __init__(self, initial_page: str = "general", **kwargs): + super().__init__(skip_usage_tracking=True, **kwargs) + + self._initial_page = initial_page + self.set_title(_("Settings")) + self.set_default_size(800, 800) + self.set_size_request(-1, -1) + + # Main layout container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_content(main_box) + + # Header bar + header_bar = Adw.HeaderBar() + main_box.append(header_bar) + + # Navigation Split View + split_view = Adw.NavigationSplitView(vexpand=True) + main_box.append(split_view) + + # Sidebar + self.sidebar_list = Gtk.ListBox( + selection_mode=Gtk.SelectionMode.SINGLE, + css_classes=["navigation-sidebar"], + ) + sidebar_page = Adw.NavigationPage.new( + self.sidebar_list, _("Categories") + ) + split_view.set_sidebar(sidebar_page) + + # Content + self.content_stack = Gtk.Stack() + + # Tracks addon page classes currently added to the stack so + # we can diff against the registry on live updates. + self._addon_page_classes: list = [] + + # Populate sidebar and content + self._add_page(GeneralPreferencesPage) + self._add_page(MachineSettingsPage) + self._add_page(MaterialManagerPage) + self._add_page(RecipeManagerPage) + self._add_page(ColorPresetPage) + self._add_page(AISettingsPage) + self._add_page(AddonManagerPage) + self._add_page(LicenseSettingsPage) + + # Addon-contributed pages (registered via the + # register_settings_pages hook). + for page_class in settings_page_registry.get_pages(): + self._add_addon_page(page_class) + + # Create the content's NavigationPage wrapper + pages = self.content_stack.get_pages() + first_stack_page = pages.get_item(0) # type: ignore + initial_title = first_stack_page.get_title() + self.content_page = Adw.NavigationPage.new( + self.content_stack, initial_title + ) + split_view.set_content(self.content_page) + + # Populate + self.sidebar_list.connect("row-selected", self._on_row_selected) + # Select the initial page + initial_index = self.PAGE_INDICES.get(self._initial_page, 0) + initial_row = self.sidebar_list.get_row_at_index(initial_index) + self.sidebar_list.select_row(initial_row) + + # Live-update when addon pages are registered/unregistered. + self._registry_handler = settings_page_registry.changed.connect( + self._on_registry_changed + ) + + def _add_page(self, page_class): + page = page_class() + page_name = page.get_title() + self.content_stack.add_titled(page, page_name, page_name) + + row = Gtk.ListBoxRow() + box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + margin_start=12, + margin_end=12, + margin_top=6, + margin_bottom=6, + ) + icon = get_icon(page.get_icon_name()) + label = Gtk.Label(label=page_name, xalign=0) + box.append(icon) + box.append(label) + row.set_child(box) + self.sidebar_list.append(row) + + def _add_addon_page(self, page_class): + """Add an addon-contributed page and track it for live removal.""" + self._add_page(page_class) + self._addon_page_classes.append(page_class) + + def _remove_addon_page(self, page_class): + """Remove an addon-contributed page by its class identity.""" + idx = None + for i, cls in enumerate(self._addon_page_classes): + if cls is page_class: + idx = i + break + if idx is None: + return + + stack_index = self._BUILTIN_PAGE_COUNT + idx + pages = self.content_stack.get_pages() + stack_page = pages.get_item(stack_index) # type: ignore + child = stack_page.get_child() + + # Preserve current selection if it's not the page being removed. + selected_row = self.sidebar_list.get_selected_row() + selected_index = selected_row.get_index() if selected_row else None + + self.content_stack.remove(child) + row = self.sidebar_list.get_row_at_index(stack_index) + if row is not None: + self.sidebar_list.remove(row) + del self._addon_page_classes[idx] + + # Restore selection, falling back to the first page. + if selected_index is not None and selected_index != stack_index: + new_row = self.sidebar_list.get_row_at_index(selected_index) + if new_row is not None: + self.sidebar_list.select_row(new_row) + else: + first_row = self.sidebar_list.get_row_at_index(0) + if first_row is not None: + self.sidebar_list.select_row(first_row) + + def _on_registry_changed(self, registry): + """Reconcile addon pages with the current registry contents.""" + current = registry.get_pages() + current_set = {id(cls) for cls in current} + + # Remove pages that are no longer registered. + for cls in list(self._addon_page_classes): + if id(cls) not in current_set: + self._remove_addon_page(cls) + + # Add pages that are newly registered, preserving registry order. + existing_ids = {id(cls) for cls in self._addon_page_classes} + for cls in current: + if id(cls) not in existing_ids: + self._add_addon_page(cls) + + def _on_row_selected(self, listbox, row): + if row: + index = row.get_index() + pages = self.content_stack.get_pages() + stack_page = pages.get_item(index) # type: ignore + widget_to_show = stack_page.get_child() + self.content_stack.set_visible_child(widget_to_show) + page_title = stack_page.get_title() + self.content_page.set_title(page_title) + + def do_close_request(self, *args) -> bool: + settings_page_registry.changed.disconnect(self._registry_handler) + return super().do_close_request(*args) diff --git a/rayforge/ui_gtk/shared/__init__.py b/rayforge/ui_gtk/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/shared/adwfix.py b/rayforge/ui_gtk/shared/adwfix.py new file mode 100644 index 000000000..44e99a4fc --- /dev/null +++ b/rayforge/ui_gtk/shared/adwfix.py @@ -0,0 +1,57 @@ +from gi.repository import Gdk, Gtk + +_SPINROW_MIN_WIDTH_CSS = "row spinbutton { min-width: 130px; }" + +_css_loaded = False + + +def ensure_spinrow_min_width(row: Gtk.Widget) -> None: + """Ensure a consistent minimum width on spin buttons inside rows. + + ``Adw.SpinRow.set_width_chars()`` delegates through the + ``Gtk.Editable`` interface but the internal layout does not honour + it, so rows with different adjustment ranges end up with + differently-sized entry fields — and multi-digit values get + clipped. Loading a global CSS rule that sets ``min-width`` on + every ``Gtk.SpinButton`` inside a row works reliably. + """ + global _css_loaded + if not _css_loaded: + provider = Gtk.CssProvider() + provider.load_from_string(_SPINROW_MIN_WIDTH_CSS) + display = Gdk.Display.get_default() + if display is not None: + Gtk.StyleContext.add_provider_for_display( + display, + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + _css_loaded = True + + +def get_spinrow_int(spinrow): + # Workaround: Adw.SpinRow seems to have a bug that the value is not + # always updated if it was edited using the keyboard in the edit + # field. I.e. get_value() still returns the previous value. + # So I convert it manually from text if possible. + try: + value = int(spinrow.get_text()) + except ValueError: + value = int(spinrow.get_value()) + lower = spinrow.get_adjustment().get_lower() + upper = spinrow.get_adjustment().get_upper() + return int(max(lower, min(value, upper))) + + +def get_spinrow_float(spinrow): + # Workaround: Adw.SpinRow seems to have a bug that the value is not + # always updated if it was edited using the keyboard in the edit + # field. I.e. get_value() still returns the previous value. + # So I convert it manually from text if possible. + try: + value = float(spinrow.get_text()) + except ValueError: + value = float(spinrow.get_value()) + lower = spinrow.get_adjustment().get_lower() + upper = spinrow.get_adjustment().get_upper() + return max(lower, min(value, upper)) diff --git a/rayforge/ui_gtk/shared/color_lut_provider.py b/rayforge/ui_gtk/shared/color_lut_provider.py new file mode 100644 index 000000000..e0bf4d91d --- /dev/null +++ b/rayforge/ui_gtk/shared/color_lut_provider.py @@ -0,0 +1,135 @@ +""" +Shared colour LUT assembly for the renderers. + +Builds the per-laser and fallback colour lookup tables consumed by the +3D ops, ring buffer, and texture renderers, so that the canvases no +longer assemble raw arrays themselves. +""" + +from typing import TYPE_CHECKING, Optional + +import numpy as np + +from ...core.color import ColorSet +from ...image.util.srgb import create_lut_from_color +from ...machine.models.colors import OpsColorSet +from ...machine.models.laser import LaserHead + +if TYPE_CHECKING: + from ...machine.models.machine import Machine + + +class ColorLutProvider: + """ + Provides colour LUTs for the power-based renderers. + + Encapsulates the per-laser ``ColorSet`` resolution from the machine + and the assembly of the 1D/2D LUT arrays passed to the renderers' + ``update_color_lut`` methods. The assembled arrays are cached and + rebuilt only after :meth:`invalidate` (e.g. on a theme or laser + change). + """ + + def __init__( + self, + color_set: ColorSet, + laser_color_sets: dict[str, ColorSet], + ): + self._color_set = color_set + self._laser_color_sets = laser_color_sets + self._cut_lut: np.ndarray | None = None + self._engrave_lut: np.ndarray | None = None + self._ring_lut: np.ndarray | None = None + + @classmethod + def from_machine( + cls, + machine: Optional["Machine"], + color_set: ColorSet, + ) -> "ColorLutProvider": + """ + Build a provider from a machine's laser heads and a theme ColorSet. + """ + laser_color_sets: dict[str, ColorSet] = {} + if machine is not None: + for laser in machine.heads: + if not isinstance(laser, LaserHead): + continue + laser_color_set = OpsColorSet.from_laser(laser, color_set) + laser_color_sets[laser.uid] = laser_color_set.to_color_set() + return cls(color_set, laser_color_sets) + + @property + def color_set(self) -> ColorSet: + """The resolved base theme ColorSet.""" + return self._color_set + + @property + def laser_color_sets(self) -> dict[str, ColorSet]: + """Per-laser colour sets keyed by laser UID.""" + return self._laser_color_sets + + @property + def has_lasers(self) -> bool: + """True if per-laser colour sets have been resolved.""" + return bool(self._laser_color_sets) + + @property + def num_lasers(self) -> int: + """Number of resolved lasers (at least 1).""" + return len(self._laser_color_sets) or 1 + + def invalidate(self): + """Drop the cached LUTs so they rebuild on the next read.""" + self._cut_lut = None + self._engrave_lut = None + self._ring_lut = None + + def cut_lut(self) -> np.ndarray: + """LUT for cut/engraved lines, dimmed by power.""" + if self._cut_lut is None: + self._cut_lut = self._build_cut_lut() + return self._cut_lut + + def engrave_lut_2d(self) -> np.ndarray: + """LUT for texture/engrave rendering.""" + if self._engrave_lut is None: + self._engrave_lut = self._build_engrave_lut() + return self._engrave_lut + + def ring_lut_2d(self) -> np.ndarray: + """ + LUT for the scanline overlay ring buffer. + + The overlay dims by power too, so each laser gets a brightness + ramp rather than a flat colour. + """ + if self._ring_lut is None: + self._ring_lut = self._build_ring_lut() + return self._ring_lut + + def _build_cut_lut(self) -> np.ndarray: + if self.has_lasers: + lut = np.zeros((self.num_lasers, 256, 4), dtype=np.float32) + for row_idx, uid in enumerate(self._laser_color_sets): + lut[row_idx] = self._laser_color_sets[uid].get_lut("cut") + return lut + return create_lut_from_color(self._color_set.get_rgba("cut")) + + def _build_engrave_lut(self) -> np.ndarray: + if self.has_lasers: + lut = np.zeros((self.num_lasers, 256, 4), dtype=np.float32) + for row_idx, uid in enumerate(self._laser_color_sets): + lut[row_idx] = self._laser_color_sets[uid].get_lut("engrave") + return lut + return self._color_set.get_lut("engrave") + + def _build_ring_lut(self) -> np.ndarray: + if self.has_lasers: + lut = np.zeros((self.num_lasers, 256, 4), dtype=np.float32) + for row_idx, uid in enumerate(self._laser_color_sets): + cs = self._laser_color_sets[uid] + engrave_rgba = tuple(cs.get_lut("engrave")[255]) + lut[row_idx] = create_lut_from_color(engrave_rgba) + return lut + return create_lut_from_color((1.0, 1.0, 1.0, 1.0)) diff --git a/rayforge/ui_gtk/shared/direction_preview.py b/rayforge/ui_gtk/shared/direction_preview.py new file mode 100644 index 000000000..07980698a --- /dev/null +++ b/rayforge/ui_gtk/shared/direction_preview.py @@ -0,0 +1,114 @@ +import math + +import cairo +from gi.repository import Gtk + + +class DirectionPreview(Gtk.DrawingArea): + VISUAL_SIZE = 140 + MARGIN = 6 + LINE_SPACING = 15 + ARROW_SIZE = 5 + + TRAVEL_COLOR = (1.0, 0.4, 0.0, 0.7) + CUT_COLOR = (1.0, 0.0, 1.0, 1.0) + + def __init__( + self, direction_degrees: float = 0, cross_hatch: bool = False + ): + super().__init__() + self.direction_degrees = direction_degrees + self.cross_hatch = cross_hatch + self.set_content_width(self.VISUAL_SIZE) + self.set_content_height(self.VISUAL_SIZE) + self.set_draw_func(self._draw_func) + + def update(self, degrees: float, cross_hatch: bool = False): + self.direction_degrees = degrees + self.cross_hatch = cross_hatch + self.queue_draw() + + def _draw_pass(self, ctx, cx, cy, direction_degrees, num_lines=7): + angle_rad = math.radians(direction_degrees) + cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad) + perp_cos, perp_sin = -sin_a, cos_a + + half_extent = (self.VISUAL_SIZE - 2 * self.MARGIN) / 2 - 10 + + line_endpoints = [] + cut_starts = [] + cut_ends = [] + for i in range(num_lines): + offset = (i - num_lines // 2) * self.LINE_SPACING + lx = cx + offset * perp_cos + ly = cy + offset * perp_sin + + start_x = lx - half_extent * cos_a + 10 * cos_a + start_y = ly - half_extent * sin_a + 10 * sin_a + end_x = lx + half_extent * cos_a - 10 * cos_a + end_y = ly + half_extent * sin_a - 10 * sin_a + line_endpoints.append((start_x, start_y, end_x, end_y)) + + direction = 1 if i % 2 == 0 else -1 + if direction > 0: + cut_starts.append((start_x, start_y)) + cut_ends.append((end_x, end_y)) + else: + cut_starts.append((end_x, end_y)) + cut_ends.append((start_x, start_y)) + + ctx.set_source_rgba(*self.TRAVEL_COLOR) + ctx.set_line_width(1.5) + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + for i in range(len(cut_ends) - 1): + curr_x, curr_y = cut_ends[i] + next_x, next_y = cut_starts[i + 1] + ctx.move_to(curr_x, curr_y) + ctx.line_to(next_x, next_y) + ctx.stroke() + + ctx.set_source_rgba(*self.CUT_COLOR) + ctx.set_line_width(2.0) + for i, (start_x, start_y, end_x, end_y) in enumerate(line_endpoints): + direction = 1 if i % 2 == 0 else -1 + ctx.move_to(start_x, start_y) + ctx.line_to(end_x, end_y) + ctx.stroke() + + arrow_base_x = end_x if direction > 0 else start_x + arrow_base_y = end_y if direction > 0 else start_y + + ctx.move_to(arrow_base_x, arrow_base_y) + ctx.line_to( + arrow_base_x + + direction * self.ARROW_SIZE * (-cos_a - perp_cos * 0.5), + arrow_base_y + + direction * self.ARROW_SIZE * (-sin_a - perp_sin * 0.5), + ) + ctx.move_to(arrow_base_x, arrow_base_y) + ctx.line_to( + arrow_base_x + + direction * self.ARROW_SIZE * (-cos_a + perp_cos * 0.5), + arrow_base_y + + direction * self.ARROW_SIZE * (-sin_a + perp_sin * 0.5), + ) + ctx.stroke() + + def _draw_func(self, area, ctx, width, height): + ctx.set_source_rgba(0, 0, 0, 0) + ctx.set_operator(cairo.OPERATOR_SOURCE) + ctx.paint() + ctx.set_operator(cairo.OPERATOR_OVER) + + cx, cy = width / 2, height / 2 + + num_lines = 5 if self.cross_hatch else 7 + + if self.cross_hatch: + self._draw_pass( + ctx, cx, cy, self.direction_degrees + 90, num_lines=num_lines + ) + + self._draw_pass( + ctx, cx, cy, self.direction_degrees, num_lines=num_lines + ) diff --git a/rayforge/ui_gtk/shared/dock_area.py b/rayforge/ui_gtk/shared/dock_area.py new file mode 100644 index 000000000..3264af263 --- /dev/null +++ b/rayforge/ui_gtk/shared/dock_area.py @@ -0,0 +1,376 @@ +from blinker import Signal +from gi.repository import Gdk, Gtk + +from .gtk import apply_css + +dock_area_css = """ +box.dock-area { + background: @theme_bg_color; +} + +box.dock-area > box.dock-icon-strip button { + min-width: 28px; + min-height: 28px; + padding: 2px; + margin: 1px; + border-radius: 4px; + border: none; + background: transparent; +} + +box.dock-area > box.dock-icon-strip button:hover { + background: alpha(@theme_fg_color, 0.1); +} + +box.dock-area > box.dock-icon-strip button.active-tab { + background: alpha(@theme_selected_bg_color, 0.2); + color: @theme_selected_bg_color; +} + +box.dock-area > box.dock-icon-strip button.drag-highlight-top { + border-top: 3px solid @theme_selected_bg_color; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +box.dock-area > box.dock-icon-strip button.drag-highlight-bottom { + border-bottom: 3px solid @theme_selected_bg_color; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +box.dock-area.drag-active { + background: alpha(@theme_selected_bg_color, 0.06); + border: 1px dashed alpha(@theme_selected_bg_color, 0.4); + border-radius: 6px; +} +""" + +apply_css(dock_area_css) + + +class DockArea(Gtk.Box): + _drag_source_area = None + + def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, **kwargs): + super().__init__(orientation=orientation, **kwargs) + self.add_css_class("dock-area") + self.set_vexpand(True) + + self.items = {} + self._item_order = [] + self._active_item = None + self._buttons = {} + self._btn_to_name = {} + self._drag_source_name = None + self._last_highlight_btn = None + self._last_highlight_side = None + + self.layout_changed = Signal() + self.tab_changed = Signal() + self.item_dropped = Signal() + + self._icon_strip = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._icon_strip.add_css_class("dock-icon-strip") + self._icon_strip.set_spacing(2) + self._icon_strip.set_visible(False) + + self._stack = Gtk.Stack() + self._stack.set_vexpand(True) + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._stack.set_transition_duration(150) + + self.append(self._icon_strip) + self.append(self._stack) + + self._drop_target = Gtk.DropTarget.new(str, Gdk.DragAction.MOVE) + self._drop_target.connect("motion", self._on_drop_motion) + self._drop_target.connect("leave", self._on_drop_leave) + self._drop_target.connect("drop", self._on_drop) + self.add_controller(self._drop_target) + + def add_item(self, item, position=-1): + if item.name in self.items: + return + parent = item.widget.get_parent() + if parent is not None: + parent.remove(item.widget) + + self.items[item.name] = item + if position < 0 or position >= len(self._item_order): + self._item_order.append(item.name) + else: + self._item_order.insert(position, item.name) + + btn = self._create_tab_button(item) + if position == 0 and len(self._item_order) > 1: + self._icon_strip.prepend(btn) + else: + self._icon_strip.append(btn) + self._buttons[item.name] = btn + self._btn_to_name[btn] = item.name + + self._stack.add_named(item.widget, item.name) + + if self._active_item is None: + self._activate_item(item.name) + else: + self._sync_expand() + + self._update_strip() + + def remove_item(self, name): + if name not in self.items: + return None + item = self.items.pop(name) + self._item_order.remove(name) + + btn = self._buttons.pop(name, None) + if btn: + del self._btn_to_name[btn] + self._icon_strip.remove(btn) + + widget = self._stack.get_child_by_name(name) + if widget: + self._stack.remove(widget) + + if self._active_item == name: + self._active_item = ( + self._item_order[0] if self._item_order else None + ) + if self._active_item and self._stack.get_child_by_name( + self._active_item + ): + self._stack.set_visible_child_name(self._active_item) + + self._update_strip() + self._sync_expand() + return item + + def has_item(self, name): + return name in self.items + + def item_count(self): + return len(self.items) + + def get_item_names(self): + return list(self._item_order) + + def get_active_item(self): + return self._active_item + + def set_active_item(self, name): + if name in self.items and name != self._active_item: + self._activate_item(name) + + def get_layout(self): + return { + "items": list(self._item_order), + "active": self._active_item, + } + + def apply_layout(self, layout): + items = layout.get("items", []) + known = set(self.items.keys()) + filtered = [n for n in items if n in known] + missing = [n for n in self._item_order if n not in filtered] + self._item_order = filtered + missing + active = layout.get("active") + if active and active in self.items: + self._active_item = active + elif self._item_order: + self._active_item = self._item_order[0] + prev = None + for name in self._item_order: + btn = self._buttons.get(name) + if btn is None: + continue + self._icon_strip.reorder_child_after(btn, prev) + prev = btn + if self._active_item: + self._stack.set_visible_child_name(self._active_item) + self._update_strip() + + def _update_strip(self): + n = len(self._item_order) + self._icon_strip.set_visible(n >= 1) + for name, btn in self._buttons.items(): + if n > 1 and name == self._active_item: + btn.add_css_class("active-tab") + else: + btn.remove_css_class("active-tab") + + def _create_tab_button(self, item): + from ..icons import get_icon + + btn = Gtk.Button(child=get_icon(item.icon_name)) + btn.set_tooltip_text(item.label) + btn.add_css_class("flat") + btn.connect("clicked", self._on_button_clicked, item.name) + + drag_source = Gtk.DragSource() + drag_source.set_actions(Gdk.DragAction.MOVE) + drag_source.connect("prepare", self._on_drag_prepare, item.name) + drag_source.connect("drag-begin", self._on_drag_begin) + drag_source.connect("drag-end", self._on_drag_end) + btn.add_controller(drag_source) + + return btn + + def _on_button_clicked(self, button, name): + self._activate_item(name) + + def _activate_item(self, name): + if self._active_item is not None: + old_btn = self._buttons.get(self._active_item) + if old_btn is not None: + old_btn.remove_css_class("active-tab") + + self._active_item = name + self._stack.set_visible_child_name(name) + + new_btn = self._buttons.get(name) + if new_btn and len(self._item_order) > 1: + new_btn.add_css_class("active-tab") + + self._sync_expand() + self.tab_changed.send(self, name=name) + + def _sync_expand(self): + expands = any( + self.items[n].expands for n in self._item_order if n in self.items + ) + self._stack.set_hexpand(expands) + self.set_hexpand(expands) + + def _on_drag_prepare(self, source, x, y, name): + self._drag_source_name = name + DockArea._drag_source_area = self + return Gdk.ContentProvider.new_for_value(name) + + def _on_drag_begin(self, source, drag): + btn = self._buttons.get(self._drag_source_name) + if btn: + icon = btn.get_child() + if icon: + paintable = Gtk.WidgetPaintable.new(icon) + source.set_icon(paintable, 0, 0) + + def _on_drag_end(self, source, drag, delete_data): + self._drag_source_name = None + DockArea._drag_source_area = None + self._clear_highlight() + + def _on_drop_motion(self, target, x, y): + source = DockArea._drag_source_area + if source is not None and source.get_parent() is None: + DockArea._drag_source_area = None + source = None + if source is None: + self._clear_highlight() + self.remove_css_class("drag-active") + return 0 + + if source is self: + return self._handle_same_area_motion(x, y) + + self._clear_highlight() + self.add_css_class("drag-active") + return Gdk.DragAction.MOVE + + def _handle_same_area_motion(self, x, y): + self.remove_css_class("drag-active") + strip_alloc = self._icon_strip.get_allocation() + if strip_alloc.width <= 0: + self._clear_highlight() + return 0 + strip_y = y - strip_alloc.y + tab_name = self._get_tab_name_at_y(strip_y) + if tab_name is None or tab_name == self._drag_source_name: + self._clear_highlight() + return Gdk.DragAction.MOVE + btn = self._buttons[tab_name] + btn_alloc = btn.get_allocation() + mid = btn_alloc.y + btn_alloc.height / 2 + side = "top" if strip_y < mid else "bottom" + self._set_highlight(btn, side) + return Gdk.DragAction.MOVE + + def _on_drop_leave(self, target): + self._clear_highlight() + self.remove_css_class("drag-active") + + def _on_drop(self, target, value, x, y): + self._clear_highlight() + self.remove_css_class("drag-active") + if not isinstance(value, str): + return False + name = value + source = DockArea._drag_source_area + if source is not None and source.get_parent() is None: + DockArea._drag_source_area = None + source = None + + if source is self and name in self.items: + return self._handle_same_area_drop(x, y, name) + + if source is not None and name not in self.items: + self.item_dropped.send(self, name=name) + return True + + return False + + def _handle_same_area_drop(self, x, y, name): + strip_alloc = self._icon_strip.get_allocation() + strip_y = y - strip_alloc.y + target_name = self._get_tab_name_at_y(strip_y) + if target_name is None or target_name == name: + return False + source_btn = self._buttons[name] + target_btn = self._buttons[target_name] + target_btn_alloc = target_btn.get_allocation() + insert_after = ( + strip_y >= target_btn_alloc.y + target_btn_alloc.height / 2 + ) + if insert_after: + self._icon_strip.reorder_child_after(source_btn, target_btn) + else: + prev_sib = target_btn.get_prev_sibling() + self._icon_strip.reorder_child_after(source_btn, prev_sib) + self._item_order = self._get_visual_order() + self.layout_changed.send(self) + return True + + def _get_visual_order(self): + order = [] + child = self._icon_strip.get_first_child() + while child is not None: + if child in self._btn_to_name: + order.append(self._btn_to_name[child]) + child = child.get_next_sibling() + return order + + def _get_tab_name_at_y(self, y): + child = self._icon_strip.get_first_child() + while child is not None: + if child in self._btn_to_name: + alloc = child.get_allocation() + if alloc.y <= y <= alloc.y + alloc.height: + return self._btn_to_name[child] + child = child.get_next_sibling() + return None + + def _set_highlight(self, btn, side): + self._clear_highlight() + btn.add_css_class(f"drag-highlight-{side}") + self._last_highlight_btn = btn + self._last_highlight_side = side + + def _clear_highlight(self): + if self._last_highlight_btn is not None: + if self._last_highlight_side: + self._last_highlight_btn.remove_css_class( + f"drag-highlight-{self._last_highlight_side}" + ) + self._last_highlight_btn = None + self._last_highlight_side = None diff --git a/rayforge/ui_gtk/shared/dock_item.py b/rayforge/ui_gtk/shared/dock_item.py new file mode 100644 index 000000000..b382d536b --- /dev/null +++ b/rayforge/ui_gtk/shared/dock_item.py @@ -0,0 +1,36 @@ +from .gtk import apply_css + +dock_css = """ +box.dock-edge-zone { + min-width: 6px; + min-height: 6px; + background: transparent; + transition: background 150ms ease; +} + +box.dock-edge-zone.highlight-left { + background: alpha(@theme_selected_bg_color, 0.3); + border-left: 2px solid @theme_selected_bg_color; +} + +box.dock-edge-zone.highlight-right { + background: alpha(@theme_selected_bg_color, 0.3); + border-right: 2px solid @theme_selected_bg_color; +} + +box.dock-area-drop-highlight { + background: alpha(@theme_selected_bg_color, 0.08); +} +""" + + +apply_css(dock_css) + + +class DockItem: + def __init__(self, name, icon_name, widget, label=None, expands=True): + self.name = name + self.icon_name = icon_name + self.widget = widget + self.label = label or name + self.expands = expands diff --git a/rayforge/ui_gtk/shared/dock_layout.py b/rayforge/ui_gtk/shared/dock_layout.py new file mode 100644 index 000000000..dfa820547 --- /dev/null +++ b/rayforge/ui_gtk/shared/dock_layout.py @@ -0,0 +1,471 @@ +import logging + +from blinker import Signal +from gi.repository import Gdk, GLib, Graphene, Gsk, Gtk + +from .dock_area import DockArea +from .gtk import apply_css + +logger = logging.getLogger(__name__) + +divider_css = """ +.dock-divider { + background: transparent; + transition: background 100ms ease; +} +.dock-divider:hover { + background: alpha(@theme_fg_color, 0.08); +} +.dock-divider.drop-highlight { + background: alpha(@theme_selected_bg_color, 0.25); + border-left: 2px solid @theme_selected_bg_color; + border-right: 2px solid @theme_selected_bg_color; +} +""" + +apply_css(divider_css) + + +class DockLayout(Gtk.Widget): + _DIVIDER_WIDTH = 6 + + def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, **kwargs): + super().__init__(**kwargs) + self.set_hexpand(True) + self.set_vexpand(True) + + self._items = {} + self._default_buddies = {} + self._areas = [] + self._dividers = [] + self._orientation = orientation + self._area_sizes = [] + self._drag_start_sizes = [] + self._dragging = False + self._dragging_divider = -1 + self._drag_start_x = 0 + + self.layout_changed = Signal() + self.tab_changed = Signal() + + gesture = Gtk.GestureDrag() + gesture.connect("drag-begin", self._on_drag_begin) + gesture.connect("drag-update", self._on_drag_update) + gesture.connect("drag-end", self._on_drag_end) + self.add_controller(gesture) + + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + self.add_controller(motion) + + def register_item(self, item): + self._items[item.name] = item + + def set_default_item_buddy(self, item_name, buddy_name): + self._default_buddies[item_name] = buddy_name + + def get_item(self, name): + return self._items.get(name) + + def get_item_names(self): + return list(self._items.keys()) + + def add_area(self, position=-1): + area = DockArea(orientation=self._orientation) + area.layout_changed.connect(self._on_area_layout_changed) + area.tab_changed.connect(self._on_area_tab_changed) + area.item_dropped.connect(self._on_area_item_dropped) + + if position < 0 or position >= len(self._areas): + self._areas.append(area) + else: + self._areas.insert(position, area) + self._rebuild_layout() + return area + + def remove_area(self, area): + if area in self._areas: + self._areas.remove(area) + self._rebuild_layout() + + def get_areas(self): + return list(self._areas) + + def get_area_count(self): + return len(self._areas) + + def find_item_area(self, name): + for area in self._areas: + if area.has_item(name): + return area + return None + + def move_item(self, item_name, to_area, position=-1): + source_area = self.find_item_area(item_name) + if source_area is None: + return + if source_area is to_area: + return + item = source_area.remove_item(item_name) + if item is None: + return + to_area.add_item(item, position) + if source_area.item_count() == 0: + self.remove_area(source_area) + self.layout_changed.send(self) + + def _on_area_layout_changed(self, sender): + self.layout_changed.send(self) + + def _on_area_tab_changed(self, sender, *, name): + self.tab_changed.send(self, name=name) + + def _on_area_item_dropped(self, sender, *, name): + target_area = sender + GLib.idle_add(self._deferred_move_item, name, target_area) + + def _deferred_move_item(self, item_name, to_area): + DockArea._drag_source_area = None + self.move_item(item_name, to_area) + return GLib.SOURCE_REMOVE + + def _rebuild_layout(self): + child = self.get_first_child() + while child is not None: + next_child = child.get_next_sibling() + child.unparent() + child = next_child + + self._dividers.clear() + self._area_sizes = [] + + if not self._areas: + return + + for area in self._areas: + area.set_parent(self) + + for i in range(len(self._areas) - 1): + divider = self._create_divider(i) + divider.set_parent(self) + self._dividers.append(divider) + + def _create_divider(self, index): + divider = Gtk.Box() + divider.add_css_class("dock-divider") + + drop = Gtk.DropTarget.new(str, Gdk.DragAction.MOVE) + drop.connect("enter", self._on_divider_drop_enter, divider) + drop.connect("leave", self._on_divider_drop_leave, divider) + drop.connect("drop", self._on_divider_drop, index) + divider.add_controller(drop) + + return divider + + def do_get_request_mode(self): + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH + + def do_measure(self, orientation, for_size): + if not self._areas: + return (0, 0, -1, -1) + + if orientation == Gtk.Orientation.VERTICAL: + max_min = 0 + max_nat = 0 + for area in self._areas: + m, n, _, _ = area.measure(orientation, for_size) + max_min = max(max_min, m) + max_nat = max(max_nat, n) + return (max_min, max_nat, -1, -1) + + total_min = 0 + total_nat = 0 + for area in self._areas: + m, n, _, _ = area.measure(orientation, for_size) + total_min += m + total_nat += n + dividers = max(0, len(self._areas) - 1) * self._DIVIDER_WIDTH + return (total_min + dividers, total_nat + dividers, -1, -1) + + def do_size_allocate(self, width, height, baseline): + if not self._areas: + return + + n = len(self._areas) + divider_total = max(0, n - 1) * self._DIVIDER_WIDTH + available = width - divider_total + + if not self._area_sizes or len(self._area_sizes) != n: + self._compute_default_sizes(available, height) + + if self._dragging: + sizes = list(self._area_sizes) + else: + sizes = self._fit_sizes(available, height) + + x = 0 + for i, area in enumerate(self._areas): + transform = Gsk.Transform().translate(Graphene.Point().init(x, 0)) + area.allocate(sizes[i], height, baseline, transform) + x += sizes[i] + + if i < len(self._dividers): + div_transform = Gsk.Transform().translate( + Graphene.Point().init(x, 0) + ) + self._dividers[i].allocate( + self._DIVIDER_WIDTH, height, baseline, div_transform + ) + x += self._DIVIDER_WIDTH + + self._area_sizes = sizes + + def _compute_default_sizes(self, available, height): + n = len(self._areas) + sizes = [0] * n + + remaining = available + for i, area in enumerate(self._areas): + if not area.get_hexpand(): + _, nat, _, _ = area.measure(Gtk.Orientation.HORIZONTAL, height) + sizes[i] = nat + remaining -= nat + + expandable = [i for i, a in enumerate(self._areas) if a.get_hexpand()] + if expandable: + share = max(50, remaining // len(expandable)) + for i in expandable: + sizes[i] = share + + diff = available - sum(sizes) + if expandable: + sizes[expandable[-1]] += diff + + self._area_sizes = sizes + + def _fit_sizes(self, available, height): + sizes = list(self._area_sizes) + n = len(sizes) + if n == 0: + return sizes + + for i in range(n): + if not self._areas[i].get_hexpand(): + _, nat, _, _ = self._areas[i].measure( + Gtk.Orientation.HORIZONTAL, height + ) + sizes[i] = nat + + fixed_total = sum( + sizes[i] for i in range(n) if not self._areas[i].get_hexpand() + ) + remaining = available - fixed_total + expandable = [i for i in range(n) if self._areas[i].get_hexpand()] + + if expandable: + exp_total = sum(sizes[i] for i in expandable) + if exp_total > 0: + ratio = remaining / exp_total + for i in expandable: + sizes[i] = max(50, int(sizes[i] * ratio)) + else: + share = remaining // len(expandable) + for i in expandable: + sizes[i] = max(50, share) + + diff = available - sum(sizes) + if expandable: + sizes[expandable[-1]] += diff + + return sizes + + def _divider_at_x(self, x): + if len(self._area_sizes) < 2: + return -1 + + pos = 0 + for i in range(len(self._area_sizes)): + pos += self._area_sizes[i] + if abs( + x - (pos + self._DIVIDER_WIDTH / 2) + ) <= self._DIVIDER_WIDTH and i + 1 < len(self._areas): + left = self._areas[i].get_hexpand() + right = self._areas[i + 1].get_hexpand() + if left and right: + return i + pos += self._DIVIDER_WIDTH + + return -1 + + def _on_drag_begin(self, gesture, x, y): + div = self._divider_at_x(x) + if div < 0: + gesture.set_state(Gtk.EventSequenceState.DENIED) + return + self._dragging_divider = div + self._drag_start_x = x + self._drag_start_sizes = list(self._area_sizes) + self._dragging = True + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + def _on_drag_update(self, gesture, offset_x, offset_y): + if not self._dragging: + return + + i = self._dragging_divider + delta = int(offset_x) + sizes = list(self._drag_start_sizes) + + new_left = sizes[i] + delta + new_right = sizes[i + 1] - delta + + alloc = self.get_allocation() + height = alloc.height + left_min, _, _, _ = self._areas[i].measure( + Gtk.Orientation.HORIZONTAL, height + ) + right_min, _, _, _ = self._areas[i + 1].measure( + Gtk.Orientation.HORIZONTAL, height + ) + + if new_left < left_min: + new_left = left_min + new_right = sizes[i] + sizes[i + 1] - new_left + if new_right < right_min: + new_right = right_min + new_left = sizes[i] + sizes[i + 1] - new_right + + self._area_sizes[i] = new_left + self._area_sizes[i + 1] = new_right + self.queue_allocate() + + def _on_drag_end(self, gesture, x, y): + if self._dragging: + self.layout_changed.send(self) + self._dragging = False + self._dragging_divider = -1 + self._drag_start_sizes = [] + + def _on_motion(self, ctrl, x, y): + if self._divider_at_x(x) >= 0: + self.set_cursor_from_name("col-resize") + else: + self.set_cursor(None) + + def _on_divider_drop_enter(self, target, x, y, divider): + divider.add_css_class("drop-highlight") + return Gdk.DragAction.MOVE + + def _on_divider_drop_leave(self, target, divider): + divider.remove_css_class("drop-highlight") + + def _on_divider_drop(self, target, value, x, y, index): + divider = target.get_widget() + divider.remove_css_class("drop-highlight") + if not isinstance(value, str): + return False + + item_name = value + source_area = self.find_item_area(item_name) + if source_area is None: + return False + + if source_area.item_count() == 1 and source_area in self._areas: + source_idx = self._areas.index(source_area) + if index == source_idx or index == source_idx - 1: + return False + + insert_idx = index + 1 + GLib.idle_add(self._deferred_edge_drop, item_name, insert_idx) + return True + + def _deferred_edge_drop(self, item_name, insert_idx): + DockArea._drag_source_area = None + source_area = self.find_item_area(item_name) + if source_area is None: + return GLib.SOURCE_REMOVE + + new_area = self.add_area(insert_idx) + self.move_item(item_name, new_area) + self.layout_changed.send(self) + return GLib.SOURCE_REMOVE + + def get_layout(self): + areas = [] + for i, area in enumerate(self._areas): + area_data = area.get_layout() + if i < len(self._area_sizes): + area_data["size"] = self._area_sizes[i] + areas.append(area_data) + return {"areas": areas} + + def apply_layout(self, layout): + areas_data = layout.get("areas", []) + if not areas_data: + return + + all_item_names = list(self._items.keys()) + placed = set() + saved_sizes = [] + + new_areas = [] + for area_data in areas_data: + items = area_data.get("items", []) + valid = [n for n in items if n in self._items] + if not valid: + continue + + area = DockArea(orientation=self._orientation) + area.layout_changed.connect(self._on_area_layout_changed) + area.tab_changed.connect(self._on_area_tab_changed) + area.item_dropped.connect(self._on_area_item_dropped) + for name in valid: + area.add_item(self._items[name]) + placed.add(name) + active = area_data.get("active") + if active and active in area.items: + area.set_active_item(active) + new_areas.append(area) + size = area_data.get("size") + saved_sizes.append(size if isinstance(size, int) else None) + + unplaced = [n for n in all_item_names if n not in placed] + if unplaced and new_areas: + for name in unplaced: + buddy = self._default_buddies.get(name) + target = None + if buddy: + for area in new_areas: + if area.has_item(buddy): + target = area + break + if target is None: + target = new_areas[0] + target.add_item(self._items[name]) + saved_sizes.append(None) + elif unplaced: + area = DockArea(orientation=self._orientation) + area.layout_changed.connect(self._on_area_layout_changed) + area.tab_changed.connect(self._on_area_tab_changed) + area.item_dropped.connect(self._on_area_item_dropped) + for name in unplaced: + area.add_item(self._items[name]) + new_areas.append(area) + saved_sizes.append(None) + + for old_area in self._areas: + for name in list(old_area.get_item_names()): + old_area.remove_item(name) + + self._areas = new_areas + self._rebuild_layout() + + if len(saved_sizes) == len(self._areas) and all( + s is not None for s in saved_sizes + ): + self._area_sizes = saved_sizes + self.queue_allocate() + + def set_area_active(self, area_index, item_name): + if 0 <= area_index < len(self._areas): + self._areas[area_index].set_active_item(item_name) diff --git a/rayforge/ui_gtk/shared/draglist.py b/rayforge/ui_gtk/shared/draglist.py new file mode 100644 index 000000000..50711d210 --- /dev/null +++ b/rayforge/ui_gtk/shared/draglist.py @@ -0,0 +1,262 @@ +from typing import Protocol, runtime_checkable + +from blinker import Signal +from gi.repository import Gdk, Gtk + +from ..icons import get_icon +from .gtk import apply_css + +css = """ +.material-list { + background-color: transparent; + padding: 0; +} + +.material-list>row { + background-color: transparent; + transition: background-color 0.2s ease; + border-bottom: 1px solid #00000020; +} +.material-list>row:last-child { + border: 0; +} +.material-list>row:hover { +} +.material-list>row:drop(active) { + outline: none; + box-shadow: none; +} +.material-list>row.drop-above { + border: 1px solid #f00; + border-width: 2px 0px 0px 0px; +} +.material-list>row.drop-below { + border: 1px solid #f00; + border-width: 0px 0px 2px 0px; +} +.material-list>row:active { +} +.drag-handle { + opacity: 0.5; +} +.material-list>row:hover .drag-handle { + opacity: 1; +} +""" + + +@runtime_checkable +class Draggable(Protocol): + """ + A protocol for widgets that can provide content for a drag operation. + + This is typically used for dragging an item out of a list view onto another + widget, like a canvas. Any widget that implements the `get_drag_content` + method will satisfy this protocol. + """ + + def get_drag_content(self) -> Gdk.ContentProvider: + """Provides the content for a drag operation.""" + ... + + +class DragListBox(Gtk.ListBox): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_selection_mode(Gtk.SelectionMode.NONE) + self.add_css_class("material-list") + apply_css(css) + self.reordered = Signal() + self.drag_source_row = None + self.potential_drop_index = -1 + + def add_row(self, row): + # Get original content widget from the row + original_child = row.get_child() + if original_child: + row.set_child(None) # Detach to re-parent it + + # Create a container box with a handle and the original content + hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + margin_start=6, + margin_end=6, + margin_top=6, + margin_bottom=6, + ) + + # Create drag handle + handle = get_icon("drag-handle-symbolic") + handle.add_css_class("drag-handle") + handle.set_valign(Gtk.Align.CENTER) + + hbox.append(handle) + + if original_child: + original_child.set_hexpand(True) + hbox.append(original_child) + + row.set_child(hbox) + self.append(row) + self.make_row_draggable(row) + + def make_row_draggable(self, row): + # Drag source is attached to the entire row. Clicks on interactive + # children (buttons, entries) are consumed by them and won't + # start a drag, which is the desired behavior. + drag_source = Gtk.DragSource() + # Allow MOVE for reordering and COPY for dragging content out + # (e.g. to canvas) + drag_source.set_actions(Gdk.DragAction.MOVE | Gdk.DragAction.COPY) + drag_source.connect("prepare", self.on_drag_prepare, row) + drag_source.connect("drag-end", self.on_drag_end) + # Attach the drag source to the entire row. Clicks on interactive + # children (buttons, entries) are consumed by them and won't + # start a drag, which is the desired behavior. + row.add_controller(drag_source) + + # Drop target is also on the entire row. + drop_target = Gtk.DropTarget.new(Gtk.ListBoxRow, Gdk.DragAction.MOVE) + drop_target.connect("drop", self.on_drop) + drop_target.connect("motion", self.on_drag_motion) + drop_target.connect("leave", self.on_drag_leave) + row.add_controller(drop_target) + + def _remove_drop_marker(self): + row = self.get_first_child() + while row: + row.remove_css_class("drop-above") + row.remove_css_class("drop-below") + row = row.get_next_sibling() + + def on_drag_prepare(self, source, x, y, row): + snapshot = Gtk.Snapshot() + row.do_snapshot(row, snapshot) + paintable = snapshot.to_paintable() + + source.set_icon(paintable, x, y) + + self.drag_source_row = row + self.potential_drop_index = -1 + + # Default provider for reordering within the list + reorder_provider = Gdk.ContentProvider.new_for_value(row) + providers = [reorder_provider] + + # Check if the row's content widget provides custom drag content for + # dropping on external widgets (like the canvas). + hbox = row.get_child() + if hbox and isinstance(hbox, Gtk.Box): + # The actual content widget is the last child in our hbox layout. + content_widget = hbox.get_last_child() + + # Use a type-safe protocol check instead of hasattr + if isinstance(content_widget, Draggable): + # This is usually a provider for a string (e.g., sketch UID) + drag_out_provider = content_widget.get_drag_content() + if drag_out_provider: + providers.append(drag_out_provider) + + # If we have multiple providers, unite them. Otherwise, just use the + # one. + if len(providers) > 1: + return Gdk.ContentProvider.new_union(providers) + else: + return providers[0] + + def on_drag_motion(self, drop_target, x, y): + # This handler is called on the *target* list. We only want to handle + # drags that originated from *this* list. `self.drag_source_row` is + # only set on the source list in `on_drag_prepare`. + target_row = drop_target.get_widget() + if not self.drag_source_row: + return Gdk.DragAction(0) # Reject drops from other lists + + self._remove_drop_marker() + + # Determine drop position and update visual marker + if y < (target_row.get_height() / 2): + target_row.add_css_class("drop-above") + drop_index = target_row.get_index() + else: + target_row.add_css_class("drop-below") + drop_index = target_row.get_index() + 1 + + # Adjust index for the removal of the source row + assert self.drag_source_row + source_index = self.drag_source_row.get_index() + if source_index < drop_index: + drop_index -= 1 + + self.potential_drop_index = drop_index + return Gdk.DragAction.MOVE + + def on_drag_leave(self, drop_target): + self._remove_drop_marker() + + def on_drag_end(self, source, drag, delete_data): + # `delete_data` is True if `on_drop` returned True, meaning the drop + # happened on a valid target. + # If `delete_data` is False, we check if we have a last known valid + # position. + if delete_data or (self.potential_drop_index != -1): + assert self.drag_source_row + source_index = self.drag_source_row.get_index() + # Only perform the move if the position is different + if source_index != self.potential_drop_index: + self.remove(self.drag_source_row) + self.insert(self.drag_source_row, self.potential_drop_index) + self.reordered.send(self) + + self._remove_drop_marker() + self.drag_source_row = None + self.potential_drop_index = -1 + + def on_drop(self, drop_target, value, x, y): + # This handler is called on the *target* list. We only want to handle + # drags that originated from *this* list. + if not self.drag_source_row: + return False # Reject drop + + # We just signal that the drop is accepted if a valid position was + # found. + # The actual reordering is handled in `on_drag_end`. + return self.potential_drop_index != -1 + + def __iter__(self): + """ + Provides a Pythonic way to iterate over the rows of the ListBox, + which is platform-independent. + """ + child = self.get_first_child() + while child: + yield child + child = child.get_next_sibling() + + +if __name__ == "__main__": + + class DragListWindow(Gtk.ApplicationWindow): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_title("Reorderable List Example") + self.set_default_size(300, 400) + listview = DragListBox() + self.set_child(listview) + + # Add some rows + for i in range(5): + label = Gtk.Label(label=f"Item {i + 1}") + label.set_xalign(0) + row = Gtk.ListBoxRow() + row.set_child(label) + listview.add_row(row) + + def on_activate(app): + win = DragListWindow(application=app) + win.present() + + app = Gtk.Application(application_id="org.example.DragListBox") + app.connect("activate", on_activate) + app.run(None) diff --git a/rayforge/ui_gtk/shared/expander.py b/rayforge/ui_gtk/shared/expander.py new file mode 100644 index 000000000..e28402421 --- /dev/null +++ b/rayforge/ui_gtk/shared/expander.py @@ -0,0 +1,175 @@ +from gi.repository import Gtk, Pango + +from ..icons import get_icon +from .gtk import apply_css + +css = """ +.expander-card { + background-color: @headerbar_bg_color; + border-radius: 12px; + box-shadow: 0 4px 10px alpha(black, 0.06); + margin-bottom: 6px; +} + +.expander-header { + border-radius: 12px; +} + +.expander-header:hover { + background-color: shade(@headerbar_bg_color, 0.95); +} + +.expander-card.expanded .expander-header { + border-radius: 12px 12px 0 0; + border-bottom: 1px solid @borders; +} + +.expander-card.expanded .expander-header:hover { + border-radius: 12px 12px 0 0; +} + +.expander-title, .expander-subtitle { + font-weight: normal; +} + +.expander-subtitle { + color: alpha(@headerbar_fg_color, 0.7); +} + +.expander-arrow { + transition: transform 0.2s ease-in-out, color 0.2s ease-in-out; +} + +.expander-arrow.rotated { + transform: rotate(90deg); +} + +.expander-card>:nth-child(2) { + border-radius: 0; +} +""" + + +class Expander(Gtk.Box): + """ + A custom expander widget that looks and behaves like an Adwaita card. + This version uses a Gtk.Box header and direct CSS class manipulation on + the child icon to ensure reliable styling. + """ + + _css_loaded = False + + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + apply_css(css) + self.add_css_class("expander-card") + + # Header + self.header = Gtk.Box() + self.header.add_css_class("expander-header") + self.append(self.header) + + # Add event controllers for click and hover + click_controller = Gtk.GestureClick.new() + click_controller.connect("released", self._on_header_clicked) + self.header.add_controller(click_controller) + + header_content_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + margin_top=10, + margin_bottom=10, + margin_start=12, + margin_end=12, + ) + self.header.append(header_content_box) + + label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + label_box.set_hexpand(True) + header_content_box.append(label_box) + + self.title_label = Gtk.Label(xalign=0) + self.title_label.add_css_class("expander-title") + self.title_label.set_ellipsize(Pango.EllipsizeMode.END) + self.title_label.set_max_width_chars(40) + self.title_label.set_hexpand(True) + label_box.append(self.title_label) + + self.subtitle_label = Gtk.Label(xalign=0) + self.subtitle_label.add_css_class("expander-subtitle") + self.subtitle_label.set_ellipsize(Pango.EllipsizeMode.END) + self.subtitle_label.set_max_width_chars(40) + self.subtitle_label.set_hexpand(True) + label_box.append(self.subtitle_label) + + self.suffix_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=6 + ) + header_content_box.append(self.suffix_box) + + self.arrow = get_icon("chevron-right-symbolic") + self.arrow.add_css_class("expander-arrow") + self.arrow.set_valign(Gtk.Align.CENTER) + header_content_box.append(self.arrow) + + # Content revealer + self.revealer = Gtk.Revealer() + self.revealer.set_transition_type( + Gtk.RevealerTransitionType.SLIDE_DOWN + ) + self.revealer.connect("notify::reveal-child", self._update_state) + self.append(self.revealer) + + self._update_state() + + def set_title(self, title: str): + self.title_label.set_text(title) + + def set_subtitle(self, subtitle: str): + self.subtitle_label.set_text(subtitle) + + def add_suffix(self, widget: Gtk.Widget): + """Add a widget to the suffix area (between title and arrow).""" + self.suffix_box.append(widget) + + def set_expanded(self, expanded: bool): + self.revealer.set_reveal_child(expanded) + + def set_child(self, widget: Gtk.Widget): + self.revealer.set_child(widget) + + def _on_header_clicked(self, *args): + self.set_expanded(not self.revealer.get_reveal_child()) + + def _update_state(self, *args): + is_expanded = self.revealer.get_reveal_child() + if is_expanded: + # Add '.expanded' to the card for the header border + self.add_css_class("expanded") + # Add '.accent' and '.rotated' for color and rotation + self.arrow.add_css_class("accent") + self.arrow.add_css_class("rotated") + else: + self.remove_css_class("expanded") + self.arrow.remove_css_class("accent") + self.arrow.remove_css_class("rotated") + + +class ExpanderWithButton(Expander): + """ + An Expander that includes a "+" icon button in the header suffix area + and a content box for child widgets. + """ + + def __init__(self, button_label: str, **kwargs): + super().__init__(**kwargs) + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.set_child(self.content_box) + + self.add_button = Gtk.Button() + self.add_button.set_tooltip_text(button_label) + self.add_button.set_child(get_icon("add-symbolic")) + self.add_suffix(self.add_button) + + def append_content(self, widget: Gtk.Widget): + self.content_box.append(widget) diff --git a/rayforge/ui_gtk/shared/expression_entry.py b/rayforge/ui_gtk/shared/expression_entry.py new file mode 100644 index 000000000..8ef32b7f7 --- /dev/null +++ b/rayforge/ui_gtk/shared/expression_entry.py @@ -0,0 +1,469 @@ +import logging + +from blinker import Signal +from gi.repository import Gdk, GLib, Gtk, Pango + +from ...core.expression import ( + ExpressionContext, + ExpressionTokenizer, + ExpressionValidator, + Token, + TokenType, +) +from .gtk import apply_css + +logger = logging.getLogger(__name__) + +# Self-contained CSS for the widget +css = """ +/* Styles for ExpressionEntry Widget */ + +/* Add a border and background to the frame to mimic a Gtk.Entry */ +.expression-entry-frame { + background-color: @theme_bg_color; + border: 1px solid @borders; +} + +/* Add a red border when the expression is invalid */ +.expression-entry-frame.error { + border-color: @error_color; +} + +/* Set a transparent background for the TextView inside the frame */ +.expression-entry-frame > GtkTextView { + padding: 8px; + background-color: transparent; +} + +/* Label for displaying validation errors below the entry */ +.expression-error-label { + color: @error_color; + margin: 10px; +} + +.autocomplete-selector > contents { + padding: 1px; +} +""" + + +class AutoCompleteSelector(Gtk.Popover): + """ + A specialized Gtk.Popover for displaying autocompletion results. + Its contents are explicitly made non-focusable to prevent them from + stealing keyboard events from the text entry. + """ + + def __init__(self, **kwargs): + super().__init__( + **kwargs, + ) + self.add_css_class("autocomplete-selector") + self.set_can_focus(False) + + self.list_box = Gtk.ListBox() + self.list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.list_box.set_focusable(False) + + scroller = Gtk.ScrolledWindow( + child=self.list_box, + min_content_height=150, + hscrollbar_policy=Gtk.PolicyType.NEVER, + ) + scroller.set_size_request(200, -1) + + self.set_child(scroller) + self.set_autohide(False) + self.set_has_arrow(False) + self.set_position(Gtk.PositionType.BOTTOM) + + +class ExpressionEntry(Gtk.Box): + """ + A GTK widget for entering mathematical expressions with live validation, + syntax highlighting, and autocompletion. + + It uses a Gtk.Popover to display a completion list without grabbing + input or disrupting the application layout. + + Signals: + activated (blinker.Signal): Emitted when the user presses Enter. The + sender is the ExpressionEntry instance. + validated (blinker.Signal): Emitted after the text changes and is + validated. The sender is the instance, + and a keyword argument `is_valid` (bool) + is provided. + """ + + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) + apply_css(css) + + # Signals + self.activated = Signal() + self.validated = Signal() + + self._context: ExpressionContext | None = None + self._validator = ExpressionValidator() + self._tokenizer = ExpressionTokenizer() + + # Highlighting Tags + self.tag_table = Gtk.TextTagTable() + self._tags = { + "variable": self._create_tag( + self.tag_table, "variable", foreground="#809bbd" + ), + "function": self._create_tag( + self.tag_table, "function", foreground="#f5c211" + ), + "number": self._create_tag( + self.tag_table, "number", foreground="#3d84c7da" + ), + "string": self._create_tag( + self.tag_table, "string", foreground="#A39464" + ), + "operator": self._create_tag( + self.tag_table, "operator", weight=Pango.Weight.BOLD + ), + "paren": self._create_tag(self.tag_table, "paren"), + "error": self._create_tag( + self.tag_table, "error", underline=Pango.Underline.ERROR + ), + } + + # Build the main UI components + self._buffer = Gtk.TextBuffer(tag_table=self.tag_table) + self.textview = Gtk.TextView( + buffer=self._buffer, + wrap_mode=Gtk.WrapMode.NONE, + accepts_tab=False, + monospace=True, + left_margin=12, + right_margin=12, + top_margin=10, + bottom_margin=10, + ) + self.textview.set_size_request(-1, 40) + self.textview.set_vexpand(True) + + self.frame = Gtk.Frame(child=self.textview) + self.frame.add_css_class("expression-entry-frame") + self.append(self.frame) + + # Use our dedicated AutoCompleteMenu + self._completion_menu = AutoCompleteSelector() + # The popover's position is relative to its parent. Setting the parent + # to the textview allows us to position it relative to the text. + self._completion_menu.set_parent(self.textview) + + # Build and add the error label + self._error_label = Gtk.Label( + wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR, xalign=0 + ) + self._error_label.add_css_class("expression-error-label") + self._error_label.set_visible(False) + self.append(self._error_label) + + # Connect Controllers and Signals + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self.textview.add_controller(key_controller) + + self._buffer_changed_handler_id = self._buffer.connect( + "changed", self._on_buffer_changed + ) + self._completion_menu.list_box.connect( + "row-activated", self._on_completion_activated + ) + + def _create_tag(self, table: Gtk.TextTagTable, name: str, **properties): + """Creates a Gtk.TextTag and applies properties directly.""" + tag = Gtk.TextTag(name=name) + for key, value in properties.items(): + tag.set_property(key, value) + table.add(tag) + return tag + + def set_context(self, context: ExpressionContext): + """ + Sets the expression context, which defines the available variables + and functions for validation and autocompletion. + """ + self._context = context + self._populate_completion_model() + self._validate_and_highlight() + + def get_text(self) -> str: + """Returns the text content of the entry.""" + start, end = self._buffer.get_bounds() + return self._buffer.get_text(start, end, True) + + def set_text(self, text: str): + """Sets the text content of the entry.""" + self._buffer.set_text(text, len(text)) + + def _on_buffer_changed(self, buffer: Gtk.TextBuffer): + self._validate_and_highlight() + self._update_completion_popup() + + def _validate_and_highlight(self): + """ + Runs the full validation and syntax highlighting pipeline. + This is the core update logic of the widget. + """ + if not self._context: + return + + text = self.get_text() + # Validation + result = self._validator.validate(text, self._context) + + if result.is_valid: + self.frame.remove_css_class("error") + self._error_label.set_text("") + self._error_label.set_visible(False) + else: + self.frame.add_css_class("error") + if result.error_info: + message = result.error_info.get_message() + self._error_label.set_text(message) + self._error_label.set_visible(True) + else: + # Fallback for invalid state with no specific message + self._error_label.set_text("") + self._error_label.set_visible(False) + + self.validated.send(self, is_valid=result.is_valid) + + # Highlighting + self._buffer.remove_all_tags(*self._buffer.get_bounds()) + tokens = self._tokenizer.tokenize(text) + for token in tokens: + self._apply_highlighting_for_token(token) + + def _apply_highlighting_for_token(self, token: Token): + """Applies the correct Gtk.TextTag for a given Token.""" + if not self._context: + return + start = self._buffer.get_iter_at_offset(token.start) + end = self._buffer.get_iter_at_offset(token.end) + + tag_name: str | None = None + if token.type == TokenType.NUMBER: + tag_name = "number" + elif token.type == TokenType.STRING: + tag_name = "string" + elif token.type == TokenType.OPERATOR: + tag_name = "operator" + elif token.type == TokenType.PARENTHESIS: + tag_name = "paren" + elif token.type == TokenType.NAME: + if self._context.is_variable(token.value): + tag_name = "variable" + elif self._context.is_function(token.value): + tag_name = "function" + else: + tag_name = "error" + + if tag_name: + self._buffer.apply_tag_by_name(tag_name, start, end) + + def _on_key_pressed(self, controller, keyval, keycode, state): + is_completion_visible = self._completion_menu.is_visible() + + if is_completion_visible: + if keyval == Gdk.KEY_Up: + self._navigate_completion(-1) + return True + if keyval == Gdk.KEY_Down: + # Select first item if nothing is selected + if not self._completion_menu.list_box.get_selected_row(): + self._select_first_visible_completion() + else: + self._navigate_completion(1) + return True + if keyval in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab): + completion_row = ( + self._completion_menu.list_box.get_selected_row() + ) + # If nothing is selected, find the first visible one + if not completion_row: + completion_row = self._find_first_visible_row() + + if completion_row: + self._on_completion_activated( + self._completion_menu.list_box, completion_row + ) + return True + if keyval == Gdk.KEY_Escape: + self._completion_menu.popdown() + return True + + if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + selected_row = self._completion_menu.list_box.get_selected_row() + if is_completion_visible and selected_row: + self._on_completion_activated( + self._completion_menu.list_box, selected_row + ) + else: + self.activated.send(self) + return True + + return False + + def _do_apply_completion(self, completion_text: str) -> bool: + """Safely modifies the buffer. Runs deferred via GLib.idle_add.""" + self._buffer.handler_block(self._buffer_changed_handler_id) + self._buffer.begin_user_action() + + cursor_iter = self._buffer.get_iter_at_mark(self._buffer.get_insert()) + word_start_iter = cursor_iter.copy() + if word_start_iter.backward_word_start(): + self._buffer.delete(word_start_iter, cursor_iter) + self._buffer.insert_at_cursor(completion_text) + + self._buffer.end_user_action() + self._buffer.handler_unblock(self._buffer_changed_handler_id) + + self._completion_menu.popdown() + self._validate_and_highlight() + + return GLib.SOURCE_REMOVE + + # Autocompletion Logic + def _populate_completion_model(self): + """Fills the completion listbox with items from the context.""" + list_box = self._completion_menu.list_box + # Clear existing children efficiently + while child := list_box.get_first_child(): + list_box.remove(child) + + if not self._context: + return + + symbols = sorted( + list(self._context.variables.keys()) + + list(self._context.functions.keys()) + ) + for symbol in symbols: + label = Gtk.Label(label=symbol, xalign=0) + list_box.append(label) + # Make the row containing the label non-focusable + row = label.get_parent() + if isinstance(row, Gtk.ListBoxRow): + row.set_focusable(False) + + def _update_completion_popup(self): + """Shows or hides the completion popover based on the current text.""" + cursor_iter = self._buffer.get_iter_at_mark(self._buffer.get_insert()) + word_start_iter = cursor_iter.copy() + + # Find the start of the current word + if not word_start_iter.backward_word_start(): + if self._completion_menu.is_visible(): + self._completion_menu.popdown() + return + + partial_word = self._buffer.get_text( + word_start_iter, cursor_iter, True + ) + + # Filter the list and find the first visible match + has_matches = False + list_box = self._completion_menu.list_box + child = list_box.get_first_child() + while child: + if isinstance(child, Gtk.ListBoxRow): + label = child.get_child() + if isinstance(label, Gtk.Label): + is_match = label.get_label().startswith(partial_word) + child.set_visible(is_match) + if is_match: + has_matches = True + child = child.get_next_sibling() + + if has_matches and partial_word: + # Position the popover under the word being typed. + # Get the Gdk.Rectangle for the character at the start of the word. + # Coordinates are relative to the buffer's contents. + start_location = self.textview.get_iter_location(word_start_iter) + + # Convert buffer coordinates to coordinates relative to the + # TextView widget. + start_x, start_y = self.textview.buffer_to_window_coords( + Gtk.TextWindowType.WIDGET, start_location.x, start_location.y + ) + + # Create the rectangle for the popover to point to. + # Gtk.Popover centers its arrow on the provided rectangle. + # To left-align the popover with the start of the word, we + # give it a rectangle that is only 1 pixel wide at the word's + # starting x-coordinate. + pointing_rect = Gdk.Rectangle() + pointing_rect.x = start_x + pointing_rect.y = start_y + pointing_rect.width = 1 # This is the key change + pointing_rect.height = start_location.height + + # Tell the popover where to point, relative to its parent + # (the TextView). + self._completion_menu.set_pointing_to(pointing_rect) + + if not self._completion_menu.is_visible(): + self._completion_menu.popup() + self._select_first_visible_completion() + else: + if self._completion_menu.is_visible(): + self._completion_menu.popdown() + + def _find_first_visible_row(self) -> Gtk.ListBoxRow | None: + """Finds the first visible row without selecting it.""" + child = self._completion_menu.list_box.get_first_child() + while child: + if isinstance(child, Gtk.ListBoxRow) and child.is_visible(): + return child + child = child.get_next_sibling() + return None + + def _select_first_visible_completion(self): + """Selects the first visible row, safe for arrow keys.""" + first_row = self._find_first_visible_row() + if first_row: + self._completion_menu.list_box.select_row(first_row) + + def _navigate_completion(self, step: int): + """Moves the selection in the completion list up or down by 'step'.""" + list_box = self._completion_menu.list_box + selected = list_box.get_selected_row() + if not selected: + return + + next_row: Gtk.Widget | None = None + if step > 0: + sibling = selected.get_next_sibling() + while sibling: + if sibling.is_visible(): + next_row = sibling + break + sibling = sibling.get_next_sibling() + else: + sibling = selected.get_prev_sibling() + while sibling: + if sibling.is_visible(): + next_row = sibling + break + sibling = sibling.get_prev_sibling() + + if isinstance(next_row, Gtk.ListBoxRow): + list_box.select_row(next_row) + + def _on_completion_activated(self, listbox, row: Gtk.ListBoxRow | None): + """Inserts the selected completion into the text buffer.""" + if not row: + return + label = row.get_child() + if not isinstance(label, Gtk.Label): + return + + completion_text = label.get_label() + self._do_apply_completion(completion_text) diff --git a/rayforge/ui_gtk/shared/gtk.py b/rayforge/ui_gtk/shared/gtk.py new file mode 100644 index 000000000..a433b7891 --- /dev/null +++ b/rayforge/ui_gtk/shared/gtk.py @@ -0,0 +1,93 @@ +import logging +from gettext import gettext as _ +from typing import cast + +from gi.repository import Gdk, Gtk + +from ...image.registry import FileFilter +from ...shared.util.once import once_per_object + +logger = logging.getLogger(__name__) + + +def get_monitor_geometry() -> Gdk.Rectangle | None: + """ + Returns a rectangle for the current monitor dimensions. If not found, + may return None. + """ + display = Gdk.Display.get_default() + if not display: + return None + + monitors = display.get_monitors() + if not monitors: + return None + monitor = cast(Gdk.Monitor, monitors[0]) + + # Try to get the monitor under the cursor (heuristic for active + # monitor). Note: Wayland has no concept of "primary monitor" + # anymore, so Gdk.get_primary_monitor() is obsolete. + # Fallback to the first monitor if no monitor is found under the cursor + seat = display.get_default_seat() + if not seat: + return monitor.get_geometry() + + pointer = seat.get_pointer() + if not pointer: + return monitor.get_geometry() + + surface, _x, _y = pointer.get_surface_at_position() + if not surface: + return monitor.get_geometry() + + monitor_under_mouse = display.get_monitor_at_surface(surface) + if not monitor_under_mouse: + return monitor.get_geometry() + + return monitor_under_mouse.get_geometry() + + +def get_screen_size() -> tuple[int, int] | None: + """Get the current monitor's screen size as (width, height).""" + geometry = get_monitor_geometry() + if not geometry: + return None + return (geometry.width, geometry.height) + + +@once_per_object +def apply_css(css: str): + provider = Gtk.CssProvider() + provider.load_from_string(css) + display = Gdk.Display.get_default() + if not display: + logger.warning("No default Gdk display found. CSS may not apply.") + return + Gtk.StyleContext.add_provider_for_display( + display, + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + +def file_filter_to_gtk( + filt: FileFilter, translate: bool = True +) -> Gtk.FileFilter: + """ + Convert a FileFilter dataclass to a Gtk.FileFilter. + + Args: + filt: The FileFilter to convert. + translate: Whether to translate the label using gettext. + + Returns: + A configured Gtk.FileFilter instance. + """ + gtk_filter = Gtk.FileFilter() + label = _(filt.label) if translate else filt.label + gtk_filter.set_name(label) + for ext in filt.extensions: + gtk_filter.add_pattern(f"*{ext}") + for mime_type in filt.mime_types: + gtk_filter.add_mime_type(mime_type) + return gtk_filter diff --git a/rayforge/ui_gtk/shared/gtk_color.py b/rayforge/ui_gtk/shared/gtk_color.py new file mode 100644 index 000000000..5e0995726 --- /dev/null +++ b/rayforge/ui_gtk/shared/gtk_color.py @@ -0,0 +1,144 @@ +import logging +from typing import Any, TypeGuard, cast + +import numpy as np +from gi.repository import Gdk, Gtk + +from ...core.color import ( + ColorAtom, + ColorRGBA, + ColorSet, + ColorSpec, + ColorSpecDict, + GradientSpec, +) + +logger = logging.getLogger(__name__) + + +def _is_gradient_spec(val: Any) -> TypeGuard[GradientSpec]: + """ + Checks if a value conforms to the GradientSpec type. + + A ColorSpec-with-alpha is `(ColorAtom, float)`, while a gradient is + `(ColorSpec, ColorSpec)`. A ColorSpec itself is never a bare float/int, + so checking the type of the second element is a reliable way to distinguish + the two forms of 2-element tuples. + """ + return ( + isinstance(val, tuple) + and len(val) == 2 + and not isinstance(val[1], (int, float)) + ) + + +def _is_spec_with_alpha(val: Any) -> TypeGuard[tuple[ColorAtom, float]]: + """ + Checks if a value conforms to the (ColorAtom, float) variant of ColorSpec. + """ + return ( + isinstance(val, tuple) + and len(val) == 2 + and isinstance(val[1], (float, int)) + ) + + +class GtkColorResolver: + """ + A GTK-specific resolver that converts a generic ColorSpecDict into a + render-ready, UI-agnostic ColorSet using a Gtk.StyleContext. + + Note: Uses deprecated get_style_context().lookup_color() because + PyGObject doesn't expose gtk_widget_lookup_color() (GTK 4.10+). + """ + + def __init__(self, widget: Gtk.Widget): + self._context = widget.get_style_context() + self._color_cache: dict[ColorAtom, Gdk.RGBA] = {} + + def resolve(self, spec_dict: ColorSpecDict) -> ColorSet: + """ + Performs the conversion from a specification dict to a resolved + color set. + """ + resolved_data = {} + for name, spec in spec_dict.items(): + if _is_gradient_spec(spec): + # The TypeGuard guarantees `spec` is GradientSpec here. + resolved_data[name] = self._create_lut_from_gradient(spec) + else: + # The TypeGuard guarantees `spec` is not a GradientSpec, + # thus it must be a ColorSpec. + resolved_data[name] = self._resolve_color_spec_to_rgba( + cast(ColorSpec, spec) + ) + + return ColorSet(_data=resolved_data) + + def _create_lut_from_gradient(self, gradient: GradientSpec) -> np.ndarray: + """Generates a 256x4 NumPy array (LUT) from a gradient spec.""" + start_spec, end_spec = gradient + start_rgba = self._resolve_color_spec_to_rgba(start_spec) + end_rgba = self._resolve_color_spec_to_rgba(end_spec) + + s = np.array(start_rgba, dtype=np.float32) + e = np.array(end_rgba, dtype=np.float32) + + t = np.linspace(0.0, 1.0, 256, dtype=np.float32)[:, np.newaxis] + lut = s * (1 - t) + e * t + return lut + + def _resolve_color_spec_to_rgba(self, spec: ColorSpec) -> ColorRGBA: + """ + Resolves a single ColorSpec into a concrete (r, g, b, a) tuple. + """ + alpha_override: float | None = None + atom: ColorAtom + if _is_spec_with_alpha(spec): # Tuple[ColorAtom, float] + atom, alpha_override = spec + else: # ColorAtom + atom = cast(ColorAtom, spec) + + if atom in self._color_cache: + rgba = self._color_cache[atom] + elif isinstance(atom, str): + if atom.startswith("@"): + color_name = atom[1:] + found, color = self._context.lookup_color(color_name) + if not found: + logger.warning(f"Theme color '{color_name}' not found.") + color = Gdk.RGBA() + color.red = 1.0 + color.green = 0.0 + color.blue = 1.0 + color.alpha = 1.0 + else: + color = Gdk.RGBA() + if not color.parse(atom): + logger.warning(f"Could not parse color string: '{atom}'") + color = Gdk.RGBA() + color.red = 1.0 + color.green = 0.0 + color.blue = 1.0 + color.alpha = 1.0 + self._color_cache[atom] = color + rgba = color + elif isinstance(atom, tuple) and len(atom) in [3, 4]: + # Gdk.RGBA expects floats from 0.0-1.0. If integers (0-255) are + # provided, normalize them. + r, g, b = atom[0], atom[1], atom[2] + if isinstance(r, int): + r, g, b = r / 255.0, g / 255.0, b / 255.0 + a = atom[3] / 255.0 if len(atom) == 4 else 1.0 + else: + a = atom[3] if len(atom) == 4 else 1.0 + return (r, g, b, a) + else: + raise ValueError(f"Invalid ColorAtom: {atom}") + + return ( + rgba.red, + rgba.green, + rgba.blue, + alpha_override if alpha_override is not None else rgba.alpha, + ) diff --git a/rayforge/ui_gtk/shared/histogram_preview.py b/rayforge/ui_gtk/shared/histogram_preview.py new file mode 100644 index 000000000..5f5f33076 --- /dev/null +++ b/rayforge/ui_gtk/shared/histogram_preview.py @@ -0,0 +1,233 @@ +import cairo +import numpy as np +from blinker import Signal +from gi.repository import Gtk + + +class HistogramPreview(Gtk.DrawingArea): + WIDTH = 200 + HEIGHT = 100 + MARGIN = 5 + + def __init__(self): + super().__init__() + self.histogram: np.ndarray | None = None + self._black_point: int = 0 + self._white_point: int = 255 + self._auto_black_point: int = 0 + self._auto_white_point: int = 255 + self._auto_mode: bool = True + self._dragging: str | None = None + self._hovering: str | None = None + + self.black_point_changed = Signal() + self.white_point_changed = Signal() + self.auto_mode_changed = Signal() + + self.set_content_width(self.WIDTH) + self.set_content_height(self.HEIGHT) + self.set_draw_func(self._draw_func) + + click = Gtk.GestureClick.new() + click.connect("pressed", self._on_pressed) + click.connect("released", self._on_released) + self.add_controller(click) + + motion = Gtk.EventControllerMotion.new() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_leave) + self.add_controller(motion) + + @property + def black_point(self) -> int: + return self._black_point + + @black_point.setter + def black_point(self, value: int): + if self._black_point != value: + self._black_point = max(0, min(254, value)) + self.queue_draw() + + @property + def white_point(self) -> int: + return self._white_point + + @white_point.setter + def white_point(self, value: int): + if self._white_point != value: + self._white_point = max(1, min(255, value)) + self.queue_draw() + + @property + def auto_mode(self) -> bool: + return self._auto_mode + + @auto_mode.setter + def auto_mode(self, value: bool): + if self._auto_mode != value: + self._auto_mode = value + self.queue_draw() + + def set_auto_points(self, black_point: int, white_point: int): + self._auto_black_point = max(0, min(253, black_point)) + self._auto_white_point = max( + self._auto_black_point + 2, min(255, white_point) + ) + if self._auto_mode: + self.queue_draw() + + def update_histogram(self, histogram: np.ndarray | None): + self.histogram = histogram + self.queue_draw() + + def set_points(self, black_point: int, white_point: int): + self._black_point = max(0, min(254, black_point)) + self._white_point = max(1, min(255, white_point)) + self.queue_draw() + + def _value_to_x(self, value: int, width: int) -> float: + draw_width = width - 2 * self.MARGIN + return self.MARGIN + (value / 255.0) * draw_width + + def _x_to_value(self, x: float, width: int) -> int: + draw_width = width - 2 * self.MARGIN + ratio = (x - self.MARGIN) / draw_width + return round(ratio * 255) + + def _get_handle_at( + self, x: float, y: float, width: int, height: int + ) -> str | None: + if self._auto_mode: + black_x = self._value_to_x(self._auto_black_point, width) + white_x = self._value_to_x(self._auto_white_point, width) + else: + black_x = self._value_to_x(self._black_point, width) + white_x = self._value_to_x(self._white_point, width) + + threshold = 10 + + if abs(x - black_x) < threshold: + return "black" + elif abs(x - white_x) < threshold: + return "white" + return None + + def _on_pressed(self, gesture, n_press, x, y): + if self._auto_mode: + return + width = self.get_width() + handle = self._get_handle_at(x, y, width, self.get_height()) + if handle: + self._dragging = handle + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + def _on_released(self, gesture, n_press, x, y): + self._dragging = None + + def _on_motion(self, controller, x, y): + if self._auto_mode: + return + + width = self.get_width() + height = self.get_height() + + if self._dragging: + value = self._x_to_value(x, width) + value = max(0, min(255, value)) + + if self._dragging == "black": + new_black = min(value, self._white_point - 1) + if new_black != self._black_point: + self._black_point = new_black + self.queue_draw() + self.black_point_changed.send( + self, black_point=self._black_point + ) + else: + new_white = max(value, self._black_point + 1) + if new_white != self._white_point: + self._white_point = new_white + self.queue_draw() + self.white_point_changed.send( + self, white_point=self._white_point + ) + else: + handle = self._get_handle_at(x, y, width, height) + if handle != self._hovering: + self._hovering = handle + self.queue_draw() + + def _on_leave(self, controller): + if self._hovering: + self._hovering = None + self.queue_draw() + + def _draw_func(self, area, ctx: cairo.Context, width: int, height: int): + ctx.set_source_rgba(0, 0, 0, 0) + ctx.set_operator(cairo.OPERATOR_SOURCE) + ctx.paint() + ctx.set_operator(cairo.OPERATOR_OVER) + + if self.histogram is None: + ctx.set_source_rgba(0.5, 0.5, 0.5, 1.0) + ctx.set_font_size(12) + ctx.move_to(width // 2 - 40, height // 2) + ctx.show_text("No image") + return + + draw_width = width - 2 * self.MARGIN + draw_height = height - 2 * self.MARGIN + max_count = np.max(self.histogram) if np.max(self.histogram) > 0 else 1 + + bar_width = draw_width / len(self.histogram) + + color = self.get_color() + ctx.set_source_rgba(color.red, color.green, color.blue, color.alpha) + for i, count in enumerate(self.histogram): + x = self.MARGIN + i * bar_width + bar_height = (count / max_count) * draw_height + ctx.rectangle( + x, height - self.MARGIN - bar_height, bar_width, bar_height + ) + ctx.fill() + + if self._auto_mode: + black_x = self._value_to_x(self._auto_black_point, width) + white_x = self._value_to_x(self._auto_white_point, width) + else: + black_x = self._value_to_x(self._black_point, width) + white_x = self._value_to_x(self._white_point, width) + + if self._auto_mode: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.5) + ctx.set_dash([4, 4]) + else: + ctx.set_source_rgba(0.2, 0.6, 1.0, 0.7) + ctx.set_dash([]) + if self._hovering == "black" or self._dragging == "black": + ctx.set_line_width(3) + else: + ctx.set_line_width(2) + ctx.move_to(black_x, self.MARGIN) + ctx.line_to(black_x, height - self.MARGIN) + ctx.stroke() + + if self._auto_mode: + ctx.set_source_rgba(1.0, 0.4, 0.2, 0.5) + ctx.set_dash([4, 4]) + else: + ctx.set_source_rgba(1.0, 0.4, 0.2, 0.7) + ctx.set_dash([]) + if self._hovering == "white" or self._dragging == "white": + ctx.set_line_width(3) + else: + ctx.set_line_width(2) + ctx.move_to(white_x, self.MARGIN) + ctx.line_to(white_x, height - self.MARGIN) + ctx.stroke() + + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.3) + ctx.rectangle( + black_x, self.MARGIN, white_x - black_x, height - 2 * self.MARGIN + ) + ctx.fill() diff --git a/rayforge/ui_gtk/shared/icon_tab_widget.py b/rayforge/ui_gtk/shared/icon_tab_widget.py new file mode 100644 index 000000000..a53ad36a9 --- /dev/null +++ b/rayforge/ui_gtk/shared/icon_tab_widget.py @@ -0,0 +1,252 @@ +from blinker import Signal +from gi.repository import Gdk, Gtk + +from ..icons import get_icon +from .gtk import apply_css + +css = """ +box.icon-tab-strip button { + min-width: 36px; + min-height: 36px; + padding: 4px; + margin: 2px; + border-radius: 6px; + border: none; + background: transparent; +} + +box.icon-tab-strip button:hover { + background: alpha(@theme_fg_color, 0.1); +} + +box.icon-tab-strip button.active-tab { + background: alpha(@theme_selected_bg_color, 0.2); + color: @theme_selected_bg_color; +} + +box.icon-tab-strip button.drag-highlight-top { + border-top: 3px solid @theme_selected_bg_color; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +box.icon-tab-strip button.drag-highlight-bottom { + border-bottom: 3px solid @theme_selected_bg_color; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +""" + + +class IconTabWidget(Gtk.Box): + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, **kwargs) + apply_css(css) + + self.tab_changed = Signal() + self.tab_order_changed = Signal() + self._buttons = {} + self._btn_to_name = {} + self._active_name = None + self._drag_source_name = None + + self._icon_strip = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._icon_strip.add_css_class("icon-tab-strip") + self._icon_strip.set_spacing(2) + + self._stack = Gtk.Stack() + self._stack.set_hexpand(True) + self._stack.set_vexpand(True) + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._stack.set_transition_duration(150) + + self.append(self._icon_strip) + self.append(self._stack) + + self._dnd_controller = Gtk.DropTarget.new(str, Gdk.DragAction.MOVE) + self._dnd_controller.connect("motion", self._on_dnd_motion) + self._dnd_controller.connect("leave", self._on_dnd_leave) + self._dnd_controller.connect("drop", self._on_dnd_drop) + self._icon_strip.add_controller(self._dnd_controller) + + self._last_highlight_btn = None + self._last_highlight_side = None + + def add_tab(self, name, icon_name, widget, tooltip=None, position=-1): + btn = Gtk.Button(child=get_icon(icon_name)) + btn.set_tooltip_text(tooltip or name) + btn.add_css_class("flat") + btn.connect("clicked", self._on_button_clicked, name) + + drag_source = Gtk.DragSource() + drag_source.set_actions(Gdk.DragAction.MOVE) + drag_source.connect("prepare", self._on_drag_prepare, name) + drag_source.connect("drag-begin", self._on_drag_begin) + drag_source.connect("drag-end", self._on_drag_end) + btn.add_controller(drag_source) + + if position == 0: + self._icon_strip.prepend(btn) + else: + self._icon_strip.append(btn) + self._stack.add_named(widget, name) + self._buttons[name] = btn + self._btn_to_name[btn] = name + + if self._active_name is None: + self._activate_tab(name) + + def get_tab_order(self): + order = [] + child = self._icon_strip.get_first_child() + while child is not None: + if child in self._btn_to_name: + order.append(self._btn_to_name[child]) + child = child.get_next_sibling() + return order + + def set_tab_order(self, order): + prev = None + for name in order: + btn = self._buttons.get(name) + if btn is None: + continue + self._icon_strip.reorder_child_after(btn, prev) + prev = btn + + def set_current_tab(self, name): + if name in self._buttons and name != self._active_name: + self._activate_tab(name) + + def get_current_tab(self): + return self._active_name + + def tab_count(self): + return len(self._buttons) + + def remove_tab(self, name): + if name not in self._buttons: + return + btn = self._buttons.pop(name) + del self._btn_to_name[btn] + self._icon_strip.remove(btn) + widget = self._stack.get_child_by_name(name) + if widget: + self._stack.remove(widget) + if self._active_name == name: + self._active_name = None + remaining = self.get_tab_order() + if remaining: + self._activate_tab(remaining[0]) + + def has_tab(self, name): + return name in self._buttons + + def _iter_children(self): + child = self._icon_strip.get_first_child() + while child is not None: + yield child + child = child.get_next_sibling() + + def _on_button_clicked(self, button, name): + self._activate_tab(name) + + def _activate_tab(self, name): + if self._active_name is not None: + old_btn = self._buttons.get(self._active_name) + if old_btn is not None: + old_btn.remove_css_class("active-tab") + + self._active_name = name + self._stack.set_visible_child_name(name) + + new_btn = self._buttons[name] + new_btn.add_css_class("active-tab") + + self.tab_changed.send(self, name=name) + + def _on_drag_prepare(self, source, x, y, name): + self._drag_source_name = name + return Gdk.ContentProvider.new_for_value(name) + + def _on_drag_begin(self, source, drag): + btn = self._buttons.get(self._drag_source_name) + if btn: + icon = btn.get_child() + if icon: + paintable = Gtk.WidgetPaintable.new(icon) + source.set_icon(paintable, 0, 0) + + def _on_drag_end(self, source, drag, delete_data): + self._drag_source_name = None + self._clear_highlight() + + def _on_dnd_motion(self, target, x, y): + if self._drag_source_name is None: + self._clear_highlight() + return 0 + + tab_name = self._get_tab_name_at_y(y) + if tab_name is None or tab_name == self._drag_source_name: + self._clear_highlight() + return Gdk.DragAction.MOVE + + btn = self._buttons[tab_name] + btn_center = btn.get_allocation().height / 2 + side = "top" if y < btn_center else "bottom" + self._set_highlight(btn, side) + return Gdk.DragAction.MOVE + + def _on_dnd_leave(self, target): + self._clear_highlight() + + def _on_dnd_drop(self, target, value, x, y): + self._clear_highlight() + if not isinstance(value, str): + return False + source_name = value + if source_name not in self._buttons: + return False + + target_name = self._get_tab_name_at_y(y) + if target_name is None or target_name == source_name: + return False + + source_btn = self._buttons[source_name] + target_btn = self._buttons[target_name] + btn_center = target_btn.get_allocation().height / 2 + insert_after = y >= btn_center + + if insert_after: + self._icon_strip.reorder_child_after(source_btn, target_btn) + else: + prev_sib = target_btn.get_prev_sibling() + self._icon_strip.reorder_child_after(source_btn, prev_sib) + + self.tab_order_changed.send(self) + return True + + def _get_tab_name_at_y(self, y): + child = self._icon_strip.get_first_child() + while child is not None: + if child in self._btn_to_name: + alloc = child.get_allocation() + if alloc.y <= y <= alloc.y + alloc.height: + return self._btn_to_name[child] + child = child.get_next_sibling() + return None + + def _set_highlight(self, btn, side): + self._clear_highlight() + btn.add_css_class(f"drag-highlight-{side}") + self._last_highlight_btn = btn + self._last_highlight_side = side + + def _clear_highlight(self): + if self._last_highlight_btn is not None: + if self._last_highlight_side: + self._last_highlight_btn.remove_css_class( + f"drag-highlight-{self._last_highlight_side}" + ) + self._last_highlight_btn = None + self._last_highlight_side = None diff --git a/rayforge/ui_gtk/shared/key.py b/rayforge/ui_gtk/shared/key.py new file mode 100644 index 000000000..0a724c507 --- /dev/null +++ b/rayforge/ui_gtk/shared/key.py @@ -0,0 +1,23 @@ +from gi.repository import Gtk + +from ..shared.gtk import apply_css + +css = """ +.key { + padding: 5px 8px; + border-radius: 6px; + background-color: @theme_base_color; + color: @theme_fg_color; + border: 1px solid @borders; + font-size: 12px; + font-weight: 500; +} +""" + + +class Key(Gtk.Label): + def __init__(self, label: str, **kwargs): + super().__init__(label=label, **kwargs) + apply_css(css) + self.add_css_class("key") + self.set_valign(Gtk.Align.CENTER) diff --git a/rayforge/ui_gtk/shared/keyboard.py b/rayforge/ui_gtk/shared/keyboard.py new file mode 100644 index 000000000..b8324b8b7 --- /dev/null +++ b/rayforge/ui_gtk/shared/keyboard.py @@ -0,0 +1,37 @@ +import sys + +from gi.repository import Gdk + +if sys.platform == "darwin": + PRIMARY_MODIFIER_MASK = Gdk.ModifierType(0) + for mask_name in ("META_MASK", "SUPER_MASK", "MOD2_MASK"): + mask = getattr(Gdk.ModifierType, mask_name, None) + if mask is not None: + PRIMARY_MODIFIER_MASK |= mask + if PRIMARY_MODIFIER_MASK == 0: + PRIMARY_MODIFIER_MASK = Gdk.ModifierType.CONTROL_MASK + PRIMARY_ACCEL = "" + PRIMARY_KEY_NAME = "Cmd" +else: + PRIMARY_MODIFIER_MASK = Gdk.ModifierType.CONTROL_MASK + PRIMARY_ACCEL = "" + PRIMARY_KEY_NAME = "Ctrl" + + +def is_primary_modifier(state: Gdk.ModifierType) -> bool: + return bool(state & PRIMARY_MODIFIER_MASK) + + +def is_primary_keyval(keyval: int) -> bool: + if sys.platform == "darwin": + command_key = getattr(Gdk, "KEY_Command", None) + primary_keys = [ + Gdk.KEY_Meta_L, + Gdk.KEY_Meta_R, + Gdk.KEY_Super_L, + Gdk.KEY_Super_R, + ] + if command_key is not None: + primary_keys.append(command_key) + return keyval in primary_keys + return keyval in (Gdk.KEY_Control_L, Gdk.KEY_Control_R) diff --git a/rayforge/ui_gtk/shared/model_selection_dialog.py b/rayforge/ui_gtk/shared/model_selection_dialog.py new file mode 100644 index 000000000..36a1d2e16 --- /dev/null +++ b/rayforge/ui_gtk/shared/model_selection_dialog.py @@ -0,0 +1,125 @@ +"""Reusable model selection dialog for picking 3D models.""" + +import logging +from gettext import gettext as _ +from pathlib import Path + +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...core.model import Model +from ..icons import get_icon +from ..sim3d import initialized as canvas3d_initialized + +logger = logging.getLogger(__name__) + + +class ModelSelectionDialog(Adw.MessageDialog): + """A picker dialog that lists models from ModelManager libraries. + + Shows a 3D preview via ModelPreviewWidget when a model is selected. + Returns the selected model_path string (or None on cancel). + """ + + def __init__( + self, + current_model_path: str | None = None, + **kwargs, + ): + super().__init__(**kwargs) + self._current_model_path = current_model_path + self._selected_model_path: str | None = None + self._row_paths: dict[Adw.ActionRow, str] = {} + self._setup_ui() + + def _setup_ui(self): + self.set_heading(_("Select Model")) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + self._preview_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=6 + ) + self._preview_box.set_size_request(512, 288) + + model_mgr = get_context().model_mgr + models: list[Model] = model_mgr.get_all_models() + + scrolled = Gtk.ScrolledWindow( + min_content_height=200, + max_content_height=300, + ) + self._list_box = Gtk.ListBox( + selection_mode=Gtk.SelectionMode.SINGLE, + show_separators=True, + css_classes=["boxed-list"], + ) + self._list_box.connect("row-selected", self._on_row_selected) + + none_row = Adw.ActionRow(title=_("None"), activatable=True) + none_row.add_prefix(get_icon("edit-clear-symbolic")) + self._list_box.append(none_row) + + for model in models: + row = Adw.ActionRow(title=model.name, activatable=True) + self._row_paths[row] = str(model.path) + suffix = model.path.suffix.upper().lstrip(".") + row.set_subtitle(suffix) + row.add_prefix(get_icon("image-x-generic-symbolic")) + self._list_box.append(row) + + if self._current_model_path: + for row, path in self._row_paths.items(): + if path == self._current_model_path: + self._list_box.select_row(row) + break + else: + self._list_box.select_row(none_row) + + scrolled.set_child(self._list_box) + content_box.append(scrolled) + content_box.append(self._preview_box) + + self.set_extra_child(content_box) + + self.add_response("cancel", _("Cancel")) + self.add_response("select", _("Select")) + self.set_default_response("select") + self.set_response_appearance( + "select", Adw.ResponseAppearance.SUGGESTED + ) + + def _on_row_selected(self, listbox, row): + while child := self._preview_box.get_first_child(): + self._preview_box.remove(child) + + if row is None: + self._selected_model_path = None + return + + model_path = self._row_paths.get(row) + if model_path is None: + self._selected_model_path = None + return + + self._selected_model_path = model_path + + if not canvas3d_initialized: + return + + model_mgr = get_context().model_mgr + model = Model.from_path(Path(model_path)) + resolved = model_mgr.resolve(model) + if resolved is None: + return + + from ..settings.model_preview_widget import ModelPreviewWidget + + preview = ModelPreviewWidget() + preview.load_model(resolved) + preview.set_vexpand(True) + preview.set_hexpand(True) + self._preview_box.append(preview) + + def get_selected_model_path(self) -> str | None: + return self._selected_model_path diff --git a/rayforge/ui_gtk/shared/number_badge.py b/rayforge/ui_gtk/shared/number_badge.py new file mode 100644 index 000000000..74f766004 --- /dev/null +++ b/rayforge/ui_gtk/shared/number_badge.py @@ -0,0 +1,101 @@ +from gi.repository import Graphene, Gtk, Pango, PangoCairo + +from .gtk import apply_css + +css = """ +.number-badge { + border-radius: 6px; + border: 1px solid transparent; + background-color: @accent_bg_color; + color: @accent_fg_color; +} +.number-badge.dimmed { + background-color: alpha(@window_fg_color, 0.15); + color: @window_fg_color; +} +""" + + +def _contrast_color(hex_color: str) -> str: + h = hex_color.lstrip("#") + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return "#000000" if luminance > 0.5 else "#ffffff" + + +class NumberBadge(Gtk.Widget): + def __init__(self, number: int = 0, **kwargs): + super().__init__(**kwargs) + apply_css(css) + self.add_css_class("number-badge") + self._number: int = number + self._color_class: str | None = None + + def do_measure(self, orientation, for_size): + return (32, 32, -1, -1) + + def do_snapshot(self, snapshot): + w = self.get_width() + h = self.get_height() + if w == 0 or h == 0: + return + + layout = self.create_pango_layout(str(self._number)) + font_desc = layout.get_font_description() + if font_desc is None: + font_desc = Pango.FontDescription() + + font_size = min(w, h) * 0.55 * Pango.SCALE + font_desc.set_absolute_size(font_size) + layout.set_font_description(font_desc) + + lw, lh = layout.get_pixel_size() + if lw > w - 4: + font_desc.set_absolute_size(font_size * (w - 4) / lw) + layout.set_font_description(font_desc) + lw, lh = layout.get_pixel_size() + + rect = Graphene.Rect().init(0, 0, w, h) + ctx = snapshot.append_cairo(rect) + + color = self.get_color() + ctx.set_source_rgba(color.red, color.green, color.blue, color.alpha) + ctx.move_to((w - lw) / 2, (h - lh) / 2) + PangoCairo.show_layout(ctx, layout) + + def set_number(self, number: int): + self._number = number + self.queue_draw() + + def get_number(self) -> int: + return self._number + + def set_color(self, hex_color: str | None): + if self._color_class: + self.remove_css_class(self._color_class) + self._color_class = None + + if hex_color is None: + self.add_css_class("number-badge") + else: + self.remove_css_class("number-badge") + fg = _contrast_color(hex_color) + class_name = f"badge-{hex_color.lstrip('#').lower()}" + color_css = ( + f".{class_name} {{" + f" border-radius: 6px;" + f" border: 1px solid @borders;" + f" background-color: {hex_color};" + f" color: {fg};" + "}" + ) + apply_css(color_css) + self._color_class = class_name + self.add_css_class(class_name) + self.queue_draw() + + def set_dimmed(self, dimmed: bool): + if dimmed: + self.add_css_class("dimmed") + else: + self.remove_css_class("dimmed") diff --git a/rayforge/ui_gtk/shared/optional_spin_row.py b/rayforge/ui_gtk/shared/optional_spin_row.py new file mode 100644 index 000000000..506cf906a --- /dev/null +++ b/rayforge/ui_gtk/shared/optional_spin_row.py @@ -0,0 +1,118 @@ +from blinker import Signal +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...shared.units.definitions import get_unit + + +class OptionalSpinRowController: + """Manages an ActionRow with a SpinButton and a Switch.""" + + def __init__( + self, + group: Adw.PreferencesGroup, + title: str, + subtitle: str, + quantity: str, + ): + self.changed = Signal() + self.quantity = quantity + + config = get_context().config + unit_name = config.unit_preferences.get(self.quantity) + self.unit = get_unit(unit_name) if unit_name else None + if not self.unit: + raise ValueError( + f"Could not determine unit for quantity '{quantity}'" + ) + + self.row = Adw.ActionRow(title=title, subtitle=subtitle) + group.add(self.row) + + adj = Gtk.Adjustment(lower=0, upper=9999, step_increment=0.1) + self.spin_button = Gtk.SpinButton( + adjustment=adj, digits=self.unit.precision + ) + self.spin_button.set_valign(Gtk.Align.CENTER) + + self.switch = Gtk.Switch(valign=Gtk.Align.CENTER) + + self.row.add_suffix(self.switch) + self.row.add_suffix(self.spin_button) + + self.switch.connect("notify::active", self._on_toggled) + self._value_changed_handler_id = self.spin_button.connect( + "value-changed", lambda btn: self.changed.send(self) + ) + + self._config_handler_id = get_context().config.changed.connect( + self._on_config_changed + ) + self._destroy_handler_id = self.row.connect( + "destroy", self._on_destroy + ) + + self._on_toggled(self.switch, None) + + def _on_toggled(self, switch, _pspec): + is_active = switch.get_active() + self.spin_button.set_sensitive(is_active) + self.changed.send(self) + + def _on_config_changed(self, _sender, **_kwargs): + # Live-update when the display unit changes: preserve the semantic + # value while converting the shown value and digits. + if not self.unit: + return + base_value = self.unit.to_base(self.spin_button.get_value()) + unit_name = get_context().config.unit_preferences.get(self.quantity) + new_unit = get_unit(unit_name) if unit_name else None + if not new_unit: + return + self.unit = new_unit + self.spin_button.handler_block(self._value_changed_handler_id) + try: + self.spin_button.set_digits(new_unit.precision) + self.spin_button.set_value(new_unit.from_base(base_value)) + finally: + self.spin_button.handler_unblock(self._value_changed_handler_id) + + def _on_destroy(self, _widget): + if self._config_handler_id: + get_context().config.changed.disconnect(self._config_handler_id) + self._config_handler_id = None + self._destroy_handler_id = None + + def get_value(self) -> float | None: + """Gets the value in base units, or None if disabled.""" + if not self.switch.get_active(): + return None + return self.get_spin_value_in_base() + + def set_value(self, value_in_base: float | None): + """Sets the value from base units, or disables if None.""" + if value_in_base is None: + self.switch.set_active(False) + self.set_spin_value_in_base(0) + else: + self.switch.set_active(True) + self.set_spin_value_in_base(value_in_base) + + def get_spin_value_in_base(self) -> float: + """Gets the spinbutton's value in base units, ignoring the switch.""" + if not self.unit: + return 0.0 + display_value = self.spin_button.get_value() + return self.unit.to_base(display_value) + + def set_spin_value_in_base(self, value_in_base: float): + """ + Sets the spinbutton's value from base units, without touching the + switch. + """ + if not self.unit: + return + self.spin_button.handler_block(self._value_changed_handler_id) + display_value = self.unit.from_base(value_in_base) + self.spin_button.set_value(display_value) + self.spin_button.handler_unblock(self._value_changed_handler_id) diff --git a/rayforge/ui_gtk/shared/patched_dialog_window.py b/rayforge/ui_gtk/shared/patched_dialog_window.py new file mode 100644 index 000000000..4a662e83e --- /dev/null +++ b/rayforge/ui_gtk/shared/patched_dialog_window.py @@ -0,0 +1,79 @@ +import re + +from gi.repository import Adw, Gdk, Gtk + +from ...usage import get_usage_tracker +from .keyboard import is_primary_modifier + + +def _camel_to_kebab(name: str) -> str: + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() + + +""" +PatchedDialogWindow: +A replacement for Adw.Window that fixes wrong window +being focused when a dialog is closed on windows. +See: +https://bugzilla.gnome.org/show_bug.cgi?id=112404 +& https://gitlab.gnome.org/GNOME/gtk/-/issues/7313 +""" + + +class PatchedDialogWindow(Adw.Window): + def __init__(self, skip_usage_tracking: bool = False, **kwargs): + super().__init__(**kwargs) + self._tracked = False + self._skip_usage_tracking = skip_usage_tracking + self.connect("map", self._on_map) + + key_controller = Gtk.EventControllerKey() + key_controller.connect("key-pressed", self._on_key_pressed) + self.add_controller(key_controller) + + def _on_map(self, widget): + if not self._tracked: + self._tracked = True + if not self._skip_usage_tracking: + self._track_view() + + def _on_key_pressed(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_Escape or ( + is_primary_modifier(state) and keyval == Gdk.KEY_w + ): + self.close() + return True + return False + + def _track_view(self): + title = self.get_title() or self.__class__.__name__ + url = f"/{_camel_to_kebab(self.__class__.__name__)}" + get_usage_tracker().track_page_view(url=url, title=title) + + def do_close_request(self, *args) -> bool: + parent = self.get_transient_for() + # Focus the original parent + if parent: + parent.present() + # Let GTK close the window + return False + + +class PatchedMessageDialog(Adw.MessageDialog): + def __init__(self, skip_usage_tracking: bool = False, **kwargs): + super().__init__(**kwargs) + self._tracked = False + self._skip_usage_tracking = skip_usage_tracking + self.connect("map", self._on_map) + + def _on_map(self, widget): + if not self._tracked: + self._tracked = True + if not self._skip_usage_tracking: + self._track_view() + + def _track_view(self): + title = self.get_heading() or self.__class__.__name__ + url = f"/{_camel_to_kebab(self.__class__.__name__)}" + get_usage_tracker().track_page_view(url=url, title=title) diff --git a/rayforge/ui_gtk/shared/piemenu.py b/rayforge/ui_gtk/shared/piemenu.py new file mode 100644 index 000000000..e50305ba0 --- /dev/null +++ b/rayforge/ui_gtk/shared/piemenu.py @@ -0,0 +1,314 @@ +import logging +import math +from typing import Any + +import cairo +from blinker import Signal +from gi.repository import Gdk, Gtk + +from ..icons import get_icon_pixbuf +from .gtk import apply_css + +logger = logging.getLogger(__name__) + +css = """ +.pie-menu > contents { + background-color: transparent; + box-shadow: none; + border: none; +} +""" + + +class PieMenuItem: + def __init__(self, icon_name: str, label: str, data: Any = None): + self.icon_name = icon_name + self.label = label + self.data = data + self.visible = True + # Signal emitted when item is activated. argument: sender (PieMenuItem) + self.on_click = Signal() + + +class PieMenu(Gtk.Popover): + """ + A radial menu implemented as a Gtk.Popover. + It is transparent (custom CSS) and centers itself over the cursor. + """ + + def __init__(self, parent_widget: Gtk.Widget): + super().__init__() + self.set_parent(parent_widget) + self.set_has_arrow(False) + + # Signal emitted when user right-clicks the menu, to request + # repositioning. + # arguments: sender(PieMenu), gesture, n_press, x, y + self.right_clicked = Signal() + + # Disable autohide to prevent Gtk from aggressively closing the popover + # on clicks it thinks are "outside". + # We will manually handle closing in _on_release and _on_key_press. + self.set_autohide(False) + + self.radius_outer = 75 + self.radius_inner = 30 + self.icon_size = 24 + + # Margin to allow text to be drawn outside the pie without clipping + self.text_margin = 120 + # Total radius including margins for calculating the widget size + self.total_radius = self.radius_outer + self.text_margin + + self.add_css_class("pie-menu") + apply_css(css) + + self.items: list[PieMenuItem] = [] + self._active_index: int = -1 + + self.drawing_area = Gtk.DrawingArea() + # Size needs to cover diameter + margins on both sides + size = int(self.total_radius * 2) + self.drawing_area.set_content_width(size) + self.drawing_area.set_content_height(size) + + # Make drawing area focusable to help with event state accounting + self.drawing_area.set_draw_func(self._draw_func) + self.drawing_area.set_focusable(True) + self.set_child(self.drawing_area) + + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_leave) + self.drawing_area.add_controller(motion) + + # Click: Execute action + click = Gtk.GestureClick() + click.connect("pressed", self._on_press) + click.connect("released", self._on_release) + self.drawing_area.add_controller(click) + + # Key: Escape to close + key = Gtk.EventControllerKey() + key.connect("key-pressed", self._on_key_press) + self.add_controller(key) + + # Handle right-clicks on the menu itself to allow repositioning. + right_click = Gtk.GestureClick() + right_click.set_button(3) + right_click.connect("pressed", self._on_right_press) + self.drawing_area.add_controller(right_click) + + def add_item(self, item: PieMenuItem): + self.items.append(item) + self.drawing_area.queue_draw() + + def set_items(self, items: list[PieMenuItem]): + self.items = items + self._active_index = -1 + self.drawing_area.queue_draw() + + def popup_at_location(self, widget_x: float, widget_y: float): + """ + Opens the menu centered at the specific widget coordinates. + """ + rect = Gdk.Rectangle() + rect.x = int(widget_x) + rect.y = int(widget_y) + rect.width = 0 + rect.height = 0 + + self.set_pointing_to(rect) + self.set_position(Gtk.PositionType.BOTTOM) + + # Offset must account for the larger drawing area size due to text + # margins. We shift up/left by the center coordinate to align the + # pie center with the target rect. + self.set_offset(0, -int(self.total_radius)) + + logger.debug(f"Popup at {widget_x}, {widget_y}") + self.popup() + self._active_index = -1 + self.drawing_area.grab_focus() + + def _get_index_at(self, x, y): + """Calculates which slice index is under the coordinates.""" + items = [i for i in self.items if i.visible] + dx = x - self.total_radius + dy = y - self.total_radius + dist = math.hypot(dx, dy) + + # Allow interaction only within the visible pie slices. + if dist < self.radius_inner or dist > self.radius_outer or not items: + return -1 + + angle = math.atan2(dy, dx) + if angle < 0: + angle += 2 * math.pi + + slice_angle = (2 * math.pi) / len(items) + return int(angle / slice_angle) % len(items) + + def _on_motion(self, controller, x, y): + new_index = self._get_index_at(x, y) + if new_index != self._active_index: + self._active_index = new_index + self.drawing_area.queue_draw() + + def _on_leave(self, controller): + self._active_index = -1 + self.drawing_area.queue_draw() + + def _on_press(self, gesture, n_press, x, y): + """ + Handle press. CRITICAL: We must CLAIM the event sequence here. + """ + logger.debug(f"Press at {x:.1f}, {y:.1f}") + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + def _on_release(self, gesture, n_press, x, y): + """Handle click release to trigger action.""" + logger.debug(f"Release at {x:.1f}, {y:.1f}") + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + triggered_index = self._get_index_at(x, y) + items = [i for i in self.items if i.visible] + + self.popdown() + + if triggered_index >= 0 and triggered_index < len(items): + item = items[triggered_index] + logger.debug(f"Activating '{item.label}' with data '{item.data}'") + item.on_click.send(item) + else: + logger.debug("Release on background/nothing") + + def _on_key_press(self, controller, keyval, keycode, state): + if keyval == Gdk.KEY_Escape: + self.popdown() + return True + return False + + def _on_right_press(self, gesture, n_press, x, y): + """Fires a signal to let the parent handle repositioning.""" + self.right_clicked.send( + self, gesture=gesture, n_press=n_press, x=x, y=y + ) + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + def _draw_func(self, drawing_area, ctx, width, height): + items = [i for i in self.items if i.visible] + if not items: + return + + # Fetch theme colors from the style context + style = drawing_area.get_style_context() + fg = style.get_color() + + # Base color components (0-1) + r, g, b = fg.red, fg.green, fg.blue + + # Create palette based on theme foreground + color_fg = (r, g, b, 1.0) + # Slices use the FG color but with low opacity + color_slice_normal = (r, g, b, 0.1) + color_slice_active = (r, g, b, 0.3) + color_border = (r, g, b, 0.2) + + # Use the actual center of the drawing area for robustness + cx, cy = width / 2, height / 2 + count = len(items) + step = (2 * math.pi) / count + + # 1. Draw Slices and Icons + for i, item in enumerate(items): + start_angle = i * step + end_angle = (i + 1) * step + mid_angle = start_angle + (step / 2) + + is_active = i == self._active_index + + # Slice Shape + ctx.new_path() + ctx.arc(cx, cy, self.radius_outer, start_angle, end_angle) + ctx.arc_negative(cx, cy, self.radius_inner, end_angle, start_angle) + ctx.close_path() + + if is_active: + ctx.set_source_rgba(*color_slice_active) + else: + ctx.set_source_rgba(*color_slice_normal) + + ctx.fill_preserve() + + # Border + ctx.set_source_rgba(*color_border) + ctx.set_line_width(1) + ctx.stroke() + + # Icon + icon_dist = (self.radius_inner + self.radius_outer) / 2 + ix = cx + math.cos(mid_angle) * icon_dist + iy = cy + math.sin(mid_angle) * icon_dist + + if item.icon_name: + icon_pixbuf = get_icon_pixbuf(item.icon_name, self.icon_size) + if icon_pixbuf: + icon_x = ix - (icon_pixbuf.get_width() / 2) + icon_y = iy - (icon_pixbuf.get_height() / 2) + + ctx.save() + # 1. Place the icon in the source + Gdk.cairo_set_source_pixbuf( + ctx, icon_pixbuf, icon_x, icon_y + ) + # 2. Paint it (creates the shape) + ctx.paint() + # 3. Use operator IN to keep only the intersection of the + # next paint with the previously drawn icon shape + ctx.set_operator(cairo.OPERATOR_IN) + # 4. Set source to theme foreground and paint + ctx.set_source_rgba(*color_fg) + ctx.paint() + ctx.restore() + + # 2. Draw Active Label (External) + if self._active_index >= 0 and self._active_index < len(items): + active_item = items[self._active_index] + + # Calculate angle again for the active item + start_angle = self._active_index * step + mid_angle = start_angle + (step / 2) + + # Determine position outside the ring + label_dist = self.radius_outer + 15 + lx = cx + math.cos(mid_angle) * label_dist + ly = cy + math.sin(mid_angle) * label_dist + + ctx.save() + ctx.set_source_rgba(*color_fg) + ctx.select_font_face( + "Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD + ) + ctx.set_font_size(13) + extents = ctx.text_extents(active_item.label) + + # Determine Alignment based on angle (cos) + cos_a = math.cos(mid_angle) + + text_x = 0.0 + text_y = ly - (extents.height / 2) - extents.y_bearing + + if cos_a > 0.3: + # Right side: Text starts at lx + text_x = lx + elif cos_a < -0.3: + # Left side: Text ends at lx + text_x = lx - extents.width - extents.x_bearing + else: + # Top/Bottom: Text centered on lx + text_x = lx - (extents.width / 2) - extents.x_bearing + + ctx.move_to(text_x, text_y) + ctx.show_text(active_item.label) + ctx.restore() diff --git a/rayforge/ui_gtk/shared/popover_menu.py b/rayforge/ui_gtk/shared/popover_menu.py new file mode 100644 index 000000000..7c352c87c --- /dev/null +++ b/rayforge/ui_gtk/shared/popover_menu.py @@ -0,0 +1,73 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Optional + +from gi.repository import Gdk, Gtk + +if TYPE_CHECKING: + from ...context import RayforgeContext + + +css = """ +.popover-menu-label { + font-family: 'Roboto', sans-serif; + font-size: 14px; + margin: 12px; +} +""" + + +class PopoverMenu(Gtk.Popover): + def __init__( + self, + *, + step_factories: list[Callable] | None = None, + items: list[tuple[str, Any]] | None = None, + context: Optional["RayforgeContext"] = None, + **kwargs, + ): + super().__init__(**kwargs) + self.set_autohide(True) + self.selected_item: Any | None = None + + # Create a ListBox inside the Popover + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.set_child(self.listbox) + + provider = Gtk.CssProvider() + provider.load_from_string(css) + display = Gdk.Display.get_default() + if display: + Gtk.StyleContext.add_provider_for_display( + display, + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + if step_factories and context: + # Add step_factories to the ListBox + for factory_func in step_factories: + # Create a temporary, parentless step to get its default label. + # This is a bit of a hack but keeps the UI decoupled. + temp_step = factory_func(context) + self._add_row(temp_step.typelabel, factory_func) + elif items: + for label_text, item_value in items: + self._add_row(label_text, item_value) + + # Connect the row-activated signal to handle item selection + self.listbox.connect("row-activated", self.on_row_activated) + + def _add_row(self, label_text: str, item_value: Any): + """Helper to create and add a row to the listbox.""" + label = Gtk.Label(label=label_text) + label.set_xalign(0) + label.add_css_class("popover-menu-label") + row = Gtk.ListBoxRow() + row.set_child(label) + row.item_value = item_value # type: ignore + self.listbox.append(row) + + def on_row_activated(self, listbox, row): + self.selected_item = row.item_value + self.popdown() diff --git a/rayforge/ui_gtk/shared/pref_rows/__init__.py b/rayforge/ui_gtk/shared/pref_rows/__init__.py new file mode 100644 index 000000000..31267df0b --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/__init__.py @@ -0,0 +1,21 @@ +""" +Preference-row widgets: subclassable spin rows and unit-aware variants. +""" + +from .acceleration_spin_row import AccelerationSpinRow +from .angle_spin_row import AngleSpinRow +from .base import SpinRow +from .length_choice_spin_row import LengthChoiceSpinRow +from .length_spin_row import LengthSpinRow +from .speed_spin_row import SpeedSpinRow +from .unit_spin_row import UnitSpinRow + +__all__ = [ + "AccelerationSpinRow", + "AngleSpinRow", + "LengthChoiceSpinRow", + "LengthSpinRow", + "SpeedSpinRow", + "SpinRow", + "UnitSpinRow", +] diff --git a/rayforge/ui_gtk/shared/pref_rows/acceleration_spin_row.py b/rayforge/ui_gtk/shared/pref_rows/acceleration_spin_row.py new file mode 100644 index 000000000..fa68a2f5f --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/acceleration_spin_row.py @@ -0,0 +1,23 @@ +from .unit_spin_row import UnitSpinRow + + +class AccelerationSpinRow(UnitSpinRow): + """Unit-aware spin row for ``acceleration`` (base mm/s^2).""" + + __gtype_name__ = "RayforgeAccelerationSpinRow" + + def __init__( + self, + title: str, + subtitle: str | None = None, + *, + step_increment: float = 10.0, + **kwargs, + ): + super().__init__( + title, + subtitle, + quantity="acceleration", + step_increment=step_increment, + **kwargs, + ) diff --git a/rayforge/ui_gtk/shared/pref_rows/angle_spin_row.py b/rayforge/ui_gtk/shared/pref_rows/angle_spin_row.py new file mode 100644 index 000000000..15ef88838 --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/angle_spin_row.py @@ -0,0 +1,33 @@ +from .base import SpinRow + + +class AngleSpinRow(SpinRow): + """ + A spin row for angle values in degrees. + + Builds on :class:`SpinRow` with degree-appropriate defaults: a full + rotation range of -360..360 degrees, whole-degree stepping and one + decimal place. Pass ``lower``/``upper``/``digits`` to override + (e.g. a 0..180 half-turn or an integer-degree field). + """ + + __gtype_name__ = "RayforgeAngleSpinRow" + + def __init__( + self, + title: str, + subtitle: str | None = None, + *, + lower: float = -360.0, + upper: float = 360.0, + digits: int = 1, + **kwargs, + ): + super().__init__( + title, + subtitle, + lower=lower, + upper=upper, + digits=digits, + **kwargs, + ) diff --git a/rayforge/ui_gtk/shared/pref_rows/base.py b/rayforge/ui_gtk/shared/pref_rows/base.py new file mode 100644 index 000000000..c78360dfc --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/base.py @@ -0,0 +1,207 @@ +import logging + +from blinker import ANY, Signal +from gi.repository import Adw, GLib, Gtk + +from ..adwfix import ensure_spinrow_min_width + +logger = logging.getLogger(__name__) + + +class _StrongSignal(Signal): + """ + A blinker Signal that holds receivers strongly by default. + + Widget consumers often connect inline lambdas (e.g. + ``row.value_changed.connect(lambda r: ...)``). Under blinker's + default weak referencing such lambdas have no other strong + reference and are collected immediately, silently never firing. + Holding strongly matches the GTK ``connect()`` semantics callers + are used to; the widget and its owning page are co-owned and reach + the cyclic collector together. + """ + + def connect(self, receiver, sender: object = ANY, weak: bool = False): + return super().connect(receiver, sender=sender, weak=weak) + + +# Uniform character width for every spin entry. ``Gtk.SpinButton`` would +# otherwise size itself from its *initial* value (and would need per-field +# range knowledge for dynamic bounds), producing inconsistent widths across +# rows. A single fixed width keeps every entry visually identical and is +# wide enough for the values used across the app (e.g. ``-10000.00``). +_SPINROW_WIDTH_CHARS = 10 + + +class SpinRow(Adw.ActionRow): + """ + A subclassable spin row: :class:`Adw.ActionRow` + :class:`Gtk.SpinButton`. + + ``Adw.SpinRow`` is declared final by libadwaita and cannot be + subclassed, so this widget composes an ActionRow with an embedded + SpinButton to provide a real widget class that other rows + (e.g. :class:`UnitSpinRow`) can build on. + + Consumer signal wiring uses the blinker signal :attr:`value_changed` + only; it fires on user edits but not on programmatic + :meth:`set_value`. Pass ``debounce_ms > 0`` to coalesce rapid edits. + """ + + __gtype_name__ = "RayforgeSpinRow" + + def __init__( + self, + title: str, + subtitle: str | None = None, + *, + lower: float = 0.0, + upper: float = 1e9, + step_increment: float = 1.0, + page_increment: float | None = None, + digits: int = 0, + numeric: bool = False, + value: float | None = None, + debounce_ms: int = 0, + ): + super().__init__(title=title, activatable=False) + if subtitle: + self.set_subtitle(subtitle) + + self._is_updating = False + self._debounce_ms = debounce_ms + self._debounce_timer_id: int | None = None + self._last_emitted_value: float | None = None + + adj = Gtk.Adjustment( + lower=lower, + upper=upper, + step_increment=step_increment, + page_increment=( + step_increment * 10 + if page_increment is None + else page_increment + ), + value=(lower if value is None else value), + ) + self._spin_button = Gtk.SpinButton(adjustment=adj, digits=digits) + self._spin_button.set_valign(Gtk.Align.CENTER) + # Use one uniform entry width so every row is visually consistent. + self._spin_button.set_width_chars(_SPINROW_WIDTH_CHARS) + if numeric: + self._spin_button.set_numeric(True) + self._spin_button.connect("value-changed", self._on_value_changed) + # Mirror the historical Adw.SpinRow wiring: value-changed alone does + # not fire on every keystroke, so also observe notify::text to keep + # live consumers (e.g. array previews) responsive while typing. + self._spin_button.connect("notify::text", self._on_text_changed) + + self.add_suffix(self._spin_button) + + self.value_changed = _StrongSignal() + self._destroy_handler_id = self.connect("destroy", self._on_destroy) + + ensure_spinrow_min_width(self) + + def get_value(self) -> float: + """Return the current value (display units), text-aware.""" + return self._get_display_value() + + def get_int_value(self) -> int: + """Return the current value as an int, clamped to the range.""" + return round(self._get_display_value()) + + def set_value(self, value: float) -> None: + """ + Set the value programmatically. + + This does not emit :attr:`value_changed`; only user edits do. + """ + if self._is_updating: + return + self._is_updating = True + try: + self._spin_button.set_value(value) + finally: + self._is_updating = False + + def set_range(self, lower: float, upper: float) -> None: + """Update the adjustment lower and upper bounds.""" + adj = self._spin_button.get_adjustment() + adj.set_lower(lower) + adj.set_upper(upper) + + def set_digits(self, digits: int) -> None: + self._spin_button.set_digits(digits) + + def get_digits(self) -> int: + return self._spin_button.get_digits() + + def set_numeric(self, numeric: bool) -> None: + self._spin_button.set_numeric(numeric) + + def get_adjustment(self) -> Gtk.Adjustment: + return self._spin_button.get_adjustment() + + def get_spin_button(self) -> Gtk.SpinButton: + """Escape hatch for callers that need the raw SpinButton.""" + return self._spin_button + + def set_editable(self, editable: bool) -> None: + self._spin_button.set_editable(editable) + + def get_editable(self) -> bool: + return self._spin_button.get_editable() + + def set_width_chars(self, n: int) -> None: + self._spin_button.set_width_chars(n) + + def _get_display_value(self) -> float: + # A keyboard edit may not be reflected in get_value() immediately + # (the historical Adw.SpinRow bug), so prefer the editable text and + # clamp to the adjustment range. + adj = self._spin_button.get_adjustment() + try: + v = float(self._spin_button.get_text()) + except ValueError: + v = float(self._spin_button.get_value()) + return max(adj.get_lower(), min(v, adj.get_upper())) + + def _on_value_changed(self, _spin_button: Gtk.SpinButton) -> None: + if self._is_updating: + return + self._emit_changed() + + def _on_text_changed(self, _spin_button, _pspec) -> None: + if self._is_updating: + return + self._emit_changed() + + def _emit_changed(self) -> None: + # value-changed and notify::text both fire for a single edit; dedupe + # by value so consumers see exactly one notification per real change. + current = self._get_display_value() + if ( + self._last_emitted_value is not None + and abs(current - self._last_emitted_value) < 1e-12 + ): + return + self._last_emitted_value = current + if self._debounce_ms > 0: + if self._debounce_timer_id is not None: + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = GLib.timeout_add( + self._debounce_ms, self._flush_changed + ) + else: + self.value_changed.send(self) + + def _flush_changed(self) -> bool: + self._debounce_timer_id = None + self.value_changed.send(self) + return GLib.SOURCE_REMOVE + + def _on_destroy(self, _widget) -> None: + if self._debounce_timer_id is not None: + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = None + self._destroy_handler_id = None diff --git a/rayforge/ui_gtk/shared/pref_rows/length_choice_spin_row.py b/rayforge/ui_gtk/shared/pref_rows/length_choice_spin_row.py new file mode 100644 index 000000000..82732c355 --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/length_choice_spin_row.py @@ -0,0 +1,91 @@ +"""A length spin row with an inline unit-chooser dropdown.""" + +from gi.repository import Gtk + +from ....shared.units.definitions import get_units_for_quantity +from .length_spin_row import LengthSpinRow + + +class LengthChoiceSpinRow(LengthSpinRow): + """ + A length spin row with a unit chooser dropdown. + + Like :class:`LengthSpinRow`, values are exchanged with the caller in + base units (mm), but instead of following the global display-unit + preference the user picks the unit per row via a dropdown right of + the spin button. It defaults to the configured preferred unit for + ``length`` (e.g. ``mm``); switching the dropdown does not change the + global preference and survives later preference changes. + """ + + __gtype_name__ = "RayforgeLengthChoiceSpinRow" + + def __init__( + self, + title: str, + subtitle: str | None = None, + **kwargs, + ): + self._unit_override: str | None = None + self._units = get_units_for_quantity("length") + self._dropdown_populated = False + self._unit_dropdown = Gtk.DropDown() + super().__init__(title, subtitle, **kwargs) + + self._populate_dropdown() + self._sync_dropdown_to_unit() + self._unit_dropdown.connect("notify::selected", self._on_unit_selected) + self.add_suffix(self._unit_dropdown) + + def _resolve_unit_name(self) -> str | None: + """Prefer the per-row choice over the global preference.""" + if self._unit_override is not None: + return self._unit_override + return super()._resolve_unit_name() + + def update_unit_and_bounds(self) -> None: + super().update_unit_and_bounds() + self._sync_dropdown_to_unit() + + def _populate_dropdown(self) -> None: + string_list = Gtk.StringList() + for unit in self._units: + string_list.append(unit.label) + self._unit_dropdown.set_model(string_list) + self._unit_dropdown.set_valign(Gtk.Align.CENTER) + self._dropdown_populated = True + + def _unit_index(self, unit_name: str) -> int: + for i, unit in enumerate(self._units): + if unit.name == unit_name: + return i + return 0 + + def _sync_dropdown_to_unit(self) -> None: + if self._unit is None or not self._dropdown_populated: + return + self._is_updating = True + try: + self._unit_dropdown.set_selected(self._unit_index(self._unit.name)) + finally: + self._is_updating = False + + def _on_unit_selected(self, _dropdown, _pspec) -> None: + if self._is_updating: + return + index = self._unit_dropdown.get_selected() + if index < 0 or index >= len(self._units): + return + unit = self._units[index] + if self._unit is not None and unit.name == self._unit.name: + return + # Preserve the semantic value while switching the display unit. + base_value = self.get_value_in_base_units() + self._unit_override = unit.name + self._is_updating = True + try: + self.update_unit_and_bounds() + if self._unit: + self._spin_button.set_value(self._unit.from_base(base_value)) + finally: + self._is_updating = False diff --git a/rayforge/ui_gtk/shared/pref_rows/length_spin_row.py b/rayforge/ui_gtk/shared/pref_rows/length_spin_row.py new file mode 100644 index 000000000..1e7d17f32 --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/length_spin_row.py @@ -0,0 +1,10 @@ +from .unit_spin_row import UnitSpinRow + + +class LengthSpinRow(UnitSpinRow): + """Unit-aware spin row for the ``length`` quantity (base unit mm).""" + + __gtype_name__ = "RayforgeLengthSpinRow" + + def __init__(self, title: str, subtitle: str | None = None, **kwargs): + super().__init__(title, subtitle, quantity="length", **kwargs) diff --git a/rayforge/ui_gtk/shared/pref_rows/speed_spin_row.py b/rayforge/ui_gtk/shared/pref_rows/speed_spin_row.py new file mode 100644 index 000000000..d16b8d6be --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/speed_spin_row.py @@ -0,0 +1,23 @@ +from .unit_spin_row import UnitSpinRow + + +class SpeedSpinRow(UnitSpinRow): + """Unit-aware spin row for the ``speed`` quantity (base mm/min).""" + + __gtype_name__ = "RayforgeSpeedSpinRow" + + def __init__( + self, + title: str, + subtitle: str | None = None, + *, + step_increment: float = 10.0, + **kwargs, + ): + super().__init__( + title, + subtitle, + quantity="speed", + step_increment=step_increment, + **kwargs, + ) diff --git a/rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py b/rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py new file mode 100644 index 000000000..eb6e3e6a4 --- /dev/null +++ b/rayforge/ui_gtk/shared/pref_rows/unit_spin_row.py @@ -0,0 +1,166 @@ +import logging +from gettext import gettext as _ + +from ....context import get_context +from ....shared.units.definitions import Unit, get_unit +from .base import SpinRow + +logger = logging.getLogger(__name__) + + +class UnitSpinRow(SpinRow): + """ + A unit-aware spin row. + + Builds on :class:`SpinRow`, showing the current unit (e.g. ``"mm"``) + as a tooltip on the entry box, live conversion on display-unit + changes, and base-unit get/set. The unit is shown via the tooltip + rather than repeated in every subtitle or as a suffix. + + Values are exchanged with the caller in application base units through + :meth:`get_value_in_base_units` / :meth:`set_value_in_base_units`. + Bounds passed as ``lower``/``upper`` (or :meth:`set_range`) are also + expressed in base units and converted to the display unit whenever it + changes. + """ + + __gtype_name__ = "RayforgeUnitSpinRow" + + def __init__( + self, + title: str, + subtitle: str | None = None, + *, + quantity: str = "length", + lower: float = 0.0, + upper: float = 1e9, + step_increment: float = 1.0, + page_increment: float | None = None, + digits: int = 2, + numeric: bool = False, + value_in_base: float | None = None, + debounce_ms: int = 0, + ): + super().__init__( + title, + subtitle, + lower=lower, + upper=upper, + step_increment=step_increment, + page_increment=page_increment, + digits=digits, + numeric=numeric, + debounce_ms=debounce_ms, + ) + + self.quantity = quantity + self._unit: Unit | None = None + self._min_digits = digits + self._lower = lower + self._upper = upper + + self._config_handler_id = get_context().config.changed.connect( + self._on_config_changed + ) + + # Guard the initial unit/value setup so it does not fire + # ``value_changed``. + self._is_updating = True + try: + self.update_unit_and_bounds() + if value_in_base is not None and self._unit: + self._spin_button.set_value( + self._unit.from_base(value_in_base) + ) + finally: + self._is_updating = False + + def _resolve_unit_name(self) -> str | None: + """Return the name of the unit to display for this row. + + Defaults to the configured preference for :attr:`quantity`; + subclasses (e.g. a row with an inline unit chooser) override this + to return a per-row choice instead. + """ + return get_context().config.unit_preferences.get(self.quantity) + + def update_unit_and_bounds(self) -> None: + """ + Re-read the active unit and refresh the unit tooltip, adjustment + bounds, and digits. + + The bounds are converted from base units to the active display + unit. Does not touch the current value and does not manage the + ``_is_updating`` guard; callers wrap as needed. + """ + unit_name = self._resolve_unit_name() + self._unit = get_unit(unit_name) if unit_name else None + if not self._unit: + logger.warning( + "UnitSpinRow: no unit found for quantity %r", self.quantity + ) + return + + self._spin_button.set_tooltip_text( + _("Value in {unit}").format(unit=self._unit.label) + ) + + adj = self._spin_button.get_adjustment() + adj.set_lower(self._unit.from_base(self._lower)) + adj.set_upper(self._unit.from_base(self._upper)) + self._spin_button.set_digits( + max(self._unit.precision, self._min_digits) + ) + + def get_value_in_base_units(self) -> float: + """Return the current value converted to application base units.""" + if not self._unit: + return self._get_display_value() + return float(self._unit.to_base(self._get_display_value())) + + def set_value_in_base_units(self, base_value: float) -> None: + """Set the value from an application base-unit value.""" + if self._is_updating: + return + self._is_updating = True + try: + self.update_unit_and_bounds() + if not self._unit: + logger.warning("UnitSpinRow: skipping set, no unit") + return + self._spin_button.set_value(self._unit.from_base(base_value)) + finally: + self._is_updating = False + + def set_range(self, lower: float, upper: float) -> None: + """Set the adjustment bounds (in base units) and re-render.""" + self._lower = lower + self._upper = upper + self.update_unit_and_bounds() + + def set_min_digits(self, min_digits: int) -> None: + """Override the minimum number of decimal digits shown.""" + self._min_digits = min_digits + self.update_unit_and_bounds() + + def _on_config_changed(self, _sender, **_kwargs) -> None: + # Preserve the semantic value across a display-unit switch. + if not self._unit: + self.update_unit_and_bounds() + return + base_value = self._unit.to_base(self._get_display_value()) + self._is_updating = True + try: + self.update_unit_and_bounds() + if self._unit: + display_value = self._unit.from_base(base_value) + if abs(display_value - self._get_display_value()) >= 1e-12: + self._spin_button.set_value(display_value) + finally: + self._is_updating = False + + def _on_destroy(self, _widget) -> None: + super()._on_destroy(_widget) + if self._config_handler_id: + get_context().config.changed.disconnect(self._config_handler_id) + self._config_handler_id = None diff --git a/rayforge/ui_gtk/shared/preferences_group.py b/rayforge/ui_gtk/shared/preferences_group.py new file mode 100644 index 000000000..f3479d081 --- /dev/null +++ b/rayforge/ui_gtk/shared/preferences_group.py @@ -0,0 +1,129 @@ +from collections.abc import Iterable +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, Gtk + +from ..icons import get_icon +from .gtk import apply_css + +css = """ +/* 1. Round the top corners of the ListBox to match its .card parent. */ +.group-with-button-container > .list-box-in-card { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + +/* 2. Style the button to connect seamlessly to the ListBox above it. */ +.group-with-button-container > .flat-bottom-button, +.group-with-button-container > .flat-bottom-button > .toggle { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + box-shadow: none; +} + +/* 3. Round the top corners of a selected row if it's the first child. */ +.list-box-in-card row:first-child:selected { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} +""" + + +class PreferencesGroupWithButton(Adw.PreferencesGroup): + """ + A reusable, abstract Adw.PreferencesGroup that manages a dynamic list of + items displayed in a Gtk.ListBox, with an "Add" button at the bottom. + + Subclasses must implement the `create_row_widget` and `_on_add_clicked` + methods. They can optionally override `_create_add_button` for custom + button types like a MenuButton. + """ + + def __init__( + self, + button_label: str, + selection_mode: Gtk.SelectionMode = Gtk.SelectionMode.NONE, + empty_placeholder: str = _("No parameters"), + **kwargs, + ): + super().__init__(**kwargs) + apply_css(css) + self.add_css_class("pref-group-with-button") + self._empty_placeholder = empty_placeholder + + container_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + container_box.add_css_class("card") + container_box.add_css_class("group-with-button-container") + self.add(container_box) + + self.list_box = Gtk.ListBox( + selection_mode=selection_mode, show_separators=True + ) + self.list_box.add_css_class("list-box-in-card") + self.list_box.add_css_class("frame") + container_box.append(self.list_box) + + self.add_button = self._create_add_button(button_label) + self.add_button.add_css_class("darkbutton") + self.add_button.add_css_class("flat-bottom-button") + container_box.append(self.add_button) + + def set_items(self, items: Iterable): + is_selectable = ( + self.list_box.get_selection_mode() != Gtk.SelectionMode.NONE + ) + + while child := self.list_box.get_row_at_index(0): + self.list_box.remove(child) + + item_list = list(items) + + if not item_list: + placeholder_label = Gtk.Label(label=self._empty_placeholder) + placeholder_label.add_css_class("dim-label") + placeholder_label.set_halign(Gtk.Align.CENTER) + placeholder_label.set_margin_top(12) + placeholder_label.set_margin_bottom(12) + row = Gtk.ListBoxRow(child=placeholder_label, selectable=False) + self.list_box.append(row) + else: + for item in item_list: + widget = self.create_row_widget(item) + row = Gtk.ListBoxRow(child=widget, selectable=is_selectable) + self.list_box.append(row) + + def create_row_widget(self, item: Any) -> Gtk.Widget: + raise NotImplementedError( + "Subclasses must implement create_row_widget()" + ) + + def _create_add_button(self, button_label: str) -> Gtk.Widget: + """ + Default factory for the button. Creates a simple Gtk.Button. + Subclasses can override this to return a Gtk.MenuButton or other + widget. + """ + button = Gtk.Button() + button.connect("clicked", self._on_add_clicked) + + button_content = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + halign=Gtk.Align.CENTER, + margin_top=10, + margin_end=12, + margin_bottom=10, + margin_start=12, + ) + button.set_child(button_content) + button_content.append(get_icon("add-symbolic")) + button_content.append(Gtk.Label(label=button_label)) + return button + + def _on_add_clicked(self, button: Gtk.Button): + raise NotImplementedError( + "Subclasses must implement _on_add_clicked()" + ) diff --git a/rayforge/ui_gtk/shared/preferences_page.py b/rayforge/ui_gtk/shared/preferences_page.py new file mode 100644 index 000000000..deddb15c7 --- /dev/null +++ b/rayforge/ui_gtk/shared/preferences_page.py @@ -0,0 +1,18 @@ +from gi.repository import Adw + +from ...usage import get_usage_tracker + + +class TrackedPreferencesPage(Adw.PreferencesPage): + key = "" + path_prefix = "/settings/" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.connect("map", self._on_map) + + def _on_map(self, widget): + if self.key: + get_usage_tracker().track_page_view( + f"{self.path_prefix}{self.key}", self.key + ) diff --git a/rayforge/ui_gtk/shared/progress_bar.py b/rayforge/ui_gtk/shared/progress_bar.py new file mode 100644 index 000000000..51f76cb76 --- /dev/null +++ b/rayforge/ui_gtk/shared/progress_bar.py @@ -0,0 +1,43 @@ +from gi.repository import Gtk + +from .gtk import apply_css + + +class ProgressBar(Gtk.ProgressBar): + """ + A simple, self-contained progress bar that automatically reflects the + status of a TaskManager. It is a single thin bar that fades in when + tasks are running and fades out when idle. + """ + + def __init__(self, task_manager): + super().__init__( + hexpand=True, + halign=Gtk.Align.FILL, + valign=Gtk.Align.CENTER, + ) + self.task_manager = task_manager + + self.add_css_class("thin-progress-bar") + apply_css( + """ + progressbar.thin-progress-bar { + min-height: 5px; + transition: opacity 0.25s; + } + """ + ) + + self.task_manager.tasks_updated.connect(self._on_tasks_updated) + self.set_opacity(0) # Start faded out + + def _on_tasks_updated(self, sender, tasks, progress): + """ + Updates the progress bar's fraction and visibility based on the + state of the TaskManager. + """ + has_tasks = bool(tasks) + self.set_opacity(1 if has_tasks else 0) + + if has_tasks: + self.set_fraction(progress) diff --git a/rayforge/ui_gtk/shared/responsive_box.py b/rayforge/ui_gtk/shared/responsive_box.py new file mode 100644 index 000000000..8237f2713 --- /dev/null +++ b/rayforge/ui_gtk/shared/responsive_box.py @@ -0,0 +1,134 @@ +from gi.repository import Graphene, Gsk, Gtk + + +class ResponsiveBox(Gtk.Widget): + _SPACING = 12 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._first = None + self._second = None + + def set_children(self, first, second=None): + if self._first is not None: + self._first.unparent() + if self._second is not None: + self._second.unparent() + self._first = first + self._second = second + if first is not None: + first.set_parent(self) + if second is not None: + second.set_parent(self) + + def do_get_request_mode(self): + return Gtk.SizeRequestMode.WIDTH_FOR_HEIGHT + + def _get_vertical_height(self): + assert self._first is not None + assert self._second is not None + h1 = self._first.measure(Gtk.Orientation.VERTICAL, -1) + h2 = self._second.measure(Gtk.Orientation.VERTICAL, -1) + return h1, h2 + + def do_measure(self, orientation, for_size): + if self._first is None: + return (0, 0, -1, -1) + if self._second is None: + return self._first.measure(orientation, for_size) + + if orientation == Gtk.Orientation.VERTICAL: + h1, h2 = self._get_vertical_height() + stacked_nat = h1[1] + self._SPACING + h2[1] + + if for_size > 0: + h1c = self._first.measure(Gtk.Orientation.VERTICAL, for_size) + h2c = self._second.measure(Gtk.Orientation.VERTICAL, for_size) + side_min = max(h1c[0], h2c[0]) + + w1 = self._first.measure(Gtk.Orientation.HORIZONTAL, side_min) + w2 = self._second.measure(Gtk.Orientation.HORIZONTAL, side_min) + + if for_size >= w1[0] + self._SPACING + w2[0]: + return (side_min, stacked_nat, -1, -1) + + return ( + h1[0] + self._SPACING + h2[0], + stacked_nat, + -1, + -1, + ) + + return (max(h1[0], h2[0]), stacked_nat, -1, -1) + + if for_size > 0: + h1, h2 = self._get_vertical_height() + if for_size >= h1[1] + self._SPACING + h2[1]: + w1 = self._first.measure(Gtk.Orientation.HORIZONTAL, h1[1]) + w2 = self._second.measure(Gtk.Orientation.HORIZONTAL, h2[1]) + return (max(w1[0], w2[0]), max(w1[1], w2[1]), -1, -1) + + w1 = self._first.measure(Gtk.Orientation.HORIZONTAL, for_size) + w2 = self._second.measure(Gtk.Orientation.HORIZONTAL, for_size) + return ( + w1[0] + self._SPACING + w2[0], + w1[1] + self._SPACING + w2[1], + -1, + -1, + ) + + def do_size_allocate(self, width, height, baseline): + if self._first is None: + return + if self._second is None: + self._first.allocate(width, height, baseline, None) + return + + h1 = self._first.measure(Gtk.Orientation.VERTICAL, -1) + h2 = self._second.measure(Gtk.Orientation.VERTICAL, -1) + + if height >= h1[1] + self._SPACING + h2[1]: + self._allocate_vertical(width, h1[1], h2[1]) + else: + self._allocate_horizontal(width, height) + + def _allocate_vertical(self, width, h1_nat, h2_nat): + assert self._first is not None + assert self._second is not None + w1 = self._first.measure(Gtk.Orientation.HORIZONTAL, h1_nat) + w2 = self._second.measure(Gtk.Orientation.HORIZONTAL, h2_nat) + child_w = min(max(w1[1], w2[1]), width) + + self._first.allocate(child_w, h1_nat, -1, None) + + transform = Gsk.Transform().translate( + Graphene.Point().init(0, h1_nat + self._SPACING) + ) + self._second.allocate(child_w, h2_nat, -1, transform) + + def _allocate_horizontal(self, width, height): + assert self._first is not None + assert self._second is not None + w1 = self._first.measure(Gtk.Orientation.HORIZONTAL, height) + w2 = self._second.measure(Gtk.Orientation.HORIZONTAL, height) + + available = width - self._SPACING + total_nat = w1[1] + w2[1] + + if total_nat > 0 and available >= total_nat: + w1w = w1[1] + w2w = available - w1w + elif total_nat > 0: + ratio = available / total_nat + w1w = max(w1[0], int(w1[1] * ratio)) + w2w = available - w1w + else: + w1w = available // 2 + w2w = available - w1w + + self._first.allocate(w1w, height, -1, None) + + transform = Gsk.Transform().translate( + Graphene.Point().init(w1w + self._SPACING, 0) + ) + self._second.allocate(w2w, height, -1, transform) diff --git a/rayforge/ui_gtk/shared/round_button.py b/rayforge/ui_gtk/shared/round_button.py new file mode 100644 index 000000000..a4bd4fa17 --- /dev/null +++ b/rayforge/ui_gtk/shared/round_button.py @@ -0,0 +1,47 @@ +from gi.repository import Gtk + +css = """ +button.round-button { + min-width: 64px; + min-height: 64px; + border-radius: 32px; + padding: 0; + margin: 12px; + background-color: @theme_selected_bg_color; /* Material primary color */ + color: @theme_selected_fg_color; + font-size: 24px; + border: none; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); /* Shadow for depth */ + transition: background-color 0.2s, box-shadow 0.2s; +} + +button.round-button:hover { + background-color: shade(@theme_selected_bg_color, 0.9); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.19), + 0 6px 12px rgba(0, 0, 0, 0.23); /* Enhanced shadow on hover */ +} + +button.round-button:active { + background-color: shade(@theme_selected_bg_color, 1.1); /* Lighter shade */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.16), + 0 2px 4px rgba(0, 0, 0, 0.23); /* Reduced shadow on click */ +} +""" + + +class RoundButton(Gtk.Button): + def __init__(self, label, **kwargs): + super().__init__(**kwargs) + self.apply_css() + self.set_label(label) + self.set_halign(Gtk.Align.CENTER) + + def apply_css(self): + css_provider = Gtk.CssProvider() + css_provider.load_from_string(css) + style_context = self.get_style_context() + style_context.add_provider( + css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + style_context.add_class("round-button") diff --git a/rayforge/ui_gtk/shared/sanity_check_dialog.py b/rayforge/ui_gtk/shared/sanity_check_dialog.py new file mode 100644 index 000000000..0879695d9 --- /dev/null +++ b/rayforge/ui_gtk/shared/sanity_check_dialog.py @@ -0,0 +1,170 @@ +from collections.abc import Callable +from gettext import gettext as _ + +from gi.repository import Adw, Gtk, Pango + +from ...machine.sanity.result import ( + ISSUE_CATEGORY_LABELS, + IssueSeverity, + SanityReport, +) + + +class SanityCheckDialog(Adw.MessageDialog): + def __init__( + self, + parent: Gtk.Window, + report: SanityReport, + on_proceed: Callable[[], None] | None = None, + **kwargs, + ): + super().__init__(transient_for=parent, **kwargs) + self._report = report + self._on_proceed = on_proceed + + self.set_size_request(550, -1) + self.set_heading(_("Job Sanity Check")) + self.set_body(self._build_summary()) + + content = self._build_issue_list() + self.set_extra_child(content) + + self.add_response("cancel", _("_Cancel")) + self.add_response("proceed", _("_Proceed")) + self.set_default_response("cancel") + self.set_close_response("cancel") + if report.has_errors: + self.set_response_appearance( + "proceed", Adw.ResponseAppearance.DESTRUCTIVE + ) + else: + self.set_response_appearance( + "proceed", Adw.ResponseAppearance.SUGGESTED + ) + + self.connect("response", self._on_response) + + def _build_summary(self) -> str: + n_errors = sum( + 1 for i in self._report.issues if i.severity == IssueSeverity.ERROR + ) + n_warnings = sum( + 1 + for i in self._report.issues + if i.severity == IssueSeverity.WARNING + ) + parts = [] + if n_errors: + parts.append(_("{} error(s)").format(n_errors)) + if n_warnings: + parts.append(_("{} warning(s)").format(n_warnings)) + if not parts: + return _("No issues found.") + return _( + "Found {summary}. Proceeding may cause " + "damage to your machine or workpiece." + ).format(summary=", ".join(parts)) + + def _build_issue_list(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.set_margin_top(12) + + errors = [ + i for i in self._report.issues if i.severity == IssueSeverity.ERROR + ] + warnings = [ + i + for i in self._report.issues + if i.severity == IssueSeverity.WARNING + ] + + if errors: + box.append(self._make_section_label(_("Errors"))) + for issue in errors: + box.append(self._make_issue_row(issue)) + + if warnings: + box.append(self._make_section_label(_("Warnings"))) + for issue in warnings: + box.append(self._make_issue_row(issue)) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_propagate_natural_height(True) + scrolled.set_max_content_height(300) + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_child(box) + return scrolled + + def _make_section_label(self, text: str) -> Gtk.Label: + label = Gtk.Label( + label=text, + xalign=0.0, + margin_top=(8 if text == _("Warnings") else 0), + ) + label.add_css_class("caption-heading") + return label + + def _make_issue_row(self, issue) -> Gtk.Box: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_margin_top(2) + row.set_margin_bottom(2) + + icon_name = self._get_icon_name(issue.severity) + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_pixel_size(16) + icon.set_valign(Gtk.Align.START) + row.append(icon) + + desc = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + + title_text = self._format_title(issue) + title = Gtk.Label(label=title_text, xalign=0.0, wrap=True) + title.set_attributes(Pango.AttrList.new()) + desc.append(title) + + subtitle_text = self._format_subtitle(issue) + if subtitle_text: + sub = Gtk.Label( + label=subtitle_text, + xalign=0.0, + wrap=True, + ) + sub.add_css_class("caption") + sub.add_css_class("dim-label") + desc.append(sub) + + row.append(desc) + return row + + @staticmethod + def _get_icon_name(severity: IssueSeverity) -> str: + if severity == IssueSeverity.ERROR: + return "dialog-error-symbolic" + return "dialog-warning-symbolic" + + @staticmethod + def _format_title(issue) -> str: + label = ISSUE_CATEGORY_LABELS[issue.category] + if issue.zone_name: + return f'{label}: "{issue.zone_name}"' + return label + + @staticmethod + def _format_subtitle(issue) -> str: + parts = [] + if issue.segment_start and issue.segment_end: + parts.append( + "({:.1f}, {:.1f}) → ({:.1f}, {:.1f})".format( + *issue.segment_start, *issue.segment_end + ) + ) + elif issue.segment_end: + parts.append("({:.1f}, {:.1f})".format(*issue.segment_end)) + if issue.message: + parts.append(issue.message) + return "\n".join(parts) + + def _on_response(self, dialog, response_id): + self.destroy() + if response_id == "proceed" and self._on_proceed: + self._on_proceed() diff --git a/rayforge/ui_gtk/shared/shortcut.py b/rayforge/ui_gtk/shared/shortcut.py new file mode 100644 index 000000000..e45e3cae7 --- /dev/null +++ b/rayforge/ui_gtk/shared/shortcut.py @@ -0,0 +1,42 @@ +from gi.repository import Gtk + +from ..shared.gtk import apply_css +from .key import Key + +css = """ +.shortcut-description { + margin-left: 6px; +} +""" + + +class Shortcut(Gtk.Box): + def __init__( + self, + keys: list[str], + description: str | None = None, + separator: str = "+", + **kwargs, + ): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, **kwargs) + apply_css(css) + self.set_spacing(1) + + for i, key in enumerate(keys): + key_widget = Key(label=key) + self.append(key_widget) + + if i < len(keys) - 1: + separator_label = Gtk.Label(label=separator) + separator_label.add_css_class("caption") + separator_label.set_opacity(0.7) + self.append(separator_label) + + if description: + description_label = Gtk.Label( + label=description, + valign=Gtk.Align.CENTER, + justify=Gtk.Justification.LEFT, + ) + description_label.add_css_class("shortcut-description") + self.append(description_label) diff --git a/rayforge/ui_gtk/shared/slider.py b/rayforge/ui_gtk/shared/slider.py new file mode 100644 index 000000000..21faebb57 --- /dev/null +++ b/rayforge/ui_gtk/shared/slider.py @@ -0,0 +1,97 @@ +import locale +from collections.abc import Callable + +from gi.repository import Adw, Gtk + +from .gtk import apply_css + +SLIDER_TRACK_WIDTH = 200 +VALUE_LABEL_WIDTH = 60 + + +def create_slider( + adjustment: Gtk.Adjustment, + digits: int = 1, + draw_value: bool = True, + on_value_changed: Callable[[Gtk.Scale], None] | None = None, +) -> Gtk.Scale: + scale = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, + adjustment=adjustment, + digits=digits, + draw_value=draw_value, + ) + scale.set_size_request(SLIDER_TRACK_WIDTH, 60 if draw_value else -1) + scale.set_valign(Gtk.Align.CENTER) + + if on_value_changed: + scale.connect("value-changed", on_value_changed) + + return scale + + +_SLIDER_VALUE_ENTRY_CSS = """ +.slider-value-entry { outline: none; } +""" + + +def create_slider_row( + title: str, + adjustment: Gtk.Adjustment, + subtitle: str | None = None, + digits: int = 1, + draw_value: bool = True, + on_value_changed: Callable[[Gtk.Scale], None] | None = None, + format_suffix: str | None = None, +) -> tuple[Adw.ActionRow, Gtk.Scale]: + scale = create_slider( + adjustment=adjustment, + digits=digits, + draw_value=False, + on_value_changed=on_value_changed, + ) + + row = Adw.ActionRow(title=title) + if subtitle: + row.set_subtitle(subtitle) + + if draw_value: + entry = Gtk.Entry() + entry.set_width_chars(8) + entry.set_alignment(1.0) + entry.set_has_frame(False) + entry.add_css_class("slider-value-entry") + apply_css(_SLIDER_VALUE_ENTRY_CSS) + + def format_value(val): + text = locale.format_string(f"%.{digits}f", val) + if format_suffix: + text += format_suffix + return text + + def update_entry(s): + entry.set_text(format_value(s.get_value())) + + scale.connect("value-changed", update_entry) + update_entry(scale) + + def commit_entry(e): + text = e.get_text() + if format_suffix and text.endswith(format_suffix): + text = text[: -len(format_suffix)] + try: + adjustment.set_value(locale.atof(text)) + except ValueError: + update_entry(scale) + + entry.connect("activate", commit_entry) + + focus_ctrl = Gtk.EventControllerFocus() + focus_ctrl.connect("leave", lambda c: commit_entry(entry)) + entry.add_controller(focus_ctrl) + + row.add_suffix(entry) + + row.add_suffix(scale) + + return row, scale diff --git a/rayforge/ui_gtk/shared/splitbutton.py b/rayforge/ui_gtk/shared/splitbutton.py new file mode 100644 index 000000000..5950940fd --- /dev/null +++ b/rayforge/ui_gtk/shared/splitbutton.py @@ -0,0 +1,145 @@ +import logging +from collections.abc import Sequence +from gettext import gettext as _ + +from gi.repository import Gtk + +from ..icons import get_icon + +logger = logging.getLogger(__name__) + + +class SplitMenuButton(Gtk.Box): + """ + A composite widget that mimics a split button, integrated with Gio.Action. + + It has a main action button that shows and triggers the last-used action, + and a separate dropdown button to reveal all other actions in a popover. + """ + + def __init__( + self, + actions: Sequence[tuple[str, str, str]], + default_index: int = 0, + **kwargs, + ): + """ + Initializes the SplitMenuButton. + + Args: + actions: A sequence of tuples, where each tuple contains + (name, icon_name, action_name) for an action. + default_index: The index of the action to show by default. + """ + super().__init__(**kwargs) + self.set_orientation(Gtk.Orientation.HORIZONTAL) + self.set_spacing(0) + self.add_css_class("linked") + + if not actions: + raise ValueError("SplitMenuButton requires at least one action.") + + self.actions = actions + self._last_action_index = default_index + + # 1. The main action button + self.main_button = Gtk.Button() + # The action will be set dynamically by _set_active_action + self.append(self.main_button) + + # 2. The dropdown button for the menu + popover = self._build_popover() + self.menu_button = Gtk.MenuButton( + child=get_icon("pan-down-symbolic"), + popover=popover, + tooltip_text=_("Show all options"), + ) + self.append(self.menu_button) + + # Set the initial state of the main button + self._set_active_action(self._last_action_index) + + def set_sensitive(self, sensitive: bool): + """Sets the sensitivity of the entire composite button.""" + # The sensitivity of the buttons is now controlled by their associated + # Gio.Actions. This method can still be used for a top-level override. + super().set_sensitive(sensitive) + + def _build_popover(self) -> Gtk.Popover: + """Creates the popover menu with buttons for all actions.""" + popover = Gtk.Popover() + list_box = Gtk.ListBox() + list_box.set_selection_mode(Gtk.SelectionMode.NONE) + list_box.add_css_class("popover-list") + popover.set_child(list_box) + + for i, (name, icon_name, action_name) in enumerate(self.actions): + row = Gtk.ListBoxRow() + button = Gtk.Button() + button.set_has_frame(False) + content = Gtk.Box(spacing=6) + content.append(get_icon(icon_name)) + content.append(Gtk.Label(label=name)) + button.set_child(content) + + # Set the action for the menu item button + button.set_action_name(action_name) + + # Also connect to 'clicked' to update the main button's appearance + button.connect( + "clicked", lambda _, idx=i: self._on_menu_item_clicked(idx) + ) + row.set_child(button) + list_box.append(row) + + return popover + + def _on_menu_item_clicked(self, index: int): + """ + Called when a user clicks an item in the popover menu. + This updates the main button to reflect the choice. + """ + popover = self.menu_button.get_popover() + if popover: + popover.popdown() + self._set_active_action(index) + + def _set_active_action(self, index: int): + """ + Updates the main button to show the new active action's icon + and connects it to the correct Gio.Action. + + Args: + index: The index of the action to set as active. + """ + self._last_action_index = index + name, icon_name, action_name = self.actions[index] + + # Update the main button's appearance and its active action + self.main_button.set_child(get_icon(icon_name)) + self.main_button.set_tooltip_text(name) + self.main_button.set_action_name(action_name) + + def update_actions(self, actions: Sequence[tuple[str, str, str]]): + """ + Updates the list of actions in the button. + + Args: + actions: A sequence of tuples, where each tuple contains + (name, icon_name, action_name) for an action. + """ + if not actions: + logger.warning( + "SplitMenuButton.update_actions called with empty list" + ) + return + + self.actions = actions + self._last_action_index = 0 + + # Rebuild the popover menu + popover = self._build_popover() + self.menu_button.set_popover(popover) + + # Reset the main button + self._set_active_action(0) diff --git a/rayforge/ui_gtk/shared/status_bar.py b/rayforge/ui_gtk/shared/status_bar.py new file mode 100644 index 000000000..ce746757b --- /dev/null +++ b/rayforge/ui_gtk/shared/status_bar.py @@ -0,0 +1,37 @@ +from gi.repository import Gtk + +from .shortcut import Shortcut + + +class StatusBar(Gtk.Box): + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, **kwargs) + self.add_css_class("status-bar") + self.set_spacing(24) + + def add_shortcut_entry( + self, + keys: list[str], + description: str | None = None, + separator: str = "+", + ): + """Add a shortcut to the status bar.""" + shortcut = Shortcut( + keys=keys, description=description, separator=separator + ) + self.append(shortcut) + + def add_separator(self): + """Add a visual separator between shortcuts.""" + separator = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + separator.set_size_request(1, 16) + separator.add_css_class("separator") + separator.get_style_context().add_class("separator") + self.append(separator) + + def clear(self): + """Remove all shortcuts from the status bar.""" + child = self.get_first_child() + while child is not None: + self.remove(child) + child = self.get_first_child() diff --git a/rayforge/ui_gtk/shared/tag.py b/rayforge/ui_gtk/shared/tag.py new file mode 100644 index 000000000..3d085c74b --- /dev/null +++ b/rayforge/ui_gtk/shared/tag.py @@ -0,0 +1,40 @@ +from gi.repository import Gtk + +from .gtk import apply_css + +css = """ +.tag { + border-radius: 6px; + padding: 2px 8px; + transition: background-color 0.15s; +} + +.tag.active { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +.tag.inactive { + background-color: alpha(@view_fg_color, 0.12); + color: @view_fg_color; +} +""" + + +class TagWidget(Gtk.Box): + def __init__(self, active=True, **kwargs): + super().__init__(**kwargs) + apply_css(css) + self.add_css_class("tag") + self.set_active(active) + + def set_active(self, active: bool): + if active: + self.add_css_class("active") + self.remove_css_class("inactive") + else: + self.add_css_class("inactive") + self.remove_css_class("active") + + def get_active(self) -> bool: + return self.has_css_class("active") diff --git a/rayforge/ui_gtk/shared/theme_service.py b/rayforge/ui_gtk/shared/theme_service.py new file mode 100644 index 000000000..1b1499c75 --- /dev/null +++ b/rayforge/ui_gtk/shared/theme_service.py @@ -0,0 +1,174 @@ +""" +Shared theme colour service. + +Owns the single source of truth for the domain colours that both the 2D +and 3D canvases need: the base ``ColorSet`` (``OPS_COLOR_SPEC``), the +per-laser colour sets, and the per-layer colour sets. It binds to one +GTK widget (the main window), resolves the theme colours through that +widget's style context, and re-resolves lazily when the theme, machine, +or document changes. + +The service is exposed as a lazy ``RayforgeContext.theme`` property so +GTK is never imported eagerly in headless/worker processes. +""" + +import logging +from typing import TYPE_CHECKING, Optional + +from ...core.color import OPS_COLOR_SPEC, ColorSet, hex_to_rgba +from ...image.util.srgb import create_lut_from_color +from ...machine.models.colors import OpsColorSet +from ...machine.models.laser import LaserHead +from .color_lut_provider import ColorLutProvider +from .gtk_color import GtkColorResolver + +if TYPE_CHECKING: + from gi.repository import Gtk + + from ...core.doc import Doc + from ...machine.models.machine import Machine + +logger = logging.getLogger(__name__) + + +class ThemeColorService: + """ + Resolves and caches the shared domain colours for all canvases. + + Base ``ColorSet``, per-laser colour sets, and per-layer colour sets + are each resolved exactly once per theme change, keyed by a single + dirty flag. Both canvases read through this service so laser paths + and textures stay identical. + """ + + def __init__(self): + self._widget: Gtk.Widget | None = None + self._machine: Machine | None = None + self._doc: Doc | None = None + + self._dirty = True + self._color_set: ColorSet | None = None + self._laser_color_sets: dict[str, ColorSet] = {} + self._layer_color_sets: dict[str, ColorSet] = {} + self._lut_provider: ColorLutProvider | None = None + + def bind(self, widget: "Gtk.Widget"): + """ + Bind the service to a widget and start reacting to theme changes. + + The widget's style context is representative of the whole window; + both canvases are descendants of the bound widget. + """ + if self._widget is widget: + return + self._widget = widget + widget.connect("notify::style", self._on_style_changed) + self.mark_dirty() + + def set_machine(self, machine: Optional["Machine"]): + """Set the machine whose lasers colour the laser paths.""" + if machine is self._machine: + return + if self._machine is not None: + self._machine.changed.disconnect(self._on_machine_changed) + self._machine = machine + if machine is not None: + machine.changed.connect(self._on_machine_changed) + self.mark_dirty() + + def set_doc(self, doc: Optional["Doc"]): + """Set the document whose layers colour the layer paths.""" + if doc is self._doc: + return + if self._doc is not None: + self._doc.descendant_updated.disconnect(self._on_doc_updated) + self._doc = doc + if doc is not None: + doc.descendant_updated.connect(self._on_doc_updated) + self.mark_dirty() + + def _on_style_changed(self, widget, gparam): + self.mark_dirty() + + def _on_machine_changed(self, machine): + self.mark_dirty() + + def _on_doc_updated(self, *args, **kwargs): + self.mark_dirty() + + def mark_dirty(self): + """Mark all cached colours as stale.""" + self._dirty = True + self._lut_provider = None + + @property + def dirty(self) -> bool: + """True if cached colours need re-resolving.""" + return self._dirty + + @property + def color_set(self) -> ColorSet | None: + """The resolved base theme ColorSet.""" + self._refresh_if_dirty() + return self._color_set + + @property + def laser_color_sets(self) -> dict[str, ColorSet]: + """Per-laser colour sets keyed by laser UID.""" + self._refresh_if_dirty() + return self._laser_color_sets + + @property + def layer_color_sets(self) -> dict[str, ColorSet]: + """Per-layer colour sets keyed by layer UID.""" + self._refresh_if_dirty() + return self._layer_color_sets + + def color_lut_provider(self) -> ColorLutProvider | None: + """A provider over the current base + laser colour sets.""" + color_set = self.color_set + if color_set is None: + return None + if self._lut_provider is None: + self._lut_provider = ColorLutProvider( + color_set, self.laser_color_sets + ) + return self._lut_provider + + def _refresh_if_dirty(self): + if not self._dirty: + return + if self._widget is None: + return + resolver = GtkColorResolver(self._widget) + self._color_set = resolver.resolve(OPS_COLOR_SPEC) + self._laser_color_sets = self._resolve_laser_color_sets() + self._layer_color_sets = self._resolve_layer_color_sets() + self._dirty = False + + def _resolve_laser_color_sets(self) -> dict[str, ColorSet]: + if self._color_set is None or self._machine is None: + return {} + laser_color_sets: dict[str, ColorSet] = {} + for laser in self._machine.heads: + if not isinstance(laser, LaserHead): + continue + laser_color_set = OpsColorSet.from_laser(laser, self._color_set) + laser_color_sets[laser.uid] = laser_color_set.to_color_set() + return laser_color_sets + + def _resolve_layer_color_sets(self) -> dict[str, ColorSet]: + if self._color_set is None or self._doc is None: + return {} + layer_color_sets: dict[str, ColorSet] = {} + for layer in self._doc.layers: + cut_rgba = hex_to_rgba(layer.color) + cut_lut = create_lut_from_color(cut_rgba) + data = { + "cut": cut_lut, + "engrave": cut_lut, + "travel": self._color_set.get_rgba("travel"), + "zero_power": self._color_set.get_rgba("zero_power"), + } + layer_color_sets[layer.uid] = ColorSet(_data=data) + return layer_color_sets diff --git a/rayforge/ui_gtk/shared/time_estimate_overlay.py b/rayforge/ui_gtk/shared/time_estimate_overlay.py new file mode 100644 index 000000000..de2aa5fef --- /dev/null +++ b/rayforge/ui_gtk/shared/time_estimate_overlay.py @@ -0,0 +1,40 @@ +from gi.repository import Gtk + +from ...shared.util.time_format import format_seconds +from .gtk import apply_css + +css = """ +.time-estimate-overlay { + background-color: @theme_bg_color; + border-radius: 6px; + padding: 3px 8px; +} +""" + + +class TimeEstimateOverlay(Gtk.Box): + def __init__(self, **kwargs): + super().__init__( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=4, + **kwargs, + ) + apply_css(css) + self.add_css_class("time-estimate-overlay") + self.set_halign(Gtk.Align.END) + self.set_valign(Gtk.Align.END) + self.set_margin_bottom(6) + self.set_margin_end(6) + + self._label = Gtk.Label() + self._label.set_visible(False) + self.append(self._label) + + def set_estimated_time(self, time_seconds: float | None): + if time_seconds is None or time_seconds <= 0: + self._label.set_visible(False) + else: + self._label.set_text( + "~" + format_seconds(time_seconds, compact=True) + ) + self._label.set_visible(True) diff --git a/rayforge/ui_gtk/shared/undo_button.py b/rayforge/ui_gtk/shared/undo_button.py new file mode 100644 index 000000000..6a96c4739 --- /dev/null +++ b/rayforge/ui_gtk/shared/undo_button.py @@ -0,0 +1,170 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Gtk + +from ...core.undo import Command, HistoryManager +from ..icons import get_icon + +logger = logging.getLogger(__name__) + +# The maximum number of history items to display in the dropdown. +HISTORY_DISPLAY_LIMIT = 15 + + +class _HistoryButton(Gtk.Box): + """ + A composite widget that combines a main action button with a dropdown + button for history. This is the base class for Undo/Redo buttons. + The main button is controlled by a Gio.Action. + """ + + def __init__(self, icon_name: str, tooltip: str, **kwargs): + super().__init__(**kwargs) + self.set_orientation(Gtk.Orientation.HORIZONTAL) + self.set_spacing(0) + # The "linked" style class makes the two buttons appear joined + # together. + self.add_css_class("linked") + + self.manager: HistoryManager | None = None + + # 1. The main action button + self.main_button = Gtk.Button(child=get_icon(icon_name)) + self.main_button.set_tooltip_text(tooltip) + self.append(self.main_button) + + # 2. The dropdown button for history + self.menu_button = Gtk.MenuButton(child=get_icon("pan-down-symbolic")) + self.menu_button.set_tooltip_text(_("Show History")) + popover = Gtk.Popover() + self.menu_button.set_popover(popover) + self.append(self.menu_button) + + def set_action_name(self, action_name: str): + """Sets the Gio.Action for the main button.""" + self.main_button.set_action_name(action_name) + + def set_history_manager(self, manager: HistoryManager): + """Connects the button to a HistoryManager instance.""" + if self.manager: + self.manager.changed.disconnect(self._on_history_changed) + + self.manager = manager + self.manager.changed.connect(self._on_history_changed) + self._on_history_changed(self.manager) + + def _on_history_changed(self, sender: HistoryManager, **kwargs): + """Updates the button's state and menu when the history changes.""" + # The main_button's sensitivity is now controlled by its Gio.Action. + # We only need to control the dropdown arrow's sensitivity here. + can_act = self._can_act() + self.menu_button.set_sensitive(can_act) + + popover = self.menu_button.get_popover() + if not popover: + return + + # Get the full stack and then limit it for display purposes. + full_stack = self._get_stack() + display_stack = full_stack[:HISTORY_DISPLAY_LIMIT] + + if display_stack: + # Rebuild the listbox only if needed + list_box = popover.get_child() + if not isinstance(list_box, Gtk.ListBox): + list_box = Gtk.ListBox() + list_box.set_selection_mode(Gtk.SelectionMode.NONE) + list_box.add_css_class("popover-list") + popover.set_child(list_box) + + # A simple implementation is to clear and refill. + # For high-frequency updates, one might optimize this. + child = list_box.get_first_child() + while child: + list_box.remove(child) + child = list_box.get_first_child() + + for command in display_stack: + row = Gtk.ListBoxRow() + button = Gtk.Button(label=command.name or _("Unnamed Action")) + button.set_has_frame(False) + button.connect("clicked", self._on_menu_item_clicked, command) + row.set_child(button) + list_box.append(row) + else: + if popover.get_child(): + popover.set_child(None) + + def _on_menu_item_clicked(self, _, command: Command): + """Performs undo/redo up to a specific command from the popover.""" + popover = self.menu_button.get_popover() + if popover: + popover.popdown() + if not self.manager: + return + self._act_to(command) + + def _get_stack(self) -> list[Command]: + """Subclasses must implement this to return the correct stack.""" + raise NotImplementedError + + def _can_act(self) -> bool: + """ + Subclasses must implement this to check if an action can be performed. + """ + raise NotImplementedError + + def _act_to(self, command: Command): + """Subclasses must implement this for the history dropdown action.""" + raise NotImplementedError + + +class UndoButton(_HistoryButton): + """A Gtk.Box composite widget for undoing actions.""" + + def __init__(self, **kwargs): + super().__init__( + icon_name="undo-symbolic", + tooltip=_("Undo the last action"), + **kwargs, + ) + + def _get_stack(self) -> list[Command]: + if not self.manager: + return [] + # Newest action should appear first in the dropdown. + return list(reversed(self.manager.undo_stack)) + + def _can_act(self) -> bool: + return self.manager is not None and self.manager.can_undo() + + def _act_to(self, command: Command): + if not self.manager: + return + self.manager.undo_to(command) + + +class RedoButton(_HistoryButton): + """A Gtk.Box composite widget for redoing actions.""" + + def __init__(self, **kwargs): + super().__init__( + icon_name="redo-symbolic", + tooltip=_("Redo the last action"), + **kwargs, + ) + + def _get_stack(self) -> list[Command]: + if not self.manager: + return [] + # Newest action to be redone should appear first. + return list(reversed(self.manager.redo_stack)) + + def _can_act(self) -> bool: + return self.manager is not None and self.manager.can_redo() + + def _act_to(self, command: Command): + if not self.manager: + return + self.manager.redo_to(command) diff --git a/rayforge/ui_gtk/shared/usage_consent_dialog.py b/rayforge/ui_gtk/shared/usage_consent_dialog.py new file mode 100644 index 000000000..485a40ad2 --- /dev/null +++ b/rayforge/ui_gtk/shared/usage_consent_dialog.py @@ -0,0 +1,51 @@ +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...usage import get_usage_tracker + + +class UsageConsentDialog(Adw.MessageDialog): + def __init__(self, parent: Gtk.Window): + super().__init__(transient_for=parent, modal=True) + self.set_heading(_("Help Improve Rayforge")) + self.set_body( + _( + "Would you like to help improve Rayforge by allowing " + "anonymous usage reporting? This helps us understand " + "how the app is used and prioritize improvements.\n\n" + "No personal data is collected." + ) + ) + + link_label = Gtk.Label( + label=_( + 'Learn more about usage tracking ' + "and privacy." + ), + use_markup=True, + wrap=True, + max_width_chars=50, + halign=Gtk.Align.START, + margin_top=12, + ) + self.set_extra_child(link_label) + + self.add_response("decline", _("No Thanks")) + self.add_response("accept", _("Allow Reporting")) + self.set_response_appearance( + "accept", Adw.ResponseAppearance.SUGGESTED + ) + self.set_default_response("accept") + self.set_close_response("decline") + + self.connect("response", self._on_response) + + def _on_response(self, dialog, response_id): + consent = response_id == "accept" + context = get_context() + context.config.set_usage_consent(consent) + get_usage_tracker().set_enabled(consent) + self.close() diff --git a/rayforge/ui_gtk/shared/visibility_overlay.py b/rayforge/ui_gtk/shared/visibility_overlay.py new file mode 100644 index 000000000..38fdfff13 --- /dev/null +++ b/rayforge/ui_gtk/shared/visibility_overlay.py @@ -0,0 +1,170 @@ +from gettext import gettext as _ + +from gi.repository import Gdk, Gtk + +from ..icons import get_icon +from .gtk import apply_css + +css = """ +.visibility-overlay { + background-color: alpha(@theme_bg_color, 0.75); + border-radius: 6px; + padding: 3px; +} +.visibility-overlay button { + min-width: 28px; + min-height: 28px; + padding: 0; +} +""" + + +class VisibilityOverlay(Gtk.Box): + """ + A row of visibility toggle buttons meant to be placed as an overlay + on top of a canvas widget. + """ + + def __init__( + self, + show_workpiece=True, + show_camera=False, + show_models=False, + show_grid=False, + show_tabs=False, + shortcuts=None, + **kwargs, + ): + super().__init__( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=2, + **kwargs, + ) + apply_css(css) + self.add_css_class("visibility-overlay") + self.set_halign(Gtk.Align.END) + self.set_valign(Gtk.Align.START) + self.set_margin_top(6) + self.set_margin_end(6) + self._shortcuts = shortcuts or {} + + if show_workpiece: + self._vis_on_icon = get_icon("visibility-on-symbolic") + self._vis_off_icon = get_icon("visibility-off-symbolic") + self.workpiece_button = Gtk.ToggleButton() + self.workpiece_button.set_active(True) + self.workpiece_button.set_child(self._vis_on_icon) + self.workpiece_button.set_tooltip_text( + self._format_tooltip( + _("Toggle workpiece visibility"), + "win.show_workpieces", + ) + ) + self.workpiece_button.set_action_name("win.show_workpieces") + self.workpiece_button.connect( + "toggled", self._on_workpiece_toggled + ) + self.append(self.workpiece_button) + + if show_tabs: + self.tabs_button = Gtk.ToggleButton() + self.tabs_button.set_child(get_icon("tabs-visible-symbolic")) + self.tabs_button.set_active(True) + self.tabs_button.set_tooltip_text( + self._format_tooltip( + _("Toggle tab visibility"), "win.show_tabs" + ) + ) + self.tabs_button.set_action_name("win.show_tabs") + self.append(self.tabs_button) + + self._cam_on_icon = get_icon("camera-on-symbolic") + self._cam_off_icon = get_icon("camera-off-symbolic") + self.camera_button = Gtk.ToggleButton() + self.camera_button.set_active(True) + self.camera_button.set_child(self._cam_on_icon) + self.camera_button.set_tooltip_text( + self._format_tooltip( + _("Toggle camera image visibility"), + "win.toggle_camera_view", + ) + ) + self.camera_button.set_action_name("win.toggle_camera_view") + self.camera_button.connect("toggled", self._on_camera_toggled) + self.append(self.camera_button) + self.camera_button.set_visible(show_camera) + + if show_models: + self.models_button = Gtk.ToggleButton() + self.models_button.set_child(get_icon("model-symbolic")) + self.models_button.set_active(True) + self.models_button.set_tooltip_text( + self._format_tooltip( + _("Toggle 3D model visibility"), "win.show_models" + ) + ) + self.models_button.set_action_name("win.show_models") + self.append(self.models_button) + + if show_grid: + self.grid_button = Gtk.ToggleButton() + self.grid_button.set_child(get_icon("sketch-grid-symbolic")) + self.grid_button.set_active(True) + self.grid_button.set_tooltip_text( + self._format_tooltip( + _("Toggle grid visibility"), "win.show_grid" + ) + ) + self.grid_button.set_action_name("win.show_grid") + self.append(self.grid_button) + + self.travel_button = Gtk.ToggleButton() + self.travel_button.set_child(get_icon("travel-path-symbolic")) + self.travel_button.set_active(False) + self.travel_button.set_tooltip_text( + self._format_tooltip( + _("Toggle travel move visibility"), + "win.toggle_travel_view", + ) + ) + self.travel_button.set_action_name("win.toggle_travel_view") + self.append(self.travel_button) + + self.nogo_button = Gtk.ToggleButton() + self.nogo_button.set_child(get_icon("block-symbolic")) + self.nogo_button.set_active(True) + self.nogo_button.set_tooltip_text( + self._format_tooltip( + _("Toggle no-go zone visibility"), "win.show_nogo_zones" + ) + ) + self.nogo_button.set_action_name("win.show_nogo_zones") + self.append(self.nogo_button) + + def set_camera_visible(self, visible: bool): + self.camera_button.set_visible(visible) + + def _format_tooltip(self, text, action_name): + if action_name in self._shortcuts: + shortcut_str = self._shortcuts[action_name] + trigger = Gtk.ShortcutTrigger.parse_string(shortcut_str) + if trigger is None: + return text + display = Gdk.Display.get_default() + if display is not None: + label = trigger.to_label(display) + if label is not None: + return f"{text} ({label})" + return text + + def _on_workpiece_toggled(self, button): + if button.get_active(): + button.set_child(self._vis_on_icon) + else: + button.set_child(self._vis_off_icon) + + def _on_camera_toggled(self, button): + if button.get_active(): + button.set_child(self._cam_on_icon) + else: + button.set_child(self._cam_off_icon) diff --git a/rayforge/ui_gtk/sim3d/__init__.py b/rayforge/ui_gtk/sim3d/__init__.py new file mode 100644 index 000000000..fd988dda4 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/__init__.py @@ -0,0 +1,103 @@ +import logging +import os + +# Check if 3D canvas is explicitly disabled via environment variable +# This must be checked before any GL-related imports occur +_3d_disabled = os.environ.get("RAYFORGE_DISABLE_3D", "").lower() in ( + "true", + "1", +) + +# This flag can be checked by other parts of the application +# to decide whether to instantiate and show the 3D canvas. +initialized = False + +# Store the exception if initialization fails, for better debugging. +initialization_error = None + +logger = logging.getLogger(__name__) + + +def initialize(): + """ + Tries to initialize the required OpenGL bindings. + + This function attempts to import PyOpenGL. A failure indicates that + the necessary libraries are not available on the system, and the 3D + canvas cannot be used. It sets the package-level 'initialized' flag + accordingly. This should be called from the main application entry + point before any UI is created. + + If the RAYFORGE_DISABLE_3D environment variable is set to 'true' or + '1', the 3D canvas will be disabled without attempting initialization. + """ + global initialized, initialization_error + if initialized or initialization_error: + return + + # Check if 3D canvas is explicitly disabled via environment variable + if _3d_disabled: + logger.info( + "3D canvas disabled via RAYFORGE_DISABLE_3D environment variable." + ) + initialized = False + return + + try: + # The import itself triggers platform-specific initialization and will + # fail if the necessary libraries are not found (e.g., libGL.so). + from OpenGL import GL # Imported for side effects + + _ = GL # Mark as used to silence pyflakes + + logger.info("PyOpenGL imported successfully. 3D canvas is available.") + initialized = True + except ImportError as e: + initialization_error = e + logger.error( + "Failed to import PyOpenGL. The 3D canvas will be disabled. " + "Error: %s", + e, + ) + logger.info( + "This might be due to missing graphics drivers or an " + "unsupported environment. Please ensure OpenGL libraries are " + "installed on your system (e.g., 'mesa-libGL' on Linux)." + ) + initialized = False + except Exception as e: + # Catch other potential errors during initial module load. + initialization_error = e + logger.exception( + "An unexpected error occurred during OpenGL initialization. " + "The 3D canvas will be disabled." + ) + initialized = False + + +class _PlaceholderCanvas3D: + """A placeholder class for when the 3D canvas is disabled/unavailable.""" + + def __init__(self, *args, **kwargs): + raise RuntimeError("3D Canvas is not available.") + + +# Expose the main widget class from the package. +# Skip import entirely if disabled to avoid any GL loading. +if _3d_disabled: + logger.info("Skipping Canvas3D import due to RAYFORGE_DISABLE_3D") + Canvas3D = _PlaceholderCanvas3D +else: + try: + from .canvas3d import Canvas3D # type: ignore + except Exception as e: + logger.exception( + "Failed to import Canvas3D. The 3D canvas will not be available." + ) + initialization_error = f"Canvas3D import failed: {e}" + Canvas3D = _PlaceholderCanvas3D + + +__all__ = [ + "Canvas3D", +] diff --git a/rayforge/ui_gtk/sim3d/camera.py b/rayforge/ui_gtk/sim3d/camera.py new file mode 100644 index 000000000..4a1b22cd6 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/camera.py @@ -0,0 +1,268 @@ +""" +Defines the Camera class for managing 3D perspective and navigation. +""" + +import enum +import math +from typing import ClassVar + +import numpy as np + + +class ViewDirection(enum.Enum): + TOP = "top" + FRONT = "front" + RIGHT = "right" + LEFT = "left" + BACK = "back" + ISO = "iso" + + +def rotation_matrix_from_axis_angle( + axis: np.ndarray, angle: float +) -> np.ndarray: + """Creates a rotation matrix from an axis and an angle (Rodrigues).""" + norm = np.linalg.norm(axis) + if norm < 1e-6: + return np.identity(3, dtype=np.float64) + axis = axis / norm + + c = math.cos(angle) + s = math.sin(angle) + t = 1 - c + x, y, z = axis + return np.array( + [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ], + dtype=np.float64, + ) + + +class Camera: + """ + Manages the camera's position, orientation, and projection. + """ + + def __init__( + self, + position: np.ndarray, + target: np.ndarray, + up: np.ndarray, + width: int, + height: int, + ): + """ + Initializes the Camera. + + Args: + position: The 3D position of the camera. + target: The 3D point the camera is looking at. + up: The "up" direction vector for the camera. + width: The width of the viewport in pixels. + height: The height of the viewport in pixels. + """ + self.position = np.array(position, dtype=np.float64) + self.target = np.array(target, dtype=np.float64) + self.up = np.array(up, dtype=np.float64) + self.width = int(width) + self.height = int(height) + self.is_perspective = False + self._ortho_zoom = 1.0 + self._ortho_ref_distance: float | None = None + + def get_view_matrix(self) -> np.ndarray: + """ + Calculates the view matrix (look-at matrix). + + Returns: + A 4x4 numpy array representing the view transformation. + """ + forward = self.target - self.position + forward /= np.linalg.norm(forward) + + side = np.cross(forward, self.up) + side /= np.linalg.norm(side) + + up_vec = np.cross(side, forward) + + view_matrix = np.identity(4, dtype=np.float32) + view_matrix[0, 0], view_matrix[1, 0], view_matrix[2, 0] = side + view_matrix[0, 1], view_matrix[1, 1], view_matrix[2, 1] = up_vec + view_matrix[0, 2], view_matrix[1, 2], view_matrix[2, 2] = -forward + view_matrix[3, 0] = -np.dot(side, self.position) + view_matrix[3, 1] = -np.dot(up_vec, self.position) + view_matrix[3, 2] = np.dot(forward, self.position) + return view_matrix.T + + def get_projection_matrix(self) -> np.ndarray: + """ + Calculates the projection matrix (perspective or orthographic). + + Returns: + A 4x4 numpy array for the projection transformation. + """ + aspect_ratio = self.width / self.height if self.height > 0 else 1.0 + + if not self.is_perspective: + near_clip = 0.1 + far_clip = 10000.0 + return self._get_ortho_matrix(aspect_ratio, near_clip, far_clip) + + near_clip, far_clip = 0.1, 10000.0 + return self._get_perspective_matrix(aspect_ratio, near_clip, far_clip) + + def _get_perspective_matrix( + self, aspect_ratio: float, near: float, far: float + ) -> np.ndarray: + """Builds a perspective projection matrix.""" + fovy_rad = math.radians(45.0) + f = 1.0 / math.tan(fovy_rad / 2.0) + return np.array( + [ + [f / aspect_ratio, 0.0, 0.0, 0.0], + [0.0, f, 0.0, 0.0], + [ + 0.0, + 0.0, + (far + near) / (near - far), + (2 * far * near) / (near - far), + ], + [0.0, 0.0, -1.0, 0.0], + ], + dtype=np.float32, + ) + + def _get_ortho_matrix( + self, aspect_ratio: float, near: float, far: float + ) -> np.ndarray: + """Builds an orthographic projection matrix.""" + ref = self._ortho_ref_distance + if ref is None: + ref = np.linalg.norm(self.target - self.position) + effective_distance = ref / self._ortho_zoom + fov_y_rad = math.radians(45.0) + ortho_height = effective_distance * math.tan(fov_y_rad / 2.0) * 2.0 + ortho_width = ortho_height * aspect_ratio + right, top = ortho_width / 2.0, ortho_height / 2.0 + + return np.array( + [ + [1.0 / right, 0.0, 0.0, 0.0], + [0.0, 1.0 / top, 0.0, 0.0], + [0.0, 0.0, -2.0 / (far - near), -(far + near) / (far - near)], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ) + + def pan(self, delta_x: float, delta_y: float): + """ + Moves the camera and its target sideways and up/down. + + Args: + delta_x: The horizontal change in screen coordinates. + delta_y: The vertical change in screen coordinates. + """ + distance = np.linalg.norm(self.target - self.position) + + if not self.is_perspective and self._ortho_ref_distance is not None: + pan_speed = 0.001 * self._ortho_ref_distance / self._ortho_zoom + else: + pan_speed = 0.001 * distance + + forward = self.target - self.position + forward /= distance + 1e-9 + + side = np.cross(forward, self.up) + side /= np.linalg.norm(side) + 1e-9 + + up_vec = np.cross(side, forward) + + pan_vector = (side * delta_x - up_vec * delta_y) * pan_speed + self.position += pan_vector + self.target += pan_vector + + def dolly(self, delta_z: float): + """ + Moves the camera forward or backward along its line of sight. + + In orthographic mode, adjusts the zoom factor instead of dollying + the camera, preventing the ortho clip box from shrinking and + clipping model geometry. + + Args: + delta_z: The amount to dolly (typically from a scroll wheel). + """ + if not self.is_perspective: + factor = 1.0 + delta_z * 0.1 + if factor <= 0.01: + return + new_zoom = self._ortho_zoom / factor + if new_zoom > 1000.0 or new_zoom < 0.01: + return + self._ortho_zoom = new_zoom + return + + forward = self.target - self.position + distance = np.linalg.norm(forward) + + if distance < 0.2 and delta_z < 0: + return + + zoom_amount = -delta_z * 0.1 * distance + self.position += (forward / distance) * zoom_amount + + def orbit(self, pivot: np.ndarray, axis: np.ndarray, angle: float): + """ + Orbits the camera around a pivot point. + + Args: + pivot: The 3D point to orbit around. + axis: The axis of rotation. + angle: The angle of rotation in radians. + """ + if abs(angle) < 1e-6: + return + + rot_matrix = rotation_matrix_from_axis_angle(axis, angle) + + self.position = pivot + rot_matrix @ (self.position - pivot) + self.target = pivot + rot_matrix @ (self.target - pivot) + self.up = rot_matrix @ self.up + + _VIEW_CONFIGS: ClassVar[ + dict[ViewDirection, tuple[list[float], list[float], float]] + ] = { + ViewDirection.TOP: ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0], 1.5), + ViewDirection.FRONT: ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0], 1.7), + ViewDirection.RIGHT: ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 1.7), + ViewDirection.LEFT: ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 1.7), + ViewDirection.BACK: ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0], 1.7), + ViewDirection.ISO: ([-1.0, -1.0, 1.0], [0.0, 0.0, 1.0], 1.7), + } + + def set_view( + self, + direction: ViewDirection, + world_width: float, + world_depth: float, + ): + """Configures the camera for a preset view direction (Z-up).""" + dir_raw, up_raw, dist_factor = self._VIEW_CONFIGS[direction] + center_x, center_y = world_width / 2.0, world_depth / 2.0 + max_dim = max(world_width, world_depth) + + self.target = np.array([center_x, center_y, 0.0], dtype=np.float64) + + direction_vec = np.array(dir_raw, dtype=np.float64) + direction_vec = direction_vec / np.linalg.norm(direction_vec) + + distance = max_dim * dist_factor + self.position = self.target + direction_vec * distance + + self.up = np.array(up_raw, dtype=np.float64) + self._ortho_zoom = 1.0 + self._ortho_ref_distance = distance diff --git a/rayforge/ui_gtk/sim3d/camera_controller.py b/rayforge/ui_gtk/sim3d/camera_controller.py new file mode 100644 index 000000000..ac15b47d8 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/camera_controller.py @@ -0,0 +1,470 @@ +""" +Camera + interaction controller for the 3D canvas. + +Owns the :class:`Camera` instance, the drag/scroll gesture wiring, the +orbit/pan/dolly math, view resetting, and viewport resizing. The canvas +stays a thin ``Gtk.GLArea`` that consumes the camera during rendering. +""" + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +import numpy as np +from gi.repository import Gdk, Gtk +from raygeo.geo.types import Point + +from .camera import Camera, ViewDirection, rotation_matrix_from_axis_angle +from .gl_utils import rotation_4x4 + +if TYPE_CHECKING: + from .viewport import ViewportConfig + +logger = logging.getLogger(__name__) + + +class CameraController: + """ + Manages the 3D camera and all mouse/key interactions for the canvas. + + The controller attaches its own GTK gesture/event controllers to the + widget it is given and requests redraws through ``request_render``. + """ + + def __init__( + self, + widget: Gtk.Widget, + get_viewport: Callable[[], "ViewportConfig"], + request_render: Callable[[], None], + on_key_pressed: Callable | None = None, + ): + self.camera: Camera | None = None + self._widget = widget + self._get_viewport = get_viewport + self._request_render = request_render + + # State for interactions + self._is_orbiting = False + self._is_z_rotating = False + self._last_pan_offset: Point | None = None + self._pan_anchor: np.ndarray | None = None + self._pan_start_screen: tuple[float, float] | None = None + self._rotation_pivot: np.ndarray | None = None + self._last_orbit_pos: Point | None = None + self._last_z_rotate_screen_pos: Point | None = None + + # The EventControllerScroll provides no access to the pointer + # position, so it is tracked here via a motion controller. + self._mouse_pos: tuple[float, float] | None = None + + self._setup_interactions(on_key_pressed) + + def create_camera(self, width: int, height: int) -> Camera: + """Create the camera at the given widget size and store it.""" + self.camera = Camera( + np.array([0.0, 0.0, 1.0]), + np.array([0.0, 0.0, 0.0]), + np.array([0.0, 1.0, 0.0]), + width, + height, + ) + return self.camera + + def on_resize(self, area, width: int, height: int): + """Handles the window resize event.""" + if self.camera: + self.camera.width, self.camera.height = int(width), int(height) + self._request_render() + + def get_world_coords_on_plane( + self, x: float, y: float + ) -> np.ndarray | None: + """Calculates the 3D world coordinates on the XY plane from 2D.""" + camera = self.camera + if camera is None: + return None + + ndc_x = (2.0 * x) / camera.width - 1.0 + ndc_y = 1.0 - (2.0 * y) / camera.height + + try: + inv_proj = np.linalg.inv(camera.get_projection_matrix()) + inv_view = np.linalg.inv(camera.get_view_matrix()) + except np.linalg.LinAlgError: + return None + + # Unproject two points on the near and far clip planes and use + # their difference as the ray direction. This yields converging + # rays for the perspective projection and parallel rays for the + # orthographic projection. + near_clip = np.array([ndc_x, ndc_y, -1.0, 1.0], dtype=np.float32) + far_clip = np.array([ndc_x, ndc_y, 1.0, 1.0], dtype=np.float32) + near_eye = inv_proj @ near_clip + far_eye = inv_proj @ far_clip + near_world = inv_view @ (near_eye / near_eye[3]) + far_world = inv_view @ (far_eye / far_eye[3]) + + ray_dir = far_world[:3] - near_world[:3] + norm = np.linalg.norm(ray_dir) + if norm < 1e-6: + return None + ray_dir = ray_dir / norm + ray_origin = near_world[:3] + + plane_normal = np.array([0, 0, 1], dtype=np.float64) + denom = np.dot(plane_normal, ray_dir) + if abs(denom) < 1e-6: + return None + + t = -np.dot(plane_normal, ray_origin) / denom + if t < 0: + return None + + return ray_origin + t * ray_dir + + def _setup_interactions(self, on_key_pressed: Callable | None = None): + """Connects GTK4 gesture and event controllers for interaction.""" + # Middle mouse drag for Pan/Orbit + drag_middle = Gtk.GestureDrag.new() + drag_middle.set_button(Gdk.BUTTON_MIDDLE) + drag_middle.connect("drag-begin", self.on_drag_begin) + drag_middle.connect("drag-update", self.on_drag_update) + drag_middle.connect("drag-end", self.on_drag_end) + self._widget.add_controller(drag_middle) + + # Left mouse drag for Z-axis rotation + drag_left = Gtk.GestureDrag.new() + drag_left.set_button(Gdk.BUTTON_PRIMARY) + drag_left.connect("drag-begin", self.on_z_rotate_begin) + drag_left.connect("drag-update", self.on_z_rotate_update) + drag_left.connect("drag-end", self.on_z_rotate_end) + self._widget.add_controller(drag_left) + + scroll = Gtk.EventControllerScroll.new( + Gtk.EventControllerScrollFlags.VERTICAL + ) + scroll.connect("scroll", self.on_scroll) + self._widget.add_controller(scroll) + + # Track the pointer position for zooming towards the cursor. + motion = Gtk.EventControllerMotion.new() + motion.connect("motion", self.on_motion) + motion.connect("leave", self.on_motion_leave) + self._widget.add_controller(motion) + + # Grab keyboard focus when the canvas is clicked so that the + # EventControllerKey actually receives key events. Without this, + # the previously-focused widget keeps consuming them. + click = Gtk.GestureClick.new() + click.set_button(0) + click.connect("pressed", self._on_click_focus) + self._widget.add_controller(click) + + key_controller = Gtk.EventControllerKey.new() + if on_key_pressed is not None: + key_controller.connect("key-pressed", on_key_pressed) + self._widget.add_controller(key_controller) + + def _on_click_focus(self, gesture, n_press, x, y): + """Grab keyboard focus so the canvas receives key events.""" + self._widget.grab_focus() + + def _clear_drag_state(self): + """Resets all state variables related to any drag operation.""" + self._is_orbiting = False + self._is_z_rotating = False + self._last_pan_offset = None + self._pan_anchor = None + self._pan_start_screen = None + self._rotation_pivot = None + self._last_orbit_pos = None + self._last_z_rotate_screen_pos = None + + def reset_view(self, direction: ViewDirection): + """Resets the camera to the specified preset view.""" + if not self.camera: + return + logger.info("Resetting to %s view.", direction.value) + viewport = self._get_viewport() + self.camera.set_view( + direction, + viewport.width_mm, + viewport.depth_mm, + ) + self._clear_drag_state() + self._request_render() + + def on_drag_begin(self, gesture, x: float, y: float): + """Handles the start of a middle-mouse-button drag.""" + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + state = gesture.get_current_event_state() + is_shift = bool(state & Gdk.ModifierType.SHIFT_MASK) + + if not is_shift and self.camera: + # Orbit around the point on the floor plane under the cursor. + self._rotation_pivot = self.get_world_coords_on_plane(x, y) + if self._rotation_pivot is None: + self._rotation_pivot = self.camera.target.copy() + + self._last_orbit_pos = None + self._is_orbiting = True + else: + self._pan_anchor = self.get_world_coords_on_plane(x, y) + self._pan_start_screen = x, y + self._last_pan_offset = 0.0, 0.0 + self._is_orbiting = False + + def on_drag_update(self, gesture, offset_x: float, offset_y: float): + """Handles updates during a drag operation (panning or orbiting).""" + if not self.camera: + return + camera = self.camera + + state = gesture.get_current_event_state() + is_shift = bool(state & Gdk.ModifierType.SHIFT_MASK) + + if is_shift: + self._update_pan(camera, offset_x, offset_y) + self._request_render() + return + + delta = self._get_orbit_delta(gesture) + if delta is not None and self._rotation_pivot is not None: + self._apply_orbit(camera, self._rotation_pivot, *delta) + self._request_render() + + def _update_pan(self, camera: Camera, offset_x: float, offset_y: float): + """Pans so the floor-plane point under the cursor tracks the mouse. + + The world point on the XY plane that was grabbed at drag start is + kept pinned under the cursor, so the plane moves 1:1 with the mouse + in every view. Falls back to pixel-based panning when the ray to + the cursor does not hit the plane. + """ + if self._pan_anchor is not None and self._pan_start_screen is not None: + start_x, start_y = self._pan_start_screen + current = self.get_world_coords_on_plane( + start_x + offset_x, start_y + offset_y + ) + if current is not None: + shift = self._pan_anchor - current + camera.position += shift + camera.target += shift + return + + if self._last_pan_offset is None: + self._last_pan_offset = 0.0, 0.0 + dx = offset_x - self._last_pan_offset[0] + dy = offset_y - self._last_pan_offset[1] + camera.pan(-dx, -dy) + self._last_pan_offset = offset_x, offset_y + + def _get_orbit_delta(self, gesture) -> tuple[float, float] | None: + """Returns the (dx, dy) since the last orbit step, or None.""" + if not self._is_orbiting or self._rotation_pivot is None: + return None + + event = gesture.get_last_event() + if not event: + return None + _, x_curr, y_curr = event.get_position() + + if self._last_orbit_pos is None: + self._last_orbit_pos = x_curr, y_curr + return None + + prev_x, prev_y = self._last_orbit_pos + self._last_orbit_pos = x_curr, y_curr + return x_curr - prev_x, y_curr - prev_y + + def _apply_orbit( + self, + camera: Camera, + pivot: np.ndarray, + delta_x: float, + delta_y: float, + ): + """Orbits the camera around the given pivot by the drag delta.""" + sensitivity = 0.004 + + if camera.is_perspective: + self._orbit_perspective( + camera, pivot, delta_x, delta_y, sensitivity + ) + else: + self._orbit_orthographic( + camera, pivot, delta_x, delta_y, sensitivity + ) + + def _orbit_perspective( + self, + camera: Camera, + pivot: np.ndarray, + delta_x: float, + delta_y: float, + sensitivity: float, + ): + """Perspective orbit (Turntable Style).""" + if abs(delta_x) > 1e-6: + axis_yaw = np.array([0, 1, 0], dtype=np.float64) + camera.orbit(pivot, axis_yaw, -delta_x * sensitivity) + if abs(delta_y) > 1e-6: + forward = camera.target - camera.position + axis_pitch = np.cross(forward, camera.up) + if np.linalg.norm(axis_pitch) > 1e-6: + camera.orbit(pivot, axis_pitch, -delta_y * sensitivity) + + def _orbit_orthographic( + self, + camera: Camera, + pivot: np.ndarray, + delta_x: float, + delta_y: float, + sensitivity: float, + ): + """Orthographic orbit (Z-Up Turntable).""" + yaw_angle = -delta_x * sensitivity + pitch_angle = -delta_y * sensitivity + + # Yaw Rotation (around World Z axis) + if abs(yaw_angle) > 1e-6: + axis_yaw = np.array([0.0, 0.0, 1.0], dtype=np.float64) + rot_yaw = rotation_4x4(axis_yaw, yaw_angle)[:3, :3] + # Apply to position and target vectors + camera.position = pivot + rot_yaw @ (camera.position - pivot) + camera.target = pivot + rot_yaw @ (camera.target - pivot) + camera.up = rot_yaw @ camera.up + + # Pitch Rotation (around Camera's local right axis) + if abs(pitch_angle) > 1e-6: + self._apply_ortho_pitch(camera, pivot, pitch_angle) + + def _apply_ortho_pitch( + self, camera: Camera, pivot: np.ndarray, pitch_angle: float + ): + """Applies a single pitch step around the camera's local right axis.""" + # Get camera's state *after* the yaw rotation + forward_vec = camera.target - camera.position + world_z_axis = np.array([0.0, 0.0, 1.0]) + + # Gimbal Lock Prevention + norm_fwd = np.linalg.norm(forward_vec) + if norm_fwd > 1e-6: + dot_prod = np.dot(forward_vec / norm_fwd, world_z_axis) + # Stop if looking down and trying to pitch more down + if ( + dot_prod < -0.999 + and pitch_angle < 0 + or dot_prod > 0.999 + and pitch_angle > 0 + ): + pitch_angle = 0.0 + + if abs(pitch_angle) > 1e-6: + axis_pitch = np.cross(forward_vec, camera.up) + if np.linalg.norm(axis_pitch) > 1e-6: + rot_pitch = rotation_matrix_from_axis_angle( + axis_pitch, pitch_angle + ) + # Apply to position and target vectors + camera.position = pivot + rot_pitch @ (camera.position - pivot) + camera.target = pivot + rot_pitch @ (camera.target - pivot) + camera.up = rot_pitch @ camera.up + + def on_drag_end(self, gesture, offset_x, offset_y): + """Handles the end of a drag operation.""" + self._clear_drag_state() + self._request_render() + + def on_z_rotate_begin(self, gesture, x: float, y: float): + """ + Handles the start of a left-mouse-button drag for Z-axis rotation. + """ + if not self.camera: + return + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + self._is_z_rotating = True + self._last_z_rotate_screen_pos = None # Will be set on first update + + def on_z_rotate_update(self, gesture, offset_x: float, offset_y: float): + """Handles updates during a Z-axis rotation drag (linear motion).""" + if not self.camera or not self._is_z_rotating: + return + + # Initialize the last position with the current offset if it's None. + # This handles the start of the drag smoothly. + if self._last_z_rotate_screen_pos is None: + self._last_z_rotate_screen_pos = (0.0, 0.0) + + prev_off_x, _ = self._last_z_rotate_screen_pos + + # Calculate delta from the last frame's offset + delta_x = offset_x - prev_off_x + + # Update the stored offset for the next frame + self._last_z_rotate_screen_pos = (offset_x, offset_y) + + # Apply rotation. Dragging left/right rotates around world Z. + # Sensitivity: Radians per pixel. + sensitivity = 0.01 + angle = -delta_x * sensitivity + + axis_z = np.array([0, 0, 1], dtype=np.float64) + pivot_world = self.camera.target + self.camera.orbit(pivot_world, axis_z, angle) + self._request_render() + + def on_z_rotate_end(self, gesture, offset_x, offset_y): + """Handles the end of a Z-axis rotation drag.""" + self._clear_drag_state() + self._request_render() + + def on_motion(self, controller, x: float, y: float): + """Stores the current pointer position for scroll zooming.""" + self._mouse_pos = x, y + + def on_motion_leave(self, controller): + """Clears the stored pointer position when the pointer leaves.""" + self._mouse_pos = None + + def on_scroll(self, controller, dx, dy): + """Handles the mouse scroll wheel for zooming. + + Zooms towards the point on the floor plane under the mouse cursor: + the camera is dollied and then translated so that the plane point + under the cursor stays under the cursor. + """ + if not self.camera: + return + + if self._mouse_pos is not None: + self.zoom_towards_point(*self._mouse_pos, dy) + else: + self.camera.dolly(dy) + self._request_render() + + def zoom_towards_point(self, x: float, y: float, dy: float) -> None: + """ + Dollies the camera keeping the floor plane point under the cursor. + + The plane point under the screen position (x, y) is anchored before + the dolly and the camera is translated afterwards so that the same + point stays under (x, y) at the new zoom level. + + Args: + x: The screen x coordinate of the anchor point. + y: The screen y coordinate of the anchor point. + dy: The scroll delta passed to :meth:`Camera.dolly`. + """ + camera = self.camera + if camera is None: + return + + anchor = self.get_world_coords_on_plane(x, y) + camera.dolly(dy) + if anchor is not None: + follow = self.get_world_coords_on_plane(x, y) + if follow is not None: + shift = anchor - follow + camera.position += shift + camera.target += shift diff --git a/rayforge/ui_gtk/sim3d/canvas3d.py b/rayforge/ui_gtk/sim3d/canvas3d.py new file mode 100644 index 000000000..4b2161032 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/canvas3d.py @@ -0,0 +1,380 @@ +import logging +import math +import time +from typing import TYPE_CHECKING + +from gi.repository import Gdk, Gtk, Pango +from OpenGL import GL +from OpenGL.error import GLError + +from ...context import RayforgeContext +from ...pipeline.pipeline import Pipeline +from ...shared.units.formatter import ( + get_default_grid_step_mm, + get_preferred_unit_factor, +) +from .camera import ViewDirection +from .camera_controller import CameraController +from .chunked_upload import ChunkedUploadController +from .doc_signals import DocSignalHub +from .render_context import FrameInputs, RenderContext +from .renderer.scene_renderer import SceneRenderer +from .scene_presenter import ScenePresenter +from .theme_resolver import ThemeResolver +from .viewport import ViewportConfig + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...doceditor.editor import DocEditor + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Canvas3D(Gtk.GLArea): + """A GTK Widget for rendering a 3D scene with OpenGL.""" + + def __init__( + self, + context: RayforgeContext, + doc_editor: "DocEditor", + viewport: ViewportConfig, + **kwargs, + ): + super().__init__(**kwargs) + # Render only on demand (queue_render), not continuously: GTK 4.22 + # defaults auto_render to True, which renders every frame clock + # tick and starves lower-priority idle callbacks (task when_done + # handlers) while wasting GPU work. + self.set_auto_render(False) + self._context = context + self._doc_editor = doc_editor + self._viewport = viewport + + self._scene = SceneRenderer() + self._ctx = RenderContext() + self._show_travel_moves = False + self._show_nogo_zones = True + self._show_models = True + self._show_grid = True + self._gl_initialized = False + self._scene_gl_dirty = False + + self._theme_resolver = ThemeResolver( + self, + scene=self._scene, + get_machine=lambda: self._context.machine, + get_gl_initialized=lambda: self._gl_initialized, + request_render=self.queue_render, + ) + + self._upload_ctrl = ChunkedUploadController( + self._scene, + get_artifact=lambda: self._presenter.compiled_artifact, + get_show_travel_moves=lambda: self._show_travel_moves, + get_gl_initialized=lambda: self._gl_initialized, + make_current=self.make_current, + request_render=self.queue_render, + on_luts_required=self._theme_resolver.update_renderer_color_luts, + ) + + self._presenter = ScenePresenter( + self._context, + self._doc_editor, + self._scene, + theme_resolver=self._theme_resolver, + get_viewport=self._get_viewport, + get_gl_initialized=lambda: self._gl_initialized, + get_show_travel_moves=lambda: self._show_travel_moves, + get_camera_available=lambda: self._cam_ctrl.camera is not None, + make_current=self.make_current, + mark_scene_dirty=self._mark_scene_dirty, + mark_artifact_dirty=lambda: ( + self._upload_ctrl.mark_artifact_dirty() + ), + reset_view=self.reset_view, + request_render=self.queue_render, + upload_complete=self._upload_ctrl.upload_complete, + ) + + self._cam_ctrl = CameraController( + self, + get_viewport=self._get_viewport, + request_render=self.queue_render, + on_key_pressed=self._on_key_pressed, + ) + + self._doc_hub = DocSignalHub( + self._context, + self._doc_editor, + set_viewport=self._set_viewport, + mark_scene_dirty=self._mark_scene_dirty, + request_render=self.queue_render, + refresh_scene=self._presenter.update_scene_from_doc, + get_gl_initialized=lambda: self._gl_initialized, + ) + + self.set_has_depth_buffer(True) + self.set_focusable(True) + self.connect("realize", self.on_realize) + self.connect("unrealize", self.on_unrealize) + self.connect("render", self.on_render) + self.connect("resize", self._cam_ctrl.on_resize) + self.connect("notify::style", self._theme_resolver.on_style_changed) + + self._doc_hub.connect() + + self._context.config.changed.connect(self._on_config_changed) + + def set_machine(self, viewport: ViewportConfig | None = None): + self._doc_hub.set_machine(viewport) + + def has_stale_job(self) -> bool: + """True if the cached job handle is from an older generation.""" + return self._presenter.has_stale_job() + + @property + def doc(self) -> "Doc": + """Returns the current document from the editor.""" + return self._doc_hub.doc + + @property + def pipeline(self) -> "Pipeline": + """Returns the current pipeline from the editor.""" + return self._doc_hub.pipeline + + def reset_view(self, direction: ViewDirection): + """Resets the camera to the specified preset view.""" + self._cam_ctrl.reset_view(direction) + + def set_perspective(self, enabled: bool) -> bool: + """Toggles the 3D camera between perspective and orthographic. + + Returns True if the camera was available and updated. + """ + camera = self._cam_ctrl.camera + if camera is None: + return False + camera.is_perspective = enabled + self.queue_render() + return True + + def set_playback_overlay(self, overlay): + """Attach the playback overlay widget and bind it to this canvas.""" + self._presenter.set_playback_overlay(overlay) + overlay.set_canvas(self) + + def _on_config_changed(self, sender, **kwargs): + """Updates renderer color LUTs when config settings change.""" + if not self._gl_initialized: + return + axis_renderer = self._scene.axis_renderer + if axis_renderer: + new_step = get_default_grid_step_mm() + if not math.isclose(axis_renderer.grid_size_mm, new_step): + self.make_current() + axis_renderer.set_grid_size(new_step) + axis_renderer.set_grid_unit_factor( + get_preferred_unit_factor("length") + ) + self._theme_resolver.update_renderer_color_luts() + self.queue_render() + + def on_realize(self, area) -> None: + """Called when the GLArea is ready to have its context made current.""" + logger.info("GLArea realized.") + + self._cam_ctrl.create_camera(self.get_width(), self.get_height()) + + self._init_gl_resources() + self._theme_resolver.mark_dirty() + + self.reset_view(ViewDirection.ISO) + self._theme_resolver.update_theme_and_colors() + self._presenter.connect() + + if self._presenter.job_handle is None and self.pipeline: + self._presenter.job_handle = self.pipeline.last_completed_handle + + self._presenter.update_scene_from_doc() + + def _on_key_pressed(self, controller, keyval, keycode, state): + overlay = self._presenter.playback_overlay + if keyval == Gdk.KEY_space and overlay: + overlay.handle_space() + return True + return False + + def on_unrealize(self, area) -> None: + """Called before the GLArea is unrealized.""" + logger.info("GLArea unrealized. Cleaning up GL resources.") + self._presenter.disconnect() + self._doc_hub.disconnect() + self._context.config.changed.disconnect(self._on_config_changed) + self._upload_ctrl.cancel() + try: + self.make_current() + self._presenter.cancel_scene_preparation() + self._scene.cleanup() + except GLError as e: + logger.debug("Error during GL cleanup on unrealize: %s", e) + finally: + self._gl_initialized = False + logger.debug("on_unrealize: finished.") + + def _init_gl_resources(self) -> None: + """Initializes OpenGL state, shaders, and renderer objects.""" + try: + self.make_current() + GL.glEnable(GL.GL_DEPTH_TEST) + GL.glDepthFunc(GL.GL_LEQUAL) + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + + # Get the theme's default font family from GTK + font_family = "sans-serif" # A safe fallback + settings = Gtk.Settings.get_default() + if settings: + font_name_str = settings.get_property("gtk-font-name") + logger.debug(f"Gtk uses font {font_name_str}") + if font_name_str: + # Use Pango to reliably parse the string + # (e.g., "Ubuntu Sans") + font_desc = Pango.FontDescription.from_string( + font_name_str + ) + font_family = font_desc.get_family() or "sans-serif" + logger.debug(f"Pango normalized font to {font_family}") + + self._scene.set_viewport(self._viewport) + self._scene.set_font_family(font_family) + self._scene.init_gl() + + self._gl_initialized = True + except Exception: + logger.exception("OpenGL Initialization Error") + self._gl_initialized = False + + def _get_viewport(self) -> ViewportConfig: + """Returns the current viewport configuration.""" + return self._viewport + + def _set_viewport(self, viewport: ViewportConfig): + """Sets the viewport configuration (from the signal hub).""" + self._viewport = viewport + + def _mark_scene_dirty(self): + """Mark the GL scene as needing a rebuild on the next frame.""" + self._scene_gl_dirty = True + + def _process_pending_gl_updates(self): + if self._scene_gl_dirty: + self._scene_gl_dirty = False + if self._scene.update_axis_from_viewport(self._viewport): + self._theme_resolver.mark_dirty() + self.make_current() + self._scene.update_cylinders_from_doc( + self.doc, self._viewport, self._context.machine + ) + self._scene.update_models_from_context( + self._context, self._context.machine + ) + machine = self._context.machine + if self._scene.zone_renderer and machine: + self._scene.update_zones_from_machine(machine) + self._upload_ctrl.process_pending() + + def on_render(self, area, ctx) -> bool: + """The main rendering loop.""" + if not self._cam_ctrl.camera or not self._gl_initialized: + return False + + self._process_pending_gl_updates() + + if self._theme_resolver.theme_is_dirty: + self._theme_resolver.update_theme_and_colors() + + if not self._theme_resolver.color_set: + return False + + t_render_start = time.perf_counter() + try: + GL.glViewport( + 0, 0, self._cam_ctrl.camera.width, self._cam_ctrl.camera.height + ) + GL.glClear( + GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT # type: ignore + ) + + frame = FrameInputs( + camera=self._cam_ctrl.camera, + viewport=self._viewport, + color_set=self._theme_resolver.color_set, + op_player=self._presenter.op_player, + machine=self._context.machine, + playback_assembly=self._presenter.playback_assembly, + compiled_artifact=self._presenter.compiled_artifact, + doc=self.doc, + cylinder_transform=self._scene.cylinder_transform, + had_rotary_layers=self._scene.had_rotary_layers, + show_travel_moves=self._show_travel_moves, + show_grid=self._show_grid, + show_nogo_zones=self._show_nogo_zones, + show_models=self._show_models, + ) + self._ctx.update(frame) + self._scene.prepare(self._ctx) + self._scene.render(self._ctx, None) + + except Exception: + logger.exception("OpenGL Render Error") + return False + + t_render_elapsed = (time.perf_counter() - t_render_start) * 1000 + if t_render_elapsed > 16: + logger.debug(f"on_render took {t_render_elapsed:.1f}ms") + return True + + def set_show_travel_moves(self, visible: bool): + """Sets the visibility of travel moves in the 3D view.""" + if self._show_travel_moves == visible: + return + self._show_travel_moves = visible + self._presenter.update_renderers_from_artifact() + + def set_show_nogo_zones(self, visible: bool): + if self._show_nogo_zones == visible: + return + self._show_nogo_zones = visible + self.queue_render() + + def set_show_models(self, visible: bool): + if self._show_models == visible: + return + self._show_models = visible + self.queue_render() + + def set_show_grid(self, visible: bool): + if self._show_grid == visible: + return + self._show_grid = visible + self.queue_render() + + def update_scene_from_doc(self): + """Refreshes the 3D scene content from the document.""" + self._presenter.update_scene_from_doc() + + def scene_is_ready(self) -> bool: + """True when the compiled scene is uploaded and rendered. + + Tooling (e.g. the screenshot harness) polls this to wait for the + 3D view to reach a stable, rendered state. + """ + if not self._gl_initialized: + return False + if self._presenter.compiled_artifact is None: + return False + if self._upload_ctrl.is_dirty: + return False + task = self._presenter.scene_preparation_task + return task is None or task.is_final() diff --git a/rayforge/ui_gtk/sim3d/chunked_upload.py b/rayforge/ui_gtk/sim3d/chunked_upload.py new file mode 100644 index 000000000..e55ccf8d5 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/chunked_upload.py @@ -0,0 +1,165 @@ +""" +Chunked artifact upload controller for the 3D canvas. + +Owns the chunked-upload state machine: when the compiled scene artifact +becomes GL-dirty, the controller prepares the per-layer upload items and +steps through them one at a time so a frame is never blocked uploading a +whole artifact. CPU-bound item preparation (vertex decompression and +concatenation) runs in a worker thread; only the actual GL uploads run +on the main thread. It also tracks the pending idle source so it can be +cancelled on teardown. + +Emits ``upload_complete`` once every item has been processed, so the +presenter can build playback after the fresh layer groups exist. +""" + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from blinker import Signal +from gi.repository import GLib + +from ...shared.tasker import Task, task_mgr + +if TYPE_CHECKING: + from ...simulator.scene3d import CompiledSceneArtifact + from .renderer.scene_renderer import SceneRenderer, UploadItem + +logger = logging.getLogger(__name__) + + +@dataclass +class _UploadState: + """Progress of a chunked upload in flight.""" + + items: list["UploadItem"] + index: int + + +class ChunkedUploadController: + """ + Steps through per-layer vertex/texture uploads on idle callbacks. + + ``_artifact_gl_dirty`` and ``_upload_state`` track whether the compiled + artifact needs uploading and how far the chunked upload has progressed. + ``process_pending`` is called each frame and starts a new chunked upload + when the artifact is dirty. + """ + + def __init__( + self, + scene: "SceneRenderer", + get_artifact: Callable[[], Optional["CompiledSceneArtifact"]], + get_show_travel_moves: Callable[[], bool], + get_gl_initialized: Callable[[], bool], + make_current: Callable[[], None], + request_render: Callable[[], None], + on_luts_required: Callable[[], None], + ): + self.upload_complete = Signal() + self._scene = scene + self._get_artifact = get_artifact + self._get_show_travel_moves = get_show_travel_moves + self._get_gl_initialized = get_gl_initialized + self._make_current = make_current + self._request_render = request_render + self._on_luts_required = on_luts_required + + self._artifact_gl_dirty = False + self._upload_state: _UploadState | None = None + self._idle_source_id: int | None = None + + def mark_artifact_dirty(self): + """Mark the compiled artifact as needing a (re)upload.""" + self._artifact_gl_dirty = True + + @property + def is_dirty(self) -> bool: + """True while a compiled artifact upload is still pending.""" + return self._artifact_gl_dirty + + def process_pending(self): + """Start a chunked upload when the artifact is GL-dirty.""" + if self._artifact_gl_dirty: + self._artifact_gl_dirty = False + self.start() + + def cancel(self): + """Cancel any pending idle callback and reset upload state.""" + if self._idle_source_id is not None: + GLib.source_remove(self._idle_source_id) + self._idle_source_id = None + self._upload_state = None + self._artifact_gl_dirty = False + + def start(self): + artifact = self._get_artifact() + if not artifact: + self._scene.clear_layers() + self._request_render() + return + + if not self._get_gl_initialized(): + return + + self._make_current() + + upload_items = self._scene.prepare_chunked_upload( + artifact, self._get_show_travel_moves() + ) + + # Upload the power colour LUTs before any vertex data. The chunked + # upload runs on idle callbacks, which can be pre-empted by a + # redraw between items; a redraw that renders powered lines against + # an uninitialised LUT would draw them at full brightness. This must + # run after prepare_chunked_upload so the fresh renderers get it. + self._on_luts_required() + + self._upload_state = _UploadState(items=upload_items, index=0) + self._idle_source_id = GLib.idle_add(self._step) + + def _step(self) -> bool: + self._idle_source_id = None + if self._upload_state is None: + return False + + state = self._upload_state + if state.index >= len(state.items): + self._upload_state = None + self.upload_complete.send(self) + self._request_render() + return False + + item = state.items[state.index] + state.index += 1 + + # Decompress/concat the vertex data in a worker thread; the GL + # upload runs on the main thread via the when_done callback (the + # GL context is only current there). + task_mgr.run_thread( + item.prepare, + key=(id(self), "prepare-chunk-upload", state.index), + when_done=lambda task: self._on_item_prepared(state, task), + ) + return False + + def _on_item_prepared(self, state: _UploadState, task: Task) -> None: + """Uploads an item after its worker-thread preparation finished.""" + if self._upload_state is not state: + return + if task.get_status() != "completed": + self._upload_state = None + return + + item = state.items[state.index - 1] + try: + self._make_current() + self._scene.upload_chunk(item) + except Exception: + logger.exception("[CANVAS3D] Error during chunked upload") + self._upload_state = None + return + + self._idle_source_id = GLib.idle_add(self._step) diff --git a/rayforge/ui_gtk/sim3d/doc_signals.py b/rayforge/ui_gtk/sim3d/doc_signals.py new file mode 100644 index 000000000..29d548c3b --- /dev/null +++ b/rayforge/ui_gtk/sim3d/doc_signals.py @@ -0,0 +1,160 @@ +""" +Document/model signal hub for the 3D canvas. + +Owns the machine and document signal subscriptions, the viewport (re)build +on WCS/layer changes, and the active-layer WCS tracking. The canvas asks +the hub to refresh the viewport through callbacks and keeps scene +compilation out of this module. +""" + +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...doceditor.editor import DocEditor + from ...machine.models.machine import Machine + from .viewport import ViewportConfig + + +class DocSignalHub: + """ + Manages the machine/doc signal wiring and WCS viewport math. + + Signal handlers that need scene state are provided as callbacks so the + hub stays a pure subscription/viewport module. + """ + + def __init__( + self, + context, + doc_editor: "DocEditor", + *, + set_viewport: Callable[["ViewportConfig"], None], + mark_scene_dirty: Callable[[], None], + request_render: Callable[[], None], + refresh_scene: Callable[[], None], + get_gl_initialized: Callable[[], bool], + ): + self._context = context + self._doc_editor = doc_editor + self._set_viewport = set_viewport + self._mark_scene_dirty = mark_scene_dirty + self._request_render = request_render + self._refresh_scene = refresh_scene + self._get_gl_initialized = get_gl_initialized + + self._active_layer_wcs_conn = None + + @property + def doc(self) -> "Doc": + """Returns the current document from the editor.""" + return self._doc_editor.doc + + @property + def pipeline(self): + """Returns the current pipeline from the editor.""" + return self._doc_editor.pipeline + + @property + def rotary_enabled(self) -> bool: + """Returns True if the active layer has rotary mode enabled.""" + if self.doc and self.doc.active_layer: + return self.doc.active_layer.rotary_enabled + return False + + def connect(self): + """Subscribe to the machine and doc signals.""" + machine = self._context.machine + if machine: + machine.wcs_updated.connect(self._on_wcs_updated) + machine.changed.connect(self._on_wcs_updated) + self._on_wcs_updated(machine) + + self.doc.active_layer_changed.connect(self._on_active_layer_changed) + self._connect_active_layer_wcs() + + def disconnect(self): + """Unsubscribe from the machine and doc signals.""" + self._disconnect_active_layer_wcs() + self.doc.active_layer_changed.disconnect(self._on_active_layer_changed) + + machine = self._context.machine + if machine: + machine.wcs_updated.disconnect(self._on_wcs_updated) + machine.changed.disconnect(self._on_wcs_updated) + + def set_machine(self, viewport: Optional["ViewportConfig"] = None): + """Reconnect the machine signals and refresh the viewport.""" + old_machine = self._context.machine + if old_machine: + old_machine.wcs_updated.disconnect(self._on_wcs_updated) + old_machine.changed.disconnect(self._on_wcs_updated) + + if viewport is None: + from .viewport import ViewportConfig + + viewport = ViewportConfig.default() + + self._set_viewport(viewport) + + new_machine = self._context.machine + if new_machine: + new_machine.wcs_updated.connect(self._on_wcs_updated) + new_machine.changed.connect(self._on_wcs_updated) + self._on_wcs_updated(new_machine) + + if self._get_gl_initialized(): + self._refresh_scene() + + def _on_wcs_updated(self, machine: "Machine", **kwargs): + """Handler for when the machine's WCS state changes.""" + if machine: + self._set_viewport(self._build_viewport(machine)) + self._mark_scene_dirty() + self._request_render() + + def _get_active_layer_wcs_offset(self, machine: "Machine"): + """Returns the WCS offset for the active layer.""" + layer = self.doc.active_layer if self.doc else None + if layer and layer.wcs: + return machine.get_wcs_offset(layer.wcs) + return machine.get_active_wcs_offset() + + def _build_viewport(self, machine: "Machine") -> "ViewportConfig": + """Build a ViewportConfig using the active layer's WCS.""" + from .viewport import ViewportConfig + + return ViewportConfig.from_machine_with_wcs( + machine, self._get_active_layer_wcs_offset(machine) + ) + + def _connect_active_layer_wcs(self): + """Connect to the active layer's updated signal for WCS changes.""" + self._disconnect_active_layer_wcs() + + layer = self.doc.active_layer + if layer: + self._active_layer_wcs_conn = layer.updated.connect( + self._on_active_layer_updated + ) + + def _disconnect_active_layer_wcs(self): + """Disconnect the active layer's updated signal.""" + if self._active_layer_wcs_conn is not None: + old_layer = self.doc.active_layer + old_layer.updated.disconnect(self._active_layer_wcs_conn) + self._active_layer_wcs_conn = None + + def _on_active_layer_changed(self, sender): + """Reconnect WCS tracking to the new active layer.""" + self._connect_active_layer_wcs() + machine = self._context.machine + if machine: + self._on_wcs_updated(machine) + + def _on_active_layer_updated(self, layer): + """Handle property changes on the active layer, including WCS.""" + machine = self._context.machine + if machine: + self._on_wcs_updated(machine) diff --git a/rayforge/ui_gtk/sim3d/gl_state.py b/rayforge/ui_gtk/sim3d/gl_state.py new file mode 100644 index 000000000..bd91d8945 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/gl_state.py @@ -0,0 +1,162 @@ +""" +OpenGL pipeline-state save/restore context managers. + +The :func:`gl_state` context manager brackets a renderer's ``render`` +body so that state mutations — depth test, blend, blend function, +depth mask, depth function, line width, pixel unpack alignment, and +texture bindings — are restored on exit, including the exceptional +path. + +The :func:`render_pass` context manager combines ``gl_state`` with the +``Shader`` context-manager protocol (``with shader:`` snapshots and +restores that shader's uniforms) for a single renderer draw call. + +Usage:: + + with render_pass(self.main_shader): + self.zone_renderer.render(ctx, self.shader_set) +""" + +from collections.abc import Generator +from contextlib import ExitStack, contextmanager + +import numpy as np +from OpenGL import GL + +from .shader.base import Shader + + +def _get_int(name: int) -> int: + value = GL.glGetIntegerv(name) + if value is None: + return 0 + flat = np.asarray(value).reshape(-1) + if flat.size == 0: + return 0 + return int(flat.item(0)) + + +def _get_float(name: int) -> float: + value = GL.glGetFloatv(name) + if value is None: + return 0.0 + flat = np.asarray(value).reshape(-1) + if flat.size == 0: + return 0.0 + return float(flat.item(0)) + + +def _is_enabled(name: int) -> bool: + return bool(GL.glIsEnabled(name)) + + +_TEXTURE_UNITS = (GL.GL_TEXTURE0, GL.GL_TEXTURE1) + + +@contextmanager +def gl_state( + *, + save_depth_test: bool = True, + save_blend: bool = True, + save_depth_mask: bool = True, + save_depth_func: bool = True, + save_line_width: bool = True, + save_unpack_alignment: bool = True, + save_texture_bindings: bool = True, +) -> Generator[None, None, None]: + """ + Snapshot a set of GL pipeline states on entry, restore on exit. + + Each ``save_*`` flag toggles whether a given state is snapshotted. + Renderers that are known not to touch a state can skip its + save/restore to avoid extra GL queries. + """ + snap_depth_test: bool | None = None + snap_blend: bool | None = None + snap_blend_src: int | None = None + snap_blend_dst: int | None = None + snap_depth_mask: bool | None = None + snap_depth_func: int | None = None + snap_line_width: float | None = None + snap_unpack_alignment: int | None = None + snap_active_texture: int | None = None + snap_texture_bindings: dict = {} + + try: + if save_depth_test: + snap_depth_test = _is_enabled(GL.GL_DEPTH_TEST) + if save_blend: + snap_blend = _is_enabled(GL.GL_BLEND) + snap_blend_src = _get_int(GL.GL_BLEND_SRC_RGB) + snap_blend_dst = _get_int(GL.GL_BLEND_DST_RGB) + if save_depth_mask: + snap_depth_mask = bool(_get_int(GL.GL_DEPTH_WRITEMASK)) + if save_depth_func: + snap_depth_func = _get_int(GL.GL_DEPTH_FUNC) + if save_line_width: + snap_line_width = _get_float(GL.GL_LINE_WIDTH) + if save_unpack_alignment: + snap_unpack_alignment = _get_int(GL.GL_UNPACK_ALIGNMENT) + if save_texture_bindings: + snap_active_texture = _get_int(GL.GL_ACTIVE_TEXTURE) + for unit in _TEXTURE_UNITS: + GL.glActiveTexture(unit) + snap_texture_bindings[unit] = _get_int( + GL.GL_TEXTURE_BINDING_2D + ) + if snap_active_texture is not None: + GL.glActiveTexture(snap_active_texture) + yield + finally: + if snap_depth_test is not None: + if snap_depth_test: + GL.glEnable(GL.GL_DEPTH_TEST) + else: + GL.glDisable(GL.GL_DEPTH_TEST) + if snap_blend is not None: + if snap_blend: + GL.glEnable(GL.GL_BLEND) + else: + GL.glDisable(GL.GL_BLEND) + if snap_blend_src is not None and snap_blend_dst is not None: + GL.glBlendFunc(snap_blend_src, snap_blend_dst) + if snap_depth_mask is not None: + GL.glDepthMask(GL.GL_TRUE if snap_depth_mask else GL.GL_FALSE) + if snap_depth_func is not None: + GL.glDepthFunc(snap_depth_func) + if snap_line_width is not None: + GL.glLineWidth(snap_line_width) + if snap_unpack_alignment is not None: + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, snap_unpack_alignment) + if snap_texture_bindings: + for unit, binding in snap_texture_bindings.items(): + GL.glActiveTexture(unit) + GL.glBindTexture(GL.GL_TEXTURE_2D, binding) + if snap_active_texture is not None: + GL.glActiveTexture(snap_active_texture) + + +@contextmanager +def render_pass(*shaders: Shader | None) -> Generator[None, None, None]: + """ + Bracket a single renderer draw call with state isolation. + + Saves and restores GL pipeline state and, for each shader provided, + snapshots and restores that shader's uniforms via its context-manager + protocol. A renderer wrapped by this context manager cannot leak GL + state or uniform changes to subsequent renderers, even on exception. + + Example:: + + with render_pass(self.main_shader, self.text_shader): + self.axis_renderer.render(ctx, self.shader_set) + """ + with gl_state(save_texture_bindings=False, save_line_width=False): + if not shaders: + yield + return + with ExitStack() as stack: + for shader in shaders: + if shader is not None: + stack.enter_context(shader) + yield diff --git a/rayforge/ui_gtk/sim3d/gl_utils.py b/rayforge/ui_gtk/sim3d/gl_utils.py new file mode 100644 index 000000000..06f3dbffe --- /dev/null +++ b/rayforge/ui_gtk/sim3d/gl_utils.py @@ -0,0 +1,74 @@ +""" +A collection of utility classes and functions for simplifying common +PyOpenGL tasks, such as shader compilation and buffer management. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +from OpenGL import GL + +if TYPE_CHECKING: + from .shader.base import Shader + + +def rotation_4x4(axis: np.ndarray, angle: float) -> np.ndarray: + """ + Build a 4x4 rotation matrix from an axis and angle (Rodrigues). + + Returns the identity if *angle* is near zero. + """ + if abs(angle) < 1e-9: + return np.eye(4, dtype=np.float64) + norm = np.linalg.norm(axis) + if norm < 1e-6: + return np.eye(4, dtype=np.float64) + ax = axis / norm + c = math.cos(angle) + s = math.sin(angle) + t = 1 - c + x, y, z = ax + rot = np.eye(4, dtype=np.float64) + rot[:3, :3] = [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ] + return rot + + +def set_line_width(requested: float) -> None: + try: + width_range = GL.glGetFloatv(GL.GL_ALIASED_LINE_WIDTH_RANGE) + except GL.GLError: + width_range = None + + if width_range is None or len(width_range) < 2: + GL.glLineWidth(requested) + return + + min_width = float(width_range[0]) + max_width = float(width_range[1]) + clamped = max(min_width, min(requested, max_width)) + GL.glLineWidth(clamped) + + +@dataclass +class ShaderSet: + """ + Bag of shaders passed to ``render``. + + Each renderer picks the program it needs (``main`` / ``text`` / + ``texture``) instead of receiving a bespoke positional ``shader`` + argument. Fields default to ``None`` so partial populations are + valid during migration. + """ + + main: Shader | None = None + text: Shader | None = None + texture: Shader | None = None + background: Shader | None = None diff --git a/rayforge/ui_gtk/sim3d/playback_overlay.py b/rayforge/ui_gtk/sim3d/playback_overlay.py new file mode 100644 index 000000000..d61b7c577 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/playback_overlay.py @@ -0,0 +1,484 @@ +import logging +from gettext import gettext as _ +from typing import Any, Protocol + +from blinker import Signal +from gi.repository import GLib, Gtk + +from ..icons import get_icon +from ..shared.gtk import apply_css + +logger = logging.getLogger(__name__) + + +class PlaybackPlayer(Protocol): + """Minimal OpPlayer surface required by the playback overlay.""" + + ops: Any + + @property + def current_index(self) -> int: ... + + def seek(self, index: int) -> None: ... + + def seek_to_fraction(self, fraction: float) -> None: ... + + def find_index_at_sim_time(self, t: float) -> int: ... + + def get_cumulative_time(self, idx: int) -> float: ... + + def set_sim_time(self, t: float) -> None: ... + + def playback_progress(self) -> tuple[int, float]: ... + + +SPEED_OPTIONS = [1, 2, 4, 8, 16, 32, 64] + +# Wall-clock interval between playback ticks (~60 fps, matching the +# display frame rate). The simulated clock advances by this amount per +# tick, scaled by the speed multiplier. Every tick queues a render so +# the interpolated playhead is redrawn continuously, not only when the +# slider value (command index) changes. +TICK_SECONDS = 1.0 / 60.0 + +# Wall-clock span of the step-button animation (~0.2 s). Each manual +# step plays out over this fixed number of ticks, regardless of the +# command's simulated length, so the playhead glides to the next +# command instead of jumping. +STEP_ANIMATION_TICKS = 12 +STEP_ANIMATION_SECONDS = STEP_ANIMATION_TICKS * TICK_SECONDS + +css = """ +.playback-overlay { + background-color: alpha(@theme_bg_color, 0.75); + border-radius: 6px; + padding: 3px 6px; +} +.playback-overlay scale { + min-width: 250px; +} +.speed-button { + min-width: 36px; + padding: 2px 6px; + font-size: small; +} +""" + + +class PlaybackOverlay(Gtk.Box): + """ + Playback controls (play/pause button + slider + speed button) + shown as a bar below the 3D canvas. Slider drives OpPlayer.seek(); + play button starts a ~24 fps timer that advances the playhead by + simulated machine time (speed multiplier scales real machine speed). + """ + + step_changed = Signal() + + def __init__(self, **kwargs): + super().__init__( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + **kwargs, + ) + apply_css(css) + self.add_css_class("playback-overlay") + self.set_halign(Gtk.Align.FILL) + self.set_hexpand(True) + self.set_margin_top(6) + self.set_margin_bottom(6) + + self._play_icon = get_icon("play-arrow-symbolic") + self._pause_icon = get_icon("pause-symbolic") + + self._play_button = Gtk.Button() + self._play_button.set_child(self._play_icon) + self._play_button.set_tooltip_text(_("Play simulation")) + self._play_button.set_sensitive(False) + self._play_button.set_focus_on_click(False) + self._play_button.connect("clicked", self._on_play_clicked) + self.append(self._play_button) + + self._step_back_button = Gtk.Button() + self._step_back_button.set_child(get_icon("skip-previous-symbolic")) + self._step_back_button.set_tooltip_text(_("Step backward")) + self._step_back_button.set_sensitive(False) + self._step_back_button.set_focus_on_click(False) + self._step_back_button.connect("clicked", self._on_step_back) + self.append(self._step_back_button) + + self._step_fwd_button = Gtk.Button() + self._step_fwd_button.set_child(get_icon("skip-forward-symbolic")) + self._step_fwd_button.set_tooltip_text(_("Step forward")) + self._step_fwd_button.set_sensitive(False) + self._step_fwd_button.set_focus_on_click(False) + self._step_fwd_button.connect("clicked", self._on_step_fwd) + self.append(self._step_fwd_button) + + self._slider = Gtk.Scale.new_with_range( + Gtk.Orientation.HORIZONTAL, 0, 1, 1 + ) + self._slider.set_draw_value(False) + self._slider.set_hexpand(True) + self._slider.set_size_request(300, -1) + self._slider.set_sensitive(False) + self._slider.set_focus_on_click(False) + self._slider.connect("value-changed", self._on_slider_changed) + self.append(self._slider) + + self._speed_index = 0 + self._speed_button = Gtk.Button(label=f"{SPEED_OPTIONS[0]}x") + self._speed_button.add_css_class("speed-button") + self._speed_button.set_tooltip_text(_("Playback speed")) + self._speed_button.set_focus_on_click(False) + self._speed_button.connect("clicked", self._on_speed_clicked) + self.append(self._speed_button) + + self._playing = False + self._timer_id: int | None = None + self._canvas = None + self._player: PlaybackPlayer | None = None + self._is_syncing = False + self._suppress_seek = False + self._tick_driving_slider = False + self._sim_time: float = 0.0 + self._step_timer_id: int | None = None + self._step_animating = False + self._step_ticks_remaining = 0 + self._step_start_time = 0.0 + self._step_end_time = 0.0 + self._step_target = -1 + self._step_consumed = 0 + self._pending_steps = 0 + + self.connect("destroy", self._on_destroy) + + def _on_destroy(self, widget): + self._stop_playback() + self._canvas = None + + def set_canvas(self, canvas): + """Connect this overlay to a Canvas3D instance.""" + self._canvas = canvas + + def set_player( + self, + player: PlaybackPlayer | None, + initial_index: int = 0, + ): + """Set the OpPlayer backing this overlay's slider and seek calls. + + ``initial_index`` positions the slider for a freshly built player + (typically 0). The player itself may already be seeked to the + first layer for rendering. + """ + self._cancel_step_animation() + self._player = player + if player is not None: + self.update_ops_range(len(player.ops), initial_index) + # Sync the simulated clock even when the slider does not + # move (initial_index 0 with the slider already at 0), so + # that stepping and playback start from the real position. + if not self._playing: + self._sim_time = player.get_cumulative_time(initial_index) + player.set_sim_time(self._sim_time) + else: + self.update_ops_range(0) + + @property + def command_count(self) -> int: + """Number of commands in the current playback, or 0.""" + if self._player: + return len(self._player.ops) + return 0 + + @property + def current_index(self) -> int: + """Current OpPlayer index, or -1.""" + if self._player: + return self._player.current_index + return -1 + + def seek(self, index: int): + """Seek the OpPlayer to the given command index. + + While paused, the simulated clock is resynced to the new + position so that resuming play continues from there. + """ + self._cancel_step_animation() + if self._player: + self._player.seek(index) + if not self._playing: + self._sim_time = self._player.get_cumulative_time(index) + self._player.set_sim_time(self._sim_time) + if self._canvas: + self._canvas.queue_render() + + def seek_to_fraction(self, fraction: float): + """Seek the OpPlayer by fraction (0.0 to 1.0) and sync the slider.""" + if self._player: + self._player.seek_to_fraction(fraction) + self.update_ops_range( + len(self._player.ops), + self._player.current_index, + ) + if not self._playing: + self._sim_time = self._player.get_cumulative_time( + self._player.current_index + ) + self._player.set_sim_time(self._sim_time) + if self._canvas: + self._canvas.queue_render() + + def handle_space(self): + """Toggle playback when the space key is pressed.""" + if self.can_play(): + self.toggle_playback() + + def update_ops_range(self, command_count: int, initial_index: int = 0): + """Update slider range for the given number of commands. + + initial_index sets the slider to the first layer's position + so the canvas displays the correct surface from the start. + """ + if command_count > 0: + self._slider.set_range(0, command_count - 1) + self._slider.set_value(initial_index) + self._slider.set_sensitive(True) + self._play_button.set_sensitive(True) + self._step_back_button.set_sensitive(True) + self._step_fwd_button.set_sensitive(True) + else: + self._slider.set_range(0, 1) + self._slider.set_value(0) + self._slider.set_sensitive(False) + self._play_button.set_sensitive(False) + self._step_back_button.set_sensitive(False) + self._step_fwd_button.set_sensitive(False) + + def get_slider_index(self) -> int: + return int(self._slider.get_value()) + + def _on_slider_changed(self, slider): + if self._canvas: + index = int(slider.get_value()) + if self.current_index != index: + self.seek(index) + # A user-initiated scrub while playing resyncs the simulated + # clock to the new position so the next tick continues from + # there instead of snapping back to the pre-drag playhead. + # Tick-driven slider moves set ``_tick_driving_slider`` so they + # are not mistaken for user scrubs. + if self._playing and self._player and not self._tick_driving_slider: + self._sim_time = self._player.get_cumulative_time( + int(slider.get_value()) + ) + self._player.set_sim_time(self._sim_time) + if not self._is_syncing: + self.step_changed.send(self, ops_index=int(slider.get_value())) + + def set_playback_position(self, ops_index: int): + """ + Set the slider position from an external source (e.g. a G-code + viewer click) without triggering a feedback loop. + """ + self._cancel_step_animation() + self._is_syncing = True + self._slider.set_value(ops_index) + self._is_syncing = False + + def can_play(self) -> bool: + """Returns True if the play button is currently sensitive.""" + return self._play_button.get_sensitive() + + def toggle_playback(self): + """Toggles play/pause state, as if the play button was clicked.""" + self._on_play_clicked(self._play_button) + + def _on_play_clicked(self, button): + if self._playing: + self._stop_playback() + else: + self._start_playback() + + def _start_playback(self): + if not self._canvas or self.command_count == 0: + return + max_idx = self.command_count - 1 + current = int(self._slider.get_value()) + if max_idx >= 0 and current >= max_idx: + self._slider.set_value(0) + current = 0 + # Resync the simulated clock to the current playhead so that + # playback continues from wherever the user left the slider. + # An in-flight step animation keeps its interpolated time so + # that playback continues seamlessly from the gliding playhead. + if self._player: + if self._step_animating: + self._cancel_step_animation() + else: + self._sim_time = self._player.get_cumulative_time(current) + self._player.set_sim_time(self._sim_time) + else: + self._sim_time = 0.0 + self._playing = True + self._play_button.set_child(self._pause_icon) + self._play_button.set_tooltip_text(_("Pause simulation")) + if self._timer_id is not None: + GLib.source_remove(self._timer_id) + self._timer_id = GLib.timeout_add( + int(TICK_SECONDS * 1000), self._on_tick + ) + + def _stop_playback(self): + self._cancel_step_animation() + self._playing = False + self._play_button.set_child(self._play_icon) + self._play_button.set_tooltip_text(_("Play simulation")) + if self._timer_id is not None: + GLib.source_remove(self._timer_id) + self._timer_id = None + + def _on_tick(self) -> bool: + if not self._playing: + return False + if not self._canvas or not self._canvas.get_realized(): + self._stop_playback() + return False + if not self._player or self.command_count == 0: + self._stop_playback() + return False + + # Advance the simulated clock by real time times the speed + # multiplier, then land on the command in effect at that time. + multiplier = SPEED_OPTIONS[self._speed_index] + self._sim_time += TICK_SECONDS * multiplier + self._player.set_sim_time(self._sim_time) + max_idx = self.command_count - 1 + target = self._player.find_index_at_sim_time(self._sim_time) + + if target >= max_idx: + self._tick_driving_slider = True + self._slider.set_value(max_idx) + self._tick_driving_slider = False + self._stop_playback() + return False + + self._tick_driving_slider = True + self._slider.set_value(target) + self._tick_driving_slider = False + # The slider value only changes at command boundaries; within a + # command the playhead still moves, so always redraw. + self._canvas.queue_render() + return True + + def _on_speed_clicked(self, button): + self._speed_index = (self._speed_index + 1) % len(SPEED_OPTIONS) + button.set_label(f"{SPEED_OPTIONS[self._speed_index]}x") + + def _on_step_back(self, button): + # The bounds check must look past the playhead (slider + queued + # steps): a backward click may cancel a forward glide even + # while the slider still sits at 0. + if self._pending_steps > 0 or int(self._slider.get_value()) > 0: + self._queue_step(-1) + + def _on_step_fwd(self, button): + max_idx = self._slider.get_adjustment().get_upper() + if self._pending_steps < 0 or int(self._slider.get_value()) < max_idx: + self._queue_step(1) + + def _queue_step(self, delta: int): + """Queue one manual step and start (or extend) its glide. + + Clicks arriving while a glide is running accumulate into the + same glide, so rapid clicks move the coalesced number of + commands in the time of a single step. While playing, steps + jump instantly as before. + """ + if self._playing or not self._player: + self._slider.set_value(int(self._slider.get_value()) + delta) + return + self._pending_steps += delta + self._start_step_glide() + + def _start_step_glide(self): + """Start a glide covering the coalesced queued steps. + + The batch takes the duration of a single step no matter how + many commands it spans; clicks arriving mid-glide retarget it + to the new coalesced total. + """ + if self._playing or not self._player: + return + current = int(self._slider.get_value()) + max_idx = int(self._slider.get_adjustment().get_upper()) + target = max(0, min(current + self._pending_steps, max_idx)) + consumed = target - current + if self._step_animating: + if consumed == 0: + # The queued clicks cancel each other out: snap back to + # the playhead and drop the batch. + self._cancel_step_animation() + self._sim_time = self._player.get_cumulative_time(current) + self._player.set_sim_time(self._sim_time) + if self._canvas: + self._canvas.queue_render() + return + self._step_consumed = consumed + self._step_target = target + self._step_end_time = self._player.get_cumulative_time(target) + return + if consumed == 0: + self._pending_steps = 0 + return + end_time = self._player.get_cumulative_time(target) + while end_time == self._sim_time: + # Zero-duration commands between the playhead and the + # target: move through them instantly, then continue with + # the rest of the batch. + self._pending_steps -= consumed + self._slider.set_value(target) + current = target + target = max(0, min(current + self._pending_steps, max_idx)) + consumed = target - current + if consumed == 0: + return + end_time = self._player.get_cumulative_time(target) + self._step_animating = True + self._step_ticks_remaining = STEP_ANIMATION_TICKS + self._step_start_time = self._sim_time + self._step_end_time = end_time + self._step_target = target + self._step_consumed = consumed + self._step_timer_id = GLib.timeout_add( + int(TICK_SECONDS * 1000), self._on_step_tick + ) + + def _on_step_tick(self) -> bool: + """Advance an in-flight step glide by one tick.""" + if not self._step_animating or not self._player: + return False + self._step_ticks_remaining -= 1 + progress = 1.0 - self._step_ticks_remaining / STEP_ANIMATION_TICKS + self._sim_time = ( + self._step_start_time + + (self._step_end_time - self._step_start_time) * progress + ) + self._player.set_sim_time(self._sim_time) + if self._canvas: + self._canvas.queue_render() + if self._step_ticks_remaining == 0: + self._pending_steps -= self._step_consumed + self._cancel_step_animation() + self._slider.set_value(self._step_target) + return False + return True + + def _cancel_step_animation(self): + """Stop any in-flight step glide and drop queued steps.""" + if self._step_timer_id is not None: + GLib.source_remove(self._step_timer_id) + self._step_timer_id = None + self._step_animating = False + self._pending_steps = 0 diff --git a/rayforge/ui_gtk/sim3d/render_context/__init__.py b/rayforge/ui_gtk/sim3d/render_context/__init__.py new file mode 100644 index 000000000..bf37045dc --- /dev/null +++ b/rayforge/ui_gtk/sim3d/render_context/__init__.py @@ -0,0 +1,22 @@ +"""Per-frame render context sections. + +The RenderContext is a composite of four mutable sections (camera, +viewport, kinematics, playback), each refreshing itself in place from a +shared :class:`FrameInputs` bundle via ``update()``. +""" + +from .base import FrameInputs, RenderContext +from .camera import CameraContext +from .kinematics import HeadConfig, KinematicsContext +from .playback import PlaybackContext +from .viewport import ViewportContext + +__all__ = [ + "CameraContext", + "FrameInputs", + "HeadConfig", + "KinematicsContext", + "PlaybackContext", + "RenderContext", + "ViewportContext", +] diff --git a/rayforge/ui_gtk/sim3d/render_context/base.py b/rayforge/ui_gtk/sim3d/render_context/base.py new file mode 100644 index 000000000..3620088aa --- /dev/null +++ b/rayforge/ui_gtk/sim3d/render_context/base.py @@ -0,0 +1,95 @@ +"""Composite render context and its per-frame input bundle.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +import numpy as np + +from .camera import CameraContext +from .kinematics import KinematicsContext +from .playback import PlaybackContext +from .viewport import ViewportContext + +if TYPE_CHECKING: + from ....core.color import ColorSet + from ....core.doc import Doc + from ....machine.assembly import Assembly + from ....machine.models.machine import Machine + from ....simulator.op_player import OpPlayer + from ....simulator.scene3d import CompiledSceneArtifact + from ..camera import Camera + from ..viewport import ViewportConfig + + +@dataclass +class FrameInputs: + """Raw per-frame inputs consumed by the section contexts. + + ``cylinder_transform`` and ``had_rotary_layers`` are provided by the + canvas from the scene state, so the contexts never reach into the + SceneRenderer themselves. + """ + + camera: "Camera" + viewport: "ViewportConfig" + color_set: "ColorSet" + op_player: Optional["OpPlayer"] = None + machine: Optional["Machine"] = None + playback_assembly: Optional["Assembly"] = None + compiled_artifact: Optional["CompiledSceneArtifact"] = None + doc: Optional["Doc"] = None + cylinder_transform: np.ndarray | None = None + had_rotary_layers: bool = False + show_travel_moves: bool = False + show_grid: bool = True + show_nogo_zones: bool = True + show_models: bool = True + + +class RenderContext: + """Composite per-frame rendering state, sectioned by concern. + + Matrices are row-major (NumPy convention). ``Shader.set_mat4`` / + ``Shader.set_mat3`` transpose to column-major at the GL boundary, so + renderers pass row-major matrices directly. + + Sections: + - ``camera``: view/projection matrices, colours, line width and + display toggles shared by all renderers. + - ``viewport``: grid/world transforms derived from the viewport. + - ``kinematics``: pre-computed machine head positions, model + transforms and rotary matrices. + - ``playback``: the op player, compiled artifact and per-frame + execution counters. + + Each section refreshes itself in place from a :class:`FrameInputs` + bundle via :meth:`update`, so a single context can be reused across + frames. + """ + + def __init__( + self, + camera: CameraContext | None = None, + viewport: ViewportContext | None = None, + kinematics: KinematicsContext | None = None, + playback: PlaybackContext | None = None, + ): + self.camera = camera if camera is not None else CameraContext() + self.viewport = viewport if viewport is not None else ViewportContext() + self.kinematics = ( + kinematics if kinematics is not None else KinematicsContext() + ) + self.playback = playback if playback is not None else PlaybackContext() + + def update(self, frame: FrameInputs) -> None: + """Refreshes every section from the given frame inputs. + + Sections are updated in dependency order: camera and viewport + first, then kinematics (which consumes both), then playback. + """ + self.camera.update(frame) + self.viewport.update(frame) + self.kinematics.update( + frame, camera=self.camera, viewport=self.viewport + ) + self.playback.update(frame) diff --git a/rayforge/ui_gtk/sim3d/render_context/camera.py b/rayforge/ui_gtk/sim3d/render_context/camera.py new file mode 100644 index 000000000..95e45e739 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/render_context/camera.py @@ -0,0 +1,105 @@ +"""Camera/frame-level render context section.""" + +from typing import TYPE_CHECKING, Optional + +import numpy as np + +from ....core.color import ColorSet + +if TYPE_CHECKING: + from ....machine.models.machine import Machine + from ..camera import Camera + from .base import FrameInputs + + +class CameraContext: + """Frame-level camera matrices, colors, line width and display toggles. + + The plain constructor leaves the section empty; call :meth:`update` + each frame to populate it from the camera, machine and viewport. + """ + + def __init__( + self, + *, + proj_matrix: np.ndarray | None = None, + view_matrix: np.ndarray | None = None, + mvp_ui: np.ndarray | None = None, + viewport_height: int = 0, + camera_position: np.ndarray | None = None, + color_set: Optional["ColorSet"] = None, + line_width: float = 2.0, + show_travel_moves: bool = False, + show_grid: bool = True, + show_nogo_zones: bool = True, + show_models: bool = True, + ): + identity = np.eye(4, dtype=np.float32) + self.proj_matrix = identity if proj_matrix is None else proj_matrix + self.view_matrix = identity if view_matrix is None else view_matrix + self.mvp_ui = identity if mvp_ui is None else mvp_ui + self.viewport_height = viewport_height + self.camera_position = ( + np.zeros(3) if camera_position is None else camera_position + ) + self.color_set = color_set if color_set is not None else ColorSet() + self.line_width = line_width + self.show_travel_moves = show_travel_moves + self.show_grid = show_grid + self.show_nogo_zones = show_nogo_zones + self.show_models = show_models + + def update(self, frame: "FrameInputs") -> None: + """Recomputes the camera section from the current frame inputs.""" + camera = frame.camera + proj_matrix = camera.get_projection_matrix() + view_matrix = camera.get_view_matrix() + mvp_ui = proj_matrix @ view_matrix + self.proj_matrix = proj_matrix + self.view_matrix = view_matrix + self.mvp_ui = mvp_ui + self.viewport_height = camera.height + self.camera_position = camera.position + self.color_set = frame.color_set + self.line_width = self._compute_spot_line_width( + frame.machine, camera, mvp_ui + ) + self.show_travel_moves = frame.show_travel_moves + self.show_grid = frame.show_grid + self.show_nogo_zones = frame.show_nogo_zones + self.show_models = frame.show_models + + @staticmethod + def _world_size_to_pixels( + mvp: np.ndarray, + world_mm: float, + viewport_w: int, + viewport_h: int, + ) -> float: + p0 = mvp @ np.array([0, 0, 0, 1], dtype=np.float32) + p1 = mvp @ np.array([world_mm, 0, 0, 1], dtype=np.float32) + if abs(p0[3]) < 1e-9 or abs(p1[3]) < 1e-9: + return 1.0 + ndc_dx = (p1[0] / p1[3]) - (p0[0] / p0[3]) + return abs(ndc_dx) * viewport_w * 0.5 + + @classmethod + def _compute_spot_line_width( + cls, + machine: Optional["Machine"], + camera: "Camera", + mvp: np.ndarray, + ) -> float: + spot_mm = 0.1 + laser_head = machine.get_default_laser_head() if machine else None + if laser_head is not None: + spot_mm = laser_head.spot_size_mm[0] + if not camera: + return 2.0 + px = cls._world_size_to_pixels( + mvp, + spot_mm, + camera.width, + camera.height, + ) + return max(2.0, px) diff --git a/rayforge/ui_gtk/sim3d/render_context/kinematics.py b/rayforge/ui_gtk/sim3d/render_context/kinematics.py new file mode 100644 index 000000000..f8744531f --- /dev/null +++ b/rayforge/ui_gtk/sim3d/render_context/kinematics.py @@ -0,0 +1,295 @@ +"""Machine kinematics section for the render context. + +A single :class:`KinematicsContext` carries both flat and rotary state; +``mvp_for()`` and ``cylinder_mesh_mvp()`` branch on the current rotary +configuration, so renderers do not need to distinguish the two cases +themselves. +""" + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +import numpy as np + +from ....core.color import hex_to_rgba +from ....machine.models.laser import LaserHead +from ....simulator.machine_state import MachineState +from ..gl_utils import rotation_4x4 + +if TYPE_CHECKING: + from raygeo.ops.axis import Axis + + from ....core.doc import Doc + from ....machine.models.machine import Machine + from ....simulator.op_player import OpPlayer + from .base import FrameInputs + from .camera import CameraContext + from .viewport import ViewportContext + +_DEFAULT_FOCAL_DISTANCE = 50.0 +_VIS_ROT_AXIS = np.array([1.0, 0.0, 0.0], dtype=np.float64) + + +@dataclass +class HeadConfig: + """Beam visual config for one laser head.""" + + beam_height: float + beam_color: tuple + valid: bool = True + + +class KinematicsContext: + """Pre-computed machine kinematics for the current frame. + + ``model_world_transforms`` / ``head_positions`` / ``head_configs`` + are populated by :meth:`update` from the machine assembly. Flat + frames use the plain UI MVP; rotary frames additionally expose a + rotated toolpath MVP and the cylinder mesh MVP. + + The plain constructor leaves the section empty; call :meth:`update` + each frame to recompute it from the current state. + """ + + def __init__( + self, + *, + mvp_ui: np.ndarray | None = None, + mvp_rot: np.ndarray | None = None, + cyl_mesh_mvp: np.ndarray | None = None, + model_world_transforms: dict[str, np.ndarray] | None = None, + head_positions: dict[str, tuple] | None = None, + head_configs: dict[str, HeadConfig] | None = None, + rotary_head_positions: dict[str, np.ndarray] | None = None, + focused_rotary_head_positions: dict[str, np.ndarray] | None = None, + has_rotary: bool = False, + rotary_axis: Optional["Axis"] = None, + ): + identity = np.eye(4, dtype=np.float32) + self._mvp_ui = identity if mvp_ui is None else mvp_ui + self._mvp_rot = mvp_rot + self._cyl_mesh_mvp = cyl_mesh_mvp + self.model_world_transforms = model_world_transforms or {} + self.head_positions = head_positions or {} + self.head_configs = head_configs or {} + self.rotary_head_positions = rotary_head_positions or {} + self.focused_rotary_head_positions = ( + focused_rotary_head_positions or {} + ) + self.has_rotary = has_rotary + self.rotary_axis = rotary_axis + self.laser_light_pos: np.ndarray | None = None + + @property + def is_rotary(self) -> bool: + """True when a rotary axis is active for this frame.""" + return self.rotary_axis is not None and self.has_rotary + + def mvp_for(self, renderer_is_rotary: bool) -> np.ndarray: + """MVP for a toolpath renderer, rotary or flat layer.""" + if renderer_is_rotary and self._mvp_rot is not None: + return self._mvp_rot + return self._mvp_ui + + def cylinder_mesh_mvp(self) -> np.ndarray | None: + """MVP for the rotary cylinder mesh, or None when not rotary.""" + return self._cyl_mesh_mvp + + def update( + self, + frame: "FrameInputs", + *, + camera: "CameraContext", + viewport: "ViewportContext", + ) -> None: + """Recomputes the kinematics section from the current frame.""" + self.laser_light_pos = None + mvp_ui = camera.mvp_ui + machine = frame.machine + asm = frame.playback_assembly + if asm is None and machine is not None: + asm = machine.assembly + if asm is None: + self._apply_flat( + mvp_ui, + model_world_transforms={}, + head_positions={}, + head_configs={}, + has_rotary=False, + ) + return + + op_player = frame.op_player + state = ( + op_player.render_state() + if op_player is not None + else MachineState() + ) + wcs = viewport.wcs_offset_mm + model_world_transforms = asm.model_world_transforms( + state, wcs_offset=wcs + ) + try: + head_positions = asm.head_positions(state, wcs_offset=wcs) + except ValueError: + head_positions = {} + head_configs = { + name: _head_config(machine, name) for name in head_positions + } + has_rotary = asm.has_rotary + + if not frame.had_rotary_layers: + self._apply_flat( + mvp_ui, + model_world_transforms=model_world_transforms, + head_positions=head_positions, + head_configs=head_configs, + has_rotary=has_rotary, + ) + return + + op_player = frame.op_player + rotary_axis = op_player.rotary_axis if op_player else None + diameter = ( + _current_rotary_diameter(op_player, frame.doc) + if op_player + else 0.0 + ) + rotary_head_positions = asm.head_rotary_positions(state, diameter) + focused = _focused_rotary_head_positions(machine, asm, state, diameter) + + cyl_angle = 0.0 + if op_player and rotary_axis is not None and has_rotary: + cyl_angle = math.radians(state.axes.get(rotary_axis, 0.0)) + + margin_shift = viewport.margin_shift + cylinder_transform = ( + frame.cylinder_transform + if frame.cylinder_transform is not None + else np.eye(4, dtype=np.float64) + ) + cyl_base_mvp = ( + mvp_ui.astype(np.float64) + @ margin_shift.astype(np.float64) + @ cylinder_transform + ) + rot_4x4 = rotation_4x4(_VIS_ROT_AXIS, cyl_angle) + mvp_rot = (cyl_base_mvp @ rot_4x4).astype(np.float32) + cyl_mesh_mvp = ( + mvp_ui @ margin_shift @ cylinder_transform @ rot_4x4 + ).astype(np.float32) + + self._mvp_ui = mvp_ui + self._mvp_rot = mvp_rot + self._cyl_mesh_mvp = cyl_mesh_mvp + self.model_world_transforms = model_world_transforms + self.head_positions = head_positions + self.head_configs = head_configs + self.rotary_head_positions = rotary_head_positions + self.focused_rotary_head_positions = focused + self.has_rotary = has_rotary + self.rotary_axis = rotary_axis + + def _apply_flat( + self, + mvp_ui: np.ndarray, + *, + model_world_transforms: dict[str, np.ndarray], + head_positions: dict[str, tuple], + head_configs: dict[str, HeadConfig], + has_rotary: bool, + ) -> None: + self._mvp_ui = mvp_ui + self._mvp_rot = None + self._cyl_mesh_mvp = None + self.model_world_transforms = model_world_transforms + self.head_positions = head_positions + self.head_configs = head_configs + self.rotary_head_positions = {} + self.focused_rotary_head_positions = {} + self.has_rotary = has_rotary + self.rotary_axis = None + + +def _current_rotary_diameter( + op_player: "OpPlayer", doc: Optional["Doc"] +) -> float: + """Return the current layer's rotary diameter, or 0.0 if none.""" + if doc is None: + return 0.0 + current_layer = op_player.get_current_layer(doc) + if current_layer is None: + return 0.0 + return current_layer.rotary_diameter or 0.0 + + +def _head_focal_distance( + machine: Optional["Machine"], head_name: str +) -> float: + """Return the focal distance of the named laser head.""" + if machine is None or not head_name.startswith("head_"): + return _DEFAULT_FOCAL_DISTANCE + try: + idx = int(head_name.split("_")[1]) + laser = machine.heads[idx] + except (ValueError, IndexError, TypeError, AttributeError): + return _DEFAULT_FOCAL_DISTANCE + if not isinstance(laser, LaserHead): + return _DEFAULT_FOCAL_DISTANCE + if laser.focal_distance and laser.focal_distance > 0: + return laser.focal_distance + return _DEFAULT_FOCAL_DISTANCE + + +def _focused_rotary_head_positions( + machine: Optional["Machine"], + asm, + state: MachineState, + diameter: float, +) -> dict[str, np.ndarray]: + """Rotary head positions with each head's focal distance applied. + + Only links with HEAD role appear in the result, so it doubles as the + set of model links that should be placed above the cylinder. + """ + if not asm.has_rotary: + return {} + result: dict[str, np.ndarray] = {} + for name in asm.head_rotary_positions(state, diameter): + focal = _head_focal_distance(machine, name) + focused = asm.head_rotary_positions( + state, diameter, focal_distance=focal + ) + if name in focused: + result[name] = focused[name] + return result + + +def _head_config(machine: Optional["Machine"], head_name: str) -> HeadConfig: + """Return the beam config for the named head link.""" + default = HeadConfig( + beam_height=_DEFAULT_FOCAL_DISTANCE, + beam_color=(1.0, 0.3, 0.1, 1.0), + ) + if machine is None or not head_name.startswith("head_"): + return default + try: + idx = int(head_name.split("_")[1]) + laser = machine.heads[idx] + except (ValueError, IndexError, TypeError, AttributeError): + return default + if not isinstance(laser, LaserHead): + return HeadConfig( + beam_height=_DEFAULT_FOCAL_DISTANCE, + beam_color=(1.0, 0.3, 0.1, 1.0), + valid=False, + ) + beam_height = ( + laser.focal_distance + if laser.focal_distance and laser.focal_distance > 0 + else _DEFAULT_FOCAL_DISTANCE + ) + return HeadConfig( + beam_height=beam_height, beam_color=hex_to_rgba(laser.cut_color) + ) diff --git a/rayforge/ui_gtk/sim3d/render_context/playback.py b/rayforge/ui_gtk/sim3d/render_context/playback.py new file mode 100644 index 000000000..b1f3c1a5a --- /dev/null +++ b/rayforge/ui_gtk/sim3d/render_context/playback.py @@ -0,0 +1,43 @@ +"""Playback render context section.""" + +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from ....simulator.op_player import OpPlayer + from ....simulator.scene3d import CompiledSceneArtifact + from .base import FrameInputs + + +class PlaybackContext: + """Playback state and per-frame execution counters. + + ``executed_vertex_count`` / ``executed_travel_vertex_count`` are + written each frame by the layer renderer groups; ``reached_count`` + by the texture renderer. ``alpha_pending`` is the pending-alpha used + while drawing not-yet-executed toolpaths. + + The plain constructor leaves the section empty; call :meth:`update` + each frame to refresh the playback state. + """ + + def __init__( + self, + *, + op_player: Optional["OpPlayer"] = None, + compiled_artifact: Optional["CompiledSceneArtifact"] = None, + ): + self.op_player = op_player + self.compiled_artifact = compiled_artifact + self.executed_vertex_count = -1 + self.executed_travel_vertex_count = -1 + self.alpha_pending = 0.2 + self.reached_count: int | None = None + + def update(self, frame: "FrameInputs") -> None: + """Refreshes the playback section from the current frame inputs.""" + self.op_player = frame.op_player + self.compiled_artifact = frame.compiled_artifact + self.executed_vertex_count = -1 + self.executed_travel_vertex_count = -1 + self.alpha_pending = 0.2 + self.reached_count = None diff --git a/rayforge/ui_gtk/sim3d/render_context/viewport.py b/rayforge/ui_gtk/sim3d/render_context/viewport.py new file mode 100644 index 000000000..626fcb2e8 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/render_context/viewport.py @@ -0,0 +1,46 @@ +"""Viewport-derived render context section.""" + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from .base import FrameInputs + + +class ViewportContext: + """Grid/world transforms derived from the viewport configuration. + + The plain constructor leaves the section empty; call :meth:`update` + each frame to populate it from the viewport config. + """ + + def __init__( + self, + *, + model_matrix: np.ndarray | None = None, + margin_shift: np.ndarray | None = None, + wcs_offset_mm: tuple[float, float, float] | None = None, + x_right: bool = False, + x_negative: bool = False, + y_negative: bool = False, + ): + identity = np.eye(4, dtype=np.float32) + self.model_matrix = identity if model_matrix is None else model_matrix + self.margin_shift = identity if margin_shift is None else margin_shift + self.wcs_offset_mm = ( + (0.0, 0.0, 0.0) if wcs_offset_mm is None else wcs_offset_mm + ) + self.x_right = x_right + self.x_negative = x_negative + self.y_negative = y_negative + + def update(self, frame: "FrameInputs") -> None: + """Recomputes the viewport section from the current frame inputs.""" + viewport = frame.viewport + self.model_matrix = viewport.model_matrix + self.margin_shift = viewport.margin_shift + self.wcs_offset_mm = viewport.wcs_offset_mm + self.x_right = viewport.x_right + self.x_negative = viewport.x_negative + self.y_negative = viewport.y_negative diff --git a/rayforge/ui_gtk/sim3d/renderer/__init__.py b/rayforge/ui_gtk/sim3d/renderer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/sim3d/renderer/axis_renderer_3d.py b/rayforge/ui_gtk/sim3d/renderer/axis_renderer_3d.py new file mode 100644 index 000000000..b2bd7b200 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/axis_renderer_3d.py @@ -0,0 +1,510 @@ +""" +Renders a 3D grid and axes for a scene. + +This module provides the AxisRenderer3D class, which is responsible for +creating and drawing a grid on the XY plane, along with labeled X and Y +axes. It uses a composed PlaneRenderer for the background. +""" + +from __future__ import annotations + +import logging +import math + +import numpy as np +from OpenGL import GL + +from ..gl_utils import ShaderSet, set_line_width +from ..render_context import RenderContext +from .base import BaseRenderer +from .plane_renderer import PlaneRenderer +from .text_renderer import TextRenderer + +logger = logging.getLogger(__name__) + + +class AxisRenderer3D(BaseRenderer): + """Renders a 3D grid with axes, background, and labels on the XY plane.""" + + def __init__( + self, + width_mm: float, + height_mm: float, + grid_size_mm: float = 10.0, + font_family: str | None = None, + grid_unit_factor: float = 1.0, + ): + """Initializes the AxisRenderer3D with scene dimensions. + + Args: + width_mm: The total width of the grid along the X-axis in mm. + height_mm: The total height of the grid along the Y-axis in mm. + grid_size_mm: The spacing between grid lines in mm. + font_family: The name of the font to use for labels + (e.g. "Cantarell"). + grid_unit_factor: The number of mm in one user-preferred length + unit. Grid spacing is expressed as a multiple of this factor and + labels are shown in the corresponding unit. + """ + super().__init__() + self.width_mm = float(width_mm) + self.height_mm = float(height_mm) + self.grid_size_mm = float(grid_size_mm) + self.grid_unit_factor = float(grid_unit_factor) + self.font_family = font_family + + # Colors + self.background_color = 0.8, 0.8, 0.8, 0.1 + self.grid_color = 0.4, 0.4, 0.4, 1.0 + self.axis_color = 1.0, 1.0, 1.0, 1.0 + self.wcs_marker_color = 0.2, 0.8, 0.2, 0.9 + self.label_color = 0.9, 0.9, 0.9, 1.0 + self.extent_frame_color = 1.0, 0.0, 0.0, 0.5 + + # Extent frame properties + self.extent_x_mm: float = 0.0 + self.extent_y_mm: float = 0.0 + self.extent_width_mm: float = float(width_mm) + self.extent_height_mm: float = float(height_mm) + self.show_extent_frame: bool = False + + # Composition + self.background_renderer = PlaneRenderer( + width=self.width_mm, + height=self.height_mm, + color=self.background_color, + z_offset=-0.002, + ) + self._add_child_renderer(self.background_renderer) + + self.text_renderer: TextRenderer | None = None + + # Grid and Axes resources + self.grid_vao, self.grid_vbo, self.grid_vertex_count = 0, 0, 0 + self.axes_vao, self.axes_vbo, self.axes_vertex_count = 0, 0, 0 + ( + self.wcs_marker_vao, + self.wcs_marker_vbo, + self.wcs_marker_vertex_count, + ) = (0, 0, 0) + ( + self.extent_frame_vao, + self.extent_frame_vbo, + self.extent_frame_vertex_count, + ) = (0, 0, 0) + + def set_background_color(self, color: tuple[float, float, float, float]): + """Sets the color for the background plane.""" + self.background_color = color + self.background_renderer.color = color + + def set_grid_color(self, color: tuple[float, float, float, float]): + """Sets the color for the grid lines.""" + self.grid_color = color + + def set_grid_size(self, grid_size_mm: float) -> None: + """Sets the grid spacing and rebuilds the grid geometry if needed.""" + grid_size_mm = float(grid_size_mm) + if math.isclose(self.grid_size_mm, grid_size_mm): + return + self.grid_size_mm = grid_size_mm + if self.grid_vao: + self._clear_line_resources() + self._init_grid_and_axes() + + def _clear_line_resources(self) -> None: + """Deletes the grid/axis/marker/frame buffers before rebuilding.""" + self._delete_owned(vao=self.grid_vao, vbo=self.grid_vbo) + self.grid_vao, self.grid_vbo = 0, 0 + self._delete_owned(vao=self.axes_vao, vbo=self.axes_vbo) + self.axes_vao, self.axes_vbo = 0, 0 + self._delete_owned(vao=self.wcs_marker_vao, vbo=self.wcs_marker_vbo) + self.wcs_marker_vao, self.wcs_marker_vbo = 0, 0 + self._delete_owned( + vao=self.extent_frame_vao, vbo=self.extent_frame_vbo + ) + self.extent_frame_vao, self.extent_frame_vbo = 0, 0 + + def set_grid_unit_factor(self, grid_unit_factor: float) -> None: + """Sets the number of mm in one user-preferred length unit.""" + self.grid_unit_factor = float(grid_unit_factor) + + def set_axis_color(self, color: tuple[float, float, float, float]): + """Sets the color for the main X and Y axis lines.""" + self.axis_color = color + + def set_label_color(self, color: tuple[float, float, float, float]): + """Sets the color for the axis labels.""" + self.label_color = color + + def set_extent_frame( + self, + x: float, + y: float, + width: float, + height: float, + show: bool = True, + ): + """Sets the extent frame position, dimensions and visibility.""" + self.extent_x_mm = float(x) + self.extent_y_mm = float(y) + self.extent_width_mm = float(width) + self.extent_height_mm = float(height) + self.show_extent_frame = show + if self.extent_frame_vao: + self._update_extent_frame_buffer() + + def init_gl(self) -> None: + """Initializes OpenGL resources for all components.""" + # Delegate initialization to child renderers + self.background_renderer.init_gl() + + self.text_renderer = TextRenderer(font_family=self.font_family) + self.text_renderer.init_gl() + self._add_child_renderer(self.text_renderer) + + # Initialize self-managed components using base class helpers + self._init_grid_and_axes() + + def prepare(self, ctx: RenderContext) -> None: + """No per-frame state to prepare.""" + + def _init_grid_and_axes(self): + """Creates VAOs/VBOs for the grid and axis lines.""" + grid_z_pos = -0.001 + w, h = self.width_mm, self.height_mm + + # Grid vertices + grid_verts = [] + for x in np.arange(self.grid_size_mm, w, self.grid_size_mm): + grid_verts.extend([x, 0.0, grid_z_pos, x, h, grid_z_pos]) + for y in np.arange(self.grid_size_mm, h, self.grid_size_mm): + grid_verts.extend([0.0, y, grid_z_pos, w, y, grid_z_pos]) + + # Axis vertices + axis_verts = [0.0, 0.0, 0.0, w, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, h, 0.0] + + # WCS Marker vertices (a cross) + marker_size = self.grid_size_mm * 0.5 + marker_z_pos = 0.001 # Slightly above the axes + wcs_marker_verts = [ + -marker_size, + 0.0, + marker_z_pos, + marker_size, + 0.0, + marker_z_pos, + 0.0, + -marker_size, + marker_z_pos, + 0.0, + marker_size, + marker_z_pos, + ] + + # Create Grid resources + self.grid_vao = self._create_vao() + self.grid_vbo = self._create_vbo() + self.grid_vertex_count = len(grid_verts) // 3 + GL.glBindVertexArray(self.grid_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.grid_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + np.array(grid_verts, dtype=np.float32).nbytes, + np.array(grid_verts, dtype=np.float32), + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + # Create Axis resources + self.axes_vao = self._create_vao() + self.axes_vbo = self._create_vbo() + self.axes_vertex_count = len(axis_verts) // 3 + GL.glBindVertexArray(self.axes_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.axes_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + np.array(axis_verts, dtype=np.float32).nbytes, + np.array(axis_verts, dtype=np.float32), + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + # Create WCS Marker resources + self.wcs_marker_vao = self._create_vao() + self.wcs_marker_vbo = self._create_vbo() + self.wcs_marker_vertex_count = len(wcs_marker_verts) // 3 + GL.glBindVertexArray(self.wcs_marker_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.wcs_marker_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + np.array(wcs_marker_verts, dtype=np.float32).nbytes, + np.array(wcs_marker_verts, dtype=np.float32), + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + # Create Extent Frame resources (initially empty, updated dynamically) + self._update_extent_frame_buffer() + + GL.glBindVertexArray(0) + + def _update_extent_frame_buffer(self): + """Creates or updates the VAO/VBO for the extent frame.""" + extent_z_pos = 0.002 + x, y = self.extent_x_mm, self.extent_y_mm + w, h = self.extent_width_mm, self.extent_height_mm + + # Rectangle outline: 4 lines = 8 vertices + extent_verts = [ + x, + y, + extent_z_pos, + x + w, + y, + extent_z_pos, + x + w, + y, + extent_z_pos, + x + w, + y + h, + extent_z_pos, + x + w, + y + h, + extent_z_pos, + x, + y + h, + extent_z_pos, + x, + y + h, + extent_z_pos, + x, + y, + extent_z_pos, + ] + + if self.extent_frame_vao == 0: + self.extent_frame_vao = self._create_vao() + self.extent_frame_vbo = self._create_vbo() + + self.extent_frame_vertex_count = len(extent_verts) // 3 + GL.glBindVertexArray(self.extent_frame_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.extent_frame_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + np.array(extent_verts, dtype=np.float32).nbytes, + np.array(extent_verts, dtype=np.float32), + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + GL.glBindVertexArray(0) + + def render( + self, + ctx: RenderContext, + shaders: ShaderSet, + **kwargs, + ) -> None: + """ + Orchestrates the rendering of all components in the correct order. + + Args: + ctx: The current render context; carries the shaders, scene and + text MVPs, and the viewport origin/axis flags. + shaders: The shader set; ``main`` for lines and ``text`` for + labels. + """ + if not all( + ( + self.grid_vao, + self.axes_vao, + self.wcs_marker_vao, + self.text_renderer, + ) + ): + return + + if not ctx.camera.show_grid: + return + + line_shader = shaders.main + text_shader = shaders.text + if line_shader is None or text_shader is None: + return + + text_mvp = ctx.camera.mvp_ui + origin_offset_mm = ( + ctx.viewport.wcs_offset_mm + if ctx.viewport.wcs_offset_mm is not None + else (0.0, 0.0, 0.0) + ) + + # 1. Calculate the world-space position of the WCS origin. + # origin_offset_mm is in grid coordinates (workarea-relative). + # For negative axes, flip the offset direction. + off_x, off_y, off_z = origin_offset_mm + + offset_vec = np.array([off_x, off_y, off_z, 1.0], dtype=np.float32) + world_offset_vec = ctx.viewport.model_matrix @ offset_vec + + # 2. Construct the MVP for the static grid/axes. + grid_mvp = text_mvp @ ctx.viewport.model_matrix + + # Enable blending for transparent objects + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + line_shader.use() + line_shader.set_float("uHasNormals", 0.0) + line_shader.set_int("uExecutedVertexCount", -1) + line_shader.set_float("uAlphaPending", 0.2) + + # Draw background plane + GL.glDepthMask(GL.GL_FALSE) + self.background_renderer.render(ctx, shaders) + + # Draw grid + line_shader.set_mat4("uMVP", grid_mvp) + line_shader.set_vec4("uColor", self.grid_color) + set_line_width(1.0) + GL.glBindVertexArray(self.grid_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.grid_vertex_count) + + # Draw axes + line_shader.set_mat4("uMVP", grid_mvp) + line_shader.set_vec4("uColor", self.axis_color) + set_line_width(2.0) + GL.glBindVertexArray(self.axes_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.axes_vertex_count) + + # 3. Draw the WCS origin marker + wcs_translation_matrix = np.identity(4, dtype=np.float32) + wcs_translation_matrix[:3, 3] = world_offset_vec[:3] + wcs_marker_mvp = text_mvp @ wcs_translation_matrix + + line_shader.set_mat4("uMVP", wcs_marker_mvp) + line_shader.set_vec4("uColor", self.wcs_marker_color) + GL.glBindVertexArray(self.wcs_marker_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.wcs_marker_vertex_count) + + # 4. Draw the extent frame if enabled + if self.show_extent_frame and self.extent_frame_vao: + line_shader.set_mat4("uMVP", grid_mvp) + line_shader.set_vec4("uColor", self.extent_frame_color) + set_line_width(2.0) + GL.glBindVertexArray(self.extent_frame_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.extent_frame_vertex_count) + + # 5. Pass the correct world-space offset vector to the label renderer. + self._render_axis_labels(ctx, shaders) + + def _render_axis_labels( + self, + ctx: RenderContext, + shaders: ShaderSet, + ) -> None: + """Helper method to render text labels along the axes.""" + if not self.text_renderer: + return + self.text_renderer.begin_batch() + model_matrix = ctx.viewport.model_matrix + label_height_mm = 2.5 + x_axis_label_y_offset = label_height_mm * 1.2 + y_axis_label_x_offset = label_height_mm * 0.6 + + # origin_offset_mm is in grid coordinates (workarea-relative) + origin_offset_mm = ( + ctx.viewport.wcs_offset_mm + if ctx.viewport.wcs_offset_mm is not None + else (0.0, 0.0, 0.0) + ) + x_right = ctx.viewport.x_right + x_negative = ctx.viewport.x_negative + y_negative = ctx.viewport.y_negative + wcs_local_x, wcs_local_y, _ = origin_offset_mm + + # X-axis labels + # Find the range of grid lines that are on the machine bed. + # Grid covers 0..Width. + # We label relative to WCS. + # delta = x_phys_local - wcs_local_x + # min_delta = 0 - wcs_local_x + # max_delta = Width - wcs_local_x + min_delta_x = 0.0 - wcs_local_x + max_delta_x = self.width_mm - wcs_local_x + k_start_x = math.ceil(min_delta_x / self.grid_size_mm) + k_end_x = math.floor(max_delta_x / self.grid_size_mm) + + for k in range(k_start_x, k_end_x + 1): + delta = k * self.grid_size_mm + + # Physical position of the grid line in local space + x_phys_local = wcs_local_x + delta + + # Position of the label text below the grid line + pos_local = np.array( + [x_phys_local, -x_axis_label_y_offset, 0.0, 1.0] + ) + pos_final = (model_matrix @ pos_local)[:3] + + # Label value logic: + # If negative axis, movement into the bed (+delta) corresponds + # to more negative values. + # If positive axis, movement into the bed (+delta) corresponds + # to more positive values. + # This holds true regardless of origin corner (x_right) because + # delta is defined in the flipped local space. + label_val = -delta if x_negative else delta + label_val = label_val / self.grid_unit_factor + label_text = str(round(label_val)) + + self.text_renderer.render( + ctx, + shaders, + text=label_text, + position=pos_final, + height_in_world_units=label_height_mm, + color=self.label_color, + ) + + # Y-axis labels + y_label_align = "right" + if x_right: + y_label_align = "left" + + min_delta_y = 0.0 - wcs_local_y + max_delta_y = self.height_mm - wcs_local_y + k_start_y = math.ceil(min_delta_y / self.grid_size_mm) + k_end_y = math.floor(max_delta_y / self.grid_size_mm) + + for k in range(k_start_y, k_end_y + 1): + delta = k * self.grid_size_mm + + # Physical position of the grid line in local space + y_phys_local = wcs_local_y + delta + + # Position of the label text next to the grid line + pos_local = np.array( + [-y_axis_label_x_offset, y_phys_local, 0.0, 1.0] + ) + pos_final = (model_matrix @ pos_local)[:3] + + # Label value logic: same as X + label_val = -delta if y_negative else delta + label_val = label_val / self.grid_unit_factor + label_text = str(round(label_val)) + + self.text_renderer.render( + ctx, + shaders, + text=label_text, + position=pos_final, + height_in_world_units=label_height_mm, + color=self.label_color, + align=y_label_align, + ) + + self.text_renderer.end_batch() diff --git a/rayforge/ui_gtk/sim3d/renderer/background_renderer.py b/rayforge/ui_gtk/sim3d/renderer/background_renderer.py new file mode 100644 index 000000000..3601b773c --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/background_renderer.py @@ -0,0 +1,79 @@ +""" +A renderer for a gradient background that gives a raytraced studio appearance. +""" + +import logging + +import numpy as np +from OpenGL import GL + +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +class BackgroundRenderer(BaseRenderer): + """Renders a fullscreen gradient quad behind the 3D scene.""" + + def __init__(self): + super().__init__() + self.vao: int = 0 + self.vbo: int = 0 + self._bg_color = (0.11, 0.12, 0.14) + self._bg_color_light = (0.18, 0.20, 0.23) + + def set_colors(self, bg_color: tuple, bg_color_light: tuple): + self._bg_color = bg_color + self._bg_color_light = bg_color_light + + def init_gl(self): + vertices = np.array( + [ + -1, + -1, + 0, + 1, + -1, + 0, + -1, + 1, + 0, + 1, + 1, + 0, + ], + dtype=np.float32, + ) + + self.vao = self._create_vao() + self.vbo = self._create_vbo() + + GL.glBindVertexArray(self.vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL.GL_STATIC_DRAW + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def prepare(self, ctx: RenderContext) -> None: + """No per-frame state to prepare.""" + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs): + shader = shaders.background + if not shader or not self.vao: + return + + shader.use() + shader.set_vec3("uBgColor", self._bg_color) + shader.set_vec3("uBgColorLight", self._bg_color_light) + + GL.glDisable(GL.GL_DEPTH_TEST) + GL.glDisable(GL.GL_BLEND) + GL.glBindVertexArray(self.vao) + GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4) diff --git a/rayforge/ui_gtk/sim3d/renderer/base.py b/rayforge/ui_gtk/sim3d/renderer/base.py new file mode 100644 index 000000000..5554cb4ea --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/base.py @@ -0,0 +1,126 @@ +""" +Base class for OpenGL renderers that manage their own GPU resources. +""" + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, final + +from OpenGL import GL + +from ..shader.base import Shader + +if TYPE_CHECKING: + from ..gl_utils import ShaderSet + from ..render_context import RenderContext + +logger = logging.getLogger(__name__) + + +class BaseRenderer(ABC): + """A base class for an OpenGL renderer that manages its own + resources.""" + + @abstractmethod + def prepare(self, ctx: "RenderContext") -> None: + """Per-frame state setup before the draw.""" + raise NotImplementedError + + @abstractmethod + def render( + self, ctx: "RenderContext", shaders: "ShaderSet", **kwargs + ) -> None: + """Performs the GL draw using the given shaders.""" + raise NotImplementedError + + @abstractmethod + def init_gl(self) -> None: + """Creates the renderer's OpenGL resources.""" + raise NotImplementedError + + def __init__(self): + """Initializes the resource tracking lists.""" + self.shader: Shader | None = None + self._owned_vaos: list[int] = [] + self._owned_vbos: list[int] = [] + self._owned_textures: list[int] = [] + self._owned_renderers: list[BaseRenderer] = [] + + def _create_vao(self) -> int: + """Creates a VAO and registers it for automatic cleanup.""" + vao = GL.glGenVertexArrays(1) + self._owned_vaos.append(vao) + return vao + + def _create_vbo(self) -> int: + """Creates a VBO and registers it for automatic cleanup.""" + vbo = GL.glGenBuffers(1) + self._owned_vbos.append(vbo) + return vbo + + def _create_texture(self) -> int: + """Creates a Texture and registers it for automatic cleanup.""" + texture = GL.glGenTextures(1) + self._owned_textures.append(texture) + return texture + + def _add_child_renderer(self, renderer: "BaseRenderer"): + """Adds a child renderer to be cleaned up automatically.""" + self._owned_renderers.append(renderer) + + def _remove_child_renderer(self, renderer: "BaseRenderer") -> None: + """Removes a child renderer from automatic cleanup.""" + try: + self._owned_renderers.remove(renderer) + except ValueError: + pass + + def _delete_owned(self, vao: int = 0, vbo: int = 0) -> None: + """Deletes owned GL resources and untracks them from cleanup.""" + if vao: + try: + self._owned_vaos.remove(vao) + except ValueError: + pass + GL.glDeleteVertexArrays(1, [vao]) + if vbo: + try: + self._owned_vbos.remove(vbo) + except ValueError: + pass + GL.glDeleteBuffers(1, [vbo]) + + def _cleanup_self(self) -> None: + """ + A method for subclasses to override for their specific cleanup + logic. + """ + + @final + def cleanup(self) -> None: + """Cleans up all tracked OpenGL resources.""" + try: + self._cleanup_self() + + for renderer in self._owned_renderers: + renderer.cleanup() + + if self.shader: + self.shader.cleanup() + + if self._owned_textures: + GL.glDeleteTextures( + len(self._owned_textures), self._owned_textures + ) + self._owned_textures.clear() + + if self._owned_vaos: + GL.glDeleteVertexArrays( + len(self._owned_vaos), self._owned_vaos + ) + self._owned_vaos.clear() + if self._owned_vbos: + GL.glDeleteBuffers(len(self._owned_vbos), self._owned_vbos) + self._owned_vbos.clear() + except GL.GLError: + logger.exception("Error during renderer cleanup") diff --git a/rayforge/ui_gtk/sim3d/renderer/cylinder_renderer.py b/rayforge/ui_gtk/sim3d/renderer/cylinder_renderer.py new file mode 100644 index 000000000..ef8cad9f8 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/cylinder_renderer.py @@ -0,0 +1,130 @@ +""" +Renders a cylinder wireframe for visualizing rotary mode workpieces. +""" + +import logging +import math + +import numpy as np +from OpenGL import GL + +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +class CylinderRenderer(BaseRenderer): + """Renders a wireframe cylinder to visualize rotary mode workpieces.""" + + def __init__( + self, + diameter: float, + length: float, + rings: int = 16, + length_segments: int = 8, + ): + super().__init__() + self.diameter = diameter + self.length = length + self.rings = rings + self.length_segments = length_segments + + self.vao: int = 0 + self.vbo: int = 0 + self.vertex_count = 0 + self._mvp: np.ndarray | None = None + self._color: tuple[float, float, float, float] = ( + 0.5, + 0.5, + 0.5, + 0.3, + ) + + def set_color(self, color: tuple[float, float, float, float]): + """Sets the wireframe color.""" + self._color = color + + def prepare(self, ctx: RenderContext) -> None: + """Caches the per-frame MVP matrix for the cylinder mesh.""" + self._mvp = ctx.kinematics.cylinder_mesh_mvp() + + def init_gl(self) -> None: + """Generates cylinder wireframe vertices and initializes OpenGL.""" + vertices = [] + radius = self.diameter / 2.0 + + for i in range(self.length_segments + 1): + cyl_pos = (i / self.length_segments) * self.length + for j in range(self.rings): + theta1 = (j / self.rings) * 2.0 * math.pi + theta2 = ((j + 1) / self.rings) * 2.0 * math.pi + + r1a = radius * math.sin(theta1) + r1b = radius * math.cos(theta1) + r2a = radius * math.sin(theta2) + r2b = radius * math.cos(theta2) + + p1 = [cyl_pos, r1a, r1b] + p2 = [cyl_pos, r2a, r2b] + vertices.extend(p1) + vertices.extend(p2) + + for j in range(self.rings): + theta = (j / self.rings) * 2.0 * math.pi + ra = radius * math.sin(theta) + rb = radius * math.cos(theta) + + for i in range(self.length_segments): + cyl1 = (i / self.length_segments) * self.length + cyl2 = ((i + 1) / self.length_segments) * self.length + p1 = [cyl1, ra, rb] + p2 = [cyl2, ra, rb] + vertices.extend(p1) + vertices.extend(p2) + + self.vertex_count = len(vertices) // 3 + vertex_data = np.array(vertices, dtype=np.float32) + + self.vao = self._create_vao() + self.vbo = self._create_vbo() + + GL.glBindVertexArray(self.vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + vertex_data.nbytes, + vertex_data, + GL.GL_STATIC_DRAW, + ) + + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + GL.glBindVertexArray(0) + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: + """ + Renders the cylinder wireframe. + + Args: + shaders: The shader set; the ``main`` program is used. + """ + shader = shaders.main + if not shader or not self.vao or self.vertex_count == 0: + return + if self._mvp is None: + return + + shader.use() + shader.set_mat4("uMVP", self._mvp) + shader.set_vec4("uColor", self._color) + shader.set_float("uUseVertexColor", 0.0) + shader.set_float("uHasNormals", 0.0) + + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + GL.glBindVertexArray(self.vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.vertex_count) diff --git a/rayforge/ui_gtk/sim3d/renderer/laser_beam_renderer.py b/rayforge/ui_gtk/sim3d/renderer/laser_beam_renderer.py new file mode 100644 index 000000000..a87fbb845 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/laser_beam_renderer.py @@ -0,0 +1,195 @@ +""" +A renderer for a laser beam that appears as a glowing vertical line from +above the workpiece down to the current cutting position. +""" + +import logging +import math + +import numpy as np +from OpenGL import GL + +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + +SEGMENTS = 16 + + +def _build_cylinder_verts(): + verts = [] + for i in range(SEGMENTS): + a0 = 2.0 * math.pi * i / SEGMENTS + a1 = 2.0 * math.pi * (i + 1) / SEGMENTS + c0, s0 = math.cos(a0), math.sin(a0) + c1, s1 = math.cos(a1), math.sin(a1) + verts.extend([c0, s0, 0.0]) + verts.extend([c1, s1, 0.0]) + verts.extend([c0, s0, 1.0]) + verts.extend([c1, s1, 1.0]) + verts.extend([c1, s1, 0.0]) + verts.extend([c0, s0, 1.0]) + return verts + + +def _build_disc_verts(z): + verts = [] + for i in range(SEGMENTS): + a0 = 2.0 * math.pi * i / SEGMENTS + a1 = 2.0 * math.pi * (i + 1) / SEGMENTS + verts.extend([0.0, 0.0, z]) + verts.extend([math.cos(a0), math.sin(a0), z]) + verts.extend([math.cos(a1), math.sin(a1), z]) + return verts + + +class LaserBeamRenderer(BaseRenderer): + """Renders a glowing laser beam as a world-space cylinder with caps.""" + + def __init__(self): + super().__init__() + self.vao: int = 0 + self.vbo: int = 0 + self.vertex_count: int = 0 + self._beams: list[tuple[np.ndarray, float, tuple]] = [] + self.laser_light_pos: np.ndarray | None = None + + def init_gl(self): + self.vao = self._create_vao() + self.vbo = self._create_vbo() + + verts = _build_cylinder_verts() + verts += _build_disc_verts(0.0) + verts += _build_disc_verts(1.0) + + self.vertex_count = len(verts) // 3 + data = np.array(verts, dtype=np.float32) + + GL.glBindVertexArray(self.vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, data.nbytes, data, GL.GL_STATIC_DRAW + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def prepare(self, ctx: RenderContext) -> None: + """Computes and caches the laser beams from the current state.""" + self._beams = [] + self.laser_light_pos = None + + op_player = ctx.playback.op_player + if op_player is None: + ctx.kinematics.laser_light_pos = None + return + + state = op_player.render_state() + kinematics = ctx.kinematics + if ctx.viewport is None or not kinematics.head_positions: + ctx.kinematics.laser_light_pos = None + return + + ra = kinematics.rotary_axis + margin_shift = ctx.viewport.margin_shift + vis_mat = margin_shift.astype(np.float32) + for name, (hx, hy, hz) in kinematics.head_positions.items(): + head_pos = vis_mat @ np.array([hx, hy, hz, 1.0], dtype=np.float32) + cfg = kinematics.head_configs.get(name) + if cfg is None or not cfg.valid: + continue + beam_height = cfg.beam_height + beam_color = cfg.beam_color + if not state.laser_on: + continue + if ra is not None and kinematics.has_rotary: + rotary_heads = kinematics.rotary_head_positions or {} + if name in rotary_heads: + beam_pos = vis_mat @ np.array( + [*rotary_heads[name], 1.0], dtype=np.float32 + ) + else: + beam_pos = head_pos.copy() + else: + beam_pos = head_pos.copy() + self._beams.append((beam_pos[:3], beam_height, beam_color)) + self.laser_light_pos = beam_pos[:3].astype(np.float32) + + ctx.kinematics.laser_light_pos = self.laser_light_pos + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs): + if not self.vao: + return + + shader = shaders.main + if shader is None: + return + + proj_matrix = ctx.camera.proj_matrix + view_matrix = ctx.camera.view_matrix + viewport_height = ctx.camera.viewport_height + + p11 = float(proj_matrix[1, 1]) + if abs(p11) < 1e-6: + return + is_persp = abs(float(proj_matrix[3, 2])) > 0.1 + + GL.glDisable(GL.GL_DEPTH_TEST) + GL.glEnable(GL.GL_BLEND) + shader.use() + shader.set_float("uHasNormals", 0.0) + shader.set_float("uUseVertexColor", 0.0) + shader.set_int("uExecutedVertexCount", -1) + GL.glBindVertexArray(self.vao) + + for position, beam_height, color in self._beams: + if is_persp: + view_pos = view_matrix.astype(np.float64) @ np.array( + [ + float(position[0]), + float(position[1]), + float(position[2]), + 1.0, + ], + dtype=np.float64, + ) + depth = max(-view_pos[2], 0.1) + wpp = 2.0 * depth / (p11 * max(viewport_height, 1)) + else: + wpp = 2.0 / (p11 * max(viewport_height, 1)) + + cr, cg, cb = color[:3] + wr = min(cr * 0.5 + 0.5, 1.0) + wg = min(cg * 0.5 + 0.5, 1.0) + wb = min(cb * 0.5 + 0.5, 1.0) + + num_passes = 16 + for i in range(num_passes, 0, -1): + t = i / num_passes + radius_px = 0.5 + t * 10.0 + alpha = 0.08 * (1.0 - t) ** 2 + pass_color = ( + wr + (1.0 - wr) * (1.0 - t), + wg + (1.0 - wg) * (1.0 - t), + wb + (1.0 - wb) * (1.0 - t), + alpha, + ) + + r = radius_px * wpp + model = np.eye(4, dtype=np.float32) + model[0, 0] = np.float32(r) + model[1, 1] = np.float32(r) + model[2, 2] = np.float32(beam_height) + model[0, 3] = np.float32(position[0]) + model[1, 3] = np.float32(position[1]) + model[2, 3] = np.float32(position[2]) + + mvp = proj_matrix @ view_matrix @ model + shader.set_mat4("uMVP", mvp) + shader.set_float("uEmissive", 1.0) + shader.set_vec4("uColor", pass_color) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, self.vertex_count) diff --git a/rayforge/ui_gtk/sim3d/renderer/model_renderer.py b/rayforge/ui_gtk/sim3d/renderer/model_renderer.py new file mode 100644 index 000000000..3d551611b --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/model_renderer.py @@ -0,0 +1,290 @@ +""" +Renders a .glb 3D model using OpenGL triangles with per-vertex normals. +""" + +import logging +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import trimesh +from OpenGL import GL +from trimesh.visual.color import ColorVisuals +from trimesh.visual.material import PBRMaterial + +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +@dataclass +class _CachedModelData: + positions: np.ndarray + normals: np.ndarray + colors: np.ndarray | None + faces: np.ndarray + bounds: tuple[np.ndarray, np.ndarray] + triangle_count: int + + +_model_cache: dict[Path, _CachedModelData] = {} + + +def _extract_color(mesh: trimesh.Trimesh) -> np.ndarray | None: + if mesh.visual is None: + return None + if isinstance(mesh.visual, ColorVisuals): + vc = mesh.visual.vertex_colors + if vc is not None and len(vc) == len(mesh.vertices): + return np.array(vc, dtype=np.float32) / 255.0 + return None + mat = mesh.visual.material + if isinstance(mat, PBRMaterial): + base = mat.baseColorFactor + else: + base = mat.diffuse + if base is not None: + c = np.array(base, dtype=np.float32) + if c.max() > 1.0: + c = c / 255.0 + if c.shape[0] == 3: + c = np.append(c, 1.0) + return np.tile(c, (len(mesh.vertices), 1)) + return None + + +def _load_mesh_data(path: Path) -> _CachedModelData | None: + cached = _model_cache.get(path) + if cached is not None: + return cached + + try: + loaded = trimesh.load(str(path), file_type="glb") + if isinstance(loaded, trimesh.Scene): + meshes = [] + colors = [] + for node in loaded.graph.nodes_geometry: + transform, geom_name = loaded.graph.get(node) + geom = loaded.geometry[geom_name] + color = _extract_color(geom) + geom = geom.apply_transform(transform) + meshes.append(geom) + if color is not None: + colors.append(color) + mesh = trimesh.util.concatenate(meshes) + assert isinstance(mesh, trimesh.Trimesh) + has_colors = len(colors) == len(meshes) and sum( + c.shape[0] for c in colors + ) == len(mesh.vertices) + vertex_colors = ( + np.vstack(colors).astype(np.float32) if has_colors else None + ) + elif isinstance(loaded, trimesh.Trimesh): + mesh = loaded + vertex_colors = _extract_color(mesh) + else: + logger.error( + "Unexpected type from trimesh.load: %s", + type(loaded).__name__, + ) + return None + + assert isinstance(mesh, trimesh.Trimesh) + + positions = np.array(mesh.vertices, dtype=np.float32) + normals = np.array(mesh.vertex_normals, dtype=np.float32) + + y_up_to_z_up = np.array( + [[1, 0, 0], [0, 0, -1], [0, 1, 0]], dtype=np.float32 + ) + positions = (y_up_to_z_up @ positions.T).T + normals = (y_up_to_z_up @ normals.T).T + + bounds = ( + positions.min(axis=0), + positions.max(axis=0), + ) + + faces = np.array(mesh.faces, dtype=np.uint32) + triangle_count = len(faces) + + data = _CachedModelData( + positions=positions, + normals=normals, + colors=vertex_colors, + faces=faces, + bounds=bounds, + triangle_count=triangle_count, + ) + _model_cache[path] = data + return data + except Exception as e: # noqa: BLE001 - trimesh library boundary + logger.error("Failed to load model %s: %s", path, e) + return None + + +def get_model_extent(path: Path) -> float | None: + data = _load_mesh_data(path) + if data is None: + return None + bmin, bmax = data.bounds + return float(np.max(bmax - bmin)) + + +class ModelRenderer(BaseRenderer): + """Loads and renders a .glb model as GL_TRIANGLES.""" + + def __init__(self, resolved_path: Path, link_name: str = ""): + super().__init__() + self._path = resolved_path + self.link_name = link_name + self._vao: int = 0 + self._vbo_pos: int = 0 + self._vbo_norm: int = 0 + self._vbo_color: int = 0 + self._vertex_count: int = 0 + self._has_colors: bool = False + self._bounds: tuple[np.ndarray, np.ndarray] | None = None + self._loaded: bool = False + self._mesh_data: _CachedModelData | None = None + self._mvp_matrix: np.ndarray | None = None + self._model_matrix: np.ndarray | None = None + self._point_light_pos: np.ndarray | None = None + + def prepare(self, ctx: RenderContext) -> None: + """Computes and caches the per-frame matrices for the model mesh.""" + kinematics = ctx.kinematics + if not kinematics.model_world_transforms or ctx.viewport is None: + return + + t = kinematics.model_world_transforms.get(self.link_name) + if t is None: + return + + module_transform = t.astype(np.float32) + if kinematics.is_rotary: + focused = kinematics.focused_rotary_head_positions + if focused and self.link_name in focused: + pos = focused[self.link_name] + module_transform[:3, 3] = pos.astype(np.float32) + + combined = ( + ctx.camera.mvp_ui @ ctx.viewport.margin_shift @ module_transform + ) + self._mvp_matrix = combined + self._model_matrix = ctx.viewport.margin_shift @ module_transform + self._point_light_pos = kinematics.laser_light_pos + + def _load_mesh(self) -> bool: + self._mesh_data = _load_mesh_data(self._path) + if self._mesh_data is None: + return False + + flat_indices = self._mesh_data.faces.flatten() + self._positions = self._mesh_data.positions[flat_indices] + self._normals = self._mesh_data.normals[flat_indices] + self._vertex_count = len(flat_indices) + self._bounds = self._mesh_data.bounds + if self._mesh_data.colors is not None: + self._colors = self._mesh_data.colors[flat_indices] + self._has_colors = True + self._loaded = True + return True + + def init_gl(self) -> None: + if not self._loaded and not self._load_mesh(): + return + + self._vao = self._create_vao() + self._vbo_pos = self._create_vbo() + self._vbo_norm = self._create_vbo() + if self._has_colors: + self._vbo_color = self._create_vbo() + + GL.glBindVertexArray(self._vao) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._vbo_pos) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self._positions.nbytes, + self._positions, + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + if self._has_colors: + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._vbo_color) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self._colors.nbytes, + self._colors, + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(1) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._vbo_norm) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self._normals.nbytes, + self._normals, + GL.GL_STATIC_DRAW, + ) + GL.glVertexAttribPointer(2, 3, GL.GL_FLOAT, GL.GL_TRUE, 0, None) + GL.glEnableVertexAttribArray(2) + + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: + if not ctx.camera.show_models: + return + if not self._vao or self._mvp_matrix is None: + return + + shader = shaders.main + if shader is None: + return + + light_dir = np.array([0.5, 0.8, 1.0], dtype=np.float32) + fill_dir = np.array([-0.6, -0.4, 0.3], dtype=np.float32) + camera_position = ctx.camera.camera_position + model_matrix = self._model_matrix + point_light_pos = self._point_light_pos + + if model_matrix is not None and camera_position is not None: + model_inv = np.linalg.inv(model_matrix) + cam_pos = model_inv[:3, :3] @ camera_position + model_inv[:3, 3] + cam_pos = cam_pos.astype(np.float32) + if point_light_pos is not None: + point_light_pos = ( + model_inv[:3, :3] @ point_light_pos + model_inv[:3, 3] + ) + point_light_pos = point_light_pos.astype(np.float32) + else: + cam_pos = np.zeros(3, dtype=np.float32) + + shader.use() + shader.set_mat4("uMVP", self._mvp_matrix) + shader.set_float("uUseVertexColor", 1.0 if self._has_colors else 0.0) + shader.set_vec4("uColor", (0.5, 0.6, 0.7, 1.0)) + shader.set_float("uHasNormals", 1.0) + shader.set_vec3("uLightDir", light_dir) + shader.set_vec3("uLightDir2", fill_dir) + shader.set_vec3("uCameraPos", cam_pos) + if point_light_pos is not None: + shader.set_vec3("uPointLightPos", point_light_pos) + shader.set_float("uPointLightOn", 1.0) + else: + shader.set_vec3("uPointLightPos", np.zeros(3, dtype=np.float32)) + shader.set_float("uPointLightOn", 0.0) + + GL.glBindVertexArray(self._vao) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, self._vertex_count) + + @property + def bounds(self): + return self._bounds diff --git a/rayforge/ui_gtk/sim3d/renderer/ops_renderer.py b/rayforge/ui_gtk/sim3d/renderer/ops_renderer.py new file mode 100644 index 000000000..358659d68 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/ops_renderer.py @@ -0,0 +1,400 @@ +""" +A renderer for visualizing toolpath operations (Ops) in 3D. +""" + +import logging +from dataclasses import dataclass + +import numpy as np +from OpenGL import GL + +from ....simulator.scene3d import VertexLayer +from ...shared.color_lut_provider import ColorLutProvider +from ..gl_utils import ShaderSet, set_line_width +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +@dataclass +class OpsUploadPayload: + """Pre-built vertex arrays ready for GL upload. + + Constructed off the main thread by ``prepare_vertex_layer`` so the + main thread only performs the actual ``glBufferData`` uploads. + """ + + powered_vertices: np.ndarray + powered_attrib: np.ndarray + travel_vertices: np.ndarray + + +def prepare_vertex_layer( + vl: VertexLayer, show_travel_moves: bool +) -> OpsUploadPayload: + """Decompresses and concatenates a vertex layer without touching GL. + + Runs in a worker thread; the returned payload is uploaded with GL + calls on the main thread. + """ + powered_verts = vl.powered_verts.to_numpy() + powered_attrib = vl.powered_attrib.to_numpy() + travel_verts = vl.travel_verts.to_numpy() + zero_power_verts = vl.zero_power_verts.to_numpy() + + if show_travel_moves: + pv_final = np.concatenate((powered_verts, zero_power_verts)) + zero_count = zero_power_verts.size // 3 + zero_attrib = np.zeros(zero_count * 4, dtype=np.float32) + zero_attrib[3::4] = 1.0 + attrib = np.concatenate((powered_attrib.ravel(), zero_attrib)) + tv_final = travel_verts + else: + pv_final = powered_verts + attrib = powered_attrib + tv_final = np.array([], dtype=np.float32) + + logger.debug( + f"[UPLOAD] is_rotary={vl.is_rotary} " + f"powered={powered_verts.size // 3} " + f"zero_power={zero_power_verts.size // 3} " + f"total={pv_final.size // 3} " + f"travel={tv_final.size // 3} " + f"show_travel={show_travel_moves}" + ) + + return OpsUploadPayload( + powered_vertices=np.ascontiguousarray(pv_final, dtype=np.float32), + powered_attrib=np.ascontiguousarray(attrib, dtype=np.float32), + travel_vertices=np.ascontiguousarray(tv_final, dtype=np.float32), + ) + + +class OpsRenderer(BaseRenderer): + """Renders toolpath operations (cuts and travels) as colored lines.""" + + def __init__(self, is_rotary: bool = False): + """Initializes the OpsRenderer.""" + super().__init__() + self.is_rotary = is_rotary + self.powered_vao: int = 0 + self.travel_vao: int = 0 + + self.powered_vbo: int = 0 + self.powered_powers_vbo: int = 0 + self.travel_vbo: int = 0 + + self.powered_vertex_count: int = 0 + self.travel_vertex_count: int = 0 + + self.powered_offsets: np.ndarray = np.array([], dtype=np.int32) + self.travel_offsets: np.ndarray = np.array([], dtype=np.int32) + self._powered_positions: np.ndarray = np.array([], dtype=np.float32) + self._travel_positions: np.ndarray = np.array([], dtype=np.float32) + self._exec_powered = -1 + self._exec_travel = -1 + self._partial_powered_id = -1 + self._partial_powered_end = np.zeros(3, dtype=np.float32) + self._partial_travel_id = -1 + self._partial_travel_end = np.zeros(3, dtype=np.float32) + + self._color_lut_texture: int = 0 + self._num_laser_luts: int = 1 + + def init_gl(self): + self.powered_vbo = self._create_vbo() + self.powered_powers_vbo = self._create_vbo() + self.travel_vbo = self._create_vbo() + self._color_lut_texture = self._create_texture() + + self.powered_vao = self._create_vao() + GL.glBindVertexArray(self.powered_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.powered_vbo) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.powered_powers_vbo) + GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(1) + + self.travel_vao = self._create_vao() + GL.glBindVertexArray(self.travel_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.travel_vbo) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def clear(self): + """Clears the renderer's buffers and resets vertex counts.""" + self.update_from_vertex_data( + np.array([], dtype=np.float32), + np.array([], dtype=np.float32), + np.array([], dtype=np.float32), + ) + + def update_from_vertex_layer( + self, vl: VertexLayer, show_travel_moves: bool + ): + """Uploads a compiled vertex layer into the renderer's buffers. + + Prepares and uploads synchronously. The chunked upload path + prepares the payload in a worker thread and only calls + ``update_from_vertex_data`` from the main thread. + """ + payload = prepare_vertex_layer(vl, show_travel_moves) + self.update_from_vertex_data( + payload.powered_vertices, + payload.powered_attrib, + payload.travel_vertices, + ) + + def update_from_vertex_data( + self, + powered_vertices: np.ndarray, + powered_attrib: np.ndarray, + travel_vertices: np.ndarray, + ): + self.powered_vertex_count = powered_vertices.size // 3 + self._powered_positions = np.ascontiguousarray( + powered_vertices, dtype=np.float32 + ) + self._load_buffer_data(self.powered_vbo, powered_vertices) + self._load_buffer_data( + self.powered_powers_vbo, + np.ascontiguousarray(powered_attrib, dtype=np.float32), + ) + self.travel_vertex_count = travel_vertices.size // 3 + self._travel_positions = np.ascontiguousarray( + travel_vertices, dtype=np.float32 + ) + self._load_buffer_data(self.travel_vbo, travel_vertices) + + def update_color_lut(self, lut_data: np.ndarray, num_lasers: int = 1): + if not self._color_lut_texture: + return + self._num_laser_luts = num_lasers + lut = np.ascontiguousarray(lut_data, dtype=np.float32) + if lut.ndim == 3: + width, height = lut.shape[1], lut.shape[0] + else: + width, height = lut.shape[0], 1 + GL.glBindTexture(GL.GL_TEXTURE_2D, self._color_lut_texture) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + GL.GL_RGBA32F, + width, + height, + 0, + GL.GL_RGBA, + GL.GL_FLOAT, + lut, + ) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + + def update_color_lut_from(self, provider: ColorLutProvider): + """Updates the colour LUT from a shared ColorLutProvider.""" + self.update_color_lut(provider.cut_lut(), provider.num_lasers) + + def prepare(self, ctx: RenderContext) -> None: + """ + Computes the executed-vertex counts for this frame. + + Reads the playhead from ``ctx.playback.op_player`` and maps it + through the renderer's command offsets, stashing the resulting + counts so ``render`` can publish them back into ``ctx``. When + the playhead falls inside a command, a fractional executed count + is split into an int count plus a partial boundary segment (the + moved end vertex + interpolated endpoint) for a smooth reveal. + """ + exec_powered = -1 + exec_travel = -1 + self._partial_powered_id = -1 + self._partial_powered_end = np.zeros(3, dtype=np.float32) + self._partial_travel_id = -1 + self._partial_travel_end = np.zeros(3, dtype=np.float32) + op_player = ctx.playback.op_player + if op_player: + p, frac = op_player.playback_progress() + ( + exec_powered, + self._partial_powered_id, + self._partial_powered_end, + ) = self._fractional_exec_count( + self.powered_offsets, + self._powered_positions, + p, + frac, + ) + exec_travel, self._partial_travel_id, self._partial_travel_end = ( + self._fractional_exec_count( + self.travel_offsets, + self._travel_positions, + p, + frac, + ) + ) + self._exec_powered = exec_powered + self._exec_travel = exec_travel + + @staticmethod + def _fractional_exec_count( + offsets: np.ndarray, + positions: np.ndarray, + p: int, + frac: float, + ): + """Map ``(in_progress_command, fraction)`` to executed vertices. + + Returns ``(executed_count, partial_vertex_id, partial_end)``. + ``executed_count`` is the int uniform value (fully drawn + vertices plus the boundary segment); ``partial_vertex_id`` is + the end vertex of the boundary segment that ``render`` moves to + ``partial_end`` (or -1 when no partial segment exists). + """ + if len(offsets) == 0: + return -1, -1, np.zeros(3, dtype=np.float32) + total = positions.size // 3 if positions is not None else 0 + if len(offsets) < 2: + return total, -1, np.zeros(3, dtype=np.float32) + if p + 1 >= len(offsets): + p = len(offsets) - 2 + frac = 1.0 + p = max(p, 0) + base = int(offsets[p]) + span = int(offsets[p + 1]) - base + exec_f = base + frac * span + zero = np.zeros(3, dtype=np.float32) + if total == 0: + # No position data uploaded: fall back to the raw count. + return int(exec_f), -1, zero + if exec_f >= total: + return total, -1, zero + if exec_f <= 0: + return 0, -1, zero + seg = int(exec_f) // 2 + f_in_seg = exec_f - 2 * seg + if f_in_seg <= 1e-9: + return 2 * seg, -1, zero + if positions is None or 2 * seg + 1 >= total: + return 2 * seg + 2, -1, zero + v0 = positions[2 * seg * 3 : 2 * seg * 3 + 3] + v1 = positions[(2 * seg + 1) * 3 : (2 * seg + 1) * 3 + 3] + partial_end = (v0 + (v1 - v0) * (f_in_seg / 2.0)).astype(np.float32) + return 2 * seg + 2, 2 * seg + 1, partial_end + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: + """ + Renders the toolpaths. The vertices are assumed to be in world space. + + Publishes the executed-vertex counts computed in ``prepare`` into + ``ctx``, then pulls the MVP and pending alpha from it. + + Args: + ctx: The current render context (carries color set, line width, + travel-move visibility, and per-frame execution state). + shaders: The shader set; the ``main`` program is used. + """ + ctx.playback.executed_vertex_count = self._exec_powered + ctx.playback.executed_travel_vertex_count = self._exec_travel + + shader = shaders.main + if shader is None: + return + + mvp = ctx.kinematics.mvp_for(self.is_rotary) + if mvp is None: + return + + colors = ctx.camera.color_set + show_travel_moves = ctx.camera.show_travel_moves + line_width = ctx.camera.line_width + executed_vertex_count = ctx.playback.executed_vertex_count + executed_travel_vertex_count = ( + ctx.playback.executed_travel_vertex_count + ) + alpha_pending = ctx.playback.alpha_pending + + if executed_vertex_count > self.powered_vertex_count: + raise ValueError( + f"executed_vertex_count ({executed_vertex_count}) " + f"> powered_vertex_count ({self.powered_vertex_count})" + ) + + shader.use() + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + # The toolpath draws on top of the raster texture; never cull it + # by the surface depth (which would split lines on a cylinder's + # curved face). Depth writes stay off so the trail/ring drawn + # afterwards is unaffected. + GL.glDepthMask(GL.GL_FALSE) + GL.glDepthFunc(GL.GL_ALWAYS) + shader.set_mat4("uMVP", mvp) + shader.set_float("uHasNormals", 0.0) + + shader.set_int("uExecutedVertexCount", executed_vertex_count) + shader.set_float("uAlphaPending", alpha_pending) + shader.set_float("uEmissive", 1.0) + if self._partial_powered_id >= 0: + shader.set_int("uPartialVertexID", self._partial_powered_id) + shader.set_vec3("uPartialEnd", self._partial_powered_end) + else: + shader.set_int("uPartialVertexID", -1) + shader.set_vec3("uPartialEnd", (0.0, 0.0, 0.0)) + + if self.powered_vertex_count > 0: + set_line_width(line_width) + shader.set_float("uUsePowerLUT", 1.0) + shader.set_int("uNumLaserLUTs", self._num_laser_luts) + shader.set_vec4("uZeroPowerColor", colors.get_rgba("zero_power")) + GL.glActiveTexture(GL.GL_TEXTURE1) + GL.glBindTexture(GL.GL_TEXTURE_2D, self._color_lut_texture) + shader.set_int("uColorLUT", 1) + GL.glBindVertexArray(self.powered_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.powered_vertex_count) + + should_draw_travel = self.travel_vertex_count > 0 and ( + executed_travel_vertex_count >= 0 or show_travel_moves + ) + if should_draw_travel: + set_line_width(line_width) + shader.set_float("uUsePowerLUT", 0.0) + shader.set_float("uUseVertexColor", 0.0) + shader.set_float("uEmissive", 0.0) + shader.set_int( + "uExecutedVertexCount", executed_travel_vertex_count + ) + if self._partial_travel_id >= 0: + shader.set_int("uPartialVertexID", self._partial_travel_id) + shader.set_vec3("uPartialEnd", self._partial_travel_end) + else: + shader.set_int("uPartialVertexID", -1) + shader.set_vec3("uPartialEnd", (0.0, 0.0, 0.0)) + shader.set_vec4("uColor", colors.get_rgba("travel")) + GL.glBindVertexArray(self.travel_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self.travel_vertex_count) + + def _load_buffer_data(self, vbo: int, data: np.ndarray): + """Loads vertex data into a VBO.""" + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + data.nbytes if data.size > 0 else 0, + data if data.size > 0 else None, + GL.GL_DYNAMIC_DRAW, + ) diff --git a/rayforge/ui_gtk/sim3d/renderer/plane_renderer.py b/rayforge/ui_gtk/sim3d/renderer/plane_renderer.py new file mode 100644 index 000000000..92deecc76 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/plane_renderer.py @@ -0,0 +1,99 @@ +""" +A simple renderer for a 2D plane in 3D space. +""" + +from __future__ import annotations + +import logging + +import numpy as np +from OpenGL import GL + +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +class PlaneRenderer(BaseRenderer): + """Renders a single, colored plane on the XY axis.""" + + def __init__( + self, + width: float, + height: float, + color: tuple[float, float, float, float], + z_offset: float = 0.0, + ): + """Initializes the PlaneRenderer.""" + super().__init__() + self.width = width + self.height = height + self.color = color + self.z_offset = z_offset + self.vao: int = 0 + self.vbo: int = 0 + self.vertex_count: int = 0 + + def init_gl(self) -> None: + """Creates the VAO and VBO for the plane.""" + vertices = [ + 0.0, + 0.0, + self.z_offset, + self.width, + 0.0, + self.z_offset, + 0.0, + self.height, + self.z_offset, + self.width, + 0.0, + self.z_offset, + self.width, + self.height, + self.z_offset, + 0.0, + self.height, + self.z_offset, + ] + self.vertex_count = len(vertices) // 3 + + # Use the base class helpers to create and track resources + self.vao = self._create_vao() + self.vbo = self._create_vbo() + + GL.glBindVertexArray(self.vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + data = np.array(vertices, dtype=np.float32) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, data.nbytes, data, GL.GL_STATIC_DRAW + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def prepare(self, ctx: RenderContext) -> None: + """No per-frame state to prepare.""" + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: + """Draws the plane.""" + if not self.vao: + return + + shader = shaders.main + if shader is None: + return + + mvp = ctx.camera.mvp_ui @ ctx.viewport.model_matrix + + shader.set_mat4("uMVP", mvp) + shader.set_vec4("uColor", self.color) + shader.set_float("uHasNormals", 0.0) + shader.set_float("uUseVertexColor", 0.0) + + GL.glBindVertexArray(self.vao) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, self.vertex_count) diff --git a/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py b/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py new file mode 100644 index 000000000..780e3919f --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py @@ -0,0 +1,269 @@ +""" +A ring-buffer GPU renderer for progressively revealing raster scanlines +during simulation playback. + +The buffer has a fixed vertex capacity. As the playhead advances through +ScanLinePowerCommands, their powered line-segments are uploaded into the +ring, wrapping around as needed. When all scanlines for a given texture +instance have been fully executed the texture is un-dimmed and those ring +slots become available for recycling. +""" + +import numpy as np +from OpenGL import GL + +from ....simulator.scene3d import ScanlineOverlayLayer +from ...shared.color_lut_provider import ColorLutProvider +from ..gl_utils import ShaderSet, set_line_width +from ..render_context import RenderContext +from .base import BaseRenderer + + +class RingBufferRenderer(BaseRenderer): + """ + Renders scanline line-segments from a fixed-size ring buffer. + + The caller encodes powered pixel-segments for each ScanLinePowerCommand + and appends them in command-index order. ``render()`` draws only the + first *n* vertices, where *n* corresponds to the playhead position. + """ + + def __init__( + self, capacity_vertices: int = 4_000_000, is_rotary: bool = False + ): + super().__init__() + self._capacity = capacity_vertices + self.is_rotary = is_rotary + self.vao: int = 0 + self.pos_vbo: int = 0 + self.pow_vbo: int = 0 + self.vertex_count: int = 0 + self.ring_offsets: np.ndarray = np.array([], dtype=np.int32) + self._positions: np.ndarray = np.array([], dtype=np.float32) + self._exec_ring = -1 + self._partial_ring_id = -1 + self._partial_ring_end = np.zeros(3, dtype=np.float32) + self._color_lut_texture: int = 0 + self._num_laser_luts: int = 1 + + def init_gl(self): + self.pos_vbo = self._create_vbo() + self.pow_vbo = self._create_vbo() + self._color_lut_texture = self._create_texture() + + zeros = np.zeros(self._capacity * 3, dtype=np.float32) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.pos_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, zeros.nbytes, zeros, GL.GL_DYNAMIC_DRAW + ) + + zeros_pow = np.zeros(self._capacity * 4, dtype=np.float32) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.pow_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + zeros_pow.nbytes, + zeros_pow, + GL.GL_DYNAMIC_DRAW, + ) + + self.vao = self._create_vao() + GL.glBindVertexArray(self.vao) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.pos_vbo) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.pow_vbo) + GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(1) + + GL.glBindVertexArray(0) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + + def upload( + self, + positions: np.ndarray, + attrib: np.ndarray, + ): + pos = np.ascontiguousarray(positions, dtype=np.float32).ravel() + n = pos.size // 3 + assert n <= self._capacity, ( + f"Scanline overlay has {n} vertices but ring capacity is " + f"{self._capacity}" + ) + + self._positions = pos + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.pos_vbo) + GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, pos.nbytes, pos) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.pow_vbo) + a = np.ascontiguousarray(attrib, dtype=np.float32) + GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, a.nbytes, a) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) + self.vertex_count = n + + def update_from_overlay_layer(self, ol: ScanlineOverlayLayer): + """Uploads a compiled scanline overlay layer into the ring buffer.""" + positions = ol.positions.to_numpy() + attrib = ol.overlay_attrib.to_numpy() + self.update_from_overlay_layer_payload(positions, attrib) + + def update_from_overlay_layer_payload( + self, positions: np.ndarray, attrib: np.ndarray + ): + """Uploads pre-decompressed overlay arrays into the ring buffer.""" + self.upload(positions.ravel(), attrib) + + def update_color_lut(self, lut_data: np.ndarray, num_lasers: int = 1): + if not self._color_lut_texture: + return + self._num_laser_luts = num_lasers + lut = np.ascontiguousarray(lut_data, dtype=np.float32) + if lut.ndim == 3: + width, height = lut.shape[1], lut.shape[0] + else: + width, height = lut.shape[0], 1 + GL.glBindTexture(GL.GL_TEXTURE_2D, self._color_lut_texture) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + GL.GL_RGBA32F, + width, + height, + 0, + GL.GL_RGBA, + GL.GL_FLOAT, + lut, + ) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + + def update_color_lut_from(self, provider: ColorLutProvider): + """Updates the colour LUT from a shared ColorLutProvider.""" + self.update_color_lut(provider.ring_lut_2d(), provider.num_lasers) + + def clear(self): + self.vertex_count = 0 + + def prepare(self, ctx: RenderContext) -> None: + """ + Computes the executed-vertex count for this frame. + + Reads the playhead from ``ctx.playback.op_player`` and maps it + through the renderer's command offsets, stashing the resulting + count so ``render`` can publish it back into ``ctx``. When the + playhead falls inside a command, a fractional executed count is + split into an int count plus a partial boundary segment for a + smooth reveal. + """ + exec_ring = -1 + self._partial_ring_id = -1 + self._partial_ring_end = np.zeros(3, dtype=np.float32) + op_player = ctx.playback.op_player + if op_player: + p, frac = op_player.playback_progress() + exec_ring, self._partial_ring_id, self._partial_ring_end = ( + self._fractional_exec_count( + self.ring_offsets, self._positions, p, frac + ) + ) + self._exec_ring = exec_ring + + @staticmethod + def _fractional_exec_count(offsets, positions, p, frac): + """Map ``(in_progress_command, fraction)`` to executed vertices. + + Returns ``(executed_count, partial_vertex_id, partial_end)``; + see :meth:`OpsRenderer._fractional_exec_count` for details. + """ + if len(offsets) == 0: + return -1, -1, np.zeros(3, dtype=np.float32) + total = positions.size // 3 if positions is not None else 0 + if len(offsets) < 2: + return total, -1, np.zeros(3, dtype=np.float32) + if p + 1 >= len(offsets): + p = len(offsets) - 2 + frac = 1.0 + p = max(p, 0) + base = int(offsets[p]) + span = int(offsets[p + 1]) - base + exec_f = base + frac * span + zero = np.zeros(3, dtype=np.float32) + if total == 0: + # No position data uploaded: fall back to the raw count. + return int(exec_f), -1, zero + if exec_f >= total: + return total, -1, zero + if exec_f <= 0: + return 0, -1, zero + seg = int(exec_f) // 2 + f_in_seg = exec_f - 2 * seg + if f_in_seg <= 1e-9: + return 2 * seg, -1, zero + if positions is None or 2 * seg + 1 >= total: + return 2 * seg + 2, -1, zero + v0 = positions[2 * seg * 3 : 2 * seg * 3 + 3] + v1 = positions[(2 * seg + 1) * 3 : (2 * seg + 1) * 3 + 3] + partial_end = (v0 + (v1 - v0) * (f_in_seg / 2.0)).astype(np.float32) + return 2 * seg + 2, 2 * seg + 1, partial_end + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs): + if self.vertex_count == 0: + return + + ctx.playback.executed_vertex_count = self._exec_ring + + shader = shaders.main + if shader is None: + return + + mvp = ctx.kinematics.mvp_for(self.is_rotary) + if mvp is None: + return + + draw_count = self.vertex_count + executed_vertex_count = ctx.playback.executed_vertex_count + + line_width = ctx.camera.line_width + shader.use() + shader.set_mat4("uMVP", mvp) + shader.set_float("uHasNormals", 0.0) + shader.set_float("uUsePowerLUT", 1.0) + shader.set_int("uNumLaserLUTs", self._num_laser_luts) + shader.set_vec4( + "uZeroPowerColor", ctx.camera.color_set.get_rgba("zero_power") + ) + shader.set_int("uExecutedVertexCount", executed_vertex_count) + shader.set_float("uAlphaPending", ctx.playback.alpha_pending) + if self._partial_ring_id >= 0: + shader.set_int("uPartialVertexID", self._partial_ring_id) + shader.set_vec3("uPartialEnd", self._partial_ring_end) + else: + shader.set_int("uPartialVertexID", -1) + shader.set_vec3("uPartialEnd", (0.0, 0.0, 0.0)) + + GL.glActiveTexture(GL.GL_TEXTURE1) + GL.glBindTexture(GL.GL_TEXTURE_2D, self._color_lut_texture) + shader.set_int("uColorLUT", 1) + + # The scanline trail must always draw on top of the toolpath and + # the raster texture; never cull it by surface depth (which would + # let travel lines or the texture's depth split the trail on a + # cylinder). Depth writes stay off so later geometry is unaffected. + GL.glDepthFunc(GL.GL_ALWAYS) + GL.glDepthMask(GL.GL_FALSE) + set_line_width(line_width) + GL.glBindVertexArray(self.vao) + GL.glDrawArrays(GL.GL_LINES, 0, draw_count) diff --git a/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py b/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py new file mode 100644 index 000000000..29cb64732 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py @@ -0,0 +1,661 @@ +""" +A composite renderer owning all scene GPU resources for the 3D canvas. + +The SceneRenderer owns the child renderers and shaders plus the per-layer +collections (ops/ring renderers, cylinders, models). Canvas3D is +responsible for the GL context lifecycle and per-frame state; it delegates +resource creation, rebuilds and theme/colour application to this class. +""" + +import logging +from dataclasses import dataclass, field +from typing import Optional, Protocol + +import numpy as np +from OpenGL.error import GLError + +from ....shared.units.formatter import ( + get_default_grid_step_mm, + get_preferred_unit_factor, +) +from ....simulator.scene3d import ( + CompiledSceneArtifact, + ScanlineOverlayLayer, + VertexLayer, +) +from ...shared.color_lut_provider import ColorLutProvider +from ..gl_state import render_pass +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from ..shader import ( + BackgroundShader, + Shader, + SimpleShader, + TextShader, + TextureShader, +) +from ..viewport import ViewportConfig +from .axis_renderer_3d import AxisRenderer3D +from .background_renderer import BackgroundRenderer +from .base import BaseRenderer +from .cylinder_renderer import CylinderRenderer +from .laser_beam_renderer import LaserBeamRenderer +from .model_renderer import ModelRenderer +from .ops_renderer import OpsRenderer, OpsUploadPayload, prepare_vertex_layer +from .ring_buffer_renderer import RingBufferRenderer +from .texture_renderer import ( + PreparedTextureLayer, + TextureArtifactRenderer, + prepare_texture_layer, +) +from .zone_renderer import ZoneRenderer + +logger = logging.getLogger(__name__) + + +def match_vertex_layer( + vertex_layers: list[VertexLayer], is_rotary: bool +) -> VertexLayer | None: + """Returns the vertex layer matching the given rotary flag.""" + for vl in vertex_layers: + if vl.is_rotary == is_rotary: + return vl + return None + + +def match_overlay_layer( + overlay_layers: list[ScanlineOverlayLayer], is_rotary: bool +) -> ScanlineOverlayLayer | None: + """Returns the overlay layer matching the given rotary flag.""" + for ol in overlay_layers: + if ol.is_rotary == is_rotary: + return ol + return None + + +def _ring_capacity_for( + artifact: "CompiledSceneArtifact", is_rotary: bool +) -> int: + """Returns the ring buffer capacity needed for an overlay layer.""" + ol = match_overlay_layer(artifact.overlay_layers, is_rotary) + if ol is None: + return 0 + # Float32, 3 components per vertex. + return ol.positions.uncompressed_size // (3 * 4) + + +class UploadItem(Protocol): + """A single unit of work in a chunked scene upload.""" + + def prepare(self) -> "OpsUploadPayload | None": + """Prepares this item's data off the main thread.""" + raise NotImplementedError + + def upload(self) -> None: + """Uploads this item's data into its renderer.""" + raise NotImplementedError + + +@dataclass +class OpsLayerUploadItem: + """Uploads a vertex layer into an ops renderer.""" + + renderer: "OpsRenderer" + vertex_layer: VertexLayer + show_travel_moves: bool + _payload: OpsUploadPayload | None = field( + default=None, init=False, repr=False + ) + + def prepare(self) -> OpsUploadPayload: + """Decompresses/concat vertex arrays off the main thread. + + Stores the built payload on the item so the main-thread + ``upload`` only performs the GL buffer uploads. + """ + payload = prepare_vertex_layer( + self.vertex_layer, self.show_travel_moves + ) + self._payload = payload + return payload + + def upload(self) -> None: + payload = self._payload + if payload is None: + payload = self.prepare() + self.renderer.update_from_vertex_data( + payload.powered_vertices, + payload.powered_attrib, + payload.travel_vertices, + ) + + +@dataclass +class OverlayLayerUploadItem: + """Uploads an overlay layer into a ring renderer.""" + + renderer: "RingBufferRenderer" + overlay_layer: ScanlineOverlayLayer + _positions: np.ndarray | None = field(default=None, init=False, repr=False) + _attrib: np.ndarray | None = field(default=None, init=False, repr=False) + + def prepare(self) -> None: + """Decompresses overlay arrays off the main thread.""" + self._positions = self.overlay_layer.positions.to_numpy() + self._attrib = self.overlay_layer.overlay_attrib.to_numpy() + + def upload(self) -> None: + positions = self._positions + attrib = self._attrib + if positions is None or attrib is None: + self.prepare() + positions = self._positions + attrib = self._attrib + assert positions is not None and attrib is not None + self.renderer.update_from_overlay_layer_payload(positions, attrib) + + +@dataclass +class TextureUploadItem: + """Uploads the artifact's texture layers into the texture renderer.""" + + renderer: Optional["TextureArtifactRenderer"] + artifact: CompiledSceneArtifact + _prepared_layers: list[PreparedTextureLayer] | None = field( + default=None, init=False, repr=False + ) + + def prepare(self) -> None: + """Decompresses/mip-maps texture layers off the main thread.""" + if self.renderer is None: + return + prepared = [ + prepare_texture_layer( + tl, + self.artifact.laser_uid_order, + self.renderer.max_texture_size, + ) + for tl in self.artifact.texture_layers + ] + self._prepared_layers = prepared + + def upload(self) -> None: + if self.renderer is None: + return + prepared = self._prepared_layers + if prepared is None: + self.prepare() + prepared = self._prepared_layers + assert prepared is not None + self.renderer.upload_prepared(prepared) + + +class SceneRenderer(BaseRenderer): + """Owns the GPU renderers, shaders and collections for the 3D scene.""" + + def __init__(self): + super().__init__() + self.main_shader: Shader | None = None + self.text_shader: Shader | None = None + self.texture_shader: Shader | None = None + self.background_shader: Shader | None = None + self.shader_set: ShaderSet | None = None + + self.axis_renderer: AxisRenderer3D | None = None + self.background_renderer: BackgroundRenderer | None = ( + BackgroundRenderer() + ) + self.texture_renderer: TextureArtifactRenderer | None = None + self.zone_renderer: ZoneRenderer | None = None + self.laser_beam_renderer: LaserBeamRenderer | None = ( + LaserBeamRenderer() + ) + + self.ops_renderers: list[OpsRenderer] = [] + self.ring_renderers: list[RingBufferRenderer] = [] + self.cylinder_renderers: dict[float, CylinderRenderer] = {} + self.model_renderers: list[ModelRenderer] = [] + self.had_rotary_layers = False + self.cylinder_transform = np.eye(4, dtype=np.float64) + + self._viewport: ViewportConfig | None = None + self._font_family: str | None = None + + # Ordered list of (renderer, shader_keys) in draw order. The + # deferred ring passes come after the texture renderer so rings + # draw on top of the textures during playback. + self.render_registry: list[tuple[BaseRenderer, tuple[str, ...]]] = [] + + def _rebuild_registry(self) -> None: + """Rebuilds the render registry from the current children.""" + registry: list[tuple[BaseRenderer, tuple[str, ...]]] = [] + + # Draw the world first. + if self.background_renderer is not None: + registry.append((self.background_renderer, ("background",))) + if self.axis_renderer is not None: + registry.append((self.axis_renderer, ("main", "text"))) + + # Draw the hardware. + if self.zone_renderer is not None: + registry.append((self.zone_renderer, ("main",))) + for renderer in self.model_renderers: + registry.append((renderer, ("main",))) + for renderer in self.cylinder_renderers.values(): + registry.append((renderer, ("main",))) + + # Draw the ops and textures. + if self.texture_renderer is not None: + registry.append((self.texture_renderer, ("texture",))) + for renderer in self.ops_renderers: + registry.append((renderer, ("main",))) + for renderer in self.ring_renderers: + registry.append((renderer, ("main",))) + if self.laser_beam_renderer is not None: + registry.append((self.laser_beam_renderer, ("main",))) + self.render_registry = registry + + def set_cylinder_transform(self, transform: np.ndarray): + """Stores the assembly's cylinder base transform.""" + self.cylinder_transform = transform + + def set_viewport(self, viewport: ViewportConfig): + """Stores the viewport config used to build children in init_gl.""" + self._viewport = viewport + + def set_font_family(self, font_family: str): + """Stores the font family used to build the axis labels.""" + self._font_family = font_family + + def init_gl(self): + """Creates and initializes all scene shaders and renderers.""" + viewport = self._viewport + if viewport is None: + viewport = ViewportConfig.default() + font_family = self._font_family or "sans-serif" + self.main_shader = SimpleShader() + self.text_shader = TextShader() + self.texture_shader = TextureShader() + self.background_shader = BackgroundShader() + self.shader_set = ShaderSet( + main=self.main_shader, + text=self.text_shader, + texture=self.texture_shader, + background=self.background_shader, + ) + + self.axis_renderer = AxisRenderer3D( + viewport.width_mm, + viewport.depth_mm, + grid_size_mm=get_default_grid_step_mm(), + grid_unit_factor=get_preferred_unit_factor("length"), + font_family=font_family, + ) + self.apply_extent_frame(viewport) + self.axis_renderer.init_gl() + self.texture_renderer = TextureArtifactRenderer() + self.texture_renderer.init_gl() + if self.laser_beam_renderer: + self.laser_beam_renderer.init_gl() + try: + if self.background_renderer: + self.background_renderer.init_gl() + except GLError as e: + logger.warning( + "Background renderer init failed, " + "falling back to clear color: %s", + e, + ) + self.background_renderer = None + self.zone_renderer = ZoneRenderer() + self.zone_renderer.init_gl() + + for renderer in ( + self.axis_renderer, + self.background_renderer, + self.texture_renderer, + self.zone_renderer, + self.laser_beam_renderer, + ): + if renderer is not None: + self._add_child_renderer(renderer) + + self._rebuild_registry() + + def _cleanup_self(self): + """Cleans up dynamically-rebuilt collections and shaders. + + Static children (axis, background, texture, zone, laser) are + cleaned automatically by the base ``cleanup()`` walking + ``_owned_renderers``. Only the rebuilt collections and the + owned shaders need manual cleanup here. + """ + for renderer in self.ops_renderers: + renderer.cleanup() + for renderer in self.ring_renderers: + renderer.cleanup() + for renderer in self.cylinder_renderers.values(): + renderer.cleanup() + for renderer in self.model_renderers: + renderer.cleanup() + if self.main_shader: + self.main_shader.cleanup() + if self.text_shader: + self.text_shader.cleanup() + if self.texture_shader: + self.texture_shader.cleanup() + if self.background_shader: + self.background_shader.cleanup() + + def apply_extent_frame(self, viewport: ViewportConfig): + """Applies the extent frame to the axis renderer if present.""" + if not self.axis_renderer or viewport.extent_frame is None: + return + fx, fy, fw, fh = viewport.extent_frame + ml = -fx + mb = -fy + mt = fh - viewport.depth_mm - mb + mr = fw - viewport.width_mm - ml + if viewport.x_right: + fx = -mr + if viewport.y_down: + fy = -mt + self.axis_renderer.set_extent_frame(fx, fy, fw, fh, show=True) + + def update_axis_from_viewport(self, viewport: ViewportConfig) -> bool: + """Rebuilds the axis renderer if the viewport dimensions changed.""" + if not self.axis_renderer: + return False + if ( + self.axis_renderer.width_mm == viewport.width_mm + and self.axis_renderer.height_mm == viewport.depth_mm + ): + self.apply_extent_frame(viewport) + return False + font_family = self.axis_renderer.font_family + self._remove_child_renderer(self.axis_renderer) + self.axis_renderer.cleanup() + self.axis_renderer = AxisRenderer3D( + viewport.width_mm, + viewport.depth_mm, + grid_size_mm=get_default_grid_step_mm(), + grid_unit_factor=get_preferred_unit_factor("length"), + font_family=font_family, + ) + self.apply_extent_frame(viewport) + self.axis_renderer.init_gl() + self._add_child_renderer(self.axis_renderer) + self._rebuild_registry() + return True + + def update_cylinders_from_doc(self, doc, viewport, machine): + """Reads chuck diameters from the assembly and rebuilds cylinders.""" + desired_diameters: dict[float, bool] = {} + if machine and self.had_rotary_layers: + for layer in doc.layers: + if layer.rotary_enabled and layer.rotary_diameter > 0: + desired_diameters[layer.rotary_diameter] = True + + max_length = viewport.width_mm + if machine: + default_rm = machine.get_default_rotary_module() + if default_rm: + max_length = min(max_length, default_rm.max_workpiece_length) + + for diameter, renderer in list(self.cylinder_renderers.items()): + if diameter not in desired_diameters: + renderer.cleanup() + del self.cylinder_renderers[diameter] + + grid_size = ( + self.axis_renderer.grid_size_mm if self.axis_renderer else 10.0 + ) + length_segments = max(1, round(max_length / grid_size)) + + for diameter in desired_diameters: + if diameter not in self.cylinder_renderers: + renderer = CylinderRenderer( + diameter=diameter, + length=max_length, + rings=24, + length_segments=length_segments, + ) + renderer.set_color((0.4, 0.6, 0.8, 0.25)) + renderer.init_gl() + self.cylinder_renderers[diameter] = renderer + logger.debug( + f"Initialized cylinder renderer: " + f"diameter={diameter}mm, length={max_length}mm" + ) + self._rebuild_registry() + + def update_zones_from_machine(self, machine): + """Pushes the machine's no-go zones into the zone renderer.""" + if not self.zone_renderer: + return + if not machine: + return + zones = list(machine.nogo_zones.values()) + self.zone_renderer.update_zones(zones) + + def clear_models(self): + """Removes all model renderers without rebuilding.""" + for renderer in self.model_renderers: + renderer.cleanup() + self.model_renderers.clear() + self._rebuild_registry() + + def update_models_from_context(self, context, machine): + """Rebuilds renderers for all assembly links with 3D models.""" + self.clear_models() + if not machine: + return + + assembly = machine.assembly + if assembly is None: + return + + model_links = assembly.get_model_links() + logger.debug("Model renderers: %d links with models", len(model_links)) + + for link in model_links: + assert link.model is not None + logger.debug( + "Model renderers: resolving model %s for link %s", + link.model, + link.name, + ) + resolved = context.model_mgr.resolve(link.model) + if resolved is None: + logger.warning( + "Model file not found: %s, skipping.", + link.model.path, + ) + continue + + renderer = ModelRenderer(resolved, link_name=link.name) + renderer.init_gl() + logger.debug( + "Model renderer created: vao=%d, vertex_count=%d, bounds=%s", + renderer._vao, + renderer._vertex_count, + renderer.bounds, + ) + self.model_renderers.append(renderer) + self._rebuild_registry() + + def apply_background_colors(self, bg_color, bg_light): + """Applies the resolved background colors to the background.""" + if self.background_renderer: + self.background_renderer.set_colors(bg_color, bg_light) + + def apply_axis_colors(self, axis_color, grid_color, bg_plane_color): + """Applies the resolved foreground colors to the axis renderer.""" + if self.axis_renderer: + self.axis_renderer.set_background_color(bg_plane_color) + self.axis_renderer.set_axis_color(axis_color) + self.axis_renderer.set_label_color(axis_color) + self.axis_renderer.set_grid_color(grid_color) + + def update_color_luts(self, provider: ColorLutProvider | None): + """Fans out the shared colour LUT provider to all consumers.""" + if provider is None: + return + for renderer in self.ops_renderers: + renderer.update_color_lut_from(provider) + for renderer in self.ring_renderers: + renderer.update_color_lut_from(provider) + + if self.texture_renderer: + if provider.has_lasers: + logger.debug( + f"[COLOR_LUT] Using multi-laser 2D LUT " + f"({provider.num_lasers} lasers)" + ) + self.texture_renderer.update_color_lut_from(provider) + + def update_from_artifact( + self, artifact: CompiledSceneArtifact, show_travel_moves: bool + ): + """Rebuilds the per-layer ops/ring renderers from an artifact.""" + for renderer in self.ops_renderers: + renderer.cleanup() + for renderer in self.ring_renderers: + renderer.cleanup() + self.ops_renderers.clear() + self.ring_renderers.clear() + + for vl in artifact.vertex_layers: + ops = OpsRenderer(is_rotary=vl.is_rotary) + ops.init_gl() + ops.update_from_vertex_layer(vl, show_travel_moves) + ops.powered_offsets = vl.powered_cmd_offsets + ops.travel_offsets = vl.travel_cmd_offsets + self.ops_renderers.append(ops) + + ring = RingBufferRenderer(is_rotary=vl.is_rotary) + ring.init_gl() + ol = match_overlay_layer(artifact.overlay_layers, vl.is_rotary) + if ol is not None: + ring.update_from_overlay_layer(ol) + ring.ring_offsets = ol.cmd_offsets + else: + ring.clear() + ring.ring_offsets = np.array([], dtype=np.int32) + self.ring_renderers.append(ring) + + if self.texture_renderer: + self.texture_renderer.update_from_artifact(artifact) + self._rebuild_registry() + + def prepare_chunked_upload( + self, artifact: CompiledSceneArtifact, show_travel_moves: bool + ) -> list[UploadItem]: + """Creates fresh per-layer renderers and returns upload items.""" + for renderer in self.ops_renderers: + renderer.cleanup() + for renderer in self.ring_renderers: + renderer.cleanup() + self.ops_renderers.clear() + self.ring_renderers.clear() + + upload_items: list[UploadItem] = [] + + for vl in artifact.vertex_layers: + ops = OpsRenderer(is_rotary=vl.is_rotary) + ops.init_gl() + self.ops_renderers.append(ops) + upload_items.append(OpsLayerUploadItem(ops, vl, show_travel_moves)) + + ring = RingBufferRenderer( + capacity_vertices=_ring_capacity_for(artifact, vl.is_rotary), + is_rotary=vl.is_rotary, + ) + ring.init_gl() + self.ring_renderers.append(ring) + + for ol in artifact.overlay_layers: + for ring in self.ring_renderers: + if ring.is_rotary == ol.is_rotary: + upload_items.append(OverlayLayerUploadItem(ring, ol)) + break + + upload_items.append(TextureUploadItem(self.texture_renderer, artifact)) + self._rebuild_registry() + return upload_items + + def upload_chunk(self, item: UploadItem) -> None: + """Processes one prepared per-layer upload item.""" + item.upload() + + def clear_layers(self) -> None: + """Clears all per-layer ops/ring/texture GPU buffers.""" + for renderer in self.ops_renderers: + renderer.clear() + for renderer in self.ring_renderers: + renderer.clear() + if self.texture_renderer: + self.texture_renderer.clear() + + def extract_playback_offsets(self, artifact: CompiledSceneArtifact): + """Stores each renderer's playback offsets from an artifact.""" + for renderer in self.ops_renderers: + vl = match_vertex_layer(artifact.vertex_layers, renderer.is_rotary) + if vl is not None: + renderer.powered_offsets = vl.powered_cmd_offsets + renderer.travel_offsets = vl.travel_cmd_offsets + else: + renderer.powered_offsets = np.array([], dtype=np.int32) + renderer.travel_offsets = np.array([], dtype=np.int32) + + for renderer in self.ring_renderers: + ol = match_overlay_layer( + artifact.overlay_layers, renderer.is_rotary + ) + if ol is not None: + renderer.ring_offsets = ol.cmd_offsets + else: + renderer.ring_offsets = np.array([], dtype=np.int32) + + def prepare(self, ctx: RenderContext) -> None: + """ + Prepares every registry renderer for the current frame. + + Runs the ``prepare`` phase of each renderer in the registry so + that frame-level cross-dependencies (e.g. the laser point light + feeding the model renderers) resolve before any draw. + """ + for renderer, _ in self.render_registry: + renderer.prepare(ctx) + + def render( + self, + ctx: RenderContext, + shaders: ShaderSet | None = None, + **kwargs, + ) -> None: + """ + Renders the whole scene for one frame via the render registry. + + All per-frame state is read from ``ctx`` (populated by the + caller). The root composite owns the shaders, so it uses + ``self.shader_set`` and passes them to each registry renderer + under ``render_pass`` state isolation. + """ + for shader in ( + self.main_shader, + self.text_shader, + self.texture_shader, + self.background_shader, + ): + if shader: + shader.reset_uniforms() + + shaders = self.shader_set if shaders is None else shaders + if shaders is None: + return + + for renderer, shader_keys in self.render_registry: + pass_shaders = tuple(getattr(shaders, key) for key in shader_keys) + with render_pass(*pass_shaders): + renderer.render(ctx, shaders) diff --git a/rayforge/ui_gtk/sim3d/renderer/text_renderer.py b/rayforge/ui_gtk/sim3d/renderer/text_renderer.py new file mode 100644 index 000000000..ba31b5b26 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/text_renderer.py @@ -0,0 +1,469 @@ +""" +Renders text in a 3D OpenGL scene. + +This module provides a class `TextRenderer3D` for rendering text that faces +the camera (billboarding) in a 3D environment. It creates a texture atlas +from a specified font for the characters '0'-'9'. +""" + +import logging +import math + +import cairo +import numpy as np +from gi.repository import Pango, PangoCairo +from OpenGL import GL + +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +class TextRenderer(BaseRenderer): + """Renders billboarded text in a 3D scene.""" + + def __init__(self, font_family: str | None = None, font_size: int = 128): + """ + Initializes the text renderer on the CPU. + + Args: + font_family: The name of the font to use (e.g. "Arial"). + font_size: The size of the font for the texture atlas. + """ + super().__init__() + self.char_data: dict[str, dict[str, float | int]] = {} + self.texture_id: int = 0 + self.atlas_width: int = 0 + self.atlas_height: int = 0 + self.vao: int = 0 + self.vbo: int = 0 + self._max_vertices: int = 0 + self._font_size_px = font_size + self._atlas_buffer: bytes | None = None + self._cached_billboard: np.ndarray | None = None + self._cached_view: np.ndarray | None = None + self._batch_vertices: list[np.ndarray] = [] + self._batch_shader = None + self._batch_color: tuple[float, float, float, float] | None = None + self._batch_mvp: np.ndarray | None = None + self._batch_billboard: np.ndarray | None = None + + # This part no longer loads a font object, it just stores the + # description + self.font_desc = Pango.FontDescription() + font_name = font_family if font_family else "sans-serif" + self.font_desc.set_family(font_name) + self.font_desc.set_size(self._font_size_px * Pango.SCALE) + logger.info( + f"Using Pango font description: {self.font_desc.to_string()}" + ) + + self._prepare_texture_atlas_pango() + + def _prepare_texture_atlas_pango(self) -> None: + """ + Creates a texture atlas for numeric characters using Pango and Cairo. + This version uses font-wide metrics (ascent/descent) to ensure all + characters are vertically aligned to a common baseline. + """ + chars_to_render = "0123456789-" + padding_px = 2 + + # Create a dummy Cairo surface to create a context for Pango + dummy_surface = cairo.ImageSurface(cairo.FORMAT_A8, 1, 1) + cr = cairo.Context(dummy_surface) + layout = PangoCairo.create_layout(cr) + layout.set_font_description(self.font_desc) + + # Get font-wide metrics to establish a common baseline. This is key + # to correctly aligning characters with different vertical extents, + # like '8' and '-'. + pango_context = layout.get_context() + metrics = pango_context.get_metrics(self.font_desc, None) + ascent = metrics.get_ascent() / Pango.SCALE + descent = metrics.get_descent() / Pango.SCALE + + # The atlas height is the full logical line height of the font. + self.atlas_height = math.ceil(ascent + descent) + + char_metrics = {} + total_advance_px = 0 + + for char in chars_to_render: + layout.set_text(char, -1) + ink_rect, logical_rect = layout.get_pixel_extents() + # We use the logical width (advance) for spacing calculation + advance_px = logical_rect.width + char_metrics[char] = { + "ink_rect": ink_rect, + "advance_px": advance_px, + } + total_advance_px += advance_px + padding_px + logger.debug( + f"Char '{char}': advance={advance_px}px, ink_rect={ink_rect}" + ) + + self.atlas_width = int(total_advance_px) + + if self.atlas_width <= 0 or self.atlas_height <= 0: + logger.error( + "Failed to calculate valid atlas size: %dx%d", + self.atlas_width, + self.atlas_height, + ) + return + + # Create the real surface for the atlas + atlas_surface = cairo.ImageSurface( + cairo.FORMAT_A8, self.atlas_width, self.atlas_height + ) + cr = cairo.Context(atlas_surface) + layout = PangoCairo.create_layout(cr) + layout.set_font_description(self.font_desc) + cr.set_source_rgba(1.0, 1.0, 1.0, 1.0) # Draw in white + + x_cursor = 0 + for char in chars_to_render: + metrics = char_metrics[char] + ink_rect = metrics["ink_rect"] + advance_px = metrics["advance_px"] + + # We position the layout at y=0. Pango draws text relative to its + # logical box. Since our atlas height is exactly ascent+descent, + # this aligns the font baseline to `y = ascent`, keeping all + # characters vertically aligned correctly. + # We shift X to remove the left-side bearing for tighter packing, + # but we allocate the full logical width for the texture slot. + cr.move_to(x_cursor - ink_rect.x, 0) + layout.set_text(char, -1) + PangoCairo.show_layout(cr, layout) + + self.char_data[char] = { + "u0": x_cursor / self.atlas_width, + "v0": 0.0, + "u1": min((x_cursor + advance_px) / self.atlas_width, 1.0), + "v1": 1.0, + "width_px": advance_px, + "height_px": float(self.atlas_height), + } + x_cursor += advance_px + padding_px + + # Get the raw byte data and stride from the Cairo surface + atlas_surface.flush() + buffer = atlas_surface.get_data() + stride = atlas_surface.get_stride() + + # Repack buffer if stride != width (remove padding bytes) + if stride != self.atlas_width: + logger.debug( + f"Atlas stride ({stride}) != width ({self.atlas_width}). " + "Repacking buffer." + ) + unpacked_buffer = bytearray(self.atlas_width * self.atlas_height) + for i in range(self.atlas_height): + row_start_in = i * stride + row_end_in = row_start_in + self.atlas_width + row_start_out = i * self.atlas_width + unpacked_buffer[ + row_start_out : row_start_out + self.atlas_width + ] = buffer[row_start_in:row_end_in] + self._atlas_buffer = bytes(unpacked_buffer) + else: + self._atlas_buffer = bytes(buffer) + + def _cleanup_self(self) -> None: + """Resets GPU-bound state so a later init_gl can recreate it.""" + self.texture_id = 0 + self.vao = 0 + self.vbo = 0 + + def init_gl(self) -> None: + """Initializes all OpenGL resources.""" + if self.texture_id == 0: + self._upload_atlas_to_gpu() + + self.vao = self._create_vao() + self.vbo = self._create_vbo() + + # Capacity for 1024 chars * 6 vertices; grown on demand. + self._max_vertices = 1024 * 6 + stride = 10 * 4 + + GL.glBindVertexArray(self.vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self._max_vertices * stride, + None, + GL.GL_DYNAMIC_DRAW, + ) + GL.glEnableVertexAttribArray(0) + GL.glVertexAttribPointer(0, 4, GL.GL_FLOAT, GL.GL_FALSE, stride, None) + GL.glEnableVertexAttribArray(1) + GL.glVertexAttribPointer( + 1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, GL.GLvoidp(16) + ) + GL.glEnableVertexAttribArray(2) + GL.glVertexAttribPointer( + 2, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, GL.GLvoidp(28) + ) + GL.glBindVertexArray(0) + + def _upload_atlas_to_gpu(self) -> None: + """Helper to create and configure the OpenGL texture.""" + if not self._atlas_buffer: + return + self.texture_id = self._create_texture() + GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture_id) + + # Tell OpenGL how to unpack the pixel data. We have 1-byte alignment + # since we manually created a tightly-packed buffer. + old_alignment = GL.glGetIntegerv(GL.GL_UNPACK_ALIGNMENT) + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 1) + + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + GL.GL_R8, # Internal format: 8-bit red channel + self.atlas_width, + self.atlas_height, + 0, + GL.GL_RED, # Source fmt: also red (from our single-channel data) + GL.GL_UNSIGNED_BYTE, + self._atlas_buffer, + ) + + # Restore the original alignment + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, old_alignment) + + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + + def prepare(self, ctx: RenderContext) -> None: + """No per-frame state to prepare.""" + + def render( + self, + ctx: RenderContext, + shaders: ShaderSet, + *, + text: str, + position: np.ndarray, + height_in_world_units: float, + color: tuple[float, float, float, float], + align: str = "center", + **kwargs, + ) -> None: + """ + Renders a string of text at a given 3D position, facing the camera. + The entire string billboards as a single unit. + + Args: + ctx: The current render context. + shaders: The shader set; the ``text`` program is used. + text: The string to render (must contain characters of the atlas). + position: A numpy array (vec3) for the text's anchor point. + height_in_world_units: Desired height of the text in world units. + color: A tuple (r, g, b, a) for the text color. + align: Horizontal alignment ('left', 'center', 'right'). + """ + if not self.vao or not text or self.atlas_height < 1: + return + + shader = shaders.text + if shader is None: + return + + chars = [c for c in text if c in self.char_data] + if not chars: + return + + # Frame-constant state; the last label's values are used by + # ``end_batch`` (all labels share camera and label color). + self._batch_shader = shader + self._batch_color = color + self._batch_mvp = ctx.camera.mvp_ui + self._batch_billboard = self._get_billboard(ctx) + + pixel_to_world_scale = height_in_world_units / self.atlas_height + total_text_width_px = sum(self.char_data[c]["width_px"] for c in chars) + total_text_width_world = total_text_width_px * pixel_to_world_scale + + if align == "right": + current_x_local = -total_text_width_world + elif align == "left": + current_x_local = 0.0 + else: # 'center' + current_x_local = -total_text_width_world / 2.0 + + ax, ay, az = (float(v) for v in position[:3]) + + for char in chars: + char_info = self.char_data[char] + char_width_world = char_info["width_px"] * pixel_to_world_scale + char_height_world = char_info["height_px"] * pixel_to_world_scale + u0, v0 = char_info["u0"], char_info["v0"] + u1, v1 = char_info["u1"], char_info["v1"] + + # Two triangles: (TL, BL, TR), (BL, BR, TR). Each vertex is + # (x, y, u, v, offsetX, quadSizeX, quadHeight, ax, ay, az). + block = np.array( + [ + -0.5, + 0.5, + u0, + v0, + current_x_local, + char_width_world, + char_height_world, + ax, + ay, + az, + -0.5, + -0.5, + u0, + v1, + current_x_local, + char_width_world, + char_height_world, + ax, + ay, + az, + 0.5, + 0.5, + u1, + v0, + current_x_local, + char_width_world, + char_height_world, + ax, + ay, + az, + -0.5, + -0.5, + u0, + v1, + current_x_local, + char_width_world, + char_height_world, + ax, + ay, + az, + 0.5, + -0.5, + u1, + v1, + current_x_local, + char_width_world, + char_height_world, + ax, + ay, + az, + 0.5, + 0.5, + u1, + v0, + current_x_local, + char_width_world, + char_height_world, + ax, + ay, + az, + ], + dtype=np.float32, + ) + self._batch_vertices.append(block) + current_x_local += char_width_world + + def begin_batch(self) -> None: + """Starts a frame's text batch; call once before any ``render``.""" + self._batch_vertices = [] + self._batch_shader = None + self._batch_color = None + self._batch_mvp = None + self._batch_billboard = None + + def end_batch(self) -> None: + """Uploads and draws everything queued since ``begin_batch``.""" + if not self._batch_vertices: + return + shader = self._batch_shader + color = self._batch_color + mvp = self._batch_mvp + billboard = self._batch_billboard + if shader is None or color is None or mvp is None: + return + if billboard is None: + billboard = np.identity(3) + + buf = np.concatenate(self._batch_vertices) + n_verts = buf.size // 10 + self._batch_vertices = [] + + if n_verts > self._max_vertices: + self._max_vertices = n_verts + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + n_verts * 10 * 4, + None, + GL.GL_DYNAMIC_DRAW, + ) + + shader.use() + GL.glActiveTexture(GL.GL_TEXTURE0) + GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture_id) + GL.glBindVertexArray(self.vao) + shader.set_vec4("uTextColor", color) + shader.set_mat4("uMVP", mvp) + shader.set_mat3("uBillboard", billboard) + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, buf.nbytes, buf) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, n_verts) + + def _get_billboard(self, ctx) -> np.ndarray: + """Returns the camera billboard matrix, recomputed only on change. + + The billboard only depends on the camera view matrix, so it is + cached across labels (and frames) while the camera is static. + """ + view = ctx.camera.view_matrix + if self._cached_view is None or not np.array_equal( + view, self._cached_view + ): + self._cached_view = view.copy() + self._cached_billboard = self._compute_billboard(ctx) + billboard = self._cached_billboard + assert billboard is not None + return billboard + + def _compute_billboard(self, ctx) -> np.ndarray: + """Builds the 3x3 billboard rotation from the camera view.""" + try: + inv_view = np.linalg.inv(ctx.camera.view_matrix) + camera_rotation_matrix_row_major = inv_view[:3, :3] + u = camera_rotation_matrix_row_major[:, 0] + u /= np.linalg.norm(u) + v = camera_rotation_matrix_row_major[:, 1] + v -= np.dot(v, u) * u + v /= np.linalg.norm(v) + w = np.cross(u, v) + return np.column_stack((u, v, w)) + except np.linalg.LinAlgError: + logger.warning( + "View matrix inversion failed, using identity for billboard." + ) + return np.identity(3) diff --git a/rayforge/ui_gtk/sim3d/renderer/texture_renderer.py b/rayforge/ui_gtk/sim3d/renderer/texture_renderer.py new file mode 100644 index 000000000..815a8e348 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/texture_renderer.py @@ -0,0 +1,576 @@ +""" +A renderer for visualizing texture-based artifacts using GPU texture rendering. +""" + +import logging +import time +from dataclasses import dataclass +from typing import Any + +import numpy as np +from OpenGL import GL +from OpenGL.error import GLError + +from ....pipeline.artifact.base import TextureData +from ....simulator.scene3d import CompiledSceneArtifact, TextureLayer +from ...shared.color_lut_provider import ColorLutProvider +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + + +@dataclass +class PreparedTextureLayer: + """Texture data ready for GL upload, built off the main thread.""" + + mips: list[np.ndarray] + model_matrix: np.ndarray + rotary_enabled: bool = False + rotary_diameter: float = 0.0 + cylinder_vertices: np.ndarray | None = None + laser_index: int = 0 + + +def _downsample_texture( + data: np.ndarray, new_height: int, new_width: int +) -> np.ndarray: + """Downsamples texture data using nearest-neighbor sampling.""" + h, w = data.shape + y_step = h / new_height + x_step = w / new_width + y_coords = (np.arange(new_height) * y_step).astype(int) + x_coords = (np.arange(new_width) * x_step).astype(int) + return data[y_coords][:, x_coords].astype(np.uint8) + + +def _build_mipmap_levels( + power_data: np.ndarray, max_texture_size: int +) -> list[np.ndarray]: + """Downsamples if needed and builds the mip pyramid.""" + height, width = power_data.shape + if width > max_texture_size or height > max_texture_size: + scale = min( + max_texture_size / width, + max_texture_size / height, + ) + new_width = int(width * scale) + new_height = int(height * scale) + logger.warning( + f"Texture size {width}x{height} exceeds max " + f"{max_texture_size}, downsampling to " + f"{new_width}x{new_height}" + ) + power_data = _downsample_texture(power_data, new_height, new_width) + return TextureArtifactRenderer._build_mipmaps(power_data) + + +def prepare_texture_layer( + tl: "TextureLayer", + laser_uid_order: list[str] | None, + max_texture_size: int, +) -> PreparedTextureLayer: + """Decompresses and mip-maps a texture layer without touching GL. + + Runs in a worker thread; the result is uploaded with GL calls on + the main thread. + """ + laser_index = 0 + if tl.laser_uid and laser_uid_order and tl.laser_uid in laser_uid_order: + laser_index = laser_uid_order.index(tl.laser_uid) + + power_data = tl.power_texture.to_numpy() + mips = _build_mipmap_levels(power_data, max_texture_size) + return PreparedTextureLayer( + mips=mips, + model_matrix=tl.model_matrix, + rotary_enabled=tl.rotary_enabled, + rotary_diameter=tl.rotary_diameter, + cylinder_vertices=tl.cylinder_vertices, + laser_index=laser_index, + ) + + +class TextureArtifactRenderer(BaseRenderer): + """ + Renders texture-based artifacts as textured quads for high-performance + visualization. + + This renderer uses a single quad with a texture containing power values, + allowing for instant rendering of complex raster operations that would + otherwise require millions of individual lines. + """ + + def __init__(self): + """Initializes the TextureArtifactRenderer.""" + super().__init__() + self.vao: int = 0 + self.vbo: int = 0 + self.texture: int = 0 + self.color_lut_texture: int = 0 + self.is_initialized: bool = False + self.max_texture_size: int = 0 + self.instances: list[dict[str, Any]] = [] + self.cylinder_vao: int = 0 + self.cylinder_vbo: int = 0 + self._num_laser_luts: int = 1 + self._flat_mvp: np.ndarray | None = None + self._cyl_mvp: np.ndarray | None = None + + def prepare(self, ctx: RenderContext) -> None: + """Caches the per-frame MVP matrices for the texture quads.""" + self._flat_mvp = ctx.camera.mvp_ui + self._cyl_mvp = ctx.kinematics.cylinder_mesh_mvp() + + def init_gl(self): + """ + Initializes OpenGL resources for rendering textured quads. + + Creates the VAO/VBO for a quad and OpenGL Textures for the texture + data and color lookup table (LUT). + """ + if self.is_initialized: + return + + max_size = GL.GLint() + GL.glGetIntegerv(GL.GL_MAX_TEXTURE_SIZE, max_size) + self.max_texture_size = max_size.value + logger.debug(f"OpenGL max texture size: {self.max_texture_size}") + + self.vbo = self._create_vbo() + self.vao = self._create_vao() + self.texture = self._create_texture() + self.color_lut_texture = self._create_texture() + + # Define quad vertices (position, texture coordinates) + # fmt: off + quad_vertices = np.array( + [ + # Position (x, y, z) Texture Coords (s, t) + 0.0, 0.0, 0.0, 0.0, 1.0, # Bottom-left + 1.0, 0.0, 0.0, 1.0, 1.0, # Bottom-right + 1.0, 1.0, 0.0, 1.0, 0.0, # Top-right + 0.0, 1.0, 0.0, 0.0, 0.0, # Top-left + ], + dtype=np.float32, + ) + # fmt: on + + # Upload vertex data + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + quad_vertices.nbytes, + quad_vertices, + GL.GL_STATIC_DRAW, + ) + + # Set up vertex attributes + GL.glBindVertexArray(self.vao) + + # Position attribute (location 0) + GL.glVertexAttribPointer( + 0, 3, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(0) + ) + GL.glEnableVertexAttribArray(0) + + # Texture coordinate attribute (location 1) + GL.glVertexAttribPointer( + 1, 2, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(3 * 4) + ) + GL.glEnableVertexAttribArray(1) + + GL.glBindVertexArray(0) + + # Set up 2D texture for power data + GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + + # Set up 2D texture (with height=1) for color LUT for compatibility + # with the sampler2D in the shader. + GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + + self.cylinder_vbo = self._create_vbo() + self.cylinder_vao = self._create_vao() + + self.is_initialized = True + logger.debug("TextureArtifactRenderer initialized") + + def _cleanup_self(self): + """Cleans up OpenGL resources specific to this renderer.""" + if not self.is_initialized: + return + + try: + self.clear() + self.is_initialized = False + except GLError as e: + logger.warning(f"TextureArtifactRenderer cleanup warning: {e}") + + @staticmethod + def _max_reduce(data: np.ndarray) -> np.ndarray: + """Halves a power map taking the 2x2 block maximum.""" + h, w = data.shape + nh, nw = (h + 1) // 2, (w + 1) // 2 + if h % 2 == 1: + data = np.pad(data, ((0, 1), (0, 0)), mode="edge") + if w % 2 == 1: + data = np.pad(data, ((0, 0), (0, 1)), mode="edge") + return data.reshape(nh, 2, nw, 2).max(axis=(1, 3)) + + @classmethod + def _build_mipmaps(cls, data: np.ndarray) -> list[np.ndarray]: + """Builds a max-reduction mip pyramid of a power map. + + Down-sampling with the maximum (rather than the average) keeps + the scanline structure intact at every zoom level, so the shader + can pick a mip level that matches its texel footprint instead of + aliasing between rows when the texture is minified. + """ + mips = [data] + level = data + while min(level.shape) > 1: + level = cls._max_reduce(level) + mips.append(level) + return mips + + def clear(self): + """Clears all instances and their associated textures.""" + if not self.is_initialized: + return + # This needs to be called on the GL thread. + textures_to_delete = [ + instance["texture_id"] for instance in self.instances + ] + if textures_to_delete: + GL.glDeleteTextures(textures_to_delete) + self.instances.clear() + + def add_instance( + self, + texture_data: TextureData, + final_model_matrix: np.ndarray, + rotary_enabled: bool = False, + rotary_diameter: float = 25.0, + cylinder_vertices: np.ndarray | None = None, + laser_index: int = 0, + ): + """Adds a texture artifact to be rendered in the next frame. + + Prepares the mip pyramid and uploads synchronously. The chunked + upload path prepares in a worker thread and calls + ``upload_prepared`` from the main thread instead. + """ + if not self.is_initialized: + return + + mips = _build_mipmap_levels( + texture_data.power_texture_data, self.max_texture_size + ) + prepared = PreparedTextureLayer( + mips=mips, + model_matrix=final_model_matrix, + rotary_enabled=rotary_enabled, + rotary_diameter=rotary_diameter, + cylinder_vertices=cylinder_vertices, + laser_index=laser_index, + ) + self._upload_prepared_instance(prepared) + + def _upload_prepared_instance( + self, prepared: PreparedTextureLayer + ) -> None: + """Uploads one prepared texture layer's mips into a GL texture.""" + texture_id = GL.glGenTextures(1) + GL.glBindTexture(GL.GL_TEXTURE_2D, texture_id) + # NEAREST_MIPMAP_NEAREST keeps the texture mipmap complete so + # texelFetch() can read the explicit LOD the shader computes + # from the texel footprint (fixes minification moire banding). + GL.glTexParameteri( + GL.GL_TEXTURE_2D, + GL.GL_TEXTURE_MIN_FILTER, + GL.GL_NEAREST_MIPMAP_NEAREST, + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 1) + for level, mip in enumerate(prepared.mips): + mh, mw = mip.shape + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + level, + GL.GL_R8, + mw, + mh, + 0, + GL.GL_RED, + GL.GL_UNSIGNED_BYTE, + mip, + ) + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 4) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + + instance_data = { + "texture_id": texture_id, + "model_matrix": prepared.model_matrix, + "rotary_enabled": prepared.rotary_enabled, + "rotary_diameter": prepared.rotary_diameter, + "laser_index": prepared.laser_index, + "max_mip": len(prepared.mips) - 1, + } + + if prepared.rotary_enabled and prepared.cylinder_vertices is not None: + instance_data["cylinder_vertices"] = prepared.cylinder_vertices + + self.instances.append(instance_data) + + def upload_prepared( + self, prepared_layers: list[PreparedTextureLayer] + ) -> None: + """Uploads prepared texture layers, clearing existing instances.""" + self.clear() + for prepared in prepared_layers: + self._upload_prepared_instance(prepared) + + def add_instance_from_texture_layer( + self, + tl: TextureLayer, + laser_uid_order: list[str] | None = None, + ): + """Adds a texture instance from a compiled texture layer.""" + laser_index = 0 + if ( + tl.laser_uid + and laser_uid_order + and tl.laser_uid in laser_uid_order + ): + laser_index = laser_uid_order.index(tl.laser_uid) + power_data = tl.power_texture.to_numpy() + tex_data = TextureData( + power_texture_data=power_data, + dimensions_mm=(0.0, 0.0), + position_mm=(0.0, 0.0), + ) + self.add_instance( + tex_data, + tl.model_matrix, + rotary_enabled=tl.rotary_enabled, + rotary_diameter=tl.rotary_diameter, + cylinder_vertices=tl.cylinder_vertices, + laser_index=laser_index, + ) + + def update_from_artifact(self, artifact: CompiledSceneArtifact): + """Clears existing instances and uploads the artifact's layers.""" + self.clear() + for tl in artifact.texture_layers: + self.add_instance_from_texture_layer(tl, artifact.laser_uid_order) + + def update_color_lut(self, lut_data: np.ndarray, num_lasers: int = 1): + """ + Updates the color lookup table texture, now using GL_TEXTURE_2D. + """ + if not self.is_initialized: + return + + self._num_laser_luts = num_lasers + lut_data = np.ascontiguousarray(lut_data, dtype=np.float32) + + if lut_data.ndim == 3: + width, height = lut_data.shape[1], lut_data.shape[0] + else: + width, height = lut_data.shape[0], 1 + + GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + GL.GL_RGBA32F, + width, + height, + 0, + GL.GL_RGBA, + GL.GL_FLOAT, + lut_data, + ) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + + def update_color_lut_from(self, provider: ColorLutProvider): + """Updates the colour LUT from a shared ColorLutProvider.""" + self.update_color_lut(provider.engrave_lut_2d(), provider.num_lasers) + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: + """ + Renders all texture instances: flat quads first, then the + cylinder-mapped (rotary) ones. + + Args: + ctx: The current render context; carries the reached count. + shaders: The shader set; the ``texture`` program is used. + """ + if not self.is_initialized or not self.instances: + return + + shader = shaders.texture + if shader is None: + return + + pending_alpha = 0.3 + self._draw_flat(shader, pending_alpha) + self._draw_cylinder(shader, pending_alpha) + + def _draw_flat( + self, + shader, + pending_alpha: float = 0.3, + ): + """Draws all flat (non-rotary) texture instances.""" + if self._flat_mvp is None: + return + + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + # Fill depth across the whole raster quad (including the + # zero-power gaps) so occluders behind it cannot show through. + GL.glDepthMask(GL.GL_TRUE) + GL.glDepthFunc(GL.GL_LEQUAL) + shader.use() + + GL.glActiveTexture(GL.GL_TEXTURE0) + shader.set_int("uTexture", 0) + GL.glActiveTexture(GL.GL_TEXTURE1) + shader.set_int("uColorLUT", 1) + shader.set_int("uNumLaserLUTs", self._num_laser_luts) + GL.glBindVertexArray(self.vao) + + for i, instance in enumerate(self.instances): + if instance["rotary_enabled"]: + continue + + shader.set_float("uAlpha", pending_alpha) + shader.set_float("uMaxMip", float(instance.get("max_mip", 0))) + + shader.set_int("uLaserIndex", instance.get("laser_index", 0)) + + final_mvp = self._flat_mvp @ instance["model_matrix"] + shader.set_mat4("uMVP", final_mvp) + + GL.glActiveTexture(GL.GL_TEXTURE1) + GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) + GL.glActiveTexture(GL.GL_TEXTURE0) + GL.glBindTexture(GL.GL_TEXTURE_2D, instance["texture_id"]) + + GL.glDrawArrays(GL.GL_TRIANGLE_FAN, 0, 4) + + def _draw_cylinder( + self, + shader, + pending_alpha: float = 0.3, + ): + """Draws all texture instances mapped onto a cylinder.""" + if self._cyl_mvp is None: + return + + t_cyl_start = time.perf_counter() + + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + GL.glDepthMask(GL.GL_TRUE) + GL.glDepthFunc(GL.GL_LEQUAL) + shader.use() + + GL.glActiveTexture(GL.GL_TEXTURE0) + shader.set_int("uTexture", 0) + GL.glActiveTexture(GL.GL_TEXTURE1) + shader.set_int("uColorLUT", 1) + shader.set_int("uNumLaserLUTs", self._num_laser_luts) + + num_rotary = 0 + + for i, instance in enumerate(self.instances): + if not instance["rotary_enabled"]: + continue + num_rotary += 1 + + shader.set_float("uAlpha", pending_alpha) + shader.set_float("uMaxMip", float(instance.get("max_mip", 0))) + + shader.set_int("uLaserIndex", instance.get("laser_index", 0)) + + vertices = instance.get("cylinder_vertices") + if vertices is None: + continue + + vertex_count = len(vertices) // 5 + + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.cylinder_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + vertices.nbytes, + vertices, + GL.GL_DYNAMIC_DRAW, + ) + + GL.glBindVertexArray(self.cylinder_vao) + GL.glVertexAttribPointer( + 0, 3, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(0) + ) + GL.glEnableVertexAttribArray(0) + GL.glVertexAttribPointer( + 1, 2, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(3 * 4) + ) + GL.glEnableVertexAttribArray(1) + GL.glBindVertexArray(0) + + # Draw using the full Scene Matrix, so it correctly + # inherits WCS and _model_matrix. + shader.set_mat4("uMVP", self._cyl_mvp) + + GL.glActiveTexture(GL.GL_TEXTURE1) + GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) + GL.glActiveTexture(GL.GL_TEXTURE0) + GL.glBindTexture(GL.GL_TEXTURE_2D, instance["texture_id"]) + + GL.glBindVertexArray(self.cylinder_vao) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, vertex_count) + + t_cyl_elapsed = (time.perf_counter() - t_cyl_start) * 1000 + if t_cyl_elapsed > 5: + logger.info( + f"[TEX3D] render_cylinder took {t_cyl_elapsed:.1f}ms " + f"(rotary={num_rotary})" + ) diff --git a/rayforge/ui_gtk/sim3d/renderer/zone_renderer.py b/rayforge/ui_gtk/sim3d/renderer/zone_renderer.py new file mode 100644 index 000000000..779f6e290 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/renderer/zone_renderer.py @@ -0,0 +1,374 @@ +import logging +import math + +import numpy as np +from OpenGL import GL + +from ....machine.models.zone import Zone, ZoneShape +from ..gl_utils import ShaderSet +from ..render_context import RenderContext +from .base import BaseRenderer + +logger = logging.getLogger(__name__) + +_DEFAULT_FILL_COLOR = (1.0, 0.0, 0.0, 0.05) +_DEFAULT_EDGE_COLOR = (1.0, 0.0, 0.0, 0.2) +_CYLINDER_SEGMENTS = 16 + + +def _rect_triangles(p: dict) -> list[float]: + x, y = p.get("x", 0.0), p.get("y", 0.0) + w, h = p.get("w", 10.0), p.get("h", 10.0) + z = 0.003 + return [ + x, + y, + z, + x + w, + y, + z, + x, + y + h, + z, + x + w, + y, + z, + x + w, + y + h, + z, + x, + y + h, + z, + ] + + +def _rect_edges(p: dict) -> list[float]: + x, y = p.get("x", 0.0), p.get("y", 0.0) + w, h = p.get("w", 10.0), p.get("h", 10.0) + z = 0.004 + return [ + x, + y, + z, + x + w, + y, + z, + x + w, + y, + z, + x + w, + y + h, + z, + x + w, + y + h, + z, + x, + y + h, + z, + x, + y + h, + z, + x, + y, + z, + ] + + +def _box_triangles(p: dict) -> list[float]: + x, y, z = p.get("x", 0.0), p.get("y", 0.0), p.get("z", 0.0) + w, h, d = p.get("w", 10.0), p.get("h", 10.0), p.get("d", 10.0) + x2, y2, z2 = x + w, y + h, z + d + faces = [ + [x, y, z, x2, y, z, x, y2, z, x2, y, z, x2, y2, z, x, y2, z], + [x, y, z2, x2, y, z2, x, y2, z2, x2, y, z2, x2, y2, z2, x, y2, z2], + [x, y, z, x, y2, z, x, y, z2, x, y2, z, x, y2, z2, x, y, z2], + [x2, y, z, x2, y2, z, x2, y, z2, x2, y2, z, x2, y2, z2, x2, y, z2], + [x, y, z, x2, y, z, x, y, z2, x2, y, z, x2, y, z2, x, y, z2], + [x, y2, z, x2, y2, z, x, y2, z2, x2, y2, z, x2, y2, z2, x, y2, z2], + ] + verts = [] + for face in faces: + verts.extend(face) + return verts + + +def _box_edges(p: dict) -> list[float]: + x, y, z = p.get("x", 0.0), p.get("y", 0.0), p.get("z", 0.0) + w, h, d = p.get("w", 10.0), p.get("h", 10.0), p.get("d", 10.0) + x2, y2, z2 = x + w, y + h, z + d + return [ + x, + y, + z, + x2, + y, + z, + x2, + y, + z, + x2, + y2, + z, + x2, + y2, + z, + x, + y2, + z, + x, + y2, + z, + x, + y, + z, + x, + y, + z2, + x2, + y, + z2, + x2, + y, + z2, + x2, + y2, + z2, + x2, + y2, + z2, + x, + y2, + z2, + x, + y2, + z2, + x, + y, + z2, + x, + y, + z, + x, + y, + z2, + x2, + y, + z, + x2, + y, + z2, + x2, + y2, + z, + x2, + y2, + z2, + x, + y2, + z, + x, + y2, + z2, + ] + + +def _cylinder_triangles(p: dict) -> list[float]: + cx, cy = p.get("x", 0.0), p.get("y", 0.0) + cz = p.get("z", 0.0) + radius = p.get("radius", 5.0) + height = p.get("height", 10.0) + n = _CYLINDER_SEGMENTS + verts: list[float] = [] + for i in range(n): + a1 = 2.0 * math.pi * i / n + a2 = 2.0 * math.pi * (i + 1) / n + c1x = cx + radius * math.cos(a1) + c1y = cy + radius * math.sin(a1) + c2x = cx + radius * math.cos(a2) + c2y = cy + radius * math.sin(a2) + verts.extend( + [ + cx, + cy, + cz, + c1x, + c1y, + cz, + c2x, + c2y, + cz, + ] + ) + zt = cz + height + verts.extend( + [ + cx, + cy, + zt, + c2x, + c2y, + zt, + c1x, + c1y, + zt, + ] + ) + verts.extend( + [ + c1x, + c1y, + cz, + c2x, + c2y, + cz, + c2x, + c2y, + zt, + ] + ) + verts.extend( + [ + c1x, + c1y, + cz, + c2x, + c2y, + zt, + c1x, + c1y, + zt, + ] + ) + return verts + + +def _cylinder_edges(p: dict) -> list[float]: + cx, cy = p.get("x", 0.0), p.get("y", 0.0) + cz = p.get("z", 0.0) + radius = p.get("radius", 5.0) + height = p.get("height", 10.0) + n = _CYLINDER_SEGMENTS + verts: list[float] = [] + for i in range(n): + a1 = 2.0 * math.pi * i / n + a2 = 2.0 * math.pi * (i + 1) / n + c1x = cx + radius * math.cos(a1) + c1y = cy + radius * math.sin(a1) + c2x = cx + radius * math.cos(a2) + c2y = cy + radius * math.sin(a2) + verts.extend([c1x, c1y, cz, c2x, c2y, cz]) + zt = cz + height + verts.extend([c1x, c1y, zt, c2x, c2y, zt]) + verts.extend([c1x, c1y, cz, c1x, c1y, zt]) + return verts + + +_TRIANGLE_FUNCS = { + ZoneShape.RECT: _rect_triangles, + ZoneShape.BOX: _box_triangles, + ZoneShape.CYLINDER: _cylinder_triangles, +} + +_EDGE_FUNCS = { + ZoneShape.RECT: _rect_edges, + ZoneShape.BOX: _box_edges, + ZoneShape.CYLINDER: _cylinder_edges, +} + + +class ZoneRenderer(BaseRenderer): + def __init__(self): + super().__init__() + self._fill_vao = 0 + self._fill_vbo = 0 + self._fill_vertex_count = 0 + self._edge_vao = 0 + self._edge_vbo = 0 + self._edge_vertex_count = 0 + self._fill_color: tuple[float, ...] = _DEFAULT_FILL_COLOR + self._edge_color: tuple[float, ...] = _DEFAULT_EDGE_COLOR + + def update_zones(self, zones: list[Zone]): + fill_verts: list[float] = [] + edge_verts: list[float] = [] + for zone in zones: + if not zone.enabled: + continue + tri_fn = _TRIANGLE_FUNCS.get(zone.shape) + edge_fn = _EDGE_FUNCS.get(zone.shape) + if tri_fn: + fill_verts.extend(tri_fn(zone.params)) + if edge_fn: + edge_verts.extend(edge_fn(zone.params)) + + self._delete_owned(self._fill_vao, self._fill_vbo) + self._fill_vao = 0 + self._fill_vbo = 0 + self._delete_owned(self._edge_vao, self._edge_vbo) + self._edge_vao = 0 + self._edge_vbo = 0 + + if fill_verts: + self._fill_vao = self._create_vao() + self._fill_vbo = self._create_vbo() + self._fill_vertex_count = len(fill_verts) // 3 + data = np.array(fill_verts, dtype=np.float32) + GL.glBindVertexArray(self._fill_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._fill_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, data.nbytes, data, GL.GL_STATIC_DRAW + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + if edge_verts: + self._edge_vao = self._create_vao() + self._edge_vbo = self._create_vbo() + self._edge_vertex_count = len(edge_verts) // 3 + data = np.array(edge_verts, dtype=np.float32) + GL.glBindVertexArray(self._edge_vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._edge_vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, data.nbytes, data, GL.GL_STATIC_DRAW + ) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glEnableVertexAttribArray(0) + + GL.glBindVertexArray(0) + + def init_gl(self) -> None: + pass + + def prepare(self, ctx: RenderContext) -> None: + """No per-frame state to prepare.""" + + def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: + if not ctx.camera.show_nogo_zones: + return + if not self._fill_vao and not self._edge_vao: + return + + shader = shaders.main + if shader is None: + return + + mvp = ctx.camera.mvp_ui @ ctx.viewport.margin_shift + + shader.use() + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + shader.set_float("uHasNormals", 0.0) + shader.set_float("uUseVertexColor", 0.0) + + if self._fill_vao: + GL.glDepthMask(GL.GL_FALSE) + shader.set_mat4("uMVP", mvp) + shader.set_vec4("uColor", self._fill_color) + GL.glBindVertexArray(self._fill_vao) + GL.glDrawArrays(GL.GL_TRIANGLES, 0, self._fill_vertex_count) + + if self._edge_vao: + shader.set_mat4("uMVP", mvp) + shader.set_vec4("uColor", self._edge_color) + GL.glBindVertexArray(self._edge_vao) + GL.glDrawArrays(GL.GL_LINES, 0, self._edge_vertex_count) diff --git a/rayforge/ui_gtk/sim3d/scene_presenter.py b/rayforge/ui_gtk/sim3d/scene_presenter.py new file mode 100644 index 000000000..3c624d2a2 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/scene_presenter.py @@ -0,0 +1,592 @@ +""" +Scene presenter for the 3D canvas. + +Owns scene compilation scheduling, the compiled artifact, the playback +OpPlayer, and the playback overlay binding. Constructed by Canvas3D with +injected callables so it never reaches back into the widget; the canvas +keeps the GL lifecycle and per-frame rendering. +""" + +import logging +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional + +import numpy as np +from blinker import Signal +from raygeo.ops import Ops + +from ...context import RayforgeContext +from ...machine.kinematic_mapping import ( + KinematicMapping, + build_layer_assembly, + resolve_layer_rotary, +) +from ...machine.models.laser import LaserHead +from ...pipeline.artifact.handle import BaseArtifactHandle +from ...pipeline.artifact.job import JobArtifact +from ...shared.tasker import Task, task_mgr +from ...simulator.op_player import OpPlayer, build_snapshots +from ...simulator.scene3d import ( + CompiledSceneArtifact, + LayerRenderConfig, + RenderConfig3D, + compile_scene_from_job, +) +from .camera import ViewDirection + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...doceditor.editor import DocEditor + from ...machine.assembly import Assembly + from .renderer.scene_renderer import SceneRenderer + from .theme_resolver import ThemeResolver + from .viewport import ViewportConfig + +logger = logging.getLogger(__name__) + + +class ScenePresenter: + """ + Compiles the scene, builds the playback player, and binds playback. + + The canvas owns the GL context and per-frame render state; this class + owns everything that turns a document + job artifact into a compiled + ``CompiledSceneArtifact`` and an ``OpPlayer``. Dependencies are + injected as callables so the presenter stays independent of the widget. + """ + + def __init__( + self, + context: RayforgeContext, + doc_editor: "DocEditor", + scene: "SceneRenderer", + *, + theme_resolver: "ThemeResolver", + get_viewport: Callable[[], "ViewportConfig"], + get_gl_initialized: Callable[[], bool], + get_show_travel_moves: Callable[[], bool], + get_camera_available: Callable[[], bool], + make_current: Callable[[], None], + mark_scene_dirty: Callable[[], None], + mark_artifact_dirty: Callable[[], None], + reset_view: Callable[[ViewDirection], None], + request_render: Callable[[], None], + upload_complete: Signal, + ): + self._context = context + self._doc_editor = doc_editor + self._scene = scene + self._theme_resolver = theme_resolver + self._get_viewport = get_viewport + self._get_gl_initialized = get_gl_initialized + self._get_show_travel_moves = get_show_travel_moves + self._get_camera_available = get_camera_available + self._make_current = make_current + self._mark_scene_dirty = mark_scene_dirty + self._mark_artifact_dirty = mark_artifact_dirty + self._reset_view = reset_view + self._request_render = request_render + self._upload_complete = upload_complete + + self._scene_preparation_task: Task | None = None + self._compiled_artifact: CompiledSceneArtifact | None = None + self._current_job_handle: BaseArtifactHandle | None = None + self._compiled_job_generation: int | None = None + self._op_player: OpPlayer | None = None + self._playback_assembly: Assembly | None = None + self._playback_overlay = None + + def connect(self): + """Subscribe to the pipeline and upload events that drive the scene. + + Called once the canvas has realized its GL context. ``connect`` / + ``disconnect`` pair keeps the presenter's signal wiring in one + place instead of being threaded through the canvas. + """ + self._upload_complete.connect(self._on_upload_complete) + pipeline = self._doc_editor.pipeline + if pipeline: + pipeline.processing_state_changed.connect( + self._on_pipeline_state_changed + ) + pipeline.job_generation_finished.connect( + self._on_job_generation_finished + ) + + def disconnect(self): + """Unsubscribe from pipeline and upload events.""" + self._upload_complete.disconnect(self._on_upload_complete) + pipeline = self._doc_editor.pipeline + if pipeline: + pipeline.processing_state_changed.disconnect( + self._on_pipeline_state_changed + ) + pipeline.job_generation_finished.disconnect( + self._on_job_generation_finished + ) + + @property + def doc(self) -> "Doc": + """Returns the current document from the editor.""" + return self._doc_editor.doc + + @property + def op_player(self) -> OpPlayer | None: + """The current playback player, or None.""" + return self._op_player + + @property + def playback_assembly(self) -> Optional["Assembly"]: + """The throwaway assembly for the current playback layer, or None.""" + return self._playback_assembly + + @property + def compiled_artifact(self) -> CompiledSceneArtifact | None: + """The last compiled scene artifact, or None.""" + return self._compiled_artifact + + @property + def scene_preparation_task(self) -> Task | None: + """The in-flight scene compilation task, or None.""" + return self._scene_preparation_task + + @property + def job_handle(self) -> BaseArtifactHandle | None: + """The job artifact handle driving the scene, or None.""" + return self._current_job_handle + + @job_handle.setter + def job_handle(self, handle: BaseArtifactHandle | None): + self._current_job_handle = handle + + @property + def playback_overlay(self): + """The attached playback overlay widget, or None.""" + return self._playback_overlay + + def set_playback_overlay(self, overlay): + """Store the playback overlay so players can be bound to it.""" + self._playback_overlay = overlay + + def cancel_scene_preparation(self): + """Cancel any in-flight scene compilation task.""" + if self._scene_preparation_task: + self._scene_preparation_task.cancel() + self._scene_preparation_task = None + + def has_stale_job(self) -> bool: + """True if the cached job handle is from an older generation.""" + handle = self._current_job_handle + if handle is None: + return True + return ( + handle.generation_id + != self._doc_editor.pipeline.data_generation_id + ) + + def _on_pipeline_state_changed(self, sender, *, is_processing: bool): + """ + Handler for when the pipeline's busy state changes. When it becomes + not busy, the document has settled and the scene should be updated. + """ + if not is_processing and self._current_job_handle is not None: + if self.has_stale_job(): + logger.debug( + "Pipeline settled with stale job. Clearing 3D scene." + ) + self._current_job_handle = None + self._compiled_job_generation = None + self._compiled_artifact = None + self._mark_artifact_dirty() + self._request_render() + else: + if ( + self._current_job_handle.generation_id + == self._compiled_job_generation + ): + logger.debug( + "[CANVAS3D] Scene already compiled for this " + "generation; skipping duplicate update." + ) + else: + logger.debug("Pipeline has settled. Updating 3D scene.") + self.update_scene_from_doc() + + def _on_job_generation_finished(self, sender, **kwargs): + task_status = kwargs.get("task_status") + handle = kwargs.get("handle") + logger.debug( + f"[CANVAS3D] _on_job_generation_finished: " + f"status={task_status}, handle={'yes' if handle else 'none'}" + ) + if task_status == "completed": + if handle is not None: + self._current_job_handle = handle + self.update_scene_from_doc() + self._request_render() + else: + logger.debug( + "[CANVAS3D] Job completed with no output. Clearing scene." + ) + self._current_job_handle = None + self._compiled_job_generation = None + self._compiled_artifact = None + self._mark_artifact_dirty() + self._request_render() + + def _on_upload_complete(self, sender=None, **_kwargs): + self._build_op_player_async() + if self._compiled_artifact and self._op_player: + self._scene.extract_playback_offsets(self._compiled_artifact) + + def _build_op_player_async(self): + ops = self._get_ops_for_playback() + time_ops = self._get_time_ops_for_playback() + machine = self._context.machine + if machine is None: + return + + if ops is None or ops.is_empty(): + self._op_player = None + self._playback_assembly = None + for renderer in self._scene.ops_renderers: + renderer.powered_offsets = np.array([], dtype=np.int32) + renderer.travel_offsets = np.array([], dtype=np.int32) + for renderer in self._scene.ring_renderers: + renderer.ring_offsets = np.array([], dtype=np.int32) + if self._playback_overlay: + self._playback_overlay.set_player(None) + self._request_render() + return + + # Preserve the playhead and seek snapshots when the underlying + # ops object has not changed (e.g. only the viewport moved). + saved_index = None + reused_snapshots = [] + if self._op_player is not None and self._op_player.ops is ops: + saved_index = self._op_player.current_index + reused_snapshots = self._op_player.snapshots + + player = OpPlayer( + ops, + machine, + self.doc, + build_snapshots=False, + time_ops=time_ops, + ) + player.set_playback_params( + machine.max_cut_speed, + machine.max_travel_speed, + machine.acceleration, + ) + player.set_snapshots(reused_snapshots) + + # Make the player available right away so that the next render + # can dim textures that have not been reached yet. Seeking to + # the first layer is cheap (the first LAYER_START is near the + # start of the ops), and reused snapshots keep restores of a + # previous playhead fast as well. + if saved_index is not None: + player.seek(saved_index) + initial_index = saved_index + else: + player.seek_to_first_layer() + initial_index = 0 + self._op_player = player + player.layer_changed.connect(self._on_playback_layer_changed) + self._on_playback_layer_changed(player) + if self._playback_overlay: + self._playback_overlay.set_player(player, initial_index) + self._request_render() + + # Build seek-acceleration snapshots in the background. They are + # collected into a fresh list and attached from the main thread + # to avoid racing with concurrent seeks reading _snapshots. + def _on_snapshots_done(task): + if task.get_status() != "completed": + return + if self._op_player is player: + player.set_snapshots(task.result()) + + task_mgr.run_thread( + build_snapshots, + ops, + machine, + self.doc, + key=(id(self), "build-snapshots"), + when_done=_on_snapshots_done, + ) + + def _on_playback_layer_changed(self, player, layer_uid=None, **_kwargs): + """Rebuild the throwaway playback assembly for the current layer. + + Connected to ``OpPlayer.layer_changed`` and also called once on + player creation. Resolves the effective layer (current or the + first layer while in the preamble) and updates the scene's + cylinder transform without mutating the live machine. + """ + machine = self._context.machine + if machine is None or player is None: + return + layer = player.get_effective_layer(self.doc) + assembly = build_layer_assembly(machine, layer) + self._playback_assembly = assembly + if assembly.has_rotary: + self._scene.set_cylinder_transform( + assembly.cylinder_base_transform() + ) + else: + self._scene.set_cylinder_transform(np.eye(4, dtype=np.float64)) + self._request_render() + + def _on_scene_prepared(self, task: Task): + """ + Callback for when the background scene compilation task is + finished. The compiled artifact is available directly as + ``task.result_value`` since the compilation runs in-process. + """ + if task.get_status() != "completed": + if task.is_cancelled(): + logger.debug( + "[CANVAS3D] Scene preparation task cancelled (superseded)." + ) + else: + self._compiled_artifact = None + self._op_player = None + self._playback_assembly = None + logger.error("[CANVAS3D] Scene preparation task failed.") + self._mark_artifact_dirty() + self._request_render() + return + + self._scene_preparation_task = None + + artifact = task.result() + if artifact is None: + logger.warning( + "[CANVAS3D] Scene task completed but produced no " + "artifact (possibly empty scene)." + ) + self._compiled_artifact = None + self._mark_artifact_dirty() + self._request_render() + return + + if not isinstance(artifact, CompiledSceneArtifact): + logger.error( + f"[CANVAS3D] Expected CompiledSceneArtifact, got " + f"{type(artifact).__name__}" + ) + self._compiled_artifact = None + self._mark_artifact_dirty() + self._request_render() + return + + logger.debug("[CANVAS3D] Scene compilation finished.") + self._compiled_artifact = artifact + self._mark_artifact_dirty() + self._request_render() + + def update_renderers_from_artifact(self): + if not self._compiled_artifact: + for renderer in self._scene.ops_renderers: + renderer.clear() + for renderer in self._scene.ring_renderers: + renderer.clear() + renderer.ring_offsets = np.array([], dtype=np.int32) + if self._scene.texture_renderer: + self._scene.texture_renderer.clear() + self._request_render() + return + + if not self._get_gl_initialized(): + return + + self._make_current() + + self._scene.update_from_artifact( + self._compiled_artifact, self._get_show_travel_moves() + ) + + self._theme_resolver.update_renderer_color_luts() + + logger.debug( + "[CANVAS3D] Scanline overlay uploaded. Groups: {}".format( + ", ".join( + "{}:{}".format( + "rot" if r.is_rotary else "flat", + r.vertex_count, + ) + for r in self._scene.ring_renderers + ) + ) + ) + + self._request_render() + + def _get_ops_for_playback(self) -> Ops | None: + handle = self._current_job_handle + if handle is not None: + artifact = self._context.artifact_store.get(handle) + if isinstance(artifact, JobArtifact): + return artifact.preview_ops + return None + + def _get_time_ops_for_playback(self) -> Ops | None: + """Unmapped ops for the playback time model. + + The preview ops of rotary jobs keep endpoint Y at a constant + while the real rotation lives in extra axes, which distorts + distances and makes arcs degenerate. The raw assembled ops + carry the true (unwrapped) path, so durations must come from + them; command indices and order match the preview ops 1:1. + """ + handle = self._current_job_handle + if handle is not None: + artifact = self._context.artifact_store.get(handle) + if isinstance(artifact, JobArtifact): + return artifact.ops + return None + + def update_scene_from_doc(self): + """ + Updates the entire scene content from the document. This is the main + entry point for refreshing the 3D view. + """ + if not self._get_gl_initialized(): + return + if not self._scene.texture_renderer: + return + + t_update_start = time.perf_counter() + logger.debug("Canvas3D: Updating scene from document.") + + # Theme/color updates only need to happen once per theme change + if self._theme_resolver.theme_is_dirty: + self._theme_resolver.update_theme_and_colors() + if not self._theme_resolver.color_set: + logger.warning("Cannot update scene, color set not resolved.") + return + + viewport = self._get_viewport() + + # Update cylinder renderers and camera based on layer rotary state + any_rotary = any(layer.rotary_enabled for layer in self.doc.layers) + self._mark_scene_dirty() + if ( + self._scene.had_rotary_layers + and not any_rotary + and self._get_camera_available() + ): + self._reset_view(ViewDirection.ISO) + self._scene.had_rotary_layers = any_rotary + + world_to_visual = np.identity(4, dtype=np.float32) + world_to_cyl_local = np.identity(4, dtype=np.float32) + + machine = self._context.machine + if machine: + ms = viewport.margin_shift + wcs = viewport.wcs_offset_mm + world_to_visual[0, 3] = ms[0, 3] + world_to_visual[1, 3] = ms[1, 3] + world_to_visual[2, 3] = wcs[2] + + asm = machine.assembly + if asm.has_rotary: + self._scene.set_cylinder_transform( + asm.cylinder_base_transform() + ) + else: + self._scene.set_cylinder_transform(np.eye(4, dtype=np.float64)) + + laser_dot_widths_mm: dict[str, float] = {} + if machine: + for head in machine.heads: + if isinstance(head, LaserHead): + spot_x, _spot_y = LaserHead.get_spot_size(head) + laser_dot_widths_mm[head.uid] = spot_x + + layer_configs: dict[str, LayerRenderConfig] = {} + for layer in self.doc.layers: + axis_position = 0.0 + reverse = False + axis_position_3d = None + cylinder_dir = None + if layer.rotary_enabled and machine: + cfg = resolve_layer_rotary(layer, machine) + module = cfg.module + if module is not None: + mapping = KinematicMapping.from_rotary_module( + module, + layer.rotary_diameter, + apply_gear_ratio=False, + ) + if mapping is not None: + axis_position = mapping.axis_position + axis_position_3d = tuple( + mapping.axis_position_3d.tolist() + ) + cylinder_dir = tuple(mapping.cylinder_dir.tolist()) + reverse = mapping.reverse + layer_configs[layer.uid] = LayerRenderConfig( + rotary_enabled=layer.rotary_enabled, + rotary_diameter=layer.rotary_diameter, + axis_position=axis_position, + reverse=reverse, + axis_position_3d=axis_position_3d, + cylinder_dir=cylinder_dir, + ) + + render_config = RenderConfig3D( + world_to_visual=world_to_visual, + world_to_cyl_local=world_to_cyl_local, + layer_configs=layer_configs, + laser_dot_widths_mm=laser_dot_widths_mm, + ) + + self._schedule_scene_preparation(render_config.to_dict()) + + t_update_elapsed = (time.perf_counter() - t_update_start) * 1000 + if t_update_elapsed > 5: + logger.debug( + f"update_scene_from_doc took {t_update_elapsed:.1f}ms" + ) + + def _schedule_scene_preparation( + self, + render_config_dict: dict, + ): + task_key = (id(self), "prepare-3d-scene-vertices") + + if ( + not self._get_gl_initialized() + or self._theme_resolver.color_set is None + ): + return + + job_handle = self._current_job_handle + if job_handle is None: + logger.debug("[CANVAS3D] No job artifact, skipping compilation.") + return + + if self._scene_preparation_task: + self._scene_preparation_task.cancel() + self._scene_preparation_task = None + logger.debug( + "[CANVAS3D] Cancelled in-progress compilation, " + "scheduling new one." + ) + + logger.debug("[CANVAS3D] Scheduling scene compilation task.") + self._compiled_job_generation = job_handle.generation_id + assert render_config_dict is not None + self._scene_preparation_task = task_mgr.run_thread( + compile_scene_from_job, + self._context.artifact_store, + job_handle.to_dict(), + render_config_dict, + key=task_key, + when_done=self._on_scene_prepared, + ) diff --git a/rayforge/ui_gtk/sim3d/shader/__init__.py b/rayforge/ui_gtk/sim3d/shader/__init__.py new file mode 100644 index 000000000..e8b759374 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/shader/__init__.py @@ -0,0 +1,17 @@ +""" +Shader program classes used by the 3D workbench. +""" + +from .background_shader import BackgroundShader +from .base import Shader +from .simple_shader import SimpleShader +from .text_shader import TextShader +from .texture_shader import TextureShader + +__all__ = ( + "BackgroundShader", + "Shader", + "SimpleShader", + "TextShader", + "TextureShader", +) diff --git a/rayforge/ui_gtk/sim3d/shader/background_shader.py b/rayforge/ui_gtk/sim3d/shader/background_shader.py new file mode 100644 index 000000000..a3955bdd5 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/shader/background_shader.py @@ -0,0 +1,56 @@ +""" +Fullscreen background gradient shader. +""" + +from .base import Shader + +BACKGROUND_VERTEX_SHADER = """ +layout (location = 0) in vec3 aPos; + +out vec2 vTexCoord; + +void main() { + gl_Position = vec4(aPos.xy, 0.0, 1.0); + vTexCoord = aPos.xy * 0.5 + 0.5; +} +""" + +BACKGROUND_FRAGMENT_SHADER = """ +in vec2 vTexCoord; +out vec4 FragColor; + +uniform vec3 uBgColor; +uniform vec3 uBgColorLight; + +void main() { + vec2 uv = vTexCoord; + + float vertical = mix(0.55, 1.0, uv.y); + + vec2 center = vec2(0.5, 0.45); + float dist = length(uv - center); + float vignette = 1.0 - smoothstep(0.0, 0.9, dist) * 0.45; + + float brightness = vertical * vignette; + + vec3 color = mix(uBgColor, uBgColorLight, brightness); + + float highlight = exp(-dist * dist * 6.0) * 0.12; + color += vec3(highlight * 0.8, highlight * 0.9, highlight); + + FragColor = vec4(color, 1.0); +} +""" + + +class BackgroundShader(Shader): + """Fullscreen gradient shader used as the canvas backdrop.""" + + def __init__(self): + super().__init__(BACKGROUND_VERTEX_SHADER, BACKGROUND_FRAGMENT_SHADER) + + def reset_uniforms(self) -> None: + """Sets every uniform this shader reads to its idle value.""" + self.use() + self.set_vec3("uBgColor", (0.11, 0.12, 0.14)) + self.set_vec3("uBgColorLight", (0.18, 0.20, 0.23)) diff --git a/rayforge/ui_gtk/sim3d/shader/base.py b/rayforge/ui_gtk/sim3d/shader/base.py new file mode 100644 index 000000000..4da2f97f2 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/shader/base.py @@ -0,0 +1,278 @@ +""" +Base class for GLSL shader programs. + +Compilation, uniform setting, and the snapshot/restore pair used by +``with shader:`` (see the context-manager protocol) and the +``gl_state`` context managers. +""" + +import logging +from typing import Any + +import numpy as np +from OpenGL import GL +from OpenGL.GL import shaders +from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +class Shader: + """Manages a GLSL shader program, including compilation and uniforms.""" + + def __init__(self, vertex_source: str, fragment_source: str): + """ + Compiles and links the vertex and fragment shader sources. + + Args: + vertex_source: The source code for the vertex shader. + fragment_source: The source code for the fragment shader. + + Raises: + ShaderCompilationError: If a shader source fails to compile. + ShaderLinkError: If the shader program fails to link. + """ + # Cache of the most recent value written to each uniform by + # ``set_*``. ``save()`` / ``restore()`` use this to snapshot + # and replay uniforms across a renderer pass without re-issuing + # GL queries. + self.program = None + self._uniform_values: dict[str, Any] = {} + self._uniform_snapshots: list[dict[str, Any]] = [] + self._uniform_location_cache: dict[str, int] = {} + # Determine the correct GLSL header for the current context. + version_str = GL.glGetString(GL.GL_VERSION) + is_es = version_str is not None and b"OpenGL ES" in version_str + if is_es: + vert_header = "#version 300 es\n" + frag_header = ( + "#version 300 es\n" + "precision highp float;\n" + "precision highp int;\n" + ) + logger.debug("Using OpenGL ES shader headers.") + else: + vert_header = "#version 330 core\n" + frag_header = "#version 330 core\n" + logger.debug("Using OpenGL desktop shader headers.") + + vertex_source = vert_header + vertex_source + fragment_source = frag_header + fragment_source + + try: + self.program = shaders.compileProgram( + shaders.compileShader(vertex_source, GL.GL_VERTEX_SHADER), + shaders.compileShader(fragment_source, GL.GL_FRAGMENT_SHADER), + ) + except shaders.ShaderValidationError as e: + logger.warning( + "Shader validation failed during program creation; " + "retrying without validation: %s", + e, + ) + self.program = shaders.compileProgram( + shaders.compileShader(vertex_source, GL.GL_VERTEX_SHADER), + shaders.compileShader(fragment_source, GL.GL_FRAGMENT_SHADER), + validate=False, + ) + except Exception: + logger.exception("Shader Compilation Failed") + raise + + def use(self) -> None: + """Activates this shader program for rendering.""" + GL.glUseProgram(self.program) + + def __enter__(self) -> Self: + """ + Snapshots the current uniform values. + + Pairs with :meth:`__exit__` so ``with shader:`` restores the + uniform state the shader had on entry — even if the body throws. + Nested ``with`` blocks on the same shader are supported. + """ + self._uniform_snapshots.append(self.save()) + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Restores the uniform snapshot taken by :meth:`__enter__`.""" + if self._uniform_snapshots: + self.restore(self._uniform_snapshots.pop()) + + def reset_uniforms(self) -> None: + """ + Sets all uniforms to their neutral/idle values. + + Called once at the start of a frame so that :meth:`save` / + :meth:`restore` have a stable baseline. Subclasses override to + set the uniforms their draw path reads. + """ + + def set_mat4(self, name: str, mat: np.ndarray) -> None: + """ + Sets a mat4 uniform in the shader. + + The matrix is expected to be in row-major format (NumPy + convention); it is transposed here so the GPU receives + column-major data. All renderers pass row-major matrices. + + Args: + name: The name of the uniform variable in the shader. + mat: A 4x4 NumPy array, row-major. + """ + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniformMatrix4fv(loc, 1, GL.GL_TRUE, mat) + self._uniform_values[name] = ("mat4", np.array(mat, copy=True)) + + def set_mat3(self, name: str, mat: np.ndarray) -> None: + """ + Sets a mat3 uniform in the shader. + + The matrix is expected to be in row-major format (NumPy + convention); it is transposed here so the GPU receives + column-major data. All renderers pass row-major matrices. + + Args: + name: The name of the uniform variable in the shader. + mat: A 3x3 NumPy array, row-major. + """ + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniformMatrix3fv(loc, 1, GL.GL_TRUE, mat) + self._uniform_values[name] = ("mat3", np.array(mat, copy=True)) + + def set_vec2(self, name: str, vec: tuple | list | np.ndarray) -> None: + """ + Sets a vec2 uniform in the shader. + + Args: + name: The name of the uniform variable in the shader. + vec: A sequence (tuple, list, or array) of 2 floats. + """ + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniform2fv(loc, 1, np.asarray(vec, dtype=np.float32)) + self._uniform_values[name] = ( + "vec2", + np.asarray(vec, dtype=np.float32).copy(), + ) + + def set_vec3(self, name: str, vec: tuple | list | np.ndarray) -> None: + """Sets a vec3 uniform in the shader. + + Args: + name: The name of the uniform variable in the shader. + vec: A sequence (tuple, list, or array) of 3 floats. + """ + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniform3fv(loc, 1, np.asarray(vec, dtype=np.float32)) + self._uniform_values[name] = ( + "vec3", + np.asarray(vec, dtype=np.float32).copy(), + ) + + def set_vec4(self, name: str, vec: tuple | list | np.ndarray) -> None: + """Sets a vec4 uniform in the shader. + + Args: + name: The name of the uniform variable in the shader. + vec: A sequence (tuple, list, or array) of 4 floats. + """ + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniform4fv(loc, 1, np.asarray(vec, dtype=np.float32)) + self._uniform_values[name] = ( + "vec4", + np.asarray(vec, dtype=np.float32).copy(), + ) + + def save(self) -> dict[str, Any]: + """ + Snapshots all ``set_*``-tracked uniform values. + + Returns the snapshot so the caller can replay it later via + :meth:`restore`. Used by the ``with shader:`` context manager + to bracket renderers that mutate overlapping uniforms. + """ + return { + name: (kind, np.array(val, copy=True)) + for name, (kind, val) in self._uniform_values.items() + } + + def restore(self, snapshot: dict[str, Any]) -> None: + """ + Replays a uniform snapshot produced by :meth:`save`. + + Binds this program first (uniforms are stored per-program) and + re-issues the same ``set_*`` call for each entry whose current + value differs from the snapshot, so values are restored even if a + renderer clobbered them mid-frame or left a different program + active. Uniforms the renderer did not touch are skipped to avoid + redundant GL round-trips. Unknown uniform locations are silently + skipped (as ``set_*`` is). + """ + self.use() + for name, (kind, val) in snapshot.items(): + current = self._uniform_values.get(name) + if current is None: + continue + current_kind, current_val = current + if current_kind != kind or not self._uniforms_equal( + kind, current_val, val + ): + self._restore_one(name, kind, val) + + @staticmethod + def _uniforms_equal(kind: str, a: Any, b: Any) -> bool: + """True if two stored uniform values are equal.""" + if kind in ("float", "int"): + return a == b + return bool(np.array_equal(np.asarray(a), np.asarray(b))) + + def _restore_one(self, name: str, kind: str, val: Any) -> None: + """Re-issues one uniform value via the matching ``set_*``.""" + if kind == "mat4": + self.set_mat4(name, val) + elif kind == "mat3": + self.set_mat3(name, val) + elif kind == "vec2": + self.set_vec2(name, val) + elif kind == "vec3": + self.set_vec3(name, val) + elif kind == "vec4": + self.set_vec4(name, val) + elif kind == "float": + self.set_float(name, val) + elif kind == "int": + self.set_int(name, val) + + def cleanup(self) -> None: + """Deletes the shader program from GPU context to free resources.""" + if self.program: + GL.glDeleteProgram(self.program) + self.program = None + self._uniform_location_cache.clear() + + def get_uniform_location(self, name: str) -> int: + """Gets the location of a uniform variable, cached per program.""" + loc = self._uniform_location_cache.get(name) + if loc is None: + loc = GL.glGetUniformLocation(self.program, name) + self._uniform_location_cache[name] = loc + return loc + + def set_float(self, name: str, value: float) -> None: + """Sets a float uniform.""" + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniform1f(loc, value) + self._uniform_values[name] = ("float", float(value)) + + def set_int(self, name: str, value: int) -> None: + """Sets an integer uniform.""" + loc = self.get_uniform_location(name) + if loc != -1: + GL.glUniform1i(loc, value) + self._uniform_values[name] = ("int", int(value)) diff --git a/rayforge/ui_gtk/sim3d/shader/simple_shader.py b/rayforge/ui_gtk/sim3d/shader/simple_shader.py new file mode 100644 index 000000000..ed5d61def --- /dev/null +++ b/rayforge/ui_gtk/sim3d/shader/simple_shader.py @@ -0,0 +1,133 @@ +""" +Simple two-light + LUT-driven shader used by most renderers. +""" + +from .base import Shader + +SIMPLE_VERTEX_SHADER = """ +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec4 aColor; +layout (location = 2) in vec3 aNormal; +uniform mat4 uMVP; +uniform vec3 uPartialEnd; +uniform int uPartialVertexID; +out vec4 vColor; +out vec3 vNormal; +out vec3 vPos; +flat out int vVertexID; +void main() { + vec3 pos = aPos; + if (gl_VertexID == uPartialVertexID) { + pos = uPartialEnd; + } + gl_Position = uMVP * vec4(pos, 1.0); + vColor = aColor; + vNormal = aNormal; + vPos = pos; + vVertexID = gl_VertexID; +} +""" + +SIMPLE_FRAGMENT_SHADER = """ +out vec4 FragColor; +in vec4 vColor; +in vec3 vNormal; +in vec3 vPos; +flat in int vVertexID; +uniform vec4 uColor; +uniform float uUseVertexColor; +uniform float uHasNormals; +uniform vec3 uLightDir; +uniform vec3 uLightDir2; +uniform vec3 uCameraPos; +uniform int uExecutedVertexCount; +uniform float uAlphaPending; +uniform float uEmissive; +uniform vec3 uPointLightPos; +uniform float uPointLightOn; +uniform float uUsePowerLUT; +uniform sampler2D uColorLUT; +uniform int uNumLaserLUTs; +uniform vec4 uZeroPowerColor; +void main() { + vec4 baseColor; + if (uUsePowerLUT > 0.5) { + float power = clamp(vColor.r, 0.0, 1.0); + if (power < 0.001) { + baseColor = uZeroPowerColor; + } else { + int laserIdx = int(vColor.g + 0.5); + float lutY = (float(laserIdx) + 0.5) + / float(max(uNumLaserLUTs, 1)); + float lutX = 0.5 + 0.5 * power; + baseColor = texture(uColorLUT, vec2(lutX, lutY)); + } + } else if (uUseVertexColor > 0.5) { + baseColor = vColor; + } else { + baseColor = uColor; + } + if (uHasNormals > 0.5) { + vec3 n = normalize(vNormal); + vec3 lightDir = normalize(uLightDir); + float diff = max(dot(n, lightDir), 0.0); + float ambient = 0.35; + float diffuse = (1.0 - ambient) * diff; + + vec3 viewDir = normalize(uCameraPos - vPos); + vec3 halfDir = normalize(lightDir + viewDir); + float spec = pow(max(dot(n, halfDir), 0.0), 48.0); + float specular = 0.35 * spec; + + vec3 lightDir2 = normalize(uLightDir2); + float diff2 = max(dot(n, lightDir2), 0.0); + + float light = ambient + diffuse + specular + 0.3 * diff2; + + if (uPointLightOn > 0.5) { + // Point light from laser + vec3 toPoint = uPointLightPos - vPos; + float dist = length(toPoint); + float atten = 1.0 / (1.0 + 0.005 * dist * dist); + if (dist > 0.001) { + vec3 plDir = toPoint / dist; + float plDiff = max(dot(n, plDir), 0.0); + light += plDiff * atten; + } + } + + FragColor = vec4(baseColor.rgb * light, baseColor.a); + } else { + FragColor = baseColor; + } + FragColor.rgb *= (1.0 + uEmissive); + if (uExecutedVertexCount >= 0) { + if (vVertexID >= uExecutedVertexCount) { + FragColor.a *= uAlphaPending; + } + } +} +""" + + +class SimpleShader(Shader): + """The two-light LUT-driven shader used by most 3D renderers.""" + + def __init__(self): + super().__init__(SIMPLE_VERTEX_SHADER, SIMPLE_FRAGMENT_SHADER) + + def reset_uniforms(self) -> None: + """Sets every uniform this shader reads to its idle value.""" + self.use() + self.set_float("uUseVertexColor", 0.0) + self.set_float("uHasNormals", 0.0) + self.set_int("uExecutedVertexCount", -1) + self.set_int("uPartialVertexID", -1) + self.set_vec3("uPartialEnd", (0.0, 0.0, 0.0)) + self.set_float("uAlphaPending", 0.2) + self.set_float("uEmissive", 0.0) + self.set_float("uUsePowerLUT", 0.0) + self.set_int("uNumLaserLUTs", 1) + self.set_vec4("uZeroPowerColor", (0.0, 0.0, 0.0, 1.0)) + self.set_float("uPointLightOn", 0.0) + self.set_vec3("uPointLightPos", (0.0, 0.0, 0.0)) diff --git a/rayforge/ui_gtk/sim3d/shader/text_shader.py b/rayforge/ui_gtk/sim3d/shader/text_shader.py new file mode 100644 index 000000000..9c1cbd414 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/shader/text_shader.py @@ -0,0 +1,73 @@ +""" +Billboarded text shader for axis/wcs labels. +""" + +from .base import Shader + +# This shader calculates vertex positions relative to a single string +# anchor, ensuring the whole label billboards as one unit. +TEXT_VERTEX_SHADER = """ +layout (location = 0) in vec4 aVertex; // In: x, y ([-0.5, 0.5]), u, v +layout (location = 1) in vec3 aCharInfo; // In: offsetX, quadSizeX, quadHeight +layout (location = 2) in vec3 aAnchor; // In: string anchor world position + +// Uniforms +uniform mat4 uMVP; // Model-View-Projection Matrix +uniform mat3 uBillboard; // Camera's rotation matrix to billboard the plane + +// Outputs +out vec2 vTexCoord; + +void main() { + // 1. Calculate the vertex's local position relative to the + // string's anchor. + // aVertex.x is [-0.5, 0.5], so (aVertex.x + 0.5) is [0, 1]. + // This places the character quad correctly along the local + // X-axis. The Y-position is centered on the axis. + vec3 vertex_pos_local = vec3( + aCharInfo.x + (aVertex.x + 0.5) * aCharInfo.y, + aVertex.y * aCharInfo.z, + 0.0 + ); + + // 2. Rotate this local position vector using the billboard matrix. + // This orients the entire string plane to face the camera. + vec3 rotated_offset = uBillboard * vertex_pos_local; + + // 3. Add the final rotated offset to the string's world anchor + // position. + gl_Position = uMVP * vec4(aAnchor + rotated_offset, 1.0); + + // 4. Pass texture coordinates to the fragment shader. + vTexCoord = aVertex.zw; +} +""" + +TEXT_FRAGMENT_SHADER = """ +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uTextAtlas; +uniform vec4 uTextColor; + +void main() { + float alpha = texture(uTextAtlas, vTexCoord).r; + if (alpha < 0.1) { + discard; + } + FragColor = vec4(uTextColor.rgb, uTextColor.a * alpha); +} +""" + + +class TextShader(Shader): + """Billboarded text shader used by the axis label renderer.""" + + def __init__(self): + super().__init__(TEXT_VERTEX_SHADER, TEXT_FRAGMENT_SHADER) + + def reset_uniforms(self) -> None: + """Sets every uniform this shader reads to its idle value.""" + self.use() + self.set_int("uTextAtlas", 0) + self.set_vec4("uTextColor", (1.0, 1.0, 1.0, 1.0)) diff --git a/rayforge/ui_gtk/sim3d/shader/texture_shader.py b/rayforge/ui_gtk/sim3d/shader/texture_shader.py new file mode 100644 index 000000000..f657d38c5 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/shader/texture_shader.py @@ -0,0 +1,92 @@ +""" +Two-source texture shader with a per-laser LUT recolour pass. +""" + +from .base import Shader + +TEXTURE_VERTEX_SHADER = """ +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +uniform mat4 uMVP; + +out vec2 vTexCoord; + +void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vTexCoord = aTexCoord; +} +""" + +TEXTURE_FRAGMENT_SHADER = """ +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uTexture; +uniform sampler2D uColorLUT; +uniform int uNumLaserLUTs; +uniform int uLaserIndex; +uniform float uAlpha; +uniform float uMaxMip; + +void main() { + // Select the mip level that matches the texel footprint so the + // raster rows do not alias into a moire banding pattern when the + // texture is minified (zoomed out). + ivec2 texSize = textureSize(uTexture, 0); + vec2 p = vTexCoord * vec2(texSize); + vec2 ddx = abs(dFdx(p)); + vec2 ddy = abs(dFdy(p)); + float footprint = max(max(ddx.x, ddx.y), max(ddy.x, ddy.y)); + float lod = clamp(log2(max(footprint, 1.0)), 0.0, uMaxMip); + int li = int(lod + 0.5); + ivec2 ls = textureSize(uTexture, li); + + vec2 tc = vTexCoord * vec2(ls) - 0.5; + ivec2 base = ivec2(floor(tc)); + float power = 0.0; + for (int dy = 0; dy <= 1; dy++) { + for (int dx = 0; dx <= 1; dx++) { + ivec2 idx = clamp( + base + ivec2(dx, dy), + ivec2(0), + ls - ivec2(1) + ); + power = max(power, texelFetch(uTexture, idx, li).r); + } + } + + if (power <= 0.0) { + // Write depth for the gaps between scanlines so geometry drawn + // behind the raster (e.g. rotary module models) cannot bleed + // through the texture's zero-power pixels and band the preview. + // Contribute no colour (alpha 0). + FragColor = vec4(0.0, 0.0, 0.0, 0.0); + return; + } + + float lutY = (float(uLaserIndex) + 0.5) + / float(max(uNumLaserLUTs, 1)); + float lutX = 0.5 + 0.5 * power; + vec4 color = texture(uColorLUT, vec2(lutX, lutY)); + + FragColor = vec4(color.rgb, color.a * uAlpha); +} +""" + + +class TextureShader(Shader): + """Texture shader used by the texture-artifact renderer.""" + + def __init__(self): + super().__init__(TEXTURE_VERTEX_SHADER, TEXTURE_FRAGMENT_SHADER) + + def reset_uniforms(self) -> None: + """Sets every uniform this shader reads to its idle value.""" + self.use() + self.set_int("uTexture", 0) + self.set_int("uColorLUT", 1) + self.set_int("uNumLaserLUTs", 1) + self.set_int("uLaserIndex", 0) + self.set_float("uAlpha", 1.0) + self.set_float("uMaxMip", 0.0) diff --git a/rayforge/ui_gtk/sim3d/theme_resolver.py b/rayforge/ui_gtk/sim3d/theme_resolver.py new file mode 100644 index 000000000..1108df606 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/theme_resolver.py @@ -0,0 +1,138 @@ +""" +Theme + colour resolution for the 3D canvas. + +Delegates the shared domain colours (base ``ColorSet``, laser colour +sets) to the context-wide :class:`ThemeColorService`, and keeps only the +GL-specific background/axis/grid derivation local. The canvas asks the +resolver to refresh when its theme is dirty and reads the resolved state +through properties. +""" + +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional + +from OpenGL import GL + +from ...context import get_context +from ...core.color import ColorSet + +if TYPE_CHECKING: + from gi.repository import Gtk + + from ...machine.models.machine import Machine + from .renderer.scene_renderer import SceneRenderer + + +class ThemeResolver: + """ + Resolves theme-derived colours and colour LUTs for the 3D scene. + + Base ``ColorSet`` and laser colour sets come from the shared theme + service; the GL background/axis/grid derivation stays here. + """ + + def __init__( + self, + widget: "Gtk.Widget", + scene: "SceneRenderer", + get_machine: Callable[[], Optional["Machine"]], + get_gl_initialized: Callable[[], bool], + request_render: Callable[[], None], + ): + self._widget = widget + self._scene = scene + self._get_machine = get_machine + self._get_gl_initialized = get_gl_initialized + self._request_render = request_render + + @property + def color_set(self) -> ColorSet | None: + """The resolved theme ColorSet, or None if not yet resolved.""" + return get_context().theme.color_set + + @property + def theme_is_dirty(self) -> bool: + """True if theme-derived colours need re-resolving.""" + return get_context().theme.dirty + + def mark_dirty(self): + """Mark theme-derived colours as needing re-resolution.""" + get_context().theme.mark_dirty() + + def on_style_changed(self, widget, gparam): + """Marks theme resources as dirty when the GTK theme changes.""" + get_context().theme.mark_dirty() + self._request_render() + + def update_theme_and_colors(self): + """ + Resolves the ColorSet and updates other theme-dependent elements. + """ + if not self._scene.axis_renderer or not self._scene.texture_renderer: + return + + service = get_context().theme + service.set_machine(self._get_machine()) + color_set = service.color_set + if color_set is None: + return + + style_context = self._widget.get_style_context() + found, bg_rgba = style_context.lookup_color("theme_bg_color") + if not found: + found, bg_rgba = style_context.lookup_color("view_bg_color") + + if found: + bg_color = ( + bg_rgba.red * 0.35, + bg_rgba.green * 0.35, + bg_rgba.blue * 0.35, + ) + bg_light = ( + min(1.0, bg_rgba.red * 0.9), + min(1.0, bg_rgba.green * 0.9), + min(1.0, bg_rgba.blue * 0.9), + ) + clear_color = ( + bg_rgba.red, + bg_rgba.green, + bg_rgba.blue, + bg_rgba.alpha, + ) + else: + bg_color = (0.11, 0.11, 0.14) + bg_light = (0.2, 0.2, 0.25) + clear_color = (0.2, 0.2, 0.25, 1.0) + + self._scene.apply_background_colors(bg_color, bg_light) + + GL.glClearColor(*clear_color) + + # Get the foreground color for axes and labels + found, fg_rgba = style_context.lookup_color("view_fg_color") + if found: + axis_color = ( + fg_rgba.red, + fg_rgba.green, + fg_rgba.blue, + fg_rgba.alpha, + ) + # Grid color is derived from fg color to be less prominent + grid_color = fg_rgba.red, fg_rgba.green, fg_rgba.blue, 0.5 + bg_plane_color = fg_rgba.red, fg_rgba.green, fg_rgba.blue, 0.08 + + self._scene.apply_axis_colors( + axis_color, grid_color, bg_plane_color + ) + + self.update_renderer_color_luts() + + def update_renderer_color_luts(self): + if not self._get_gl_initialized(): + return + + provider = get_context().theme.color_lut_provider() + if provider is None: + return + + self._scene.update_color_luts(provider) diff --git a/rayforge/ui_gtk/sim3d/viewport.py b/rayforge/ui_gtk/sim3d/viewport.py new file mode 100644 index 000000000..47f504888 --- /dev/null +++ b/rayforge/ui_gtk/sim3d/viewport.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +from raygeo.geo.types import Point3D, Rect + +if TYPE_CHECKING: + from ...machine.models.machine import Machine + + +@dataclass +class ViewportConfig: + width_mm: float + depth_mm: float + model_matrix: np.ndarray + wcs_offset_mm: Point3D + margin_shift: np.ndarray + extent_frame: Rect | None + x_right: bool + y_down: bool + x_negative: bool + y_negative: bool + + @classmethod + def default( + cls, width_mm: float = 100.0, depth_mm: float = 100.0 + ) -> "ViewportConfig": + identity = np.identity(4, dtype=np.float32) + return cls( + width_mm=width_mm, + depth_mm=depth_mm, + model_matrix=identity, + wcs_offset_mm=(0.0, 0.0, 0.0), + margin_shift=identity, + extent_frame=None, + x_right=False, + y_down=False, + x_negative=False, + y_negative=False, + ) + + @classmethod + def from_machine(cls, machine: "Machine") -> "ViewportConfig": + return cls.from_machine_with_wcs( + machine, machine.get_active_wcs_offset() + ) + + @classmethod + def from_machine_with_wcs( + cls, machine: "Machine", wcs_offset: tuple + ) -> "ViewportConfig": + panel = machine.panel + width_mm = float(panel.workarea_size[0]) + depth_mm = float(panel.workarea_size[1]) + + translate_mat = np.identity(4, dtype=np.float32) + scale_mat = np.identity(4, dtype=np.float32) + if panel.y_axis_down: + translate_mat[1, 3] = depth_mm + scale_mat[1, 1] = -1.0 + if panel.x_axis_right: + translate_mat[0, 3] = width_mm + scale_mat[0, 0] = -1.0 + model_matrix = translate_mat @ scale_mat + + if machine.wcs_origin_is_workarea_origin: + wcs_offset_mm: Point3D = (0.0, 0.0, 0.0) + else: + wcs_x, wcs_y, wcs_z = wcs_offset + ml, mt, mr, mb = panel.margins + machine_x = -mr if panel.x_axis_right else -ml + machine_y = -mt if panel.y_axis_down else -mb + local_x = ( + machine_x - wcs_x + if panel.x_axis_negative + else machine_x + wcs_x + ) + local_y = ( + machine_y - wcs_y + if panel.y_axis_negative + else machine_y + wcs_y + ) + wcs_offset_mm = (local_x, local_y, wcs_z) + + margin_shift = np.identity(4, dtype=np.float32) + ml, _, _, mb = panel.margins + margin_shift[0, 3] = -ml + margin_shift[1, 3] = -mb + + extent_frame: Rect | None = None + if panel.has_custom_work_area: + extent_frame = panel.extent_frame + + return cls( + width_mm=width_mm, + depth_mm=depth_mm, + model_matrix=model_matrix, + wcs_offset_mm=wcs_offset_mm, + margin_shift=margin_shift, + extent_frame=extent_frame, + x_right=panel.x_axis_right, + y_down=panel.y_axis_down, + x_negative=panel.x_axis_negative, + y_negative=panel.y_axis_negative, + ) diff --git a/rayforge/ui_gtk/task_bar.py b/rayforge/ui_gtk/task_bar.py new file mode 100644 index 000000000..eaca26920 --- /dev/null +++ b/rayforge/ui_gtk/task_bar.py @@ -0,0 +1,33 @@ +import logging + +from blinker import Signal +from gi.repository import Gtk + +from ..machine.models.machine import Machine +from .shared.progress_bar import ProgressBar + +logger = logging.getLogger(__name__) + + +class TaskBar(Gtk.Box): + """ + A status bar with an overall progress bar. + """ + + log_requested = Signal() + + def __init__(self, task_mgr): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.task_mgr = task_mgr + self.add_css_class("statusbar") + + # Overall Task Progress Bar + self.overall_progress_bar = ProgressBar(task_mgr) + self.append(self.overall_progress_bar) + + gesture = Gtk.GestureClick() + gesture.connect("pressed", lambda *args: self.log_requested.send(self)) + self.add_controller(gesture) + + def set_machine(self, machine: Machine | None): + pass diff --git a/rayforge/ui_gtk/toolbar.py b/rayforge/ui_gtk/toolbar.py new file mode 100644 index 000000000..0b423adec --- /dev/null +++ b/rayforge/ui_gtk/toolbar.py @@ -0,0 +1,293 @@ +import logging +from gettext import gettext as _ + +from blinker import Signal +from gi.repository import Gdk, Gtk + +from .action_registry import action_registry +from .icons import get_icon +from .shared.splitbutton import SplitMenuButton +from .shared.undo_button import RedoButton, UndoButton +from .sim3d import initialized as canvas3d_initialized + +logger = logging.getLogger(__name__) + + +class MainToolbar(Gtk.Box): + """ + The main application toolbar. + Connects its buttons to Gio.Actions for centralized control. + """ + + def __init__(self, **kwargs): + super().__init__( + orientation=Gtk.Orientation.HORIZONTAL, spacing=6, **kwargs + ) + # Signals for View-State controls (not app actions) + self.machine_warning_clicked = Signal() + + self.set_margin_bottom(2) + self.set_margin_top(2) + self.set_margin_start(12) + self.set_margin_end(12) + + # File related buttons (open, save, import, export) + self.open_button = Gtk.Button(child=get_icon("open-symbolic")) + self.open_button.set_tooltip_text(_("Open Project")) + self.open_button.set_action_name("win.open") + self.append(self.open_button) + + self.save_button = Gtk.Button(child=get_icon("save-symbolic")) + self.save_button.set_tooltip_text(_("Save")) + self.save_button.set_action_name("win.save") + self.append(self.save_button) + + self.save_as_button = Gtk.Button(child=get_icon("save-as-symbolic")) + self.save_as_button.set_tooltip_text(_("Save As...")) + self.save_as_button.set_action_name("win.save-as") + self.append(self.save_as_button) + + open_button = Gtk.Button(child=get_icon("download-symbolic")) + open_button.set_tooltip_text(_("Import image")) + open_button.set_action_name("win.import") + self.append(open_button) + + self.export_button = Gtk.Button(child=get_icon("export-symbolic")) + self.export_button.set_tooltip_text(_("Generate G-code")) + self.export_button.set_action_name("win.export") + self.append(self.export_button) + + # Undo/Redo Buttons + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.append(sep) + + self.undo_button = UndoButton() + self.undo_button.set_tooltip_text(_("Undo")) + self.undo_button.set_action_name("win.undo") + self.append(self.undo_button) + + self.redo_button = RedoButton() + self.redo_button.set_tooltip_text(_("Redo")) + self.redo_button.set_action_name("win.redo") + self.append(self.redo_button) + + # Add a button to open the 3D preview window. + view_3d_button = Gtk.ToggleButton(child=get_icon("3d-symbolic")) + view_3d_button.set_action_name("win.show_3d_view") + view_3d_button.set_sensitive(canvas3d_initialized) + if not canvas3d_initialized: + view_3d_button.set_tooltip_text( + _("3D view disabled (missing dependencies like PyOpenGL)") + ) + else: + view_3d_button.set_tooltip_text(_("Show 3D Preview")) + self.append(view_3d_button) + + self.recalculate_button = Gtk.Button( + child=get_icon("refresh-symbolic"), + ) + self.recalculate_button.set_tooltip_text( + _("Recalculate (Shift+Click to force)") + ) + self.recalculate_button.connect( + "clicked", self._on_recalculate_clicked + ) + recalc_gesture = Gtk.GestureClick.new() + recalc_gesture.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + recalc_gesture.connect("pressed", self._on_recalculate_pressed) + self.recalculate_button.add_controller(recalc_gesture) + self.append(self.recalculate_button) + self._recalculate_force = False + + # Add a button to toggle the control panel. + self.bottom_panel_button = Gtk.ToggleButton() + self.bottom_panel_button.set_child(get_icon("jog-symbolic")) + self.bottom_panel_button.set_active(False) + self.bottom_panel_button.set_tooltip_text(_("Toggle bottom panel")) + self.bottom_panel_button.set_action_name("win.toggle_bottom_panel") + self.append(self.bottom_panel_button) + + # Arrangement buttons (Consolidated Dropdown) + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.append(sep) + + self.arrange_actions = self._build_arrange_actions() + self.arrange_menu_button = SplitMenuButton( + actions=self.arrange_actions + ) + self.arrange_menu_button.set_tooltip_text(_("Arrange selection")) + self.append(self.arrange_menu_button) + + # Tabbing buttons (Split Dropdown) + tab_actions = [ + ( + _("Add Equidistant Tabs…"), + "tabs-equidistant-symbolic", + "win.add-tabs-equidistant", + ), + ( + _("Add Cardinal Tabs (N,S,E,W)"), + "compass-symbolic", + "win.add-tabs-cardinal", + ), + ] + self.tab_menu_button = SplitMenuButton(actions=tab_actions) + self.tab_menu_button.set_tooltip_text(_("Add Tabs to selection")) + self.append(self.tab_menu_button) + + # Control buttons: home, send, pause, stop + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.append(sep) + + self.home_button = Gtk.Button(child=get_icon("home-symbolic")) + self.home_button.set_tooltip_text(_("Home the machine")) + self.home_button.set_action_name("win.machine-home") + self.append(self.home_button) + + self.frame_button = Gtk.Button(child=get_icon("frame-symbolic")) + self.frame_button.set_tooltip_text( + _("Cycle laser head around the occupied area") + ) + self.frame_button.set_action_name("win.machine-frame") + self.append(self.frame_button) + + self.send_button = Gtk.Button(child=get_icon("send-symbolic")) + self.send_button.set_tooltip_text(_("Send to machine")) + self.send_button.set_action_name("win.machine-send") + self.append(self.send_button) + + self.hold_on_icon = get_icon("play-arrow-symbolic") + self.hold_off_icon = get_icon("pause-symbolic") + self.hold_button = Gtk.ToggleButton() + self.hold_button.set_child(self.hold_off_icon) + self.hold_button.set_tooltip_text(_("Pause machine")) + self.hold_button.set_action_name("win.machine-hold") + self.append(self.hold_button) + + self.cancel_button = Gtk.Button(child=get_icon("stop-symbolic")) + self.cancel_button.set_tooltip_text(_("Cancel running job")) + self.cancel_button.set_action_name("win.machine-cancel") + self.append(self.cancel_button) + + self.clear_alarm_button = Gtk.Button( + child=get_icon("clear-alarm-symbolic") + ) + self.clear_alarm_button.set_tooltip_text( + _("Clear machine alarm (unlock)") + ) + self.clear_alarm_button.set_action_name("win.machine-clear-alarm") + self.append(self.clear_alarm_button) + + self.focus_on_icon = get_icon("laser-on-symbolic") + self.focus_off_icon = get_icon("laser-off-symbolic") + self.focus_button = Gtk.ToggleButton() + self.focus_button.set_child(self.focus_on_icon) + self.focus_button.set_tooltip_text(_("Toggle focus laser")) + self.focus_button.set_action_name("win.toggle-focus") + self.focus_button.connect("toggled", self._on_focus_toggled) + self.append(self.focus_button) + + # Add clickable warning for misconfigured machine + self.machine_warning_box = Gtk.Box(spacing=6) + self.machine_warning_box.set_margin_end(12) + warning_icon = get_icon("warning-symbolic") + self.warning_label = Gtk.Label(label=_("Machine not fully configured")) + self.warning_label.add_css_class("warning-label") + self.machine_warning_box.append(warning_icon) + self.machine_warning_box.append(self.warning_label) + self.machine_warning_box.set_tooltip_text( + _("Machine driver is missing required settings. Click to edit.") + ) + self.machine_warning_box.set_visible(False) + warning_click = Gtk.GestureClick.new() + warning_click.connect( + "pressed", lambda *_: self.machine_warning_clicked.send(self) + ) + self.machine_warning_box.add_controller(warning_click) + self.append(self.machine_warning_box) + + # Connect to action registry changes for dynamic toolbar updates + action_registry.changed.connect(self._on_action_registry_changed) + + def _on_recalculate_pressed(self, gesture, n_press, x, y): + self._recalculate_force = bool( + gesture.get_current_event_state() & Gdk.ModifierType.SHIFT_MASK + ) + + def _on_recalculate_clicked(self, button): + force = self._recalculate_force + self._recalculate_force = False + action_name = "win.force-recalculate" if force else "win.recalculate" + self.activate_action(action_name, None) + + def _build_arrange_actions(self): + """Build the list of arrange actions including registered layouts.""" + arrange_actions = [ + ( + _("Center Horizontally"), + "align-horizontal-center-symbolic", + "win.align-h-center", + ), + ( + _("Center Vertically"), + "align-vertical-center-symbolic", + "win.align-v-center", + ), + (_("Align Left"), "align-left-symbolic", "win.align-left"), + (_("Align Right"), "align-right-symbolic", "win.align-right"), + (_("Align Top"), "align-top-symbolic", "win.align-top"), + (_("Align Bottom"), "align-bottom-symbolic", "win.align-bottom"), + ( + _("Spread Horizontally"), + "distribute-horizontal-symbolic", + "win.spread-h", + ), + ( + _("Spread Vertically"), + "distribute-vertical-symbolic", + "win.spread-v", + ), + ( + _("Flip Horizontal"), + "flip-horizontal-symbolic", + "win.flip-horizontal", + ), + ( + _("Flip Vertical"), + "flip-vertical-symbolic", + "win.flip-vertical", + ), + ] + for info in action_registry.get_toolbar_items("arrange"): + if info.label: + icon = info.icon_name or "auto-layout-symbolic" + arrange_actions.append( + ( + info.label, + icon, + f"win.{info.action_name}", + ) + ) + return arrange_actions + + def _on_action_registry_changed(self, sender): + """Handle action registry changes by refreshing arrange menu.""" + self.arrange_actions = self._build_arrange_actions() + self.arrange_menu_button.update_actions(self.arrange_actions) + + def _on_focus_toggled(self, button: Gtk.ToggleButton): + """Callback to update the focus icon when the button's + state changes for any reason (user click or action state change).""" + if button.get_active(): + button.set_child(self.focus_off_icon) + else: + button.set_child(self.focus_on_icon) + + def set_machine_warning( + self, error_title: str, error_code: int, error_description: str + ): + """ + Update the machine warning label with title, code and description. + """ + self.warning_label.set_label(f"{error_title} ({error_code})") + self.machine_warning_box.set_tooltip_text(error_description) diff --git a/rayforge/ui_gtk/varset/__init__.py b/rayforge/ui_gtk/varset/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/ui_gtk/varset/adapter/__init__.py b/rayforge/ui_gtk/varset/adapter/__init__.py new file mode 100644 index 000000000..cadf5fd67 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/__init__.py @@ -0,0 +1,17 @@ +from .base import ( + NULL_CHOICE_LABEL, + RowAdapter, + escape_title, + natural_sort_key, + register_adapter, +) +from .registry import create_row_for_var + +__all__ = [ + "NULL_CHOICE_LABEL", + "RowAdapter", + "create_row_for_var", + "escape_title", + "natural_sort_key", + "register_adapter", +] diff --git a/rayforge/ui_gtk/varset/adapter/appkey.py b/rayforge/ui_gtk/varset/adapter/appkey.py new file mode 100644 index 000000000..9e345ae00 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/appkey.py @@ -0,0 +1,282 @@ +import json +import logging +import urllib.error +import urllib.request +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, GLib, Gtk + +from ....core.varset import AppKeyVar, Var +from .base import RowAdapter, escape_title, register_adapter + +logger = logging.getLogger(__name__) + +_POLL_INTERVAL_MS = 2000 +_REQUEST_TIMEOUT = 10 + + +@register_adapter(AppKeyVar) +class AppKeyAdapter(RowAdapter): + """ + Adapter that renders an AppKeyVar. + + Shows an ExpanderRow with a text entry for manual API key input + and a "Request Access" button that initiates the decision-based + key approval flow (probe → request → poll). + """ + + has_natural_commit = True + + def __init__( + self, + row: Adw.ExpanderRow, + entry_row: Adw.EntryRow, + request_btn: Gtk.Button, + clear_btn: Gtk.Button, + var: AppKeyVar, + ): + super().__init__() + self._row = row + self._entry_row = entry_row + self._request_btn = request_btn + self._clear_btn = clear_btn + self._var: AppKeyVar = var + self._key_value: str = var.value or "" + self._poll_source_id: int | None = None + + self._entry_row.connect("apply", self._on_entry_apply) + self._request_btn.connect("clicked", self._on_request) + self._clear_btn.connect("clicked", self._on_clear) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "AppKeyAdapter"]: + app_var = var + assert isinstance(app_var, AppKeyVar) + + row = Adw.ExpanderRow(title=escape_title(app_var.label)) + if app_var.description: + row.set_subtitle(app_var.description) + + entry_row = Adw.EntryRow( + title=_("API Key"), + ) + entry_row.set_show_apply_button(True) + row.add_row(entry_row) + + btn_box = Gtk.Box( + spacing=6, + valign=Gtk.Align.CENTER, + margin_top=8, + margin_bottom=8, + margin_start=12, + margin_end=12, + ) + + request_btn = Gtk.Button( + label=_("Request Access"), + css_classes=["pill", "suggested-action"], + ) + btn_box.append(request_btn) + + clear_btn = Gtk.Button( + label=_("Clear"), + css_classes=["pill"], + visible=False, + ) + btn_box.append(clear_btn) + + row.add_row(btn_box) + + adapter = cls(row, entry_row, request_btn, clear_btn, app_var) + adapter._update_status() + return row, adapter + + def get_value(self) -> Any | None: + return self._key_value + + def set_value(self, value: Any) -> None: + self._key_value = str(value) if value is not None else "" + self._update_status() + + def update_from_var(self, var: Var) -> None: + if not isinstance(var, AppKeyVar): + return + self._var = var + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_subtitle(var.description) + self._key_value = var.value or "" + self._update_status() + + def _get_key(self) -> str | None: + if not self._key_value: + return None + try: + data = json.loads(self._key_value) + if isinstance(data, dict): + return data.get("api_key") + return str(data) + except (json.JSONDecodeError, TypeError): + return self._key_value.strip() or None + + def _update_status(self) -> None: + api_key = self._get_key() + if api_key: + self._row.set_subtitle(_("API key configured")) + self._clear_btn.set_visible(True) + self._request_btn.set_label(_("Request New Key")) + self._entry_row.set_text(api_key) + else: + desc = self._var.description + self._row.set_subtitle( + desc if desc else _("No API key configured") + ) + self._clear_btn.set_visible(False) + self._request_btn.set_label(_("Request Access")) + self._entry_row.set_text("") + + def _on_entry_apply(self, entry_row) -> None: + text = entry_row.get_text().strip() + if text: + self._key_value = json.dumps({"api_key": text}) + self._update_status() + self.changed.send(self) + + def _on_clear(self, _btn) -> None: + self._stop_polling() + self._key_value = "" + self._update_status() + self.changed.send(self) + + def _resolve_base_url(self) -> str | None: + config = self._var.resolve_config() + request_url = config.get("request_url") + if not request_url: + return None + try: + from urllib.parse import urlparse + + parsed = urlparse(request_url) + scheme = parsed.scheme or "http" + return f"{scheme}://{parsed.netloc}" + except ValueError: + return None + + def _on_request(self, _btn) -> None: + config = self._var.resolve_config() + probe_url = config.get("probe_url") + request_url = config.get("request_url") + app_name = config.get("app_name", "RayForge") + + if not request_url: + self._row.set_subtitle( + _("Hostname and port must be configured first") + ) + return + + if probe_url: + try: + req = urllib.request.Request(probe_url, method="GET") + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT): + pass + except urllib.error.URLError: + self._row.set_subtitle( + _( + "Device not reachable or does not support " + "automatic key requests" + ) + ) + return + + try: + data = json.dumps({"app": app_name}).encode() + req = urllib.request.Request( + request_url, + data=data, + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: + result = json.loads(resp.read().decode()) + app_token = result.get("app_token") + if not app_token: + self._row.set_subtitle(_("Unexpected response from device")) + return + except urllib.error.HTTPError as e: + if e.code == 429: + self._row.set_subtitle( + _("Too many requests. Try again later.") + ) + else: + self._row.set_subtitle( + _("Request failed: {code}").format(code=e.code) + ) + return + except (OSError, TimeoutError, ValueError) as e: + self._row.set_subtitle( + _("Connection failed: {err}").format(err=str(e)) + ) + return + + poll_url = config.get("poll_url") + if not poll_url: + return + resolved_poll = poll_url.replace("{app_token}", app_token) + + self._row.set_subtitle(_("Waiting for approval on device…")) + self._request_btn.set_sensitive(False) + self._request_btn.set_label(_("Waiting…")) + self._start_polling(resolved_poll) + + def _start_polling(self, poll_url: str) -> None: + self._stop_polling() + self._poll_url = poll_url + self._poll_count = 0 + self._poll_source_id = GLib.timeout_add( + _POLL_INTERVAL_MS, self._poll_tick + ) + + def _stop_polling(self) -> None: + if self._poll_source_id is not None: + GLib.source_remove(self._poll_source_id) + self._poll_source_id = None + self._request_btn.set_sensitive(True) + self._request_btn.set_label(_("Request Access")) + + def _poll_tick(self) -> bool: + self._poll_count += 1 + if self._poll_count > 150: + self._row.set_subtitle(_("Approval timed out. Please try again.")) + self._stop_polling() + return False + + try: + req = urllib.request.Request(self._poll_url, method="GET") + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: + result = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 404: + self._row.set_subtitle(_("Request denied or expired.")) + self._stop_polling() + return False + return True + except ( + urllib.error.URLError, + json.JSONDecodeError, + UnicodeDecodeError, + ): + return True + + api_key = result.get("api_key") + if api_key: + self._key_value = json.dumps({"api_key": api_key}) + self._stop_polling() + self._update_status() + self.changed.send(self) + return False + + return True diff --git a/rayforge/ui_gtk/varset/adapter/base.py b/rayforge/ui_gtk/varset/adapter/base.py new file mode 100644 index 000000000..6aaebcba7 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/base.py @@ -0,0 +1,86 @@ +import re +from abc import ABC, abstractmethod +from gettext import gettext as _ +from typing import ( + Any, + TypeVar, +) + +from blinker import Signal +from gi.repository import Adw + +from ....core.varset import Var + +NULL_CHOICE_LABEL = _("None Selected") + +_ADAPTER_REGISTRY: dict[type[Var], type["RowAdapter"]] = {} + +_A = TypeVar("_A", bound="RowAdapter") + + +def register_adapter(*var_classes: type[Var]): + """ + Decorator to register a RowAdapter for one or more Var subclasses. + Lookup uses MRO, so only the most-specific Var class needs + registration — subclasses inherit the adapter automatically. + """ + + def decorator(adapter_cls: type[_A]) -> type[_A]: + for var_cls in var_classes: + _ADAPTER_REGISTRY[var_cls] = adapter_cls + return adapter_cls + + return decorator + + +def escape_title(text: str) -> str: + return text.replace("&", "&&") + + +def natural_sort_key(s: str) -> list[int | str]: + return [ + int(t) if t.isdigit() else t.lower() for t in re.split("([0-9]+)", s) + ] + + +class RowAdapter(ABC): + """ + Base class for row value adapters. + + Each adapter owns both the row widget creation and the value + read/write logic. VarSetWidget uses adapters exclusively — + it never dispatches on row/var type itself. + + Subclasses must implement create(), get_value(), and set_value(). + Use the @register_adapter decorator to associate with Var subclasses. + + Convention: adapters store their row as self._row so that + update_from_var can operate on it. + """ + + changed: Signal + has_natural_commit = False + + def __init__(self): + self.changed = Signal() + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "RowAdapter"]: + raise NotImplementedError + + @abstractmethod + def get_value(self) -> Any | None: + raise NotImplementedError + + @abstractmethod + def set_value(self, value: Any) -> None: + raise NotImplementedError + + def needs_rebuild(self, old_var: Var, new_var: Var) -> bool: + """Return True if the row must be recreated for the new var.""" + return type(old_var) is not type(new_var) + + def update_from_var(self, var: Var): + pass diff --git a/rayforge/ui_gtk/varset/adapter/combo.py b/rayforge/ui_gtk/varset/adapter/combo.py new file mode 100644 index 000000000..fb887fc32 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/combo.py @@ -0,0 +1,164 @@ +from typing import Any + +from gi.repository import Adw, Gtk + +from ....core.varset import ( + BaudrateVar, + ChoiceVar, + SerialPortVar, + Var, +) +from ....machine.transport.serial import SerialTransport +from .base import ( + NULL_CHOICE_LABEL, + RowAdapter, + escape_title, + natural_sort_key, + register_adapter, +) + + +@register_adapter(ChoiceVar) +class ComboAdapter(RowAdapter): + def __init__(self, row: Adw.ComboRow, var: Var) -> None: + super().__init__() + self._row = row + self._var = var + self._row.connect( + "notify::selected-item", + lambda r, p: self.changed.send(self), + ) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "ComboAdapter"]: + assert isinstance(var, ChoiceVar) + null_label = var.null_label or NULL_CHOICE_LABEL + choices: list[str] = ( + [null_label] + var.choices if var.allow_none else list(var.choices) + ) + store = Gtk.StringList.new(choices) + row = Adw.ComboRow(model=store, title=escape_title(var.label)) + if var.description: + row.set_subtitle(var.description) + initial_val = getattr(var, target_property) + if initial_val: + display_str = var.get_display_for_value(str(initial_val)) + if display_str in choices: + row.set_selected(choices.index(display_str)) + else: + row.set_selected(0) + else: + row.set_selected(0) + return row, cls(row, var) + + def get_value(self) -> Any | None: + selected = self._row.get_selected_item() + display_str = "" + if selected: + display_str = selected.get_string() # type: ignore + + null_label = ( + getattr(self._var, "null_label", None) or NULL_CHOICE_LABEL + ) + if display_str == null_label: + return None + if isinstance(self._var, ChoiceVar): + return self._var.get_value_for_display(display_str) + return display_str + + def set_value(self, value: Any) -> None: + model = self._row.get_model() + if not isinstance(model, Gtk.StringList): + return + null_label = ( + getattr(self._var, "null_label", None) or NULL_CHOICE_LABEL + ) + display_str = null_label + if value is not None: + if isinstance(self._var, ChoiceVar): + display_str = self._var.get_display_for_value( + str(value) + ) or str(value) + else: + display_str = str(value) + for i in range(model.get_n_items()): + if model.get_string(i) == display_str: + self._row.set_selected(i) + break + + def needs_rebuild(self, old_var: Var, new_var: Var) -> bool: + if super().needs_rebuild(old_var, new_var): + return True + if isinstance(old_var, ChoiceVar) and isinstance(new_var, ChoiceVar): + return old_var.choices != new_var.choices + return False + + def update_from_var(self, var: Var): + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_subtitle(var.description) + + +@register_adapter(BaudrateVar) +class BaudRateAdapter(ComboAdapter): + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "BaudRateAdapter"]: + assert isinstance(var, BaudrateVar) + choices_str = [str(rate) for rate in var.choices] + store = Gtk.StringList.new(choices_str) + row = Adw.ComboRow(model=store, title=escape_title(var.label)) + if var.description: + row.set_subtitle(var.description) + initial_val = getattr(var, target_property) + if initial_val is not None and str(initial_val) in choices_str: + row.set_selected(choices_str.index(str(initial_val))) + return row, cls(row, var) + + +@register_adapter(SerialPortVar) +class SerialPortAdapter(ComboAdapter): + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "SerialPortAdapter"]: + initial_val = getattr(var, target_property) + port_set = set(SerialTransport.list_ports()) + if initial_val: + port_set.add(initial_val) + sorted_ports = sorted(port_set, key=natural_sort_key) + choices = [NULL_CHOICE_LABEL] + sorted_ports + store = Gtk.StringList.new(choices) + row = Adw.ComboRow(model=store, title=escape_title(var.label)) + if var.description: + row.set_subtitle(var.description) + if initial_val and initial_val in choices: + row.set_selected(choices.index(initial_val)) + + def on_open(gesture, n_press, x, y): + selected_obj = row.get_selected_item() + current_sel = None + if selected_obj: + current_sel = selected_obj.get_string() # type: ignore + + new_ports = SerialTransport.list_ports() + port_set = set(new_ports) + if current_sel and current_sel != NULL_CHOICE_LABEL: + port_set.add(current_sel) + new_sorted = sorted(port_set, key=natural_sort_key) + new_choices = [NULL_CHOICE_LABEL] + new_sorted + + model = row.get_model() + if isinstance(model, Gtk.StringList): + model.splice(0, model.get_n_items(), new_choices) + if current_sel in new_choices: + row.set_selected(new_choices.index(current_sel)) + + click_controller = Gtk.GestureClick.new() + click_controller.connect("pressed", on_open) + row.add_controller(click_controller) + return row, cls(row, var) diff --git a/rayforge/ui_gtk/varset/adapter/entry.py b/rayforge/ui_gtk/varset/adapter/entry.py new file mode 100644 index 000000000..a25945f85 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/entry.py @@ -0,0 +1,69 @@ +from typing import Any + +from gi.repository import Adw + +from ....core.varset import HostnameVar, Var +from ....core.varset.hostnamevar import is_valid_hostname_or_ip +from .base import RowAdapter, escape_title, register_adapter + + +class EntryAdapter(RowAdapter): + has_natural_commit = True + + def __init__(self, row: Adw.EntryRow) -> None: + super().__init__() + self._row = row + self._row.connect("apply", lambda r: self.changed.send(self)) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "EntryAdapter"]: + row = Adw.EntryRow(title=escape_title(var.label)) + if var.description: + row.set_tooltip_text(var.description) + row.set_show_apply_button(True) + initial_val = getattr(var, target_property) + if initial_val is not None: + row.set_text(str(initial_val)) + return row, cls(row) + + def get_value(self) -> Any | None: + return self._row.get_text() + + def set_value(self, value: Any) -> None: + self._row.set_text(str(value)) + + def update_from_var(self, var: Var): + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_tooltip_text(var.description) + + +@register_adapter(HostnameVar) +class HostnameAdapter(EntryAdapter): + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "HostnameAdapter"]: + row = Adw.EntryRow(title=escape_title(var.label)) + if var.description: + row.set_tooltip_text(var.description) + row.set_show_apply_button(True) + initial_val = getattr(var, target_property) + if initial_val is not None: + row.set_text(str(initial_val)) + return row, cls(row) + + def __init__(self, row: Adw.EntryRow) -> None: + super().__init__(row) + + def on_validate(entry_row): + if is_valid_hostname_or_ip(entry_row.get_text()): + entry_row.remove_css_class("error") + else: + entry_row.add_css_class("error") + + self._row.connect("changed", on_validate) + on_validate(self._row) diff --git a/rayforge/ui_gtk/varset/adapter/length.py b/rayforge/ui_gtk/varset/adapter/length.py new file mode 100644 index 000000000..1b1317748 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/length.py @@ -0,0 +1,57 @@ +from typing import Any + +from ....core.varset import LengthVar, Var +from ...shared.pref_rows.length_spin_row import LengthSpinRow +from .base import RowAdapter, escape_title, register_adapter + + +@register_adapter(LengthVar) +class LengthRowAdapter(RowAdapter): + """ + Adapts a LengthSpinRow for length values with unit conversion. + + Values are always read/written in base units (mm). + """ + + def __init__(self, row: LengthSpinRow) -> None: + super().__init__() + self._row = row + row.value_changed.connect(lambda r: self.changed.send(self)) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[LengthSpinRow, "LengthRowAdapter"]: + assert isinstance(var, LengthVar) + initial_val = getattr(var, target_property) + min_val = var.min_val if var.min_val is not None else -2147483647 + max_val = var.max_val if var.max_val is not None else 2147483647 + + row = LengthSpinRow( + escape_title(var.label), + None, + lower=min_val, + upper=max_val, + value_in_base=( + float(initial_val) if initial_val is not None else 0.0 + ), + ) + if var.description: + row.set_subtitle(var.description) + return row, cls(row) + + def get_value(self) -> Any | None: + return self._row.get_value_in_base_units() + + def set_value(self, value: Any) -> None: + self._row.set_value_in_base_units(value) + + def update_from_var(self, var: Var): + assert isinstance(var, LengthVar) + if var.label: + self._row.set_title(escape_title(var.label)) + if var.min_val is not None or var.max_val is not None: + self._row.set_range( + var.min_val if var.min_val is not None else -2147483647, + var.max_val if var.max_val is not None else 2147483647, + ) diff --git a/rayforge/ui_gtk/varset/adapter/oauth.py b/rayforge/ui_gtk/varset/adapter/oauth.py new file mode 100644 index 000000000..e410a0881 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/oauth.py @@ -0,0 +1,284 @@ +import json +import logging +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, GLib, Gtk + +from ....core.varset import OAuthFlowVar, Var +from ....shared.oauth.flow import OAuthFlow, OAuthFlowConfig +from .base import RowAdapter, escape_title, register_adapter + +logger = logging.getLogger(__name__) + + +@register_adapter(OAuthFlowVar) +class OAuthFlowAdapter(RowAdapter): + """ + Adapter that renders an OAuthFlowVar. + + When all URL fields are provided, uses a simple ActionRow with + the auth status as subtitle and a Sign In / Sign Out button as + suffix. When URL fields are ``None``, uses an ExpanderRow so + the user can fill in the missing values before authenticating. + """ + + has_natural_commit = True + + _FIELD_DEFS = ( + ("authorize_url", _("Authorize URL")), + ("token_url", _("Token URL")), + ("client_id", _("Client ID")), + ) + + def __init__( + self, + row: Adw.ActionRow | Adw.ExpanderRow, + sign_in_btn: Gtk.Button, + sign_out_btn: Gtk.Button, + var: OAuthFlowVar, + dynamic_entries: dict[str, Adw.EntryRow] | None = None, + ): + super().__init__() + self._row = row + self._sign_in_btn = sign_in_btn + self._sign_out_btn = sign_out_btn + self._var: OAuthFlowVar = var + self._dynamic_entries = dynamic_entries or {} + self._token_value: str = var.value or "" + + self._sign_in_btn.connect("clicked", self._on_sign_in) + self._sign_out_btn.connect("clicked", self._on_sign_out) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "OAuthFlowAdapter"]: + oauth_var = var + assert isinstance(oauth_var, OAuthFlowVar) + + needs_entries = any( + getattr(oauth_var, key, None) is None for key, _ in cls._FIELD_DEFS + ) + + dynamic_entries: dict[str, Adw.EntryRow] = {} + if needs_entries: + row = cls._create_expander_row(oauth_var, dynamic_entries) + else: + row = cls._create_action_row(oauth_var) + + # --- Buttons (suffixes on the main row) --- + sign_in_btn = Gtk.Button( + label=_("Sign In"), + css_classes=["pill", "suggested-action"], + valign=Gtk.Align.CENTER, + ) + row.add_suffix(sign_in_btn) + + sign_out_btn = Gtk.Button( + label=_("Sign Out"), + css_classes=["pill", "destructive-action"], + valign=Gtk.Align.CENTER, + visible=False, + ) + row.add_suffix(sign_out_btn) + + adapter = cls( + row, + sign_in_btn, + sign_out_btn, + oauth_var, + dynamic_entries=dynamic_entries, + ) + adapter._update_status() + return row, adapter + + @classmethod + def _create_action_row(cls, oauth_var: OAuthFlowVar) -> Adw.ActionRow: + row = Adw.ActionRow(title=escape_title(oauth_var.label)) + if oauth_var.description: + row.set_subtitle(oauth_var.description) + return row + + @classmethod + def _create_expander_row( + cls, + oauth_var: OAuthFlowVar, + dynamic_entries: dict[str, Adw.EntryRow], + ) -> Adw.ExpanderRow: + row = Adw.ExpanderRow(title=escape_title(oauth_var.label)) + if oauth_var.description: + row.set_subtitle(oauth_var.description) + + for field_key, field_label in cls._FIELD_DEFS: + if getattr(oauth_var, field_key, None) is None: + entry_row = Adw.EntryRow(title=field_label) + row.add_row(entry_row) + dynamic_entries[field_key] = entry_row + + return row + + # ------------------------------------------------------------------ + # RowAdapter interface + # ------------------------------------------------------------------ + + def get_value(self) -> Any | None: + return self._token_value + + def set_value(self, value: Any) -> None: + self._token_value = str(value) if value is not None else "" + self._update_status() + + def update_from_var(self, var: Var) -> None: + if not isinstance(var, OAuthFlowVar): + return + self._var = var + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_subtitle(var.description) + self._token_value = var.value or "" + self._update_status() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _get_token_data(self) -> dict[str, Any] | None: + if not self._token_value: + return None + try: + return json.loads(self._token_value) + except (json.JSONDecodeError, TypeError): + return None + + def _update_status(self) -> None: + tokens = self._get_token_data() + if tokens and tokens.get("access_token"): + if self._var._is_expired(tokens): + self._row.set_subtitle(_("Token expired")) + self._sign_in_btn.set_label(_("Refresh")) + self._sign_out_btn.set_visible(True) + else: + self._row.set_subtitle(_("Authenticated")) + self._sign_in_btn.set_label(_("Re-authorize")) + self._sign_out_btn.set_visible(True) + else: + desc = self._var.description + self._row.set_subtitle(desc if desc else _("Not connected")) + self._sign_in_btn.set_label(_("Sign In")) + self._sign_out_btn.set_visible(False) + + def _collect_overrides(self) -> dict[str, str]: + overrides: dict[str, str] = {} + for field_key, entry_row in self._dynamic_entries.items(): + text = entry_row.get_text().strip() + if text: + overrides[field_key] = text + return overrides + + def _on_sign_in(self, _btn) -> None: + refresh_token = self._var.get_refresh_token() + if refresh_token: + self._try_refresh(refresh_token) + else: + self._start_full_flow() + + def _try_refresh(self, refresh_token: str) -> None: + config_dict = self._var.resolve_config(self._collect_overrides()) + token_url = config_dict.get("token_url") + client_id = config_dict.get("client_id") + if not token_url or not client_id: + self._start_full_flow() + return + + config = OAuthFlowConfig( + authorize_url=config_dict.get("authorize_url", ""), + token_url=token_url, + client_id=client_id, + client_secret=config_dict.get("client_secret"), + redirect_port=config_dict.get("redirect_port", 8765), + ) + flow = OAuthFlow(config) + self._sign_in_btn.set_sensitive(False) + self._sign_in_btn.set_label(_("Refreshing…")) + + def on_refresh_complete(result): + GLib.idle_add(self._on_flow_complete, result) + + def on_refresh_error(error): + logger.debug( + "Token refresh failed, starting full flow: %s", + error, + ) + GLib.idle_add(self._start_full_flow) + + try: + result = flow.refresh(refresh_token) + self._on_flow_complete(result) + except (KeyError, ValueError, AttributeError, OSError): + logger.exception("Error during token refresh") + self._start_full_flow() + + def _start_full_flow(self) -> None: + config_dict = self._var.resolve_config(self._collect_overrides()) + + authorize_url = config_dict.get("authorize_url") + token_url = config_dict.get("token_url") + client_id = config_dict.get("client_id") + + if not authorize_url or not token_url or not client_id: + logger.warning( + "Cannot start OAuth flow: missing authorize_url, " + "token_url, or client_id" + ) + return + + config = OAuthFlowConfig( + authorize_url=authorize_url, + token_url=token_url, + client_id=client_id, + client_secret=config_dict.get("client_secret"), + scopes=config_dict.get("scopes", []), + redirect_port=config_dict.get("redirect_port", 8765), + ) + + flow = OAuthFlow(config) + self._sign_in_btn.set_sensitive(False) + self._sign_in_btn.set_label(_("Waiting…")) + + def on_complete(result): + GLib.idle_add(self._on_flow_complete, result) + + def on_error(error): + GLib.idle_add(self._on_flow_error, error) + + flow.start(on_complete, on_error) + + def _on_flow_complete(self, result) -> bool: + self._sign_in_btn.set_sensitive(True) + token_data: dict[str, Any] = { + "access_token": result.access_token, + } + if result.refresh_token: + token_data["refresh_token"] = result.refresh_token + if result.expires_at: + token_data["expires_at"] = result.expires_at.isoformat() + if result.scope: + token_data["scope"] = result.scope + + self._token_value = json.dumps(token_data) + self._update_status() + self.changed.send(self) + return False + + def _on_flow_error(self, error) -> bool: + logger.error("OAuth flow failed: %s", error) + self._sign_in_btn.set_sensitive(True) + self._update_status() + return False + + def _on_sign_out(self, _btn) -> None: + self._token_value = "" + self._update_status() + self.changed.send(self) diff --git a/rayforge/ui_gtk/varset/adapter/registry.py b/rayforge/ui_gtk/varset/adapter/registry.py new file mode 100644 index 000000000..e3ab8152e --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/registry.py @@ -0,0 +1,78 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw + +from ....core.varset import Var + +# Trigger @register_adapter decorators. Every adapter module must be +# imported here so that its class registers itself in _ADAPTER_REGISTRY. +from .appkey import AppKeyAdapter +from .base import _ADAPTER_REGISTRY, RowAdapter, escape_title +from .combo import BaudRateAdapter, ComboAdapter, SerialPortAdapter +from .entry import EntryAdapter, HostnameAdapter +from .length import LengthRowAdapter +from .oauth import OAuthFlowAdapter +from .slider import SliderAdapter +from .speed import SpeedRowAdapter +from .spin_row import SpinRowAdapter +from .switch import SwitchAdapter +from .textarea import TextAreaAdapter + +_ALL_ADAPTERS = ( + AppKeyAdapter, + BaudRateAdapter, + ComboAdapter, + SerialPortAdapter, + HostnameAdapter, + LengthRowAdapter, + OAuthFlowAdapter, + SliderAdapter, + SpeedRowAdapter, + TextAreaAdapter, +) + +logger = logging.getLogger(__name__) + +_FALLBACK_MAP: dict[type, type[RowAdapter]] = { + int: SpinRowAdapter, + float: SpinRowAdapter, + bool: SwitchAdapter, + str: EntryAdapter, +} + + +def create_row_for_var( + var: Var, target_property: str = "value" +) -> tuple[Adw.PreferencesRow, RowAdapter | None]: + """ + Creates a PreferencesRow and RowAdapter for the given Var. + + Walks the Var's MRO to find the most specific registered adapter. + Falls back to var.var_type if no adapter is registered for any + class in the hierarchy. + """ + adapter_cls: type[RowAdapter] | None = None + + for cls in type(var).__mro__: + if cls in _ADAPTER_REGISTRY: + adapter_cls = _ADAPTER_REGISTRY[cls] + break + + if adapter_cls is None: + adapter_cls = _FALLBACK_MAP.get(var.var_type) + + if adapter_cls is not None: + return adapter_cls.create(var, target_property) + + logger.warning( + "No UI widget defined for Var with key '%s' and type %s", + var.key, + type(var), + ) + row = Adw.ActionRow( + title=escape_title(var.label), + subtitle=_("Unsupported type: {t}").format(t=type(var).__name__), + sensitive=False, + ) + return row, None diff --git a/rayforge/ui_gtk/varset/adapter/slider.py b/rayforge/ui_gtk/varset/adapter/slider.py new file mode 100644 index 000000000..cc75fe6d9 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/slider.py @@ -0,0 +1,83 @@ +from typing import Any + +from gi.repository import Adw, Gtk + +from ....core.varset import FloatVar, SliderFloatVar, Var +from ...shared.slider import create_slider_row +from .base import RowAdapter, escape_title, register_adapter + + +@register_adapter(SliderFloatVar) +class SliderAdapter(RowAdapter): + def __init__( + self, + row: Adw.PreferencesRow, + scale: Gtk.Scale, + min_val: float, + max_val: float, + ) -> None: + super().__init__() + self._row = row + self._scale = scale + self._min_val = min_val + self._max_val = max_val + self._scale.connect("value-changed", lambda s: self.changed.send(self)) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "SliderAdapter"]: + assert isinstance(var, SliderFloatVar) + min_val = var.min_val if var.min_val is not None else 0.0 + max_val = var.max_val if var.max_val is not None else 1.0 + val = getattr(var, target_property) + if val is None: + val = min_val + + initial_percent = 0.0 + range_size = max_val - min_val + if range_size > 1e-9: + initial_percent = ((val - min_val) / range_size) * 100.0 + + adj = Gtk.Adjustment( + value=initial_percent, + lower=0.0, + upper=100.0, + step_increment=0.1, + page_increment=10, + ) + suffix = f" {var.format_suffix}" if var.format_suffix else None + row, scale = create_slider_row( + title=escape_title(var.label), + subtitle=var.description if var.description else None, + adjustment=adj, + digits=1, + draw_value=var.show_value, + format_suffix=suffix, + ) + row.set_activatable_widget(scale) + return row, cls(row, scale, min_val, max_val) + + def get_value(self) -> Any | None: + percent = self._scale.get_value() / 100.0 + return self._min_val + percent * (self._max_val - self._min_val) + + def set_value(self, value: Any) -> None: + range_size = self._max_val - self._min_val + percent = 0.0 + if range_size > 1e-9: + percent = ((float(value) - self._min_val) / range_size) * 100.0 + self._scale.set_value(percent) + + def update_from_var(self, var: Var): + assert isinstance(var, FloatVar) + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_tooltip_text(var.description) + min_val = var.min_val if var.min_val is not None else 0.0 + max_val = var.max_val if var.max_val is not None else 1.0 + if min_val is not None: + self._min_val = float(min_val) + if max_val is not None: + self._max_val = float(max_val) diff --git a/rayforge/ui_gtk/varset/adapter/speed.py b/rayforge/ui_gtk/varset/adapter/speed.py new file mode 100644 index 000000000..3a85d3232 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/speed.py @@ -0,0 +1,62 @@ +from typing import Any + +from ....context import get_context +from ....core.varset import SpeedVar, Var +from ...shared.pref_rows.speed_spin_row import SpeedSpinRow +from .base import RowAdapter, escape_title, register_adapter + +_DEFAULT_MAX_SPEED = 3000 + + +def _resolve_max_speed(var: SpeedVar) -> int: + if var.max_val is not None: + return var.max_val + machine = get_context().machine if get_context() else None + if machine is None: + return _DEFAULT_MAX_SPEED + if var.role == "travel": + return machine.max_travel_speed + return machine.max_cut_speed + + +@register_adapter(SpeedVar) +class SpeedRowAdapter(RowAdapter): + """ + Adapts a SpeedSpinRow for speed values with unit conversion. + + Values are always read/written in base units (mm/min). + """ + + def __init__(self, row: SpeedSpinRow) -> None: + super().__init__() + self._row = row + row.value_changed.connect(lambda r: self.changed.send(self)) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[SpeedSpinRow, "SpeedRowAdapter"]: + assert isinstance(var, SpeedVar) + max_speed = _resolve_max_speed(var) + initial_val = getattr(var, target_property) + min_val = var.min_val or 0 + + row = SpeedSpinRow( + escape_title(var.label), + lower=min_val, + upper=max_speed, + value_in_base=(int(initial_val) if initial_val is not None else 0), + ) + return row, cls(row) + + def get_value(self) -> Any | None: + return int(self._row.get_value_in_base_units()) + + def set_value(self, value: Any) -> None: + self._row.set_value_in_base_units(value) + + def update_from_var(self, var: Var): + assert isinstance(var, SpeedVar) + if var.label: + self._row.set_title(escape_title(var.label)) + self._row.set_range(var.min_val or 0, _resolve_max_speed(var)) diff --git a/rayforge/ui_gtk/varset/adapter/spin_row.py b/rayforge/ui_gtk/varset/adapter/spin_row.py new file mode 100644 index 000000000..64baa689a --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/spin_row.py @@ -0,0 +1,60 @@ +from typing import Any + +from ....core.varset import FloatVar, IntVar, Var +from ...shared.pref_rows.base import SpinRow +from .base import RowAdapter, escape_title, register_adapter + + +@register_adapter(IntVar, FloatVar) +class SpinRowAdapter(RowAdapter): + def __init__(self, row: SpinRow, is_int: bool) -> None: + super().__init__() + self._row = row + self._is_int = is_int + row.value_changed.connect(lambda r: self.changed.send(self)) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[SpinRow, "SpinRowAdapter"]: + min_val = getattr(var, "min_val", None) + max_val = getattr(var, "max_val", None) + lower = min_val if min_val is not None else -2147483647 + upper = max_val if max_val is not None else 2147483647 + initial_val = getattr(var, target_property) + is_int = var.var_type is int + + row = SpinRow( + escape_title(var.label), + var.description or None, + lower=lower, + upper=upper, + digits=0 if is_int else 3, + value=( + (int(initial_val) if is_int else float(initial_val)) + if initial_val is not None + else (0 if is_int else 0.0) + ), + ) + return row, cls(row, is_int) + + def get_value(self) -> Any | None: + if self._is_int: + return self._row.get_int_value() + return self._row.get_value() + + def set_value(self, value: Any) -> None: + self._row.set_value(float(value)) + + def update_from_var(self, var: Var): + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_subtitle(var.description) + min_val = getattr(var, "min_val", None) + max_val = getattr(var, "max_val", None) + if min_val is not None or max_val is not None: + self._row.set_range( + min_val if min_val is not None else -2147483647, + max_val if max_val is not None else 2147483647, + ) diff --git a/rayforge/ui_gtk/varset/adapter/switch.py b/rayforge/ui_gtk/varset/adapter/switch.py new file mode 100644 index 000000000..86bb90113 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/switch.py @@ -0,0 +1,39 @@ +from typing import Any + +from gi.repository import Adw + +from ....core.varset import BoolVar, Var +from .base import RowAdapter, escape_title, register_adapter + + +@register_adapter(BoolVar) +class SwitchAdapter(RowAdapter): + def __init__(self, row: Adw.SwitchRow) -> None: + super().__init__() + self._row = row + self._row.connect( + "notify::active", lambda r, p: self.changed.send(self) + ) + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "SwitchAdapter"]: + row = Adw.SwitchRow(title=escape_title(var.label)) + if var.description: + row.set_subtitle(var.description) + initial_val = getattr(var, target_property) + row.set_active(bool(initial_val) if initial_val is not None else False) + return row, cls(row) + + def get_value(self) -> Any | None: + return self._row.get_active() + + def set_value(self, value: Any) -> None: + self._row.set_active(bool(value)) + + def update_from_var(self, var: Var): + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_subtitle(var.description) diff --git a/rayforge/ui_gtk/varset/adapter/textarea.py b/rayforge/ui_gtk/varset/adapter/textarea.py new file mode 100644 index 000000000..917a6d3b1 --- /dev/null +++ b/rayforge/ui_gtk/varset/adapter/textarea.py @@ -0,0 +1,50 @@ +from typing import Any + +from gi.repository import Adw, Gtk + +from ....core.varset import TextAreaVar, Var +from .base import RowAdapter, escape_title, register_adapter + + +@register_adapter(TextAreaVar) +class TextAreaAdapter(RowAdapter): + def __init__(self, row: Adw.ExpanderRow, text_view: Gtk.TextView) -> None: + super().__init__() + self._row = row + self._text_view = text_view + + @classmethod + def create( + cls, var: Var, target_property: str + ) -> tuple[Adw.PreferencesRow, "TextAreaAdapter"]: + row = Adw.ExpanderRow(title=escape_title(var.label)) + if var.description: + row.set_subtitle(var.description) + text_view = Gtk.TextView( + monospace=True, wrap_mode=Gtk.WrapMode.WORD_CHAR + ) + scroller = Gtk.ScrolledWindow( + child=text_view, + min_content_height=100, + hscrollbar_policy=Gtk.PolicyType.NEVER, + ) + row.add_row(scroller) + initial_val = getattr(var, target_property) + if initial_val is not None: + text_view.get_buffer().set_text(str(initial_val)) + row.core_widget = text_view # type: ignore + return row, cls(row, text_view) + + def get_value(self) -> Any | None: + buf = self._text_view.get_buffer() + start, end = buf.get_start_iter(), buf.get_end_iter() + return buf.get_text(start, end, True) + + def set_value(self, value: Any) -> None: + self._text_view.get_buffer().set_text(str(value)) + + def update_from_var(self, var: Var): + if var.label: + self._row.set_title(escape_title(var.label)) + if var.description: + self._row.set_subtitle(var.description) diff --git a/rayforge/ui_gtk/varset/varset_editor.py b/rayforge/ui_gtk/varset/varset_editor.py new file mode 100644 index 000000000..db18cd590 --- /dev/null +++ b/rayforge/ui_gtk/varset/varset_editor.py @@ -0,0 +1,810 @@ +import logging +import re +from collections.abc import Iterable +from gettext import gettext as _ +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Optional, + cast, +) + +from blinker import Signal +from gi.repository import Adw, Gdk, GLib, Gtk + +from ...core.undo.property_cmd import ChangePropertyCommand +from ...core.varset import ( + ChoiceVar, + FloatVar, + IntVar, + SliderFloatVar, + Var, + VarSet, + get_editable_var_types, +) +from ..icons import get_icon +from ..shared.pref_rows.base import SpinRow +from ..shared.preferences_group import PreferencesGroupWithButton +from .adapter import NULL_CHOICE_LABEL, create_row_for_var + +if TYPE_CHECKING: + from ...core.undo import HistoryManager + +logger = logging.getLogger(__name__) + + +def adjust_value( + min_val: float | None, + max_val: float | None, + value: float, + keep: Literal["min", "max", "value"], +) -> tuple[float | None, float | None, float]: + """ + Adjusts min, max, and value to be consistent, keeping one value fixed. + Returns a tuple of (final_min, final_max, final_value). + """ + if keep == "value": + if min_val is not None and value < min_val: + min_val = value + if max_val is not None and value > max_val: + max_val = value + elif keep == "min": + if min_val is not None: + if max_val is not None and min_val > max_val: + max_val = min_val + value = max(value, min_val) + elif keep == "max": + if max_val is not None: + if min_val is not None and max_val < min_val: + min_val = max_val + value = min(value, max_val) + return min_val, max_val, value + + +class VarDefinitionRowWidget(Adw.ExpanderRow): + """ + A widget for displaying and editing the definition of a single Var. + Supports Drag and Drop reordering and Undo/Redo. + """ + + def __init__( + self, + var: Var, + undo_manager: Optional["HistoryManager"] = None, + **kwargs, + ): + super().__init__(**kwargs) + self.var = var + self.undo_manager = undo_manager + self._in_update = False + self._updating_key_from_label = False + + # Only auto-update key if it looks like a freshly added parameter. + # Existing variables (loaded from files) should never auto-update key + # based on label to prevent breaking references. + is_temp_key = self.var.key.startswith("new_parameter") + self._key_manually_edited = not is_temp_key + + # Define signals as INSTANCE attributes for proper scoping + self.delete_clicked = Signal() + self.reorder_requested = Signal() + + self._update_header() + + # --- Prefix Area (Drag Handle & Delete) --- + prefix_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + prefix_box.set_margin_end(8) + + # Drag Handle + drag_handle = get_icon("drag-handle-symbolic") + drag_handle.set_tooltip_text(_("Drag to reorder")) + drag_handle.add_css_class("dim-label") + drag_handle.set_cursor(Gdk.Cursor.new_from_name("grab", None)) + prefix_box.append(drag_handle) + + # Delete Button + delete_button = Gtk.Button( + child=get_icon("delete-symbolic"), + tooltip_text=_("Delete Variable"), + valign=Gtk.Align.CENTER, + ) + delete_button.add_css_class("flat") + delete_button.connect( + "clicked", lambda b: self.delete_clicked.send(self) + ) + prefix_box.append(delete_button) + + self.add_prefix(prefix_box) + + # --- Drag Source Setup (On the Handle) --- + drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE) + drag_source.connect("prepare", self._on_drag_prepare) + drag_handle.add_controller(drag_source) + + # --- Drop Target Setup (On the Row) --- + # We accept strings (the variable key) + drop_target = Gtk.DropTarget.new(type=str, actions=Gdk.DragAction.MOVE) + drop_target.connect("drop", self._on_drop) + self.add_controller(drop_target) + + self._build_content_rows() + + def _update_header(self): + """Updates the row title and subtitle based on var state.""" + self.set_title(self.var.label) + self.set_subtitle(f"{self.var.key} ({type(self.var).__name__})") + + def _derive_key_from_label(self, label: str) -> str: + """Converts a label into a snake_case key.""" + s = label.lower().strip().replace(" ", "_") + return re.sub(r"[^a-z0-9_]", "", s) + + def _on_drag_prepare(self, source, x, y): + """ + Called when dragging starts. Returns content provider with the key. + """ + return Gdk.ContentProvider.new_for_value(self.var.key) + + def _on_drop(self, target, value, x, y): + """Called when something is dropped onto this row.""" + if isinstance(value, str) and value != self.var.key: + # Emit signal: moved 'value' (key) to 'self.var.key' (target) + self.reorder_requested.send( + self, source_key=value, target_key=self.var.key + ) + return True + return False + + def _build_content_rows(self): + """Creates and wires up the editor rows for the Var's properties.""" + + # 1. Label Row (First) + self.label_row = Adw.EntryRow(title=_("Label")) + if self.var.label: + self.label_row.set_text(self.var.label) + self.label_row.connect("changed", self._on_label_changed) + self.add_row(self.label_row) + + # 2. Key Row (Second, derived from Label unless edited) + self.key_row = Adw.EntryRow(title=_("Key")) + self.key_row.set_text(self.var.key) + self.key_row.connect("changed", self._on_key_changed) + self.add_row(self.key_row) + + self.desc_row = Adw.EntryRow(title=_("Description")) + if self.var.description: + self.desc_row.set_tooltip_text(self.var.description) + self.desc_row.set_text(self.var.description) + self.desc_row.connect("changed", self._on_description_changed) + self.add_row(self.desc_row) + + self.default_row, __ = create_row_for_var( + self.var, target_property="default" + ) + self.default_row.set_title(_("Default Value")) + self._wire_up_default_row() + self.add_row(self.default_row) + + if isinstance(self.var, (IntVar, FloatVar)): + is_slider = isinstance(self.var, SliderFloatVar) + bound_var_instance = ( + FloatVar(key=self.var.key, label=self.var.label) + if is_slider + else self.var + ) + default_val = ( + self.var.default if self.var.default is not None else 0 + ) + + # --- Minimum / Start Value Row --- + self.min_val_row, __ = create_row_for_var( + bound_var_instance, "min_val" + ) + if isinstance(self.min_val_row, SpinRow): + self.min_val_row.set_title( + _("Start Value") if is_slider else _("Minimum Value") + ) + + if not is_slider: + self.min_toggle = Gtk.Switch(valign=Gtk.Align.CENTER) + self.min_toggle.connect( + "state-set", + self._on_bound_toggle, + self.min_val_row, + "min_val", + ) + self.min_val_row.add_prefix(self.min_toggle) + has_min = self.var.min_val is not None + self.min_toggle.set_active(has_min) + self.min_val_row.set_editable(has_min) + if not has_min: + self.min_val_row.set_value(default_val) + else: + # Sliders always have active bounds, no toggle needed + self.min_val_row.set_editable(True) + if self.var.min_val is None: + self.min_val_row.set_value(0.0) + + self._wire_up_bound_row(self.min_val_row, "min_val") + self.add_row(self.min_val_row) + + # --- Maximum / End Value Row --- + self.max_val_row, __ = create_row_for_var( + bound_var_instance, "max_val" + ) + if isinstance(self.max_val_row, SpinRow): + self.max_val_row.set_title( + _("End Value") if is_slider else _("Maximum Value") + ) + + if not is_slider: + self.max_toggle = Gtk.Switch(valign=Gtk.Align.CENTER) + self.max_toggle.connect( + "state-set", + self._on_bound_toggle, + self.max_val_row, + "max_val", + ) + self.max_val_row.add_prefix(self.max_toggle) + has_max = self.var.max_val is not None + self.max_toggle.set_active(has_max) + self.max_val_row.set_editable(has_max) + if not has_max: + min_val_from_ui = ( + self.min_val_row.get_value() + if hasattr(self, "min_val_row") + and isinstance(self.min_val_row, SpinRow) + and self.min_val_row.get_editable() + else default_val + ) + self.max_val_row.set_value( + max(default_val, min_val_from_ui) + ) + else: + self.max_val_row.set_editable(True) + if self.var.max_val is None: + self.max_val_row.set_value(1.0) + + self._wire_up_bound_row(self.max_val_row, "max_val") + self.add_row(self.max_val_row) + + def _wire_up_default_row(self): + widget = getattr( + self.default_row, "get_activatable_widget", lambda: None + )() + widget = widget or self.default_row + + if isinstance(self.default_row, SpinRow): + self.default_row.value_changed.connect( + self._on_default_changed_spinrow + ) + elif isinstance(self.default_row, Adw.EntryRow): + self.default_row.connect("changed", self._on_default_changed_entry) + elif isinstance(self.default_row, Adw.ComboRow): + self.default_row.connect( + "notify::selected-item", self._on_default_changed_combo + ) + elif isinstance(widget, Gtk.Switch): + widget.connect("state-set", self._on_default_changed_switch) + elif isinstance(widget, Gtk.Scale): + widget.connect("value-changed", self._on_default_changed_scale) + + def _wire_up_bound_row(self, row: Adw.PreferencesRow, property_name: str): + if isinstance(row, SpinRow): + row.value_changed.connect( + lambda r, pn=property_name: self._on_bound_changed_spinrow( + r, pn + ) + ) + + def _sync_prop(self, row, prop_name, sync_header=False): + val = getattr(self.var, prop_name) or "" + if row.get_text() != val: + row.set_text(val) + if sync_header: + self._update_header() + + def _sync_bound(self, row, toggle: Gtk.Switch | None, prop_name): + if not isinstance(self.var, (IntVar, FloatVar)) or not isinstance( + row, SpinRow + ): + return + self._in_update = True + try: + val = getattr(self.var, prop_name) + has_val = val is not None + + if toggle: + if toggle.get_active() != has_val: + toggle.set_active(has_val) + row.set_editable(has_val) + else: + row.set_editable(True) + + if has_val and row.get_value() != val: + row.set_value(val) + finally: + self._in_update = False + + def _sync_default(self): + val = self.var.default + row = self.default_row + widget = getattr(row, "get_activatable_widget", lambda: None)() + + # Prevent signal recursion during sync + self._in_update = True + try: + if isinstance(row, SpinRow): + if row.get_value() != val: + row.set_value(val if val is not None else 0) + elif isinstance(row, Adw.EntryRow): + text_val = str(val or "") + if row.get_text() != text_val: + row.set_text(text_val) + elif isinstance(widget, Gtk.Switch): + active_val = bool(val) + if widget.get_active() != active_val: + widget.set_active(active_val) + elif isinstance(widget, Gtk.Scale) and isinstance( + self.var, SliderFloatVar + ): + min_val = ( + self.var.min_val if self.var.min_val is not None else 0.0 + ) + max_val = ( + self.var.max_val if self.var.max_val is not None else 1.0 + ) + range_size = max_val - min_val + percent = 0.0 + if val is not None and range_size > 1e-9: + percent = ((float(val) - min_val) / range_size) * 100.0 + if abs(widget.get_value() - percent) > 1e-6: + widget.set_value(percent) + elif isinstance(row, Adw.ComboRow): + model = row.get_model() + if isinstance(model, Gtk.StringList): + display_str = NULL_CHOICE_LABEL + if val is not None: + display_str = ( + self.var.get_display_for_value(str(val)) + or str(val) + if isinstance(self.var, ChoiceVar) + else str(val) + ) + for i in range(model.get_n_items()): + if model.get_string(i) == display_str: + if row.get_selected() != i: + row.set_selected(i) + break + finally: + self._in_update = False + + def _on_change_generic(self, row, prop, sync_header=False): + new_val = row.get_text() + if getattr(self.var, prop) == new_val: + return + + def sync_callback(): + self._sync_prop(row, prop, sync_header) + + if self.undo_manager: + cmd = ChangePropertyCommand( + self.var, prop, new_val, on_change_callback=sync_callback + ) + self.undo_manager.execute(cmd) + else: + setattr(self.var, prop, new_val) + sync_callback() + + def _on_key_changed(self, row): + if not self._updating_key_from_label: + # User manually typed in the key row; stop auto-updates + self._key_manually_edited = True + + self._on_change_generic(row, "key", sync_header=True) + + def _on_label_changed(self, row): + # Update the actual Label property + self._on_change_generic(row, "label", sync_header=True) + + # If not manually edited, auto-update the Key + if not self._key_manually_edited: + new_key = self._derive_key_from_label(row.get_text()) + + # Use flag to prevent _on_key_changed from marking this as manual + self._updating_key_from_label = True + self.key_row.set_text(new_key) + self._updating_key_from_label = False + + def _on_description_changed(self, row): + self._on_change_generic(row, "description") + + def _commit_property_change(self, prop_name: str, new_val: Any): + if ( + not isinstance(self.var, (IntVar, FloatVar)) + and prop_name != "default" + ): + return + + def sync_callback(): + if prop_name == "default": + self._sync_default() + else: + row = getattr(self, f"{prop_name}_row") + toggle = None + if prop_name == "min_val": + toggle = getattr(self, "min_toggle", None) + elif prop_name == "max_val": + toggle = getattr(self, "max_toggle", None) + + self._sync_bound(row, toggle, prop_name) + + if self.undo_manager: + cmd = ChangePropertyCommand( + self.var, prop_name, new_val, on_change_callback=sync_callback + ) + self.undo_manager.execute(cmd) + else: + setattr(self.var, prop_name, new_val) + sync_callback() + + def _on_bound_toggle( + self, + switch: Gtk.Switch, + state: bool, + spin_row: SpinRow, + prop_name: str, + ): + if self._in_update: + return False + spin_row.set_editable(state) + new_val = spin_row.get_value() if state else None + self._commit_property_change(prop_name, new_val) + if state: + self._on_bound_changed_spinrow(spin_row, prop_name) + return False + + def _commit_numeric_changes( + self, + default: float | None, + min_val: float | None, + max_val: float | None, + keep: Literal["min", "max", "value"], + ): + if ( + not isinstance(self.var, (IntVar, FloatVar)) + or not self.undo_manager + ): + return + + with self.undo_manager.transaction(_("Adjust Value")): + final_min, final_max, final_default = adjust_value( + min_val, max_val, default or 0.0, keep + ) + if self.var.default != final_default: + self._commit_property_change("default", final_default) + if self.var.min_val != final_min: + self._commit_property_change("min_val", final_min) + if self.var.max_val != final_max: + self._commit_property_change("max_val", final_max) + + def _on_bound_changed_spinrow(self, spin_row, prop_name): + if self._in_update: + return + self._in_update = True + try: + new_bound_val = spin_row.get_value() + + if isinstance(self.var, SliderFloatVar): + # For sliders, we want to keep the relative percentage fixed + # rather than the absolute value when bounds change. + self._update_slider_bounds(prop_name, new_bound_val) + else: + default_val = ( + self.default_row.get_value() + if isinstance(self.default_row, SpinRow) + else 0.0 + ) + min_val = ( + self.min_val_row.get_value() + if hasattr(self, "min_val_row") + and isinstance(self.min_val_row, SpinRow) + and self.min_val_row.get_editable() + else None + ) + max_val = ( + self.max_val_row.get_value() + if hasattr(self, "max_val_row") + and isinstance(self.max_val_row, SpinRow) + and self.max_val_row.get_editable() + else None + ) + + if prop_name == "min_val": + self._commit_numeric_changes( + default_val, new_bound_val, max_val, keep="min" + ) + else: + self._commit_numeric_changes( + default_val, min_val, new_bound_val, keep="max" + ) + finally: + self._in_update = False + + def _update_slider_bounds(self, prop_name: str, new_val: float): + """ + Special logic for SliderFloatVar: changing bounds keeps the slider's + relative position (percentage) constant, recalculating default value. + """ + scale_widget = getattr( + self.default_row, "get_activatable_widget", lambda: None + )() + if not isinstance(scale_widget, Gtk.Scale): + return + + var = cast(SliderFloatVar, self.var) + + # 1. Get current percentage [0..1] + percent = scale_widget.get_value() / 100.0 + + # 2. Determine new bounds + min_val = var.min_val if var.min_val is not None else 0.0 + max_val = var.max_val if var.max_val is not None else 1.0 + + if prop_name == "min_val": + min_val = new_val + else: + max_val = new_val + + # 3. Calculate new default to preserve percentage + new_default = min_val + percent * (max_val - min_val) + + # 4. Commit changes + def apply(): + if prop_name == "min_val": + self._commit_property_change("min_val", min_val) + else: + self._commit_property_change("max_val", max_val) + self._commit_property_change("default", new_default) + + if self.undo_manager: + with self.undo_manager.transaction(_("Adjust Slider Range")): + apply() + else: + apply() + + def _on_default_changed_spinrow(self, spin_row: SpinRow): + if self._in_update: + return + self._in_update = True + try: + new_default = spin_row.get_value() + min_val = ( + self.min_val_row.get_value() + if hasattr(self, "min_val_row") + and isinstance(self.min_val_row, SpinRow) + and self.min_val_row.get_editable() + else None + ) + max_val = ( + self.max_val_row.get_value() + if hasattr(self, "max_val_row") + and isinstance(self.max_val_row, SpinRow) + and self.max_val_row.get_editable() + else None + ) + self._commit_numeric_changes( + new_default, min_val, max_val, keep="value" + ) + finally: + self._in_update = False + + def _on_default_changed_entry(self, entry_row: Adw.EntryRow): + self._commit_property_change("default", entry_row.get_text()) + + def _on_default_changed_switch(self, switch: Gtk.Switch, state: bool): + self._commit_property_change("default", state) + + def _on_default_changed_scale(self, scale: Gtk.Scale, _pspec=None): + if self._in_update or not isinstance(self.var, SliderFloatVar): + return + self._in_update = True + try: + # For sliders, min_val and max_val are assumed valid/present + min_val = self.var.min_val if self.var.min_val is not None else 0.0 + max_val = self.var.max_val if self.var.max_val is not None else 1.0 + + percent = scale.get_value() / 100.0 + new_default = min_val + percent * (max_val - min_val) + self._commit_numeric_changes( + new_default, min_val, max_val, keep="value" + ) + finally: + self._in_update = False + + def _on_default_changed_combo(self, combo_row: Adw.ComboRow, _pspec): + selected = combo_row.get_selected_item() + display_str = selected.get_string() if selected else "" # type: ignore + val = ( + self.var.get_value_for_display(display_str) + if isinstance(self.var, ChoiceVar) + and display_str != NULL_CHOICE_LABEL + else (None if display_str == NULL_CHOICE_LABEL else display_str) + ) + self._commit_property_change("default", val) + + +class VarSetEditorWidget(PreferencesGroupWithButton): + """ + A widget for interactively defining a VarSet, styled to integrate + seamlessly with an 'Add' button at the bottom. + """ + + def __init__( + self, + vartypes: Iterable[type[Var]] | None = None, + undo_manager: Optional["HistoryManager"] = None, + **kwargs, + ): + """ + Args: + vartypes: A set or list of Var classes allowed to be added. + undo_manager: Optional HistoryManager for undo/redo support. + """ + self._allowed_types = set(vartypes) if vartypes else None + self._undo_manager = undo_manager + + # Pass a dummy label; we override the button creation entirely. + super().__init__(button_label="", **kwargs) + self._var_set = VarSet() + self._add_counter = 0 + + @property + def undo_manager(self) -> Optional["HistoryManager"]: + return self._undo_manager + + @undo_manager.setter + def undo_manager(self, value: Optional["HistoryManager"]): + self._undo_manager = value + # Propagate to existing rows + i = 0 + while row := self.list_box.get_row_at_index(i): + widget = row.get_child() + if isinstance(widget, VarDefinitionRowWidget): + widget.undo_manager = value + i += 1 + + def _create_add_button(self, button_label: str) -> Gtk.Widget: + """Overrides the base class to create a Gtk.MenuButton.""" + add_button = Gtk.MenuButton() + + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + button_box.set_margin_top(10) + button_box.set_margin_end(12) + button_box.set_margin_bottom(10) + button_box.set_margin_start(12) + button_box.append(get_icon("add-symbolic")) + lbl = Gtk.Label(label=_("Add Parameter")) + button_box.append(lbl) + add_button.set_child(button_box) + + menu = Gtk.PopoverMenu() + add_button.set_popover(menu) + + # Create a box to hold the menu item buttons + menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + menu.set_child(menu_box) + + for label, var_class in get_editable_var_types(): + # Filter types if a restriction list is provided + if self._allowed_types and var_class not in self._allowed_types: + continue + + item_button = Gtk.Button(label=label) + item_button.add_css_class("flat") + item_button.set_halign(Gtk.Align.FILL) + item_button.connect( + "clicked", self._on_add_var_activated, var_class, menu + ) + menu_box.append(item_button) + + return add_button + + def create_row_widget(self, item: Var) -> Gtk.Widget: + row_widget = VarDefinitionRowWidget( + item, undo_manager=self._undo_manager + ) + row_widget.delete_clicked.connect(self._on_delete_var_clicked) + row_widget.reorder_requested.connect(self._on_reorder_requested) + return row_widget + + def _on_add_var_activated(self, button, var_class, popover: Gtk.Popover): + """Handler for when a user selects a Var type to add.""" + self._add_counter += 1 + # Create a placeholder label/key that matches the validation rules + label = _("New Parameter") + key = "new_parameter" + + new_var: Var + if var_class is ChoiceVar: + new_var = var_class( + key=key, label=label, choices=["Option 1", "Option 2"] + ) + elif var_class is Var: + new_var = var_class(key=key, label=label, var_type=str) + elif var_class is FloatVar: + # Use a non-zero default to avoid geometry collapse on assignment + new_var = var_class(key=key, label=label, default=10.0) + elif var_class is IntVar: + # Use a non-zero default to avoid geometry collapse on assignment + new_var = var_class(key=key, label=label, default=10) + else: + new_var = var_class(key=key, label=label) + + base_key = key + counter = 1 + while base_key in self._var_set.get_values(): + base_key = f"{key}_{counter}" + counter += 1 + new_var.key = base_key + + self._var_set.add(new_var) + self.populate(self._var_set) + + i = 0 + while row := self.list_box.get_row_at_index(i): + widget = row.get_child() + if ( + isinstance(widget, VarDefinitionRowWidget) + and widget.var.key == base_key + ): + widget.set_expanded(True) + label_row = widget.label_row + + def _grab_focus(label_row=label_row): + label_row.grab_focus() + return GLib.SOURCE_REMOVE + + GLib.timeout_add(100, _grab_focus) + break + i += 1 + + popover.popdown() + + def _on_delete_var_clicked(self, sender: VarDefinitionRowWidget): + """Handler for when a row's delete button is clicked.""" + var_to_delete = sender.var + self._var_set.remove(var_to_delete.key) + self.populate(self._var_set) + + def _on_reorder_requested(self, sender, source_key: str, target_key: str): + """ + Handler for when a row is dropped onto another row. + Reorders the VarSet and refreshes the UI. + """ + # Determine current index of target + try: + target_index = -1 + for i, var in enumerate(self._var_set.vars): + if var.key == target_key: + target_index = i + break + + if target_index != -1: + self._var_set.move_var(source_key, target_index) + self.populate(self._var_set) + except (KeyError, ValueError) as e: + logger.error(f"Failed to reorder vars: {e}") + + def populate(self, var_set: VarSet): + """Populates the editor with an existing VarSet.""" + self._var_set = var_set + if var_set.title: + self.set_title(var_set.title) + if var_set.description: + self.set_description(var_set.description) + self.set_items(self._var_set.vars) + + def get_var_set(self) -> VarSet: + return self._var_set diff --git a/rayforge/ui_gtk/varset/varsetwidget.py b/rayforge/ui_gtk/varset/varsetwidget.py new file mode 100644 index 000000000..c2cbe5cfb --- /dev/null +++ b/rayforge/ui_gtk/varset/varsetwidget.py @@ -0,0 +1,257 @@ +import logging +from gettext import gettext as _ +from typing import Any + +from blinker import Signal +from gi.repository import Adw, GLib, Gtk + +from ...core.varset import Var, VarSet +from ..icons import get_icon +from .adapter import RowAdapter, create_row_for_var, escape_title + +logger = logging.getLogger(__name__) + +_DEBOUNCE_DELAY_MS = 300 + + +class _VarSetRowManager: + """ + Mixin providing all VarSet row management logic (populate, get/set + values, debouncing, apply buttons). Subclasses must implement + ``_add_row`` and ``_remove_row``, and may override + ``_set_group_title`` and ``_set_group_description``. + """ + + def _init_varset( + self, explicit_apply=False, debounce_ms=0, show_reset=False + ): + self.explicit_apply = explicit_apply + self.debounce_ms = debounce_ms + self.show_reset = show_reset + self.widget_map: dict[str, tuple[Adw.PreferencesRow, Var]] = {} + self._adapters: dict[str, RowAdapter] = {} + self._created_rows = [] + self._apply_buttons = [] + self._reset_buttons = [] + self.data_changed = Signal() + self._debounce_timer_id: int | None = None + self._pending_keys: set = set() + + def _add_row(self, row): + raise NotImplementedError + + def _remove_row(self, row): + raise NotImplementedError + + def _set_group_title(self, title): + pass + + def _set_group_description(self, desc): + pass + + def clear_dynamic_rows(self): + """Removes only the rows dynamically created by populate().""" + self._cancel_debounce() + for row in self._created_rows: + self._remove_row(row) + self._created_rows.clear() + self._apply_buttons.clear() + self._reset_buttons.clear() + self.widget_map.clear() + self._adapters.clear() + + def populate(self, var_set: VarSet): + """ + Clears previous dynamic rows and builds new ones from a VarSet. + Any static rows added manually are preserved. + Reuse existing rows if possible to preserve state. + """ + if var_set.title: + self._set_group_title(escape_title(var_set.title)) + if var_set.description: + self._set_group_description(escape_title(var_set.description)) + + new_keys = {var.key for var in var_set} + existing_keys = list(self.widget_map.keys()) + + for key in existing_keys: + if key not in new_keys: + row, _ = self.widget_map.pop(key) + self._remove_row(row) + if row in self._created_rows: + self._created_rows.remove(row) + + for var in var_set: + if var.key in self.widget_map: + row, old_var = self.widget_map[var.key] + adapter = self._adapters.get(var.key) + + needs_rebuild = type(var) is not type(old_var) + if not needs_rebuild and adapter is not None: + needs_rebuild = adapter.needs_rebuild(old_var, var) + + if needs_rebuild: + self._remove_row(row) + if row in self._created_rows: + self._created_rows.remove(row) + del self.widget_map[var.key] + else: + self.widget_map[var.key] = (row, var) + adapter = self._adapters.get(var.key) + if adapter is not None: + adapter.update_from_var(var) + continue + + row, adapter = create_row_for_var(var, "value") + if row: + self._wire_up_row(row, var, adapter) + self._add_row(row) + self._created_rows.append(row) + self.widget_map[var.key] = (row, var) + if adapter is not None: + self._adapters[var.key] = adapter + + def get_values(self) -> dict[str, Any]: + values = {} + for key in self.widget_map: + adapter = self._adapters.get(key) + if adapter is not None: + values[key] = adapter.get_value() + else: + values[key] = None + return values + + def set_values(self, values: dict[str, Any]): + for key, value in values.items(): + if key not in self.widget_map or value is None: + continue + adapter = self._adapters.get(key) + if adapter is not None: + adapter.set_value(value) + + def _on_data_changed(self, key: str): + if self.debounce_ms > 0: + self._pending_keys.add(key) + self._schedule_debounce() + else: + self.data_changed.send(self, key=key) + + def _schedule_debounce(self): + if self._debounce_timer_id is not None: + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = GLib.timeout_add( + self.debounce_ms, self._flush_debounce + ) + + def _cancel_debounce(self): + if self._debounce_timer_id is not None: + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = None + self._pending_keys.clear() + + def _flush_debounce(self): + self._debounce_timer_id = None + keys = set(self._pending_keys) + self._pending_keys.clear() + for key in keys: + self.data_changed.send(self, key=key) + + def _add_apply_button_if_needed(self, row, key): + if not self.explicit_apply: + return + apply_button = Gtk.Button( + child=get_icon("check-symbolic"), + tooltip_text=_("Apply Change"), + ) + apply_button.add_css_class("flat") + apply_button.set_valign(Gtk.Align.CENTER) + apply_button.connect("clicked", lambda b: self._on_data_changed(key)) + row.add_suffix(apply_button) + self._apply_buttons.append(apply_button) + + def _add_reset_button_if_needed(self, row, var, adapter): + if not self.show_reset: + return + reset_button = Gtk.Button( + child=get_icon("undo-symbolic"), + tooltip_text=_("Reset to Default"), + ) + reset_button.add_css_class("flat") + reset_button.set_valign(Gtk.Align.CENTER) + reset_button.connect( + "clicked", + lambda b: ( + adapter.set_value(var.default) + if var.default is not None + else None + ), + ) + row.add_suffix(reset_button) + self._reset_buttons.append(reset_button) + + def _wire_up_row( + self, + row: Adw.PreferencesRow, + var: Var, + adapter: RowAdapter | None, + ): + self._add_apply_button_if_needed(row, var.key) + self._add_reset_button_if_needed(row, var, adapter) + if adapter is not None and ( + not self.explicit_apply or adapter.has_natural_commit + ): + adapter.changed.connect( + lambda sender: self._on_data_changed(var.key), + weak=False, + ) + + def set_apply_buttons_sensitive(self, sensitive: bool): + for button in self._apply_buttons: + button.set_sensitive(sensitive) + + +class VarSetWidget(Adw.PreferencesGroup, _VarSetRowManager): + """ + A self-contained Adwaita Preferences Group that populates itself with + rows based on a VarSet. Supports both immediate updates and explicit + "Apply" buttons, with built-in debouncing for rapid value changes. + """ + + def __init__( + self, explicit_apply=False, debounce_ms=0, show_reset=False, **kwargs + ): + Adw.PreferencesGroup.__init__(self, **kwargs) + self._init_varset(explicit_apply, debounce_ms, show_reset) + + def _add_row(self, row): + self.add(row) + + def _remove_row(self, row): + self.remove(row) + + def _set_group_title(self, title): + self.set_title(title) + + def _set_group_description(self, desc): + self.set_description(desc) + + +class VarSetRowList(Gtk.ListBox, _VarSetRowManager): + """ + A Gtk.ListBox that populates itself with rows based on a VarSet. + Intended for use inside Expander cards where Adw.PreferencesGroup + styling would be visually inconsistent. + """ + + def __init__( + self, explicit_apply=False, debounce_ms=0, show_reset=False, **kwargs + ): + Gtk.ListBox.__init__(self, **kwargs) + self.set_selection_mode(Gtk.SelectionMode.NONE) + self._init_varset(explicit_apply, debounce_ms, show_reset) + + def _add_row(self, row): + self.append(row) + + def _remove_row(self, row): + self.remove(row) diff --git a/rayforge/ui_gtk/view_mode_cmd.py b/rayforge/ui_gtk/view_mode_cmd.py new file mode 100644 index 000000000..820c47173 --- /dev/null +++ b/rayforge/ui_gtk/view_mode_cmd.py @@ -0,0 +1,109 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Optional + +from gi.repository import Adw, GLib + +from ..context import get_context +from ..usage import get_usage_tracker + +if TYPE_CHECKING: + from gi.repository import Gio + + from ..doceditor.editor import DocEditor + from ..ui_gtk.mainwindow import MainWindow + from .sim3d import Canvas3D + +logger = logging.getLogger(__name__) + + +class ViewModeCmd: + """Handles commands for switching and controlling views (2D/3D).""" + + def __init__(self, editor: "DocEditor", win: "MainWindow"): + self._editor = editor + self._win = win + + def toggle_3d_view( + self, + action: "Gio.SimpleAction", + value: Optional["GLib.Variant"], + ): + """ + Handles the logic for switching between the 2D and 3D views. + """ + from .sim3d import initialized as canvas3d_initialized + + win = self._win + current_state = action.get_state() + is_3d = current_state.get_boolean() if current_state else False + request_3d = value.get_boolean() if value else not is_3d + + if is_3d == request_3d: + return + + if request_3d: + if not canvas3d_initialized: + logger.warning( + "Attempted to open 3D view, but it is not available." + ) + toast = Adw.Toast.new( + _("3D view is not available due to missing dependencies.") + ) + win.toast_overlay.add_toast(toast) + return + + if not get_context().machine: + logger.warning( + "Cannot show 3D view without an active machine." + ) + toast = Adw.Toast.new( + _("Select a machine to open the 3D view.") + ) + win.toast_overlay.add_toast(toast) + return + + action.set_state(GLib.Variant.new_boolean(True)) + win.view_stack.set_visible_child_name("3d") + if win.main_stack.get_visible_child_name() == "main": + + def _grab_3d(): + if win.canvas3d: + win.canvas3d.grab_focus() + return False + + GLib.idle_add(_grab_3d) + get_usage_tracker().track_page_view("/view/3d", "3D View") + + else: + action.set_state(GLib.Variant.new_boolean(False)) + win.view_stack.set_visible_child_name("2d") + if win.main_stack.get_visible_child_name() == "main": + + def _grab_2d(): + if win.surface: + win.surface.grab_focus() + return False + + GLib.idle_add(_grab_2d) + + def set_view(self, direction, canvas3d: Optional["Canvas3D"]): + """Sets the 3D view to the specified preset orientation.""" + if canvas3d: + canvas3d.reset_view(direction) + + def toggle_perspective( + self, + canvas3d: Optional["Canvas3D"], + action: "Gio.SimpleAction", + value: "GLib.Variant", + ): + """Toggles the 3D camera between perspective and orthographic.""" + if canvas3d: + is_perspective = value.get_boolean() + if not canvas3d.set_perspective(is_perspective): + return + action.set_state(value) + config = get_context().config + config.canvas_view.perspective_mode = is_perspective + config.changed.send(config) diff --git a/rayforge/uiscript.py b/rayforge/uiscript.py new file mode 100644 index 000000000..396c18bb2 --- /dev/null +++ b/rayforge/uiscript.py @@ -0,0 +1,66 @@ +""" +Runtime utilities for UI scripts. + +This module provides the execution environment for scripts run via +`--uiscript`. +Scripts can explicitly import the app and window instances: + + from rayforge.uiscript import app, win +""" + +import logging +import sys +import threading +import traceback +from pathlib import Path + +logger = logging.getLogger(__name__) + +app = None +win = None + + +def _set_context(application, window): + """Called by the app to populate the script context.""" + global app, win + app = application + win = window + + +def run_script(script_path: Path, application, window): + """ + Execute a UI script in a background thread. + + Args: + script_path: Path to the Python script to execute. + application: The RayforgeApplication instance. + window: The MainWindow instance. + """ + if not script_path.exists(): + logger.error(f"UIScript not found: {script_path}") + return + + logger.info(f"Executing UI script: {script_path}") + + def execute(): + _set_context(application, window) + + script_globals = { + "__name__": "__uiscript__", + "__file__": str(script_path), + } + script_dir = str(script_path.parent.resolve()) + sys.path.insert(0, script_dir) + try: + with open(script_path, "r") as f: + code = compile(f.read(), str(script_path), "exec") + exec(code, script_globals) # noqa: S102 + except Exception as e: # noqa: BLE001 - arbitrary user script + logger.error(f"Error executing UI script: {e}") + traceback.print_exc() + finally: + if sys.path[0] == script_dir: + sys.path.pop(0) + + thread = threading.Thread(target=execute, daemon=True) + thread.start() diff --git a/rayforge/updater.py b/rayforge/updater.py new file mode 100644 index 000000000..d92aa9bdc --- /dev/null +++ b/rayforge/updater.py @@ -0,0 +1,97 @@ +import asyncio +import logging +import webbrowser +from gettext import gettext as _ +from typing import TYPE_CHECKING + +import aiohttp +from blinker import Signal + +from . import __version__ +from .const import DOWNLOAD_URL, GITHUB_RELEASES_API +from .shared.util.versioning import is_newer_version + +if TYPE_CHECKING: + from .context import RayforgeContext + from .shared.tasker import TaskManager + +logger = logging.getLogger(__name__) + + +class AppUpdateChecker: + """ + Checks for new Rayforge versions via the GitHub Releases API. + Runs checks in the background and notifies the UI via signals. + """ + + notification_requested = Signal() + + def __init__(self, task_mgr: "TaskManager", context: "RayforgeContext"): + self._task_mgr = task_mgr + self._context = context + + def check_on_startup(self): + config = self._context.config + if not config.check_for_app_updates: + logger.info("App update check disabled by user.") + return + logger.info("Scheduling app version update check.") + self._task_mgr.add_coroutine( + self._check_worker, key="app-update-check" + ) + + async def _check_worker(self, ctx): + ctx.set_message(_("Checking for Rayforge updates...")) + try: + release = await self._fetch_latest_release() + except Exception as e: # noqa: BLE001 - async task boundary + logger.error(f"Failed to check for app updates: {e}") + ctx.set_message(_("Update check failed.")) + return + + if release is None: + ctx.set_message(_("Update check failed.")) + return + + latest_tag = release.get("tag_name", "") + + if is_newer_version(latest_tag, __version__ or "0.0.0"): + logger.info( + f"New version available: {latest_tag} (current: {__version__})" + ) + msg = _("Rayforge {version} is available.").format( + version=latest_tag + ) + + def _open_download(): + webbrowser.open(DOWNLOAD_URL) + + self._task_mgr.schedule_on_main_thread( + self.notification_requested.send, + self, + message=msg, + persistent=True, + action_label=_("Download"), + action_callback=_open_download, + ) + ctx.set_message(_("New version available.")) + else: + logger.info("Rayforge is up to date.") + ctx.set_message(_("Rayforge is up to date.")) + + async def _fetch_latest_release(self) -> dict | None: + try: + async with ( + aiohttp.ClientSession() as session, + session.get( + GITHUB_RELEASES_API, + timeout=aiohttp.ClientTimeout(total=15), + ) as response, + ): + if response.status == 200: + return await response.json() + logger.warning(f"GitHub API returned status {response.status}") + return None + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + logger.error(f"Error fetching release info: {e}") + return None diff --git a/rayforge/usage.py b/rayforge/usage.py new file mode 100644 index 000000000..e574a3ce6 --- /dev/null +++ b/rayforge/usage.py @@ -0,0 +1,159 @@ +import json +import locale +import logging +import platform +import threading +import urllib.error +import urllib.request +import uuid +from typing import Optional + +from . import __version__ +from .config import UMAMI_URL, UMAMI_WEBSITE_ID + +logger = logging.getLogger(__name__) + + +def _get_language() -> str: + try: + locale.setlocale(locale.LC_ALL, "") + lang = locale.getlocale()[0] + if lang: + return lang.replace("_", "-") + return "en-US" + except locale.Error: + return "en-US" + + +def _get_os_info() -> str: + system = platform.system() + if system == "Linux": + try: + release = platform.freedesktop_os_release() + distro = release.get("ID", "linux") + version = release.get("VERSION_ID", "") + if version: + return f"linux/{distro}/{version}" + return f"linux/{distro}" + except (OSError, KeyError, ValueError): + return "linux" + elif system == "Windows": + return f"windows/{platform.release()}" + elif system == "Darwin": + return f"macos/{platform.mac_ver()[0]}" + return system.lower() + + +class UsageTracker: + _instance: Optional["UsageTracker"] = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + self._enabled = False + self._screen = self._get_screen_size() + self._language = _get_language() + self._os = _get_os_info() + self._version = __version__ or "unknown" + self._cache_token: str | None = None + self._session_id = str(uuid.uuid4()) + + def _get_screen_size(self) -> str: + try: + from .ui_gtk.shared.gtk import get_screen_size + + size = get_screen_size() + if size: + return f"{size[0]}x{size[1]}" + except Exception: + logger.debug("Failed to get screen size", exc_info=True) + return "unknown" + + def set_enabled(self, enabled: bool): + self._enabled = enabled + if enabled: + logger.info("Usage tracking enabled") + else: + logger.info("Usage tracking disabled") + + def track_page_view(self, url: str, title: str | None = None): + if not self._enabled: + return + if not url.startswith("/"): + url = "/" + url + full_url = f"file://{url}" + self._send_event( + payload={ + "website": UMAMI_WEBSITE_ID, + "screen": self._screen, + "language": self._language, + "title": title or url, + "hostname": "", + "url": full_url, + "referrer": "", + "sessionId": self._session_id, + "data": { + "app_version": self._version, + "os": self._os, + }, + }, + ) + + def _send_event(self, payload: dict): + def _send(): + try: + body = {"type": "event", "payload": payload} + data = json.dumps(body).encode("utf-8") + headers = { + "Content-Type": "application/json", + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "*/*", + "Origin": "null", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "cross-site", + } + if self._cache_token: + headers["x-umami-cache"] = self._cache_token + + req = urllib.request.Request( + UMAMI_URL, data=data, headers=headers, method="POST" + ) + with urllib.request.urlopen(req, timeout=5) as response: + response_body = response.read().decode("utf-8") + try: + resp_json = json.loads(response_body) + if resp_json and "cache" in resp_json: + self._cache_token = resp_json["cache"] + except json.JSONDecodeError: + pass + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8") + logger.warning( + f"Usage tracking request failed: {e.code} {error_body}" + ) + except urllib.error.URLError as e: + logger.warning(f"Usage tracking request failed: {e}") + except (OSError, TimeoutError, ValueError) as e: + logger.warning(f"Usage tracking error: {e}") + + thread = threading.Thread(target=_send, daemon=True) + thread.start() + + +def get_usage_tracker() -> UsageTracker: + return UsageTracker() diff --git a/rayforge/util/adwfix.py b/rayforge/util/adwfix.py deleted file mode 100644 index 5591fffe4..000000000 --- a/rayforge/util/adwfix.py +++ /dev/null @@ -1,26 +0,0 @@ -def get_spinrow_int(spinrow): - # Workaround: Adw.SpinRow seems to have a bug that the value is not - # always updated if it was edited using the keyboard in the edit - # field. I.e. get_value() still returns the previous value. - # So I convert it manually from text if possible. - try: - value = int(spinrow.get_text()) - except ValueError: - value = int(spinrow.get_value()) - lower = spinrow.get_adjustment().get_lower() - upper = spinrow.get_adjustment().get_upper() - return max(lower, min(value, upper)) - - -def get_spinrow_float(spinrow): - # Workaround: Adw.SpinRow seems to have a bug that the value is not - # always updated if it was edited using the keyboard in the edit - # field. I.e. get_value() still returns the previous value. - # So I convert it manually from text if possible. - try: - value = float(spinrow.get_text()) - except ValueError: - value = float(spinrow.get_value()) - lower = spinrow.get_adjustment().get_lower() - upper = spinrow.get_adjustment().get_upper() - return max(lower, min(value, upper)) diff --git a/rayforge/util/cairoutil.py b/rayforge/util/cairoutil.py deleted file mode 100644 index 320964994..000000000 --- a/rayforge/util/cairoutil.py +++ /dev/null @@ -1,54 +0,0 @@ -import cairo -import numpy as np - - -def convert_surface_to_grayscale(surface): - # Determine the number of channels based on the format - surface_format = surface.get_format() - if surface_format != cairo.FORMAT_ARGB32: - raise ValueError("Unsupported Cairo surface format") - - width, height = surface.get_width(), surface.get_height() - data = surface.get_data() - data = np.frombuffer(data, dtype=np.uint8).reshape((height, width, 4)) - - # Convert RGB to grayscale using luminosity method - gray = (0.299*data[:, :, 2] - + 0.587*data[:, :, 1] - + 0.114*data[:, :, 0]).astype(np.uint8) - - # Set RGB channels to gray, keep alpha unchanged - data[:, :, :3] = gray[:, :, None] - - return surface - - -def make_transparent(surface, threshold=250): - if surface.get_format() != cairo.FORMAT_ARGB32: - raise ValueError("Surface must be in ARGB32 format.") - - width, height = surface.get_width(), surface.get_height() - stride = surface.get_stride() - - # Get pixel data as a NumPy array - data = surface.get_data() - buf = np.frombuffer(data, dtype=np.uint8).reshape((height, stride)) - - # Convert to 32-bit ARGB view - argb = buf.view(dtype=np.uint32)[:, :width] - - # Extract channels - r = (argb >> 16) & 0xFF # Red - g = (argb >> 8) & 0xFF # Green - b = argb & 0xFF # Blue - - # Find "almost white" pixels - brightness = (r.astype(np.uint16) - + g.astype(np.uint16) - + b.astype(np.uint16)) // 3 - mask = brightness >= threshold - - # Set these pixels to transparent - argb[mask] = (0x00 << 24) | (r[mask] << 16) | (g[mask] << 8) | b[mask] - - # No need to return anything as the surface is modified in place diff --git a/rayforge/util/resources.py b/rayforge/util/resources.py deleted file mode 100644 index fadae3c26..000000000 --- a/rayforge/util/resources.py +++ /dev/null @@ -1,14 +0,0 @@ -import importlib.resources -from gi.repository import Gtk -from ..resources import icons - - -def get_icon_path(icon_name): - """Retrieve the path of an icon inside the package.""" - with importlib.resources.path(icons, f"{icon_name}.svg") as path: - return str(path) - - -def get_icon(icon_name): - """Retrieve the path of an icon inside the package.""" - return Gtk.Image.new_from_file(get_icon_path(icon_name)) diff --git a/rayforge/version.py b/rayforge/version.py index b8018bd10..6557771e5 100644 --- a/rayforge/version.py +++ b/rayforge/version.py @@ -1,22 +1,33 @@ import os import subprocess +import sys __dir__ = os.path.dirname(__file__) -def get_version_from_git() -> str: +def get_version_from_git() -> str | None: + kwargs = { + "stderr": subprocess.DEVNULL, + "cwd": __dir__, + } + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + try: - output = subprocess.check_output(['git', 'describe'], - stderr=subprocess.DEVNULL, - cwd=__dir__) - except (subprocess.CalledProcessError, FileNotFoundError): + # Use **kwargs to pass the arguments + output = subprocess.check_output(["git", "describe"], **kwargs) + except ( + subprocess.CalledProcessError, + FileNotFoundError, + NotADirectoryError, + ): return None - return output.decode('ascii').strip() + return output.decode("ascii").strip() -def get_version_from_pkg() -> str: +def get_version_from_pkg() -> str | None: try: - from importlib.metadata import version, PackageNotFoundError + from importlib.metadata import PackageNotFoundError, version except ImportError: return None @@ -24,3 +35,12 @@ def get_version_from_pkg() -> str: return version("rayforge") except PackageNotFoundError: return None + + +def get_version_from_file() -> str | None: + version_file = os.path.join(__dir__, "version.txt") + try: + with open(version_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + return None diff --git a/rayforge/widgets/canvas.py b/rayforge/widgets/canvas.py deleted file mode 100644 index 59d8f900f..000000000 --- a/rayforge/widgets/canvas.py +++ /dev/null @@ -1,524 +0,0 @@ -from __future__ import annotations -import cairo -from gi.repository import Gtk, Gdk, Graphene -from copy import deepcopy -from blinker import Signal - - -class CanvasElement: - def __init__(self, x_mm, y_mm, width_mm, height_mm, - selected: bool = False, - selectable: bool = True, - visible: bool = True, - background: (float, float, float, float) = (0, 0, 0, 0), - canvas: Canvas = None, - parent: Canvas | CanvasElement = None, - data: object = None): - self.x_mm: float = None # Relative to parent (or canvas if top-level) - self.y_mm: float = None # Relative to parent (or canvas if top-level) - self.width_mm: float = None # Real-world width in mm - self.height_mm: float = None # Real-world height in mm - self.selected: bool = selected - self.selectable: bool = selectable - self.visible: bool = visible - self.surface: cairo.Surface = None - self.canvas: object = canvas - self.parent: object = parent - self.children: list = [] - self.background: (float, float, float, float) = 0, 0, 0, 0 - self.data: object = data - self.dirty: bool = True - - self.set_pos(x_mm, y_mm) - self.set_size(width_mm, height_mm) - - def get_pixels_per_mm(self): - return self.canvas.pixels_per_mm_x, \ - self.canvas.pixels_per_mm_y - - def copy(self): - return deepcopy(self) - - def add(self, elem): - self.children.append(elem) - elem.canvas = self.canvas - elem.parent = self - elem.allocate() - self.dirty = True - - def set_visible(self, visible=True): - self.visible = visible - self.dirty = True - - def find_by_data(self, data): - if data == self.data: - return self - for child in self.children: - result = child.find_by_data(data) - if result: - return result - return None - - def clear(self): - children = self.children - self.children = [] - for child in children: - self.canvas.elem_removed.send(self, child=child) - self.dirty = True - - def remove(self): - assert self.parent is not None - self.parent.remove_child(self) - self.dirty = True - - def remove_child(self, elem): - """ - Not recursive. - """ - for child in self.children[:]: - if child == elem: - self.children.remove(child) - self.canvas.elem_removed.send(self, child=child) - self.dirty = True - - def remove_selected(self): - for child in self.children[:]: - if child.selected: - self.children.remove(child) - self.canvas.elem_removed.send(self, child=child) - child.remove_selected() - self.dirty = True - - def unselect_all(self): - for child in self.children: - child.unselect_all() - self.selected = False - self.dirty = True - - def get_max_child_size(self, aspect_ratio): - """ - Returns the maximum size for a child with the given - aspect ratio. - """ - width_mm = self.width_mm - height_mm = width_mm/aspect_ratio - if height_mm > self.height_mm: - height_mm = self.height_mm - width_mm = height_mm*aspect_ratio - return width_mm, height_mm - - def set_pos(self, x_mm, y_mm): - self.x_mm, self.y_mm = x_mm, y_mm - if self.parent: - self.parent.dirty = True - - def pos(self): - return self.x_mm, self.y_mm - - def pos_px(self): - pixels_per_mm_x, pixels_per_mm_y = self.get_pixels_per_mm() - return self.x_mm*pixels_per_mm_x, self.y_mm*pixels_per_mm_y - - def pos_abs(self): - parent_x, parent_y = 0, 0 - if isinstance(self.parent, CanvasElement): - parent_x, parent_y = self.parent.pos_abs() - return self.x_mm+parent_x, self.y_mm+parent_y - - def pos_abs_px(self): - pixels_per_mm_x, pixels_per_mm_y = self.get_pixels_per_mm() - x_mm, y_mm = self.pos_abs() - return x_mm*pixels_per_mm_x, y_mm*pixels_per_mm_y - - def size(self): - return self.width_mm, self.height_mm - - def set_size(self, width_mm, height_mm): - self.width_mm, self.height_mm = width_mm, height_mm - self.dirty = True - if self.canvas: - self.canvas.queue_draw() - - def size_px(self): - pixels_per_mm_x, pixels_per_mm_y = self.get_pixels_per_mm() - return (int(self.width_mm*pixels_per_mm_x), - int(self.height_mm*pixels_per_mm_y)) - - def rect(self): - return self.x_mm, self.y_mm, self.width_mm, self.height_mm - - def rect_abs(self): - x_mm, y_mm = self.pos_abs() - return x_mm, y_mm, self.width_mm, self.height_mm - - def rect_px(self): - px_mm_x, px_mm_y = self.get_pixels_per_mm() - return (self.x_mm*px_mm_x, - self.y_mm*px_mm_y, - self.width_mm*px_mm_x, - self.height_mm*px_mm_y) - - def get_aspect_ratio(self): - return self.width_mm / self.height_mm - - def allocate(self, force=False): - if not self.canvas: - return # cannot allocate if i don't know pixels per mm - - width, height = self.size_px() - - for child in self.children: - child.allocate(force) - - # If the size didn't change, do nothing. - if self.surface \ - and self.surface.get_width() == width \ - and self.surface.get_height() == height \ - and not force: - return - - self.dirty = True - self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - - def _rect_to_child_coords_px(self, child, rect_px): - x, y, w, h = rect_px - child_x, child_y, child_w, child_h = child.rect_px() - return x-child_x, y-child_y, w, h - - def clear_surface(self, clip=None): - if clip is None: - clip = self.rect_px() - - # Paint background - x, y, w, h = clip - ctx = cairo.Context(self.surface) - ctx.rectangle(0, 0, x+w, y+h) - ctx.clip() - ctx.save() - ctx.set_source_rgba(*self.background) - ctx.set_operator(cairo.OPERATOR_SOURCE) - ctx.paint() - - def render(self, clip=None): - """ - clip: x, y, w, h. the region to render - """ - if clip is None: - clip = self.rect_px() - self.clear_surface(clip) - - # Paint children - x, y, w, h = clip - ctx = cairo.Context(self.surface) - ctx.rectangle(0, 0, x+w, y+h) - ctx.clip() - for child in self.children: - if child.dirty: - rect = self._rect_to_child_coords_px(child, child.rect_px()) - child.render(rect) - child.dirty = False - if child.visible: - ctx.set_source_surface(child.surface, *child.pos_px()) - ctx.paint() - - def has_dirty_children(self): - if self.dirty: - return True - for child in self.children: - if child.has_dirty_children(): - return True - return False - - def render_if_dirty(self, clip=None): - if clip is None: - clip = self.rect_px() - - if not self.has_dirty_children(): - return - if not self.visible: - self.clear_surface(clip) - return - - self.render(clip) - self.dirty = False - - def get_elem_hit(self, x_mm, y_mm, selectable=False): - """ - Check if the point (x_mm, y_mm) hits this elem or any of its children. - If selectable is True, only selectable elems are considered. - """ - # Check children (child-to-parent order) - for child in reversed(self.children): - # Translate the coordinates to the child's local coordinate system - child_x_mm = x_mm - child.x_mm - child_y_mm = y_mm - child.y_mm - hit = child.get_elem_hit(child_x_mm, child_y_mm, selectable) - if hit: - return hit - - if selectable and not self.selectable: - return None - - # Check if the point is within the elem's bounds - if 0 <= x_mm <= self.width_mm and 0 <= y_mm <= self.height_mm: - return self - - return None - - def dump(self, indent=0): - print(" "*indent + self.__class__.__name__ + ':') - print(" "*(indent+1) + "Visible:", self.visible) - print(" "*(indent+1) + "Dirty:", self.dirty) - print(" "*(indent+1) + "Dirty (recurs.):", self.has_dirty_children()) - print(" "*(indent+1) + "Size:", self.rect()) - print(" "*(indent+1) + "Size (px):", self.rect_px()) - for child in self.children: - child.dump(indent+1) - - -class Canvas(Gtk.DrawingArea): - def __init__(self, width_mm=100, height_mm=100, **kwargs): - super().__init__(**kwargs) - self.root = CanvasElement(0, - 0, - width_mm, - height_mm, - canvas=self, - parent=self) - self.pixels_per_mm_x = 1 # Updated in do_size_allocate() - self.pixels_per_mm_y = 1 # Updated in do_size_allocate() - self.handle_size = 12 # Resize handle size - self.active_elem = None - self.active_rect = None, None, None, None - self._setup_interactions() - - def add(self, elem): - self.root.add(elem) - - def remove(self, elem): - self.root.remove(elem) - - def find_by_data(self, data): - """ - Returns the CanvasElement with the given data, or None if none - was found. - """ - return self.root.find_by_data(data) - - def size(self): - return self.root.size() - - def _setup_interactions(self): - self.click_gesture = Gtk.GestureClick() - self.click_gesture.connect("pressed", self.on_button_press) - self.add_controller(self.click_gesture) - - self.motion_controller = Gtk.EventControllerMotion() - self.motion_controller.connect("motion", self.on_motion) - self.add_controller(self.motion_controller) - - self.drag_gesture = Gtk.GestureDrag() - self.drag_gesture.connect("drag-update", self.on_mouse_drag) - self.drag_gesture.connect("drag-end", self.on_button_release) - self.add_controller(self.drag_gesture) - self.resizing = False - self.moving = False - - self.key_controller = Gtk.EventControllerKey.new() - self.key_controller.connect("key-pressed", self.on_key_pressed) - self.key_controller.connect("key-released", self.on_key_released) - self.add_controller(self.key_controller) - self.shift_pressed = False - self.set_focusable(True) - self.grab_focus() - - self.elem_removed = Signal() - - def do_size_allocate(self, width: int, height: int, baseline: int): - self.pixels_per_mm_x = width/self.root.width_mm - self.pixels_per_mm_y = height/self.root.height_mm - self.root.allocate() - - def do_snapshot(self, snapshot): - width, height = self.get_width(), self.get_height() - bounds = Graphene.Rect().init(0, 0, width, height) - - self.root.render_if_dirty() - ctx = snapshot.append_cairo(bounds) - ctx.set_source_surface(self.root.surface, *self.root.pos_px()) - ctx.paint() - - self._render_selection(ctx, self.root, 0, 0) - - def _render_selection(self, ctx, elem, parent_x_mm, parent_y_mm): - # Calculate absolute position of the elem - absolute_x_mm = parent_x_mm + elem.x_mm - absolute_y_mm = parent_y_mm + elem.y_mm - elem_x = absolute_x_mm * self.pixels_per_mm_x - elem_y = absolute_y_mm * self.pixels_per_mm_y - target_width = elem.width_mm * self.pixels_per_mm_x - target_height = elem.height_mm * self.pixels_per_mm_y - - # Draw rectangle around selected elems - if elem.selected: - ctx.save() - ctx.set_source_rgb(.4, .4, .4) - ctx.set_dash((5, 5)) - ctx.rectangle(elem_x, elem_y, target_width, target_height) - ctx.stroke() - ctx.restore() - - # Draw resize handle - if elem == self.active_elem: - ctx.save() - ctx.set_source_rgb(.4, .4, .4) - ctx.set_line_width(1) - handle_x = elem_x + target_width - handle_y = elem_y + target_height - ctx.rectangle(handle_x-self.handle_size/2, - handle_y-self.handle_size/2, - self.handle_size, - self.handle_size) - ctx.stroke() - ctx.restore() - - # Recursively render children - for child in elem.children: - self._render_selection(ctx, child, absolute_x_mm, absolute_y_mm) - - def get_elem_handle_hit(self, elem, x_mm, y_mm, selectable=True): - for child in elem.children: - child_x_mm = x_mm-elem.x_mm - child_y_mm = y_mm-elem.y_mm - hit = self.get_elem_handle_hit(child, - child_x_mm, - child_y_mm, - selectable=True) - if hit: - return hit - if selectable and not elem.selectable: - return - if not elem.selected: - return None - handle_size_mm_x = self.handle_size/self.pixels_per_mm_x - handle_size_mm_y = self.handle_size/self.pixels_per_mm_y - handle_x1 = elem.x_mm+elem.width_mm-handle_size_mm_x/2 - handle_x2 = handle_x1+handle_size_mm_x - handle_y1 = elem.y_mm+elem.height_mm-handle_size_mm_y/2 - handle_y2 = handle_y1+handle_size_mm_y - if handle_x1 <= x_mm <= handle_x2 and handle_y1 <= y_mm <= handle_y2: - return elem - return None - - def on_button_press(self, gesture, n_press, x, y): - self.grab_focus() - - x_mm = x/self.pixels_per_mm_x - y_mm = y/self.pixels_per_mm_y - - hit = self.get_elem_handle_hit(self.root, x_mm, y_mm, selectable=True) - - self.root.unselect_all() - - if hit and hit != self.root: - hit.selected = True - self.resizing = True - self.active_elem = hit - self.active_origin = hit.rect() - self.queue_draw() - return - - hit = self.root.get_elem_hit(x_mm, y_mm, selectable=True) - if hit and hit != self.root: - hit.selected = True - self.moving = True - self.active_elem = hit - self.active_origin = hit.rect() - self.queue_draw() - return - - self.active_elem = None - self.queue_draw() - - def on_motion(self, gesture, x, y): - x_mm = x/self.pixels_per_mm_x - y_mm = y/self.pixels_per_mm_y - - hit = self.get_elem_handle_hit(self.root, x_mm, y_mm, selectable=True) - if hit: - cursor_name = "se-resize" - else: - cursor_name = "default" - cursor = Gdk.Cursor.new_from_name(cursor_name) - self.set_cursor(cursor) - - def on_mouse_drag(self, gesture, x, y): - if not self.active_elem: - return - - start_x_mm, start_y_mm, start_w_mm, start_h_mm = self.active_origin - delta_x_mm = x/self.pixels_per_mm_x - delta_y_mm = y/self.pixels_per_mm_y - - if self.moving: - self.active_elem.set_pos(start_x_mm+delta_x_mm, - start_y_mm+delta_y_mm) - self.active_elem.parent.dirty = True - - if self.resizing: - new_w_mm = max(self.handle_size, start_w_mm+delta_x_mm) - new_w_mm = min(new_w_mm, self.active_elem.parent.width_mm) - if self.shift_pressed: - aspect = start_w_mm/start_h_mm - new_h_mm = new_w_mm/aspect - else: - new_h_mm = max(self.handle_size, start_h_mm+delta_y_mm) - new_h_mm = min(new_h_mm, self.active_elem.parent.height_mm) - self.active_elem.parent.dirty = True - self.active_elem.set_size(new_w_mm, new_h_mm) - self.active_elem.allocate() - - self.queue_draw() - - def on_button_release(self, gesture, x, y): - self.resizing = False - self.moving = False - - def on_key_pressed(self, controller, keyval, keycode, state): - if keyval == Gdk.KEY_Shift_L or keyval == Gdk.KEY_Shift_R: - self.shift_pressed = True - elif keyval == Gdk.KEY_Delete: - self.root.remove_selected() - self.active_elem = None - self.active_rect = None, None, None, None - self.queue_draw() - - def on_key_released(self, controller, keyval, keycode, state): - if keyval == Gdk.KEY_Shift_L or keyval == Gdk.KEY_Shift_R: - self.shift_pressed = False - - -if __name__ == "__main__": - class CanvasApp(Gtk.Application): - def __init__(self): - super().__init__(application_id="com.example.CanvasApp") - - def do_activate(self): - win = Gtk.ApplicationWindow(application=self) - win.set_default_size(800, 800) - canvas = Canvas(200, 200) - win.set_child(canvas) - group = CanvasElement(50, 50, 140, 130, - background=(0, 1, 1, 1)) - group.add(CanvasElement(50, 50, 40, 30, - background=(0, 0, 1, 1), - selectable=False)) - group.add(CanvasElement(100, 100, 30, 30, - background=(0, 1, 0, 1))) - group.add(CanvasElement(50, 100, 50, 50, - background=(1, 0, 1, 1))) - canvas.add(group) - win.present() - - app = CanvasApp() - app.run([]) diff --git a/rayforge/widgets/draglist.py b/rayforge/widgets/draglist.py deleted file mode 100644 index cd6e44178..000000000 --- a/rayforge/widgets/draglist.py +++ /dev/null @@ -1,147 +0,0 @@ -from gi.repository import Gtk, Gdk -from blinker import Signal - -css = """ -.material-list row { - padding: 2px 16px; - border: none; - transition: background-color 0.2s ease; -} -.material-list row:last-child { - border-bottom: none; -} -.material-list row:hover { - background-color: #fff; -} -.material-list row:drop(active) { - outline: none; - box-shadow: none; -} -.material-list row.drop-above { - border: 1px solid #f00; - border-width: 2px 0px 0px 0px; -} -.material-list row.drop-below { - border: 1px solid #f00; - border-width: 0px 0px 2px 0px; -} -.material-list row:active { -} -""" - - -class DragListBox(Gtk.ListBox): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.set_selection_mode(Gtk.SelectionMode.NONE) - self.add_css_class("material-list") - self.apply_css() - self.reordered = Signal() - - def apply_css(self): - provider = Gtk.CssProvider() - provider.load_from_data(css.encode()) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - def add_row(self, row): - row.add_css_class("material-row") - self.append(row) - self.make_row_draggable(row) - - def make_row_draggable(self, row): - drag_source = Gtk.DragSource() - drag_source.set_actions(Gdk.DragAction.MOVE) - drag_source.connect("prepare", self.on_drag_prepare, row) - drag_source.connect("drag-end", self.on_drag_end, row) - row.add_controller(drag_source) - - drop_target = Gtk.DropTarget.new(Gtk.ListBoxRow, Gdk.DragAction.MOVE) - drop_target.connect("drop", self.on_drop, row) - drop_target.connect("motion", self.on_drag_motion, row) - row.add_controller(drop_target) - - def _remove_drop_marker(self): - row = self.get_first_child() - while row: - row.remove_css_class("drop-above") - row.remove_css_class("drop-below") - row = row.get_next_sibling() - - def on_drag_prepare(self, source, x, y, row): - snapshot = Gtk.Snapshot() - row.do_snapshot(row, snapshot) - paintable = snapshot.to_paintable() - source.set_icon(paintable, x, row.get_height()/2) - return Gdk.ContentProvider.new_for_value(row) - - def on_drag_motion(self, drop_target, x, y, row): - self._remove_drop_marker() - - # Determine whether the drop marker should be above or below - if y < (row.get_height() / 2): - row.add_css_class("drop-above") - else: - row.add_css_class("drop-below") - return Gdk.DragAction.MOVE - - def on_drag_leave(self, drag, row): - row.remove_css_class("drop-above") - row.remove_css_class("drop-below") - - def on_drag_end(self, source, drag, delete_data, row): - self._remove_drop_marker() - - def on_drop(self, drop_target, value, x, y, target_row): - if not isinstance(value, Gtk.ListBoxRow): - return False - - source_row = value - source_index = source_row.get_index() - target_index = target_row.get_index() - - if source_index == target_index: - return False - - # Allow inserting before the first item - if y < target_row.get_height() / 2: - target_index -= 1 - - # Adjust target_index when dragging up - if source_index > target_index: - target_index += 1 - - self.remove(source_row) - self.insert(source_row, target_index) - - self.reordered.send(self) - return True - - -if __name__ == "__main__": - class DragListWindow(Gtk.ApplicationWindow): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.set_title("Reorderable List Example") - self.set_default_size(300, 400) - listview = DragListBox() - self.set_child(listview) - - # Add some rows - for i in range(5): - label = Gtk.Label(label=f"Item {i + 1}") - label.set_xalign(0) - row = Gtk.ListBoxRow() - row.set_child(label) - listview.add_row(row) - - def on_activate(app): - win = DragListWindow(application=app) - win.present() - - app = Gtk.Application(application_id='org.example.DragListBox') - app.connect('activate', on_activate) - app.run(None) diff --git a/rayforge/widgets/dynamicprefs.py b/rayforge/widgets/dynamicprefs.py deleted file mode 100644 index a6476e466..000000000 --- a/rayforge/widgets/dynamicprefs.py +++ /dev/null @@ -1,100 +0,0 @@ -import inspect -from gi.repository import Gtk, Adw -from blinker import Signal -from ..util.adwfix import get_spinrow_int - - -class DynamicPreferencesGroup(Adw.PreferencesGroup): - def __init__(self, *args, **kwargs): - """ - Params is a dict of items as returned by - inspect.signature.parameters.items() - """ - super().__init__(*args, **kwargs) - self.widget_map = {} - self.data_changed = Signal() - - def clear(self): - for row in self.widget_map.values(): - self.remove(row) - self.widget_map = {} - - def create_params(self, params): - self.clear() - - # Get constructor parameters - for name, param in params: - if name == 'self': - continue - - annotation = param.annotation - isempty = param.default == inspect.Parameter.empty - default = param.default if not isempty else None - - # Create appropriate row based on type - if annotation == str: - row = self._create_string_row(name, default) - elif annotation == bool: - row = self._create_boolean_row(name, default) - elif annotation == int: - row = self._create_integer_row(name, default) - else: - continue # Skip unsupported types - - self.add(row) - self.widget_map[name] = row - - def _create_string_row(self, name, default): - row = Adw.EntryRow(title=name.capitalize()) - if default is not None: - row.set_text(str(default)) - row.connect("changed", lambda e: self.data_changed.send(e)) - return row - - def _create_boolean_row(self, name, default): - row = Adw.ActionRow(title=name.capitalize()) - switch = Gtk.Switch() - switch.set_active(default if default is not None else False) - switch.set_valign(Gtk.Align.CENTER) - row.add_suffix(switch) - row.activatable_widget = switch - row.switch = switch # Store reference - return row - - def _create_integer_row(self, name, default): - adjustment = Gtk.Adjustment( - value=default if default is not None else 0, - lower=-2147483648, - upper=2147483647, - step_increment=1 - ) - row = Adw.SpinRow(title=name.capitalize(), adjustment=adjustment) - row.connect("changed", lambda e: self.data_changed.send(e)) - return row - - def get_values(self): - values = {} - for name, row in self.widget_map.items(): - if isinstance(row, Adw.EntryRow) and hasattr(row, 'spin'): - # Integer input - values[name] = get_spinrow_int(row) - elif isinstance(row, Adw.ActionRow): - # Boolean switch - values[name] = row.switch.get_active() - else: - # String input - values[name] = row.get_text() - return values - - def set_values(self, values): - for name, value in values.items(): - row = self.widget_map.get(name) - if row is None: - continue - if isinstance(row, Adw.EntryRow): - row.set_text(str(value)) - elif isinstance(row, Adw.SpinRow): - row.set_value(int(value)) - else: - row.switch.set_active(bool(value)) - return values diff --git a/rayforge/widgets/groupbox.py b/rayforge/widgets/groupbox.py deleted file mode 100644 index 2fc725a67..000000000 --- a/rayforge/widgets/groupbox.py +++ /dev/null @@ -1,126 +0,0 @@ -from gi.repository import Gtk, Gdk - - -css = """ -.group-view { - border-radius: 8px; - background-color: #ffffff; - box-shadow: 0 6px 6px rgba(0, 0, 0, 0.2); - margin: 6px 12px 6px 12px; -} - -.group-view:hover { - box-shadow: 0 10px 10px rgba(0, 0, 0, 0.2); -} - -.group-title { - font-size: 1.2em; - font-weight: bold; - color: #333; -} - -.group-subtitle { - font-size: 0.9em; - color: #666; -} - -.group-icon-button { - background-color: transparent; - border: none; - border-radius: 12px; - min-width: 22px; - min-height: 22px; - padding: 6px; -} - -.group-icon-button:hover { - background-color: #eee; -} - -.group-icon-button:active { - background-color: #ddd; -} - -.group-view > box > box:last-child { - padding: 12px; -} -""" - - -class GroupBox(Gtk.Box): - def __init__(self, title, subtitle): - super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) - - # Rounded corners and "Material Design" styling (basic implementation) - self.set_css_classes(["group-view"]) # Use CSS for styling - - # Add box for header, subtitle and icon - self.header_hbox = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=6 - ) - self.header_hbox.set_margin_start(12) - self.header_hbox.set_margin_end(12) - self.header_hbox.set_margin_top(12) - self.header_hbox.set_margin_bottom(6) - self.header_hbox.set_halign(Gtk.Align.FILL) - self.append(self.header_hbox) - - # Header Box (title, subtitle) - header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - header_box.set_hexpand(True) - header_box.set_valign(Gtk.Align.CENTER) - self.title_label = Gtk.Label(label=title, halign=Gtk.Align.START) - self.title_label.set_css_classes(["group-title"]) - self.subtitle_label = Gtk.Label(label=subtitle, halign=Gtk.Align.START) - self.subtitle_label.set_css_classes(["group-subtitle"]) - header_box.append(self.title_label) - header_box.append(self.subtitle_label) - self.header_hbox.append(header_box) - - # Child widget area - self.child_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.append(self.child_area) - self.set_hexpand(True) - self.set_vexpand(True) - self.apply_css() - - def apply_css(self): - provider = Gtk.CssProvider() - provider.load_from_data(css.encode()) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - def add_button(self, button): - button.set_css_classes(["group-icon-button"]) - button.set_valign(Gtk.Align.CENTER) - self.header_hbox.append(button) - - def add_child(self, widget): - self.child_area.append(widget) - - -if __name__ == "__main__": - class GroupWindow(Gtk.ApplicationWindow): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - group_widget = GroupBox(title="My Group", - subtitle="A subtitle for the group", - icon_name="help-symbolic") - self.set_child(group_widget) - self.set_default_size(300, 200) - - label = Gtk.Label(label="This is the child widget.") - group_widget.add_child(label) - - def on_activate(app): - win = GroupWindow(application=app) - win.present() - - app = Gtk.Application(application_id="org.example.groupviewexample") - app.connect('activate', on_activate) - app.run() diff --git a/rayforge/widgets/machinesettings.py b/rayforge/widgets/machinesettings.py deleted file mode 100644 index 2a4582771..000000000 --- a/rayforge/widgets/machinesettings.py +++ /dev/null @@ -1,458 +0,0 @@ -from gi.repository import Gtk, Adw -from ..driver import drivers, get_driver_cls, get_params -from ..models.machine import Laser -from ..util.adwfix import get_spinrow_int, get_spinrow_float -from .dynamicprefs import DynamicPreferencesGroup - - -class MachineSettingsDialog(Adw.PreferencesDialog): - def __init__(self, machine, **kwargs): - super().__init__(**kwargs) - self.machine = machine - - # Make the dialog resizable - self.set_size_request(-1, -1) - - # Create the "General" page (first page) - general_page = Adw.PreferencesPage( - title="General", - icon_name='preferences-system-symbolic' - ) - self.add(general_page) - - self.driver_group = DynamicPreferencesGroup(title="Driver Settings") - self.driver_group.data_changed.connect(self.on_driver_param_changed) - general_page.add(self.driver_group) - - # Driver selector - self.driver_store = Gtk.StringList() - for d in drivers: - self.driver_store.append(d.label) - driver_cls = get_driver_cls(machine.driver) - self.combo_row = Adw.ComboRow( - title=driver_cls.label if driver_cls else 'Select driver', - subtitle=driver_cls.subtitle if driver_cls else None, - model=self.driver_store - ) - self.combo_row.set_use_subtitle(True) - self.combo_row.set_subtitle(driver_cls.subtitle) - self.driver_group.add(self.combo_row) - self.driver_group.create_params(get_params(driver_cls)) - self.driver_group.set_values(machine.driver_args) - - # Set up a custom factory to display both title and subtitle - factory = Gtk.SignalListItemFactory() - factory.connect("setup", self.on_factory_setup) - factory.connect("bind", self.on_factory_bind) - self.combo_row.set_factory(factory) - - if driver_cls: - selected_index = drivers.index(driver_cls) - self.combo_row.set_selected(selected_index) - - # Connect to the "notify::selected" signal to handle selection changes - self.combo_row.connect("notify::selected", self.on_combo_row_changed) - - # Group for Machine Settings - machine_group = Adw.PreferencesGroup(title="Machine Settings") - general_page.add(machine_group) - - home_on_start_row = Adw.SwitchRow() - home_on_start_row.set_title("Home On Start") - home_on_start_row.set_subtitle( - "Whether Rayforce will send a homing command when it is started" - ) - home_on_start_row.set_active(machine.home_on_start) - home_on_start_row.connect( - 'notify::active', - self.on_home_on_start_changed - ) - machine_group.add(home_on_start_row) - - # Max Travel Speed - travel_speed_adjustment = Gtk.Adjustment( - value=self.machine.max_travel_speed, - lower=0, - upper=10000, - step_increment=1, - page_increment=10 - ) - self.travel_speed_row = Adw.SpinRow( - title="Max Travel Speed", - subtitle="Maximum travel speed in mm/min", - adjustment=travel_speed_adjustment - ) - self.travel_speed_row.connect("changed", self.on_travel_speed_changed) - machine_group.add(self.travel_speed_row) - - # Max Cut Speed - cut_speed_adjustment = Gtk.Adjustment( - value=self.machine.max_cut_speed, - lower=0, - upper=10000, - step_increment=1, - page_increment=10 - ) - self.cut_speed_row = Adw.SpinRow( - title="Max Cut Speed", - subtitle="Maximum cutting speed in mm/min", - adjustment=cut_speed_adjustment - ) - self.cut_speed_row.connect("changed", self.on_cut_speed_changed) - machine_group.add(self.cut_speed_row) - - # Dimensions - dimensions_group = Adw.PreferencesGroup(title="Dimensions") - general_page.add(dimensions_group) - - width_adjustment = Gtk.Adjustment( - value=self.machine.dimensions[0], - lower=50, - upper=10000, - step_increment=1, - page_increment=10 - ) - self.width_row = Adw.SpinRow( - title="Width", - subtitle="Width of the machine in mm", - adjustment=width_adjustment - ) - self.width_row.connect("changed", self.on_width_changed) - dimensions_group.add(self.width_row) - - height_adjustment = Gtk.Adjustment( - value=self.machine.dimensions[1], - lower=50, - upper=10000, - step_increment=1, - page_increment=10 - ) - self.height_row = Adw.SpinRow( - title="Height", - subtitle="Height of the machine in mm", - adjustment=height_adjustment - ) - self.height_row.connect("changed", self.on_height_changed) - dimensions_group.add(self.height_row) - - # Create the "GCode" page - gcode_page = Adw.PreferencesPage( - title="GCode", - icon_name='applications-engineering-symbolic' - ) - self.add(gcode_page) - - # Preamble - preamble_group = Adw.PreferencesGroup(title="Preamble") - gcode_page.add(preamble_group) - self.preamble_entry = Gtk.TextView() - self.preamble_entry.set_size_request(300, 50) - self.preamble_entry.get_buffer().set_text( - "\n".join(self.machine.preamble) - ) - self.preamble_entry.get_buffer().connect( - "changed", self.on_preamble_changed - ) - preamble_group.add(self.preamble_entry) - - # Postscript - postscript_group = Adw.PreferencesGroup(title="Postscript") - gcode_page.add(postscript_group) - self.postscript_entry = Gtk.TextView() - self.postscript_entry.set_size_request(300, 50) - self.postscript_entry.get_buffer().set_text( - "\n".join(self.machine.postscript) - ) - self.postscript_entry.get_buffer().connect( - "changed", self.on_postscript_changed - ) - postscript_group.add(self.postscript_entry) - - # Air Assist Settings - air_assist_group = Adw.PreferencesGroup(title="Air Assist") - gcode_page.add(air_assist_group) - - # Air Assist Enable - self.air_assist_on_row = Adw.EntryRow() - gcode = self.machine.air_assist_on or "" - self.air_assist_on_row.set_title( - "Air Assist Enable GCode (blank if unsupported)" - ) - self.air_assist_on_row.set_text(gcode) - self.air_assist_on_row.connect( - "changed", self.on_air_assist_on_changed - ) - air_assist_group.add(self.air_assist_on_row) - - # Air Assist Disable - self.air_assist_off_row = Adw.EntryRow() - gcode = self.machine.air_assist_off or "" - self.air_assist_off_row.set_title( - "Air Assist Disable GCode (blank if unsupported)" - ) - self.air_assist_off_row.set_text(gcode) - self.air_assist_off_row.connect( - "changed", self.on_air_assist_off_changed - ) - air_assist_group.add(self.air_assist_off_row) - - # Create the "Laser Heads" page - laserhead_page = Adw.PreferencesPage( - title="Laser Heads", - icon_name="preferences-other-symbolic" - ) - self.add(laserhead_page) - - # List of Lasers - laserhead_list_group = Adw.PreferencesGroup(title="Laser Heads") - laserhead_page.add(laserhead_list_group) - self.laserhead_list = Gtk.ListBox() - self.laserhead_list.set_selection_mode(Gtk.SelectionMode.SINGLE) - self.laserhead_list.set_show_separators(True) # Add separators - laserhead_list_group.add(self.laserhead_list) - - # Add and Remove buttons (right-aligned) - button_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=5, - halign=Gtk.Align.END - ) - add_button = Gtk.Button(icon_name="list-add-symbolic") - add_button.connect("clicked", self.on_add_laserhead) - remove_button = Gtk.Button(icon_name="list-remove-symbolic") - remove_button.connect("clicked", self.on_remove_laserhead) - button_box.append(add_button) - button_box.append(remove_button) - laserhead_list_group.add(button_box) - - # Configuration panel for the selected Laser - self.laserhead_config_group = Adw.PreferencesGroup( - title="Laser Configuration" - ) - laserhead_page.add(self.laserhead_config_group) - - max_power_adjustment = Gtk.Adjustment( - value=0, - lower=0, - upper=10000, - step_increment=1, - page_increment=10 - ) - self.max_power_row = Adw.SpinRow( - title="Max Power", - subtitle="Maximum power value in GCode", - adjustment=max_power_adjustment - ) - self.max_power_row.connect("changed", self.on_max_power_changed) - self.laserhead_config_group.add(self.max_power_row) - - frame_power_adjustment = Gtk.Adjustment( - value=0, - lower=0, - upper=100, - step_increment=1, - page_increment=10 - ) - self.frame_power_row = Adw.SpinRow( - title="Frame Power", - subtitle="Power value in Gcode to use when framing. 0 to disable", - adjustment=frame_power_adjustment - ) - self.frame_power_row.connect("changed", self.on_frame_power_changed) - self.laserhead_config_group.add(self.frame_power_row) - - spot_size_x_adjustment = Gtk.Adjustment( - value=0.1, - lower=0.01, - upper=0.2, - step_increment=0.01, - page_increment=0.05 - ) - self.spot_size_x_row = Adw.SpinRow( - title="Spot Size X", - subtitle="Size of the laser spot in the X direction", - digits=3, - adjustment=spot_size_x_adjustment - ) - self.spot_size_x_row.connect("changed", self.on_spot_size_changed) - self.laserhead_config_group.add(self.spot_size_x_row) - - spot_size_y_adjustment = Gtk.Adjustment( - value=0.1, - lower=0.01, - upper=0.2, - step_increment=0.01, - page_increment=0.05 - ) - self.spot_size_y_row = Adw.SpinRow( - title="Spot Size Y", - subtitle="Size of the laser spot in the Y direction", - digits=3, - adjustment=spot_size_y_adjustment - ) - self.spot_size_y_row.connect("changed", self.on_spot_size_changed) - self.laserhead_config_group.add(self.spot_size_y_row) - - # Connect signals - self.laserhead_list.connect("row-selected", self.on_laserhead_selected) - - # Populate the list with existing Lasers - self.populate_laserhead_list() - - def populate_laserhead_list(self): - """Populate the list of Lasers.""" - for head in self.machine.heads: - row = Adw.ActionRow(title=f"Laser (Max Power: {head.max_power})") - row.set_margin_top(5) - row.set_margin_bottom(5) - self.laserhead_list.append(row) - row = self.laserhead_list.get_row_at_index(0) - self.laserhead_list.select_row(row) - - def on_driver_param_changed(self, sender): - self.machine.set_driver_args(self.driver_group.get_values()) - - def on_factory_setup(self, factory, list_item): - row = Adw.ActionRow() - list_item.set_child(row) - - def on_factory_bind(self, factory, list_item): - index = list_item.get_position() - driver_cls = drivers[index] - row = list_item.get_child() - row.set_title(driver_cls.label) - row.set_subtitle(driver_cls.subtitle) - - def on_combo_row_changed(self, combo_row, _): - selected_index = combo_row.get_selected() - driver_cls = drivers[selected_index] - - # This is a workaround due to an Adw.ComboRow bug. - # Update the ComboRow title to reflect the selected item. - self.combo_row.set_title(driver_cls.label) - self.combo_row.set_subtitle(driver_cls.subtitle) - - self.machine.set_driver(driver_cls) - self.driver_group.create_params(get_params(driver_cls)) - - def on_add_laserhead(self, button): - """Add a new Laser to the machine.""" - new_head = Laser() - self.machine.add_head(new_head) - row = Adw.ActionRow(title=f"Laser (Max Power: {new_head.max_power})") - row.set_margin_top(5) - row.set_margin_bottom(5) - self.laserhead_list.append(row) - self.laserhead_list.select_row(row) - - def on_remove_laserhead(self, button): - """Remove the selected Laser from the machine.""" - selected_row = self.laserhead_list.get_selected_row() - if selected_row: - index = selected_row.get_index() - head = self.machine.heads[index] - self.machine.remove_head(head) - self.laserhead_list.remove(selected_row) - - def on_laserhead_selected(self, listbox, row): - """Update the configuration panel when a Laser is selected.""" - if row is not None: - index = row.get_index() - selected_head = self.machine.heads[index] - self.max_power_row.set_value(selected_head.max_power) - self.frame_power_row.set_value(selected_head.frame_power) - spot_x, spot_y = selected_head.spot_size_mm - self.spot_size_x_row.set_value(spot_x) - self.spot_size_y_row.set_value(spot_y) - - def _get_selected_laser(self): - selected_row = self.laserhead_list.get_selected_row() - if not selected_row: - return None - index = selected_row.get_index() - return self.machine.heads[index] - - def on_max_power_changed(self, spinrow): - """Update the max power of the selected Laser.""" - selected_laser = self._get_selected_laser() - if not selected_laser: - return - selected_laser.set_max_power(get_spinrow_int(spinrow)) - self.update_laserhead_list() - - def on_frame_power_changed(self, spinrow): - """Update the max power of the selected Laser.""" - selected_laser = self._get_selected_laser() - if not selected_laser: - return - selected_laser.set_frame_power(get_spinrow_int(spinrow)) - self.update_laserhead_list() - - def on_spot_size_changed(self, spinrow): - """Update the spot size of the selected Laser.""" - selected_laser = self._get_selected_laser() - if not selected_laser: - return - x = get_spinrow_float(self.spot_size_x_row) - y = get_spinrow_float(self.spot_size_y_row) - selected_laser.set_spot_size(x, y) - self.update_laserhead_list() - - def update_laserhead_list(self): - """Update the labels in the Laser list.""" - for i, row in enumerate(self.laserhead_list): - head = self.machine.heads[i] - row.set_title(f"Laser (Max Power: {head.max_power})") - - def on_preamble_changed(self, buffer): - """Update the preamble when the text changes.""" - text = buffer.get_text( - buffer.get_start_iter(), - buffer.get_end_iter(), - True - ) - self.machine.set_preamble(text.splitlines()) - - def on_postscript_changed(self, buffer): - """Update the postscript when the text changes.""" - text = buffer.get_text( - buffer.get_start_iter(), - buffer.get_end_iter(), - True - ) - self.machine.set_postscript(text.splitlines()) - - def on_air_assist_on_changed(self, entry): - """Update the air assist enable GCode when the value changes.""" - text = entry.get_text().strip() - self.machine.set_air_assist_on(text if text else None) - - def on_air_assist_off_changed(self, entry): - """Update the air assist disable GCode when the value changes.""" - text = entry.get_text().strip() - self.machine.set_air_assist_off(text if text else None) - - def on_home_on_start_changed(self, row, _): - self.machine.set_home_on_start(row.get_active()) - - def on_travel_speed_changed(self, spinrow): - """Update the max travel speed when the value changes.""" - value = get_spinrow_int(spinrow) - self.machine.set_max_travel_speed(value) - - def on_cut_speed_changed(self, spinrow): - """Update the max cut speed when the value changes.""" - value = get_spinrow_int(spinrow) - self.machine.set_max_cut_speed(value) - - def on_width_changed(self, spinrow): - """Update the width when the value changes.""" - width = get_spinrow_int(spinrow) - height = self.machine.dimensions[1] - self.machine.set_dimensions(width, height) - - def on_height_changed(self, spinrow): - """Update the height when the value changes.""" - width = self.machine.dimensions[0] - height = get_spinrow_int(spinrow) - self.machine.set_dimensions(width, height) diff --git a/rayforge/widgets/machineview.py b/rayforge/widgets/machineview.py deleted file mode 100644 index dca79c1c7..000000000 --- a/rayforge/widgets/machineview.py +++ /dev/null @@ -1,106 +0,0 @@ -from datetime import datetime -from typing import Optional -from gi.repository import Gtk, Adw, GLib -from ..driver.driver import driver_mgr, TransportStatus - - -css = """ -.terminal { - font-family: Monospace; - font-size: 10pt; -} -""" - - -class MachineView(Adw.Dialog): - def __init__(self): - super().__init__() - self.set_presentation_mode(Adw.DialogPresentationMode.BOTTOM_SHEET) - - # Main container - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - self.set_child(box) - - # WebSocket terminal-like display - self.terminal = Gtk.TextView() - self.terminal.set_editable(False) # Make it read-only - self.terminal.set_cursor_visible(False) # Hide the cursor - self.terminal.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) # Wrap text - self.terminal.set_margin_top(12) - self.terminal.set_margin_bottom(12) - self.terminal.set_margin_start(12) - self.terminal.set_margin_end(12) - - # Apply a monospace font using CSS - css_provider = Gtk.CssProvider() - css_provider.load_from_data(css) - self.terminal.get_style_context().add_provider( - css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - # Wrap the TextView in a ScrolledWindow - scrolled_window = Gtk.ScrolledWindow() - scrolled_window.set_min_content_height(400) - scrolled_window.set_child(self.terminal) - box.append(scrolled_window) - - # Listen to driver - driver = driver_mgr.driver - driver.log_received.connect(self.on_log_received) - driver.command_status_changed.connect( - self.on_command_status_changed - ) - driver.connection_status_changed.connect( - self.on_connection_status_changed - ) - - # The dialog does not support expansion. Adw 1.6 will support - # BottomSheet, at which time this widget should probably use that - # instead. But for now, it is not available in Ubuntu 24.04, - # so we need to define a fixed size. - self.set_size_request(900, -1) # Allow the dialog to expand - self.set_follows_content_size(True) - - def append_to_terminal(self, data): - # Get the current timestamp in the user's locale - timestamp = datetime.now().strftime("%x %X") - formatted_message = f"[{timestamp}] {data}\n" - - # Get the TextBuffer and insert the new message - text_buffer = self.terminal.get_buffer() - text_buffer.insert(text_buffer.get_end_iter(), formatted_message) - - # Scroll to the end of the buffer. Gtk may not have calculated the - # text dimensions yet, so we queue this using idle_add. This ensures - # that the calculations are complete. - GLib.idle_add(self._scroll_to_bottom) - - def _scroll_to_bottom(self): - text_buffer = self.terminal.get_buffer() - end_iter = text_buffer.get_end_iter() - self.terminal.scroll_to_iter(end_iter, 0.0, False, 0.0, 0.0) - return False # Ensure this callback is only run once - - def on_log_received(self, sender, message=None): - """ - Update terminal display. - """ - driver_name = sender.__class__.__name__ - self.append_to_terminal(f"{driver_name}: {message}") - - def on_command_status_changed(self, - sender, - status: TransportStatus, - message: Optional[str] = None): - self.append_to_terminal( - f"Command status changed to {status} with message: {message}" - ) - - def on_connection_status_changed(self, - sender, - status: - TransportStatus, - message: Optional[str] = None): - self.append_to_terminal( - f"Connection status changed to {status} with message: {message}" - ) diff --git a/rayforge/widgets/mainwindow.py b/rayforge/widgets/mainwindow.py deleted file mode 100644 index e617e9693..000000000 --- a/rayforge/widgets/mainwindow.py +++ /dev/null @@ -1,489 +0,0 @@ -from gi.repository import Gtk, Gio, GLib, Gdk, Adw -from .. import __version__ -from ..asyncloop import run_async -from ..config import config -from ..driver import get_driver_cls -from ..driver.driver import driver_mgr, DeviceStatus -from ..driver.dummy import NoDeviceDriver -from ..util.resources import get_icon -from ..models.doc import Doc -from ..models.workpiece import WorkPiece -from ..opsencoder.gcode import GcodeEncoder -from ..render import renderers, renderer_by_mime_type -from .workbench import WorkBench -from .workplanview import WorkPlanView -from .statusview import ConnectionStatusMonitor, \ - TransportStatus, \ - MachineStatusMonitor -from .machineview import MachineView -from .machinesettings import MachineSettingsDialog - - -css = """ -.mainpaned > separator { - border: none; - box-shadow: none; -} - -.statusbar { - border-radius: 5px; - padding: 12px; -} - -.statusbar:hover { - background-color: @theme_hover_bg_color; -} -""" - - -class MainWindow(Adw.ApplicationWindow): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.set_title("Rayforge") - - # Get the primary monitor size - display = Gdk.Display.get_default() - monitor = display.get_primary_monitor() - geometry = monitor.get_geometry() - self.set_default_size(int(geometry.width*0.6), - int(geometry.height*0.6)) - - # Define a "window quit" action. - quit_action = Gio.SimpleAction.new("quit", None) - quit_action.connect("activate", self.on_quit_action) - self.add_action(quit_action) - - # Create the main vbox - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - self.set_content(vbox) - - # Show the application header bar with hamburger menu - header_bar = Adw.HeaderBar() - vbox.append(header_bar) - - # Create a menu - menu_button = Gtk.MenuButton() - menu_button.set_icon_name("open-menu-symbolic") - menu_model = Gio.Menu() - menu_model.append("About", "win.about") - menu_model.append("Preferences", "win.settings") - menu_button.set_menu_model(menu_model) - header_bar.pack_end(menu_button) - - # Add the "about" action - about_action = Gio.SimpleAction.new("about", None) - about_action.connect("activate", self.show_about_dialog) - self.add_action(about_action) - - # Add the "quit" action - settings_action = Gio.SimpleAction.new("settings", None) - settings_action.connect("activate", self.show_machine_settings) - self.add_action(settings_action) - - # Create a toolbar - toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - toolbar.set_margin_bottom(2) - toolbar.set_margin_top(2) - toolbar.set_margin_start(12) - toolbar.set_margin_end(12) - vbox.append(toolbar) - - # Import and export icons - open_button = Gtk.Button() - open_button.set_child(get_icon('open')) - open_button.set_tooltip_text("Import image") - open_button.connect("clicked", self.on_open_clicked) - toolbar.append(open_button) - - self.export_button = Gtk.Button() - self.export_button.set_child(get_icon('publish')) - self.export_button.set_tooltip_text("Generate GCode") - self.export_button.connect("clicked", self.on_export_clicked) - toolbar.append(self.export_button) - - # Clear and visibility - sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - toolbar.append(sep) - - clear_button = Gtk.Button() - clear_button.set_child(get_icon('clear-layers')) - clear_button.set_tooltip_text("Remove all workpieces") - clear_button.connect("clicked", self.on_clear_clicked) - toolbar.append(clear_button) - - self.visibility_on_icon = get_icon('visibility-on') - self.visibility_off_icon = get_icon('visibility-off') - button = Gtk.ToggleButton() - button.set_active(True) - button.set_child(self.visibility_on_icon) - button.set_tooltip_text("Toggle workpiece visibility") - toolbar.append(button) - button.connect('clicked', self.on_button_visibility_clicked) - - # Control buttons: home, send, pause, stop - sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - toolbar.append(sep) - - self.home_button = Gtk.Button() - self.home_button.set_child(get_icon('home')) - self.home_button.set_tooltip_text("Home the machine") - self.home_button.connect("clicked", self.on_home_clicked) - toolbar.append(self.home_button) - - self.frame_button = Gtk.Button() - self.frame_button.set_child(get_icon('frame')) - self.frame_button.set_tooltip_text( - "Cycle laser head around the occupied area" - ) - self.frame_button.connect("clicked", self.on_frame_clicked) - toolbar.append(self.frame_button) - - self.send_button = Gtk.Button() - self.send_button.set_child(get_icon('send')) - self.send_button.set_tooltip_text("Send to machine") - self.send_button.connect("clicked", self.on_send_clicked) - toolbar.append(self.send_button) - - self.hold_on_icon = get_icon('play-arrow') - self.hold_off_icon = get_icon('pause') - self.hold_button = Gtk.ToggleButton() - self.hold_button.set_child(self.hold_off_icon) - self.hold_button.set_tooltip_text("Pause machine") - self.hold_button.connect("clicked", self.on_hold_clicked) - toolbar.append(self.hold_button) - - self.cancel_button = Gtk.Button() - self.cancel_button.set_child(get_icon('stop')) - self.cancel_button.set_tooltip_text("Cancel running job") - self.cancel_button.connect("clicked", self.on_cancel_clicked) - toolbar.append(self.cancel_button) - - # Create the Paned splitting the window into left and right sections. - self.paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) - vbox.append(self.paned) - - # Apply styles - self.paned.add_css_class("mainpaned") - provider = Gtk.CssProvider() - provider.load_from_data(css.encode()) - Gtk.StyleContext.add_provider_for_display( - display, - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - # Create a work area to display the image and paths - width_mm, height_mm = config.machine.dimensions - ratio = width_mm/height_mm - self.frame = Gtk.AspectFrame(ratio=ratio, obey_child=False) - self.frame.set_margin_start(12) - self.frame.set_margin_end(12) - self.frame.set_hexpand(True) - self.paned.set_start_child(self.frame) - - self.workbench = WorkBench(width_mm, height_mm) - self.workbench.set_hexpand(True) - self.frame.set_child(self.workbench) - - # Make a default document. - self.doc = Doc() - self.doc.changed.connect(self.on_doc_changed) - - # Show the work plan. - self.workplanview = WorkPlanView(self.doc.workplan) - self.workplanview.set_size_request(400, -1) - self.workplanview.set_margin_top(12) - self.workplanview.set_margin_bottom(12) - self.paned.set_end_child(self.workplanview) - self.paned.set_resize_end_child(False) - self.paned.set_shrink_end_child(False) - - # Create a status bar. - status_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - status_bar.set_halign(Gtk.Align.END) - status_bar.set_margin_end(12) - status_bar.get_style_context().add_class("statusbar") - vbox.append(status_bar) - - # Monitor machine status - label = Gtk.Label() - label.set_markup("Machine status:") - status_bar.append(label) - - self.machine_status = MachineStatusMonitor() - status_bar.append(self.machine_status) - self.machine_status.changed.connect( - self.on_machine_status_changed - ) - - # Monitor connection status - label = Gtk.Label() - label.set_markup("Connection status:") - label.set_margin_start(12) - status_bar.append(label) - - self.connection_status = ConnectionStatusMonitor() - status_bar.append(self.connection_status) - self.connection_status.changed.connect( - self.on_connection_status_changed - ) - - # Open machine log if status bar is clicked. - gesture = Gtk.GestureClick() - gesture.connect("pressed", self.on_status_bar_clicked, status_bar) - status_bar.add_controller(gesture) - - # Set up driver and config signals. - self._try_driver_setup() - config.changed.connect(self.on_config_changed) - driver_mgr.changed.connect(self.on_driver_changed) - self.needs_homing = config.machine.home_on_start - - def _try_driver_setup(self): - # Reconfigure, because params may have changed. - driver_cls = get_driver_cls(config.machine.driver) - try: - run_async(driver_mgr.select_by_cls( - driver_cls, - **config.machine.driver_args - )) - except Exception as e: - print("Failed to set up driver:", e) - return - - def on_driver_changed(self, sender, driver): - self.update_state() - - def on_machine_status_changed(self, sender): - # If the machine is idle for the first time, perform auto-homing - # if requested. - if self.needs_homing: - device_status = self.machine_status.get_status() - if device_status == DeviceStatus.IDLE: - self.needs_homing = False - run_async(driver_mgr.driver.home()) - - self.update_state() - - def on_connection_status_changed(self, sender): - self.update_state() - - def on_doc_changed(self, sender, **kwargs): - self.workbench.update(self.doc) - self.update_state() - - def on_config_changed(self, sender, **kwargs): - self.workbench.set_size(*config.machine.dimensions) - width_mm, height_mm = config.machine.dimensions - ratio = width_mm/height_mm - self.frame.set_ratio(ratio) - - # Apply selected device driver. - self._try_driver_setup() - self.workbench.update(self.doc) - self.update_state() - - def update_state(self): - device_status = self.machine_status.get_status() - - # Update button states - self.export_button.set_sensitive(self.doc.has_workpiece()) - self.home_button.set_sensitive(device_status == DeviceStatus.IDLE) - - # Frame button - can_frame = config.machine.can_frame() and self.doc.has_result() - can_frame = can_frame and device_status == DeviceStatus.IDLE - self.frame_button.set_sensitive(can_frame) - - # Send button - conn_status = self.connection_status.get_status() - if driver_mgr.driver.__class__ is NoDeviceDriver: - text = "Send to machine (select driver to enable)" - sensitive = False - elif conn_status != TransportStatus.CONNECTED: - text = "Send to machine (connect to enable)" - sensitive = False - else: - text = "Send to machine" - sensitive = True - self.send_button.set_sensitive(sensitive) - self.send_button.set_tooltip_text(text) - - # Pause button - sensitive = device_status in (DeviceStatus.RUN, DeviceStatus.HOLD) - self.hold_button.set_sensitive(sensitive) - self.hold_button.set_active(device_status == DeviceStatus.HOLD) - - # Cancel button - sensitive = device_status in ( - DeviceStatus.RUN, - DeviceStatus.HOLD, - DeviceStatus.JOG, - DeviceStatus.CYCLE, - ) - self.cancel_button.set_sensitive(sensitive) - - # Laser dot - connected = conn_status == TransportStatus.CONNECTED - self.workbench.set_laser_dot_visible(connected) - state = self.machine_status.state - if state and None not in state.machine_pos: - self.workbench.set_laser_dot_position(*state.machine_pos[:2]) - - def on_status_bar_clicked(self, gesture, n_press, x, y, box): - dialog = MachineView() - dialog.present(self) - - def on_quit_action(self, action, parameter): - self.close() - - def on_open_clicked(self, button): - # Create a file chooser dialog - dialog = Gtk.FileDialog.new() - dialog.set_title("Open SVG File") - - # Create a Gio.ListModel for the filters - filter_list = Gio.ListStore.new(Gtk.FileFilter) - all_supported = Gtk.FileFilter() - all_supported.set_name("All supported") - for renderer in renderers: - file_filter = Gtk.FileFilter() - file_filter.set_name(renderer.label) - for mime_type in renderer.mime_types: - file_filter.add_mime_type(mime_type) - all_supported.add_mime_type(mime_type) - filter_list.append(file_filter) - filter_list.append(all_supported) - - # Set the filters for the dialog - dialog.set_filters(filter_list) - dialog.set_default_filter(all_supported) - - # Show the dialog and handle the response - dialog.open(self, None, self.on_file_dialog_response) - - def on_button_visibility_clicked(self, button): - self.workbench.set_workpieces_visible(button.get_active()) - if button.get_active(): - button.set_child(self.visibility_on_icon) - else: - button.set_child(self.visibility_off_icon) - - def on_clear_clicked(self, button): - self.workbench.clear() - - def on_export_clicked(self, button): - # Create a file chooser dialog for saving the file - dialog = Gtk.FileDialog.new() - dialog.set_title("Save G-code File") - - # Set the default file name - dialog.set_initial_name("output.gcode") - - # Create a Gio.ListModel for the filters - filter_list = Gio.ListStore.new(Gtk.FileFilter) - gcode_filter = Gtk.FileFilter() - gcode_filter.set_name("G-code files") - gcode_filter.add_mime_type("text/x.gcode") - filter_list.append(gcode_filter) - - # Set the filters for the dialog - dialog.set_filters(filter_list) - dialog.set_default_filter(gcode_filter) - - # Show the dialog and handle the response - dialog.save(self, None, self.on_save_dialog_response) - - def on_home_clicked(self, button): - run_async(driver_mgr.driver.home()) - - def on_frame_clicked(self, button): - try: - head = config.machine.heads[0] - except IndexError: - return - if not head.frame_power: - return - - ops = self.doc.workplan.execute() - frame = ops.get_frame( - power=head.frame_power, - speed=config.machine.max_travel_speed - ) - frame *= 20 # cycle 20 times - run_async(driver_mgr.driver.run(frame, config.machine)) - - def on_send_clicked(self, button): - ops = self.doc.workplan.execute() - run_async(driver_mgr.driver.run(ops, config.machine)) - - def on_hold_clicked(self, button): - if button.get_active(): - run_async(driver_mgr.driver.set_hold()) - button.set_child(self.hold_on_icon) - else: - run_async(driver_mgr.driver.set_hold(False)) - button.set_child(self.hold_off_icon) - - def on_cancel_clicked(self, button): - run_async(driver_mgr.driver.cancel()) - - def on_save_dialog_response(self, dialog, result): - try: - file = dialog.save_finish(result) - if not file: - return - file_path = file.get_path() - - # Serialize the G-code - encoder = GcodeEncoder() - ops = self.doc.workplan.execute() - gcode = encoder.encode(ops, config.machine) - - # Write the G-code to the file - with open(file_path, 'w') as f: - f.write(gcode) - except GLib.Error as e: - print(f"Error saving file: {e.message}") - - def on_file_dialog_response(self, dialog, result): - try: - # Get the selected file - file = dialog.open_finish(result) - if file: - # Load the SVG file and convert it to a grayscale surface - file_path = file.get_path() - file_info = file.query_info( - Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, - Gio.FileQueryInfoFlags.NONE, - None - ) - mime_type = file_info.get_content_type() - self.load_file(file_path, mime_type) - except GLib.Error as e: - print(f"Error opening file: {e.message}") - - def load_file(self, filename, mime_type): - renderer = renderer_by_mime_type[mime_type] - wp = WorkPiece.from_file(filename, renderer) - self.doc.add_workpiece(wp) - self.workbench.update(self.doc) - self.update_state() - - def show_about_dialog(self, action, param): - about_dialog = Adw.AboutDialog( - application_name="Rayforge", - application_icon="com.barebaric.rayforge", - developer_name="Barebaric", - version=__version__ or 'unknown', - copyright="© 2025 Samuel Abels", - website="https://github.com/barebaric/rayforge", - issue_url="https://github.com/barebaric/rayforge/issues", - developers=["Samuel Abels"], - license_type=Gtk.License.MIT_X11 - ) - about_dialog.present(self) - - def show_machine_settings(self, action, param): - dialog = MachineSettingsDialog(config.machine) - dialog.present(self) diff --git a/rayforge/widgets/roundbutton.py b/rayforge/widgets/roundbutton.py deleted file mode 100644 index a8b594c79..000000000 --- a/rayforge/widgets/roundbutton.py +++ /dev/null @@ -1,49 +0,0 @@ -from gi.repository import Gtk - - -css = """ -button.round-button { - min-width: 64px; - min-height: 64px; - border-radius: 32px; - padding: 0; - margin: 12px; - background-color: @theme_selected_bg_color; /* Material primary color */ - color: @theme_selected_fg_color; - font-size: 24px; - border: none; - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), - 0 3px 6px rgba(0, 0, 0, 0.23); /* Shadow for depth */ - transition: background-color 0.2s, box-shadow 0.2s; -} - -button.round-button:hover { - background-color: shade(@theme_selected_bg_color, 0.9); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.19), - 0 6px 12px rgba(0, 0, 0, 0.23); /* Enhanced shadow on hover */ -} - -button.round-button:active { - background-color: shade(@theme_selected_bg_color, 1.1); /* Lighter shade */ - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.16), - 0 2px 4px rgba(0, 0, 0, 0.23); /* Reduced shadow on click */ -} -""" - - -class RoundButton(Gtk.Button): - def __init__(self, label, **kwargs): - super().__init__(**kwargs) - self.apply_css() - self.set_label(label) - self.set_halign(Gtk.Align.CENTER) - - def apply_css(self): - css_provider = Gtk.CssProvider() - css_provider.load_from_data(css.encode()) - style_context = self.get_style_context() - style_context.add_provider( - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - style_context.add_class("round-button") diff --git a/rayforge/widgets/statusview.py b/rayforge/widgets/statusview.py deleted file mode 100644 index 4e3f2515b..000000000 --- a/rayforge/widgets/statusview.py +++ /dev/null @@ -1,197 +0,0 @@ -from gi.repository import Gtk -from typing import Optional -from blinker import Signal -from ..transport.transport import TransportStatus -from ..driver.driver import driver_mgr, DeviceState, DeviceStatus -from ..driver.dummy import NoDeviceDriver -from ..util.resources import get_icon - - -class ConnectionStatusIconWidget(Gtk.Box): - def __init__(self): - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - - # Create an image widget to display the status icon - self.status_image = Gtk.Image() - self.append(self.status_image) - - # Set the initial status - self.set_status(TransportStatus.DISCONNECTED) - - def set_status(self, status): - """Update the status icon based on the given status.""" - icon_name = self._get_icon_name_for_status(status) - self.status_image.set_from_icon_name(icon_name) - - def _get_icon_name_for_status(self, status): - """Map the status to an appropriate icon name.""" - if status == TransportStatus.UNKNOWN: - return "network-error-symbolic" - elif status == TransportStatus.IDLE: - return "network-idle-symbolic" - elif status == TransportStatus.CONNECTING: - return "network-transmit-receive-symbolic" - elif status == TransportStatus.CONNECTED: - return "network-wired-symbolic" - elif status == TransportStatus.ERROR: - return "network-error-symbolic" - elif status == TransportStatus.CLOSING: - return "network-offline-symbolic" - elif status == TransportStatus.DISCONNECTED: - return "network-offline-symbolic" - elif status == TransportStatus.SLEEPING: - return "network-offline-symbolic" - else: - return "network-offline-symbolic" # Default icon - - -class MachineStatusIconWidget(Gtk.Box): - def __init__(self): - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - - # Create an image widget to display the status icon - self.status_image = Gtk.Image() - self.append(self.status_image) - - # Set the initial status - self.set_status(DeviceStatus.UNKNOWN) - - def set_status(self, status): - """Update the status icon based on the given status.""" - self.remove(self.status_image) - self.status_image = self._get_icon_for_status(status) - self.append(self.status_image) - - def _get_icon_for_status(self, status): - """Map the status to an appropriate icon name.""" - if status == DeviceStatus.UNKNOWN: - return get_icon("question-box") - elif status == DeviceStatus.IDLE: - return get_icon("check-circle") - elif status == DeviceStatus.RUN: - return get_icon("laser-path") - elif status == DeviceStatus.HOLD: - return get_icon("pause") - elif status == DeviceStatus.JOG: - return get_icon("fast-forward") - elif status == DeviceStatus.ALARM: - return get_icon("siren") - elif status == DeviceStatus.DOOR: - return get_icon("door") - elif status == DeviceStatus.CHECK: - return get_icon("preliminary-check") - elif status == DeviceStatus.HOME: - return get_icon("homing") - elif status == DeviceStatus.SLEEP: - return get_icon("sleep") - elif status == DeviceStatus.TOOL: - return get_icon("tool-change") - elif status == DeviceStatus.QUEUE: - return get_icon("queued") - elif status == DeviceStatus.LOCK: - return get_icon("locked") - elif status == DeviceStatus.UNLOCK: - return get_icon("unlocking") - elif status == DeviceStatus.CYCLE: - return get_icon("cycle") - elif status == DeviceStatus.TEST: - return get_icon("test") - else: - return Gtk.Image.new_from_icon_name("network-offline-symbolic") - - -class StatusWidget(Gtk.Box): - def __init__(self, icon_widget, default_status): - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - - self.label = Gtk.Label() - self.append(self.label) - - self.icon = icon_widget - self.append(self.icon) - - self.set_status(default_status) - - def set_status(self, status): - self.icon.set_status(status) - if status is None: - self.label.set_label("No driver selected") - else: - self.label.set_label(status.name) - - -class ConnectionStatusMonitor(StatusWidget): - def __init__(self): - self.changed = Signal() - self.status = TransportStatus.UNKNOWN - super().__init__(ConnectionStatusIconWidget(), self.status) - - driver_mgr.changed.connect(self.on_driver_changed) - self.on_driver_changed(driver_mgr, driver_mgr.driver) - - def on_driver_changed(self, manager, driver): - nodriver = driver is None or driver.__class__ == NoDeviceDriver - self.set_status(None if nodriver else DeviceStatus.UNKNOWN) - if driver is None: - return - - # The driver may be new, or it may just have been reconfigured. - # So we disconnect the signal in case it was already connected. - driver.connection_status_changed.disconnect( - self.on_connection_status_changed - ) - driver.connection_status_changed.connect( - self.on_connection_status_changed - ) - - def on_connection_status_changed(self, - driver, - status: TransportStatus, - message: Optional[str] = None): - nodriver = driver_mgr.driver.__class__ == NoDeviceDriver - self.set_status(None if nodriver else status) - - def set_status(self, status): - self.status = status - super().set_status(status) - self.changed.send(self) - - def get_status(self): - return self.status - - -class MachineStatusMonitor(StatusWidget): - def __init__(self): - self.changed = Signal() - self.status = DeviceStatus.UNKNOWN - self.state = None - super().__init__(MachineStatusIconWidget(), self.status) - - driver_mgr.changed.connect(self.on_driver_changed) - self.on_driver_changed(self, driver_mgr.driver) # trigger update - - def on_driver_changed(self, sender, driver): - nodriver = driver is None or driver.__class__ == NoDeviceDriver - self.set_status(None if nodriver else DeviceStatus.UNKNOWN) - if driver is None: - return - - # The driver may be new, or it may just have been reconfigured. - # So we disconnect the signal in case it was already connected. - driver.state_changed.disconnect(self.on_driver_state_changed) - driver.state_changed.connect(self.on_driver_state_changed) - - def on_driver_state_changed(self, - driver, - state: DeviceState): - self.state = state - nodriver = driver_mgr.driver.__class__ == NoDeviceDriver - self.set_status(None if nodriver else state.status) - - def set_status(self, status): - self.status = status - super().set_status(status) - self.changed.send(self) - - def get_status(self): - return self.status diff --git a/rayforge/widgets/stepselector.py b/rayforge/widgets/stepselector.py deleted file mode 100644 index e6a6e6f4e..000000000 --- a/rayforge/widgets/stepselector.py +++ /dev/null @@ -1,47 +0,0 @@ -from gi.repository import Gtk, Gdk - - -css = """ -.workstep-selector-label { - font-family: 'Roboto', sans-serif; - font-size: 14px; - margin: 12px; -} -""" - - -class WorkStepSelector(Gtk.Popover): - def __init__(self, workstep_classes, **kwargs): - super().__init__(**kwargs) - self.set_autohide(True) - self.selected = None - - # Create a ListBox inside the Popover - self.listbox = Gtk.ListBox() - self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) - self.set_child(self.listbox) - - provider = Gtk.CssProvider() - provider.load_from_data(css.encode()) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - # Add workstep_classes to the ListBox - for cls in workstep_classes: - label = Gtk.Label(label=cls.typelabel) - label.set_xalign(0) - label.add_css_class("workstep-selector-label") - row = Gtk.ListBoxRow() - row.set_child(label) - row.cls = cls - self.listbox.append(row) - - # Connect the row-activated signal to handle cls selection - self.listbox.connect("row-activated", self.on_row_activated) - - def on_row_activated(self, listbox, row): - self.selected = row.cls - self.popdown() diff --git a/rayforge/widgets/workbench.py b/rayforge/widgets/workbench.py deleted file mode 100644 index eaace502e..000000000 --- a/rayforge/widgets/workbench.py +++ /dev/null @@ -1,150 +0,0 @@ -from gi.repository import Gtk, Graphene -import cairo -from .worksurface import WorkSurface, WorkPieceElement, WorkStepElement - - -class Axis(Gtk.DrawingArea): - """ - This widget displays a simple axis line with labels. - """ - def __init__(self, - length_mm=100, - orientation=Gtk.Orientation.HORIZONTAL, - thickness=None, - **kwargs): - super().__init__(**kwargs) - self.orientation = orientation - self.length_mm = length_mm - self.grid_size = 10 # in mm - self.stroke = 1 - self.label_padding = 2 - - # We need a temporary context to figure out the label size. - temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 0, 0) - temp_context = cairo.Context(temp_surface) - label = f"{self.length_mm}" - extents = temp_context.text_extents(label) - - if self.orientation == Gtk.Orientation.HORIZONTAL: - self.thickness = thickness \ - or extents.height+2*self.label_padding+self.stroke - self.set_size_request(-1, self.thickness) - else: - self.thickness = thickness \ - or extents.width+2*self.label_padding+self.stroke - self.set_size_request(self.thickness, -1) - - def set_length(self, length_mm): - self.length_mm = length_mm - self.queue_draw() - - def do_snapshot(self, snapshot): - # Calculate size in pixels. - if self.orientation == Gtk.Orientation.HORIZONTAL: - length = self.get_width() - start = 0, 0 - end = length, 0 - width, height = length, self.thickness - else: - length = self.get_height() - start = self.thickness, 0 - end = self.thickness, length - width, height = self.thickness, length - - # Create a Cairo context for the snapshot - ctx = snapshot.append_cairo( - Graphene.Rect().init(0, 0, width, height) - ) - - # Draw axis line. - ctx.set_line_width(self.stroke) - ctx.set_source_rgb(0, 0, 0) - ctx.move_to(*start) - ctx.line_to(*end) - ctx.stroke() - - # Draw axis labels - interval = self.grid_size - for pos in range(interval, int(self.length_mm)+1, interval): - pos_px = int(pos*length/self.length_mm) - label = f"{pos}" - extents = ctx.text_extents(label) - if self.orientation == Gtk.Orientation.HORIZONTAL: - if pos_px+int(extents.width/2) >= length: - pos_px -= int(extents.width/2) - ctx.move_to(pos_px-int(extents.width/2), - self.stroke+self.label_padding+extents.height) - else: - if height-pos_px <= 0: - pos_px -= int(extents.height/2) - ctx.move_to(width-self.stroke-self.label_padding-extents.width, - height-pos_px+int(extents.height/2)) - ctx.show_text(label) - - -class WorkBench(Gtk.Grid): - """ - A WorkBench wraps the WorkSurface to add an X and Y axis. - """ - def __init__(self, width_mm, height_mm, **kwargs): - super().__init__(**kwargs) - self.axis_thickness = 25 - self.doc = None - - # Create a work area to display the image and paths - self.surface = WorkSurface(width_mm=width_mm, height_mm=height_mm) - self.surface.set_hexpand(True) - self.surface.set_vexpand(True) - self.surface.set_halign(Gtk.Align.FILL) - self.surface.set_valign(Gtk.Align.FILL) - self.attach(self.surface, 1, 0, 1, 1) - self.surface.elem_removed.connect(self.on_elem_removed) - - # Add the X axis - self.axis_x = Axis(width_mm, - thickness=self.axis_thickness, - orientation=Gtk.Orientation.HORIZONTAL) - self.attach(self.axis_x, 1, 1, 1, 1) - - # Add the Y axis - self.axis_y = Axis(height_mm, - thickness=self.axis_thickness, - orientation=Gtk.Orientation.VERTICAL) - self.attach(self.axis_y, 0, 0, 1, 1) - - def set_size(self, width_mm, height_mm): - self.surface.set_size(width_mm, height_mm) - self.axis_x.set_length(width_mm) - self.axis_y.set_length(height_mm) - - def set_workpieces_visible(self, visible=True): - self.surface.set_workpieces_visible(visible) - - def set_laser_dot_visible(self, visible): - self.surface.set_laser_dot_visible(visible) - - def set_laser_dot_position(self, x_mm, y_mm): - self.surface.set_laser_dot_position(x_mm, y_mm) - - def clear(self): - self.surface.clear_workpieces() - - def update(self, doc): - self.doc = doc - - # Remove anything from the canvas that no longer exists. - for elem in self.surface.find_by_type(WorkStepElement): - if elem.data not in doc.workplan: - elem.remove() - for elem in self.surface.find_by_type(WorkPieceElement): - if elem.data not in doc: - elem.remove() - - # Add any new elements. - for workpiece in doc.workpieces: - self.surface.add_workpiece(workpiece) - for workstep in doc.workplan: - self.surface.add_workstep(workstep) - - def on_elem_removed(self, parent, child): - self.doc.remove_workpiece(child.data) diff --git a/rayforge/widgets/workplanview.py b/rayforge/widgets/workplanview.py deleted file mode 100644 index 0acae07ee..000000000 --- a/rayforge/widgets/workplanview.py +++ /dev/null @@ -1,78 +0,0 @@ -from gi.repository import Gtk, Gdk -from ..models.workplan import WorkPlan, WorkStep -from .draglist import DragListBox -from .workstepbox import WorkStepBox -from .stepselector import WorkStepSelector -from .roundbutton import RoundButton - - -css = """ -.workplan { - background-color: #ffffff; - border-radius: 8px; - margin: 0; - box-shadow: 0 8px 8px rgba(0, 0, 0, 0.1); -} -""" - - -class WorkPlanView(Gtk.ScrolledWindow): - def __init__(self, workplan: WorkPlan, **kwargs): - super().__init__(**kwargs) - self.add_css_class("workplan") - self.apply_css() - self.workplan = workplan - - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - self.set_child(self.box) - - self.draglist = DragListBox() - self.draglist.reordered.connect(self.on_workplan_reordered) - self.box.append(self.draglist) - self.workplan.changed.connect(self.on_workplan_changed) - - # Add "+" button - button = RoundButton("+") - button.connect("clicked", self.on_button_add_clicked) - self.box.append(button) - - self.update() - - def apply_css(self): - provider = Gtk.CssProvider() - provider.load_from_data(css.encode()) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - def update(self): - self.draglist.remove_all() - for seq, step in enumerate(self.workplan, start=1): - row = Gtk.ListBoxRow() - row.data = step - self.draglist.add_row(row) - workstepbox = WorkStepBox(step, prefix=f"Step {seq}: ") - workstepbox.delete_clicked.connect(self.on_button_delete_clicked) - row.set_child(workstepbox) - - def on_button_add_clicked(self, button): - popup = WorkStepSelector(WorkStep.__subclasses__()) - popup.set_parent(button) - popup.popup() - popup.connect("closed", self.on_add_dialog_response) - return - - def on_add_dialog_response(self, popup): - if popup.selected: - self.workplan.add_workstep(popup.selected()) - - def on_button_delete_clicked(self, sender, workstep, **kwargs): - self.workplan.remove_workstep(workstep) - - def on_workplan_changed(self, sender, **kwargs): - self.update() - - def on_workplan_reordered(self, sender, **kwargs): - self.workplan.set_worksteps([row.data for row in self.draglist]) diff --git a/rayforge/widgets/workstepbox.py b/rayforge/widgets/workstepbox.py deleted file mode 100644 index 1865ee74b..000000000 --- a/rayforge/widgets/workstepbox.py +++ /dev/null @@ -1,86 +0,0 @@ -from gi.repository import Gtk -from blinker import Signal -from ..models.workpiece import WorkPiece -from ..models.workplan import WorkStep -from ..util.resources import get_icon_path -from .groupbox import GroupBox -from .workstepsettings import WorkStepSettingsDialog - - -class WorkStepBox(GroupBox): - def __init__(self, workstep: WorkStep, prefix=''): - super().__init__(workstep.name, workstep.get_summary()) - self.workstep = workstep - self.prefix = prefix - self.delete_clicked = Signal() - - self.visibility_on_icon = Gtk.Image.new_from_file( - get_icon_path('visibility-on') - ) - self.visibility_off_icon = Gtk.Image.new_from_file( - get_icon_path('visibility-off') - ) - button = Gtk.ToggleButton() - button.set_active(workstep.visible) - button.set_child(self.visibility_on_icon) - self.add_button(button) - button.connect('clicked', self.on_button_view_click) - self.on_button_view_click(button) - - icon = Gtk.Image.new_from_file(get_icon_path('settings')) - button = Gtk.Button() - button.set_child(icon) - self.add_button(button) - button.connect('clicked', self.on_button_properties_clicked) - - icon = Gtk.Image.new_from_file(get_icon_path('delete')) - button = Gtk.Button() - button.set_child(icon) - self.add_button(button) - button.connect('clicked', self.on_button_delete_clicked) - - self.on_workstep_changed(self.workstep) # trigger label update - # TODO: self.add_child(thumbnail) - - def set_prefix(self, prefix): - self.prefix = prefix - - def on_workstep_changed(self, sender, **kwargs): - self.title_label.set_label(f"{self.prefix}{self.workstep.name}") - self.subtitle_label.set_label(self.workstep.get_summary()) - - def on_button_view_click(self, button): - self.workstep.set_visible(button.get_active()) - if button.get_active(): - button.set_child(self.visibility_on_icon) - else: - button.set_child(self.visibility_off_icon) - - def on_button_properties_clicked(self, button): - dialog = WorkStepSettingsDialog(self.workstep) - dialog.present(self) - dialog.changed.connect(self.on_workstep_changed) - - def on_button_delete_clicked(self, button): - self.delete_clicked.send(self, workstep=self.workstep) - - -if __name__ == "__main__": - class TestWindow(Gtk.ApplicationWindow): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - workstep = WorkStep('My test workstep') - workstep.add_workpiece(WorkPiece('Item one')) - - box = WorkStepBox(workstep) - self.set_child(box) - self.set_default_size(300, 200) - - def on_activate(app): - win = TestWindow(application=app) - win.present() - - app = Gtk.Application(application_id="org.example.groupviewexample") - app.connect('activate', on_activate) - app.run() diff --git a/rayforge/widgets/workstepsettings.py b/rayforge/widgets/workstepsettings.py deleted file mode 100644 index e5d3317a5..000000000 --- a/rayforge/widgets/workstepsettings.py +++ /dev/null @@ -1,112 +0,0 @@ -from gi.repository import Gtk, Adw -from blinker import Signal -from ..config import config -from ..util.adwfix import get_spinrow_int - - -class WorkStepSettingsDialog(Adw.PreferencesDialog): - def __init__(self, workstep, **kwargs): - super().__init__(**kwargs) - self.workstep = workstep - self.set_title(f"{workstep.name} Settings") - - # Create a preferences page - page = Adw.PreferencesPage() - self.add(page) - - # Create a preferences group - group = Adw.PreferencesGroup() - page.add(group) - - # Add a spin row for cut speed - passes_row = Adw.SpinRow( - title="Number of Passes", - subtitle="How often to repeat this workstep", - adjustment=Gtk.Adjustment( - value=workstep.passes, - lower=1, - upper=100, - step_increment=1, - page_increment=10 - ) - ) - passes_row.connect('changed', self.on_passes_changed) - group.add(passes_row) - - # Add a slider for power - power_row = Adw.ActionRow(title="Power (%)") - power_scale = Gtk.Scale( - orientation=Gtk.Orientation.HORIZONTAL, - adjustment=Gtk.Adjustment( - value=workstep.power/workstep.laser.max_power*100, - upper=100, - step_increment=1, - page_increment=10 - ), - digits=0, # No decimal places - draw_value=True # Show the current value - ) - power_scale.set_size_request(300, -1) - power_scale.connect('value-changed', self.on_power_changed) - power_row.add_suffix(power_scale) - group.add(power_row) - - # Add a spin row for cut speed - cut_speed_row = Adw.SpinRow( - title="Cut Speed (mm/min)", - subtitle=f"Max: {config.machine.max_cut_speed} mm/min", - adjustment=Gtk.Adjustment( - value=workstep.cut_speed, - lower=0, - upper=config.machine.max_cut_speed, - step_increment=1, - page_increment=100 - ) - ) - cut_speed_row.connect('changed', self.on_cut_speed_changed) - group.add(cut_speed_row) - - # Add a spin row for travel speed - travel_speed_row = Adw.SpinRow( - title="Travel Speed (mm/min)", - subtitle=f"Max: {config.machine.max_travel_speed} mm/min", - adjustment=Gtk.Adjustment( - value=workstep.travel_speed, - lower=0, - upper=config.machine.max_travel_speed, - step_increment=1, - page_increment=100 - ) - ) - travel_speed_row.connect('changed', self.on_travel_speed_changed) - group.add(travel_speed_row) - - # Add a switch for air assist - air_assist_row = Adw.SwitchRow() - air_assist_row.set_title("Air Assist") - air_assist_row.set_active(workstep.air_assist) - air_assist_row.connect('notify::active', self.on_air_assist_changed) - group.add(air_assist_row) - - self.changed = Signal() - - def on_passes_changed(self, spin_row): - self.workstep.set_passes(get_spinrow_int(spin_row)) - self.changed.send(self) - - def on_power_changed(self, scale): - max_power = self.workstep.laser.max_power - self.workstep.set_power(max_power/100*scale.get_value()) - self.changed.send(self) - - def on_cut_speed_changed(self, spin_row): - self.workstep.cut_speed = get_spinrow_int(spin_row) - self.changed.send(self) - - def on_travel_speed_changed(self, spin_row): - self.workstep.travel_speed = get_spinrow_int(spin_row) - self.changed.send(self) - - def on_air_assist_changed(self, row, _): - self.workstep.air_assist = row.get_active() - self.changed.send(self) diff --git a/rayforge/widgets/worksurface.py b/rayforge/widgets/worksurface.py deleted file mode 100644 index 13ce556d8..000000000 --- a/rayforge/widgets/worksurface.py +++ /dev/null @@ -1,318 +0,0 @@ -import math -from gi.repository import Graphene -import cairo -from ..opsencoder.cairoencoder import CairoEncoder -from ..config import config -from ..models.workpiece import WorkPiece -from ..models.workplan import WorkStep -from .canvas import Canvas, CanvasElement - - -def _copy_surface(source, target, width, height, clip): - in_width, in_height = source.get_width(), source.get_height() - scale_x = width/in_width - scale_y = height/in_height - ctx = cairo.Context(target) - clip_x, clip_y, clip_w, clip_h = clip - ctx.rectangle(0, 0, clip_x+clip_w, clip_y+clip_h) - ctx.clip() - ctx.scale(scale_x, scale_y) - ctx.set_source_surface(source, clip_x, clip_y) - ctx.paint() - return target - - -class WorkPieceElement(CanvasElement): - """ - WorkPieceElements display WorkPiece objects on the WorkSurface. - This is the "standard" element used to display workpieces on the - WorkSurface. - """ - - def __init__(self, workpiece, x_mm, y_mm, width_mm, height_mm, **kwargs): - super().__init__(x_mm, - y_mm, - width_mm, - height_mm, - data=workpiece, - **kwargs) - - def set_pos(self, x_mm, y_mm): - super().set_pos(x_mm, y_mm) - self.data.set_pos(x_mm, y_mm) - - def set_size(self, width_mm, height_mm): - super().set_size(width_mm, height_mm) - self.data.set_size(width_mm, height_mm) - self.allocate() - self.dirty = True - - def render(self, clip): - assert self.surface is not None - pixels_per_mm_x, pixels_per_mm_y = self.get_pixels_per_mm() - workpiece = self.data - surface, changed = workpiece.render(pixels_per_mm_x, - pixels_per_mm_y, - workpiece.size) - if not changed: - return - width, height = self.size_px() - self.surface = _copy_surface(surface, - self.surface, - width, - height, - clip) - - -class WorkPieceOpsElement(CanvasElement): - def __init__(self, workpiece, x_mm, y_mm, width_mm, height_mm, - **kwargs): - super().__init__(x_mm, - y_mm, - width_mm, - height_mm, - data=workpiece, - selectable=False, - **kwargs) - workpiece.changed.connect(self._on_workpiece_changed) - self.ops = None - - def _on_workpiece_changed(self, workpiece: WorkPiece): - self.set_pos(*workpiece.pos) - self.set_size(*workpiece.size) - self.allocate() - self.canvas.queue_draw() - - def render(self, clip): - super().render(clip) - if not self.parent: - return - - # Replace the current bitmap by the rendered Ops. - self.clear_surface() - workstep = self.parent.data - ops = workstep.get_ops(self.data) - if ops is None: - return - pixels_per_mm = self.get_pixels_per_mm() - encoder = CairoEncoder() - encoder.encode(ops, config.machine, self.surface, pixels_per_mm) - - -class WorkStepElement(CanvasElement): - """ - WorkStepElements display the result of a WorkStep on the - WorkSurface. The output represents the laser path. - """ - def __init__(self, workstep, x_mm, y_mm, width_mm, height_mm, **kwargs): - super().__init__(x_mm, - y_mm, - width_mm, - height_mm, - data=workstep, - selectable=False, - **kwargs) - workstep.changed.connect(self._on_workstep_changed) - workstep.ops_changed.connect(self._on_ops_changed) - for workpiece in workstep.workpieces(): - self.add_workpiece(workpiece) - - def add_workpiece(self, workpiece): - elem = self.find_by_data(workpiece) - if elem: - elem.dirty = True - return elem - elem = WorkPieceOpsElement(workpiece, - *workpiece.pos, - *workpiece.size, - canvas=self.canvas, - parent=self) - self.add(elem) - return elem - - def _on_workstep_changed(self, step: WorkStep): - for elem in self.children: - if elem.data not in step.workpieces(): - elem.remove() - # We do not need to add new workpieces here, because they are - # dynamically added once the Ops is ready in _on_ops_changed() - - def _on_ops_changed(self, sender: WorkStep, workpiece: WorkPiece): - ops = self.data.get_ops(workpiece) - if not ops: - return None - self.add_workpiece(workpiece) - self.dirty = True - self.canvas.queue_draw() - - -class LaserDotElement(CanvasElement): - """ - Draws a simple red dot. - """ - def __init__(self, radius_mm, **kwargs): - self.radius_mm = radius_mm - super().__init__(0, - 0, - 2*radius_mm, - 2*radius_mm, - visible=True, - selectable=False, - **kwargs) - - def render(self, clip): - super().render(clip) - if not self.parent: - return - - self.clear_surface() - pixels_per_mm_x, _ = self.get_pixels_per_mm() - ctx = cairo.Context(self.surface) - ctx.set_hairline(True) - ctx.set_source_rgb(.9, 0, 0) - radius = self.width_mm/2*pixels_per_mm_x - ctx.arc(radius, radius, radius-1, 0., 2*math.pi) - ctx.fill() - - -class WorkSurface(Canvas): - """ - The WorkSurface displays a grid area with WorkPieces and - WorkPieceOpsElements according to real world dimensions. - """ - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.workpiece_elements = CanvasElement( - *self.root.rect(), - selectable=False - ) - self.root.add(self.workpiece_elements) - self.laser_dot = LaserDotElement(1) - self.set_laser_dot_position(0, 0) - self.root.add(self.laser_dot) - self.grid_size = 10 # in mm - self.update() - - def set_size(self, width_mm, height_mm): - self.root.set_size(width_mm, height_mm) - for elem in self.find_by_type(WorkStepElement): - elem.set_size(width_mm, height_mm) - self.update() - - def update(self): - self.aspect_ratio = self.root.width_mm/self.root.height_mm - self.workpiece_elements.set_size(self.root.width_mm, - self.root.height_mm) - self.root.allocate() - self.queue_draw() - - def add_workstep(self, workstep): - """ - Adds the workstep, but only if it does not yet exist. - Also adds each of the WorkPieces, but only if they - do not exist. - """ - # Add or find the WorkStep. - if not self.find_by_data(workstep): - elem = WorkStepElement(workstep, - *self.root.rect(), - canvas=self, - parent=self.root) - self.add(elem) - workstep.changed.connect(self.on_workstep_changed) - self.queue_draw() - - def set_laser_dot_visible(self, visible=True): - self.laser_dot.set_visible(visible) - self.queue_draw() - - def set_laser_dot_position(self, x_mm, y_mm): - height_mm = self.size()[1] - dot_radius_mm = self.laser_dot.radius_mm - self.laser_dot.set_pos(x_mm-dot_radius_mm, - height_mm-y_mm-dot_radius_mm) - self.queue_draw() - - def on_workstep_changed(self, workstep, **kwargs): - elem = self.find_by_data(workstep) - if not elem: - return - elem.set_visible(workstep.visible) - self.queue_draw() - - def add_workpiece(self, workpiece): - """ - Adds a workpiece. - """ - if self.workpiece_elements.find_by_data(workpiece): - self.queue_draw() - return - width_mm, height_mm = workpiece.get_default_size() - elem = WorkPieceElement(workpiece, - self.root.width_mm/2-width_mm/2, - self.root.height_mm/2-height_mm/2, - width_mm, - height_mm) - self.workpiece_elements.add(elem) - self.queue_draw() - - def clear_workpieces(self): - self.workpiece_elements.clear() - self.queue_draw() - - def clear(self): - self.root.clear() - self.queue_draw() - - def find_by_type(self, thetype): - return [c for c in self.root.children if isinstance(c, thetype)] - - def set_workpieces_visible(self, visible=True): - self.workpiece_elements.set_visible(visible) - self.queue_draw() - - def do_snapshot(self, snapshot): - # Create a Cairo context for the snapshot - width, height = self.get_width(), self.get_height() - bounds = Graphene.Rect().init(0, 0, width, height) - ctx = snapshot.append_cairo(bounds) - - self.pixels_per_mm_x = width/self.root.width_mm - self.pixels_per_mm_y = height/self.root.height_mm - self._draw_grid(ctx, width, height) - - # The tree of elements in the canvas looks like this: - # root (CanvasElement) - # workpieces (CanvasElement) - # workpiece (WorkPieceElement) - # ... (WorkPieceElement) - # workstep (WorkStepElement) - # ... (WorkStepElement) - # When a workpiece moves or is resized, we need to ensure - # that the worksteps update in sync with them. - # For now, to achieve that we force worksteps to update - # always, by marking them dirty. - for elem in self.find_by_type(WorkStepElement): - elem.dirty = True - - super().do_snapshot(snapshot) - - def _draw_grid(self, ctx, width, height): - """ - Draw scales on the X and Y axes. - """ - # Draw vertical lines - for x in range(0, int(self.root.width_mm)+1, self.grid_size): - x_px = x*self.pixels_per_mm_x - ctx.move_to(x_px, 0) - ctx.line_to(x_px, height) - ctx.set_source_rgb(.9, .9, .9) - ctx.stroke() - - # Draw horizontal lines - for y in range(int(self.root.height_mm), -1, -self.grid_size): - y_px = y*self.pixels_per_mm_y - ctx.move_to(0, y_px) - ctx.line_to(width, y_px) - ctx.set_source_rgb(.9, .9, .9) - ctx.stroke() diff --git a/rayforge/worker_init.py b/rayforge/worker_init.py new file mode 100644 index 000000000..aa32138ad --- /dev/null +++ b/rayforge/worker_init.py @@ -0,0 +1,53 @@ +import builtins +import logging +import os +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def initialize_worker(shared_state=None): + """ + Sets up minimal environment required for a worker subprocess. + + Installs a fallback gettext translator and platform-specific + dynamic library paths for PyInstaller bundles. + """ + # Install a fallback gettext translator. This ensures the '_' + # function exists during the module import phase. + if not hasattr(builtins, "_"): + builtins._ = lambda s: s # type: ignore[attr-defined] + + if hasattr(sys, "_MEIPASS") and sys.platform == "darwin": + # macOS PyInstaller bundles require specific environment variables + # for dynamic linking and GObject Introspection to work correctly + # in worker subprocesses. + frameworks_dir = Path(sys._MEIPASS).parent / "Frameworks" + lib_path = str(frameworks_dir) + # DYLD_LIBRARY_PATH: Directories for dynamic linker to search + existing_dyld = os.environ.get("DYLD_LIBRARY_PATH") + os.environ["DYLD_LIBRARY_PATH"] = ( + lib_path if not existing_dyld else f"{lib_path}:{existing_dyld}" + ) + # DYLD_FALLBACK_LIBRARY_PATH: Fallback if DYLD_LIBRARY_PATH fails + os.environ.setdefault("DYLD_FALLBACK_LIBRARY_PATH", lib_path) + # GI_TYPELIB_PATH: Path to GObject Introspection typelib files + bundled_typelibs = frameworks_dir / "gi_typelibs" + if bundled_typelibs.exists(): + os.environ["GI_TYPELIB_PATH"] = str(bundled_typelibs.resolve()) + # GIO_EXTRA_MODULES: Path to additional GIO modules + bundled_gio_modules = frameworks_dir / "gio_modules" + if bundled_gio_modules.exists(): + os.environ.setdefault( + "GIO_EXTRA_MODULES", str(bundled_gio_modules) + ) + elif hasattr(sys, "_MEIPASS") and sys.platform == "win32": + # Windows PyInstaller bundles need explicit DLL search path + # for spawned subprocesses to find cairo, rsvg, etc. + base_dir = Path(sys._MEIPASS) + try: + os.add_dll_directory(str(base_dir)) + except OSError: + pass + logger.debug("Worker process initialized.") diff --git a/requirements.txt b/requirements.txt index 94c979eb8..eb2a4a4ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,25 @@ -blinker~=1.9.0 -cairocffi~=1.7.1 -CairoSVG~=2.7.1 -ezdxf~=1.3.5 -cffi~=1.17.1 -cssselect2~=0.7.0 -defusedxml~=0.7.1 -numpy~=2.2.2 -opencv-python~=4.11.0.86 -packaging~=24.2 -pillow~=11.1.0 -platformdirs~=4.3.6 -pycairo~=1.27.0 -pycparser~=2.22 -PyGObject~=3.50.0 -PyYAML~=6.0.2 -requirements-parser~=0.11.0 -scipy~=1.15.1 -svgpathtools~=1.6.1 -svgwrite~=1.4.3 -tinycss2~=1.4.0 -types-setuptools~=75.8.0.20250110 -webencodings~=0.5.1 -xdg-base-dirs~=6.0.2 -aiohttp~=3.11.12 -websockets~=14.2 -pyserial-asyncio~=0.6 -pypdf~=5.3.0 -pymupdf~=1.25.3 +raygeo==1.38.3 +aiohttp==3.14.3 +asyncudp==0.11.0 +blinker==1.9.0 +ezdxf==1.4.4 +GitPython==3.1.59 +numpy==2.5.2 +opencv_python +platformdirs==4.11.1 +pluggy==1.6.0 +pycairo==1.29.0 +PyGObject==3.56.3 +PyMuPDF==1.28.2 +PyOpenGL==3.1.10 +PyOpenGL_accelerate==3.1.10 +pypdf==6.15.0 +pyserial==3.5 +pyvips==3.1.1 +PyYAML==6.0.3 +scipy==1.18.0 +semver==3.0.4 +svgelements==1.9.6 +trimesh==5.0.0 +vtracer==0.6.15 +websockets==17.0.1 diff --git a/run.bat b/run.bat new file mode 100644 index 000000000..46ff873bd --- /dev/null +++ b/run.bat @@ -0,0 +1,115 @@ +@echo off +setlocal + +:: ========================================================================== +:: run.bat - Development Task Runner for Windows +:: +:: This script provides a simple, Pixi-like interface for common development +:: tasks. It ensures commands are run inside the MSYS2/MinGW64 environment +:: and will pause on error to allow reading the output. +:: +:: Usage: +:: run setup - Installs all dependencies +:: run dev - Installs development tools (linters, formatters) +:: run test - Runs the pytest suite +:: run lint - Runs all linters +:: run format - Formats and auto-fixes code +:: run build - Builds the final .exe +:: run app - Runs the application from source +:: +:: Prerequisite: +:: MSYS2 must be installed at "C:\msys64". If your path is different, +:: please edit the MSYS2_SHELL variable below. +:: ========================================================================== + +set "MSYS2_SHELL=C:\msys64\msys2_shell.cmd" +set "MSYS2_ARGS=-mingw64 -no-start -here -c" + +:: --- Reusable "pause on error" logic for Bash --- +set "PAUSE_ON_ERROR= || { echo; echo '*** ERROR DETECTED ***'; read -p 'Press [Enter] to close...'; exit 1; }" + +:: --- Check for command --- +if "%~1"=="" ( + goto :usage +) + +set "APP_ARGS=" +for /f "tokens=1,* delims= " %%a in ("%*") do set "APP_ARGS=%%b" + +:: --- Command Dispatcher --- +if /i "%~1"=="setup" goto :setup +if /i "%~1"=="dev" goto :dev +if /i "%~1"=="test" goto :test +if /i "%~1"=="lint" goto :lint +if /i "%~1"=="format" goto :format +if /i "%~1"=="build" goto :build +if /i "%~1"=="app" goto :app + +echo ERROR: Unknown command "%~1". +echo. +goto :usage + +:: -------------------------------------------------------------------------- +:: Task Implementations +:: -------------------------------------------------------------------------- + +:setup +echo. +echo --- Setting up Windows Environment --- +%MSYS2_SHELL% %MSYS2_ARGS% "bash scripts/win/win_setup.sh%PAUSE_ON_ERROR%" +goto :eof + +:dev +echo. +echo --- Installing Development Tools --- +%MSYS2_SHELL% %MSYS2_ARGS% "bash scripts/win/win_setup_dev.sh%PAUSE_ON_ERROR%" +goto :eof + +:test +echo. +echo --- Running Test Suite --- +%MSYS2_SHELL% %MSYS2_ARGS% "bash scripts/win/win_test.sh%PAUSE_ON_ERROR%" +goto :eof + +:lint +echo. +echo --- Running Linters --- +%MSYS2_SHELL% %MSYS2_ARGS% "bash scripts/win/win_lint.sh%PAUSE_ON_ERROR%" +goto :eof + +:format +echo. +echo --- Formatting Code --- +%MSYS2_SHELL% %MSYS2_ARGS% "bash scripts/win/win_format.sh%PAUSE_ON_ERROR%" +goto :eof + +:build +echo. +echo --- Building Windows Executable --- +%MSYS2_SHELL% %MSYS2_ARGS% "bash scripts/win/win_build.sh%PAUSE_ON_ERROR%" +goto :eof + +:app +echo. +echo --- Running Rayforge Application --- +shift +%MSYS2_SHELL% %MSYS2_ARGS% "(source .msys2_env && python -m rayforge.app %APP_ARGS%)%PAUSE_ON_ERROR%" +goto :eof + + +:: -------------------------------------------------------------------------- +:: Usage Information +:: -------------------------------------------------------------------------- + +:usage +echo Usage: run [command] +echo. +echo Available commands: +echo setup Install all dependencies +echo dev Install development tools (linters, formatters, pre-commit) +echo test Run the test suite +echo lint Run all linters +echo format Format and auto-fix code +echo build Build the Windows executable +echo app Run the application from source +goto :eof diff --git a/scripts/analyze_grbl_acks.py b/scripts/analyze_grbl_acks.py new file mode 100755 index 000000000..14e7f312b --- /dev/null +++ b/scripts/analyze_grbl_acks.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""Analyze GRBL serial logs to identify unacked commands. + +Reads a rayforge session log and tracks the send/ack lifecycle of every +gcode command, reporting which ones were never acknowledged by GRBL. + +Requires logs produced by rayforge >= 0.9 (where ack lines include +the command text via ``PendingCommand.command``). + +Usage: + python analyze_grbl_acks.py +""" + +import re +import sys +from collections import deque +from dataclasses import dataclass, field + + +@dataclass +class PendingCmd: + line: int + timestamp: str + raw_command: str + byte_len: int + acked: bool = False + ack_line: int | None = None + ack_timestamp: str | None = None + interactive: bool = False + cancelled: bool = False + + @property + def display(self): + return self.raw_command.replace("\\n", "").replace("\\r", "").strip() + + +@dataclass +class AckEvent: + line: int + timestamp: str + freed: int + queue_size: int + matched_cmd: PendingCmd | None = None + + +@dataclass +class BufferWaitEvent: + line: int + timestamp: str + buf_used: int + buf_total: int + needed: int + resume_line: int | None = None + resume_timestamp: str | None = None + resume_buf_used: int | None = None + + +@dataclass +class Report: + pending: deque[PendingCmd] = field(default_factory=deque) + all_cmds: list[PendingCmd] = field(default_factory=list) + acks: list[AckEvent] = field(default_factory=list) + anomalies: list[str] = field(default_factory=list) + buf_tracking: list[tuple[int, str, int, int]] = field(default_factory=list) + buf_waits: list[BufferWaitEvent] = field(default_factory=list) + _open_wait: BufferWaitEvent | None = field(default=None, repr=False) + job_start_line: int | None = None + job_end_line: int | None = None + + +RE_TS = r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})" +RE_USER_CMD = re.compile(rf"^{RE_TS}.*USER_COMMAND.*- (.+)$") +RE_TX_RAW = re.compile( + rf"^{RE_TS}.*\[RAW_IO\].*TX: b'(.+?)'" + r" \(buf: (\d+)/(\d+)\)" +) +RE_TX_RAW_NOBUF = re.compile(rf"^{RE_TS}.*\[RAW_IO\].*TX: b'(.+?)'$") +RE_PROCESSED_OK = re.compile( + rf"^{RE_TS}.*Processed 'ok', freed (\d+) bytes" + r" for '(.+?)'" + r" \(buf: (\d+)/(\d+)" +) +RE_BUFFER_ACK = re.compile( + rf"^{RE_TS}.*Buffer ack: freeing (\d+) bytes for '(.+?)'" +) +RE_ERROR = re.compile(rf"^{RE_TS}.*Extracted 'error:(\d+)' from raw buffer") +RE_TIMEOUT = re.compile(rf"^{RE_TS}.*Timeout waiting for buffer space") +RE_BUFFER_STALL = re.compile( + rf"^{RE_TS}.*Buffer stall: timed out" + r".*waiting for (\d+) bytes" + r".*\(buf: (\d+)/(\d+)" +) +RE_BUFFER_WAIT = re.compile( + rf"^{RE_TS}.*Buffer full \((\d+)/(\d+)\), waiting for (\d+) bytes" +) +RE_BUFFER_RESUME = re.compile( + rf"^{RE_TS}.*Buffer space available, resuming" + r".*\(buf: (\d+)/(\d+)\)" +) +RE_QUEUE_CLEARED = re.compile( + r"Command queue cleared after cancel" + r"|Deadlock recovery: sending" +) +RE_JOB_START = re.compile(r"Starting GRBL streaming job") +RE_JOB_END = re.compile( + r"G-code streaming finished" + r"|G-code streaming cancelled" + r"|G-code streaming aborted" + r"|G-code streaming ended unexpectedly" +) + + +def _payload_bytes(payload: str) -> int: + return len(payload.encode().decode("unicode_escape")) + + +def _is_realtime(payload: str) -> bool: + stripped = payload.replace("\\n", "").replace("\\r", "").strip() + return stripped in ("?", "~", "!", "\x18") + + +def parse(path: str) -> Report: + r = Report() + + with open(path) as f: + for lineno, raw in enumerate(f, 1): + line = raw.rstrip("\n") + + m = RE_USER_CMD.match(line) + if m: + r.job_start_line = r.job_start_line or lineno + continue + + m = RE_TX_RAW.match(line) + if m: + ts, payload, used, total = ( + m.group(1), + m.group(2), + int(m.group(3)), + int(m.group(4)), + ) + r.buf_tracking.append((lineno, ts, used, total)) + if _is_realtime(payload): + continue + p = PendingCmd( + line=lineno, + timestamp=ts, + raw_command=payload, + byte_len=_payload_bytes(payload), + ) + r.pending.append(p) + r.all_cmds.append(p) + continue + + m = RE_TX_RAW_NOBUF.match(line) + if m: + ts, payload = m.group(1), m.group(2) + if _is_realtime(payload): + continue + p = PendingCmd( + line=lineno, + timestamp=ts, + raw_command=payload, + byte_len=_payload_bytes(payload), + interactive=True, + ) + r.pending.append(p) + r.all_cmds.append(p) + continue + + m = RE_PROCESSED_OK.match(line) + if m: + ts = m.group(1) + freed = int(m.group(2)) + ack_cmd = m.group(3) + used = int(m.group(4)) + total = int(m.group(5)) + matched = None + if r.pending: + matched = r.pending.popleft() + if ack_cmd != matched.raw_command: + r.anomalies.append( + f"L{lineno} {ts}: ack desync - " + f"ack references {ack_cmd!r} " + f"but queue head is " + f"{matched.raw_command!r} " + f"(L{matched.line})" + ) + matched.acked = True + matched.ack_line = lineno + matched.ack_timestamp = ts + else: + r.anomalies.append( + f"L{lineno} {ts}: ok for " + f"{ack_cmd!r} " + f"but pending queue is empty" + ) + ev = AckEvent( + line=lineno, + timestamp=ts, + freed=freed, + queue_size=0, + matched_cmd=matched, + ) + r.acks.append(ev) + continue + + m = RE_BUFFER_ACK.match(line) + if m: + ts = m.group(1) + ack_cmd = m.group(2) + if not r.pending: + r.anomalies.append( + f"L{lineno} {ts}: buffer ack for " + f"{ack_cmd!r} " + f"but pending queue is empty" + ) + continue + + m = RE_ERROR.match(line) + if m: + ts, err_code = m.group(1), int(m.group(2)) + if not r.pending: + r.anomalies.append( + f"L{lineno} {ts}: error:{err_code} " + f"but pending queue is empty" + ) + else: + leaked = r.pending.popleft() + r.anomalies.append( + f"L{lineno} {ts}: error:{err_code} " + f"popped unacked " + f"{leaked.raw_command!r} (L{leaked.line})" + ) + continue + + m = RE_TIMEOUT.match(line) + if m: + ts = m.group(1) + r.anomalies.append(f"L{lineno} {ts}: buffer space timeout") + + m = RE_BUFFER_STALL.match(line) + if m: + ts = m.group(1) + needed = int(m.group(2)) + used = int(m.group(3)) + total = int(m.group(4)) + r.anomalies.append( + f"L{lineno} {ts}: buffer stall - " + f"timed out waiting for {needed} bytes " + f"(buf: {used}/{total})" + ) + + m = RE_BUFFER_WAIT.match(line) + if m: + ts = m.group(1) + buf_used = int(m.group(2)) + buf_total = int(m.group(3)) + needed = int(m.group(4)) + ev = BufferWaitEvent( + line=lineno, + timestamp=ts, + buf_used=buf_used, + buf_total=buf_total, + needed=needed, + ) + r.buf_waits.append(ev) + r._open_wait = ev + + m = RE_BUFFER_RESUME.match(line) + if m: + ts = m.group(1) + resume_used = int(m.group(2)) + if r._open_wait: + r._open_wait.resume_line = lineno + r._open_wait.resume_timestamp = ts + r._open_wait.resume_buf_used = resume_used + r._open_wait = None + + if RE_QUEUE_CLEARED.search(line): + while r.pending: + r.pending.popleft().cancelled = True + + if RE_JOB_END.search(line): + r.job_end_line = r.job_end_line or lineno + + return r + + +def fmt_report(r: Report): + streaming = [ + c for c in r.all_cmds if not c.interactive and not c.cancelled + ] + total = len(streaming) + acked = sum(1 for c in streaming if c.acked) + cancelled = sum(1 for c in r.all_cmds if c.cancelled and not c.interactive) + unacked = [c for c in streaming if not c.acked] + + print(f"Commands sent: {total}") + print(f"Acked (ok): {acked}") + print(f"Unacked: {len(unacked)}") + if cancelled: + print(f"Cancelled: {cancelled}") + print() + + if r.anomalies: + print("=== Anomalies ===") + for a in r.anomalies: + print(f" {a}") + print() + + if unacked: + print("=== Unacked Commands ===") + for c in unacked: + print( + f" L{c.line} {c.timestamp} {c.display} ({c.byte_len} bytes)" + ) + print() + + pending_bytes = sum(c.byte_len for c in unacked) + if pending_bytes: + print(f" Total orphaned buffer: {pending_bytes} bytes") + + if r.buf_waits: + print() + unresolved = [w for w in r.buf_waits if w.resume_line is None] + print( + "=== Buffer Waits " + f"({len(r.buf_waits)} total, " + f"{len(unresolved)} unresolved) ===" + ) + for w in r.buf_waits[-50:]: + status = ( + f"resumed L{w.resume_line} " + f"(buf: {w.resume_buf_used}/{w.buf_total})" + if w.resume_line + else "UNRESOLVED" + ) + print( + f" L{w.line:<6} {w.timestamp} " + f"wait {w.needed}B " + f"(buf: {w.buf_used}/{w.buf_total}) " + f"{status}" + ) + + print() + print("=== Buffer Timeline (last 200 entries) ===") + for lineno, ts, used, total in r.buf_tracking[-200:]: + bar_len = total + filled = int(bar_len * used / total) if total else 0 + bar = "|" + "#" * filled + "-" * (bar_len - filled) + "|" + print(f" L{lineno:<6} {ts} {bar} {used}/{total}") + + +def main(): + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + r = parse(sys.argv[1]) + fmt_report(r) + + unacked = [ + c + for c in r.all_cmds + if not c.acked and not c.interactive and not c.cancelled + ] + if unacked: + print() + print("=" * 60) + print("COMMANDS NOT ACKNOWLEDGED BY GRBL:") + print("=" * 60) + for c in unacked: + print(f" [{c.timestamp}] line {c.line}: {c.display}") + print(f"\n {len(unacked)} command(s) never received an ack.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100644 index 000000000..90124f99f --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -e + +# --- Setup & Cleanup --- +BUILD_DIR=$(mktemp -d) +ORIG_DIR=$(pwd) +cleanup() { + echo "--- Cleaning up temporary build directory: $BUILD_DIR ---" + rm -rf "$BUILD_DIR" + echo "Cleanup complete." +} +trap cleanup EXIT + +# --- 1. Dynamic Version Detection --- +echo "--- Determining version from Git repository ---" +if [[ -n "$GITHUB_REF_NAME" && "$GITHUB_REF_TYPE" == "tag" ]]; then + UPSTREAM_VERSION_RAW="$GITHUB_REF_NAME" +else + UPSTREAM_VERSION_RAW=$(git describe --tags --always --long | sed -e 's/^v//' -e 's/\([^-]*\)-\([0-9]*\)-g\([0-9a-f]*\)/\1~dev\2~\3/') +fi +UPSTREAM_VERSION="${UPSTREAM_VERSION_RAW#v}" +UPSTREAM_VERSION="${UPSTREAM_VERSION/-/\~}" +echo "Detected upstream version: ${UPSTREAM_VERSION}" + +# --- 2. Vendor Dependencies: Pre-download wheels --- +echo "--- Vendoring pre-built wheels ---" +TMP_SRC_DIR="${BUILD_DIR}/rayforge-${UPSTREAM_VERSION}" +mkdir -p "${TMP_SRC_DIR}/vendor/sdist" + +REQUIREMENTS_FILE="debian/requirements-bundle.txt" +if [[ ! -f "$REQUIREMENTS_FILE" ]]; then + echo "::error::File not found: $REQUIREMENTS_FILE" + exit 1 +fi + +# Use pip download instead of curl/jq to ensure ABI compatibility +# This grabs wheels matching the current system (Ubuntu 24.04/Py3.12) +# which matches both the runner and the PPA builder. +while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + if [[ "$line" != *"=="* ]]; then + echo "::error::Invalid requirement format (must contain '=='): $line" + exit 1 + fi + + echo "Downloading artifact for $line..." + # --prefer-binary: Get wheels (prevents compilation on Launchpad) + # --no-deps: Only vendor the specific packages listed + python3 -m pip download \ + --dest "${TMP_SRC_DIR}/vendor/sdist" \ + --no-deps \ + --prefer-binary \ + "$line" + +done < "$REQUIREMENTS_FILE" + +if [[ -z "$(ls -A "${TMP_SRC_DIR}/vendor/sdist/" 2>/dev/null)" ]]; then + echo "::error::No wheels were downloaded." + exit 1 +fi + +# --- 3. Create Upstream Tarball --- +echo "--- Creating upstream tarball with vendored wheels ---" +rsync -a \ + --exclude='.git' \ + --exclude='.pixi' \ + --exclude='.venv' \ + --exclude='dist' \ + --exclude='build' \ + --exclude='repo' \ + --exclude='*.egg-info' \ + --exclude='__pycache__' \ + --exclude='debian' \ + "$ORIG_DIR"/ "$TMP_SRC_DIR"/ + +TARBALL_NAME="rayforge_${UPSTREAM_VERSION}.orig.tar.gz" +tar -czf "$BUILD_DIR/$TARBALL_NAME" -C "$BUILD_DIR" "rayforge-${UPSTREAM_VERSION}" +echo "Created: $BUILD_DIR/$TARBALL_NAME" + +# --- 4. Build the Package --- +cd "$BUILD_DIR" +cp -r "$ORIG_DIR/debian" "$TMP_SRC_DIR/" +cd "$TMP_SRC_DIR" + +MAINTAINER_INFO=$(grep '^Maintainer:' debian/control | head -n 1 | sed 's/Maintainer: //') +export DEBEMAIL=$(echo "$MAINTAINER_INFO" | sed -E 's/.*<(.*)>.*/\1/') +export DEBFULLNAME=$(echo "$MAINTAINER_INFO" | sed -E 's/ <.*//') + +# Set the version string based on whether --source is passed (for PPA) or not (for local testing) +if [[ "${1:-}" == "--source" ]]; then +# Use the TARGET_DISTRIBUTION from the environment, defaulting to 'noble' if not set + TARGET_DIST="${TARGET_DISTRIBUTION:-noble}" + dch --newversion "${UPSTREAM_VERSION}-1~ppa1~${TARGET_DIST}1" --distribution "$TARGET_DIST" "New PPA release for ${TARGET_DIST}." +else + dch --newversion "${UPSTREAM_VERSION}-1~local1" "New local build ${UPSTREAM_VERSION}." +fi + +# --- 4a. Build Source Package (Strictly source-only for PPA) --- +# Must be built BEFORE binary build to avoid 'debian/files' pollution +echo "--- Building Source Package (for PPA) ---" +env -i \ + HOME="$HOME" \ + PATH="/usr/sbin:/usr/bin:/sbin:/bin" \ + DEBEMAIL="$DEBEMAIL" \ + DEBFULLNAME="$DEBFULLNAME" \ + dpkg-buildpackage -S -sa -us -uc + +# --- 4b. Build Binary Package (For local testing) --- +echo "--- Building Binary Package (for testing) ---" +env -i \ + HOME="$HOME" \ + PATH="/usr/sbin:/usr/bin:/sbin:/bin" \ + DEBEMAIL="$DEBEMAIL" \ + DEBFULLNAME="$DEBFULLNAME" \ + dpkg-buildpackage -b -us -uc + +# --- 5. Copy Artifacts --- +echo "--- Copying build artifacts back to project's dist/ directory ---" +mkdir -p "$ORIG_DIR/dist" +# This finds the .deb, .dsc, .tar.gz, and the new _source.changes +find "$BUILD_DIR" -maxdepth 1 -name 'rayforge*' -type f -exec cp -v {} "$ORIG_DIR/dist/" \; + +echo "Build complete. Artifacts are in the dist/ directory." diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 000000000..d03d9f62e --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,3 @@ +#!/bin/bash +find . -type d \( -name __pycache__ -o -name "*.egg-info" \) -exec rm -r {} + 2>/dev/null || true +find . -type f \( -name "*.mo" -o -name "rayforge.po~" \) -delete diff --git a/scripts/deploy_website.sh b/scripts/deploy_website.sh new file mode 100644 index 000000000..d20365fca --- /dev/null +++ b/scripts/deploy_website.sh @@ -0,0 +1,180 @@ +#!/bin/bash +set -e + +# Configuration +if [ -z "$DEPLOY_VERSION" ]; then + echo "Error: DEPLOY_VERSION environment variable is not set." + exit 1 +fi +if [ -z "$DEPLOY_REPO_URL" ]; then + echo "Error: DEPLOY_REPO_URL environment variable is not set." + exit 1 +fi +if [ -z "$DEPLOY_BRANCH" ]; then + echo "Error: DEPLOY_BRANCH environment variable is not set." + exit 1 +fi +if [ -z "$IS_TAGGED_RELEASE" ]; then + echo "Error: IS_TAGGED_RELEASE environment variable is not set." + exit 1 +fi +if [ -z "$IS_PRERELEASE" ]; then + echo "Error: IS_PRERELEASE environment variable is not set." + exit 1 +fi + +# Rewrite the deployment branch history so only tagged release snapshots +# and the current HEAD survive. Every deploy replaces the whole site tree +# so intermediate untagged commits carry no unique content and only bloat +# the repository. Run this from inside the deploy repo checkout. +prune_untagged_history() { + local current_head total kept sha + + current_head=$(git rev-parse HEAD) + total=$(git rev-list --count HEAD) + + # Without at least one tag anchor there is no way to distinguish a + # release snapshot from a rolling refresh — pruning would collapse the + # entire pre-tag history to a single commit. Wait until a tagged + # release has been deployed, then this activates. + if ! git tag -l | grep -q .; then + echo "No release tags yet; skipping prune (need an anchor)." + return 0 + fi + + # Count commits that will survive: every tagged commit + the current + # HEAD (the rolling "latest" docs, even if untagged). + kept=0 + while read -r sha; do + local keep=no + [ "$sha" = "$current_head" ] && keep=yes + if [ "$keep" = no ] && git tag -l --points-at "$sha" | grep -q .; then + keep=yes + fi + [ "$keep" = yes ] && kept=$((kept + 1)) + done < <(git rev-list HEAD) + + if [ "$kept" -ge "$total" ]; then + echo "No untagged history to prune (keeping ${kept}/${total} commits)." + return 0 + fi + + echo "Pruning history: keeping ${kept}/${total} tagged/latest commits." + + # Drop every commit that is neither tagged nor the current HEAD. + # filter-branch rewires parents of surviving commits, and + # --tag-name-filter cat re-points tags at the rewritten commits. + # Because each deploy is a full snapshot, skipped commits carry no + # unique tree content. + PRUNE_HEAD="$current_head" \ + git filter-branch -f --tag-name-filter cat --commit-filter ' + keep=no + [ "$GIT_COMMIT" = "$PRUNE_HEAD" ] && keep=yes + if [ "$keep" = no ] && git tag -l --points-at "$GIT_COMMIT" | grep -q .; then + keep=yes + fi + if [ "$keep" = yes ]; then + git commit-tree "$@" + else + skip_commit "$@" + fi + ' -- --all + + # filter-branch does not touch the working tree; resync it to the + # rewritten HEAD (the tree is identical, but the sha has changed). + git reset --hard HEAD + + # Drop filter-branch backup refs so the old, bloated history is not + # retained locally (the remote is rewritten by the force-push below). + git for-each-ref --format='%(refname)' refs/original/ | + while read -r ref; do git update-ref -d "$ref"; done +} + +# Use absolute paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BUILD_DIR="${PROJECT_ROOT}/website/build" +DEPLOY_DIR="${PROJECT_ROOT}/build/deploy_repo" +WEBSITE_SRC_DIR="${PROJECT_ROOT}/website" + +echo "Starting website deployment for version: ${DEPLOY_VERSION}" +echo "Tagged release: ${IS_TAGGED_RELEASE}" +echo "Pre-release: ${IS_PRERELEASE}" +echo "Project root: ${PROJECT_ROOT}" +echo "Build directory: ${BUILD_DIR}" +echo "Deploy directory: ${DEPLOY_DIR}" + +# Clone Deployment Repository +echo "Cloning deployment repository from ${DEPLOY_REPO_URL}..." +rm -rf "${DEPLOY_DIR}" +git clone "${DEPLOY_REPO_URL}" "${DEPLOY_DIR}" +git -C "${DEPLOY_DIR}" checkout -B "${DEPLOY_BRANCH}" + +BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" +git -C "${DEPLOY_DIR}" config user.name "github-actions[bot]" +git -C "${DEPLOY_DIR}" config user.email "${BOT_EMAIL}" + +# Sync pre-generated raygeo API docs into the website tree +echo "Syncing raygeo API docs..." +python3 "${PROJECT_ROOT}/scripts/update_api_docs.py" + +# Install dependencies and build the Docusaurus site +echo "Installing dependencies..." +cd "${WEBSITE_SRC_DIR}" +npm install + +# Strip the 'v' prefix from version for download links (e.g., v1.0.2 -> 1.0.2) +RAYFORGE_VERSION="${DEPLOY_VERSION#v}" +echo "Building static site with version: ${RAYFORGE_VERSION}" +RAYFORGE_VERSION="${RAYFORGE_VERSION}" IS_PRERELEASE="${IS_PRERELEASE}" npm run build + +# Verify build output +echo "Build output:" +ls -la "${BUILD_DIR}/" + +# Deploy: sync build output to deployment directory +# Exclude .git (repo data), .github (workflows), .well-known (domain verification) +echo "Deploying built site to ${DEPLOY_DIR}" +rsync -av --delete --exclude '.git' --exclude '.github' --exclude '.well-known' "${BUILD_DIR}/" "${DEPLOY_DIR}/" + +# Verify deployment +echo "Deployed content:" +ls -la "${DEPLOY_DIR}/" + +# Commit and Push to Deployment Repository +echo "Committing and pushing changes..." +( +cd "${DEPLOY_DIR}" +echo "Changed to folder $(pwd)" + +# Abort if this is not a git repository. +if [ ! -d ".git" ]; then + echo "CRITICAL ERROR: The deployment directory is not a Git repository. Aborting." + exit 1 +fi + +# Using --all to stage deletions as well +git add --all . +if [ -z "$(git status --porcelain)" ]; then + echo "No changes to deploy. Exiting." + exit 0 +fi + +git commit -m "Deploy website content for ${DEPLOY_VERSION}" + +if [ "${IS_TAGGED_RELEASE}" = "true" ]; then + echo "Tagging deploy commit as ${DEPLOY_VERSION}" + git tag -f "${DEPLOY_VERSION}" +fi + +# Drop intermediate untagged commits from the branch history so the +# repository does not bloat over time. Only tagged release snapshots and +# the latest HEAD are kept. Once the first release tag exists, every +# branch push collapses old untagged refreshes into the new HEAD. +prune_untagged_history + +git push --force origin "${DEPLOY_BRANCH}" +git push --force origin --tags +) + +echo "✅ Deployment successful!" diff --git a/scripts/fetch_download_stats.py b/scripts/fetch_download_stats.py new file mode 100755 index 000000000..8765fb4ef --- /dev/null +++ b/scripts/fetch_download_stats.py @@ -0,0 +1,685 @@ +#!/usr/bin/env python3 +""" +Fetch download statistics from various sources. + +Sources: +- GitHub Releases +- Snap Store +- Flathub +- PyPI +- Launchpad PPA + +Output options: +- json: Print stats as JSON +- monthly: Print monthly breakdown +- file: Write stats to JSON file +- metrics: Push metrics to VictoriaMetrics via Prometheus remote write +""" + +import argparse +import base64 +import json +import os +import subprocess +import sys +from collections import defaultdict +from datetime import UTC, datetime +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +METRICS_URL = os.environ.get("METRICS_URL", "") +METRICS_API_USER = os.environ.get("METRICS_API_USER", "") +METRICS_API_PASSWORD = os.environ.get("METRICS_API_PASSWORD", "") + + +def fetch_json(url, headers=None): + req = Request(url, headers=headers or {}) + req.add_header("Accept", "application/json") + with urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + + +def get_github_releases(owner, repo): + url = f"https://api.github.com/repos/{owner}/{repo}/releases" + data = fetch_json(url) + total = 0 + by_version = {} + for release in data: + version = release["tag_name"] + count = sum( + asset.get("download_count", 0) + for asset in release.get("assets", []) + ) + by_version[version] = count + total += count + return {"total": total, "by_version": by_version} + + +def get_snap_downloads(snap_name): + url = f"https://api.snapcraft.io/v2/snaps/info/{snap_name}" + headers = {"Snap-Device-Series": "16"} + try: + data = fetch_json(url, headers) + channel_map = data.get("channel-map", []) + by_channel = {} + first_release = None + for channel in channel_map: + channel_info = channel.get("channel", {}) + channel_name = channel_info.get("name", "unknown") + version = channel.get("version", "unknown") + released_at = channel_info.get("released-at") + by_channel[channel_name] = { + "version": version, + "released_at": released_at, + } + if released_at and ( + first_release is None or released_at < first_release + ): + first_release = released_at + return { + "total": 0, + "by_channel": by_channel, + "first_release": first_release, + "note": "Snap download counts require snapcraft CLI", + } + except (HTTPError, URLError): + return {"total": 0, "by_channel": {}, "error": "Snap not found"} + + +def get_snap_downloads_cli(snap_name, start_date=None): + result = {"total": 0, "by_month": {}, "error": None} + cred = os.environ.get("SNAPCRAFT_STORE_CREDENTIALS") + env = os.environ.copy() + if cred: + # Check if cred is a file path that exists + if os.path.isfile(cred): + with open(cred, "r") as f: + cred = f.read().strip() + env["SNAPCRAFT_STORE_CREDENTIALS"] = cred + else: + env["SNAPCRAFT_STORE_CREDENTIALS"] = cred + try: + cmd = [ + "snapcraft", + "metrics", + snap_name, + "--name", + "daily_device_change", + "--format=json", + ] + if start_date: + cmd.extend(["--start", start_date]) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + env=env, + check=False, + ) + if proc.returncode != 0: + result["error"] = f"snapcraft CLI failed: {proc.stderr.strip()}" + return result + metric = json.loads(proc.stdout) + for series in metric.get("series", []): + if series.get("name") == "new": + values = series.get("values", []) + dates = metric.get("buckets", []) + monthly = defaultdict(int) + total = 0 + for date_str, val in zip(dates, values): + if val is not None: + month = date_str[:7] + count = int(val) + monthly[month] += count + total += count + result["total"] = total + result["by_month"] = dict(sorted(monthly.items())) + break + except FileNotFoundError: + result["error"] = "snapcraft CLI not installed" + except (subprocess.TimeoutExpired, json.JSONDecodeError) as e: + result["error"] = str(e) + return result + + +def get_flathub_downloads(app_id): + url = f"https://flathub.org/api/v2/stats/{app_id}" + try: + data = fetch_json(url) + if data is None: + return { + "total": 0, + "installs_last_month": 0, + "error": "App not on Flathub", + } + return { + "total": data.get("installs_total", 0), + "installs_last_month": data.get("installs_last_month", 0), + "installs_last_7_days": data.get("installs_last_7_days", 0), + } + except (HTTPError, URLError): + return { + "total": 0, + "installs_last_month": 0, + "error": "Flathub API error", + } + + +def get_pypi_downloads(pkg_name): + url = f"https://pypistats.org/api/packages/{pkg_name}/recent" + try: + data = fetch_json(url) + return { + "last_day": data.get("data", {}).get("last_day", 0), + "last_week": data.get("data", {}).get("last_week", 0), + "last_month": data.get("data", {}).get("last_month", 0), + } + except (HTTPError, URLError): + return {"last_day": 0, "last_week": 0, "last_month": 0} + + +def get_ppa_downloads(owner, ppa_name): + """Fetch download statistics from a Launchpad PPA. + + Args: + owner: The Launchpad user/team name (e.g., 'knipknap') + ppa_name: The PPA name (e.g., 'rayforge') + + Returns: + dict with total downloads and by_version breakdown + """ + base_url = ( + f"https://api.launchpad.net/1.0/~{owner}/+archive/ubuntu/{ppa_name}" + ) + result = {"total": 0, "by_version": {}, "error": None} + + try: + binaries_url = f"{base_url}?ws.op=getPublishedBinaries" + data = fetch_json(binaries_url) + entries = data.get("entries", []) + + for entry in entries: + binary_link = entry.get("self_link") + version = entry.get("binary_package_version", "unknown") + + if not binary_link: + continue + + try: + count_url = f"{binary_link}?ws.op=getDownloadCount" + count_data = fetch_json(count_url) + + if version not in result["by_version"]: + result["by_version"][version] = 0 + result["by_version"][version] += count_data + result["total"] += count_data + except (HTTPError, URLError): + continue + + except (HTTPError, URLError) as e: + result["error"] = str(e) + + return result + + +def get_ppa_monthly(owner, ppa_name): + """Fetch monthly download statistics from a Launchpad PPA. + + Args: + owner: The Launchpad user/team name (e.g., 'knipknap') + ppa_name: The PPA name (e.g., 'rayforge') + + Returns: + dict with monthly download counts + """ + base_url = ( + f"https://api.launchpad.net/1.0/~{owner}/+archive/ubuntu/{ppa_name}" + ) + monthly = defaultdict(int) + + try: + binaries_url = f"{base_url}?ws.op=getPublishedBinaries" + data = fetch_json(binaries_url) + entries = data.get("entries", []) + + for entry in entries: + binary_link = entry.get("self_link") + if not binary_link: + continue + + try: + daily_url = f"{binary_link}?ws.op=getDailyDownloadTotals" + daily_data = fetch_json(daily_url) + + for date_str, count in daily_data.items(): + month = date_str[:7] + monthly[month] += count + except (HTTPError, URLError): + continue + + except (HTTPError, URLError): + pass + + return dict(sorted(monthly.items())) + + +def get_flathub_monthly(app_id): + url = f"https://flathub.org/api/v2/stats/{app_id}" + try: + data = fetch_json(url) + if data is None: + return {} + daily = data.get("installs_per_day", {}) + monthly = defaultdict(int) + for date_str, count in daily.items(): + month = date_str[:7] + monthly[month] += count + return dict(sorted(monthly.items())) + except (HTTPError, URLError): + return {} + + +def get_pypi_monthly(pkg_name): + url = ( + f"https://pypistats.org/api/packages/{pkg_name}/overall?mirrors=false" + ) + try: + data = fetch_json(url) + daily = data.get("data", []) + monthly = defaultdict(int) + for entry in daily: + date_str = entry.get("date", "") + if date_str: + month = date_str[:7] + monthly[month] += entry.get("downloads", 0) + return dict(sorted(monthly.items())) + except (HTTPError, URLError): + return {} + + +def get_pypi_daily(pkg_name): + url = ( + f"https://pypistats.org/api/packages/{pkg_name}/overall?mirrors=false" + ) + try: + data = fetch_json(url) + daily = {} + for entry in data.get("data", []): + date_str = entry.get("date", "") + if date_str: + daily[date_str] = entry.get("downloads", 0) + return daily + except (HTTPError, URLError): + return {} + + +def query_victoriametrics(query): + url = f"{METRICS_URL}/api/v1/query?query={query}" + credentials = base64.b64encode( + f"{METRICS_API_USER}:{METRICS_API_PASSWORD}".encode() + ).decode() + req = Request(url) + req.add_header("Authorization", f"Basic {credentials}") + try: + with urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + results = data.get("data", {}).get("result", []) + if results: + return float(results[0].get("value", [0, 0])[1]) + except (HTTPError, URLError) as e: + print(f"Failed to query VictoriaMetrics: {e}") + return None + + +def query_victoriametrics_labels(query, label): + url = f"{METRICS_URL}/api/v1/series?match[]={query}&start=0&end=now" + credentials = base64.b64encode( + f"{METRICS_API_USER}:{METRICS_API_PASSWORD}".encode() + ).decode() + req = Request(url) + req.add_header("Authorization", f"Basic {credentials}") + try: + with urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + results = data.get("data", []) + return {r.get(label, "") for r in results if label in r} + except (HTTPError, URLError) as e: + print(f"Failed to query VictoriaMetrics labels: {e}") + return set() + + +def send_to_victoriametrics(metrics): + lines = [] + for metric in metrics: + name = metric["name"] + value = metric["value"] + if value == 0: + continue + tags = metric.get("tags", {}) + + tags_str = ",".join(f'{k}="{v}"' for k, v in tags.items()) + if tags_str: + tags_str = "{" + tags_str + "}" + + lines.append(f"{name}{tags_str} {value}") + + url = f"{METRICS_URL}/api/v1/import/prometheus" + data = "\n".join(lines).encode() + + credentials = base64.b64encode( + f"{METRICS_API_USER}:{METRICS_API_PASSWORD}".encode() + ).decode() + + req = Request(url, data=data, method="POST") + req.add_header("Content-Type", "text/plain") + req.add_header("Authorization", f"Basic {credentials}") + + try: + with urlopen(req, timeout=30): + return len(lines) + except HTTPError as e: + print(f"Failed to send to VictoriaMetrics: {e}") + print(f"Response: {e.read().decode()}") + return 0 + except URLError as e: + print(f"Failed to send to VictoriaMetrics: {e}") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="Fetch download stats") + parser.add_argument("--github-owner", default="barebaric") + parser.add_argument("--github-repo", default="rayforge") + parser.add_argument("--snap-name", default="rayforge") + parser.add_argument("--flathub-id", default="org.rayforge.rayforge") + parser.add_argument("--pypi-package", default="rayforge") + parser.add_argument("--ppa-owner", default="knipknap") + parser.add_argument("--ppa-name", default="rayforge") + parser.add_argument( + "--output", + choices=["json", "monthly", "file", "metrics"], + default="json", + ) + parser.add_argument( + "--output-file", + default="website/static/stats.json", + help="Output file for stats (default: website/static/stats.json)", + ) + args = parser.parse_args() + + if args.output == "monthly": + print("Fetching monthly download stats...") + flathub_monthly = get_flathub_monthly(args.flathub_id) + pypi_monthly = get_pypi_monthly(args.pypi_package) + snap_data = get_snap_downloads_cli(args.snap_name, "2024-01-01") + snap_monthly = snap_data.get("by_month", {}) + snap_error = snap_data.get("error") + ppa_monthly = get_ppa_monthly(args.ppa_owner, args.ppa_name) + + all_months = sorted( + set(flathub_monthly.keys()) + | set(pypi_monthly.keys()) + | set(snap_monthly.keys()) + | set(ppa_monthly.keys()) + ) + + print("\nMonthly Downloads by Source:") + print("-" * 72) + print( + f"{'Month':<10} {'Flathub':>10} {'PyPI':>10} " + f"{'Snap':>10} {'PPA':>10} {'Total':>10}" + ) + print("-" * 72) + + for month in all_months: + fh = flathub_monthly.get(month, 0) + pypi = pypi_monthly.get(month, 0) + snap = snap_monthly.get(month, 0) + ppa = ppa_monthly.get(month, 0) + total = fh + pypi + snap + ppa + print( + f"{month:<10} {fh:>10,} {pypi:>10,} {snap:>10,} " + f"{ppa:>10,} {total:>10,}" + ) + + print("-" * 72) + fh_total = sum(flathub_monthly.values()) + pypi_total = sum(pypi_monthly.values()) + snap_total = sum(snap_monthly.values()) + ppa_total = sum(ppa_monthly.values()) + print( + f"{'TOTAL':<10} " + f"{fh_total:>10,} " + f"{pypi_total:>10,} " + f"{snap_total:>10,} " + f"{ppa_total:>10,} " + f"{fh_total + pypi_total + snap_total + ppa_total:>10,}" + ) + if snap_error: + print(f"\nNote: Snap stats unavailable - {snap_error}") + return 0 + + ppa_stats = get_ppa_downloads(args.ppa_owner, args.ppa_name) + snap_stats = get_snap_downloads_cli(args.snap_name, "2024-01-01") + pypi_monthly = get_pypi_monthly(args.pypi_package) + pypi_total = sum(pypi_monthly.values()) + stats = { + "timestamp": datetime.now(UTC).isoformat(), + "github": get_github_releases(args.github_owner, args.github_repo), + "snap": snap_stats, + "flathub": get_flathub_downloads(args.flathub_id), + "pypi": get_pypi_downloads(args.pypi_package), + "ppa": ppa_stats, + } + + stats["total_downloads"] = ( + stats["github"]["total"] + + stats["snap"]["total"] + + stats["flathub"]["total"] + + pypi_total + + ppa_stats["total"] + ) + + if args.output == "file": + flathub_monthly = get_flathub_monthly(args.flathub_id) + snap_data = get_snap_downloads_cli(args.snap_name, "2024-01-01") + snap_monthly = snap_data.get("by_month", {}) + ppa_monthly = get_ppa_monthly(args.ppa_owner, args.ppa_name) + + all_months = sorted( + set(flathub_monthly.keys()) + | set(pypi_monthly.keys()) + | set(snap_monthly.keys()) + | set(ppa_monthly.keys()) + ) + + monthly = [] + for month in all_months: + fh = flathub_monthly.get(month, 0) + pypi = pypi_monthly.get(month, 0) + snap = snap_monthly.get(month, 0) + ppa = ppa_monthly.get(month, 0) + monthly.append( + { + "month": month, + "flathub": fh, + "pypi": pypi, + "snap": snap, + "ppa": ppa, + "total": fh + pypi + snap + ppa, + } + ) + + full_stats = { + "timestamp": datetime.now(UTC).isoformat(), + "totals": { + "github": stats["github"]["total"], + "flathub": stats["flathub"]["total"], + "pypi": stats["pypi"]["last_month"], + "snap": snap_data.get("total", 0), + "ppa": ppa_stats["total"], + }, + "github_by_version": stats["github"]["by_version"], + "ppa_by_version": ppa_stats.get("by_version", {}), + "monthly": monthly, + } + + with open(args.output_file, "w") as f: + json.dump(full_stats, f, indent=2) + print(f"Stats written to {args.output_file}") + return 0 + + if args.output == "metrics": + if not METRICS_URL or not METRICS_API_USER or not METRICS_API_PASSWORD: + print( + "Error: METRICS_URL, METRICS_API_USER, and " + "METRICS_API_PASSWORD must be set" + ) + return 1 + + pypi_daily = get_pypi_daily(args.pypi_package) + existing_dates = query_victoriametrics_labels( + 'downloads_daily{source="pypi"}', "date" + ) + new_dates = set(pypi_daily.keys()) - existing_dates + new_downloads = sum(pypi_daily[d] for d in new_dates) + + stored_pypi_total = query_victoriametrics( + 'sum(downloads_daily{source="pypi"})' + ) + stored_pypi_total = int(stored_pypi_total) if stored_pypi_total else 0 + pypi_cumulative = stored_pypi_total + new_downloads + + print( + f"PyPI: {len(existing_dates)} existing dates, " + f"{len(new_dates)} new, +{new_downloads} downloads, " + f"total={pypi_cumulative}" + ) + + metrics = [ + { + "name": "downloads_total", + "value": stats["github"]["total"], + "tags": {"source": "github"}, + }, + { + "name": "downloads_total", + "value": stats["flathub"]["total"], + "tags": {"source": "flathub"}, + }, + { + "name": "downloads_total", + "value": pypi_cumulative, + "tags": {"source": "pypi"}, + }, + { + "name": "downloads_recent", + "value": stats["flathub"].get("installs_last_7_days", 0), + "tags": {"source": "flathub", "period": "7d"}, + }, + { + "name": "downloads_recent", + "value": stats["flathub"].get("installs_last_month", 0), + "tags": {"source": "flathub", "period": "30d"}, + }, + { + "name": "downloads_recent", + "value": stats["pypi"]["last_day"], + "tags": {"source": "pypi", "period": "1d"}, + }, + { + "name": "downloads_recent", + "value": stats["pypi"]["last_week"], + "tags": {"source": "pypi", "period": "7d"}, + }, + { + "name": "downloads_recent", + "value": stats["pypi"]["last_month"], + "tags": {"source": "pypi", "period": "30d"}, + }, + ] + + for month, count in pypi_monthly.items(): + metrics.append( + { + "name": "downloads_by_month", + "value": count, + "tags": {"source": "pypi", "month": month}, + } + ) + + for date in new_dates: + metrics.append( + { + "name": "downloads_daily", + "value": pypi_daily[date], + "tags": {"source": "pypi", "date": date}, + } + ) + + for version, count in stats["github"].get("by_version", {}).items(): + metrics.append( + { + "name": "downloads_by_version", + "value": count, + "tags": {"source": "github", "version": version}, + } + ) + + metrics.append( + { + "name": "downloads_total", + "value": ppa_stats["total"], + "tags": {"source": "ppa"}, + } + ) + + for version, count in ppa_stats.get("by_version", {}).items(): + metrics.append( + { + "name": "downloads_by_version", + "value": count, + "tags": {"source": "ppa", "version": version}, + } + ) + + snap_error = snap_stats.get("error") + if snap_error: + print(f"Warning: Snap stats unavailable - {snap_error}") + else: + snap_total = snap_stats.get("total", 0) + snap_months = len(snap_stats.get("by_month", {})) + print(f"Snap stats: {snap_total} total, {snap_months} months") + + metrics.append( + { + "name": "downloads_total", + "value": snap_stats.get("total", 0), + "tags": {"source": "snap"}, + } + ) + + for month, count in snap_stats.get("by_month", {}).items(): + metrics.append( + { + "name": "downloads_by_month", + "value": count, + "tags": {"source": "snap", "month": month}, + } + ) + + sent = send_to_victoriametrics(metrics) + if sent: + print(f"Successfully sent {sent} metrics to VictoriaMetrics") + return 0 + else: + print("Failed to send metrics to VictoriaMetrics") + return 1 + + print(json.dumps(stats, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/flatpak-pip-generator.py b/scripts/flatpak-pip-generator.py deleted file mode 100755 index 013181a31..000000000 --- a/scripts/flatpak-pip-generator.py +++ /dev/null @@ -1,533 +0,0 @@ -#!/usr/bin/env python3 - -__license__ = 'MIT' - -import argparse -import json -import hashlib -import os -import re -import shutil -import subprocess -import sys -import tempfile -import urllib.request - -from collections import OrderedDict -from typing import Dict - -try: - import requirements -except ImportError: - exit('Requirements modules is not installed. Run "pip install requirements-parser"') - -parser = argparse.ArgumentParser() -parser.add_argument('packages', nargs='*') -parser.add_argument('--python2', action='store_true', - help='Look for a Python 2 package') -parser.add_argument('--cleanup', choices=['scripts', 'all'], - help='Select what to clean up after build') -parser.add_argument('--requirements-file', '-r', - help='Specify requirements.txt file') -parser.add_argument('--build-only', action='store_const', - dest='cleanup', const='all', - help='Clean up all files after build') -parser.add_argument('--build-isolation', action='store_true', - default=False, - help=( - 'Do not disable build isolation. ' - 'Mostly useful on pip that does\'t ' - 'support the feature.' - )) -parser.add_argument('--ignore-installed', - type=lambda s: s.split(','), - default='', - help='Comma-separated list of package names for which pip ' - 'should ignore already installed packages. Useful when ' - 'the package is installed in the SDK but not in the ' - 'runtime.') -parser.add_argument('--checker-data', action='store_true', - help='Include x-checker-data in output for the "Flatpak External Data Checker"') -parser.add_argument('--output', '-o', - help='Specify output file name') -parser.add_argument('--runtime', - help='Specify a flatpak to run pip inside of a sandbox, ensures python version compatibility') -parser.add_argument('--yaml', action='store_true', - help='Use YAML as output format instead of JSON') -parser.add_argument('--ignore-errors', action='store_true', - help='Ignore errors when downloading packages') -parser.add_argument('--ignore-pkg', nargs='*', - help='Ignore a package when generating the manifest. Can only be used with a requirements file') -opts = parser.parse_args() - -if opts.yaml: - try: - import yaml - except ImportError: - exit('PyYAML modules is not installed. Run "pip install PyYAML"') - - -def get_pypi_url(name: str, filename: str) -> str: - url = 'https://pypi.org/pypi/{}/json'.format(name) - print('Extracting download url for', name) - with urllib.request.urlopen(url) as response: - body = json.loads(response.read().decode('utf-8')) - for release in body['releases'].values(): - for source in release: - if source['filename'] == filename: - return source['url'] - raise Exception('Failed to extract url from {}'.format(url)) - - -def get_tar_package_url_pypi(name: str, version: str) -> str: - url = 'https://pypi.org/pypi/{}/{}/json'.format(name, version) - with urllib.request.urlopen(url) as response: - body = json.loads(response.read().decode('utf-8')) - for ext in ['bz2', 'gz', 'xz', 'zip', 'none-any.whl']: - for source in body['urls']: - if source['url'].endswith(ext): - return source['url'] - err = 'Failed to get {}-{} source from {}'.format(name, version, url) - raise Exception(err) - - -def get_package_name(filename: str) -> str: - if filename.endswith(('bz2', 'gz', 'xz', 'zip')): - segments = filename.split('-') - if len(segments) == 2: - return segments[0] - return '-'.join(segments[:len(segments) - 1]) - elif filename.endswith('whl'): - segments = filename.split('-') - if len(segments) == 5: - return segments[0] - candidate = segments[:len(segments) - 4] - # Some packages list the version number twice - # e.g. PyQt5-5.15.0-5.15.0-cp35.cp36.cp37.cp38-abi3-manylinux2014_x86_64.whl - if candidate[-1] == segments[len(segments) - 4]: - return '-'.join(candidate[:-1]) - return '-'.join(candidate) - else: - raise Exception( - 'Downloaded filename: {} does not end with bz2, gz, xz, zip, or whl'.format(filename) - ) - - -def get_file_version(filename: str) -> str: - name = get_package_name(filename) - segments = filename.split(name + '-') - version = segments[1].split('-')[0] - for ext in ['tar.gz', 'whl', 'tar.xz', 'tar.gz', 'tar.bz2', 'zip']: - version = version.replace('.' + ext, '') - return version - - -def get_file_hash(filename: str) -> str: - sha = hashlib.sha256() - print('Generating hash for', filename.split('/')[-1]) - with open(filename, 'rb') as f: - while True: - data = f.read(1024 * 1024 * 32) - if not data: - break - sha.update(data) - return sha.hexdigest() - - -def download_tar_pypi(url: str, tempdir: str) -> None: - with urllib.request.urlopen(url) as response: - file_path = os.path.join(tempdir, url.split('/')[-1]) - with open(file_path, 'x+b') as tar_file: - shutil.copyfileobj(response, tar_file) - - -def parse_continuation_lines(fin): - for line in fin: - line = line.rstrip('\n') - while line.endswith('\\'): - try: - line = line[:-1] + next(fin).rstrip('\n') - except StopIteration: - exit('Requirements have a wrong number of line continuation characters "\\"') - yield line - - -def fprint(string: str) -> None: - separator = '=' * 72 # Same as `flatpak-builder` - print(separator) - print(string) - print(separator) - - -packages = [] -if opts.requirements_file: - requirements_file_input = os.path.expanduser(opts.requirements_file) - try: - with open(requirements_file_input, 'r') as req_file: - reqs = parse_continuation_lines(req_file) - reqs_as_str = '\n'.join([r.split('--hash')[0] for r in reqs]) - reqs_list_raw = reqs_as_str.splitlines() - py_version_regex = re.compile(r';.*python_version .+$') # Remove when pip-generator can handle python_version - reqs_list = [py_version_regex.sub('', p) for p in reqs_list_raw] - if opts.ignore_pkg: - reqs_new = '\n'.join(i for i in reqs_list if i not in opts.ignore_pkg) - else: - reqs_new = reqs_as_str - packages = list(requirements.parse(reqs_new)) - with tempfile.NamedTemporaryFile('w', delete=False, prefix='requirements.') as req_file: - req_file.write(reqs_new) - requirements_file_output = req_file.name - except FileNotFoundError as err: - print(err) - sys.exit(1) - -elif opts.packages: - packages = list(requirements.parse('\n'.join(opts.packages))) - with tempfile.NamedTemporaryFile('w', delete=False, prefix='requirements.') as req_file: - req_file.write('\n'.join(opts.packages)) - requirements_file_output = req_file.name -else: - if not len(sys.argv) > 1: - exit('Please specifiy either packages or requirements file argument') - else: - exit('This option can only be used with requirements file') - -for i in packages: - if i["name"].lower().startswith("pyqt"): - print("PyQt packages are not supported by flapak-pip-generator") - print("However, there is a BaseApp for PyQt available, that you should use") - print("Visit https://github.com/flathub/com.riverbankcomputing.PyQt.BaseApp for more information") - sys.exit(0) - -with open(requirements_file_output, 'r') as req_file: - use_hash = '--hash=' in req_file.read() - -python_version = '2' if opts.python2 else '3' -if opts.python2: - pip_executable = 'pip2' -else: - pip_executable = 'pip3' - -if opts.runtime: - flatpak_cmd = [ - 'flatpak', - '--devel', - '--share=network', - '--filesystem=/tmp', - '--command={}'.format(pip_executable), - 'run', - opts.runtime - ] - if opts.requirements_file: - if os.path.exists(requirements_file_output): - prefix = os.path.realpath(requirements_file_output) - flag = '--filesystem={}'.format(prefix) - flatpak_cmd.insert(1,flag) -else: - flatpak_cmd = [pip_executable] - -output_path = '' - -if opts.output: - output_path = os.path.dirname(opts.output) - output_package = os.path.basename(opts.output) -elif opts.requirements_file: - output_package = 'python{}-{}'.format( - python_version, - os.path.basename(opts.requirements_file).replace('.txt', ''), - ) -elif len(packages) == 1: - output_package = 'python{}-{}'.format( - python_version, packages[0].name, - ) -else: - output_package = 'python{}-modules'.format(python_version) -if opts.yaml: - output_filename = os.path.join(output_path, output_package) + '.yaml' -else: - output_filename = os.path.join(output_path, output_package) + '.json' - -modules = [] -vcs_modules = [] -sources = {} - -unresolved_dependencies_errors = [] - -tempdir_prefix = 'pip-generator-{}'.format(output_package) -with tempfile.TemporaryDirectory(prefix=tempdir_prefix) as tempdir: - pip_download = flatpak_cmd + [ - 'download', - '--exists-action=i', - '--dest', - tempdir, - '-r', - requirements_file_output - ] - if use_hash: - pip_download.append('--require-hashes') - - fprint('Downloading sources') - cmd = ' '.join(pip_download) - print('Running: "{}"'.format(cmd)) - try: - subprocess.run(pip_download, check=True) - os.remove(requirements_file_output) - except subprocess.CalledProcessError: - os.remove(requirements_file_output) - print('Failed to download') - print('Please fix the module manually in the generated file') - if not opts.ignore_errors: - print('Ignore the error by passing --ignore-errors') - raise - - try: - os.remove(requirements_file_output) - except FileNotFoundError: - pass - - fprint('Downloading arch independent packages') - for filename in os.listdir(tempdir): - if not filename.endswith(('bz2', 'any.whl', 'gz', 'xz', 'zip')): - version = get_file_version(filename) - name = get_package_name(filename) - try: - url = get_tar_package_url_pypi(name, version) - print('Downloading {}'.format(url)) - download_tar_pypi(url, tempdir) - except Exception as err: - # Can happen if only an arch dependent wheel is available like for wasmtime-27.0.2 - unresolved_dependencies_errors.append(err) - print('Deleting', filename) - try: - os.remove(os.path.join(tempdir, filename)) - except FileNotFoundError: - pass - - files = {get_package_name(f): [] for f in os.listdir(tempdir)} - - for filename in os.listdir(tempdir): - name = get_package_name(filename) - files[name].append(filename) - - # Delete redundant sources, for vcs sources - for name in files: - if len(files[name]) > 1: - zip_source = False - for f in files[name]: - if f.endswith('.zip'): - zip_source = True - if zip_source: - for f in files[name]: - if not f.endswith('.zip'): - try: - os.remove(os.path.join(tempdir, f)) - except FileNotFoundError: - pass - - vcs_packages = { - x.name: {'vcs': x.vcs, 'revision': x.revision, 'uri': x.uri} - for x in packages - if x.vcs - } - - fprint('Obtaining hashes and urls') - for filename in os.listdir(tempdir): - name = get_package_name(filename) - sha256 = get_file_hash(os.path.join(tempdir, filename)) - is_pypi = False - - if name in vcs_packages: - uri = vcs_packages[name]['uri'] - revision = vcs_packages[name]['revision'] - vcs = vcs_packages[name]['vcs'] - url = 'https://' + uri.split('://', 1)[1] - s = 'commit' - if vcs == 'svn': - s = 'revision' - source = OrderedDict([ - ('type', vcs), - ('url', url), - (s, revision), - ]) - is_vcs = True - else: - name = name.casefold() - is_pypi = True - url = get_pypi_url(name, filename) - source = OrderedDict([ - ('type', 'file'), - ('url', url), - ('sha256', sha256)]) - if opts.checker_data: - source['x-checker-data'] = { - 'type': 'pypi', - 'name': name} - if url.endswith(".whl"): - source['x-checker-data']['packagetype'] = 'bdist_wheel' - is_vcs = False - sources[name] = {'source': source, 'vcs': is_vcs, 'pypi': is_pypi} - -# Python3 packages that come as part of org.freedesktop.Sdk. -system_packages = ['cython', 'easy_install', 'mako', 'markdown', 'meson', 'pip', 'pygments', 'setuptools', 'six', 'wheel'] - -fprint('Generating dependencies') -for package in packages: - - if package.name is None: - print('Warning: skipping invalid requirement specification {} because it is missing a name'.format(package.line), file=sys.stderr) - print('Append #egg= to the end of the requirement line to fix', file=sys.stderr) - continue - elif package.name.casefold() in system_packages: - print(f"{package.name} is in system_packages. Skipping.") - continue - - if len(package.extras) > 0: - extras = '[' + ','.join(extra for extra in package.extras) + ']' - else: - extras = '' - - version_list = [x[0] + x[1] for x in package.specs] - version = ','.join(version_list) - - if package.vcs: - revision = '' - if package.revision: - revision = '@' + package.revision - pkg = package.uri + revision + '#egg=' + package.name - else: - pkg = package.name + extras + version - - dependencies = [] - # Downloads the package again to list dependencies - - tempdir_prefix = 'pip-generator-{}'.format(package.name) - with tempfile.TemporaryDirectory(prefix='{}-{}'.format(tempdir_prefix, package.name)) as tempdir: - pip_download = flatpak_cmd + [ - 'download', - '--exists-action=i', - '--dest', - tempdir, - ] - try: - print('Generating dependencies for {}'.format(package.name)) - subprocess.run(pip_download + [pkg], check=True, stdout=subprocess.DEVNULL) - for filename in sorted(os.listdir(tempdir)): - dep_name = get_package_name(filename) - if dep_name.casefold() in system_packages: - continue - dependencies.append(dep_name) - - except subprocess.CalledProcessError: - print('Failed to download {}'.format(package.name)) - - is_vcs = True if package.vcs else False - package_sources = [] - for dependency in dependencies: - casefolded = dependency.casefold() - if casefolded in sources and sources[casefolded].get("pypi") is True: - source = sources[casefolded] - elif dependency in sources and sources[dependency].get("pypi") is False: - source = sources[dependency] - elif ( - casefolded.replace("_", "-") in sources - and sources[casefolded.replace("_", "-")].get("pypi") is True - ): - source = sources[casefolded.replace("_", "-")] - elif ( - dependency.replace("_", "-") in sources - and sources[dependency.replace("_", "-")].get("pypi") is False - ): - source = sources[dependency.replace("_", "-")] - else: - continue - - if not (not source['vcs'] or is_vcs): - continue - - package_sources.append(source['source']) - - if package.vcs: - name_for_pip = '.' - else: - name_for_pip = pkg - - module_name = 'python{}-{}'.format(python_version, package.name) - - pip_command = [ - pip_executable, - 'install', - '--verbose', - '--exists-action=i', - '--no-index', - '--find-links="file://${PWD}"', - '--prefix=${FLATPAK_DEST}', - '"{}"'.format(name_for_pip) - ] - if package.name in opts.ignore_installed: - pip_command.append('--ignore-installed') - if not opts.build_isolation: - pip_command.append('--no-build-isolation') - - module = OrderedDict([ - ('name', module_name), - ('buildsystem', 'simple'), - ('build-commands', [' '.join(pip_command)]), - ('sources', package_sources), - ]) - if opts.cleanup == 'all': - module['cleanup'] = ['*'] - elif opts.cleanup == 'scripts': - module['cleanup'] = ['/bin', '/share/man/man1'] - - if package.vcs: - vcs_modules.append(module) - else: - modules.append(module) - -modules = vcs_modules + modules -if len(modules) == 1: - pypi_module = modules[0] -else: - pypi_module = { - 'name': output_package, - 'buildsystem': 'simple', - 'build-commands': [], - 'modules': modules, - } - -print() -with open(output_filename, 'w') as output: - if opts.yaml: - class OrderedDumper(yaml.Dumper): - def increase_indent(self, flow=False, indentless=False): - return super(OrderedDumper, self).increase_indent(flow, False) - - def dict_representer(dumper, data): - return dumper.represent_dict(data.items()) - - OrderedDumper.add_representer(OrderedDict, dict_representer) - - output.write("# Generated with flatpak-pip-generator " + " ".join(sys.argv[1:]) + "\n") - yaml.dump(pypi_module, output, Dumper=OrderedDumper) - else: - output.write(json.dumps(pypi_module, indent=4)) - print('Output saved to {}'.format(output_filename)) - -if len(unresolved_dependencies_errors) != 0: - print("Unresolved dependencies. Handle them manually") - for e in unresolved_dependencies_errors: - print(f"- ERROR: {e}") - - workaround = """Example how to handle wheels which only support specific architectures: - - type: file - url: https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl - sha256: 7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e - only-arches: - - aarch64 - - type: file - url: https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - sha256: 666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5 - only-arches: - - x86_64 - """ - raise Exception(f"Not all dependencies can be determined. Handle them manually.\n{workaround}") diff --git a/scripts/generate_affiliate_link.py b/scripts/generate_affiliate_link.py new file mode 100644 index 000000000..8a0d4102d --- /dev/null +++ b/scripts/generate_affiliate_link.py @@ -0,0 +1,135 @@ +import argparse +import configparser +import hashlib +import json +import os +import sys +import time +import urllib.parse +import urllib.request +from typing import Any + +API_DOMAIN = "api-sg.aliexpress.com" +API_PATH = "/sync" + + +def load_config() -> dict: + path = os.path.expanduser("~/.config/aliexpress.key") + if not os.path.exists(path): + print(f"error: {path} not found", file=sys.stderr) + print("create it with:", file=sys.stderr) + print(" [aliexpress]", file=sys.stderr) + print(" app_key=533956", file=sys.stderr) + print(" app_secret=...", file=sys.stderr) + print(" tracking_id=default", file=sys.stderr) + print(" app_signature=rayforge", file=sys.stderr) + sys.exit(1) + cfg = configparser.ConfigParser() + cfg.read(path) + if "aliexpress" not in cfg: + print( + f"error: [aliexpress] section missing in {path}", file=sys.stderr + ) + sys.exit(1) + return { + "app_key": cfg["aliexpress"]["app_key"], + "app_secret": cfg["aliexpress"]["app_secret"], + "tracking_id": cfg["aliexpress"].get("tracking_id", "default"), + "app_signature": cfg["aliexpress"].get("app_signature", "rayforge"), + } + + +def sign(secret: str, params: dict) -> str: + keys = sorted(params.keys()) + s = "".join(f"{k}{params[k]}" for k in keys) + s = f"{secret}{s}{secret}" + return hashlib.md5(s.encode("utf-8")).hexdigest().upper() + + +def api_call(method: str, api_params: dict, cfg: dict) -> dict: + timestamp = str(int(time.time() * 1000)) + sys_params = { + "method": method, + "app_key": cfg["app_key"], + "sign_method": "md5", + "timestamp": timestamp, + "format": "json", + "v": "2.0", + } + sign_params = {**sys_params, **api_params} + sys_params["sign"] = sign(cfg["app_secret"], sign_params) + qs = urllib.parse.urlencode(sorted(sys_params.items())) + url = f"http://{API_DOMAIN}{API_PATH}?{qs}" + body = urllib.parse.urlencode(api_params).encode("utf-8") + req = urllib.request.Request(url, data=body) + req.add_header( + "Content-Type", "application/x-www-form-urlencoded;charset=utf-8" + ) + resp = urllib.request.urlopen(req) + return json.loads(resp.read()) + + +def generate_link(product_url: str, cfg: dict) -> str: + result = api_call( + "aliexpress.affiliate.link.generate", + { + "app_signature": cfg["app_signature"], + "promotion_link_type": "0", + "source_values": product_url, + "tracking_id": cfg["tracking_id"], + }, + cfg, + ) + resp = result.get("aliexpress_affiliate_link_generate_response", {}) + resp_result = resp.get("resp_result", {}) + results = resp_result.get("result", {}) + links = results.get("promotion_links", {}).get("promotion_link", []) + if not links: + print("error: no affiliate link in response", file=sys.stderr) + print(json.dumps(result, indent=2), file=sys.stderr) + sys.exit(1) + if "promotion_link" not in links[0]: + msg = links[0].get("message", "unknown error") + print(f"error: {msg}", file=sys.stderr) + sys.exit(1) + return links[0]["promotion_link"] + + +def product_query(keyword: str, cfg: dict) -> dict: + result = api_call( + "aliexpress.affiliate.product.query", + { + "keywords": keyword, + "page_no": "1", + "page_size": "5", + }, + cfg, + ) + return result + + +def main(): + parser = argparse.ArgumentParser( + description="Generate an Aliexpress affiliate link" + ) + parser.add_argument("url", nargs="?", help="Aliexpress product URL") + parser.add_argument("--query", "-q", help="Search for products by keyword") + args: Any = parser.parse_args() + + cfg = load_config() + + if args.query: + result = product_query(args.query, cfg) + print(json.dumps(result, indent=2)) + return + + if args.url: + link = generate_link(args.url, cfg) + print(link) + return + + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/scripts/mac/Brewfile b/scripts/mac/Brewfile new file mode 100644 index 000000000..0d25b1ded --- /dev/null +++ b/scripts/mac/Brewfile @@ -0,0 +1,13 @@ +brew "gtk4" +brew "libadwaita" +brew "gobject-introspection" +brew "librsvg" +brew "libvips" +brew "openslide" +brew "pkg-config" +brew "meson" +brew "mupdf" +brew "ninja" +brew "cairo" +brew "pango" +brew "harfbuzz" diff --git a/scripts/mac/mac_build.sh b/scripts/mac/mac_build.sh new file mode 100755 index 000000000..24c959c70 --- /dev/null +++ b/scripts/mac/mac_build.sh @@ -0,0 +1,637 @@ +#!/usr/bin/env bash +set -euo pipefail + +DO_BUILD=0 +DO_BUNDLE=0 +DO_DMG=0 +DO_RUN_APP=0 +VERSION_OVERRIDE="" +MACOS_MIN_VERSION="12.0" +MACOS_NUMPY_VERSION="1.26.4" +MACOS_SCIPY_VERSION="1.11.4" +GREEN="\033[0;32m" +NC="\033[0m" + +export MACOSX_DEPLOYMENT_TARGET="$MACOS_MIN_VERSION" + +print_info() { + local title=$1 + printf "${GREEN}%s${NC}\n" "$title" +} + +while (($#)); do + case "$1" in + --version) + VERSION_OVERRIDE="$2" + shift + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + shift +done + +echo "" +print_info "======================================" +print_info " Rayforge macOS Build Script" +print_info "======================================" +echo "" +echo "Select build option:" +echo " 1) Build" +echo " 2) Bundle (.app)" +echo " 3) Run bundled app" +echo " 4) Distribution package (.dmg)" +echo " 5) All of the above" +echo " 6) exit" +echo "" +read -r -p "Choice (1-6): " BUILD_CHOICE +case "$BUILD_CHOICE" in + 1) + DO_BUILD=1 + ;; + 2) + DO_BUNDLE=1 + ;; + 3) + DO_RUN_APP=1 + ;; + 4) + DO_DMG=1 + ;; + 5) + DO_BUILD=1 + DO_BUNDLE=1 + DO_DMG=1 + ;; + 6) + exit 0 + ;; + *) + exit 0 + ;; +esac + +if (( DO_RUN_APP == 1 )); then + APP_BIN="./dist/Rayforge.app/Contents/MacOS/Rayforge" + if [ ! -x "$APP_BIN" ]; then + echo "$APP_BIN not found or not executable." >&2 + echo "Build the app bundle first (option 2)." >&2 + exit 1 + fi + "$APP_BIN" + exit 0 +fi + +if [ ! -f .mac_env ]; then + echo ".mac_env not found. Run scripts/mac/mac_setup.sh first." >&2 + exit 1 +fi + +source .mac_env + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to build Rayforge on macOS." >&2 + exit 1 +fi + +echo "" +echo "" +print_info " Environment Setup" +print_info "--------------------------------------" +echo "" + +VENV_PATH=${VENV_PATH:-.venv} +PYTHON_BOOTSTRAP=python3 +if command -v python3.11 >/dev/null 2>&1; then + PYTHON_BOOTSTRAP=python3.11 +fi +if [ ! -d "$VENV_PATH" ]; then + "$PYTHON_BOOTSTRAP" -m venv "$VENV_PATH" +fi + +VENV_PY="$VENV_PATH/bin/python" +"$VENV_PY" -m pip install --upgrade pip +"$VENV_PY" -m pip install --upgrade build pyinstaller +TMP_REQUIREMENTS=$(mktemp) +trap 'rm -f "$TMP_REQUIREMENTS" "$TMP_REQUIREMENTS.patched"' EXIT +grep -Evi '^(PyOpenGL_accelerate|opencv[_-]python)' \ + requirements.txt > "$TMP_REQUIREMENTS" + +if [ "$(uname -s)" = "Darwin" ]; then + awk \ + -v numpy_version="$MACOS_NUMPY_VERSION" \ + -v scipy_version="$MACOS_SCIPY_VERSION" ' + BEGIN { done_numpy = 0; done_scipy = 0 } + /^numpy[=~> "$TMP_REQUIREMENTS.patched" + mv "$TMP_REQUIREMENTS.patched" "$TMP_REQUIREMENTS" +fi + +if [ "$(uname -s)" = "Darwin" ]; then + echo "Installing pinned OpenCV wheel for macOS..." + "$VENV_PY" -m pip install --only-binary=:all: \ + "opencv-python==4.10.0.84" +fi + +"$VENV_PY" -m pip install -r "$TMP_REQUIREMENTS" +if [ "$(uname -s)" = "Darwin" ]; then + "$VENV_PY" -m pip install --upgrade --force-reinstall \ + --only-binary=:all: \ + "numpy==$MACOS_NUMPY_VERSION" "scipy==$MACOS_SCIPY_VERSION" +fi +rm -f "$TMP_REQUIREMENTS" +trap - EXIT +"$VENV_PY" -m pip install PyOpenGL_accelerate==3.1.10 || \ + echo "PyOpenGL_accelerate install failed; continuing." + +if [ "$(uname -s)" = "Darwin" ]; then + "$VENV_PY" - "$MACOS_NUMPY_VERSION" \ + "$MACOS_SCIPY_VERSION" <<'PY' +import sys + +import numpy +import scipy +from scipy.linalg import null_space +from scipy.ndimage import binary_dilation +from scipy.optimize import least_squares +from scipy.signal import fftconvolve + +assert numpy.__version__ == sys.argv[1] +assert scipy.__version__ == sys.argv[2] +assert all((null_space, binary_dilation, least_squares, fftconvolve)) +print("Verified SciPy modules used by Rayforge.") +PY +fi + +bash scripts/update_translations.sh --compile-only + +VERSION=${VERSION_OVERRIDE:-$(git describe --tags --always 2>/dev/null || \ + echo "v0.0.0-local")} +echo "$VERSION" > rayforge/version.txt + +if (( DO_BUILD == 1 )); then + echo "" + echo "" + print_info " Build" + print_info "--------------------------------------" + echo "" + "$VENV_PY" -m build +elif [ -d "dist/Rayforge.app" ] && (( DO_BUNDLE == 0 )); then + echo "Note: dist/Rayforge.app exists but was not rebuilt." >&2 +fi + +if (( DO_BUNDLE == 1 )); then + echo "" + echo "" + print_info " .app Bundle" + print_info "--------------------------------------" + echo "" + "$VENV_PY" - <<'PY' +import os +import shutil +import stat +from pathlib import Path + +def _onerror(func, path, exc_info): + try: + os.chmod(path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) + func(path) + except Exception: + pass + +for target in ("dist/Rayforge", "dist/Rayforge.app"): + path = Path(target) + if path.exists(): + shutil.rmtree(path, onerror=_onerror) +PY + + # Compile .icon → Assets.car (macOS 26+ Liquid Glass icon format). + # Falls back to legacy .icns if rayforge.icon is not present. + ICON_SOURCE="" + if [ -d "rayforge/resources/icons/rayforge.icon" ]; then + echo "Compiling rayforge.icon → Assets.car..." + rm -f "Assets.car" + if ! xcrun actool rayforge/resources/icons/rayforge.icon \ + --compile "$(pwd)" \ + --app-icon rayforge \ + --platform macosx \ + --target-device mac \ + --minimum-deployment-target "$MACOS_MIN_VERSION" \ + --output-partial-info-plist /dev/null || \ + [ ! -f "Assets.car" ]; then + echo "actool failed; falling back to .icns if available." >&2 + ICON_SOURCE="icns" + else + echo "Assets.car compiled successfully." + ICON_SOURCE="car" + fi + else + echo "rayforge.icon not found, using legacy .icns path." + ICON_SOURCE="icns" + fi + + if [ "$ICON_SOURCE" = "icns" ]; then + if [ ! -f "rayforge.icns" ] || \ + [ "website/static/images/icon-app.svg" -nt "rayforge.icns" ]; then + echo "Generating macOS icon..." + bash scripts/mac/mac_create_icon.sh + else + echo "Icon is up to date, skipping generation." + fi + fi + + "$VENV_PY" -m PyInstaller --clean --noconfirm Rayforge.spec + + APP_ROOT="dist/Rayforge.app/Contents" + FW_DIR="$APP_ROOT/Frameworks" + BIN_DIR="$APP_ROOT/MacOS" + RES_DIR="$APP_ROOT/Resources" + + chmod -R u+w "dist/Rayforge.app" || true + + # Copy Assets.car into Resources and set CFBundleIconName in Info.plist. + # This is what tells macOS to use the Liquid Glass .icon instead of .icns. + if [ "$ICON_SOURCE" = "car" ] && [ -f "Assets.car" ]; then + echo "Installing Assets.car into app bundle..." + cp "Assets.car" "$RES_DIR/Assets.car" + /usr/libexec/PlistBuddy -c "Delete :CFBundleIconFile" \ + "$APP_ROOT/Info.plist" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Add :CFBundleIconName string rayforge" \ + "$APP_ROOT/Info.plist" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Set :CFBundleIconName rayforge" \ + "$APP_ROOT/Info.plist" 2>/dev/null || true + fi + + # Remove conflicting libiconv bundled by cv2. + rm -f "$FW_DIR/libiconv.2.dylib" + + # Replace the launcher with a wrapper that sets env vars, + # keeping the Mach-O as Rayforge.bin. + if [ -f "$BIN_DIR/Rayforge" ] && [ ! -f "$BIN_DIR/Rayforge.bin" ]; then + if file "$BIN_DIR/Rayforge" | grep -q "Mach-O"; then + mv "$BIN_DIR/Rayforge" "$BIN_DIR/Rayforge.bin" + else + cp "$BIN_DIR/Rayforge" "$BIN_DIR/Rayforge.bin" + fi + fi + if [ -f "$BIN_DIR/Rayforge.bin" ]; then + cat > "$BIN_DIR/Rayforge" <<'SH' +#!/bin/bash +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +export DYLD_LIBRARY_PATH="$APP_DIR/Frameworks" +export DYLD_FALLBACK_LIBRARY_PATH="$APP_DIR/Frameworks" +export GI_TYPELIB_PATH="$APP_DIR/Resources/gi_typelibs" +export GIO_EXTRA_MODULES="$APP_DIR/Frameworks/gio_modules" +exec "$APP_DIR/MacOS/Rayforge.bin" "$@" +SH + chmod +x "$BIN_DIR/Rayforge" + install_name_tool -add_rpath @executable_path/../Frameworks \ + "$BIN_DIR/Rayforge.bin" 2>/dev/null || true + fi + + BREW_PREFIX="" + if command -v brew >/dev/null 2>&1; then + BREW_PREFIX=$(brew --prefix) + fi + if [ -z "$BREW_PREFIX" ]; then + if [ -d "/opt/homebrew" ]; then + BREW_PREFIX="/opt/homebrew" + else + BREW_PREFIX="/usr/local" + fi + fi + + # Ship critical libs from Homebrew and fix their IDs. + for lib in \ + libpng16.16.dylib \ + libsharpyuv.0.dylib \ + libfontconfig.1.dylib \ + libfreetype.6.dylib \ + libintl.8.dylib \ + libvips.42.dylib \ + libvips-cpp.42.dylib \ + libOpenEXR-3_4.33.dylib \ + libOpenEXRCore-3_4.33.dylib \ + libIex-3_4.33.dylib \ + libIlmThread-3_4.33.dylib \ + libImath-3_2.30.dylib \ + libarchive.13.dylib \ + libcfitsio.10.dylib \ + libexif.12.dylib \ + libfftw3.3.dylib \ + libhwy.1.dylib \ + libopenjp2.7.dylib + do + if [ -f "$BREW_PREFIX/lib/$lib" ]; then + rm -f "$FW_DIR/$lib" + cp "$BREW_PREFIX/lib/$lib" "$FW_DIR/" + install_name_tool -id "@rpath/$lib" "$FW_DIR/$lib" + fi + done + copy_keg_lib() { + local libname=$1 + shift + if [ -f "$FW_DIR/$libname" ]; then + return + fi + for lib_dir in "$@"; do + if [ -f "$lib_dir/$libname" ]; then + rm -f "$FW_DIR/$libname" + cp "$lib_dir/$libname" "$FW_DIR/" + install_name_tool -id "@rpath/$libname" "$FW_DIR/$libname" + break + fi + done + } + copy_keg_lib libfontconfig.1.dylib \ + "$BREW_PREFIX/opt/fontconfig/lib" \ + "/usr/local/opt/fontconfig/lib" \ + "/opt/homebrew/opt/fontconfig/lib" + copy_keg_lib libfreetype.6.dylib \ + "$BREW_PREFIX/opt/freetype/lib" \ + "/usr/local/opt/freetype/lib" \ + "/opt/homebrew/opt/freetype/lib" + copy_keg_lib libsharpyuv.0.dylib \ + "$BREW_PREFIX/opt/webp/lib" \ + "/usr/local/opt/webp/lib" \ + "/opt/homebrew/opt/webp/lib" + copy_keg_lib libintl.8.dylib \ + "$BREW_PREFIX/opt/gettext/lib" \ + "/usr/local/opt/gettext/lib" \ + "/opt/homebrew/opt/gettext/lib" + copy_keg_lib libOpenEXR-3_4.33.dylib \ + "$BREW_PREFIX/opt/openexr/lib" \ + "/usr/local/opt/openexr/lib" \ + "/opt/homebrew/opt/openexr/lib" + copy_keg_lib libOpenEXRCore-3_4.33.dylib \ + "$BREW_PREFIX/opt/openexr/lib" \ + "/usr/local/opt/openexr/lib" \ + "/opt/homebrew/opt/openexr/lib" + copy_keg_lib libIex-3_4.33.dylib \ + "$BREW_PREFIX/opt/openexr/lib" \ + "/usr/local/opt/openexr/lib" \ + "/opt/homebrew/opt/openexr/lib" + copy_keg_lib libIlmThread-3_4.33.dylib \ + "$BREW_PREFIX/opt/openexr/lib" \ + "/usr/local/opt/openexr/lib" \ + "/opt/homebrew/opt/openexr/lib" + copy_keg_lib libImath-3_2.30.dylib \ + "$BREW_PREFIX/opt/imath/lib" \ + "/usr/local/opt/imath/lib" \ + "/opt/homebrew/opt/imath/lib" + if [ ! -f "$FW_DIR/libpng16.16.dylib" ]; then + for lib_dir in \ + "$BREW_PREFIX/opt/libpng/lib" \ + "/usr/local/opt/libpng/lib" \ + "/opt/homebrew/opt/libpng/lib" + do + if [ -f "$lib_dir/libpng16.16.dylib" ]; then + rm -f "$FW_DIR/libpng16.16.dylib" + cp "$lib_dir/libpng16.16.dylib" "$FW_DIR/" + install_name_tool -id "@rpath/libpng16.16.dylib" \ + "$FW_DIR/libpng16.16.dylib" + break + fi + done + fi + if [ ! -f "$FW_DIR/libarchive.13.dylib" ]; then + for lib_dir in \ + "$BREW_PREFIX/opt/libarchive/lib" \ + "/usr/local/opt/libarchive/lib" \ + "/opt/homebrew/opt/libarchive/lib" + do + if [ -f "$lib_dir/libarchive.13.dylib" ]; then + rm -f "$FW_DIR/libarchive.13.dylib" + cp "$lib_dir/libarchive.13.dylib" "$FW_DIR/" + install_name_tool -id "@rpath/libarchive.13.dylib" \ + "$FW_DIR/libarchive.13.dylib" + break + fi + done + fi + + copy_missing_deps() { + local changed=0 + local dep + local libname + local candidate + local search_dirs=("$BREW_PREFIX/lib" "/usr/local/lib" "/opt/homebrew/lib") + + while read -r dep; do + libname=$(basename "$dep") + if [ -f "$FW_DIR/$libname" ]; then + continue + fi + candidate="" + for base in "${search_dirs[@]}"; do + if [ -f "$base/$libname" ]; then + candidate="$base/$libname" + break + fi + done + if [ -z "$candidate" ]; then + for base in "$BREW_PREFIX/opt" "/usr/local/opt" "/opt/homebrew/opt"; do + if [ -d "$base" ]; then + for opt_lib in "$base"/*/lib; do + if [ -f "$opt_lib/$libname" ]; then + candidate="$opt_lib/$libname" + break + fi + done + fi + if [ -n "$candidate" ]; then + break + fi + done + fi + if [ -n "$candidate" ]; then + rm -f "$FW_DIR/$libname" + cp "$candidate" "$FW_DIR/" + install_name_tool -id "@rpath/$libname" \ + "$FW_DIR/$libname" + changed=1 + fi + done < <(otool -L "$BIN_DIR/Rayforge.bin" \ + "$FW_DIR"/*.dylib 2>/dev/null | \ + awk '{print $1}' | \ + grep -E '^/usr/local/|^/opt/homebrew/|^@rpath/' | \ + sort -u || true) + + if (( changed == 1 )); then + return 0 + fi + return 1 + } + + # Iteratively pull in any Homebrew deps referenced by bundled binaries. + for _ in 1 2 3; do + copy_missing_deps || break + done + + # Fix all library references to use @rpath instead of absolute paths + echo "Fixing library references..." + { + chmod -R u+w "$FW_DIR" "$BIN_DIR" 2>/dev/null || true + find "$FW_DIR" -name "*.dylib" -print0 | while IFS= read -r -d '' dylib; do + otool -L "$dylib" | grep -E '/usr/local/|/opt/homebrew/' | \ + awk '{print $1}' | while read dep; do + libname=$(basename "$dep") + if [ -f "$FW_DIR/$libname" ]; then + install_name_tool -change "$dep" "@rpath/$libname" "$dylib" 2>/dev/null || true + fi + done || true + done + for bin in "$BIN_DIR/Rayforge" "$BIN_DIR/Rayforge.bin"; do + [ -f "$bin" ] || continue + if ! file "$bin" | grep -q "Mach-O"; then + continue + fi + otool -L "$bin" | grep -E '/usr/local/|/opt/homebrew/' | \ + awk '{print $1}' | while read dep; do + libname=$(basename "$dep") + if [ -f "$FW_DIR/$libname" ]; then + install_name_tool -change "$dep" "@rpath/$libname" "$bin" 2>/dev/null || true + fi + done || true + done + + # Force libpng references to @rpath to avoid runtime lookups in Homebrew. + for target in "$FW_DIR"/*.dylib "$BIN_DIR/Rayforge.bin"; do + [ -f "$target" ] || continue + otool -L "$target" | awk '{print $1}' | \ + grep -E '/opt/homebrew/opt/libpng/|/usr/local/opt/libpng/' | \ + while read dep; do + install_name_tool -change "$dep" "@rpath/libpng16.16.dylib" \ + "$target" 2>/dev/null || true + done || true + done + if [ -f "$FW_DIR/libfreetype.6.dylib" ]; then + otool -L "$FW_DIR/libfreetype.6.dylib" | awk '{print $1}' | \ + grep -E '/opt/homebrew/opt/libpng/|/usr/local/opt/libpng/' | \ + while read dep; do + install_name_tool -change "$dep" "@rpath/libpng16.16.dylib" \ + "$FW_DIR/libfreetype.6.dylib" 2>/dev/null || true + done || true + fi + if [ -f "$FW_DIR/libfontconfig.1.dylib" ]; then + if [ -f "$FW_DIR/libfreetype.6.dylib" ]; then + otool -L "$FW_DIR/libfontconfig.1.dylib" | awk '{print $1}' | \ + grep -E '/opt/homebrew/opt/freetype/|/usr/local/opt/freetype/' | \ + while read dep; do + install_name_tool -change "$dep" "@rpath/libfreetype.6.dylib" \ + "$FW_DIR/libfontconfig.1.dylib" 2>/dev/null || true + done || true + fi + if [ -f "$FW_DIR/libintl.8.dylib" ]; then + otool -L "$FW_DIR/libfontconfig.1.dylib" | awk '{print $1}' | \ + grep -E '/opt/homebrew/opt/gettext/|/usr/local/opt/gettext/' | \ + while read dep; do + install_name_tool -change "$dep" "@rpath/libintl.8.dylib" \ + "$FW_DIR/libfontconfig.1.dylib" 2>/dev/null || true + done || true + fi + fi + } || true + + # Refresh cv2 dylib symlinks to the parent copies. + if [ -d "$FW_DIR/cv2/__dot__dylibs" ]; then + pushd "$FW_DIR/cv2/__dot__dylibs" >/dev/null + for lib in libpng16.16.dylib libfontconfig.1.dylib \ + libfreetype.6.dylib libintl.8.dylib + do + ln -sf ../../"$lib" "$lib" + done + popd >/dev/null + fi + + # Note: GTK4 typelibs are automatically bundled by PyInstaller to Resources/gi_typelibs + + # Re-sign after install_name_tool and dylib rewrites to keep + # macOS code-signing validation valid on Apple Silicon. + if [ "$(uname -m)" = "arm64" ]; then + APP_BUNDLE="$(pwd)/dist/Rayforge.app" + echo "Re-signing app bundle..." + if [ ! -d "$APP_BUNDLE" ]; then + echo "App bundle not found at $APP_BUNDLE" >&2 + exit 1 + fi + rm -rf "$APP_BUNDLE/Contents/_CodeSignature" + if ! codesign --force --deep --sign - "$APP_BUNDLE"; then + echo "Initial deep re-sign failed, retrying..." >&2 + sleep 1 + codesign --force --deep --sign - "$APP_BUNDLE" + fi + if ! codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"; then + echo "Warning: codesign verification failed for $APP_BUNDLE" >&2 + fi + fi + + # TODO: Bundle vips modules and gdk-pixbuf loaders when vips is installed with SVG support + # if [ -d "/usr/local/lib/vips-modules-8.17" ]; then + # cp -r "/usr/local/lib/vips-modules-8.17" "$FW_DIR/" || true + # fi + # if [ -d "/usr/local/lib/gdk-pixbuf-2.0" ]; then + # cp -r "/usr/local/lib/gdk-pixbuf-2.0" "$FW_DIR/" || true + # fi + + # Make sure the plist still points to the wrapper. + /usr/libexec/PlistBuddy -c "Set :CFBundleExecutable Rayforge" \ + "$APP_ROOT/Info.plist" 2>/dev/null || true + + echo "Cleaning dist/*.whl and dist/*.gz after app bundle..." + rm -f dist/*.whl dist/*.gz dist/*.tar.gz +fi + +if (( DO_DMG == 1 )); then + echo "" + echo "" + print_info " .dmg Distribution package" + print_info "--------------------------------------" + echo "" + echo "Creating DMG..." + if [ ! -d "dist/Rayforge.app" ]; then + echo "dist/Rayforge.app not found.\nBuild the app bundle first." >&2 + exit 1 + fi + DMG_PATH="dist/Rayforge_${VERSION}.dmg" + rm -f "$DMG_PATH" + hdiutil create -volname "Rayforge" -srcfolder "dist/Rayforge.app" \ + -ov -format UDZO "$DMG_PATH" +fi + +if (( DO_BUILD == 1 )) && (( DO_BUNDLE == 1 )) && (( DO_DMG == 1 )); then + echo "Build artifacts created in dist/, dist/*.whl, dist/Rayforge.app, and dist/Rayforge.dmg" +elif (( DO_BUILD == 1 )) && (( DO_BUNDLE == 1 )); then + echo "Build artifacts created in dist/, dist/*.whl, and dist/Rayforge.app" +elif (( DO_BUILD == 1 )); then + echo "Build artifacts created in dist/ and dist/*.whl" +elif (( DO_BUNDLE == 1 )); then + echo "App bundle created in dist/Rayforge.app" +elif (( DO_DMG == 1 )); then + echo "DMG created in dist/Rayforge.dmg" +fi + +echo "" +echo "" +print_info " Finished!" +echo "" diff --git a/scripts/mac/mac_create_icon.sh b/scripts/mac/mac_create_icon.sh new file mode 100755 index 000000000..778868e92 --- /dev/null +++ b/scripts/mac/mac_create_icon.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# Script to generate macOS ICNS icon from SVG source +# Uses only native macOS tools: rsvg-convert and iconutil +# +# Requirements: +# - rsvg-convert: brew install librsvg +# - iconutil: built-in on macOS + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" + +# Paths +SVG_PATH="$PROJECT_ROOT/website/static/images/icon-app.svg" +ICONSET_PATH="$PROJECT_ROOT/build/icon.iconset" +OUTPUT_PATH="$PROJECT_ROOT/rayforge.icns" + +# Check if SVG exists +if [ ! -f "$SVG_PATH" ]; then + echo -e "${RED}Error: SVG file not found at $SVG_PATH${NC}" + exit 1 +fi + +# Check if rsvg-convert is installed +if ! command -v rsvg-convert &> /dev/null; then + echo -e "${RED}Error: rsvg-convert not found${NC}" + echo "Install with: brew install librsvg" + exit 1 +fi + +# Check if iconutil is available (should be on all macOS systems) +if ! command -v iconutil &> /dev/null; then + echo -e "${RED}Error: iconutil not found. This script requires macOS.${NC}" + exit 1 +fi + +echo -e "${GREEN}Source SVG:${NC} $SVG_PATH" +echo -e "${GREEN}Output ICNS:${NC} $OUTPUT_PATH" +echo "" + +# Create build directory +mkdir -p "$(dirname "$OUTPUT_PATH")" + +# Remove existing iconset if it exists +if [ -d "$ICONSET_PATH" ]; then + echo -e "${YELLOW}Removing existing iconset...${NC}" + rm -rf "$ICONSET_PATH" +fi + +# Create iconset directory +echo -e "${GREEN}Creating iconset directory...${NC}" +mkdir -p "$ICONSET_PATH" + +# Function to generate PNG at specific size +generate_png() { + local size=$1 + local scale=$2 + local pixel_size=$((size * scale)) + + if [ $scale -eq 1 ]; then + local filename="icon_${size}x${size}.png" + else + local filename="icon_${size}x${size}@${scale}x.png" + fi + + local output_file="$ICONSET_PATH/$filename" + + echo " Generating ${pixel_size}x${pixel_size} → $filename" + rsvg-convert -w $pixel_size -h $pixel_size "$SVG_PATH" -o "$output_file" +} + +# Generate all required sizes for macOS ICNS +# Format: size scale +echo -e "\n${GREEN}Generating PNG files...${NC}" + +# 16x16 +generate_png 16 1 +generate_png 16 2 + +# 32x32 +generate_png 32 1 +generate_png 32 2 + +# 128x128 +generate_png 128 1 +generate_png 128 2 + +# 256x256 +generate_png 256 1 +generate_png 256 2 + +# 512x512 +generate_png 512 1 +generate_png 512 2 + +# 1024x1024 (only @2x for 512pt displays) +echo " Generating 1024x1024 → icon_512x512@2x.png" +rsvg-convert -w 1024 -h 1024 "$SVG_PATH" -o "$ICONSET_PATH/icon_512x512@2x.png" + +# Generate ICNS file using iconutil +echo -e "\n${GREEN}Generating ICNS file...${NC}" +iconutil -c icns -o "$OUTPUT_PATH" "$ICONSET_PATH" + +# Clean up iconset directory +echo -e "\n${GREEN}Cleaning up temporary files...${NC}" +rm -rf "$ICONSET_PATH" + +echo -e "\n${GREEN}✓ Done!${NC} ICNS file created at: ${YELLOW}$OUTPUT_PATH${NC}" +echo -e "File size: $(du -h "$OUTPUT_PATH" | cut -f1)" diff --git a/scripts/mac/mac_setup.sh b/scripts/mac/mac_setup.sh new file mode 100755 index 000000000..3d92d8916 --- /dev/null +++ b/scripts/mac/mac_setup.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL=0 +RUN_APP=0 +ENV_WRITTEN=0 +SKIP_DEPS=() +GREEN="\033[0;32m" +RED="\033[0;31m" +NC="\033[0m" +BREWFILE="scripts/mac/Brewfile" + +print_info() { + local title=$1 + printf "${GREEN}%s${NC}\n" "$title" +} + +print_error() { + local msg=$1 + printf "${RED}%s${NC}\n" "$msg" >&2 +} + +brew_bundle() { + local subcommand="$1" + shift + local skip_deps="${SKIP_DEPS[*]:-}" + local args=(bundle "$subcommand" --file "$BREWFILE" "$@") + + if [[ "$subcommand" == "install" ]]; then + args+=(--no-upgrade) + fi + + if [[ -n "$skip_deps" ]]; then + HOMEBREW_BUNDLE_BREW_SKIP="$skip_deps" brew "${args[@]}" + else + brew "${args[@]}" + fi +} + +for arg in "$@"; do + case "$arg" in + --install) + INSTALL=1 + ;; + --skip-*) + SKIP_DEPS+=("${arg#--skip-}") + ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; + esac +done + +if (( INSTALL == 1 )); then + INSTALL=1 +else + echo "" + print_info "======================================" + print_info " Rayforge macOS Setup Script" + print_info "======================================" + echo "" + echo "Select setup option:" + echo " 1) Check dependencies only" + echo " 2) Install missing dependencies" + echo " 3) Run Rayforge" + echo " 4) Exit" + echo "" + echo " Tip: You can skip Homebrew checks for specific packages." + echo " Example: ./scripts/mac/mac_setup.sh --skip-libvips --skip-openslide" + echo "" + read -r -p "Choice (1-4): " SETUP_CHOICE + case "$SETUP_CHOICE" in + 1) + INSTALL=0 + ;; + 2) + INSTALL=1 + ;; + 3) + RUN_APP=1 + ;; + 4) + exit 0 + ;; + *) + exit 0 + ;; + esac +fi + +if (( RUN_APP == 0 )); then + if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew is required to set up the macOS toolchain." >&2 + exit 1 + fi + + BREW_PREFIX=$(brew --prefix) + LIBFFI_PREFIX=$(brew --prefix libffi 2>/dev/null || true) + if [[ -z "$LIBFFI_PREFIX" ]]; then + LIBFFI_PREFIX="$BREW_PREFIX/opt/libffi" + fi + + if ! brew_bundle check --verbose; then + if (( INSTALL == 1 )); then + brew_bundle install + else + echo "Run again and choose 'Install missing dependencies'." >&2 + exit 1 + fi + fi + + cat > .mac_env < dict: + """Create a z-image-turbo text-to-image workflow.""" + if seed == -1: + seed = random.randint(0, 2**32 - 1) + + return { + "1": { + "inputs": { + "width": width, + "height": height, + "batch_size": 1, + }, + "class_type": "EmptySD3LatentImage", + }, + "2": { + "inputs": { + "text": prompt, + "clip": ["3", 0], + }, + "class_type": "CLIPTextEncode", + }, + "3": { + "inputs": { + "clip_name": "qwen_3_4b.safetensors", + "type": "lumina2", + }, + "class_type": "CLIPLoader", + }, + "4": { + "inputs": { + "vae_name": "ae.safetensors", + }, + "class_type": "VAELoader", + }, + "5": { + "inputs": { + "unet_name": "z_image_turbo_bf16.safetensors", + "weight_dtype": "default", + }, + "class_type": "UNETLoader", + }, + "6": { + "inputs": { + "model": ["5", 0], + "shift": 3, + }, + "class_type": "ModelSamplingAuraFlow", + }, + "7": { + "inputs": { + "conditioning": ["2", 0], + }, + "class_type": "ConditioningZeroOut", + }, + "8": { + "inputs": { + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1, + "model": ["6", 0], + "positive": ["2", 0], + "negative": ["7", 0], + "latent_image": ["1", 0], + }, + "class_type": "KSampler", + }, + "9": { + "inputs": { + "samples": ["8", 0], + "vae": ["4", 0], + }, + "class_type": "VAEDecode", + }, + "10": { + "inputs": { + "filename_prefix": "z-image-turbo", + "images": ["9", 0], + }, + "class_type": "SaveImage", + }, + } + + +def create_3d_from_image_workflow( + image_path: str | Path, + steps: int, + cfg: float, + seed: int, + resolution: int, +) -> dict: + """Create a Hunyuan3D image-to-3D workflow.""" + if seed == -1: + seed = random.randint(0, 2**32 - 1) + + image_path = Path(image_path) + filename = image_path.name + output_path = Path(COMFYUI_OUTPUT_DIR) + comfy_input_dir = output_path.parent / "input" + + comfy_input_dir.mkdir(parents=True, exist_ok=True) + dest_path = comfy_input_dir / filename + + if image_path.resolve() != dest_path.resolve(): + shutil.copy2(image_path, dest_path) + + return { + "1": { + "inputs": {"ckpt_name": "hunyuan_3d_v2.1.safetensors"}, + "class_type": "ImageOnlyCheckpointLoader", + }, + "2": { + "inputs": {"image": filename}, + "class_type": "LoadImage", + }, + "3": { + "inputs": {"model": ["1", 0], "shift": 1}, + "class_type": "ModelSamplingAuraFlow", + }, + "4": { + "inputs": { + "clip_vision": ["1", 1], + "image": ["2", 0], + "crop": "center", + }, + "class_type": "CLIPVisionEncode", + }, + "5": { + "inputs": {"clip_vision_output": ["4", 0]}, + "class_type": "Hunyuan3Dv2Conditioning", + }, + "6": { + "inputs": {"resolution": resolution, "batch_size": 1}, + "class_type": "EmptyLatentHunyuan3Dv2", + }, + "7": { + "inputs": { + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": ["3", 0], + "positive": ["5", 0], + "negative": ["5", 1], + "latent_image": ["6", 0], + }, + "class_type": "KSampler", + }, + "8": { + "inputs": { + "samples": ["7", 0], + "vae": ["1", 2], + "num_chunks": 8000, + "octree_resolution": 256, + }, + "class_type": "VAEDecodeHunyuan3D", + }, + "9": { + "inputs": { + "voxel": ["8", 0], + "algorithm": "surface net", + "threshold": 0.6, + }, + "class_type": "VoxelToMesh", + }, + "10": { + "inputs": {"filename_prefix": "mesh/ComfyUI", "mesh": ["9", 0]}, + "class_type": "SaveGLB", + }, + } + + +def generate_image( + prompt: str, + negative_prompt: str = "", + width: int = 1024, + height: int = 1024, + steps: int = 4, + cfg: float = 1.0, + seed: int = -1, + model: str = "", +) -> str: + """Generate an image using z-image-turbo text-to-image.""" + workflow = create_text_to_image_workflow( + prompt=prompt, + negative_prompt=negative_prompt, + width=width, + height=height, + steps=steps, + cfg=cfg, + seed=seed, + model=model, + ) + + prompt_data = json.dumps({"prompt": workflow}).encode("utf-8") + url = f"{COMFYUI_URL}/prompt" + req = urllib.request.Request( + url, data=prompt_data, headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=300) as response: + result = json.loads(response.read().decode("utf-8")) + + prompt_id = result["prompt_id"] + return prompt_id + + +def generate_3d_from_image( + image_path: str | Path, + steps: int = 50, + cfg: float = 7.0, + seed: int = -1, + resolution: int = 1024, +) -> str: + """Generate a 3D model using Hunyuan3D image-to-3D.""" + workflow = create_3d_from_image_workflow( + image_path=image_path, + steps=steps, + cfg=cfg, + seed=seed, + resolution=resolution, + ) + + prompt_data = json.dumps({"prompt": workflow}).encode("utf-8") + url = f"{COMFYUI_URL}/prompt" + req = urllib.request.Request( + url, data=prompt_data, headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=300) as response: + result = json.loads(response.read().decode("utf-8")) + + prompt_id = result["prompt_id"] + return prompt_id + + +def get_history(prompt_id: str) -> dict: + """Get execution history for a prompt.""" + url = f"{COMFYUI_URL}/history/{prompt_id}" + + with urllib.request.urlopen(url, timeout=300) as response: + history = json.loads(response.read().decode("utf-8")) + + return history + + +def get_queue() -> dict: + """Get current queue status.""" + url = f"{COMFYUI_URL}/queue" + + with urllib.request.urlopen(url, timeout=300) as response: + queue_data = json.loads(response.read().decode("utf-8")) + + return queue_data + + +def clear_queue() -> None: + """Clear current queue.""" + prompt_data = json.dumps({"clear": True}).encode("utf-8") + url = f"{COMFYUI_URL}/queue" + req = urllib.request.Request( + url, data=prompt_data, headers={"Content-Type": "application/json"} + ) + + urllib.request.urlopen(req, timeout=300) + + +def get_models() -> list[str]: + """List available checkpoint models.""" + try: + url = f"{COMFYUI_URL}/object_info/CheckpointLoaderSimple" + + with urllib.request.urlopen(url, timeout=300) as response: + data = json.loads(response.read().decode("utf-8")) + + if "CheckpointLoaderSimple" in data: + required = data["CheckpointLoaderSimple"]["input"]["required"] + if "ckpt_name" in required: + return required["ckpt_name"][0] + except (OSError, ValueError, KeyError): + logger.debug("Could not fetch checkpoint model list", exc_info=True) + + return [] + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="ComfyUI client for image and 3D model generation" + ) + subparsers = parser.add_subparsers( + dest="command", required=True, help="Available commands" + ) + + generate_image_parser = subparsers.add_parser( + "generate-image", + help="Generate an image using z-image-turbo text-to-image", + ) + generate_image_parser.add_argument( + "prompt", help="Text prompt for image generation" + ) + generate_image_parser.add_argument( + "--negative-prompt", + default="", + help="Negative prompt for things to avoid", + ) + generate_image_parser.add_argument( + "--width", + type=int, + default=1024, + help="Image width in pixels (default: 1024)", + ) + generate_image_parser.add_argument( + "--height", + type=int, + default=1024, + help="Image height in pixels (default: 1024)", + ) + generate_image_parser.add_argument( + "--steps", + type=int, + default=4, + help="Number of sampling steps (default: 4)", + ) + generate_image_parser.add_argument( + "--cfg", + type=float, + default=1.0, + help="Classifier free guidance scale (default: 1.0)", + ) + generate_image_parser.add_argument( + "--seed", + type=int, + default=-1, + help="Random seed (-1 for random) (default: -1)", + ) + generate_image_parser.add_argument( + "--model", + default="", + help="Model checkpoint filename (empty for default)", + ) + + generate_3d_parser = subparsers.add_parser( + "generate-3d", help="Generate a 3D model using Hunyuan3D image-to-3D" + ) + generate_3d_parser.add_argument( + "image_path", help="Path to image file for 3D model generation" + ) + generate_3d_parser.add_argument( + "--steps", + type=int, + default=50, + help="Number of sampling steps (default: 50)", + ) + generate_3d_parser.add_argument( + "--cfg", + type=float, + default=7.0, + help="Classifier free guidance scale (default: 7.0)", + ) + generate_3d_parser.add_argument( + "--seed", + type=int, + default=-1, + help="Random seed (-1 for random) (default: -1)", + ) + generate_3d_parser.add_argument( + "--resolution", + type=int, + default=1024, + help="Resolution for 3D generation (1-8192) (default: 1024)", + ) + + get_history_parser = subparsers.add_parser( + "get-history", help="Get execution history for a prompt" + ) + get_history_parser.add_argument( + "prompt_id", help="Prompt ID to get history for" + ) + + subparsers.add_parser("get-queue", help="Get current queue status") + + subparsers.add_parser("clear-queue", help="Clear current queue") + + subparsers.add_parser( + "get-models", help="List available checkpoint models" + ) + + args = parser.parse_args() + + if args.command == "generate-image": + prompt_id = generate_image( + prompt=args.prompt, + negative_prompt=args.negative_prompt, + width=args.width, + height=args.height, + steps=args.steps, + cfg=args.cfg, + seed=args.seed, + model=args.model, + ) + print( + f"z-image-turbo generation started. " + f"Prompt ID: {prompt_id}\n" + f"Use get-history tool to check status." + ) + + elif args.command == "generate-3d": + prompt_id = generate_3d_from_image( + image_path=args.image_path, + steps=args.steps, + cfg=args.cfg, + seed=args.seed, + resolution=args.resolution, + ) + print( + f"3D model generation from image started. " + f"Prompt ID: {prompt_id}\n" + f"Use get-history tool to check status." + ) + + elif args.command == "get-history": + history = get_history(args.prompt_id) + + if args.prompt_id not in history: + print(f"No history found for prompt ID: {args.prompt_id}") + sys.exit(1) + + prompt_info = history[args.prompt_id] + status = prompt_info.get("status", {}) + + result_text = f"Prompt ID: {args.prompt_id}\n" + result_text += f"Status: {json.dumps(status, indent=2)}\n" + + outputs = prompt_info.get("outputs", {}) + for node_output in outputs.values(): + if "images" in node_output: + for img in node_output["images"]: + filename = img["filename"] + subfolder = img.get("subfolder", "") + + if subfolder: + filepath = str( + Path(COMFYUI_OUTPUT_DIR) / subfolder / filename + ) + else: + filepath = str(Path(COMFYUI_OUTPUT_DIR) / filename) + + result_text += f"\nGenerated image: {filepath}\n" + elif "mesh" in node_output: + for mesh in node_output["mesh"]: + filename = mesh["filename"] + subfolder = mesh.get("subfolder", "") + + if subfolder: + filepath = str( + Path(COMFYUI_OUTPUT_DIR) / subfolder / filename + ) + else: + filepath = str(Path(COMFYUI_OUTPUT_DIR) / filename) + + result_text += f"\nGenerated 3D model: {filepath}\n" + + print(result_text) + + elif args.command == "get-queue": + queue_data = get_queue() + + queue_running = queue_data.get("queue_running", []) + queue_pending = queue_data.get("queue_pending", []) + + result_text = f"Queue running: {len(queue_running)} items\n" + result_text += f"Queue pending: {len(queue_pending)} items\n" + + if queue_running: + result_text += "\nRunning:\n" + for item in queue_running: + prompt_id = item[1] + result_text += f" - Prompt ID: {prompt_id}\n" + + if queue_pending: + result_text += "\nPending:\n" + for item in queue_pending: + prompt_id = item[1] + result_text += f" - Prompt ID: {prompt_id}\n" + + print(result_text) + + elif args.command == "clear-queue": + clear_queue() + print("Queue cleared successfully") + + elif args.command == "get-models": + models = get_models() + result_text = f"Available models ({len(models)}):\n" + for model in models: + result_text += f" - {model}\n" + print(result_text) + + +if __name__ == "__main__": + main() diff --git a/scripts/media/generate_blender_setup.py b/scripts/media/generate_blender_setup.py new file mode 100755 index 000000000..46554465b --- /dev/null +++ b/scripts/media/generate_blender_setup.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +"""Generate Blender setup for video project assembly. + +This script directly creates a Blender .blend file with clips, audio tracks, +and text overlays from a CHANGELOG.md. +""" + +import argparse +import sys +from pathlib import Path + +import bpy + + +def parse_changelog(changelog_path): + """Parse CHANGELOG.md to extract version headers and content. + + Args: + changelog_path: Path to CHANGELOG.md file. + + Returns: + List of tuples: (version, content_lines). + """ + changelog_path = Path(changelog_path) + + if not changelog_path.exists(): + msg = f"Error: CHANGELOG.md not found at '{changelog_path}'" + print(msg, file=sys.stderr) + return [] + + sections = [] + current_version = None + current_content = [] + + with open(changelog_path, encoding="utf-8") as f: + for line in f: + line = line.rstrip() + if line.startswith("## ["): + if current_version is not None: + sections.append((current_version, current_content)) + current_version = line[4:].split("]")[0] + current_content = [] + elif current_version is not None: + current_content.append(line) + + if current_version is not None: + sections.append((current_version, current_content)) + + return sections + + +def find_media_files(media_dir, extensions=(".mp4", ".mkv", ".mov", ".avi")): + """Find video files in media directory. + + Args: + media_dir: Path to media directory. + extensions: Tuple of video file extensions to include. + + Returns: + Sorted list of video file paths. + """ + media_dir = Path(media_dir) + + if not media_dir.exists(): + msg = f"Error: Media directory '{media_dir}' does not exist" + print(msg, file=sys.stderr) + return [] + + video_files = [] + for ext in extensions: + video_files.extend(media_dir.glob(f"*{ext}")) + + return sorted(video_files) + + +def find_previous_blend_file(release_dir): + """Find the previous release's blend file. + + Args: + release_dir: Path to current release directory. + + Returns: + Path to previous blend file or None. + """ + media_dir = Path(__file__).parent.parent / "media" + if not media_dir.exists(): + return None + + release_dirs = sorted( + [d for d in media_dir.iterdir() if d.is_dir()], + key=lambda x: x.name, + reverse=True, + ) + + for release in release_dirs: + if release == release_dir: + continue + blend_file = release / "draft_edit.blend" + if blend_file.exists(): + return blend_file + return None + + +def find_thumbnail(release_dir): + """Find the thumbnail for the current release. + + Args: + release_dir: Path to current release directory. + + Returns: + Path to thumbnail file or None. + """ + release_dir = Path(release_dir) + drafts_dir = release_dir / "drafts" + if drafts_dir.exists(): + for i in range(1, 6): + thumbnail = drafts_dir / f"thumbnail{i}.png" + if thumbnail.exists(): + return thumbnail + return None + + +def load_template(template_path): + """Load template blend file. + + Args: + template_path: Path to template .blend file. + + Returns: + True if template was loaded, False otherwise. + """ + if template_path and Path(template_path).exists(): + bpy.ops.wm.open_mainfile(filepath=str(template_path)) + return True + return False + + +def clear_sequencer(): + """Clear all strips from the video sequencer.""" + if bpy.context.scene.sequence_editor is None: + bpy.context.scene.sequence_editor_create() + + seq_editor = bpy.context.scene.sequence_editor + strips = list(seq_editor.strips) + for strip in strips: + seq_editor.strips.remove(strip) + + +def setup_scene_settings(res_x, res_y, fps_val): + """Configure render settings. + + Args: + res_x: Resolution width in pixels. + res_y: Resolution height in pixels. + fps_val: Frames per second. + """ + bpy.context.scene.render.resolution_x = res_x + bpy.context.scene.render.resolution_y = res_y + bpy.context.scene.render.fps = fps_val + + +def add_thumbnail(thumbnail_path): + """Add thumbnail as first frame. + + Args: + thumbnail_path: Path to thumbnail image. + + Returns: + Frame number to start video clips from. + """ + if not thumbnail_path or not Path(thumbnail_path).exists(): + return 1 + + seq_editor = bpy.context.scene.sequence_editor + seq_editor.strips.new_image( + name="thumbnail", + filepath=str(thumbnail_path), + channel=1, + frame_start=1, + ) + strip = seq_editor.strips["thumbnail"] + strip.frame_final_duration = 1 + return 2 + + +def add_video_clips(video_files, start_frame): + """Add video clips to the timeline. + + Args: + video_files: List of video file paths. + start_frame: Frame number to start adding clips. + + Returns: + Last frame number after all clips. + """ + current_frame = start_frame + track_video = 1 + seq_editor = bpy.context.scene.sequence_editor + + for i, filepath in enumerate(video_files): + full_path = str(filepath) + strip_name = f"video_{i}" + + seq_editor.strips.new_movie( + name=strip_name, + filepath=full_path, + channel=track_video, + frame_start=current_frame, + ) + + strip = seq_editor.strips[strip_name] + current_frame += strip.frame_final_duration + + return current_frame - 1 + + +def add_text_overlays(sections, start_frame, duration=180): + """Add text strips from changelog sections. + + Args: + sections: List of (version, content) tuples. + start_frame: Frame number to start adding text overlays. + duration: Duration of each text overlay in frames. + """ + if not sections: + return + + track_text = 3 + current_frame = start_frame + seq_editor = bpy.context.scene.sequence_editor + + for i, (version, _) in enumerate(sections): + title_text = f"Release {version}" + strip_name = f"text_{version}" + + seq_editor.strips.new_effect( + name=strip_name, + type="TEXT", + channel=track_text, + frame_start=current_frame, + length=duration, + ) + + strip = seq_editor.strips[strip_name] + strip.text = title_text + strip.location = (0.5, 0.7) + strip.font_size = 150 + strip.color = (1.0, 1.0, 1.0, 1.0) + + current_frame += duration + + +def generate_blender_file( + media_dir, + output_blend, + changelog_path=None, + resolution_x=1920, + resolution_y=1080, + fps=30, + template_path=None, + thumbnail_path=None, +): + """Generate Blender .blend file directly. + + Args: + media_dir: Path to media directory containing video files. + output_blend: Path for output .blend file. + changelog_path: Path to CHANGELOG.md for text overlays. + resolution_x: Video width in pixels. + resolution_y: Video height in pixels. + fps: Frames per second. + template_path: Path to template .blend file. + thumbnail_path: Path to thumbnail image. + """ + media_dir = Path(media_dir).resolve() + output_blend = Path(output_blend).resolve() + video_files = find_media_files(media_dir) + + if not video_files: + msg = f"Warning: No video files found in '{media_dir}'" + print(msg, file=sys.stderr) + + changelog_sections = [] + if changelog_path: + changelog_sections = parse_changelog(changelog_path) + + loaded = load_template(template_path) + if not loaded: + clear_sequencer() + else: + if bpy.context.scene.sequence_editor is None: + bpy.context.scene.sequence_editor_create() + + setup_scene_settings(resolution_x, resolution_y, fps) + + start_frame = add_thumbnail(thumbnail_path) + + end_frame = add_video_clips(video_files, start_frame) + + if changelog_sections: + add_text_overlays(changelog_sections, end_frame + 30) + + bpy.ops.wm.save_as_mainfile(filepath=str(output_blend)) + print(f"Blender project saved to: {output_blend}") + + +def main(): + parser = argparse.ArgumentParser( + description="Generate Blender .blend file for video project." + ) + + # Filter out Blender's arguments from sys.argv + # When run with Blender, sys.argv contains Blender's arguments first + # We need to find where our script's arguments start + argv = sys.argv + if "--" in argv: + # Arguments after -- are for our script + argv = argv[argv.index("--") + 1 :] + elif len(argv) > 1 and argv[0].endswith("generate_blender_setup.py"): + # Script is first argument, rest are our arguments + argv = argv[1:] + else: + # Try to find our script in the path + for i, arg in enumerate(argv): + if "generate_blender_setup.py" in arg: + argv = argv[i + 1 :] + break + + # Get the script's directory to resolve relative paths + script_dir = Path(__file__).parent.resolve() + project_dir = script_dir.parent.parent + parser.add_argument( + "media_dir", + help="Directory containing video files", + ) + parser.add_argument( + "-o", + "--output", + help="Output .blend file path", + default="draft_edit.blend", + ) + parser.add_argument( + "-c", + "--changelog", + help="Path to CHANGELOG.md for text overlays", + default=None, + ) + parser.add_argument( + "-r", + "--resolution", + help="Resolution as WIDTHxHEIGHT (default: 1920x1080)", + default="1920x1080", + ) + parser.add_argument( + "-f", + "--fps", + help="Frames per second (default: 30)", + type=int, + default=30, + ) + parser.add_argument( + "-t", + "--template", + help="Path to template .blend file", + default=None, + ) + parser.add_argument( + "--thumbnail", + help="Path to thumbnail image", + default=None, + ) + parser.add_argument( + "--auto-find", + action="store_true", + help="Automatically find template and thumbnail from media dir", + ) + + args = parser.parse_args(argv) + + resolution_parts = args.resolution.lower().split("x") + if len(resolution_parts) != 2: + print( + "Error: Resolution must be in WIDTHxHEIGHT format", + file=sys.stderr, + ) + sys.exit(1) + + try: + res_x = int(resolution_parts[0]) + res_y = int(resolution_parts[1]) + except ValueError: + print("Error: Resolution values must be integers", file=sys.stderr) + sys.exit(1) + + # Resolve paths relative to project directory + media_dir = Path(args.media_dir) + if not media_dir.is_absolute(): + media_dir = project_dir / media_dir + + output_blend = Path(args.output) + if not output_blend.is_absolute(): + output_blend = project_dir / output_blend + + changelog_path = args.changelog + if changelog_path is not None: + changelog_path = Path(changelog_path) + if not changelog_path.is_absolute(): + changelog_path = project_dir / changelog_path + if not changelog_path.exists(): + changelog_path = None + else: + changelog_path = project_dir / "CHANGELOG.md" + if not changelog_path.exists(): + changelog_path = None + + template_path = args.template + thumbnail_path = args.thumbnail + + if args.auto_find: + if not template_path: + template_path = find_previous_blend_file(media_dir) + if template_path: + print(f"Found template: {template_path}") + if not thumbnail_path: + thumbnail_path = find_thumbnail(media_dir) + if thumbnail_path: + print(f"Found thumbnail: {thumbnail_path}") + + generate_blender_file( + media_dir=media_dir, + output_blend=output_blend, + changelog_path=changelog_path, + resolution_x=res_x, + resolution_y=res_y, + fps=args.fps, + template_path=template_path, + thumbnail_path=thumbnail_path, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/media/generate_features_video.py b/scripts/media/generate_features_video.py new file mode 100755 index 000000000..3d03c2471 --- /dev/null +++ b/scripts/media/generate_features_video.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Generate a video showing feature lines from features.md.""" + +import math +import subprocess +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +def find_font(font_name: str = "DejaVuSans-Bold.ttf") -> str | None: + """Find a font file from common system locations.""" + font_paths = [ + Path("/usr/share/fonts/truetype/dejavu") / font_name, + Path("/usr/share/fonts/truetype/liberation") / font_name, + Path("/usr/share/fonts/truetype/freefont") / font_name, + Path.home() / ".local" / "share" / "fonts" / font_name, + Path("/System/Library/Fonts") / font_name, + Path("C:\\Windows\\Fonts") / font_name, + ] + for path in font_paths: + if path.exists(): + return str(path) + return None + + +def create_feature_image( + text: str, + output_path: Path, + width: int = 1920, + height: int = 1080, + font_size: int = 120, +): + """Create an image with the feature text centered.""" + img = Image.new("RGB", (width, height), color="#000000") + draw = ImageDraw.Draw(img) + + font_path = find_font("DejaVuSans-Bold.ttf") + if font_path: + try: + font = ImageFont.truetype(font_path, font_size) + except OSError: + font = ImageFont.load_default() + else: + font = ImageFont.load_default() + + x = width // 2 + y = height // 2 + + draw.text((x, y), text, fill="#ffffff", font=font, anchor="mm") + img.save(output_path) + + +def generate_video( + features_file: Path, + output_video: Path, + width: int = 1920, + height: int = 1080, + total_duration: float = 13.0, + font_size: int = 130, + first_item_duration: float = 1.5, +): + """Generate a video from feature lines with accelerating pace.""" + temp_dir = Path("temp_frames") + temp_dir.mkdir(exist_ok=True) + + with open(features_file) as f: + lines = [line.strip() for line in f if line.strip()] + + n = len(lines) + if n == 0: + return + + durations = [first_item_duration] + remaining = total_duration - first_item_duration + remaining_items = n - 1 + + if remaining_items > 0: + weights = [math.exp(-0.1 * i) for i in range(remaining_items)] + total_weight = sum(weights) + for i in range(remaining_items - 1): + t = remaining * weights[i] / total_weight + durations.append(t) + remaining -= t + total_weight -= weights[i] + durations.append(remaining) + + concat_file = temp_dir / "concat.txt" + with open(concat_file, "w") as f: + for i, (line, duration) in enumerate(zip(lines, durations)): + frame_path = temp_dir / f"frame_{i:04d}.png" + create_feature_image(line, frame_path, width, height, font_size) + abs_path = frame_path.resolve() + f.write(f"file '{abs_path}'\n") + f.write(f"duration {duration:.6f}\n") + dummy_frame_path = temp_dir / "frame_dummy.png" + create_feature_image("", dummy_frame_path, width, height, font_size) + abs_dummy_path = dummy_frame_path.resolve() + f.write(f"file '{abs_dummy_path}'\n") + f.write("duration 0.001\n") + + output_video.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-vf", + f"scale={width}:{height}", + "-r", + "30", + str(output_video), + ] + + subprocess.run(cmd, check=True) + + for frame in temp_dir.glob("*.png"): + frame.unlink() + concat_file.unlink() + if temp_dir.exists(): + temp_dir.rmdir() + + +if __name__ == "__main__": + generate_video( + Path("features.md"), + Path("media/1.0/features.mp4"), + width=1920, + height=1080, + ) diff --git a/scripts/media/generate_supporters_image.py b/scripts/media/generate_supporters_image.py new file mode 100755 index 000000000..096f0dd2f --- /dev/null +++ b/scripts/media/generate_supporters_image.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Generate a thank-you image for supporters to use in videos.""" + +import argparse +import re +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +WIDTH = 1920 +HEIGHT = 1080 +BG_COLOR = (18, 18, 24) +ACCENT_COLOR = (100, 180, 255) +TEXT_COLOR = (240, 240, 245) +SUBTLE_COLOR = (140, 140, 160) + +SUPPORTERS_FILE = Path(__file__).resolve().parent.parent.parent / ( + "media/supporters.md" +) + +SECTION_NAMED = "## Agreed to be mentioned" +SECTION_ANONYMOUS = ( + '## Did **not** agree to be mentioned (should be mentioned as "anonymous")' +) + + +def find_font(font_name: str = "DejaVuSans-Bold.ttf") -> str | None: + font_paths = [ + Path("/usr/share/fonts/truetype/dejavu") / font_name, + Path("/usr/share/fonts/truetype/liberation") / font_name, + Path("/usr/share/fonts/truetype/freefont") / font_name, + Path.home() / ".local" / "share" / "fonts" / font_name, + Path("/System/Library/Fonts") / font_name, + Path("C:\\Windows\\Fonts") / font_name, + ] + for path in font_paths: + if path.exists(): + return str(path) + return None + + +def parse_supporters(filepath: Path) -> tuple[list[str], int]: + content = filepath.read_text() + lines = content.splitlines() + + named_names = [] + anonymous_count = 0 + current_section = None + + for line in lines: + stripped = line.strip() + if stripped == SECTION_NAMED: + current_section = "named" + continue + elif stripped == SECTION_ANONYMOUS: + current_section = "anonymous" + continue + elif stripped.startswith("# "): + current_section = None + continue + + if not stripped: + continue + + m = re.match(r"^\d{4}-\d{2}-\d{2}\s+(.+?)(?:\s+\(.+\))?$", stripped) + if m: + if current_section == "named": + named_names.append(m.group(1).strip()) + elif current_section == "anonymous": + anonymous_count += 1 + + return named_names, anonymous_count + + +def generate_image( + names: list[str], + anonymous_count: int, + output_path: Path, + title: str = "Thank You", + subtitle: str = "to our supporters", +): + img = Image.new("RGB", (WIDTH, HEIGHT), BG_COLOR) # type: ignore[arg-type] + draw = ImageDraw.Draw(img) + + bold_path = find_font("DejaVuSans-Bold.ttf") + regular_path = find_font("DejaVuSans.ttf") + + try: + font_title = ( + ImageFont.truetype(bold_path, 100) + if bold_path + else ImageFont.load_default() + ) + font_subtitle = ( + ImageFont.truetype(regular_path, 40) + if regular_path + else ImageFont.load_default() + ) + font_name = ( + ImageFont.truetype(regular_path, 44) + if regular_path + else ImageFont.load_default() + ) + font_name_bold = ( + ImageFont.truetype(bold_path, 44) + if bold_path + else ImageFont.load_default() + ) + font_anon = ( + ImageFont.truetype(regular_path, 32) + if regular_path + else ImageFont.load_default() + ) + except OSError: + font_title = ImageFont.load_default() + font_subtitle = font_title + font_name = font_title + font_name_bold = font_title + font_anon = font_title + + line_w = WIDTH - 300 + cx = WIDTH // 2 + y_cursor = 80 + + bbox = draw.textbbox((0, 0), title, font=font_title) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + draw.text( + ((WIDTH - tw) // 2, y_cursor), + title, + fill=ACCENT_COLOR, + font=font_title, + ) + y_cursor += th + 50 + + bbox = draw.textbbox((0, 0), subtitle, font=font_subtitle) + sw = bbox[2] - bbox[0] + draw.text( + ((WIDTH - sw) // 2, y_cursor), + subtitle, + fill=SUBTLE_COLOR, + font=font_subtitle, + ) + y_cursor += 100 + + cols = _calc_columns(len(names)) + col_width = line_w // cols + col_start_x = cx - line_w // 2 + + for idx, name in enumerate(names): + col = idx % cols + row = idx // cols + x = col_start_x + col * col_width + col_width // 2 + y = y_cursor + row * 58 + + if y + 58 > HEIGHT - 120: + break + + font = font_name_bold if col == 0 else font_name + draw.text((x, y), name, fill=TEXT_COLOR, font=font, anchor="mt") + + total_rows = -(-len(names) // cols) + y_cursor += total_rows * 58 + 50 + + if anonymous_count > 0: + anon_text = "...and to everyone who supported anonymously" + bbox = draw.textbbox((0, 0), anon_text, font=font_anon) + aw = bbox[2] - bbox[0] + draw.text( + ((WIDTH - aw) // 2, y_cursor), + anon_text, + fill=SUBTLE_COLOR, + font=font_anon, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + img.save(output_path, "PNG") + print(f"Saved supporters image to: {output_path}") + + +def _calc_columns(count: int) -> int: + if count <= 5: + return 1 + elif count <= 12: + return 2 + elif count <= 24: + return 3 + return 4 + + +def main(): + parser = argparse.ArgumentParser( + description="Generate a thank-you image for supporters" + ) + parser.add_argument( + "-o", + "--output", + default=None, + help="Output path (default: media/supporters.png)", + ) + parser.add_argument( + "-t", + "--title", + default="Thank You", + help="Title text (default: 'Thank You')", + ) + parser.add_argument( + "-s", + "--subtitle", + default="To everyone supporting Rayforge", + help="Subtitle text (default: 'To everyone supporting Rayforge')", + ) + parser.add_argument( + "-f", + "--file", + default=None, + help=f"Path to supporters.md (default: {SUPPORTERS_FILE})", + ) + args = parser.parse_args() + + filepath = Path(args.file) if args.file else SUPPORTERS_FILE + if not filepath.exists(): + print(f"Error: {filepath} not found.") + return + + names, anonymous_count = parse_supporters(filepath) + if not names: + print("No named supporters found.") + return + + output = ( + Path(args.output) + if args.output + else filepath.parent / "supporters.png" + ) + + generate_image( + names, + anonymous_count, + output, + title=args.title, + subtitle=args.subtitle, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/media/generate_thumbnail.py b/scripts/media/generate_thumbnail.py new file mode 100755 index 000000000..0d8ffc990 --- /dev/null +++ b/scripts/media/generate_thumbnail.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Generate release thumbnails using AI backgrounds and version text.""" + +import argparse +import json +import random +import re +import subprocess +import sys +from pathlib import Path + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("Pillow not installed. Install with: pip install Pillow") + raise + + +def find_font(font_name: str = "DejaVuSans-Bold.ttf") -> str | None: + """Find a font file from common system locations.""" + font_paths = [ + Path("/usr/share/fonts/truetype/dejavu") / font_name, + Path("/usr/share/fonts/truetype/liberation") / font_name, + Path("/usr/share/fonts/truetype/freefont") / font_name, + Path.home() / ".local" / "share" / "fonts" / font_name, + Path("/System/Library/Fonts") / font_name, + Path("C:\\Windows\\Fonts") / font_name, + ] + for path in font_paths: + if path.exists(): + return str(path) + return None + + +def parse_version(version_str): + """Parse version string into components.""" + match = re.match(r"(\d+)(?:\.(\d+))?", version_str) + if not match: + raise ValueError(f"Invalid version format: {version_str}") + major = match.group(1) + minor = match.group(2) or "" + return major, minor + + +def get_app_logo(): + """Get path to app logo.""" + logo_path = ( + Path(__file__).parent.parent + / "rayforge" + / "resources" + / "icons" + / "org.rayforge.rayforge.svg" + ) + if not logo_path.exists(): + logo_path = Path(__file__).parent.parent / "media" / "fiber-laser.png" + return logo_path + + +def get_previous_thumbnail(release_dir): + """Get path to previous release thumbnail.""" + media_dir = Path(__file__).parent.parent / "media" + if not media_dir.exists(): + return None + + release_dirs = sorted( + [d for d in media_dir.iterdir() if d.is_dir()], + key=lambda x: x.name, + reverse=True, + ) + + for release in release_dirs: + if release == release_dir: + continue + thumbnail = release / "thumbnail.png" + if thumbnail.exists(): + return thumbnail + return None + + +def generate_ai_background(version, seed=None): + """Generate AI background using comfyui MCP server.""" + if seed is None: + seed = random.randint(1, 1000000) + + prompts = [ + ( + "laser cutting workspace, professional CNC machine, " + "modern dark theme, version {v} software interface, " + "high contrast, cinematic lighting, 1920x1080, " + "8k quality, sharp focus" + ), + ( + "abstract laser beam patterns, geometric shapes, " + "glowing blue and orange lines, dark background, " + "version {v} overlay style, futuristic tech, " + "cinematic, high contrast" + ), + ( + "precision engineering workspace, CAD software " + "interface, blueprints, technical drawings, dark " + "mode, version {v} branding, professional, " + "clean design, 8k" + ), + ( + "laser engraving close-up, sparks flying, metal " + "texture, dark industrial setting, version {v} " + "watermark, dramatic lighting, high detail, " + "1920x1080" + ), + ( + "modern software interface, dark theme, blue accent " + "colors, geometric patterns, version {v} hero image, " + "minimalist design, clean background, " + "professional software" + ), + ] + + prompt = random.choice(prompts).format(v=version) + + mcp_script = f"""import json +import sys + +result = {{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": {{ + "name": "mcp--comfyui--generate_image", + "arguments": {{ + "prompt": {json.dumps(prompt)}, + "width": 1920, + "height": 1080, + "steps": 8, + "cfg": 2, + "seed": {seed} + }} + }} +}} + +print(json.dumps(result)) +""" + + try: + result = subprocess.run( + [sys.executable, "-c", mcp_script], + capture_output=True, + text=True, + check=True, + ) + output = json.loads(result.stdout) + if "result" in output: + image_path = output["result"].get("content", [{}])[0].get("text") + if image_path: + return Path(image_path) + except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError): + pass + + return None + + +def create_thumbnail(version, output_path, base_image=None, use_ai=True): + """Create a release thumbnail with version number.""" + width, height = 1920, 1080 + + if use_ai and not base_image: + ai_image = generate_ai_background(version) + if ai_image and ai_image.exists(): + base_image = ai_image + + if base_image and base_image.exists(): + img = Image.open(base_image).convert("RGBA") + img = img.resize((width, height), Image.Resampling.LANCZOS) + else: + img = Image.new("RGBA", (width, height), (51, 51, 51, 255)) + + draw = ImageDraw.Draw(img) + + major, minor = parse_version(version) + + font_path = find_font("DejaVuSans-Bold.ttf") + if font_path: + try: + font_large = ImageFont.truetype(font_path, 180) + font_small = ImageFont.truetype(font_path, 80) + except OSError: + font_large = ImageFont.load_default() + font_small = ImageFont.load_default() + else: + font_large = ImageFont.load_default() + font_small = ImageFont.load_default() + + version_text = f"v{major}" + if minor: + version_text += f".{minor}" + + bbox = draw.textbbox((0, 0), version_text, font=font_large) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (width - text_width) // 2 + y = (height - text_height) // 2 + + draw.text((x, y), version_text, fill=(255, 255, 255, 255), font=font_large) + + sub_text = "Rayforge" + bbox_sub = draw.textbbox((0, 0), sub_text, font=font_small) + sub_width = bbox_sub[2] - bbox_sub[0] + sub_x = (width - sub_width) // 2 + sub_y = y - 100 + + draw.text( + (sub_x, sub_y), + sub_text, + fill=(200, 200, 200, 255), + font=font_small, + ) + + if minor: + minor_text = f".{minor}" + minor_x = x + text_width + 10 + minor_y = y + text_height - 80 + + draw.text( + (minor_x, minor_y), + minor_text, + fill=(100, 200, 255, 255), + font=font_small, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + img.save(output_path, "PNG") + print(f"Thumbnail saved to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser( + description="Generate release thumbnail with version number" + ) + parser.add_argument( + "version", + help="Release version (e.g., '1.0' or '0.24')", + ) + parser.add_argument( + "-n", + "--number", + type=int, + default=1, + help="Starting thumbnail number (default: 1)", + ) + parser.add_argument( + "-c", + "--count", + type=int, + default=1, + help="Number of thumbnails to generate (default: 1)", + ) + parser.add_argument( + "-o", + "--output", + help="Output path for thumbnail " + "(default: media//drafts/thumbnail.png)", + ) + parser.add_argument( + "-b", + "--base", + help="Base image to use as background " + "(default: AI-generated or app logo)", + ) + parser.add_argument( + "--use-previous", + action="store_true", + help="Use previous release thumbnail as base", + ) + parser.add_argument( + "--no-ai", + action="store_true", + help="Disable AI background generation", + ) + + args = parser.parse_args() + + base_image = None + if args.base: + base_image = Path(args.base) + elif args.use_previous: + base_dir = Path(__file__).parent.parent / "media" / args.version + base_image = get_previous_thumbnail(base_dir) + if base_image: + print(f"Using previous thumbnail: {base_image}") + + use_ai = not args.no_ai and not base_image + + for i in range(args.count): + thumb_num = args.number + i + + if args.output: + output_path = Path(args.output) + else: + output_path = ( + Path(__file__).parent.parent + / "media" + / args.version + / "drafts" + / f"thumbnail{thumb_num}.png" + ) + + create_thumbnail(args.version, output_path, base_image, use_ai) + + +if __name__ == "__main__": + main() diff --git a/scripts/media/process_audio.py b/scripts/media/process_audio.py new file mode 100755 index 000000000..342e01301 --- /dev/null +++ b/scripts/media/process_audio.py @@ -0,0 +1,698 @@ +#!/usr/bin/env python3 +"""Audio processing script for video clips. + +Processes audio in video files through a pipeline: +1. Optional: Remove silence +2. Denoise (highpass/lowpass filters) +3. Reduce reverb/echo +4. Equalize for better voice +5. Compress +6. Normalize to target loudness +7. Optional: Time compress (speed up) without pitch artifacts +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_TEMPO = 1.065 + + +def build_audio_filter_chain(tempo: float = 1.0) -> str: + """Build FFmpeg audio filter chain for the processing pipeline. + + Args: + tempo: Tempo multiplier (1.0 = normal, 1.05 = 5% faster). + Values > 2.0 or < 0.5 require chaining atempo filters. + """ + filters = [] + + if tempo != 1.0: + filters.append(build_atempo_filter(tempo)) + + filters.append("highpass=f=80") + filters.append("afftdn=nf=-25:tn=1") + filters.append( + "acompressor=threshold=-20dB:ratio=4:attack=15:release=100:makeup=3dB" + ) + filters.append("equalizer=f=150:t=q:w=3:g=4") + filters.append("loudnorm=I=-18:TP=-1.5:LRA=20") + + return ",".join(filters) + + +def build_atempo_filter(tempo: float) -> str: + """Build atempo filter chain for time compression/expansion. + + FFmpeg's atempo filter only accepts values between 0.5 and 2.0. + For values outside this range, we chain multiple atempo filters. + + Args: + tempo: Desired tempo multiplier. + + Returns: + FFmpeg atempo filter string. + """ + if 0.5 <= tempo <= 2.0: + return f"atempo={tempo}" + + atempo_filters = [] + remaining = tempo + + while remaining > 2.0: + atempo_filters.append("atempo=2.0") + remaining /= 2.0 + while remaining < 0.5: + atempo_filters.append("atempo=0.5") + remaining /= 0.5 + + if remaining != 1.0: + atempo_filters.append(f"atempo={remaining}") + + return ",".join(atempo_filters) + + +def build_video_filter(tempo: float) -> str: + """Build setpts filter for video time compression. + + Args: + tempo: Tempo multiplier (1.0 = normal, 1.05 = 5% faster). + + Returns: + FFmpeg setpts filter string. + """ + return f"setpts={1 / tempo}*PTS" + + +def detect_silence( + input_file: str, + silence_threshold: float = -50, + min_silence_duration: float = 0.5, +) -> list[tuple[float, float]]: + """Detect silent portions in a video file. + + Args: + input_file: Path to the input video file. + silence_threshold: Noise threshold in dB (negative value). + min_silence_duration: Minimum silence duration in seconds. + + Returns: + List of (start, end) tuples representing silent segments. + """ + cmd = [ + "ffmpeg", + "-i", + input_file, + "-af", + ( + f"silencedetect=noise={silence_threshold}dB:" + f"duration={min_silence_duration}" + ), + "-f", + "null", + "-", + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError(f"FFmpeg failed: {result.stderr}") + + stderr = result.stderr + silent_segments = [] + + lines = stderr.split("\n") + silence_start = None + + for line in lines: + if "silence_start" in line: + match = re.search(r"silence_start[:\s]+([\d.]+)", line) + if match: + silence_start = float(match.group(1)) + elif "silence_end" in line and silence_start is not None: + match = re.search(r"silence_end[:\s]+([\d.]+)", line) + if match: + silence_end = float(match.group(1)) + silent_segments.append((silence_start, silence_end)) + silence_start = None + + return silent_segments + + +def merge_adjacent_silence( + segments: list[tuple[float, float]], + gap: float = 0.1, +) -> list[tuple[float, float]]: + """Merge silence segments that are close to each other. + + Args: + segments: List of (start, end) tuples. + gap: Maximum gap between segments to merge. + + Returns: + List of merged (start, end) tuples. + """ + if not segments: + return [] + + merged = [list(segments[0])] + + for start, end in segments[1:]: + last_end = merged[-1][1] + if start - last_end <= gap: + merged[-1][1] = end + else: + merged.append([start, end]) + + return [(s, e) for s, e in merged] + + +def get_video_duration(input_file: str) -> float: + """Get the duration of a video file. + + Args: + input_file: Path to the input video file. + + Returns: + Duration in seconds. + """ + cmd = [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "json", + input_file, + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError(f"FFprobe failed: {result.stderr}") + + data = json.loads(result.stdout) + if "format" not in data or "duration" not in data["format"]: + raise RuntimeError("Could not determine video duration") + return float(data["format"]["duration"]) + + +def has_audio_stream(input_file: str) -> bool: + """Check if a video file has an audio stream. + + Args: + input_file: Path to the input video file. + + Returns: + True if audio stream exists, False otherwise. + """ + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a", + "-show_entries", + "stream=codec_type", + "-of", + "csv=p=0", + input_file, + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + + return bool(result.stdout.strip()) + + +def extract_segment( + input_file: str, + output_file: str, + start: float, + end: float, +) -> None: + """Extract a segment from a video file. + + Args: + input_file: Path to the input video file. + output_file: Path to the output segment file. + start: Start time in seconds. + end: End time in seconds. + """ + duration = end - start + cmd = [ + "ffmpeg", + "-ss", + str(start), + "-i", + input_file, + "-t", + str(duration), + "-c:v", + "libx264", + "-preset", + "fast", + "-c:a", + "aac", + "-y", + output_file, + ] + + result = subprocess.run(cmd, capture_output=True, check=False) + + if result.returncode != 0: + raise RuntimeError(f"Segment extraction failed: {result.stderr}") + + +def create_concat_file( + segment_files: list[str], + output_file: str, +) -> None: + """Create a concat file for FFmpeg concat demuxer. + + Args: + segment_files: List of segment file paths. + output_file: Path to the concat file. + """ + with open(output_file, "w") as f: + f.writelines(f"file '{seg_file}'\n" for seg_file in segment_files) + + +def concat_segments( + concat_file: str, + output_file: str, +) -> None: + """Concatenate segments using FFmpeg concat demuxer. + + Args: + concat_file: Path to the concat file. + output_file: Path to the output video file. + """ + cmd = [ + "ffmpeg", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_file, + "-c", + "copy", + "-y", + output_file, + ] + + result = subprocess.run(cmd, capture_output=True, check=False) + + if result.returncode != 0: + raise RuntimeError(f"Concatenation failed: {result.stderr}") + + +def remove_silence( + input_file: str, + output_file: str, + silence_threshold: float = -50, + min_silence_duration: float = 0.5, + merge_gap: float = 0.1, +) -> bool: + """Remove silent portions from a video file. + + Args: + input_file: Path to the input video file. + output_file: Path to the output video file. + silence_threshold: Noise threshold in dB (negative value). + min_silence_duration: Minimum silence duration to remove in seconds. + merge_gap: Gap between silent segments to merge. + + Returns: + True if successful, False if no silence removed (file copied). + """ + if not has_audio_stream(input_file): + print(" No audio stream found. Copying file as-is.") + subprocess.run( + ["ffmpeg", "-i", input_file, "-c", "copy", "-y", output_file], + check=True, + capture_output=True, + ) + return False + + print(" Analyzing audio for silence...") + silent_segments = detect_silence( + input_file, silence_threshold, min_silence_duration + ) + + if not silent_segments: + print(" No silence detected. Copying file as-is.") + subprocess.run( + ["ffmpeg", "-i", input_file, "-c", "copy", "-y", output_file], + check=True, + capture_output=True, + ) + return False + + silent_segments = merge_adjacent_silence(silent_segments, merge_gap) + + video_duration = get_video_duration(input_file) + + margin = 0.1 + silent_segments = [ + (start, end) + for start, end in silent_segments + if start > margin and end < video_duration - margin + ] + + if not silent_segments: + print(" No mid-video silence detected. Copying file as-is.") + subprocess.run( + ["ffmpeg", "-i", input_file, "-c", "copy", "-y", output_file], + check=True, + capture_output=True, + ) + return False + + total_silence = sum(end - start for start, end in silent_segments) + print(f" Found {len(silent_segments)} silent segment(s)") + print(f" Total silence to remove: {total_silence:.2f} seconds") + + margin = 0.005 + keep_segments = [] + prev_end = 0 + + for start, end in silent_segments: + if start > prev_end + margin: + keep_segments.append((prev_end, start - margin)) + prev_end = end + + if prev_end < video_duration - margin: + keep_segments.append((prev_end, video_duration)) + + print(f" Extracting {len(keep_segments)} non-silent segments...") + + with tempfile.TemporaryDirectory() as temp_dir: + segment_files = [] + + for i, (start, end) in enumerate(keep_segments): + if end - start < 0.01: + continue + seg_file = os.path.join(temp_dir, f"segment_{i:04d}.mkv") + print(f" Segment {i + 1}/{len(keep_segments)}") + extract_segment(input_file, seg_file, start, end) + segment_files.append(seg_file) + + if not segment_files: + print(" No non-silent segments found. Copying file as-is.") + subprocess.run( + ["ffmpeg", "-i", input_file, "-c", "copy", "-y", output_file], + check=True, + capture_output=True, + ) + return False + + print(" Concatenating segments...") + concat_file = os.path.join(temp_dir, "concat.txt") + create_concat_file(segment_files, concat_file) + concat_segments(concat_file, output_file) + + return True + + +def has_video_stream(input_file: str) -> bool: + """Check if a file has a video stream. + + Args: + input_file: Path to the input file. + + Returns: + True if video stream exists, False otherwise. + """ + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v", + "-show_entries", + "stream=codec_type", + "-of", + "csv=p=0", + input_file, + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + + return bool(result.stdout.strip()) + + +def process_video( + input_path, + output_path=None, + tempo: float = 1.0, + remove_silence_flag: bool = False, + silence_threshold: float = -50, + min_silence_duration: float = 0.5, + silence_merge_gap: float = 0.1, +): + """Process audio in video file. + + Args: + input_path: Path to input video file. + output_path: Path to output video file. If None, creates + input_processed.ext file. + tempo: Tempo multiplier for time compression (1.05 = 5% faster). + remove_silence_flag: Whether to remove silence before processing. + silence_threshold: Silence detection threshold in dB. + min_silence_duration: Minimum silence duration to remove. + silence_merge_gap: Gap between silences to merge. + + Returns: + True if successful, False otherwise. + """ + input_path = Path(input_path) + + if not input_path.exists(): + msg = f"Error: Input file '{input_path}' does not exist" + print(msg, file=sys.stderr) + return False + + if output_path is None: + raise ValueError( + "Output path must be specified. Use -o/--output-dir option." + ) + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + cleanup_files = [] + current_input = str(input_path) + + try: + if remove_silence_flag: + print("Removing silence...") + temp_silence = output_path.with_suffix(".temp_silence.mkv") + cleanup_files.append(temp_silence) + + remove_silence( + current_input, + str(temp_silence), + silence_threshold, + min_silence_duration, + silence_merge_gap, + ) + current_input = str(temp_silence) + + audio_filter = build_audio_filter_chain(tempo) + + cmd = [ + "ffmpeg", + "-i", + current_input, + ] + + is_audio_only = not has_video_stream(current_input) + + if is_audio_only: + cmd.extend(["-vn"]) + if output_path.suffix.lower() == ".wav": + cmd.extend( + [ + "-c:a", + "pcm_s16le", + "-ar", + "44100", + "-af", + audio_filter, + "-y", + str(output_path), + ] + ) + else: + cmd.extend( + [ + "-c:a", + "aac", + "-b:a", + "192k", + "-ar", + "44100", + "-af", + audio_filter, + "-y", + str(output_path), + ] + ) + else: + if tempo != 1.0: + video_filter = build_video_filter(tempo) + cmd.extend(["-vf", video_filter]) + cmd.extend(["-c:v", "libx264", "-preset", "fast"]) + else: + cmd.extend(["-c:v", "copy"]) + + cmd.extend( + [ + "-c:a", + "aac", + "-b:a", + "192k", + "-ar", + "44100", + "-af", + audio_filter, + "-y", + str(output_path), + ] + ) + + speed_info = f" (tempo: {tempo}x)" if tempo != 1.0 else "" + silence_info = " + silence removal" if remove_silence_flag else "" + print(f"Processing{silence_info}{speed_info}: {input_path}") + print(f"Output: {output_path}") + + subprocess.run(cmd, check=True, capture_output=True, text=True) + print("Processing completed successfully") + return True + + except subprocess.CalledProcessError as e: + print(f"Error processing video: {e}", file=sys.stderr) + if e.stderr: + print(f"FFmpeg stderr: {e.stderr}", file=sys.stderr) + return False + except FileNotFoundError: + msg = "Error: ffmpeg not found. Please install FFmpeg." + print(msg, file=sys.stderr) + return False + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + return False + finally: + for f in cleanup_files: + if f.exists(): + f.unlink() + + +def main(): + parser = argparse.ArgumentParser( + description="Process audio in video files for better voice quality." + ) + parser.add_argument("input", nargs="+", help="Input video file path(s)") + parser.add_argument( + "-o", + "--output-dir", + help="Output directory for processed files", + required=True, + ) + parser.add_argument( + "-t", + "--tempo", + type=float, + default=DEFAULT_TEMPO, + help=f"Tempo multiplier for time compression " + f"(default: {DEFAULT_TEMPO}, i.e. 5%% faster). " + f"Use 1.0 for no speed change.", + ) + parser.add_argument( + "--no-remove-silence", + action="store_true", + help="Disable automatic silence removal.", + ) + parser.add_argument( + "--silence-threshold", + type=float, + default=-50, + help="Silence threshold in dB (default: -50).", + ) + parser.add_argument( + "--silence-duration", + type=float, + default=0.5, + help="Minimum silence duration to remove in seconds (default: 0.5).", + ) + parser.add_argument( + "--silence-gap", + type=float, + default=0.1, + help="Gap between silences to merge in seconds (default: 0.1).", + ) + + args = parser.parse_args() + + remove_silence_flag = not args.no_remove_silence + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + success_count = 0 + total_count = len(args.input) + + for input_file in args.input: + input_path = Path(input_file) + + if not input_path.exists(): + print( + f"Warning: Input file '{input_path}' does not exist, skipping", + file=sys.stderr, + ) + continue + + output_path = output_dir / input_path.name + + if process_video( + input_path, + output_path, + tempo=args.tempo, + remove_silence_flag=remove_silence_flag, + silence_threshold=args.silence_threshold, + min_silence_duration=args.silence_duration, + silence_merge_gap=args.silence_gap, + ): + success_count += 1 + + print(f"\nProcessed {success_count}/{total_count} files successfully") + sys.exit(0 if success_count == total_count else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/media/run_blender_setup.sh b/scripts/media/run_blender_setup.sh new file mode 100755 index 000000000..5a9fb126f --- /dev/null +++ b/scripts/media/run_blender_setup.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Wrapper script to run Blender setup script with flatpak + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +flatpak run --filesystem=host --command=blender org.blender.Blender \ + --background \ + --python "$PROJECT_DIR/scripts/media/generate_blender_setup.py" \ + -- "$@" diff --git a/scripts/media/update_supporters.py b/scripts/media/update_supporters.py new file mode 100755 index 000000000..8da4304bb --- /dev/null +++ b/scripts/media/update_supporters.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 + +import asyncio +import logging +import re +import sys +from pathlib import Path + +import aiohttp +import yaml +from platformdirs import user_config_dir + +logger = logging.getLogger(__name__) + +CONFIG_DIR = Path(user_config_dir("rayforge")) +PATREON_CONFIG_FILE = CONFIG_DIR / "patreon.yaml" +PATREON_API_BASE = "https://www.patreon.com/api/oauth2/v2" +SUPPORTERS_FILE = Path(__file__).resolve().parent.parent.parent / ( + "media/supporters.md" +) + +SECTION_NAMED = "## Agreed to be mentioned" +SECTION_ANONYMOUS = ( + '## Did **not** agree to be mentioned (should be mentioned as "anonymous")' +) + + +def load_config(): + if PATREON_CONFIG_FILE.exists(): + with open(PATREON_CONFIG_FILE, "r") as f: + return yaml.safe_load(f) + return {} + + +async def get_campaign_id(access_token): + headers = {"Authorization": f"Bearer {access_token}"} + url = f"{PATREON_API_BASE}/campaigns" + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=headers) as resp, + ): + resp.raise_for_status() + data = await resp.json() + if data.get("data"): + return data["data"][0]["id"] + return None + + +async def fetch_supporters(access_token, campaign_id): + headers = {"Authorization": f"Bearer {access_token}"} + url = ( + f"{PATREON_API_BASE}/campaigns/{campaign_id}/members" + f"?include=currently_entitled_tiers,user" + f"&fields[member]=full_name,pledge_relationship_start," + f"last_charge_date,lifetime_support_cents" + f"&fields[user]=email" + f"&fields[tier]=title" + f"&sort=pledge_relationship_start" + ) + members = [] + included = [] + async with aiohttp.ClientSession() as session: + while url: + async with session.get(url, headers=headers) as resp: + resp.raise_for_status() + data = await resp.json() + members.extend(data.get("data", [])) + included.extend(data.get("included", [])) + url = data.get("links", {}).get("next") + return members, included + + +def is_paying_supporter(attrs): + last_charge = attrs.get("last_charge_date") + lifetime = attrs.get("lifetime_support_cents", 0) + return last_charge is not None or lifetime > 0 + + +def resolve_tier(member, included_lookup): + tier_id = None + rels = member.get("relationships", {}) + tiers_rel = rels.get("currently_entitled_tiers", {}) + tier_data = tiers_rel.get("data", []) + logger.debug( + "Resolving tier for %s, tier_data=%s", + member.get("attributes", {}).get("full_name"), + tier_data, + ) + if tier_data: + tier_id = tier_data[0]["id"] + if tier_id and tier_id in included_lookup: + title = ( + included_lookup[tier_id] + .get("attributes", {}) + .get("title", "Supporter") + ) + logger.debug("Found tier: %s (id=%s)", title, tier_id) + return title + logger.warning( + "Could not resolve tier for %s (tier_id=%s, lookup_keys=%s)", + member.get("attributes", {}).get("full_name"), + tier_id, + [k for k in included_lookup if isinstance(k, str)], + ) + return "Supporter" + + +def build_included_lookup(included): + lookup = {} + for item in included: + lookup[(item.get("type"), item["id"])] = item + lookup[item["id"]] = item + return lookup + + +def format_entry(name, date_str, tier): + date_part = date_str[:10] if date_str else "unknown" + return f"{date_part} {name} ({tier})" + + +def parse_existing_names(content): + names = set() + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + m = re.match(r"^\d{4}-\d{2}-\d{2}\s+(.+?)(?:\s+\(.+\))?$", line) + if m: + names.add(m.group(1).strip().lower()) + else: + names.add(line.strip().lower()) + return names + + +def parse_file_sections(content): + lines = content.splitlines() + sections = {"header": [], "named": [], "anonymous": [], "past": []} + current = "header" + for line in lines: + if line.strip() == SECTION_NAMED: + current = "named" + sections[current].append(line) + continue + elif line.strip() == SECTION_ANONYMOUS: + current = "anonymous" + sections[current].append(line) + continue + elif line.strip() == "# Past Supporters": + current = "past" + sections[current].append(line) + continue + sections[current].append(line) + return sections + + +async def main(): + logging.basicConfig(level=logging.INFO) + + config = load_config() + access_token = config.get("access_token") + + if not access_token: + print("No access token found in config file.") + print(f"\nConfig file location: {PATREON_CONFIG_FILE}") + print("\nTo add an access token:") + print( + "1. Go to: https://www.patreon.com/portal/registration/" + "register-creator" + ) + print("2. Select your app") + print("3. Click 'Create a Creator's Access Token'") + print("4. Add the token to the config file as 'access_token'") + print("\nExample config file content:") + print(" access_token: YOUR_TOKEN_HERE") + sys.exit(1) + + campaign_id = await get_campaign_id(access_token) + if not campaign_id: + print("No campaign found for this account.") + sys.exit(1) + + print(f"Fetching supporters for campaign: {campaign_id}") + members, included = await fetch_supporters(access_token, campaign_id) + included_lookup = build_included_lookup(included) + + paying = [ + m for m in members if is_paying_supporter(m.get("attributes", {})) + ] + print(f"Found {len(paying)} paying supporters") + + if not SUPPORTERS_FILE.exists(): + print(f"Error: {SUPPORTERS_FILE} not found.") + sys.exit(1) + + content = SUPPORTERS_FILE.read_text() + existing_names = parse_existing_names(content) + sections = parse_file_sections(content) + + new_entries = [] + for member in paying: + attrs = member.get("attributes", {}) + name = attrs.get("full_name", "Unknown") + pledge_start = attrs.get("pledge_relationship_start") + tier = resolve_tier(member, included_lookup) + + if name.strip().lower() in existing_names: + continue + + new_entries.append(format_entry(name, pledge_start, tier)) + existing_names.add(name.strip().lower()) + + if not new_entries: + print("No new supporters to add.") + return + + new_entries.sort() + for entry in new_entries: + print(f" Adding: {entry}") + + anonymous_lines = sections["anonymous"] + insert_idx = len(anonymous_lines) + for i, line in enumerate(anonymous_lines): + if line.strip() == "" or line.strip().startswith("*None"): + insert_idx = i + break + + for entry in reversed(new_entries): + anonymous_lines.insert(insert_idx, entry) + + result = ( + sections["header"] + + sections["named"] + + sections["anonymous"] + + sections["past"] + ) + SUPPORTERS_FILE.write_text("\n".join(result) + "\n") + print(f"\nAdded {len(new_entries)} new supporter(s) to {SUPPORTERS_FILE}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/normalize_icons.py b/scripts/normalize_icons.py new file mode 100755 index 000000000..cb4a08414 --- /dev/null +++ b/scripts/normalize_icons.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +""" +Normalize SVG icons for GTK compatibility. + +This script normalizes all SVG icons in rayforge/resources/icons/ to have: +- viewBox="0 0 24 24" +- Path coordinates transformed to fit within 0-24 range +- Fill color set to #000000 +- Transform attributes updated (translate values scaled) +- Coordinate attributes (x, y, cx, cy, etc.) transformed +- Dimension attributes (width, height, r, etc.) scaled + +Preserves original XML structure, namespaces, and formatting. +""" + +import re +import sys +from pathlib import Path + + +def parse_viewbox(viewbox_str): + """Parse viewBox attribute into (min_x, min_y, width, height).""" + parts = viewbox_str.strip().split() + if len(parts) != 4: + raise ValueError(f"Invalid viewBox: {viewbox_str}") + return tuple(float(p) for p in parts) + + +def tokenize_path(path_data): + """ + Tokenize SVG path data into a list of commands and numbers. + Handles negative numbers without spaces (e.g., "784-120" -> 784, -120). + """ + tokens = [] + i = 0 + data = path_data.strip() + + while i < len(data): + c = data[i] + + if c.isalpha(): + tokens.append(c) + i += 1 + elif c in "-0123456789.": + j = i + if data[j] == "-": + j += 1 + while j < len(data) and data[j].isdigit(): + j += 1 + if j < len(data) and data[j] == ".": + j += 1 + while j < len(data) and data[j].isdigit(): + j += 1 + if j < len(data) and data[j] in "eE": + j += 1 + if j < len(data) and data[j] in "+-": + j += 1 + while j < len(data) and data[j].isdigit(): + j += 1 + + num_str = data[i:j] + if num_str and num_str != "-": + tokens.append(float(num_str)) + i = j + elif c in ", \t\n\r": + i += 1 + else: + i += 1 + + return tokens + + +def transform_path(path_data, min_x, min_y, width, height, target_size=24): + """ + Transform path data from original viewBox to target 0-32 viewBox. + Returns the transformed path data string. + """ + tokens = tokenize_path(path_data) + scale = target_size / max(width, height) + offset_x = (target_size - width * scale) / 2 - min_x * scale + offset_y = (target_size - height * scale) / 2 - min_y * scale + + def fmt(n): + if n == int(n): + return str(int(n)) + s = f"{n:.4f}".rstrip("0").rstrip(".") + return s + + def tx(x): + return x * scale + offset_x + + def ty(y): + return y * scale + offset_y + + result = [] + i = 0 + at_start = True + + while i < len(tokens): + token = tokens[i] + + if isinstance(token, str): + cmd = token + i += 1 + + if cmd.lower() == "z": + result.append(cmd) + at_start = False + elif cmd.lower() in ("h", "v"): + args = [] + while i < len(tokens) and isinstance(tokens[i], float): + if cmd == "H": + args.append(fmt(tx(tokens[i]))) + elif cmd == "V": + args.append(fmt(ty(tokens[i]))) + elif cmd == "h": + args.append(fmt(tokens[i] * scale)) + else: + args.append(fmt(tokens[i] * scale)) + i += 1 + result.append(cmd + " " + " ".join(args)) + at_start = False + elif cmd.lower() == "a": + while i + 6 < len(tokens) and all( + isinstance(tokens[i + j], float) for j in range(7) + ): + rx = tokens[i] * scale + ry = tokens[i + 1] * scale + rot = int(tokens[i + 2]) + large_arc = int(tokens[i + 3]) + sweep = int(tokens[i + 4]) + if cmd.isupper(): + x = tx(tokens[i + 5]) + y = ty(tokens[i + 6]) + else: + x = tokens[i + 5] * scale + y = tokens[i + 6] * scale + result.append( + f"{cmd} {fmt(rx)} {fmt(ry)} {rot} {large_arc} " + f"{sweep} {fmt(x)} {fmt(y)}" + ) + i += 7 + at_start = False + elif cmd.lower() in ("m", "l", "t", "q", "s", "c"): + coords = [] + first_pair = True + while ( + i + 1 < len(tokens) + and isinstance(tokens[i], float) + and isinstance(tokens[i + 1], float) + ): + if ( + cmd.isupper() + or at_start + and first_pair + and cmd.lower() == "m" + ): + x = tx(tokens[i]) + y = ty(tokens[i + 1]) + else: + x = tokens[i] * scale + y = tokens[i + 1] * scale + coords.append((fmt(x), fmt(y))) + i += 2 + first_pair = False + + if coords: + first_x, first_y = coords[0] + result.append(f"{cmd} {first_x} {first_y}") + for x, y in coords[1:]: + result.append(f"{x} {y}") + at_start = False + else: + i += 1 + + return " ".join(result) + + +def transform_translate(transform_str, scale): + """ + Transform translate() values in a transform attribute. + Translate values are relative offsets, so only scale them (no offset). + Returns the updated transform string. + """ + + def replace_translate(m): + tx_val = float(m.group(1)) if m.group(1) else 0 + ty_val = float(m.group(2)) if m.group(2) else 0 + new_tx = tx_val * scale + new_ty = ty_val * scale + + def fmt(n): + if n == int(n): + return str(int(n)) + return f"{n:.4f}".rstrip("0").rstrip(".") + + return f"translate({fmt(new_tx)},{fmt(new_ty)})" + + return re.sub( + r"translate\s*\(\s*([-+]?\d*\.?\d+)\s*(?:,\s*([-+]?\d*\.?\d+))?\s*\)", + replace_translate, + transform_str, + ) + + +def transform_style(style_str, scale): + """ + Transform dimensional values in style attribute (e.g., stroke-width, + font-size). Returns the updated style string. + """ + + def replace_dimension(m): + prop = m.group(1) + val = float(m.group(2)) + unit = m.group(3) or "" + new_val = val * scale + + def fmt(n): + if n == int(n): + return str(int(n)) + return f"{n:.4f}".rstrip("0").rstrip(".") + + return f"{prop}:{fmt(new_val)}{unit}" + + return re.sub( + r"(stroke-width|font-size|stroke-dasharray)\s*:\s*" + r"([-+]?\d*\.?\d+)(px|pt|em|%)?", + replace_dimension, + style_str, + ) + + +def normalize_svg(content, target_size=24): + """ + Normalize SVG content. + + Returns the normalized SVG content with: + - viewBox set to "0 0 {target_size} {target_size}" + - Path data transformed to fit the new viewBox + - fill color set to #000000 + - Transform attributes updated + - Coordinate/dimension attributes transformed + """ + viewbox_match = re.search(r'viewBox\s*=\s*"([^"]+)"', content) + if not viewbox_match: + return None, "no viewBox found" + + viewbox_str = viewbox_match.group(1) + min_x, min_y, width, height = parse_viewbox(viewbox_str) + + if ( + min_x == 0 + and min_y == 0 + and width == target_size + and height == target_size + ): + content = re.sub( + r'fill\s*=\s*"[^"]*"', 'fill="#000000"', content, count=1 + ) + return content, "already normalized" + + scale = target_size / max(width, height) + offset_x = (target_size - width * scale) / 2 - min_x * scale + offset_y = (target_size - height * scale) / 2 - min_y * scale + + def fmt(n): + if n == int(n): + return str(int(n)) + return f"{n:.4f}".rstrip("0").rstrip(".") + + def tx(x): + return x * scale + offset_x + + def ty(y): + return y * scale + offset_y + + def scale_val(v): + return v * scale + + def replace_path(match): + indent = match.group(1) + path_data = match.group(2) + rest = match.group(3) + try: + new_path = transform_path( + path_data, min_x, min_y, width, height, target_size + ) + return f'{indent}d="{new_path}"{rest}' + except (ValueError, ZeroDivisionError, IndexError): + return match.group(0) + + path_pattern = r'(\n\s+)d\s*=\s*"([^"]+)"(\s*(?:/?>|\n))' + content = re.sub(path_pattern, replace_path, content) + + def replace_transform(match): + indent = match.group(1) + transform_val = match.group(2) + rest = match.group(3) + try: + new_transform = transform_translate(transform_val, scale) + return f'{indent}transform="{new_transform}"{rest}' + except (ValueError, ZeroDivisionError, IndexError): + return match.group(0) + + transform_pattern = r'(\n\s+)transform\s*=\s*"([^"]+)"(\s*(?:/?>|\n|\s))' + content = re.sub(transform_pattern, replace_transform, content) + + def replace_x(match): + indent = match.group(1) + x_val = float(match.group(2)) + rest = match.group(3) + return f'{indent}x="{fmt(tx(x_val))}"{rest}' + + content = re.sub( + r'(\n\s+)x\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_x, + content, + ) + + def replace_y(match): + indent = match.group(1) + y_val = float(match.group(2)) + rest = match.group(3) + return f'{indent}y="{fmt(ty(y_val))}"{rest}' + + content = re.sub( + r'(\n\s+)y\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_y, + content, + ) + + def replace_cx(match): + indent = match.group(1) + val = float(match.group(2)) + rest = match.group(3) + return f'{indent}cx="{fmt(tx(val))}"{rest}' + + content = re.sub( + r'(\n\s+)cx\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_cx, + content, + ) + + def replace_cy(match): + indent = match.group(1) + val = float(match.group(2)) + rest = match.group(3) + return f'{indent}cy="{fmt(ty(val))}"{rest}' + + content = re.sub( + r'(\n\s+)cy\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_cy, + content, + ) + + def replace_r(match): + indent = match.group(1) + val = float(match.group(2)) + rest = match.group(3) + return f'{indent}r="{fmt(scale_val(val))}"{rest}' + + content = re.sub( + r'(\n\s+)r\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_r, + content, + ) + + def replace_rx_attr(match): + indent = match.group(1) + val = float(match.group(2)) + rest = match.group(3) + return f'{indent}rx="{fmt(scale_val(val))}"{rest}' + + content = re.sub( + r'(\n\s+)rx\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_rx_attr, + content, + ) + + def replace_ry_attr(match): + indent = match.group(1) + val = float(match.group(2)) + rest = match.group(3) + return f'{indent}ry="{fmt(scale_val(val))}"{rest}' + + content = re.sub( + r'(\n\s+)ry\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_ry_attr, + content, + ) + + def replace_stroke_width(match): + indent = match.group(1) + val = float(match.group(2)) + rest = match.group(3) + return f'{indent}stroke-width="{fmt(scale_val(val))}"{rest}' + + content = re.sub( + r'(\n\s+)stroke-width\s*=\s*"([-+]?\d*\.?\d+)"(\s*(?:/?>|\n|\s))', + replace_stroke_width, + content, + ) + + def replace_style(match): + indent = match.group(1) + style_val = match.group(2) + rest = match.group(3) + try: + new_style = transform_style(style_val, scale) + return f'{indent}style="{new_style}"{rest}' + except (ValueError, ZeroDivisionError, IndexError): + return match.group(0) + + style_pattern = r'(\n\s+)style\s*=\s*"([^"]+)"(\s*(?:/?>|\n))' + content = re.sub(style_pattern, replace_style, content) + + content = re.sub( + r'viewBox\s*=\s*"[^"]*"', + f'viewBox="0 0 {target_size} {target_size}"', + content, + ) + + content = re.sub(r'fill\s*=\s*"[^"]*"', 'fill="#000000"', content, count=1) + + return content, None + + +def main(): + icons_dir = ( + Path(__file__).parent.parent / "rayforge" / "resources" / "icons" + ) + + if not icons_dir.exists(): + print(f"Icons directory not found: {icons_dir}") + sys.exit(1) + + svg_files = list(icons_dir.glob("**/*.svg")) + print(f"Found {len(svg_files)} SVG files to process") + + processed = 0 + skipped = 0 + + for svg_file in svg_files: + print(f"Processing {svg_file.name}...", end="", flush=True) + try: + content = svg_file.read_text(encoding="utf-8") + new_content, error = normalize_svg(content) + + if error: + if error == "already normalized": + print(" (already normalized)") + else: + print(f" SKIP: {error}") + skipped += 1 + continue + + if new_content: + svg_file.write_text(new_content, encoding="utf-8") + print(" OK") + processed += 1 + except (OSError, ValueError, ZeroDivisionError, IndexError) as e: + print(f" ERROR: {e}") + skipped += 1 + + print(f"\nDone! Processed: {processed}, Skipped: {skipped}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pixi-raygeo.sh b/scripts/pixi-raygeo.sh new file mode 100755 index 000000000..badb011a0 --- /dev/null +++ b/scripts/pixi-raygeo.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Temporarily run pixi against a local raygeo checkout, then restore. +# +# Usage: +# scripts/pixi-raygeo.sh +# +# Examples: +# scripts/pixi-raygeo.sh run lint +# scripts/pixi-raygeo.sh run test +# scripts/pixi-raygeo.sh shell +# +# This appends a raygeo dependency-override pointing at a local checkout to +# pixi.toml, runs the given pixi command, and restores the original pixi.toml +# and pixi.lock on exit (also on error or Ctrl-C). The override replaces raygeo +# everywhere, including transitive requirements (e.g. rayforge's raygeo pin). +# +# The local checkout defaults to external/raygeo; override with RAYGEO_PATH. +# The path is canonicalized because pixi canonicalizes symlink paths +# inconsistently in its lock staleness check, which would otherwise make it +# re-solve the environment on every command. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PIXI_TOML="$ROOT_DIR/pixi.toml" +PIXI_LOCK="$ROOT_DIR/pixi.lock" + +RAYGEO_PATH="${RAYGEO_PATH:-$ROOT_DIR/external/raygeo}" +if [[ ! -d "$RAYGEO_PATH" ]]; then + echo "pixi-raygeo: local raygeo checkout not found at '$RAYGEO_PATH'." >&2 + echo " create external/raygeo or set RAYGEO_PATH." >&2 + exit 1 +fi +RAYGEO_ABS="$(cd "$RAYGEO_PATH" && pwd)" + +if [[ ! -f "$PIXI_TOML" ]]; then + echo "pixi-raygeo: pixi.toml not found at '$PIXI_TOML'." >&2 + exit 1 +fi + +MARKER="# pixi-raygeo: temporary override (auto-removed)" +if grep -qF "$MARKER" "$PIXI_TOML"; then + echo "pixi-raygeo: $PIXI_TOML already has a temporary override marker." >&2 + echo " a previous run may not have restored it; run:" >&2 + echo " git checkout pixi.toml pixi.lock" >&2 + exit 1 +fi + +backup="$(mktemp -d)" +cleanup() { + # Restore the originals no matter how we exit. + cp "$backup/pixi.toml" "$PIXI_TOML" + if [[ -f "$backup/pixi.lock" ]]; then + cp "$backup/pixi.lock" "$PIXI_LOCK" + fi + rm -rf "$backup" +} +trap cleanup EXIT + +cp "$PIXI_TOML" "$backup/pixi.toml" +if [[ -f "$PIXI_LOCK" ]]; then + cp "$PIXI_LOCK" "$backup/pixi.lock" +fi + +cat >> "$PIXI_TOML" <&2 + +# Run pixi without set -e so we can restore and still preserve its exit code. +set +e +pixi "$@" +rc=$? +set -e +exit $rc diff --git a/scripts/print_untranslated.sh b/scripts/print_untranslated.sh new file mode 100755 index 000000000..3e2d8f98f --- /dev/null +++ b/scripts/print_untranslated.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status. +set -e + +# Check if language code is provided +if [ -z "$1" ]; then + echo "Error: Language code is required." + echo "Usage: pixi run print-untranslated " + echo " pixi run print-untranslated list" + echo "Example: pixi run print-untranslated de" + echo " pixi run print-untranslated list" + exit 1 +fi + +LANG_CODE="$1" + +# Function to check untranslated strings in a package +check_package() { + local pkg_name="$1" + local locale_dir="$2" + local po_file="$locale_dir/$LANG_CODE/LC_MESSAGES/$pkg_name.po" + + if [ ! -f "$po_file" ]; then + return + fi + + if msgattrib --untranslated --no-obsolete "$po_file" 2>/dev/null | grep -q "^msgid"; then + echo "" + echo "=== $pkg_name ===" + echo "($po_file)" + msgattrib --untranslated --no-obsolete "$po_file" | awk ' + /^msgid/ && entry_count >= 100 { exit } + /^msgid/ { entry_count++ } + { print } + ' + fi +} + +# List all languages with untranslated strings +if [ "$LANG_CODE" = "list" ]; then + found=0 + + # Check main app + for lang_dir in rayforge/locale/*/; do + lang=$(basename "$lang_dir") + if [ -d "$lang_dir/LC_MESSAGES" ] && [ -f "$lang_dir/LC_MESSAGES/rayforge.po" ]; then + if [ "$lang" != "en" ]; then + PO_FILE="$lang_dir/LC_MESSAGES/rayforge.po" + if msgattrib --untranslated --no-obsolete "$PO_FILE" 2>/dev/null | grep -q "^msgid"; then + echo "$lang" + found=1 + fi + fi + fi + done + + # Check addons + for addon_type in builtin_addons private_addons; do + for addon_dir in rayforge/$addon_type/*/; do + # Extract addon name from rayforge-addon.yaml + addon_yaml="$addon_dir/rayforge-addon.yaml" + if [ -f "$addon_yaml" ]; then + addon_name=$(grep "^name:" "$addon_yaml" | head -n 1 | cut -d':' -f2 | xargs) + else + addon_name=$(basename "$addon_dir") + fi + + if [ -d "$addon_dir/locale" ]; then + locale_dir="$addon_dir/locale" + else + continue + fi + + for lang_dir in "$locale_dir"/*/; do + lang=$(basename "$lang_dir") + if [ -d "$lang_dir/LC_MESSAGES" ] && [ -f "$lang_dir/LC_MESSAGES/$addon_name.po" ]; then + if [ "$lang" != "en" ]; then + PO_FILE="$lang_dir/LC_MESSAGES/$addon_name.po" + if msgattrib --untranslated --no-obsolete "$PO_FILE" 2>/dev/null | grep -q "^msgid"; then + echo "$lang ($addon_name addon)" + found=1 + fi + fi + fi + done + done + done + + if [ "$found" -eq 0 ]; then + echo "All languages are fully translated." + fi + exit 0 +fi + +# Ignore English language file +if [ "$LANG_CODE" = "en" ]; then + echo "Warning: English language file should never be translated." + echo "Available languages:" + for lang_dir in rayforge/locale/*/; do + lang=$(basename "$lang_dir") + if [ -d "$lang_dir/LC_MESSAGES" ] && [ -f "$lang_dir/LC_MESSAGES/rayforge.po" ]; then + if [ "$lang" != "en" ]; then + echo " - $lang" + fi + fi + done + exit 1 +fi + +PO_FILE="rayforge/locale/${LANG_CODE}/LC_MESSAGES/rayforge.po" + +# Check if the .po file exists +if [ ! -f "$PO_FILE" ]; then + echo "Error: Translation file not found: $PO_FILE" + echo "Available languages:" + for lang_dir in rayforge/locale/*/; do + lang=$(basename "$lang_dir") + if [ -d "$lang_dir/LC_MESSAGES" ] && [ -f "$lang_dir/LC_MESSAGES/rayforge.po" ]; then + echo " - $lang" + fi + done + exit 1 +fi + +# Print untranslated strings for main app (limited to 100 entries by default) +echo "=== rayforge (main app) ===" +echo "($PO_FILE)" +msgattrib --untranslated --no-obsolete "$PO_FILE" | awk ' + /^msgid/ && entry_count >= 100 { exit } + /^msgid/ { entry_count++ } + { print } +' + +# Check builtin packages +for pkg_dir in rayforge/builtin_packages/*/; do + pkg_name=$(basename "$pkg_dir") + locale_dir="$pkg_dir/locale" + check_package "$pkg_name" "$locale_dir" +done + +# Check addons +for addon_type in builtin_addons private_addons; do + for addon_dir in rayforge/$addon_type/*/; do + # Extract addon name from rayforge-addon.yaml + addon_yaml="$addon_dir/rayforge-addon.yaml" + if [ -f "$addon_yaml" ]; then + addon_name=$(grep "^name:" "$addon_yaml" | head -n 1 | cut -d':' -f2 | xargs) + else + addon_name=$(basename "$addon_dir") + fi + + if [ -d "$addon_dir/locale" ]; then + locale_dir="$addon_dir/locale" + else + continue + fi + + check_package "$addon_name" "$locale_dir" + done +done diff --git a/scripts/profile_raster.py b/scripts/profile_raster.py new file mode 100755 index 000000000..91f299c24 --- /dev/null +++ b/scripts/profile_raster.py @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +"""Raster-engraving performance & memory harness. + +Drives the real production functions for the three cost layers of the +raster pipeline and reports, per layer: + + * wall time + * native-heap peak delta (glibc ``mallinfo2`` — captures Rust + numpy) + * Python-heap peak delta (``tracemalloc``) + * RSS peak delta (``/proc/self/status`` sampled at ~5 ms) + +Layers measured +--------------- +A. Python preprocess pyvips load -> render to Cairo surface + -> ``preprocess_raster_image`` -> numpy arrays +B. PyO3 marshalling ``WholeImageSource(array)`` + alpha ``.tobytes()`` + (this is where ``extract_flat_u8`` runs) +C. Rust assembly ``RasterSpec`` + ``Assembler`` via ``execute_stages`` + (scan-line generation + ``Ops`` emission) + +The dimension/interval math mirrors +``EngraveStep._build_raster_part`` / ``build_compute_payload`` so the +numbers match the live app for a given laser spot size and workpiece size. + +Usage +----- + pixi run python scripts/profile_raster.py media/test-images/wolf.png + pixi run python scripts/profile_raster.py IMG --spot-mm 0.1 --mode power_modulated + pixi run python scripts/profile_raster.py IMG --sweep 0.05,0.1,0.2,0.4 + pixi run python scripts/profile_raster.py IMG --verbose # top allocators + +Profiling the real app (wolf.ryp) with memray --native +------------------------------------------------------ +This harness measures only the compute + aggregate stages headlessly. To +attribute the *full app* memory (machine-transform, the G-code encoder, +render bitmaps, GUI), profile the live app loading a ``.ryp`` under a +virtual framebuffer with ``memray --native`` so Rust/raygeo allocations +are captured. + +1. Make a tiny launcher so memray gets clean argv and you can target the + process by PID (avoids ``pkill`` self-match — see caveats):: + + # /tmp/opencode/launch_rayforge.py + import os, sys + with open("/tmp/opencode/app.pid", "w") as f: + f.write(str(os.getpid())) + from rayforge.app import main + sys.exit(main()) + +2. Run the app under xvfb + memray --native, time-bounded (the build is + heavy under instrumentation; ~3-4 min budget):: + + timeout --signal=TERM 240 \\ + xvfb-run -a -s "-screen 0 1920x1080x24" \\ + pixi run python -m memray run --native --follow-fork \\ + -o /tmp/opencode/wolf_native.bin \\ + /tmp/opencode/launch_rayforge.py wolf.ryp + +3. Analyze (``memray stats`` on a multi-GB bin is SLOW — allow minutes; + invoke the env python directly to avoid a pixi re-solve):: + + .pixi/envs/default/bin/python -m memray stats /tmp/opencode/wolf_native.bin + .pixi/envs/default/bin/python -m memray flamegraph /tmp/opencode/wolf_native.bin + +Caveats / lessons learned (this session) +---------------------------------------- +* **``pixi run`` reinstalls raygeo from PyPI.** Any plain ``pixi run`` + re-solves the env and OVERWRITES a locally-built raygeo, silently + discarding your Rust changes. To test local raygeo, add an override to + ``pixi.toml`` and rebuild with ``pixi reinstall raygeo``:: + + [pypi-options.dependency-overrides] + raygeo = { path = "external/raygeo", editable = true } + + ``scripts/rebuild-raygeo.sh`` appends its OWN override and CONFLICTS with + a manually-added one; use ``pixi reinstall raygeo`` instead. Remove the + override before committing. + +* **``scripts/pixi-raygeo.sh`` does not restore pixi.toml on SIGTERM.** Its + cleanup trap is EXIT-only, so ``timeout``-killing it leaves a stale + override marker in pixi.toml (which then blocks the wrapper). For + long-running, timeout-killed app runs, prefer the manual override above. + +* **rayforge is single-instance.** If an instance is already running, a + second launch exits immediately and memray captures only imports (tiny + bin, ~160 MB). Close the running instance first. + +* **Never ``pkill -f rayforge``** — it matches the shell running the pkill + and the command never finishes. Kill by PID via the launcher's + ``/tmp/opencode/app.pid``. + +* The app spams harmless serial-port errors (``/dev/ttyUSB0``) under xvfb. + Ignore them. + + +Baseline attribution (wolf.ryp, ~7.5 GB peak) +--------------------------------------------- +The peak is glibc retention of ~60 GB of allocation churn, not live data. +Top sources (memray --native, stock raygeo): +* native raygeo Ops copies (~38 GB churn) — addressed by ``Arc>`` + copy-on-write + boxed ``OpNode::state``. +* G-code encoder ``Ops::to_gcode`` (~12 GB / 147M allocs) — per-command + ``format!()`` strings + ``op_to_machine_code`` / ``machine_code_to_op`` + ``HashMap``s. The maps are required by the simulator/G-code preview; they + use ``HashMap`` for dense integer keys and could be compressed (dense + ``Vec`` / run-length) for several-fold reduction. +* ``kinematic_mapping.apply_to_job_ops`` (~1.3 GB) — ``transform_layers`` + copies the whole job per layer even for flat (non-rotary) jobs; early-out + when the machine has no ``rotary_modules``. +* ``surface_to_grayscale`` float32 alpha (~1.3 GB) — INTENTIONAL: cairo + needs float32; do not "compress" it. +""" + +from __future__ import annotations + +import argparse +import ctypes +import gc +import sys +import threading +import time +import tracemalloc +from contextlib import contextmanager +from dataclasses import dataclass, field + +import numpy as np +import pyvips +from raygeo.cnc.execution.specs import ( + AggregateGroup, + AggregateInput, + AggregateSpec, + ComputePayload, + MachineParams, +) +from raygeo.ops.assembly import Assembler +from raygeo.ops.assembly.raster import RasterSpec +from raygeo.ops.part import Part +from raygeo.ops.part.image_source import WholeImageSource +from raygeo.pipeline.execute import Pipeline +from raygeo.pipeline.request import NodeRequest +from raygeo.pipeline.stage import StageSpec + +from rayforge.image.util.vips import ( + normalize_to_rgba, + vips_rgba_to_cairo_surface, +) +from rayforge.pipeline.stage.assembler_helpers import ( + DepthMode, + preprocess_raster_image, +) + +IDENTITY = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], +] + + +def _aggregate_node(key: str, source_keys: list[str]) -> NodeRequest: + return NodeRequest( + key=key, + generation_id=1, + stage=StageSpec.Aggregate( + spec=AggregateSpec( + wrap_start=[], + groups=[ + AggregateGroup( + start_markers=[], + inputs=[ + AggregateInput( + source_key=sk, + placement_matrix=IDENTITY, + uid="", + target_dimensions=(0.0, 0.0), + ) + for sk in source_keys + ], + end_markers=[], + ) + ], + wrap_end=[], + machine=MachineParams(), + transformers=[], + ) + ), + ) + + +MAX_RASTER_RENDER_PIXELS = 16 * 1024 * 1024 + + +# --------------------------------------------------------------------------- +# Native-heap measurement (glibc malloc) +# --------------------------------------------------------------------------- + + +def _make_mallinfo_struct(field_type): + class _Mallinfo(ctypes.Structure): + _fields_ = [ + ("arena", field_type), + ("ordblks", field_type), + ("smblks", field_type), + ("hblks", field_type), + ("hblkhd", field_type), + ("usmblks", field_type), + ("fsmblks", field_type), + ("uordblks", field_type), + ("fordblks", field_type), + ("keepcost", field_type), + ] + + return _Mallinfo + + +class Mallinfo: + """Read glibc's in-use heap bytes (``uordblks``). + + ``mallinfo2`` (glibc >= 2.33) returns 64-bit fields; we fall back to + the deprecated ``mallinfo`` (``int``) on older systems. Rust and + numpy both allocate through the system allocator, so this captures + the transient spikes (e.g. the ``.tolist()`` path) that RSS is too + lazy to reflect. + """ + + def __init__(self): + libc = ctypes.CDLL("libc.so.6", use_errno=True) + try: + libc.mallinfo2.restype = _make_mallinfo_struct(ctypes.c_size_t) + self._fn = libc.mallinfo2 + except AttributeError: + libc.mallinfo.restype = _make_mallinfo_struct(ctypes.c_int) + self._fn = libc.mallinfo + + def in_use_bytes(self) -> int: + """Total bytes currently allocated via malloc (uordblks).""" + return int(self._fn().uordblks) + + +def _probe_mallinfo() -> Mallinfo | None: + try: + mi = Mallinfo() + mi.in_use_bytes() + return mi + except (OSError, AttributeError): + return None + + +def read_rss_kb() -> int: + try: + with open("/proc/self/status") as f: + for line in f: + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except OSError: + pass + return 0 + + +# --------------------------------------------------------------------------- +# Sampler +# --------------------------------------------------------------------------- + + +@dataclass +class PhaseRec: + name: str + elapsed: float = 0.0 + base_arena: int = 0 + base_rss: int = 0 + base_traced: int = 0 + peak_arena_delta: int = 0 + peak_rss_delta: int = 0 + peak_traced_delta: int = 0 + end_traced_delta: int = 0 + + +@dataclass +class Sampler: + interval: float = 0.005 + mallinfo: Mallinfo | None = field(default_factory=_probe_mallinfo) + phases: dict[str, PhaseRec] = field(default_factory=dict) + _cur: PhaseRec | None = None + _stop: threading.Event = field(default_factory=threading.Event) + _thread: threading.Thread | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=1.0) + + def _run(self) -> None: + while not self._stop.is_set(): + arena = ( + self.mallinfo.in_use_bytes() + if self.mallinfo is not None + else 0 + ) + rss = read_rss_kb() * 1024 + rec = self._cur + if rec is not None: + rec.peak_arena_delta = max( + rec.peak_arena_delta, arena - rec.base_arena + ) + rec.peak_rss_delta = max( + rec.peak_rss_delta, rss - rec.base_rss + ) + self._stop.wait(self.interval) + + @contextmanager + def phase(self, name: str): + gc.collect() + tracemalloc.reset_peak() + base_arena = self.mallinfo.in_use_bytes() if self.mallinfo else 0 + rec = PhaseRec( + name=name, + base_arena=base_arena, + base_rss=read_rss_kb() * 1024, + base_traced=tracemalloc.get_traced_memory()[0], + ) + self.phases[name] = rec + self._cur = rec + t0 = time.perf_counter() + try: + yield rec + finally: + t1 = time.perf_counter() + self._cur = None + rec.elapsed = t1 - t0 + cur_tr, peak_tr = tracemalloc.get_traced_memory() + rec.peak_traced_delta = max(0, peak_tr - rec.base_traced) + rec.end_traced_delta = max(0, cur_tr - rec.base_traced) + gc.collect() + + +# --------------------------------------------------------------------------- +# Dimension math (mirrors EngraveStep._build_raster_part) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RasterGeom: + target_w: int + target_h: int + px_per_mm_x: float + px_per_mm_y: float + line_interval_mm: float + sample_interval_mm: float + + +def compute_geom(spot_mm: float, size_mm: tuple[float, float]) -> RasterGeom: + spot_x = spot_y = spot_mm + sample_interval = spot_x / 2.0 + px_per_mm_x = 1.0 / sample_interval + px_per_mm_y = 1.0 / spot_y + target_w = max(1, int(size_mm[0] * px_per_mm_x)) + target_h = max(1, int(size_mm[1] * px_per_mm_y)) + n = target_w * target_h + if n > MAX_RASTER_RENDER_PIXELS: + scale = (MAX_RASTER_RENDER_PIXELS / n) ** 0.5 + target_w = max(1, int(target_w * scale)) + target_h = max(1, int(target_h * scale)) + px_per_mm_x = target_w / size_mm[0] + px_per_mm_y = target_h / size_mm[1] + return RasterGeom( + target_w=target_w, + target_h=target_h, + px_per_mm_x=px_per_mm_x, + px_per_mm_y=px_per_mm_y, + line_interval_mm=spot_y, + sample_interval_mm=sample_interval, + ) + + +# --------------------------------------------------------------------------- +# The three layers +# --------------------------------------------------------------------------- + +DEPTH_BY_RAYGEO_NAME = { + "power_modulated": DepthMode.POWER_MODULATION, + "mask_scan": DepthMode.CONSTANT_POWER, + "dither": DepthMode.DITHER, + "multi_pass": DepthMode.MULTI_PASS, +} + + +def load_source_image(path: str) -> pyvips.Image: + return pyvips.Image.pngload(path, access=pyvips.Access.RANDOM) + + +def layer_a_preprocess( + src: pyvips.Image, + geom: RasterGeom, + mode: str, +) -> tuple[ + object, tuple[np.ndarray | None, np.ndarray | None], tuple[float, float] +]: + """Render to a Cairo surface at the target resolution and run the + real ``preprocess_raster_image``. The Part is built in layer B so + marshalling is isolated from preprocessing.""" + rendered = src.thumbnail_image( + geom.target_w, height=geom.target_h, size="force" + ) + norm = normalize_to_rgba(rendered) + surface = vips_rgba_to_cairo_surface(norm) + depth = DEPTH_BY_RAYGEO_NAME[mode] + image, alpha = preprocess_raster_image( + surface, + mode=depth, + invert=False, + auto_levels=True, + laser_spot_x_mm=0.1, + pixels_per_mm_x=geom.px_per_mm_x, + ) + surface.flush() + size_mm = ( + geom.target_w / geom.px_per_mm_x, + geom.target_h / geom.px_per_mm_y, + ) + return surface, (image, alpha), size_mm + + +def layer_b_marshal( + image: np.ndarray | None, + alpha: np.ndarray | None, + size_mm: tuple[float, float], + geom: RasterGeom, +) -> tuple[Part, bytes | None]: + part = Part( + size_mm=size_mm, + pixels_per_mm=(geom.px_per_mm_x, geom.px_per_mm_y), + ) + part.image_source = WholeImageSource(image) + alpha_arr = ( + (alpha * 255).astype(np.uint8).tobytes() if alpha is not None else None + ) + return part, alpha_arr + + +def layer_c_assemble( + part: Part, + alpha_arr: bytes | None, + geom: RasterGeom, + mode: str, + num_power_levels: int, + cache_budget_bytes: int, +) -> dict: + spec = RasterSpec( + mode=mode, + line_interval_mm=geom.line_interval_mm, + sample_interval_mm=geom.sample_interval_mm, + min_power=0.0, + max_power=1.0, + step_power=0.1, + num_power_levels=num_power_levels, + angle=0.0, + scan_mode="segmented", + cross_hatch=False, + num_depth_levels=5, + alpha=alpha_arr, + ) + node = NodeRequest( + key="wp", + generation_id=1, + stage=StageSpec.Compute( + part=part, + params=ComputePayload(assembler=Assembler(spec)), + ), + ) + nodes = [ + node, + _aggregate_node("step", ["wp"]), + _aggregate_node("job", ["step"]), + ] + pipe = Pipeline(cache_budget_bytes) + completed = [] + pipe.clear_cache() + pipe.execute(nodes, completed.append, None) + pipe.clear_cache() + by_key = {c.key: c for c in completed} + final = by_key.get("job") or by_key.get("wp") + if final is None or final.error is not None: + err = final.error if final else "no completion" + raise RuntimeError(f"assembly failed: {err}") + ops = final.output.ops + info = { + "ops_len": ops.len(), + "ops_heap_mb": ops.heap_size() / 1e6, + } + return info + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def run_one( + src: pyvips.Image, + spot_mm: float, + size_mm: tuple[float, float], + mode: str, + num_power_levels: int, + sampler: Sampler, + cache_budget_bytes: int, + verbose: bool = False, +) -> dict: + geom = compute_geom(spot_mm, size_mm) + + with sampler.phase("A: render + preprocess"): + _surface, (image, alpha), size_mm = layer_a_preprocess(src, geom, mode) + if image is None: + raise RuntimeError("preprocess returned no image") + + with sampler.phase("B: PyO3 marshalling"): + part, alpha_arr = layer_b_marshal(image, alpha, size_mm, geom) + + with sampler.phase("C: Rust assembly"): + ops_info = layer_c_assemble( + part, + alpha_arr, + geom, + mode, + num_power_levels, + cache_budget_bytes, + ) + + if verbose: + snap = tracemalloc.take_snapshot() + print("\nTop Python allocators (this run, cumulative):") + for stat in snap.statistics("lineno")[:12]: + print(f" {stat}") + + a = sampler.phases["A: render + preprocess"] + b = sampler.phases["B: PyO3 marshalling"] + c = sampler.phases["C: Rust assembly"] + return { + "spot_mm": spot_mm, + "target": f"{geom.target_w}x{geom.target_h}", + "mpx": geom.target_w * geom.target_h / 1e6, + "A_time": a.elapsed, + "A_native_mb": a.peak_arena_delta / 1e6, + "A_py_mb": a.peak_traced_delta / 1e6, + "A_rss_mb": a.peak_rss_delta / 1e6, + "B_time": b.elapsed, + "B_native_mb": b.peak_arena_delta / 1e6, + "B_py_mb": b.peak_traced_delta / 1e6, + "B_rss_mb": b.peak_rss_delta / 1e6, + "C_time": c.elapsed, + "C_native_mb": c.peak_arena_delta / 1e6, + "C_py_mb": c.peak_traced_delta / 1e6, + "C_rss_mb": c.peak_rss_delta / 1e6, + "ops_len": ops_info["ops_len"], + "ops_heap_mb": ops_info["ops_heap_mb"], + } + + +def parse_size(s: str) -> tuple[float, float]: + w, h = s.lower().split("x") + return float(w), float(h) + + +def print_row(cols, widths): + print(" ".join(str(c).rjust(w) for c, w in zip(cols, widths))) + + +def print_table(rows: list[dict]): + headers = [ + "spot", + "target", + "MP", + "A_s", + "A_MB", + "B_s", + "B_MB", + "C_s", + "C_MB", + "ops_len", + "ops_MB", + ] + widths = [6, 11, 6, 6, 8, 6, 8, 6, 8, 10, 9] + print_row(headers, widths) + print(" ".join("-" * w for w in widths)) + for r in rows: + print_row( + [ + f"{r['spot_mm']:.3f}", + r["target"], + f"{r['mpx']:.1f}", + f"{r['A_time']:.2f}", + f"{r['A_native_mb']:.0f}", + f"{r['B_time']:.2f}", + f"{r['B_native_mb']:.0f}", + f"{r['C_time']:.2f}", + f"{r['C_native_mb']:.0f}", + r["ops_len"], + f"{r['ops_heap_mb']:.0f}", + ], + widths, + ) + note = ( + " _MB columns = peak native-heap delta per layer (mallinfo2).\n" + " Run under `memray run --native` for the authoritative\n" + " process-wide transient peak (incl. mmap / non-heap)." + ) + print(note) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("image", help="Path to a PNG raster image") + ap.add_argument( + "--spot-mm", + type=float, + default=0.1, + help="Laser spot size in mm (default 0.1)", + ) + ap.add_argument( + "--size-mm", + type=parse_size, + default=(200.0, 260.0), + help="Workpiece size as WxH mm (default 200x260)", + ) + ap.add_argument( + "--mode", + default="power_modulated", + choices=sorted(DEPTH_BY_RAYGEO_NAME), + help="Raster depth mode (default power_modulated)", + ) + ap.add_argument( + "--num-power-levels", + type=int, + default=10, + help="Power quantization levels (default 10)", + ) + ap.add_argument( + "--sweep", + default=None, + help="Comma-separated spot sizes in mm, e.g. 0.05,0.1,0.2,0.4", + ) + ap.add_argument( + "--verbose", action="store_true", help="Print top Python allocators" + ) + ap.add_argument( + "--cache-budget-gb", + type=float, + default=16.0, + help="raygeo cache budget in GiB (default 16)", + ) + args = ap.parse_args(argv) + + tracemalloc.start(25) + sampler = Sampler() + sampler.start() + try: + src = load_source_image(args.image) + print( + f"image: {args.image} " + f"{src.width}x{src.height} " + f"({src.width * src.height / 1e6:.1f} MP, {src.bands} bands)" + ) + print( + f"mode: {args.mode} size: {args.size_mm[0]}x" + f"{args.size_mm[1]} mm\n" + ) + + if args.sweep: + spots = [float(x) for x in args.sweep.split(",")] + else: + spots = [args.spot_mm] + + rows = [] + budget = int(args.cache_budget_gb * 1024**3) + for sp in spots: + sampler.phases.clear() + r = run_one( + src, + sp, + args.size_mm, + args.mode, + args.num_power_levels, + sampler, + budget, + args.verbose, + ) + rows.append(r) + print_table(rows) + finally: + sampler.stop() + tracemalloc.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/rebuild-raygeo.sh b/scripts/rebuild-raygeo.sh new file mode 100755 index 000000000..5cd3ac40a --- /dev/null +++ b/scripts/rebuild-raygeo.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Rebuild raygeo from external/raygeo after Rust/Python source changes. +# Clears the uv wheel cache so the new .so is compiled on reinstall. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PIXI_TOML="$ROOT_DIR/pixi.toml" +PIXI_LOCK="$ROOT_DIR/pixi.lock" + +RAYGEO_CANDIDATE="${RAYGEO_PATH:-$ROOT_DIR/external/raygeo}" +if [[ ! -d "$RAYGEO_CANDIDATE" ]]; then + echo "rebuild-raygeo: local raygeo checkout not found at '$RAYGEO_CANDIDATE'." >&2 + echo " create external/raygeo or set RAYGEO_PATH." >&2 + exit 1 +fi +RAYGEO_ABS="$(cd "$RAYGEO_CANDIDATE" && pwd)" + +# Clear the uv cache so the next build is fresh, not a stale wheel. +CACHE_DIR=$(pixi info --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["cache_dir"]+"/uv-cache")') +UV_CACHE_DIR="$CACHE_DIR" uv cache prune + +MARKER="# pixi-raygeo: temporary override (auto-removed)" +if grep -qF "$MARKER" "$PIXI_TOML"; then + echo "rebuild-raygeo: $PIXI_TOML already has a temporary override marker." >&2 + echo " a previous run may not have restored it; run:" >&2 + echo " git checkout pixi.toml pixi.lock" >&2 + exit 1 +fi + +backup="$(mktemp -d)" +cleanup() { + cp "$backup/pixi.toml" "$PIXI_TOML" + if [[ -f "$backup/pixi.lock" ]]; then + cp "$backup/pixi.lock" "$PIXI_LOCK" + fi + rm -rf "$backup" +} +trap cleanup EXIT + +cp "$PIXI_TOML" "$backup/pixi.toml" +if [[ -f "$PIXI_LOCK" ]]; then + cp "$PIXI_LOCK" "$backup/pixi.lock" +fi + +cat >> "$PIXI_TOML" < str: + parts = target.split(":") + if len(parts) > 1: + return parts[1] + return "console" + + +def main(): + target = get_target("bottom-panel:console") + tab_name = get_tab_name(target) + config = TAB_CONFIG.get(tab_name, TAB_CONFIG["console"]) + + set_window_size(win, 1400, 900) + + load_project(win, config["project"]) + 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, showing bottom panel") + + saved_states = save_panel_states(win, PANELS) + show_panel(win, "toggle_bottom_panel", True) + show_bottom_tab(win, tab_name) + + time.sleep(0.5) + + window_height = run_on_main_thread(lambda: win.get_height()) + crop_from_top = window_height - PANEL_HEIGHT - MARGIN - STATUS_BAR_HEIGHT + + logger.info( + f"Taking cropped screenshot of '{tab_name}' tab " + f"(window height={window_height}, crop_top={crop_from_top})" + ) + take_cropped_screenshot( + target_to_filename(target), + from_top=crop_from_top, + ) + + restore_panel_states(win, saved_states) + + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py new file mode 100755 index 000000000..22d0f587a --- /dev/null +++ b/scripts/screenshot/cli.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Screenshot CLI for Rayforge.""" + +import argparse +import fnmatch +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).parent +PROJECT_ROOT = SCRIPTS_DIR.parent.parent +TEST_CONFIG_DIR = PROJECT_ROOT / "tests" / "config" + +TARGETS = { + "addon:ai-workpiece-generator": "ai_workpiece_generator", + "addon:deepnest": "deepnest", + "addon:print-and-cut:pick": "print_and_cut", + "addon:print-and-cut:jog": "print_and_cut", + "addon:print-and-cut:apply": "print_and_cut", + "addon:projector-mode": "projector_mode", + "app-settings:general": "app_settings_general", + "app-settings:machines": "app_settings_machines", + "app-settings:machines:add": "add_machine_dialog", + "app-settings:materials": "app_settings_materials", + "app-settings:recipes": "app_settings_recipes", + "app-settings:addons": "app_settings_addons", + "app-settings:ai": "app_settings_ai", + "bottom-panel:console": "bottom_panel", + "bottom-panel:layers": "bottom_panel", + "config-wizard:ai-lookup": "config_wizard", + "config-wizard:ai-provider": "config_wizard", + "config-wizard:camera": "config_wizard", + "config-wizard:controller": "config_wizard", + "config-wizard:connect": "config_wizard", + "config-wizard:probe": "config_wizard", + "config-wizard:profile": "config_wizard", + "config-wizard:hardware": "config_wizard", + "config-wizard:head": "config_wizard", + "config-wizard:review": "config_wizard", + "config-wizard:rotary": "config_wizard", + "import-dialog": "import_dialog", + "machine-settings:general": "machine_settings_general", + "machine-settings:hardware": "machine_settings_hardware", + "machine-settings:advanced": "machine_settings_advanced", + "machine-settings:gcode": "machine_settings_gcode", + "machine-settings:hooks-macros": "machine_settings_hooks-macros", + "machine-settings:device": "machine_settings_device", + "machine-settings:laser": "machine_settings_laser", + "machine-settings:rotary-module": "machine_settings_rotary_module", + "machine-settings:camera": "machine_settings_camera", + "machine-settings:camera:image-settings": "machine_settings_camera", + "machine-settings:camera:lens-calibration": "machine_settings_camera", + "machine-settings:camera:lens-calibration:wizard-card": ( + "machine_settings_camera" + ), + "machine-settings:camera:lens-calibration:wizard-capture": ( + "machine_settings_camera" + ), + "machine-settings:camera:image-alignment": "machine_settings_camera", + "machine-settings:maintenance": "machine_settings_maintenance", + "machine-settings:nogo-zones": "machine_settings_nogo_zones", + "main:standard": "main_standard", + "main:3d": "main_3d", + "main:3d-rotary": "main_3d_rotary", + "main:array:grid": "array_grid", + "main:array:point-rotation": "array_point_rotation", + "main:array:circular": "array_circular", + "material-test": "material_test", + "operations:wavefront": "wavefront", + "recipe-editor:general": "recipe_editor_general", + "recipe-editor:applicability": "recipe_editor_applicability", + "recipe-editor:laser": "recipe_editor_settings", + "recipe-editor:step-settings": "recipe_editor_settings", + "recipe-editor:post-processing": "recipe_editor_settings", + "sanity-check": "sanity_check", + "step-settings:contour:general": "step_settings", + "step-settings:contour:laser": "step_settings", + "step-settings:contour:post": "step_settings", + "step-settings:engrave:general:constant_power": "step_settings", + "step-settings:engrave:general:dither": "step_settings", + "step-settings:engrave:general:multi_pass": "step_settings", + "step-settings:engrave:general:variable": "step_settings", + "step-settings:engrave:laser": "step_settings", + "step-settings:engrave:post": "step_settings", + "step-settings:frame-outline:general": "step_settings", + "step-settings:frame-outline:laser": "step_settings", + "step-settings:frame-outline:post": "step_settings", + "step-settings:shrink-wrap:general": "step_settings", + "step-settings:shrink-wrap:laser": "step_settings", + "step-settings:shrink-wrap:post": "step_settings", + "step-settings:wavefront:general": "step_settings", + "step-settings:wavefront:laser": "step_settings", + "step-settings:wavefront:post": "step_settings", +} + + +def get_matching_targets(target: str) -> list[str]: + """Find all leaf targets that match the given target spec. + + Supports glob patterns (e.g. "step-settings*post") and prefix + matching (e.g. "step-settings" matches all leaves under it). + A leaf target is one with no children. + """ + if any(c in target for c in "*?["): + matches = [t for t in TARGETS if fnmatch.fnmatch(t, target)] + if not matches: + matches = [ + t + for t in TARGETS + if fnmatch.fnmatch(t, target.replace("*", ":*")) + ] + return matches + if target in TARGETS: + return [target] + children = [t for t in TARGETS if t.startswith(target + ":")] + if children: + return [ + t + for t in children + if not any(other.startswith(t + ":") for other in TARGETS) + ] + return [] + + +def run_script(script_name: str, target: str) -> int: + with tempfile.TemporaryDirectory(prefix="rayforge-screenshot-") as tmpdir: + shutil.copytree(TEST_CONFIG_DIR, tmpdir, dirs_exist_ok=True) + cmd = [ + "pixi", + "run", + "rayforge", + "--config", + tmpdir, + "--uiscript", + str(SCRIPTS_DIR / f"{script_name}.py"), + ] + print(f"Running: {' '.join(cmd)} (TARGET={target})") + env = os.environ.copy() + env["TARGET"] = target + # Force the isolated test config even if the --config argument + # were ever dropped or parsed by a wrapping command, so screenshots + # never depend on the developer's personal machine configuration. + env["RAYFORGE_CONFIG_DIR"] = tmpdir + return subprocess.run(cmd, env=env, check=False).returncode + + +def generate_help_text() -> str: + lines = ["Available leaf targets:"] + for target in sorted(TARGETS.keys()): + lines.append(f" {target}") + lines.append("") + lines.append("Useful prefixes (match all leaves under):") + lines.append( + " main, app-settings, machine-settings, step-settings, bottom-panel" + ) + lines.append( + " addon, step-settings:engrave, step-settings:engrave:general" + ) + lines.append("") + lines.append("Use 'all' to run everything") + lines.append("") + lines.append("Glob patterns are supported (e.g. 'step-settings*post')") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Take screenshots for Rayforge documentation.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=generate_help_text(), + ) + parser.add_argument("target", help="Screenshot target") + args = parser.parse_args() + target: str = args.target + + if target == "all": + targets = list(TARGETS.keys()) + else: + targets = get_matching_targets(target) + + if not targets: + print(f"No targets match: {target}") + return 1 + + for target in targets: + script = TARGETS[target] + result = run_script(script, target) + if result != 0: + return result + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/screenshot/config_wizard.py b/scripts/screenshot/config_wizard.py new file mode 100644 index 000000000..5cfe39940 --- /dev/null +++ b/scripts/screenshot/config_wizard.py @@ -0,0 +1,121 @@ +"""Screenshot: Unified machine configuration wizard pages. + +The wizard's adaptive routing means many of the intermediate steps +either auto-skip or are only reachable via the live probe flow. +For screenshots we open the wizard, jump to the requested step, +and snapshot the rendered UI. + +Targets (``config-wizard:``): + +* ``profile`` — Step 1 (pick source) +* ``controller`` — Step 2 (choose controller) +* ``connect`` — Step 3 (connection) +* ``probe`` — Step 4 (discover device) +* ``ai-provider`` — Step 5 (AI provider) +* ``ai-lookup`` — Step 6 (AI spec lookup) +* ``hardware`` — Step 7 (hardware) +* ``head`` — Step 8 (head) +* ``rotary`` — Step 9 (rotary module) +* ``camera`` — Step 10 (cameras) +* ``review`` — Step 11 (review & name) +""" + +import logging +import time + +from utils import ( + get_target, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, +) + +from rayforge.machine.device.profile import ( + DeviceMeta, + DeviceProfile, + MachineConfig, +) +from rayforge.machine.models.machine import Origin +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +FAKE_PROFILE = DeviceProfile( + meta=DeviceMeta( + name="Ortur Laser Master 2", + vendor="Ortur", + model="Aufero Laser Master 2", + description="Auto-configured via unified wizard", + ), + machine_config=MachineConfig( + driver="GrblSerialDriver", + driver_args={"port": "/dev/ttyUSB0", "baud_rate": 115200}, + axis_extents=(400.0, 430.0), + origin=Origin.BOTTOM_LEFT, + max_travel_speed=3000, + max_cut_speed=1000, + acceleration=500, + home_on_start=True, + single_axis_homing_enabled=True, + heads=[{"head_class": "LaserHead", "max_power": 1000}], + ), + dialect_config={}, +) + + +# Wizard step name is the target's leaf with ``-`` -> ``_``. +def wizard_step_for(target: str) -> str: + return target.split(":")[-1].replace("-", "_") + + +def main(): + target = get_target("config-wizard:connect") + if not target.startswith("config-wizard:"): + logger.error("Unknown wizard screenshot target: %s", target) + app.quit_idle() + return + step = wizard_step_for(target) + + set_window_size(win, 1400, 1000) + time.sleep(0.25) + + from rayforge.ui_gtk.machine.unified_wizard import UnifiedWizard + + def open_wizard(): + wizard = UnifiedWizard(transient_for=win) + wizard.present() + + # Pre-load a known-profile state and route to the requested + # step. + wizard.profile = FAKE_PROFILE + return wizard + + wizard = run_on_main_thread(open_wizard) + time.sleep(0.5) + + if step == "probe": + # The probe page auto-starts a live probe on entry. Build the + # page first and suppress that so the screenshot shows the + # idle "Probe Now" state rather than a connection failure. + def suppress_auto_probe(): + page = wizard._get_page("probe") + page._probed = True + + run_on_main_thread(suppress_auto_probe) + + run_on_main_thread(lambda: wizard._navigate_to(step)) + time.sleep(0.5) + take_screenshot(target_to_filename(target)) + + time.sleep(0.25) + + def close_wizard(): + wizard.close() + + run_on_main_thread(close_wizard) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/deepnest.py b/scripts/screenshot/deepnest.py new file mode 100644 index 000000000..5ee408201 --- /dev/null +++ b/scripts/screenshot/deepnest.py @@ -0,0 +1,84 @@ +"""Screenshot: Deepnest addon settings dialog.""" + +import logging +import time +from pathlib import Path + +from utils import ( + get_target, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +HEARTS_PROJECT = ( + Path(__file__).parent.parent.parent / "tests" / "assets" / "pretty.ryp" +) + +PRODUCT_ID = "A56heLPCXT6uPUpnmpnZYQ==" + + +def _ensure_addon_loaded(): + from rayforge.context import get_context + + ctx = get_context() + if "deepnest" in ctx.addon_mgr.loaded_addons: + return + + def _add_license(): + ctx.license_validator.add_gumroad_license( + PRODUCT_ID, "TESTKEY-deepnest" + ) + + run_on_main_thread(_add_license) + + if "deepnest" not in ctx.addon_mgr.loaded_addons: + raise RuntimeError("Failed to load deepnest addon") + + +def open_dialog(): + import importlib + + NestingSettingsDialog = importlib.import_module( + "rayforge_addons.deepnest.deepnest.dialog" + ).NestingSettingsDialog + + dialog = NestingSettingsDialog(win) + dialog.present() + return dialog + + +def main(): + target = get_target("addon:deepnest") + _ensure_addon_loaded() + + set_window_size(win, 1400, 1000) + + def load(): + win.doc_editor.file.load_project_from_path(HEARTS_PROJECT) + + run_on_main_thread(load) + if not wait_for_settled(win, timeout=15): + logger.error("Document did not settle in time") + app.quit_idle() + return + + time.sleep(1.0) + + dialog = run_on_main_thread(open_dialog) + time.sleep(0.5) + + take_screenshot(target_to_filename(target)) + + time.sleep(0.25) + run_on_main_thread(dialog.close) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/import_dialog.py b/scripts/screenshot/import_dialog.py new file mode 100644 index 000000000..20fffc907 --- /dev/null +++ b/scripts/screenshot/import_dialog.py @@ -0,0 +1,76 @@ +""" +Screenshot: Import dialog. + +Usage: pixi run screenshot import-dialog +""" + +import logging +import time +from pathlib import Path + +from utils import ( + get_target, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TEST_IMAGE = ( + Path(__file__).parent.parent.parent + / "tests" + / "image" + / "svg" + / "rayforge.svg" +) + + +def main(): + target = get_target("import-dialog") + set_window_size(win, 1400, 1000) + + 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.ui_gtk.doceditor.import_dialog import ImportDialog + + def open_import_dialog(): + _, features = win.doc_editor.file.get_importer_info( + TEST_IMAGE, "image/svg+xml" + ) + dialog = ImportDialog( + parent=win, + editor=win.doc_editor, + file_path=TEST_IMAGE, + mime_type="image/svg+xml", + features=features, + ) + dialog.set_default_size(1100, 800) + dialog.present() + return dialog + + dialog = run_on_main_thread(open_import_dialog) + + time.sleep(1.0) + + logger.info("Taking screenshot: import-dialog.png") + take_screenshot(target_to_filename(target)) + + time.sleep(0.25) + + def close_dialog(): + dialog.close() + + run_on_main_thread(close_dialog) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_advanced.py b/scripts/screenshot/machine_settings_advanced.py new file mode 100644 index 000000000..26a022c2a --- /dev/null +++ b/scripts/screenshot/machine_settings_advanced.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - Advanced page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "advanced" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_camera.py b/scripts/screenshot/machine_settings_camera.py new file mode 100644 index 000000000..dd0a5c8e4 --- /dev/null +++ b/scripts/screenshot/machine_settings_camera.py @@ -0,0 +1,252 @@ +"""Screenshot: Machine settings - Camera page and dialogs.""" + +import logging +import subprocess +import time +from pathlib import Path + +import cv2 +from gi.repository import GLib +from utils import ( + get_target, + open_machine_settings, + run_on_main_thread, + take_screenshot, + target_to_filename, +) + +from rayforge.camera.models.camera import Camera +from rayforge.context import get_context +from rayforge.ui_gtk.camera.alignment_dialog import CameraAlignmentDialog +from rayforge.ui_gtk.camera.image_settings_dialog import ( + CameraImageSettingsDialog, +) +from rayforge.ui_gtk.camera.lens_calibration_dialog import ( + LensCalibrationDialog, +) +from rayforge.ui_gtk.camera.wizard.wizard import CameraWizard +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +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 = { + "image-settings": {"dialog_cls": CameraImageSettingsDialog}, + "lens-calibration": {"dialog_cls": LensCalibrationDialog}, + "image-alignment": {"dialog_cls": CameraAlignmentDialog}, +} + + +def parse_target(target: str) -> dict | None: + """Parse target into a handler config, or None for the settings page.""" + parts = target.split(":") + # machine-settings:camera -> None (page screenshot) + # machine-settings:camera:image-settings -> dialog + # machine-settings:camera:calibration-wizard:card -> wizard + if len(parts) <= 2: + return None + sub = ":".join(parts[2:]) + if sub.startswith("lens-calibration:wizard-"): + wizard_page = sub.split(":wizard-")[1] if ":wizard-" in sub else "" + if wizard_page not in WIZARD_PAGES: + logger.error(f"Unknown wizard page: {wizard_page}") + return None + return {"type": "wizard", "page": wizard_page} + if sub in DIALOGS: + return {"type": "dialog", "key": sub} + logger.error(f"Unknown target: {target}") + 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 + + def _add(): + camera = Camera("Test Camera", "mock-device-0") + camera.enabled = True + machine.add_camera(camera) + return camera + + camera = run_on_main_thread(_add) + time.sleep(0.5) + + camera_page = dialog.camera_page + list_box = camera_page.camera_list_editor.list_box + + def _select(): + row = list_box.get_row_at_index(0) + if row: + list_box.select_row(row) + + run_on_main_thread(_select) + time.sleep(0.25) + + controller = get_context().camera_mgr.get_controller("mock-device-0") + if controller: + inject_mock_image(controller) + return camera, controller + + +def setup_camera_page(dialog): + """Ensure a mock camera exists and is selected on the camera page.""" + machine = dialog.machine + + def _has_camera(): + return len(machine.cameras) > 0 + + if not run_on_main_thread(_has_camera): + return add_mock_camera(dialog) + + controller = get_context().camera_mgr.get_controller("mock-device-0") + if controller: + inject_mock_image(controller) + return None, controller + + +def take_wizard_screenshot(parent_dialog, wizard_page: str, output: str): + """Open the lens calibration wizard directly and take a screenshot.""" + + _camera, controller = add_mock_camera(parent_dialog) + if not controller: + logger.error("Failed to create mock camera controller") + return + + wizard = None + + def _open_wizard(): + nonlocal wizard + wizard = CameraWizard(parent_dialog, controller) + wizard.present() + return wizard + + wizard = run_on_main_thread(_open_wizard) + time.sleep(0.5) + + def _choose_automatic(): + # image settings -> lens choice -> (automatic) -> card -> capture + choice = wizard._pages["lens_choice"] + choice._on_branch_clicked(choice._automatic_btn) + + run_on_main_thread(_choose_automatic) + time.sleep(0.5) + + if wizard_page == "capture": + run_on_main_thread(lambda: wizard._navigate_to("capture")) + time.sleep(0.5) + + # Activate the wizard window so gnome-screenshot -w captures it + try: + subprocess.run( + [ + "xdotool", + "search", + "--name", + "Camera Wizard", + "windowactivate", + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + time.sleep(0.25) + except (FileNotFoundError, subprocess.TimeoutExpired): + logger.warning("xdotool not available, relying on window focus") + + take_screenshot(output) + + def _cleanup(): + wizard.close() + + run_on_main_thread(_cleanup) + time.sleep(0.25) + + +def main(): + target_info = parse_target(TARGET) + output = target_to_filename(TARGET) + time.sleep(0.25) + dialog = open_machine_settings(win, PAGE) + time.sleep(0.25) + + if target_info is None: + setup_camera_page(dialog) + time.sleep(0.25) + take_screenshot(output) + time.sleep(0.25) + app.quit_idle() + return + + if target_info["type"] == "wizard": + take_wizard_screenshot(dialog, target_info["page"], output) + time.sleep(0.25) + app.quit_idle() + return + + config = DIALOGS.get(target_info["key"]) + if config is None: + logger.error(f"Unknown camera dialog target: {target_info['key']}") + app.quit_idle() + return + + _camera, controller = add_mock_camera(dialog) + if not controller: + logger.error("Failed to create mock camera controller") + app.quit_idle() + return + + def _open(): + d = config["dialog_cls"](dialog, controller) + d.present() + return d + + camera_dialog = run_on_main_thread(_open) + time.sleep(0.5) + take_screenshot(output) + + def _close(): + camera_dialog.close() + + run_on_main_thread(_close) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_device.py b/scripts/screenshot/machine_settings_device.py new file mode 100644 index 000000000..970c5db7a --- /dev/null +++ b/scripts/screenshot/machine_settings_device.py @@ -0,0 +1,53 @@ +"""Screenshot: Machine settings - Device page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + run_on_main_thread, + take_screenshot, + target_to_filename, +) + +from rayforge.machine.driver.grbl.grbl_util import get_grbl_setting_varsets +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "device" + + +def inject_fake_device_settings(dialog): + """Inject fake device settings into the device settings page.""" + device_page = dialog.content_stack.get_child_by_name("device") + + def _inject(): + var_sets = get_grbl_setting_varsets() + + device_page._is_busy = False + device_page._clear_error_state() + device_page._not_connected_warning_dismissed = True + + device_page.machine.driver.supports_settings = True + + device_page._rebuild_settings_widgets(var_sets) + device_page._update_ui_state() + + run_on_main_thread(_inject) + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + dialog = open_machine_settings(win, PAGE) + + inject_fake_device_settings(dialog) + + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_gcode.py b/scripts/screenshot/machine_settings_gcode.py new file mode 100644 index 000000000..ce86794b0 --- /dev/null +++ b/scripts/screenshot/machine_settings_gcode.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - G-code page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "gcode" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_general.py b/scripts/screenshot/machine_settings_general.py new file mode 100644 index 000000000..82db9f3ef --- /dev/null +++ b/scripts/screenshot/machine_settings_general.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - General page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "general" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_hardware.py b/scripts/screenshot/machine_settings_hardware.py new file mode 100644 index 000000000..70b1d0059 --- /dev/null +++ b/scripts/screenshot/machine_settings_hardware.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - Hardware page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "hardware" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_hooks-macros.py b/scripts/screenshot/machine_settings_hooks-macros.py new file mode 100644 index 000000000..38ec6b2d8 --- /dev/null +++ b/scripts/screenshot/machine_settings_hooks-macros.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - Hooks & Macros page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "hooks-macros" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_laser.py b/scripts/screenshot/machine_settings_laser.py new file mode 100644 index 000000000..146520a77 --- /dev/null +++ b/scripts/screenshot/machine_settings_laser.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - Laser page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "laser" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_maintenance.py b/scripts/screenshot/machine_settings_maintenance.py new file mode 100644 index 000000000..5f8bf7894 --- /dev/null +++ b/scripts/screenshot/machine_settings_maintenance.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - Maintenance page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "maintenance" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_nogo_zones.py b/scripts/screenshot/machine_settings_nogo_zones.py new file mode 100644 index 000000000..4663e6429 --- /dev/null +++ b/scripts/screenshot/machine_settings_nogo_zones.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - No-Go Zones page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "nogo-zones" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/machine_settings_rotary_module.py b/scripts/screenshot/machine_settings_rotary_module.py new file mode 100644 index 000000000..1d13e352f --- /dev/null +++ b/scripts/screenshot/machine_settings_rotary_module.py @@ -0,0 +1,29 @@ +"""Screenshot: Machine settings - Rotary Module page.""" + +import logging +import time + +from utils import ( + get_target, + open_machine_settings, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "rotary-module" + + +def main(): + target = get_target(f"machine-settings:{PAGE}") + time.sleep(0.25) + open_machine_settings(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/main_3d.py b/scripts/screenshot/main_3d.py new file mode 100644 index 000000000..0a19852e6 --- /dev/null +++ b/scripts/screenshot/main_3d.py @@ -0,0 +1,82 @@ +""" +Screenshot: Main window in 3D mode. + +Usage: pixi run screenshot main:3d +""" + +import logging +import time + +from utils import ( + clear_window_subtitle, + get_target, + hide_panel, + load_project, + restore_panel_states, + save_panel_states, + seek_3d_playback, + set_window_size, + show_bottom_tab, + show_panel, + take_screenshot, + target_to_filename, + wait_for_3d_rendered, + wait_for_settled, + wcs, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +PANELS = ["show_3d_view", "toggle_bottom_panel"] + + +def main(): + target = get_target("main:3d") + set_window_size(win, 2400, 1650) + + load_project(win, "pretty.ryp") + logger.info("Waiting for document to settle...") + + if not wait_for_settled(win, timeout=20): + logger.error("Document did not settle in time") + app.quit_idle() + return + + logger.info("Setting up 3D mode") + + saved_states = save_panel_states(win, PANELS) + + with wcs(win, "G54"): + show_panel(win, "show_3d_view", True) + hide_panel(win, "toggle_bottom_panel") + show_bottom_tab(win, "gcode") + + logger.info( + "Waiting for pipeline to settle after 3D view activation..." + ) + if not wait_for_settled(win, timeout=30): + logger.error("Pipeline did not settle after 3D view activation") + app.quit_idle() + return + + logger.info("Waiting for 3D scene to render...") + if not wait_for_3d_rendered(win, timeout=15): + logger.error("3D scene did not render in time") + app.quit_idle() + return + + seek_3d_playback(win, 0.8) + + clear_window_subtitle(win) + logger.info("Taking screenshot: main-3d.png") + take_screenshot(target_to_filename(target)) + + restore_panel_states(win, saved_states) + + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/main_3d_rotary.py b/scripts/screenshot/main_3d_rotary.py new file mode 100644 index 000000000..4e04c202f --- /dev/null +++ b/scripts/screenshot/main_3d_rotary.py @@ -0,0 +1,95 @@ +""" +Screenshot: Main window in 3D mode with rotary project. + +Usage: pixi run screenshot main:3d-rotary +""" + +import logging +import time + +from utils import ( + clear_window_subtitle, + get_target, + hide_panel, + load_project, + restore_panel_states, + run_on_main_thread, + save_panel_states, + seek_3d_playback, + set_window_size, + show_bottom_tab, + show_panel, + take_screenshot, + target_to_filename, + wait_for_3d_rendered, + wait_for_settled, + wcs, +) + +from rayforge.ui_gtk.sim3d.camera import ViewDirection +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +PANELS = ["show_3d_view", "toggle_bottom_panel"] + + +def main(): + target = get_target("main:3d-rotary") + set_window_size(win, 2400, 1650) + + load_project(win, "rotary.ryp") + logger.info("Waiting for document to settle...") + + if not wait_for_settled(win, timeout=20): + logger.error("Document did not settle in time") + app.quit_idle() + return + + logger.info("Setting up 3D mode with rotary project") + + saved_states = save_panel_states(win, PANELS) + + with wcs(win, "G55"): + show_panel(win, "show_3d_view", True) + hide_panel(win, "toggle_bottom_panel") + show_bottom_tab(win, "gcode") + + logger.info( + "Waiting for pipeline to settle after 3D view activation..." + ) + if not wait_for_settled(win, timeout=30): + logger.error("Pipeline did not settle after 3D view activation") + app.quit_idle() + return + + logger.info("Waiting for 3D scene to render...") + if not wait_for_3d_rendered(win, timeout=15): + logger.error("3D scene did not render in time") + app.quit_idle() + return + + run_on_main_thread( + lambda: win.view_cmd.set_view(ViewDirection.ISO, win.canvas3d) + ) + + def _set_perspective() -> None: + if win.canvas3d: + win.canvas3d.set_perspective(True) + + run_on_main_thread(_set_perspective) + time.sleep(0.5) + + seek_3d_playback(win, 0.3) + + clear_window_subtitle(win) + logger.info("Taking screenshot: main-3d-rotary.png") + take_screenshot(target_to_filename(target)) + + restore_panel_states(win, saved_states) + + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/main_standard.py b/scripts/screenshot/main_standard.py new file mode 100644 index 000000000..9e8198456 --- /dev/null +++ b/scripts/screenshot/main_standard.py @@ -0,0 +1,60 @@ +""" +Screenshot: Main window in standard mode. + +Usage: pixi run screenshot main +""" + +import logging +import time + +from utils import ( + clear_window_subtitle, + get_target, + load_project, + restore_panel_states, + save_panel_states, + set_window_size, + show_bottom_tab, + show_panel, + take_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +PANELS = ["toggle_bottom_panel"] + + +def main(): + target = get_target("main:standard") + set_window_size(win, 2400, 1650) + + 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, setting up standard mode") + + saved_states = save_panel_states(win, PANELS) + show_panel(win, "toggle_bottom_panel", True) + show_bottom_tab(win, "gcode") + + time.sleep(0.25) + + clear_window_subtitle(win) + logger.info("Taking screenshot: main-standard.png") + take_screenshot(target_to_filename(target)) + + restore_panel_states(win, saved_states) + + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/material_test.py b/scripts/screenshot/material_test.py new file mode 100644 index 000000000..b0f74b843 --- /dev/null +++ b/scripts/screenshot/material_test.py @@ -0,0 +1,30 @@ +"""Screenshot: Material test grid dialog.""" + +import logging +import time + +from utils import ( + get_target, + open_material_test, + set_window_size, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +def main(): + target = get_target("material-test") + set_window_size(win, 2400, 1650) + + open_material_test(win) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/print_and_cut.py b/scripts/screenshot/print_and_cut.py new file mode 100644 index 000000000..9aee2dd5f --- /dev/null +++ b/scripts/screenshot/print_and_cut.py @@ -0,0 +1,153 @@ +"""Screenshot: Print & Cut addon wizard dialog.""" + +import importlib +import logging +import time +from pathlib import Path + +from utils import ( + get_target, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET = get_target("addon:print-and-cut:pick") + +MOUSE_SVG = ( + Path(__file__).parent.parent.parent + / "tests" + / "image" + / "svg" + / "mouse.svg" +) + +DESIGN_P1 = (0.145, 0.845) +DESIGN_P2 = (0.854, 0.175) +PHYSICAL_P1 = (10.0, 20.0) +PHYSICAL_P2 = (50.0, 60.0) + + +def setup_wizard(): + from rayforge.context import get_context + + wizard_mod = importlib.import_module( + "rayforge_addons.print_and_cut.print_and_cut.wizard" + ) + PrintAndCutWizard = wizard_mod.PrintAndCutWizard + + doc = win.doc_editor.doc + wps = [wp for layer in doc.layers for wp in layer.all_workpieces] + if not wps: + raise RuntimeError("No workpiece found after import") + item = wps[0] + + win.surface.select_items([item]) + + ctx = get_context() + machine = ctx.machine + if not machine: + raise RuntimeError("No machine configured") + + wizard = PrintAndCutWizard( + parent=win, + item=item, + machine=machine, + machine_cmd=win.machine_cmd, + editor=win.doc_editor, + ) + + wizard._design_point1 = DESIGN_P1 + wizard._design_point2 = DESIGN_P2 + wizard._pick_surface.set_points(DESIGN_P1, DESIGN_P2) + wizard._point1_row.set_subtitle("Point picked") + wizard._point2_row.set_subtitle("Point picked") + wizard._pick_status_row.set_subtitle( + "Both points selected. Click Next to continue." + ) + + return wizard + + +def show_pick_page(wizard): + wizard._next_btn.set_sensitive(True) + wizard.present() + + +def show_jog_page(wizard): + wizard._right_stack.set_visible_child_name("jog") + wizard._back_btn.set_visible(True) + wizard._reset_btn.set_visible(False) + wizard._update_jog_next_btn() + wizard.present() + + +def show_apply_page(wizard): + wizard._physical_point1 = PHYSICAL_P1 + wizard._physical_point2 = PHYSICAL_P2 + wizard._pos1_row.set_subtitle( + f"({PHYSICAL_P1[0]:.2f}, {PHYSICAL_P1[1]:.2f})" + ) + wizard._pos2_row.set_subtitle( + f"({PHYSICAL_P2[0]:.2f}, {PHYSICAL_P2[1]:.2f})" + ) + + wizard._right_stack.set_visible_child_name("apply") + wizard._back_btn.set_visible(True) + wizard._reset_btn.set_visible(False) + wizard._next_btn.set_visible(False) + wizard._apply_btn.set_visible(True) + wizard._apply_btn.set_sensitive(True) + wizard._update_apply_preview() + wizard.present() + + +def main(): + set_window_size(win, 1400, 1000) + + run_on_main_thread( + lambda: win.doc_editor.file.load_file_from_path( + MOUSE_SVG, "image/svg+xml", None + ) + ) + if not wait_for_settled(win, timeout=15): + logger.error("Document did not settle in time") + app.quit_idle() + return + + time.sleep(2.0) + + wizard = run_on_main_thread(setup_wizard) + + page = TARGET.split(":")[-1] if ":" in TARGET else "pick" + output = target_to_filename(TARGET) + + if page == "pick": + run_on_main_thread(lambda: show_pick_page(wizard)) + time.sleep(1.0) + take_screenshot(output) + elif page == "jog": + run_on_main_thread(lambda: show_jog_page(wizard)) + time.sleep(0.5) + take_screenshot(output) + elif page == "apply": + run_on_main_thread(lambda: show_apply_page(wizard)) + time.sleep(0.5) + take_screenshot(output) + else: + logger.error(f"Unknown page: {page}") + app.quit_idle() + return + + time.sleep(0.25) + run_on_main_thread(wizard.close) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/projector_mode.py b/scripts/screenshot/projector_mode.py new file mode 100644 index 000000000..9b18c6d93 --- /dev/null +++ b/scripts/screenshot/projector_mode.py @@ -0,0 +1,71 @@ +"""Screenshot: Projector Mode addon window.""" + +import logging +import time +from pathlib import Path + +from utils import ( + get_target, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +MOUSE_SVG = ( + Path(__file__).parent.parent.parent + / "tests" + / "image" + / "svg" + / "mouse.svg" +) + + +def show_projector(): + action = win.lookup_action("toggle_projector_mode") + if action is None: + raise RuntimeError("toggle_projector_mode action not found") + from gi.repository import GLib + + action.change_state(GLib.Variant.new_boolean(True)) + + from gi.repository import Gtk + + for toplevel in Gtk.Window.list_toplevels(): + if toplevel != win and toplevel.is_visible(): + return toplevel + raise RuntimeError("Projector window not found") + + +def main(): + target = get_target("addon:projector-mode") + set_window_size(win, 1400, 1000) + + run_on_main_thread( + lambda: win.doc_editor.file.load_file_from_path( + MOUSE_SVG, "image/svg+xml", None + ) + ) + if not wait_for_settled(win, timeout=15): + logger.error("Document did not settle in time") + app.quit_idle() + return + + time.sleep(2.0) + + projector_win = run_on_main_thread(show_projector) + time.sleep(1.0) + + take_screenshot(target_to_filename(target)) + + time.sleep(0.25) + run_on_main_thread(projector_win.close) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/recipe_editor_applicability.py b/scripts/screenshot/recipe_editor_applicability.py new file mode 100644 index 000000000..6fff1dcfe --- /dev/null +++ b/scripts/screenshot/recipe_editor_applicability.py @@ -0,0 +1,29 @@ +"""Screenshot: Recipe editor - Applicability page.""" + +import logging +import time + +from utils import ( + get_target, + open_recipe_editor, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "applicability" + + +def main(): + target = get_target(f"recipe-editor:{PAGE}") + time.sleep(0.25) + open_recipe_editor(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/recipe_editor_general.py b/scripts/screenshot/recipe_editor_general.py new file mode 100644 index 000000000..4e9ee40d7 --- /dev/null +++ b/scripts/screenshot/recipe_editor_general.py @@ -0,0 +1,29 @@ +"""Screenshot: Recipe editor - General page.""" + +import logging +import time + +from utils import ( + get_target, + open_recipe_editor, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) +PAGE = "general" + + +def main(): + target = get_target(f"recipe-editor:{PAGE}") + time.sleep(0.25) + open_recipe_editor(win, PAGE) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/recipe_editor_settings.py b/scripts/screenshot/recipe_editor_settings.py new file mode 100644 index 000000000..67271c774 --- /dev/null +++ b/scripts/screenshot/recipe_editor_settings.py @@ -0,0 +1,52 @@ +"""Screenshot: Recipe editor - Laser, Step Settings, Post Processing pages.""" + +import logging +import time + +from utils import ( + get_target, + open_recipe_editor, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +CONFIGS = { + "laser": { + "page": "settings", + "step_type": "ContourStep", + "settings_page": 0, + }, + "step-settings": { + "page": "settings", + "step_type": "ContourStep", + "settings_page": 1, + }, + "post-processing": { + "page": "post-processing", + "step_type": "ContourStep", + }, +} + + +def main(): + target = get_target("recipe-editor:step-settings") + _, subpage = target.split(":", 1) + config = CONFIGS[subpage] + time.sleep(0.25) + open_recipe_editor( + win, + config["page"], + step_type=config.get("step_type"), + settings_page=config.get("settings_page", 0), + ) + time.sleep(0.25) + take_screenshot(target_to_filename(target)) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/sanity_check.py b/scripts/screenshot/sanity_check.py new file mode 100644 index 000000000..840e1c193 --- /dev/null +++ b/scripts/screenshot/sanity_check.py @@ -0,0 +1,127 @@ +""" +Screenshot: Sanity check dialog. + +Usage: pixi run screenshot sanity-check +""" + +import logging +import time +from threading import Event + +from utils import ( + get_target, + load_project, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +def main(): + target = get_target("sanity-check") + set_window_size(win, 1400, 1000) + + load_project(win, "rects.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 + + from rayforge.context import get_context + from rayforge.machine.sanity.checker import SanityChecker + from rayforge.machine.sanity.result import CheckMode + from rayforge.pipeline.artifact.job import JobArtifact + from rayforge.ui_gtk.shared.sanity_check_dialog import SanityCheckDialog + + config = get_context().config + machine = config.machine + if not machine: + logger.error("No machine configured") + app.quit_idle() + return + + pipeline = win.doc_editor.pipeline + ops_result = {} + done_event = Event() + + def on_job_done(handle, error): + if error or not handle: + ops_result["error"] = error or RuntimeError( + "No job handle returned" + ) + done_event.set() + return + try: + am = pipeline.artifact_manager + with am.checkout_handle(handle) as artifact: + if isinstance(artifact, JobArtifact): + ops_result["ops"] = artifact.ops + else: + ops_result["error"] = RuntimeError("Not a JobArtifact") + except Exception as e: # noqa: BLE001 - pipeline callback boundary + ops_result["error"] = e + done_event.set() + + pipeline.generate_job_artifact(when_done=on_job_done) + + if not done_event.wait(timeout=20.0): + logger.error("Job generation timed out") + app.quit_idle() + return + + if "error" in ops_result: + logger.error(f"Job generation failed: {ops_result['error']}") + app.quit_idle() + return + + if "ops" not in ops_result: + logger.error("No ops in job result") + app.quit_idle() + return + + checker = SanityChecker(machine) + report = checker.check(ops_result["ops"], mode=CheckMode.FAST) + + errors = sum(1 for i in report.issues if i.severity.value == "error") + warnings = sum(1 for i in report.issues if i.severity.value == "warning") + logger.info( + f"Sanity check found {len(report.issues)} issue(s): " + f"{errors} errors, {warnings} warnings" + ) + + if report.is_clean: + logger.warning("No issues found, dialog will look empty") + + def open_dialog(): + dialog = SanityCheckDialog( + parent=win, + report=report, + ) + dialog.set_size_request(600, -1) + dialog.present() + return dialog + + dialog = run_on_main_thread(open_dialog) + + time.sleep(1.0) + + logger.info("Taking screenshot: sanity-check.png") + take_screenshot(target_to_filename(target)) + + 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.py b/scripts/screenshot/step_settings.py new file mode 100644 index 000000000..976ed894c --- /dev/null +++ b/scripts/screenshot/step_settings.py @@ -0,0 +1,121 @@ +"""Screenshot: Step settings dialog.""" + +import logging +import time + +from utils import ( + find_step_by_type, + get_target, + load_project, + open_step_settings, + run_on_main_thread, + set_window_size, + take_screenshot, + target_to_filename, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET = get_target("step-settings:contour:general") + +ENGRAVE_MODES = { + "constant_power": "CONSTANT_POWER", + "dither": "DITHER", + "multi_pass": "MULTI_PASS", + "variable": "POWER_MODULATION", +} + + +def parse_target(target: str) -> tuple[str, str, str | None]: + """Parse target into (step_type, page, mode).""" + parts = target.split(":") + step_type = parts[1] if len(parts) > 1 else "contour" + tab = parts[2] if len(parts) > 2 else "general" + mode = parts[3] if len(parts) > 3 else None + + if tab == "post": + page = "post-processing" + elif tab == "laser": + page = "laser" + else: + page = "step-settings" + return step_type, page, mode + + +def set_engrave_mode(dialog, mode_name: str): + """Set the engrave mode in the dialog's engraver widget.""" + from rayforge.pipeline.stage.assembler_helpers import DepthMode + + mode_enum = DepthMode[mode_name] + mode_index = list(DepthMode).index(mode_enum) + + general_view = dialog.general_view + logger.info("Searching for mode_row in general_view children...") + + def find_mode_row(widget, depth=0): + indent = " " * depth + logger.info(f"{indent}Checking: {type(widget).__name__}") + if hasattr(widget, "mode_row") and widget.mode_row is not None: + return widget.mode_row + child = widget.get_first_child() + while child: + result = find_mode_row(child, depth + 1) + if result is not None: + return result + child = child.get_next_sibling() + return None + + mode_row = find_mode_row(general_view) + if mode_row is not None: + mode_row.set_selected(mode_index) + logger.info(f"Set engrave mode to: {mode_name} (index {mode_index})") + return True + + logger.warning("Could not find engraver widget with mode_row") + return False + + +def main(): + set_window_size(win, 2400, 1650) + + load_project(win, "allsteps.ryp") + time.sleep(0.25) + + step_type, page, mode = parse_target(TARGET) + logger.info( + f"Target: {TARGET} -> type={step_type}, page={page}, mode={mode}" + ) + + found = find_step_by_type(win, step_type) + if found is None: + logger.error(f"No step found with type: {step_type}") + app.quit_idle() + return + + step, step_index = found + if step is None: + logger.error(f"No step found with type: {step_type}") + app.quit_idle() + return + + 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 mode and step_type == "engrave": + mode_name = ENGRAVE_MODES.get(mode) + if mode_name: + run_on_main_thread(lambda m=mode_name: set_engrave_mode(dialog, m)) + time.sleep(0.5) + + output_name = target_to_filename(TARGET) + + take_screenshot(output_name) + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/utils.py b/scripts/screenshot/utils.py new file mode 100644 index 000000000..beb6a1098 --- /dev/null +++ b/scripts/screenshot/utils.py @@ -0,0 +1,805 @@ +""" +Shared utilities for screenshot scripts. + +These scripts are designed to be run via `rayforge --uiscript`. +Scripts run in a background thread, so UI operations use +GLib.idle_add for thread safety. +""" + +import logging +import os +import subprocess +import time +from collections.abc import Callable +from contextlib import contextmanager +from pathlib import Path +from threading import Event +from typing import ( + TYPE_CHECKING, + Optional, + TypeVar, +) + +import numpy as np +from gi.repository import Adw, GLib +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +from rayforge.core.recipe import Recipe +from rayforge.core.step_registry import step_registry +from rayforge.ui_gtk.doceditor.recipes import ( + AddEditRecipeDialog, +) +from rayforge.ui_gtk.doceditor.step_settings.dialog import ( + StepSettingsDialog, +) + +if TYPE_CHECKING: + from rayforge.core.step import Step + from rayforge.ui_gtk.array_dialog import _BaseArrayDialog + from rayforge.ui_gtk.machine.settings_dialog import MachineSettingsDialog + from rayforge.ui_gtk.mainwindow import MainWindow + from rayforge.ui_gtk.settings.settings_dialog import SettingsWindow + +logger = logging.getLogger(__name__) + +PROJECT_ROOT = Path(__file__).parent.parent.parent +OUTPUT_DIR = PROJECT_ROOT / "website" / "static" / "screenshots" +TESTS_DIR = PROJECT_ROOT / "tests" + +SCREENSHOT_TOOLS = [ + (["gnome-screenshot", "-w", "-f"], "gnome-screenshot"), + (["import", "-window", "root"], "ImageMagick import"), +] + +T = TypeVar("T") + + +def get_target(default: str) -> str: + """Return the screenshot target from the TARGET environment variable.""" + return os.environ.get("TARGET", default) + + +def target_to_filename(target: str) -> str: + """Map a target to its output filename (1:1). + + Targets use ``:`` as a separator (e.g. ``machine-settings:camera``); + filenames use ``-`` (e.g. ``machine-settings-camera.png``). + """ + return target.replace(":", "-") + ".png" + + +def _save_png_deterministic(img: Image.Image, output_path: Path) -> bool: + """ + Save a PNG image deterministically, only updating if content changed. + + Strips metadata and uses consistent compression to ensure identical + screenshots produce identical files. + """ + img = img.copy() + img.info.clear() + + if output_path.exists(): + try: + existing = Image.open(output_path) + if ( + existing.size == img.size + and existing.mode == img.mode + and _images_visually_equal(existing, img) + ): + logger.info(f"Screenshot unchanged: {output_path}") + return True + except (OSError, ValueError) as e: + logger.debug(f"Comparison failed: {e}") + + pnginfo = PngInfo() + img.save(output_path, format="PNG", compress_level=9, pnginfo=pnginfo) + logger.info(f"Screenshot saved to {output_path}") + return True + + +def _images_visually_equal( + img1: Image.Image, + img2: Image.Image, + threshold: int = 5, + max_different: float = 0.001, +) -> bool: + """ + Compare two images using a perceptual heuristic. + + Args: + img1: First image to compare. + img2: Second image to compare. + threshold: Minimum per-channel difference to count as changed (0-255). + max_different: Maximum fraction of pixels that can differ (0.0-1.0). + + Returns: + True if images are visually equal within tolerance. + """ + arr1 = np.array(img1) + arr2 = np.array(img2) + + diff = np.abs(arr1.astype(int) - arr2.astype(int)) + significant_diff = np.any(diff > threshold, axis=-1) + different_pixels = np.sum(significant_diff) + total_pixels = arr1.shape[0] * arr1.shape[1] + + return different_pixels / total_pixels <= max_different + + +def run_on_main_thread(func: Callable[[], T], timeout: float = 10.0) -> T: + """ + Run a function on the main GTK thread and wait for completion. + """ + result: list[T] = [] + exception: list[Exception | None] = [None] + done = Event() + + def wrapper() -> bool: + try: + result.append(func()) + except Exception as e: # noqa: BLE001 - arbitrary main-thread callback + exception[0] = e + finally: + done.set() + return GLib.SOURCE_REMOVE + + GLib.idle_add(wrapper) + if done.wait(timeout=timeout): + if exception[0]: + raise exception[0] + return result[0] + raise TimeoutError(f"Function did not complete within {timeout}s") + + +def take_screenshot(output_name: str) -> bool: + """ + Take a screenshot of the active window. + + Args: + output_name: Filename (saved to website/static/images/). + + Returns: + True if screenshot was saved successfully. + """ + output_path = OUTPUT_DIR / output_name + output_path.parent.mkdir(parents=True, exist_ok=True) + + time.sleep(0.5) + + temp_path = output_path.with_suffix(".temp.png") + + for cmd_args, tool_name in SCREENSHOT_TOOLS: + result = subprocess.run( + [*cmd_args, str(temp_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + try: + img = Image.open(temp_path) + _save_png_deterministic(img, output_path) + temp_path.unlink() + return True + except (OSError, ValueError, TypeError) as e: + logger.error(f"Failed to process screenshot: {e}") + if temp_path.exists(): + temp_path.unlink() + return False + + logger.error("Failed to take screenshot with available tools") + return False + + +def take_window_screenshot(win: "MainWindow", output_name: str) -> bool: + """ + Take a screenshot of the main window including any open non-modal + dialogs. Captures the full screen via ``gnome-screenshot`` (no + ``-w`` flag) and crops to the main-window geometry obtained from + ``xwininfo``. + """ + output_path = OUTPUT_DIR / output_name + output_path.parent.mkdir(parents=True, exist_ok=True) + + time.sleep(0.5) + + def _get_xid(): + from gi.repository import GdkX11 + + surface = win.get_surface() + if isinstance(surface, GdkX11.X11Surface): + return surface.get_xid() + return None + + xid = run_on_main_thread(_get_xid) + if xid is None: + logger.error("Could not get X11 window id") + return False + + result = subprocess.run( + ["xwininfo", "-id", str(xid)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + logger.error(f"xwininfo failed: {result.stderr}") + return False + + logger.debug(f"xwininfo output:\n{result.stdout}") + + info = {} + for line in result.stdout.splitlines(): + stripped = line.strip() + for key in ( + "Absolute upper-left X", + "Absolute upper-left Y", + "Width", + "Height", + ): + if stripped.startswith(key): + info[key] = int(stripped.split(":")[-1].strip()) + + wx = info.get("Absolute upper-left X", 0) + wy = info.get("Absolute upper-left Y", 0) + ww = info.get("Width", 0) + wh = info.get("Height", 0) + if ww == 0 or wh == 0: + logger.error("Could not parse window geometry from xwininfo") + return False + + logger.info(f"Window geometry: x={wx} y={wy} w={ww} h={wh}") + + def _get_gtk_size(): + return win.get_width(), win.get_height() + + gtk_w, gtk_h = run_on_main_thread(_get_gtk_size) + shadow_x = (ww - gtk_w) // 2 + shadow_y = (wh - gtk_h) // 2 + logger.info( + f"GTK size: {gtk_w}x{gtk_h}, shadow offset: {shadow_x},{shadow_y}" + ) + + temp_path = output_path.with_suffix(".temp.png") + + for cmd_args, tool_name in SCREENSHOT_TOOLS: + args = [a for a in cmd_args if a != "-w"] + result = subprocess.run( + [*args, str(temp_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + try: + img = Image.open(temp_path) + crop_box = ( + wx + shadow_x, + wy + shadow_y, + wx + shadow_x + gtk_w, + wy + shadow_y + gtk_h, + ) + cropped = img.crop(crop_box) + _save_png_deterministic(cropped, output_path) + temp_path.unlink() + logger.info(f"Window screenshot saved to {output_path}") + return True + except (OSError, ValueError, TypeError) as e: + logger.error(f"Failed to process screenshot: {e}") + if temp_path.exists(): + temp_path.unlink() + return False + + logger.error("Failed to take screenshot with available tools") + return False + + +def take_cropped_screenshot( + output_name: str, + *, + from_bottom: int | None = None, + from_top: int | None = None, + from_left: int | None = None, + from_right: int | None = None, +) -> bool: + """ + Take a screenshot of the active window and crop it. + + Args: + output_name: Filename (saved to OUTPUT_DIR). + from_bottom: Crop this many pixels from the bottom. + from_top: Crop this many pixels from the top. + from_left: Crop this many pixels from the left. + from_right: Crop this many pixels from the right. + + Returns: + True if screenshot was saved successfully. + """ + output_path = OUTPUT_DIR / output_name + output_path.parent.mkdir(parents=True, exist_ok=True) + + time.sleep(0.5) + + temp_path = output_path.with_suffix(".temp.png") + + success = False + for cmd_args, tool_name in SCREENSHOT_TOOLS: + result = subprocess.run( + [*cmd_args, str(temp_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + success = True + break + + if not success: + logger.error("Failed to take screenshot with available tools") + return False + + try: + from PIL import Image + + img = Image.open(temp_path) + width, height = img.size + + left = from_left or 0 + top = from_top or 0 + right = width - (from_right or 0) + bottom = height - (from_bottom or 0) + + cropped = img.crop((left, top, right, bottom)) + _save_png_deterministic(cropped, output_path) + temp_path.unlink() + logger.info(f"Cropped screenshot saved to {output_path}") + return True + except (OSError, ValueError, TypeError) as e: + logger.error(f"Failed to crop screenshot: {e}") + if temp_path.exists(): + temp_path.unlink() + return False + + +def wait_for_settled(win: "MainWindow", timeout: float = 30.0) -> bool: + """ + Wait for the document to finish processing. + + Returns: + True if settled within timeout. + """ + return win.doc_editor.wait_until_settled_sync(timeout=timeout) + + +def wait_for_3d_rendered(win: "MainWindow", timeout: float = 15.0) -> bool: + """ + Wait for the 3D canvas to finish compiling and rendering the scene. + + Waits until: + - The canvas has a compiled artifact + - All GL dirty flags have been consumed by a render frame + - No scene preparation task is in flight + + Returns: + True if the scene is rendered within timeout. + """ + start = time.time() + + while time.time() - start < timeout: + canvas = run_on_main_thread(lambda: win.canvas3d) + if canvas is None: + time.sleep(0.1) + continue + + ready = run_on_main_thread(lambda c=canvas: c.scene_is_ready()) + if ready: + time.sleep(0.3) + logger.info("3D scene is compiled and rendered") + return True + + time.sleep(0.1) + + logger.warning("3D scene did not render within timeout") + return False + + +def load_project(win: "MainWindow", project_name: str) -> None: + """Load a project file from the tests/assets directory.""" + project_path = TESTS_DIR / "assets" / project_name + if not project_path.exists(): + raise FileNotFoundError(f"Project not found: {project_path}") + + def _load() -> None: + win.doc_editor.file.load_project_from_path(project_path) + + run_on_main_thread(_load) + logger.info(f"Loaded project: {project_name}") + + +def set_window_size( + win: "MainWindow", width: int, height: int, timeout: float = 1.0 +) -> bool: + """ + Force the window to a specific size, handling maximized state. + + Args: + win: The main window. + width: Desired width in pixels. + height: Desired height in pixels. + timeout: Time to wait for size to be applied. + + Returns: + True if size was successfully applied. + """ + + def _set_size() -> None: + if win.is_maximized(): + win.unmaximize() + win.set_default_size(width, height) + win.set_size_request(width, height) + + run_on_main_thread(_set_size) + + actual_width = 0 + actual_height = 0 + start = time.time() + while time.time() - start < timeout: + actual_width = run_on_main_thread(lambda: win.get_width()) + actual_height = run_on_main_thread(lambda: win.get_height()) + if actual_width == width and actual_height == height: + logger.info(f"Window size set to {width}x{height}") + return True + time.sleep(0.1) + + logger.warning( + f"Window size not applied (expected {width}x{height}, " + f"got {actual_width}x{actual_height})" + ) + return False + + +def show_panel( + win: "MainWindow", panel_name: str, visible: bool = True +) -> None: + """ + Show or hide a UI panel. + + Args: + panel_name: Action name (e.g., "toggle_bottom_panel"). + visible: True to show, False to hide. + """ + + def _show() -> None: + action = win.action_manager.get_action(panel_name) + action.change_state(GLib.Variant.new_boolean(visible)) + + run_on_main_thread(_show) + + +def show_bottom_tab(win: "MainWindow", tab_name: str) -> None: + """Switch the bottom panel to a given tab (e.g. 'console' or 'gcode').""" + + def _switch() -> None: + area = win.bottom_panel.dock_layout.find_item_area(tab_name) + if area is not None: + area.set_active_item(tab_name) + + run_on_main_thread(_switch) + + +def hide_panel(win: "MainWindow", panel_name: str) -> None: + """Hide a UI panel.""" + show_panel(win, panel_name, visible=False) + + +def get_panel_state(win: "MainWindow", panel_name: str) -> bool: + """Get the current visibility state of a panel.""" + + def get_state() -> bool: + action = win.action_manager.get_action(panel_name) + state = action.get_state() + if state is None: + return False + return state.get_boolean() + + return run_on_main_thread(get_state) + + +def save_panel_states( + win: "MainWindow", panel_names: list[str] +) -> dict[str, bool]: + """Save the current state of multiple panels.""" + return {name: get_panel_state(win, name) for name in panel_names} + + +def restore_panel_states(win: "MainWindow", states: dict[str, bool]) -> None: + """Restore panel states from a saved dictionary.""" + for name, visible in states.items(): + show_panel(win, name, visible) + + +def open_machine_settings( + win: "MainWindow", page: str = "general" +) -> "MachineSettingsDialog": + """Open machine settings dialog on the specified page.""" + from rayforge.context import get_context + from rayforge.ui_gtk.machine.settings_dialog import MachineSettingsDialog + + def _open() -> "MachineSettingsDialog": + config = get_context().config + machine = config.machine + if not machine: + raise ValueError("No machine configured") + dialog = MachineSettingsDialog( + machine=machine, + transient_for=win, + initial_page=page, + ) + dialog.present() + return dialog + + dialog = run_on_main_thread(_open) + logger.info(f"Opened machine settings on page: {page}") + return dialog + + +def open_app_settings( + win: "MainWindow", page: str = "general" +) -> "SettingsWindow": + """Open app settings dialog on the specified page.""" + from rayforge.ui_gtk.settings.settings_dialog import SettingsWindow + + def _open() -> "SettingsWindow": + dialog = SettingsWindow(initial_page=page) + dialog.set_transient_for(win) + dialog.present() + return dialog + + dialog = run_on_main_thread(_open) + logger.info(f"Opened app settings on page: {page}") + return dialog + + +def open_step_settings( + win: "MainWindow", step_index: int = 0, page: str = "step-settings" +) -> "StepSettingsDialog": + """Open step settings dialog for the step at the given index.""" + from rayforge.ui_gtk.doceditor.step_settings.dialog import ( + StepSettingsDialog, + ) + + step = get_step_by_index(win, step_index) + if not step: + raise ValueError(f"Step at index {step_index} not found") + + def _open() -> "StepSettingsDialog": + dialog = StepSettingsDialog( + editor=win.doc_editor, + step=step, + transient_for=win, + ) + dialog.set_default_size(600, 900) + dialog.present() + dialog.set_initial_page(page) + return dialog + + dialog = run_on_main_thread(_open) + logger.info(f"Opened step settings for: {step.name} on page: {page}") + return dialog + + +def get_step_by_index(win: "MainWindow", index: int) -> Optional["Step"]: + """Get a step by its index across all layers.""" + + def _get() -> Optional["Step"]: + step_index = index + for layer in win.doc_editor.doc.layers: + if layer.workflow and layer.workflow.steps: + if step_index < len(layer.workflow.steps): + return layer.workflow.steps[step_index] + step_index -= len(layer.workflow.steps) + return None + + return run_on_main_thread(_get) + + +def get_all_steps(win: "MainWindow") -> list["Step"]: + """Get all steps across all layers.""" + + def _get() -> list["Step"]: + steps: list[Step] = [] + for layer in win.doc_editor.doc.layers: + if layer.workflow and layer.workflow.steps: + steps.extend(layer.workflow.steps) + return steps + + return run_on_main_thread(_get) + + +def get_step_types(win: "MainWindow") -> list[str]: + """Get all unique step types (typelabels) in the document.""" + + def _get() -> list[str]: + types: set = set() + for layer in win.doc_editor.doc.layers: + if layer.workflow and layer.workflow.steps: + for step in layer.workflow.steps: + types.add(step.typelabel.lower().replace(" ", "-")) + return sorted(types) + + return run_on_main_thread(_get) + + +def find_step_by_type( + win: "MainWindow", step_type: str +) -> tuple[Optional["Step"], int]: + """Find first step matching the given type.""" + + def _find() -> tuple[Optional["Step"], int]: + normalized = step_type.lower().replace(" ", "-") + for layer in win.doc_editor.doc.layers: + if layer.workflow and layer.workflow.steps: + for i, step in enumerate(layer.workflow.steps): + if step.typelabel.lower().replace(" ", "-") == normalized: + return step, i + return None, -1 + + return run_on_main_thread(_find) + + +def open_recipe_editor( + win: "MainWindow", + page: str = "general", + *, + step_type: str | None = None, + settings_page: int = 0, +) -> "AddEditRecipeDialog": + """Open recipe editor dialog from app settings. + + Args: + page: Which tab to activate ("general", "applicability", + "settings", or "post-processing"). For "settings", + ``settings_page`` selects which of the dynamic settings + pages to show. "post-processing" requires ``step_type`` so + the tab exists. + step_type: Optional step class name to target. Selecting a step + type (e.g. a laser step) splits the settings into inherited + and step-specific pages and enables the post-processing tab. + settings_page: Index into the dynamic settings pages to activate + when ``page`` is "settings". + """ + + settings_dialog = open_app_settings(win, "recipes") + time.sleep(0.5) + + recipe = Recipe(name="3mm Plywood Cut") + recipe.description = "A recipe for cutting 3mm plywood with a diode laser" + if step_type: + recipe.target_step_types = [step_type] + + def _open() -> "AddEditRecipeDialog": + dialog = AddEditRecipeDialog( + parent=settings_dialog, + recipe=recipe, + ) + dialog.set_default_size(700, 800) + dialog.present() + + if page == "general": + dialog._tab_buttons["general"].set_active(True) + elif page == "applicability": + dialog._tab_buttons["applicability"].set_active(True) + elif page == "settings" and dialog._settings_pages: + names = list(dialog._settings_pages) + index = min(settings_page, len(names) - 1) + dialog._tab_buttons[names[index]].set_active(True) + elif page == "post-processing": + button = dialog._tab_buttons.get("post-processing") + if button is not None: + button.set_active(True) + return dialog + + dialog = run_on_main_thread(_open) + logger.info(f"Opened recipe editor on page: {page}") + return dialog + + +def open_material_test(win: "MainWindow") -> "StepSettingsDialog": + """Open material test grid dialog.""" + + def _open() -> "StepSettingsDialog": + step_cls = step_registry.get("MaterialTestStep") + step = step_cls.create(win.doc_editor.context) + step.name = "Material Test Grid" + dialog = StepSettingsDialog( + editor=win.doc_editor, + step=step, + transient_for=win, + ) + dialog.set_initial_page("step-settings") + dialog.set_default_size(600, 900) + dialog.present() + return dialog + + dialog = run_on_main_thread(_open) + logger.info("Opened material test grid dialog") + return dialog + + +def open_array_dialog( + win: "MainWindow", mode: str = "grid" +) -> "_BaseArrayDialog": + """Open an array dialog for the current selection.""" + from rayforge.doceditor.array import ArrayMode + from rayforge.ui_gtk.array_dialog import ( + CircularArrayDialog, + GridArrayDialog, + PointRotationArrayDialog, + ) + + mode_map = { + "grid": (ArrayMode.GRID, GridArrayDialog), + "point_rotation": ( + ArrayMode.POINT_ROTATION, + PointRotationArrayDialog, + ), + "circular": (ArrayMode.CIRCULAR, CircularArrayDialog), + } + _array_mode, cls = mode_map[mode] + + def _open(): + items = list(win.surface.get_selected_items()) + if not items: + raise ValueError("No items selected") + dialog = cls(win, win.doc_editor, win.surface, items) + dialog.present() + return dialog + + return run_on_main_thread(_open) + + +def clear_window_subtitle(win: "MainWindow") -> None: + """ + Clear the version subtitle from the main window for deterministic + screenshots. + """ + + def _clear() -> None: + title_widget = win.header_bar.get_title_widget() + if isinstance(title_widget, Adw.WindowTitle): + title_widget.set_subtitle("") + + run_on_main_thread(_clear) + + +def seek_3d_playback(win: "MainWindow", fraction: float) -> None: + """ + Seek the 3D playback to the given fraction (0.0 to 1.0). + """ + + def _seek() -> None: + win._canvas3d_playback.seek_to_fraction(fraction) + + run_on_main_thread(_seek) + time.sleep(0.3) + + +@contextmanager +def wcs(win: "MainWindow", wcs_name: str): + """ + Context manager to temporarily switch the active WCS for a screenshot. + + Restores the original WCS on exit. + """ + machine = win.doc_editor.context.machine + original = run_on_main_thread(lambda: machine.active_wcs) + + def _switch(): + machine.set_active_wcs(wcs_name) + + run_on_main_thread(_switch) + try: + yield + finally: + run_on_main_thread(lambda: machine.set_active_wcs(original)) diff --git a/scripts/screenshot/wavefront.py b/scripts/screenshot/wavefront.py new file mode 100644 index 000000000..e00ad2cb3 --- /dev/null +++ b/scripts/screenshot/wavefront.py @@ -0,0 +1,54 @@ +""" +Screenshot: Wavefront operation in the main window. + +Usage: pixi run screenshot operations:wavefront +""" + +import logging +import time + +from utils import ( + clear_window_subtitle, + get_target, + load_project, + set_window_size, + take_cropped_screenshot, + target_to_filename, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +def main(): + target = get_target("operations:wavefront") + set_window_size(win, 2400, 1650) + + load_project(win, "wavefront.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") + + clear_window_subtitle(win) + time.sleep(0.25) + + logger.info("Taking cropped screenshot: operations-wavefront.png") + take_cropped_screenshot( + target_to_filename(target), + from_left=880, + from_right=880, + from_top=590, + from_bottom=810, + ) + + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/snapshot_memory.py b/scripts/snapshot_memory.py new file mode 100644 index 000000000..e3bb40908 --- /dev/null +++ b/scripts/snapshot_memory.py @@ -0,0 +1,714 @@ +"""Memory-ownership snapshot for rayforge. + +Run via:: + + pixi run rayforge wolf.ryp --uiscript scripts/snapshot_memory.py + +The script waits for the document to settle (pipeline + scene compilation ++ view rendering all idle), then walks the live object graph from known +roots and reports, per owner, how many bytes it currently holds. + +Rust-side sizes are obtained from native getters: + - ``Ops.heap_size()`` — the Rust Vec + state heap + - ``CompressedArray.compressed_size`` — the zstd-compressed payload + - ``CompressedArray.uncompressed_size`` — the original decompressed size + - ``Pipeline.cache_used_bytes`` — the raygeo LRU cache total + +Python-side sizes use ``numpy.ndarray.nbytes``, ``len(bytes)``, and +``sys.getsizeof`` for wrapper objects. + +A ``gc.get_objects()`` type-sweep is included as a cross-check: the sum +of all live ``Ops`` / ``CompressedArray`` / ``ndarray`` / ``bytes`` +should be roughly attributable to the reported owners. Discrepancies +point to untracked holders. +""" + +from __future__ import annotations + +import gc +import logging +import os +import sys +import threading +import time +from collections import defaultdict +from typing import TYPE_CHECKING, Protocol, TypedDict, cast + +import numpy as np +from raygeo.cnc.execution.specs import AggregateOutput +from raygeo.compressed_array import CompressedArray +from raygeo.ops import Ops + +from rayforge.pipeline.artifact.job import JobArtifact +from rayforge.pipeline.artifact.workpiece import WorkPieceArtifact + +if TYPE_CHECKING: + from gi.repository import Gtk + + from rayforge.core.doc import Doc + from rayforge.doceditor.editor import DocEditor + from rayforge.pipeline.artifact.store import ArtifactStore + from rayforge.pipeline.encoder.base import EncodedOutput + from rayforge.pipeline.pipeline import Pipeline + from rayforge.pipeline.view.view_manager import ViewManager + from rayforge.ui_gtk.mainwindow import MainWindow + from rayforge.ui_gtk.sim3d.scene_presenter import ScenePresenter + +logger = logging.getLogger("memsnapshot") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_seen_ids: set[int] = set() +_ops_seen: set[int] = set() + + +def _claim(obj: object) -> bool: + """Return True if obj was not yet seen (and now claimed).""" + oid = id(obj) + if oid in _seen_ids: + return False + _seen_ids.add(oid) + return True + + +def _obj_bytes(obj: object) -> int: + """Best-effort byte size for a single object (no recursion).""" + if obj is None: + return 0 + if isinstance(obj, np.ndarray): + return obj.nbytes + if isinstance(obj, (bytes, bytearray)): + return len(obj) + if isinstance(obj, CompressedArray): + return obj.compressed_size + if isinstance(obj, Ops): + try: + return obj.heap_size() + except (RuntimeError, TypeError): + return sys.getsizeof(obj) + if isinstance(obj, str): + return len(obj) + return sys.getsizeof(obj) + + +def _walk_size(obj: object, max_depth: int = 3) -> int: + """Sum ``_obj_bytes`` over obj and its direct referents up to + *max_depth* levels, claiming each object once.""" + if not _claim(obj): + return 0 + total = _obj_bytes(obj) + if max_depth <= 0: + return total + try: + refs = gc.get_referents(obj) + except (RuntimeError, TypeError): + refs = [] + for ref in refs: + if ref is obj: + continue + if type(ref) in _SCALAR_TYPES: + continue + total += _walk_size(ref, max_depth - 1) + return total + + +_SCALAR_TYPES: frozenset[type[object]] = frozenset( + { + type(None), + int, + float, + bool, + complex, + str, + type, + frozenset, + set, + } +) + + +# --------------------------------------------------------------------------- +# Typed sweep (cross-check via gc.get_objects) +# --------------------------------------------------------------------------- + + +class TypeStats(TypedDict): + """Aggregate count and byte total for one swept type.""" + + count: int + bytes: int + + +def _gc_type_sweep() -> dict[str, TypeStats]: + """Count and size all live objects of key types.""" + gc.collect() + stats: dict[str, TypeStats] = defaultdict(lambda: {"count": 0, "bytes": 0}) + for obj in gc.get_objects(): + if isinstance(obj, Ops): + key = "Ops" + elif isinstance(obj, CompressedArray): + key = "CompressedArray" + elif isinstance(obj, np.ndarray): + key = "ndarray" + elif isinstance(obj, (bytes, bytearray)): + key = "bytes" + else: + continue + stats[key]["count"] += 1 + stats[key]["bytes"] += _obj_bytes(obj) + return dict(stats) + + +# --------------------------------------------------------------------------- +# Per-owner measurements +# --------------------------------------------------------------------------- + + +class OwnerReport: + def __init__(self, name: str) -> None: + self.name = name + self.bytes: int = 0 + self.items: list[tuple[str, int]] = [] + + def add(self, label: str, obj: object) -> int: + sz = _obj_bytes(obj) + self.bytes += sz + self.items.append((label, sz)) + return sz + + +def _claim_compressed(r: OwnerReport, label: str, ca: CompressedArray) -> None: + """Record *ca*'s compressed size unless it was already claimed.""" + if _claim(ca): + sz = ca.compressed_size + r.bytes += sz + r.items.append((f"{label} (compressed)", sz)) + + +def _claim_array(r: OwnerReport, label: str, arr: np.ndarray | None) -> None: + """Record *arr*'s nbytes unless it was already claimed.""" + if isinstance(arr, np.ndarray) and _claim(arr): + sz = arr.nbytes + r.bytes += sz + r.items.append((label, sz)) + + +def _add_bytes(r: OwnerReport, label: str, data: bytes) -> None: + """Record the size of a raw *bytes* payload.""" + sz = len(data) + r.bytes += sz + r.items.append((label, sz)) + + +def _claim_ops( + r: OwnerReport, label: str, attr: str, ops: Ops, seen: set[int] +) -> None: + """Record an Ops heap size, deduplicated by id().""" + if id(ops) in seen: + return + seen.add(id(ops)) + h = ops.heap_size() + r.bytes += h + r.items.append((f"{label}.{attr}.heap_size", h)) + + +def _measure_encoded_output( + r: OwnerReport, label: str, encoded: EncodedOutput | None +) -> None: + """Record the encoded G-code text and op-map payloads.""" + if encoded is None: + return + text_sz = len(encoded.text) + r.bytes += text_sz + r.items.append((f"{label}.encoded.text", text_sz)) + op_map = encoded.op_map + spans_sz = len(op_map.op_to_machine_code_bytes) + lines_sz = len(op_map.machine_code_to_op_bytes) + r.bytes += spans_sz + lines_sz + label1 = ( + f"{label}.encoded.op_map.op_to_mc " + f"({op_map.op_count} ops, {spans_sz} B)" + ) + r.items.append((label1, spans_sz)) + label2 = ( + f"{label}.encoded.op_map.mc_to_op " + f"({op_map.line_count} lines, {lines_sz} B)" + ) + r.items.append((label2, lines_sz)) + + +def _measure_artifact_store(store: ArtifactStore) -> OwnerReport: + r = OwnerReport(name="ArtifactStore") + seen_ops: set[int] = set() + for key, art in store._artifacts.items(): + label = f"{type(art).__name__}[{key}]" + # Walk the artifact wrapper (non-Ops fields only) + wrapper_sz = sys.getsizeof(art) + r.bytes += wrapper_sz + r.items.append((f"{label} (wrapper)", wrapper_sz)) + # Ops fields — deduplicate by id() + if isinstance(art, JobArtifact): + _claim_ops(r, label, "ops", art.ops, seen_ops) + r.bytes += 1 + r.items.append( + ( + ( + f"{label}.ops.commands " + f"(cutting={art.ops.count_cutting()}, " + f"travel={art.ops.count_travel()}, " + f"scanline={art.ops.count_scanline()})" + ), + 1, + ) + ) + if art.mapped_ops is not None: + _claim_ops(r, label, "mapped_ops", art.mapped_ops, seen_ops) + _measure_encoded_output(r, label, art.encoded_output) + elif isinstance(art, WorkPieceArtifact): + _claim_ops(r, label, "ops", art.ops, seen_ops) + return r + + +def _measure_view_manager(vm: ViewManager) -> OwnerReport: + r = OwnerReport(name="ViewManager") + for composite_id, entry in vm._view_entries.items(): + label = f"ViewEntry{composite_id}" + bitmap = entry.bitmap + bsz = bitmap.nbytes if isinstance(bitmap, np.ndarray) else 0 + r.bytes += bsz + r.items.append((f"{label}.bitmap", bsz)) + return r + + +def _measure_scene_presenter(presenter: ScenePresenter) -> OwnerReport: + r = OwnerReport(name="ScenePresenter") + art = presenter._compiled_artifact + if art is None: + r.items.append(("compiled_artifact", 0)) + return r + wrapper_sz = sys.getsizeof(art) + r.bytes += wrapper_sz + r.items.append(("compiled_artifact (wrapper)", wrapper_sz)) + for i, vl in enumerate(art.vertex_layers): + label = f"VertexLayer[{i}]" + _claim_compressed(r, f"{label}.powered_verts", vl.powered_verts) + _claim_compressed(r, f"{label}.powered_attrib", vl.powered_attrib) + _claim_compressed(r, f"{label}.travel_verts", vl.travel_verts) + _claim_compressed(r, f"{label}.zero_power_verts", vl.zero_power_verts) + _claim_array(r, f"{label}.powered_cmd_offsets", vl.powered_cmd_offsets) + _claim_array(r, f"{label}.travel_cmd_offsets", vl.travel_cmd_offsets) + for i, tl in enumerate(art.texture_layers): + label = f"TextureLayer[{i}]" + _claim_compressed(r, f"{label}.power_texture", tl.power_texture) + _claim_array(r, f"{label}.model_matrix", tl.model_matrix) + _claim_array(r, f"{label}.cylinder_vertices", tl.cylinder_vertices) + for i, ol in enumerate(art.overlay_layers): + label = f"OverlayLayer[{i}]" + _claim_compressed(r, f"{label}.positions", ol.positions) + _claim_compressed(r, f"{label}.overlay_attrib", ol.overlay_attrib) + _claim_array(r, f"{label}.cmd_offsets", ol.cmd_offsets) + return r + + +def _measure_pipeline(pipeline: Pipeline) -> OwnerReport: + r = OwnerReport(name="Pipeline") + # raygeo cache + cache_bytes = pipeline._raygeo_pipeline.cache_used_bytes + r.bytes += cache_bytes + r.items.append(("raygeo cache_used_bytes (reported)", cache_bytes)) + # Last aggregate output (Rust object) — deduplicate with store + agg = cast(AggregateOutput | None, pipeline._last_aggregate_output) + if agg is not None: + ops = agg.ops + if id(ops) not in _ops_seen: + _ops_seen.add(id(ops)) + h = ops.heap_size() + r.bytes += h + r.items.append(("_last_aggregate_output.ops.heap_size", h)) + return r + + +def _measure_source_assets(doc: Doc) -> OwnerReport: + r = OwnerReport(name="SourceAssets") + for layer in doc.layers: + for wp in layer.all_workpieces: + sa = wp.source + if sa is None: + continue + label = f"SourceAsset[{sa.name}]" + _add_bytes(r, f"{label}.original_data", sa.original_data) + if sa.base_render_data is not None: + _add_bytes(r, f"{label}.base_render_data", sa.base_render_data) + if sa.thumbnail_data is not None: + _add_bytes(r, f"{label}.thumbnail_data", sa.thumbnail_data) + cache = sa._base_image_cache + if cache: + r.items.append( + (f"{label}._base_image_cache entries", len(cache)) + ) + return r + + +def _read_rss_kb() -> int: + try: + with open("/proc/self/status") as f: + for line in f: + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except OSError: + pass + return 0 + + +def _read_smaps_rollup() -> dict[str, int] | None: + """Read /proc/self/smaps_rollup for a detailed RSS breakdown.""" + try: + result = {} + with open("/proc/self/smaps_rollup") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and parts[0].endswith(":"): + try: + result[parts[0].rstrip(":")] = int(parts[1]) * 1024 + except ValueError: + continue + return result + except OSError: + return None + + +def _malloc_trim() -> None: + """Release freed-but-cached arena pages back to the OS.""" + import ctypes + + try: + libc = ctypes.CDLL("libc.so.6", use_errno=True) + libc.malloc_trim(0) + except (OSError, AttributeError): + pass + + +def _read_mallinfo() -> dict[str, int] | None: + """Read glibc's full malloc statistics. + + Returns a dict with these keys (all in bytes): + + - ``uordblks`` — in-use small/medium heap (sbrk arena) + - ``fordblks`` — free small/medium heap (sbrk arena) + - ``arena`` — total sbrk arena (uordblks + fordblks) + - ``hblkhd`` — in-use large-block mmap'd allocations + - ``hblks`` — count of mmap'd blocks + - ``usmblks`` — in-use fastbin bytes + - ``fsmblks`` — free fastbin bytes + - ``keepcost`` — top-most releasable chunk + + The total glibc-managed in-use memory is ``uordblks + hblkhd``. + ``uordblks`` alone misses all allocations > ~128 KB, which glibc + services via ``mmap`` (tracked in ``hblkhd``), not ``sbrk``. + """ + import ctypes + + try: + libc = ctypes.CDLL("libc.so.6", use_errno=True) + except OSError: + return None + + def _fields(t): + return [ + ("arena", t), + ("ordblks", t), + ("smblks", t), + ("hblks", t), + ("hblkhd", t), + ("usmblks", t), + ("fsmblks", t), + ("uordblks", t), + ("fordblks", t), + ("keepcost", t), + ] + + try: + libc.mallinfo2.restype = type( + "_MI", (ctypes.Structure,), {"_fields_": _fields(ctypes.c_size_t)} + ) + mi = libc.mallinfo2() + except AttributeError: + try: + libc.mallinfo.restype = type( + "_MI", (ctypes.Structure,), {"_fields_": _fields(ctypes.c_int)} + ) + mi = libc.mallinfo() + except AttributeError: + return None + + return { + "uordblks": int(mi.uordblks), + "fordblks": int(mi.fordblks), + "arena": int(mi.arena), + "hblkhd": int(mi.hblkhd), + "hblks": int(mi.hblks), + "usmblks": int(mi.usmblks), + "fsmblks": int(mi.fsmblks), + "keepcost": int(mi.keepcost), + } + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def _format_bytes(n: int) -> str: + if n >= 1 << 30: + return f"{n / (1 << 30):.2f} GB" + if n >= 1 << 20: + return f"{n / (1 << 20):.1f} MB" + if n >= 1 << 10: + return f"{n / (1 << 10):.1f} KB" + return f"{n} B" + + +def _print_report( + owners: list[OwnerReport], sweep: dict[str, TypeStats], rss_kb: int +) -> None: + print("\n" + "=" * 72) + print(" MEMORY OWNERSHIP SNAPSHOT (after pipeline settle)") + print("=" * 72) + + total_attributed = 0 + for r in sorted(owners, key=lambda x: -x.bytes): + print(f"\n● {r.name}: {_format_bytes(r.bytes)}") + total_attributed += r.bytes + for label, sz in sorted(r.items, key=lambda x: -x[1]): + if sz > 0: + print(f" {label:55s} {_format_bytes(sz):>12s}") + + mi = _read_mallinfo() + smaps = _read_smaps_rollup() + + print(f"\n{'─' * 72}") + print(f" Total attributed: {_format_bytes(total_attributed):>12s}") + print(f" Process RSS: {_format_bytes(rss_kb * 1024):>12s}") + if mi is not None: + glibc_inuse = mi["uordblks"] + mi["hblkhd"] + glibc_free = mi["fordblks"] + mi["fsmblks"] + print(f" glibc in-use (sbrk): {_format_bytes(mi['uordblks']):>12s}") + print(f" glibc in-use (mmap): {_format_bytes(mi['hblkhd']):>12s}") + print(f" glibc in-use (total): {_format_bytes(glibc_inuse):>12s}") + print(f" glibc free (wasted): {_format_bytes(glibc_free):>12s}") + if smaps: + print(" smaps_rollup:") + for k in ("Rss", "Pss", "Anonymous", "Swap", "File"): + if k in smaps: + print(f" {k:20s} {_format_bytes(smaps[k]):>12s}") + + print(f"\n{'─' * 72}") + print(" GC type sweep (all live objects, cross-check):") + print(" (Note: Ops and CompressedArray are PyO3 objects and do NOT") + print(" appear in gc.get_objects(); per-owner measurement above") + print(" uses native heap_size()/compressed_size getters.)") + for key in ("Ops", "CompressedArray", "ndarray", "bytes"): + s = sweep.get(key, {"count": 0, "bytes": 0}) + print( + f" {key:20s} count={s['count']:>8d} " + f"total={_format_bytes(s['bytes']):>12s}" + ) + + gap = rss_kb * 1024 - total_attributed + print(f"\n RSS − attributed gap: {_format_bytes(gap)}") + if mi is not None: + glibc_inuse = mi["uordblks"] + mi["hblkhd"] + glibc_free = mi["fordblks"] + mi["fsmblks"] + glibc_total = glibc_inuse + glibc_free + heap_vs_attr = glibc_inuse - total_attributed + non_glibc = rss_kb * 1024 - glibc_total + print(f" glibc in-use − attributed: {_format_bytes(heap_vs_attr)}") + print(f" glibc free (wasted): {_format_bytes(glibc_free)}") + print(f" RSS − glibc total (non-glibc): {_format_bytes(non_glibc)}") + print(" (non-glibc = Python interpreter, GL/GTK textures, thread") + print(" stacks, pyvips image caches, and other non-malloc memory)") + print("=" * 72 + "\n") + + +class AppProtocol(Protocol): + """Minimal application surface this script depends on.""" + + def quit_idle(self) -> None: ... + + +def _find_scene_presenter(win: MainWindow) -> ScenePresenter | None: + """Locate the ScenePresenter on the 3D canvas, if it exists.""" + try: + from rayforge.ui_gtk.sim3d.canvas3d import Canvas3D + + def search(widget: Gtk.Widget) -> ScenePresenter | None: + if isinstance(widget, Canvas3D): + return widget._presenter + child = widget.get_first_child() + while child is not None: + found = search(child) + if found is not None: + return found + child = child.get_next_sibling() + return None + + return search(win) + except (RuntimeError, TypeError): + return None + + +def _wait_for_settle( + editor: DocEditor, quiet_seconds: float = 2.0, timeout: float = 300.0 +) -> bool: + """Block until ``editor.is_processing`` has been False for + *quiet_seconds* consecutive seconds, or *timeout* elapses.""" + deadline = time.monotonic() + timeout + last_busy = None + idle_since = None + while time.monotonic() < deadline: + busy = editor.is_processing + if busy: + idle_since = None + else: + if idle_since is None: + idle_since = time.monotonic() + elif time.monotonic() - idle_since >= quiet_seconds: + return True + if busy != last_busy: + logger.debug( + "settle-wait: is_processing=%s idle_since=%s", + busy, + idle_since, + ) + last_busy = busy + time.sleep(0.2) + logger.warning("settle-wait timed out after %.0fs", timeout) + return False + + +def _switch_to_3d_view(win: MainWindow) -> None: + """Switch the view stack to the 3D page so the GLArea realizes.""" + try: + win.view_stack.set_visible_child_name("3d") + logger.info("snapshot_memory: switched to 3D view") + except (AttributeError, RuntimeError) as e: + logger.warning("snapshot_memory: failed to switch to 3D: %s", e) + + +def _wait_for_gl(win: MainWindow, timeout: float = 30.0) -> None: + """Wait for the 3D canvas GL to initialize and scene to compile.""" + canvas = win.canvas3d + if canvas is None: + logger.warning("snapshot_memory: no canvas3d on win") + return + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if canvas._gl_initialized: + logger.info("snapshot_memory: GL initialized") + return + time.sleep(0.3) + logger.warning( + "snapshot_memory: GL did not initialize within %.0fs", timeout + ) + + +def _wait_for_scene_compiled(win: MainWindow, timeout: float = 120.0) -> None: + """Wait for the ScenePresenter to have a compiled artifact.""" + canvas = win.canvas3d + if canvas is None: + return + presenter = canvas._presenter + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if presenter._compiled_artifact is not None: + logger.info("snapshot_memory: scene compiled") + return + time.sleep(0.5) + logger.warning( + "snapshot_memory: scene did not compile within %.0fs", timeout + ) + + +def run_snapshot(app: AppProtocol, win: MainWindow) -> None: + """Entry point — called from the UI script thread.""" + logger.info("snapshot_memory: waiting for document to settle...") + editor = win.doc_editor + _wait_for_settle(editor, quiet_seconds=3.0, timeout=300.0) + + # Switch to 3D view so the GLArea realizes and the scene compiles. + _switch_to_3d_view(win) + _wait_for_gl(win, timeout=30.0) + _wait_for_scene_compiled(win, timeout=120.0) + # Wait again for any extra work triggered by the scene compilation. + _wait_for_settle(editor, quiet_seconds=3.0, timeout=60.0) + + logger.info("snapshot_memory: gc.collect() before snapshot") + gc.collect() + _malloc_trim() + time.sleep(0.5) + + pipeline = editor.pipeline + store = pipeline.artifact_store + vm = editor.view_manager + doc = editor.doc + presenter = _find_scene_presenter(win) + + global _seen_ids + _seen_ids = set() + _ops_seen.clear() + + owners = [ + _measure_pipeline(pipeline), + _measure_artifact_store(store), + _measure_view_manager(vm), + _measure_source_assets(doc), + ] + if presenter is not None: + owners.append(_measure_scene_presenter(presenter)) + else: + owners.append(OwnerReport(name="ScenePresenter (no 3D canvas)")) + + sweep = _gc_type_sweep() + rss = _read_rss_kb() + + def _force_exit() -> None: + time.sleep(5) + logger.warning("snapshot_memory: force exit after 5s") + os._exit(0) + + threading.Thread(target=_force_exit, daemon=True).start() + + try: + _print_report(owners, sweep, rss) + except Exception: + logger.exception("snapshot_memory: report printing failed") + + logger.info("snapshot_memory: done, quitting app.") + app.quit_idle() + + +# ── UI script entry point ────────────────────────────────────────── +# When run via --uiscript, the globals `app` and `win` are injected +# by rayforge.uiscript._set_context(). +_app: AppProtocol | None = None +_win: MainWindow | None = None +try: + from rayforge import uiscript as _ui + + _app = _ui.app + _win = _ui.win +except Exception: # noqa: BLE001, S110 + pass + +if _app is not None and _win is not None: + t = threading.Thread(target=run_snapshot, args=(_app, _win), daemon=True) + t.start() +else: + # Allow direct invocation for testing + if __name__ == "__main__": + print( + "This script must be run via: rayforge --uiscript